map-gl-offline 0.8.9 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -192
- package/dist/idb-offline-sw.js +78 -11
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +2062 -811
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2069 -809
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +2401 -940
- package/dist/index.umd.js.map +1 -1
- package/dist/main.d.ts +3 -0
- package/dist/managers/offlineMapManager/analyticsManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/base.d.ts +5 -5
- package/dist/managers/offlineMapManager/cleanupManagement.d.ts +6 -1
- package/dist/managers/offlineMapManager/importExportManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/maintenanceManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/regionManagement.d.ts +2 -2
- package/dist/managers/offlineMapManager/resourceManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/styleManagement.d.ts +1 -1
- package/dist/services/analyticsService.d.ts +1 -1
- package/dist/services/cleanupService.d.ts +15 -1
- package/dist/services/fontService.d.ts +1 -1
- package/dist/services/glyphService.d.ts +1 -1
- package/dist/services/importExportService.d.ts +1 -1
- package/dist/services/maintenanceService.d.ts +2 -2
- package/dist/services/modelService.d.ts +3 -1
- package/dist/services/regionService.d.ts +21 -11
- package/dist/services/resourceService.d.ts +2 -2
- package/dist/services/runtimeAssetService.d.ts +12 -0
- package/dist/services/spriteService.d.ts +1 -1
- package/dist/services/styleService.d.ts +14 -1
- package/dist/services/tileService.d.ts +1 -1
- package/dist/sheet/collar.d.ts +34 -0
- package/dist/storage/indexedDbManager.d.ts +1 -1
- package/dist/style.css +2 -2
- package/dist/sw/shared.d.ts +31 -3
- package/dist/types/glyph.d.ts +2 -0
- package/dist/types/import-export.d.ts +5 -0
- package/dist/types/maintenance.d.ts +3 -1
- package/dist/types/model.d.ts +2 -0
- package/dist/types/region.d.ts +29 -2
- package/dist/types/sprite.d.ts +2 -0
- package/dist/types/style.d.ts +4 -0
- package/dist/types/tile.d.ts +15 -0
- package/dist/ui/components/PanelActions.d.ts +1 -1
- package/dist/ui/components/PanelHeader.d.ts +1 -1
- package/dist/ui/components/RegionList.d.ts +1 -1
- package/dist/ui/components/shared/Button.d.ts +6 -0
- package/dist/ui/components/shared/LanguageSelector.d.ts +1 -1
- package/dist/ui/components/shared/MapControlButton.d.ts +1 -1
- package/dist/ui/components/shared/PanelContent.d.ts +3 -3
- package/dist/ui/components/shared/RegionDrawingTool.d.ts +2 -2
- package/dist/ui/controls/polygonControl.d.ts +7 -1
- package/dist/ui/controls/regionControl.d.ts +3 -3
- package/dist/ui/managers/ControlButtonManager.d.ts +1 -1
- package/dist/ui/managers/PanelManager.d.ts +16 -3
- package/dist/ui/managers/downloadManager.d.ts +7 -3
- package/dist/ui/modals/mbtilesModal.d.ts +1 -1
- package/dist/ui/modals/regionDetailsModal.d.ts +1 -1
- package/dist/ui/modals/regionFormModal.d.ts +11 -1
- package/dist/ui/offlineManagerControl.d.ts +4 -1
- package/dist/ui/translations/ar.d.ts +16 -1
- package/dist/ui/translations/en.d.ts +16 -1
- package/dist/ui/translations/index.d.ts +2 -0
- package/dist/utils/constants.d.ts +4 -2
- package/dist/utils/convertStyleForSW.d.ts +1 -1
- package/dist/utils/download.d.ts +1 -1
- package/dist/utils/formatting.d.ts +11 -0
- package/dist/utils/importResolver.d.ts +1 -1
- package/dist/utils/styleConfig.d.ts +39 -0
- package/dist/utils/styleProviderUtils.d.ts +21 -8
- package/dist/utils/styleUtils.d.ts +61 -10
- package/dist/utils/tileRange.d.ts +63 -0
- package/dist/utils/validation.d.ts +1 -1
- package/package.json +44 -39
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openDB } from 'idb';
|
|
1
|
+
import { openDB, unwrap } from 'idb';
|
|
2
2
|
import * as tilebelt from '@mapbox/tilebelt';
|
|
3
3
|
import i18next from 'i18next';
|
|
4
4
|
import { area } from '@turf/area';
|
|
@@ -163,8 +163,10 @@ const MAPBOX_API = {
|
|
|
163
163
|
/**
|
|
164
164
|
* Mapbox Standard sub-tilesets that are sparse-by-design across the planet.
|
|
165
165
|
* These only have tiles where the underlying feature (indoor venues, landmark
|
|
166
|
-
* POIs, 3D procedural buildings) actually exists, and return 404 for
|
|
167
|
-
*
|
|
166
|
+
* POIs, 3D procedural buildings) actually exists, and return 404 for every other
|
|
167
|
+
* coordinate, though they can be dense inside a major city. Unless the style's
|
|
168
|
+
* config has switched on a feature that needs one, the tile downloader pre-skips
|
|
169
|
+
* any source whose tile
|
|
168
170
|
* URL templates reference one of these tileset IDs, so we never issue probe
|
|
169
171
|
* or download requests for them — eliminating the 404 noise in devtools.
|
|
170
172
|
*
|
|
@@ -366,7 +368,10 @@ async function openOfflineMapDB() {
|
|
|
366
368
|
// Migration: v2 -> v3
|
|
367
369
|
// Move regions from 'regions' store to styles.regions[]
|
|
368
370
|
if (oldVersion > 0 && oldVersion < 3) {
|
|
369
|
-
|
|
371
|
+
// Unwrap to the raw IDBTransaction: on idb's wrapper, getAll()/get()
|
|
372
|
+
// return Promises, so the onsuccess callbacks the migration relies
|
|
373
|
+
// on would never fire and no region would be migrated.
|
|
374
|
+
migrateRegionsToStyles(unwrap(transaction));
|
|
370
375
|
}
|
|
371
376
|
// Migration: v3 -> v4
|
|
372
377
|
// Adds the `models` store for Mapbox Standard 3D model assets.
|
|
@@ -1027,6 +1032,20 @@ function formatBytes(bytes) {
|
|
|
1027
1032
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1028
1033
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
1029
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* A stored style's display name: its own `name`, or else the last meaningful
|
|
1037
|
+
* part of its URL ("liberty" for `.../styles/liberty`), or else its key.
|
|
1038
|
+
*/
|
|
1039
|
+
function styleDisplayName(entry) {
|
|
1040
|
+
if (entry.style?.name)
|
|
1041
|
+
return entry.style.name;
|
|
1042
|
+
const path = (entry.originalUrl ?? '').split(/[?#]/)[0];
|
|
1043
|
+
const segments = path
|
|
1044
|
+
.split('/')
|
|
1045
|
+
.map(segment => segment.replace(/\.json$/i, ''))
|
|
1046
|
+
.filter(segment => segment && !/^(style|styles)$/i.test(segment) && !segment.includes(':'));
|
|
1047
|
+
return segments.at(-1) ?? entry.key;
|
|
1048
|
+
}
|
|
1030
1049
|
/**
|
|
1031
1050
|
* Format date to human readable string
|
|
1032
1051
|
*/
|
|
@@ -1254,6 +1273,39 @@ function deriveTileExtension(tiles) {
|
|
|
1254
1273
|
* They have different shapes so cannot be shared; the key computation
|
|
1255
1274
|
* can be and is.
|
|
1256
1275
|
*/
|
|
1276
|
+
/**
|
|
1277
|
+
* `decodeURIComponent` that returns the input unchanged instead of throwing
|
|
1278
|
+
* on a malformed escape (e.g. a literal `%` in a font or source name), so a
|
|
1279
|
+
* bad path becomes a lookup miss / 404 rather than a rejected fetch.
|
|
1280
|
+
*/
|
|
1281
|
+
function safeDecodeURIComponent(value) {
|
|
1282
|
+
try {
|
|
1283
|
+
return decodeURIComponent(value);
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
return value;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
// Mapbox GL runtime assets
|
|
1291
|
+
// ---------------------------------------------------------------------------
|
|
1292
|
+
const MAPBOX_RUNTIME_ORIGIN = 'https://api.mapbox.com';
|
|
1293
|
+
const MAPBOX_RUNTIME_CACHE = 'map-gl-offline-runtime-v1';
|
|
1294
|
+
/**
|
|
1295
|
+
* Code Mapbox GL fetches from api.mapbox.com while rendering, so it has to be
|
|
1296
|
+
* available offline too. Paths are versioned, so a renderer that moves to a new
|
|
1297
|
+
* version misses here; `npm run check:mapbox-standard` compares this list with
|
|
1298
|
+
* the installed renderer. They match Mapbox GL 3.34; an app on a version with
|
|
1299
|
+
* other paths gets no offline copy of those files.
|
|
1300
|
+
*/
|
|
1301
|
+
const MAPBOX_RUNTIME_ASSETS = [
|
|
1302
|
+
'/mapbox-gl-js/draco_decoder_gltf_v1.5.6.wasm',
|
|
1303
|
+
'/mapbox-gl-js/meshopt_base_v1.2.wasm',
|
|
1304
|
+
'/mapbox-gl-js/meshopt_simd_v1.2.wasm',
|
|
1305
|
+
'/mapbox-gl-js/rtl_text_v1.0.0.wasm',
|
|
1306
|
+
'/mapbox-gl-js/building-gen/building_gen_v1.2.6.wasm',
|
|
1307
|
+
'/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
|
|
1308
|
+
];
|
|
1257
1309
|
/**
|
|
1258
1310
|
* Extensions to try in order when the requested extension misses. `glb` is
|
|
1259
1311
|
* last so batched-model sources (Mapbox Standard 3D buildings) resolve when
|
|
@@ -1291,9 +1343,10 @@ function findStyleByRegionIdIn(styles, regionId) {
|
|
|
1291
1343
|
// Glyph candidate keys
|
|
1292
1344
|
// ---------------------------------------------------------------------------
|
|
1293
1345
|
/**
|
|
1294
|
-
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
|
|
1295
|
-
* requests a comma-joined font-family fallback chain
|
|
1296
|
-
*
|
|
1346
|
+
* Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Older Mapbox
|
|
1347
|
+
* GL requests a comma-joined font-family fallback chain (current versions fetch
|
|
1348
|
+
* each font and merge them client-side); each glyph is stored individually, so
|
|
1349
|
+
* the caller tries each fontstack in order.
|
|
1297
1350
|
*/
|
|
1298
1351
|
function parseGlyphPath(decodedPath) {
|
|
1299
1352
|
const pathParts = decodedPath.split('/');
|
|
@@ -1305,6 +1358,47 @@ function parseGlyphPath(decodedPath) {
|
|
|
1305
1358
|
.filter(Boolean);
|
|
1306
1359
|
return { fontstacks, rangePart };
|
|
1307
1360
|
}
|
|
1361
|
+
function readVarint(bytes, start) {
|
|
1362
|
+
let value = 0;
|
|
1363
|
+
let multiplier = 1;
|
|
1364
|
+
for (let pos = start; pos < bytes.length && pos < start + 5; pos++) {
|
|
1365
|
+
value += (bytes[pos] & 0x7f) * multiplier;
|
|
1366
|
+
if (bytes[pos] < 0x80)
|
|
1367
|
+
return { value, next: pos + 1 };
|
|
1368
|
+
multiplier *= 128;
|
|
1369
|
+
}
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* True for a glyph range PBF holding only its font name and range. Servers
|
|
1374
|
+
* return these for blocks a font doesn't cover, e.g. `DIN Pro Medium/1536-1791`.
|
|
1375
|
+
* Serving one in place of a later font in the chain blanks those labels.
|
|
1376
|
+
*
|
|
1377
|
+
* Reads `glyphs { fontstack stacks = 1 { name = 1; range = 2; glyph = 3 } }`.
|
|
1378
|
+
* Anything unexpected counts as not empty, so unknown data is still served.
|
|
1379
|
+
*/
|
|
1380
|
+
function isEmptyGlyphRange(data) {
|
|
1381
|
+
const bytes = new Uint8Array(data);
|
|
1382
|
+
if (bytes[0] !== 0x0a)
|
|
1383
|
+
return false;
|
|
1384
|
+
const stack = readVarint(bytes, 1);
|
|
1385
|
+
if (!stack)
|
|
1386
|
+
return false;
|
|
1387
|
+
const end = stack.next + stack.value;
|
|
1388
|
+
if (end !== bytes.length)
|
|
1389
|
+
return false;
|
|
1390
|
+
let pos = stack.next;
|
|
1391
|
+
while (pos < end) {
|
|
1392
|
+
const tag = bytes[pos];
|
|
1393
|
+
if ((tag & 0x07) !== 2 || tag >> 3 === 3)
|
|
1394
|
+
return false;
|
|
1395
|
+
const length = readVarint(bytes, pos + 1);
|
|
1396
|
+
if (!length)
|
|
1397
|
+
return false;
|
|
1398
|
+
pos = length.next + length.value;
|
|
1399
|
+
}
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1308
1402
|
/**
|
|
1309
1403
|
* Build the list of keys to try for a single (fontstack, range) pair.
|
|
1310
1404
|
* Order: actualStyleId variants first (most common), then downloadId,
|
|
@@ -1475,10 +1569,10 @@ function clearAllCaches() {
|
|
|
1475
1569
|
tileCache.clear();
|
|
1476
1570
|
}
|
|
1477
1571
|
/**
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
* Results are cached to avoid repeated DB queries during tile fetching
|
|
1572
|
+
* Resolve the `{id}` segment of an `idb://{id}/...` URL to its style entry.
|
|
1573
|
+
* Styles patched since URLs became style-keyed use the style key itself (one
|
|
1574
|
+
* keyed read); older stored styles use a region id, which needs a scan of all
|
|
1575
|
+
* styles. Results are cached to avoid repeated DB queries during tile fetching.
|
|
1482
1576
|
*/
|
|
1483
1577
|
async function findStyleByRegionId(db, regionId) {
|
|
1484
1578
|
// Check cache first
|
|
@@ -1487,6 +1581,11 @@ async function findStyleByRegionId(db, regionId) {
|
|
|
1487
1581
|
return cached.styleEntry;
|
|
1488
1582
|
}
|
|
1489
1583
|
try {
|
|
1584
|
+
const direct = (await db.get('styles', regionId));
|
|
1585
|
+
if (direct) {
|
|
1586
|
+
regionToStyleCache.set(regionId, { styleEntry: direct, timestamp: Date.now() });
|
|
1587
|
+
return direct;
|
|
1588
|
+
}
|
|
1490
1589
|
const allStyles = await db.getAll('styles');
|
|
1491
1590
|
const hit = findStyleByRegionIdIn(allStyles, regionId);
|
|
1492
1591
|
if (hit) {
|
|
@@ -1576,9 +1675,11 @@ async function idbFetchHandler(url, init) {
|
|
|
1576
1675
|
}
|
|
1577
1676
|
const db = await dbPromise;
|
|
1578
1677
|
const parsed = url.replace('idb://', '').split('/');
|
|
1579
|
-
|
|
1678
|
+
// A Request's URL arrives percent-encoded, so decode the style id as the Service Worker does.
|
|
1679
|
+
const [encodedDownloadId, type, ...rest] = parsed;
|
|
1680
|
+
const downloadId = safeDecodeURIComponent(encodedDownloadId ?? '');
|
|
1580
1681
|
const resourcePath = rest.join('/');
|
|
1581
|
-
const decodedResourcePath =
|
|
1682
|
+
const decodedResourcePath = safeDecodeURIComponent(resourcePath);
|
|
1582
1683
|
const key = `${downloadId}::${decodedResourcePath}`;
|
|
1583
1684
|
try {
|
|
1584
1685
|
switch (type) {
|
|
@@ -1597,7 +1698,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1597
1698
|
// MapLibre can request fractional zoom levels (e.g., 12.5)
|
|
1598
1699
|
// but tiles are stored with integer zoom levels, so floor the value
|
|
1599
1700
|
const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
|
|
1600
|
-
const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
|
|
1701
|
+
const sourceKey = safeDecodeURIComponent(pathParts.slice(0, pathParts.length - 3).join('/'));
|
|
1601
1702
|
const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
|
|
1602
1703
|
if (yMatch) {
|
|
1603
1704
|
const y = parseInt(yMatch[1]);
|
|
@@ -1635,7 +1736,7 @@ async function idbFetchHandler(url, init) {
|
|
|
1635
1736
|
if (pathParts.length === 1) {
|
|
1636
1737
|
// Old format without sourceKey - try to extract from the URL
|
|
1637
1738
|
const encodedTileUrl = pathParts[0];
|
|
1638
|
-
const tileUrl =
|
|
1739
|
+
const tileUrl = safeDecodeURIComponent(encodedTileUrl);
|
|
1639
1740
|
// Try to extract source from URL pattern
|
|
1640
1741
|
// For example: https://domain.com/service/source/vt/{z}/{x}/{y}.pbf
|
|
1641
1742
|
const urlParts = tileUrl.split('/');
|
|
@@ -1679,21 +1780,30 @@ async function idbFetchHandler(url, init) {
|
|
|
1679
1780
|
const actualStyleId = styleEntry?.key || downloadId;
|
|
1680
1781
|
const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
|
|
1681
1782
|
idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
|
|
1783
|
+
const glyphResponse = (data) => new Response(data, {
|
|
1784
|
+
status: 200,
|
|
1785
|
+
headers: { 'Content-Type': 'application/x-protobuf' },
|
|
1786
|
+
});
|
|
1787
|
+
let emptyRange;
|
|
1682
1788
|
for (const fontstack of fontstacks) {
|
|
1683
1789
|
const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
|
|
1684
1790
|
for (const candidateKey of candidateKeys) {
|
|
1685
1791
|
const resource = await db.get('glyphs', candidateKey);
|
|
1686
1792
|
if (resource?.data) {
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1793
|
+
if (!isEmptyGlyphRange(resource.data)) {
|
|
1794
|
+
idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
|
|
1795
|
+
return glyphResponse(resource.data);
|
|
1796
|
+
}
|
|
1797
|
+
emptyRange ??= resource.data;
|
|
1692
1798
|
}
|
|
1693
1799
|
}
|
|
1694
1800
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1801
|
+
// Never a 404: Mapbox GL fails a tile's whole glyph request if one range
|
|
1802
|
+
// errors, so a range outside the downloaded set would blank all its labels.
|
|
1803
|
+
if (!emptyRange) {
|
|
1804
|
+
idbLogger.warn(`Glyph not found for any font in: ${fontstacks.join(', ')}`);
|
|
1805
|
+
}
|
|
1806
|
+
return glyphResponse(emptyRange ?? new ArrayBuffer(0));
|
|
1697
1807
|
}
|
|
1698
1808
|
case 'sprite': {
|
|
1699
1809
|
idbLogger.debug(`Looking for sprite with key: ${key}`);
|
|
@@ -1785,7 +1895,8 @@ async function idbFetchHandler(url, init) {
|
|
|
1785
1895
|
catch (error) {
|
|
1786
1896
|
idbLogger.error(`Error fetching resource from IDB: ${url}`, error);
|
|
1787
1897
|
}
|
|
1788
|
-
|
|
1898
|
+
// Normal once the map moves outside a saved region, so not worth a warning per tile.
|
|
1899
|
+
idbLogger.debug(`Resource not found in IDB: ${url}`);
|
|
1789
1900
|
return new Response('Not found in IDB', { status: 404 });
|
|
1790
1901
|
}
|
|
1791
1902
|
|
|
@@ -1846,57 +1957,103 @@ function resolveMapboxUrl(mapboxUrl, accessToken) {
|
|
|
1846
1957
|
if (!accessToken) {
|
|
1847
1958
|
throw new Error(`Mapbox access token is required to resolve mapbox:// URL: ${mapboxUrl}`);
|
|
1848
1959
|
}
|
|
1849
|
-
// Strip the protocol prefix
|
|
1850
|
-
|
|
1960
|
+
// Strip the protocol prefix, and split off any query so path matching and
|
|
1961
|
+
// the resolved URL's suffixes (`.json`, `/sprite`) go before it. Existing
|
|
1962
|
+
// parameters are kept; `access_token` is set exactly once.
|
|
1963
|
+
const withoutProtocol = mapboxUrl.slice(MAPBOX_API.PROTOCOL.length);
|
|
1964
|
+
const queryIndex = withoutProtocol.indexOf('?');
|
|
1965
|
+
const path = queryIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, queryIndex);
|
|
1966
|
+
const params = new URLSearchParams(queryIndex === -1 ? '' : withoutProtocol.slice(queryIndex + 1));
|
|
1967
|
+
params.set('access_token', accessToken);
|
|
1968
|
+
const query = `?${params.toString()}`;
|
|
1851
1969
|
// mapbox://styles/{user}/{id}
|
|
1852
1970
|
if (path.startsWith('styles/')) {
|
|
1853
1971
|
const rest = path.slice('styles/'.length);
|
|
1854
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}
|
|
1972
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}${query}`;
|
|
1855
1973
|
}
|
|
1856
1974
|
// mapbox://sprites/{user}/{id}
|
|
1857
1975
|
if (path.startsWith('sprites/')) {
|
|
1858
1976
|
const rest = path.slice('sprites/'.length);
|
|
1859
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite
|
|
1977
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.STYLES_PATH}/${rest}/sprite${query}`;
|
|
1860
1978
|
}
|
|
1861
1979
|
// mapbox://fonts/{user}/{fontstack}/{range}.pbf
|
|
1862
1980
|
if (path.startsWith('fonts/')) {
|
|
1863
1981
|
const rest = path.slice('fonts/'.length);
|
|
1864
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}
|
|
1982
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.FONTS_PATH}/${rest}${query}`;
|
|
1865
1983
|
}
|
|
1866
1984
|
// mapbox://models/{path}
|
|
1867
1985
|
if (path.startsWith('models/')) {
|
|
1868
1986
|
const rest = path.slice('models/'.length);
|
|
1869
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}
|
|
1987
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.MODELS_PATH}/${rest}${query}`;
|
|
1870
1988
|
}
|
|
1871
1989
|
// mapbox://{tileset} (e.g. mapbox://mapbox.mapbox-streets-v8)
|
|
1872
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json
|
|
1990
|
+
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${path}.json${query}`;
|
|
1873
1991
|
}
|
|
1874
1992
|
/**
|
|
1875
|
-
*
|
|
1993
|
+
* Pick the TileJSON variant Mapbox GL renders from, the way its `mergeVariants`
|
|
1994
|
+
* does: `meshopt-v2+lod`, then `meshopt`, then the base tileset.
|
|
1995
|
+
*
|
|
1996
|
+
* These aren't just different encodings of one dataset. For
|
|
1997
|
+
* `mapbox.mapbox-3dbuildings-v1`, the base tile over central Dubai is empty while
|
|
1998
|
+
* the variants hold its 3D buildings, so downloading the base loses them offline.
|
|
1999
|
+
* Variant tiles are `mapbox://3dtiles/...` URLs, resolved here using the base
|
|
2000
|
+
* URL's query string, which carries the access token.
|
|
2001
|
+
*/
|
|
2002
|
+
function selectTileJsonVariant(tileJson) {
|
|
2003
|
+
const { variants, tiles: baseTiles } = tileJson;
|
|
2004
|
+
if (!Array.isArray(variants))
|
|
2005
|
+
return tileJson;
|
|
2006
|
+
const withCapability = (capability) => variants.find(variant => Array.isArray(variant?.capabilities) &&
|
|
2007
|
+
variant.capabilities.length === 1 &&
|
|
2008
|
+
variant.capabilities[0] === capability);
|
|
2009
|
+
const selected = withCapability('meshopt-v2+lod') ?? withCapability('meshopt');
|
|
2010
|
+
if (!selected || !Array.isArray(selected.tiles))
|
|
2011
|
+
return tileJson;
|
|
2012
|
+
const firstBaseTile = Array.isArray(baseTiles) ? String(baseTiles[0] ?? '') : '';
|
|
2013
|
+
const queryIndex = firstBaseTile.indexOf('?');
|
|
2014
|
+
const query = queryIndex === -1 ? '' : firstBaseTile.slice(queryIndex);
|
|
2015
|
+
const tiles = selected.tiles.map(tile => tile.startsWith('mapbox://3dtiles/')
|
|
2016
|
+
? `${MAPBOX_API.BASE_URL}/3dtiles/v1/${tile.slice('mapbox://3dtiles/'.length)}${query}`
|
|
2017
|
+
: tile);
|
|
2018
|
+
return { ...tileJson, ...selected, tiles };
|
|
2019
|
+
}
|
|
2020
|
+
const SKU_LIFETIME_MS = 12 * 60 * 60 * 1000;
|
|
2021
|
+
const SKU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2022
|
+
let skuSession;
|
|
2023
|
+
/** A Mapbox session token in the format Mapbox GL JS generates: version + SKU id + 10 random. */
|
|
2024
|
+
function getSkuToken() {
|
|
2025
|
+
if (!skuSession || Date.now() > skuSession.expiresAt) {
|
|
2026
|
+
let randomizer = '';
|
|
2027
|
+
for (let i = 0; i < 10; i++) {
|
|
2028
|
+
randomizer += SKU_CHARS[Math.floor(Math.random() * SKU_CHARS.length)];
|
|
2029
|
+
}
|
|
2030
|
+
skuSession = { token: `101${randomizer}`, expiresAt: Date.now() + SKU_LIFETIME_MS };
|
|
2031
|
+
}
|
|
2032
|
+
return skuSession.token;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Add the `sku` session token that Mapbox `/raster/v1/` tile URLs need.
|
|
1876
2036
|
*
|
|
1877
|
-
* TileJSON
|
|
1878
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
2037
|
+
* TileJSON returns these URLs without one, and without it they 401. Mapbox GL
|
|
2038
|
+
* adds it at request time, but this library fetches tiles itself.
|
|
2039
|
+
*
|
|
2040
|
+
* Don't switch these to the `/v4/` endpoint, which needs no token: it serves a
|
|
2041
|
+
* different tile. For `mapbox.mapbox-terrain-dem-v1`, `/v4/….png` is a 256px
|
|
2042
|
+
* palette PNG with elevations off by up to ~200 m, while the renderer expects
|
|
2043
|
+
* the 514px RGB tile (`tileSize: 514`) that `/raster/v1/` returns.
|
|
1882
2044
|
*
|
|
1883
2045
|
* Example:
|
|
1884
2046
|
* https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...
|
|
1885
|
-
* -> https://
|
|
2047
|
+
* -> https://a.tiles.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png?access_token=...&sku=101...
|
|
1886
2048
|
*/
|
|
1887
2049
|
function rewriteMapboxCdnTileUrl(tileUrl) {
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
const match = tileUrl.match(/^https?:\/\/[a-z]\.tiles\.mapbox\.com\/raster\/v1\/([^?]+)(\?.*)?$/);
|
|
1893
|
-
if (!match) {
|
|
2050
|
+
if (!/^https?:\/\/(?:[a-z]\.tiles\.mapbox\.com|api\.mapbox\.com)\/raster\/v1\//.test(tileUrl)) {
|
|
2051
|
+
return tileUrl;
|
|
2052
|
+
}
|
|
2053
|
+
if (/[?&]sku=/.test(tileUrl)) {
|
|
1894
2054
|
return tileUrl;
|
|
1895
2055
|
}
|
|
1896
|
-
|
|
1897
|
-
const tilesetAndPath = match[1];
|
|
1898
|
-
const queryString = match[2] || '';
|
|
1899
|
-
return `${MAPBOX_API.BASE_URL}${MAPBOX_API.TILES_PATH}/${tilesetAndPath}${queryString}`;
|
|
2056
|
+
return `${tileUrl}${tileUrl.includes('?') ? '&' : '?'}sku=${getSkuToken()}`;
|
|
1900
2057
|
}
|
|
1901
2058
|
/**
|
|
1902
2059
|
* Detect the style provider based on the style URL or content
|
|
@@ -2122,24 +2279,100 @@ function validateStyleForProvider(style, provider) {
|
|
|
2122
2279
|
|
|
2123
2280
|
const styleLogger = logger.scope('StyleUtils');
|
|
2124
2281
|
/**
|
|
2125
|
-
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references
|
|
2282
|
+
* Patches a MapboxStyle for offline use by replacing URLs with IndexedDB references.
|
|
2283
|
+
*
|
|
2284
|
+
* Safe to call repeatedly on the same stored style (every `addRegion` does):
|
|
2285
|
+
* the first call stashes each source's upstream `tiles` / `maxzoom` under
|
|
2286
|
+
* `__originalTiles` / `__originalMaxzoom`, and later calls patch from those,
|
|
2287
|
+
* so the download pipeline can still reach the network and `maxzoom` can grow
|
|
2288
|
+
* again when a deeper region is added.
|
|
2289
|
+
*
|
|
2126
2290
|
* @param style - The style to patch
|
|
2127
|
-
* @param downloadId -
|
|
2128
|
-
* @param maxZoom -
|
|
2291
|
+
* @param downloadId - Fallback ID for `idb://` URLs when `styleId` is omitted
|
|
2292
|
+
* @param maxZoom - Max zoom downloaded for this style (the highest across its regions)
|
|
2129
2293
|
* @param tileExtension - Optional tile extension
|
|
2130
|
-
* @param styleId -
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2294
|
+
* @param styleId - Style ID used in all `idb://` URLs. Tiles, glyphs, sprites and
|
|
2295
|
+
* models are stored per style, so URLs keyed by style keep working
|
|
2296
|
+
* when any one region is deleted.
|
|
2297
|
+
*/
|
|
2298
|
+
/**
|
|
2299
|
+
* Add a region's extra tile sources to a style, unless one with that id is
|
|
2300
|
+
* already there. Sources must be in the style before it's patched for offline use.
|
|
2301
|
+
*/
|
|
2302
|
+
function injectExtraSources(style, extraSources = []) {
|
|
2303
|
+
for (const extra of extraSources) {
|
|
2304
|
+
if (style.sources[extra.id])
|
|
2305
|
+
continue;
|
|
2306
|
+
style.sources[extra.id] = {
|
|
2307
|
+
type: extra.type || 'vector',
|
|
2308
|
+
tiles: extra.tiles,
|
|
2309
|
+
...(extra.minzoom !== undefined ? { minzoom: extra.minzoom } : {}),
|
|
2310
|
+
...(extra.maxzoom !== undefined ? { maxzoom: extra.maxzoom } : {}),
|
|
2311
|
+
...(extra.attribution ? { attribution: extra.attribution } : {}),
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Keep each model's upstream URL before the style is patched, the first time
|
|
2317
|
+
* only. Patching points every model at `idb://`, so without this a model that
|
|
2318
|
+
* failed to download couldn't be fetched again.
|
|
2319
|
+
*/
|
|
2320
|
+
function stashOriginalModels(entry) {
|
|
2321
|
+
const models = entry.style.models;
|
|
2322
|
+
if (!models || entry.originalModels)
|
|
2323
|
+
return;
|
|
2324
|
+
const isPatched = (value) => String(typeof value === 'string' ? value : value?.uri).startsWith('idb://');
|
|
2325
|
+
// A style patched before this stash existed has no upstream URLs left to keep.
|
|
2326
|
+
if (Object.values(models).some(isPatched))
|
|
2327
|
+
return;
|
|
2328
|
+
entry.originalModels = JSON.parse(JSON.stringify(models));
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Point models that aren't stored back at their upstream URLs. Run after
|
|
2332
|
+
* patching, which points every model at `idb://`.
|
|
2333
|
+
*/
|
|
2334
|
+
function restoreUnstoredModels(entry, storedNames) {
|
|
2335
|
+
const models = entry.style.models;
|
|
2336
|
+
if (!models || !entry.originalModels)
|
|
2337
|
+
return;
|
|
2338
|
+
for (const name of Object.keys(models)) {
|
|
2339
|
+
if (!storedNames.has(name) && entry.originalModels[name] !== undefined) {
|
|
2340
|
+
models[name] = JSON.parse(JSON.stringify(entry.originalModels[name]));
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Whether a style uses parts of the spec only Mapbox GL JS v3 draws: config
|
|
2346
|
+
* (`schema`), 3D `models`, `lights` or `imports`. MapLibre rejects these.
|
|
2133
2347
|
*/
|
|
2348
|
+
function needsMapboxGl(style) {
|
|
2349
|
+
const parts = style;
|
|
2350
|
+
return Boolean(parts.schema || parts.models || parts.lights || parts.imports);
|
|
2351
|
+
}
|
|
2352
|
+
/** Whether a map instance is Mapbox GL JS v3, the only renderer with style config. */
|
|
2353
|
+
function isMapboxGlMap(map) {
|
|
2354
|
+
return typeof map?.setConfigProperty === 'function';
|
|
2355
|
+
}
|
|
2134
2356
|
function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId) {
|
|
2135
2357
|
styleLogger.debug(`Patching style for offline use with downloadId: ${downloadId}, maxZoom: ${maxZoom}, tileExtension: ${tileExtension}`);
|
|
2136
2358
|
styleLogger.debug(`Original style:`, style);
|
|
2359
|
+
const urlId = styleId || downloadId;
|
|
2137
2360
|
// Patch sources
|
|
2138
2361
|
for (const sourceKey in style.sources) {
|
|
2139
2362
|
const source = style.sources[sourceKey];
|
|
2140
2363
|
styleLogger.debug(`Patching source: ${sourceKey}`, source);
|
|
2364
|
+
// Stash upstream values once. Already-patched tiles (all idb://) are never
|
|
2365
|
+
// stashed as "original"; `null` records that the source had no maxzoom.
|
|
2366
|
+
if (source.tiles &&
|
|
2367
|
+
source.__originalTiles === undefined &&
|
|
2368
|
+
!source.tiles.every(url => url.startsWith('idb://'))) {
|
|
2369
|
+
source.__originalTiles = [...source.tiles];
|
|
2370
|
+
}
|
|
2371
|
+
if (source.__originalMaxzoom === undefined) {
|
|
2372
|
+
source.__originalMaxzoom = source.maxzoom ?? null;
|
|
2373
|
+
}
|
|
2141
2374
|
if (source.tiles) {
|
|
2142
|
-
const originalTiles = [...source.tiles];
|
|
2375
|
+
const originalTiles = source.__originalTiles ?? [...source.tiles];
|
|
2143
2376
|
// Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
|
|
2144
2377
|
// Extension extraction goes through the shared extractTileExtensionFromUrl
|
|
2145
2378
|
// helper so the patched URL's extension matches what tileService used when
|
|
@@ -2147,9 +2380,9 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2147
2380
|
// stored key under `.pbf` but a patched URL with `.vector`, forcing
|
|
2148
2381
|
// idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
|
|
2149
2382
|
// on every tile.
|
|
2150
|
-
source.tiles =
|
|
2383
|
+
source.tiles = originalTiles.map((url) => {
|
|
2151
2384
|
const ext = tileExtension ?? extractTileExtensionFromUrl(url);
|
|
2152
|
-
return `idb://${
|
|
2385
|
+
return `idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
|
|
2153
2386
|
});
|
|
2154
2387
|
styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
|
|
2155
2388
|
original: originalTiles,
|
|
@@ -2157,10 +2390,12 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2157
2390
|
});
|
|
2158
2391
|
}
|
|
2159
2392
|
// Cap maxzoom so the map doesn't request tiles beyond what we downloaded.
|
|
2160
|
-
// Use the lower of the
|
|
2161
|
-
// don't raise it above the tileset's actual range (e.g.
|
|
2393
|
+
// Use the lower of the downloaded maxZoom and the source's *upstream*
|
|
2394
|
+
// maxzoom so we don't raise it above the tileset's actual range (e.g.
|
|
2395
|
+
// 3dbuildings at z14) — and so an earlier, shallower region's cap doesn't
|
|
2396
|
+
// stick once a deeper region is added.
|
|
2162
2397
|
if (maxZoom !== undefined) {
|
|
2163
|
-
const originalMaxzoom = source.
|
|
2398
|
+
const originalMaxzoom = source.__originalMaxzoom ?? undefined;
|
|
2164
2399
|
source.maxzoom = originalMaxzoom !== undefined ? Math.min(maxZoom, originalMaxzoom) : maxZoom;
|
|
2165
2400
|
styleLogger.debug(`Set maxzoom for ${sourceKey}: ${originalMaxzoom} → ${source.maxzoom}`);
|
|
2166
2401
|
}
|
|
@@ -2173,7 +2408,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2173
2408
|
let ext = tileExtension;
|
|
2174
2409
|
if (!ext)
|
|
2175
2410
|
ext = 'pbf';
|
|
2176
|
-
source.tiles = [`idb://${
|
|
2411
|
+
source.tiles = [`idb://${urlId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`];
|
|
2177
2412
|
styleLogger.debug(`Added tiles array for TileJSON-only source ${sourceKey}:`, {
|
|
2178
2413
|
tiles: source.tiles,
|
|
2179
2414
|
});
|
|
@@ -2189,7 +2424,7 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2189
2424
|
// Patch glyphs
|
|
2190
2425
|
if (style.glyphs) {
|
|
2191
2426
|
const originalGlyphs = style.glyphs;
|
|
2192
|
-
style.glyphs = `idb://${
|
|
2427
|
+
style.glyphs = `idb://${urlId}/glyph/{fontstack}/{range}.pbf`;
|
|
2193
2428
|
styleLogger.debug(`Patched glyphs:`, { original: originalGlyphs, patched: style.glyphs });
|
|
2194
2429
|
}
|
|
2195
2430
|
// Patch sprite
|
|
@@ -2248,13 +2483,31 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
|
|
|
2248
2483
|
styleLogger.debug(`Final patched style:`, style);
|
|
2249
2484
|
return style;
|
|
2250
2485
|
}
|
|
2486
|
+
/** Resolve one entry of a font stack: a name, `["config", key]` or a `["concat", ...]` of those. */
|
|
2487
|
+
function resolveFontName(value, schema) {
|
|
2488
|
+
if (typeof value === 'string')
|
|
2489
|
+
return value;
|
|
2490
|
+
if (!Array.isArray(value))
|
|
2491
|
+
return undefined;
|
|
2492
|
+
if (value[0] === 'config' && typeof value[1] === 'string') {
|
|
2493
|
+
const configured = schema?.[value[1]]?.default;
|
|
2494
|
+
return typeof configured === 'string' ? configured : undefined;
|
|
2495
|
+
}
|
|
2496
|
+
if (value[0] === 'concat') {
|
|
2497
|
+
const parts = value.slice(1).map(part => resolveFontName(part, schema));
|
|
2498
|
+
return parts.every(part => part !== undefined) ? parts.join('') : undefined;
|
|
2499
|
+
}
|
|
2500
|
+
return undefined;
|
|
2501
|
+
}
|
|
2251
2502
|
/**
|
|
2252
2503
|
* Extract individual font names from a `text-font` layout property value.
|
|
2253
2504
|
*
|
|
2254
|
-
* Handles
|
|
2255
|
-
*
|
|
2505
|
+
* Handles simple arrays (`["Font A", "Font B"]`), Mapbox GL expression syntax
|
|
2506
|
+
* (e.g. `["step", ["zoom"], ["literal", ["Font A"]], 10, ["literal", ["Font B"]]]`),
|
|
2507
|
+
* and Mapbox Standard's config-built stacks
|
|
2508
|
+
* (`[["concat", ["config", "font"], " Medium"], "Arial Unicode MS Bold"]`).
|
|
2256
2509
|
*/
|
|
2257
|
-
function extractFontNamesFromTextField(textFont) {
|
|
2510
|
+
function extractFontNamesFromTextField(textFont, schema) {
|
|
2258
2511
|
const fonts = new Set();
|
|
2259
2512
|
// Known expression operators — anything starting with one of these is NOT a font name list
|
|
2260
2513
|
const expressionOps = new Set([
|
|
@@ -2294,20 +2547,40 @@ function extractFontNamesFromTextField(textFont) {
|
|
|
2294
2547
|
}
|
|
2295
2548
|
return;
|
|
2296
2549
|
}
|
|
2297
|
-
//
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2550
|
+
// A font stack: only font names, and the first isn't an expression operator
|
|
2551
|
+
const isFontEntry = (item) => typeof item === 'string' ||
|
|
2552
|
+
(Array.isArray(item) && (item[0] === 'config' || item[0] === 'concat'));
|
|
2553
|
+
if (value.every(isFontEntry) && !expressionOps.has(value[0])) {
|
|
2554
|
+
for (const item of value) {
|
|
2555
|
+
const name = resolveFontName(item, schema);
|
|
2556
|
+
if (name !== undefined)
|
|
2557
|
+
fonts.add(name);
|
|
2302
2558
|
}
|
|
2303
2559
|
return;
|
|
2304
2560
|
}
|
|
2305
|
-
//
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2561
|
+
// Only the branches that output a font. Conditions and inputs hold strings
|
|
2562
|
+
// like `["zoom"]` or match labels that aren't fonts.
|
|
2563
|
+
const last = value.length - 1;
|
|
2564
|
+
const isOutput = (i) => {
|
|
2565
|
+
switch (value[0]) {
|
|
2566
|
+
case 'step': // ["step", input, out0, stop, out, ...]
|
|
2567
|
+
return i >= 2 && i % 2 === 0;
|
|
2568
|
+
case 'match': // ["match", input, label, out, label, out, ..., fallback]
|
|
2569
|
+
return i === last || (i >= 3 && i % 2 === 1);
|
|
2570
|
+
case 'case': // ["case", cond, out, cond, out, ..., fallback]
|
|
2571
|
+
return i === last || (i >= 2 && i % 2 === 0);
|
|
2572
|
+
case 'coalesce':
|
|
2573
|
+
return i >= 1;
|
|
2574
|
+
case 'let': // ["let", name, value, ..., body]
|
|
2575
|
+
return i === last;
|
|
2576
|
+
default:
|
|
2577
|
+
return false;
|
|
2309
2578
|
}
|
|
2310
|
-
}
|
|
2579
|
+
};
|
|
2580
|
+
value.forEach((item, i) => {
|
|
2581
|
+
if (isOutput(i))
|
|
2582
|
+
walk(item);
|
|
2583
|
+
});
|
|
2311
2584
|
}
|
|
2312
2585
|
walk(textFont);
|
|
2313
2586
|
return Array.from(fonts);
|
|
@@ -2335,7 +2608,7 @@ function extractAllFontNames(style) {
|
|
|
2335
2608
|
if (style && Array.isArray(style.layers)) {
|
|
2336
2609
|
for (const layer of style.layers) {
|
|
2337
2610
|
if (layer.layout && layer.layout['text-font']) {
|
|
2338
|
-
for (const name of extractFontNamesFromTextField(layer.layout['text-font'])) {
|
|
2611
|
+
for (const name of extractFontNamesFromTextField(layer.layout['text-font'], style.schema)) {
|
|
2339
2612
|
fonts.add(name);
|
|
2340
2613
|
}
|
|
2341
2614
|
}
|
|
@@ -2404,8 +2677,13 @@ var styleUtils = /*#__PURE__*/Object.freeze({
|
|
|
2404
2677
|
extractAllFontNames: extractAllFontNames,
|
|
2405
2678
|
extractFontNamesFromTextField: extractFontNamesFromTextField,
|
|
2406
2679
|
generateGlyphUrlsFromStyle: generateGlyphUrlsFromStyle,
|
|
2680
|
+
injectExtraSources: injectExtraSources,
|
|
2681
|
+
isMapboxGlMap: isMapboxGlMap,
|
|
2682
|
+
needsMapboxGl: needsMapboxGl,
|
|
2407
2683
|
normalizeSpriteProperty: normalizeSpriteProperty,
|
|
2408
|
-
patchStyleForOffline: patchStyleForOffline
|
|
2684
|
+
patchStyleForOffline: patchStyleForOffline,
|
|
2685
|
+
restoreUnstoredModels: restoreUnstoredModels,
|
|
2686
|
+
stashOriginalModels: stashOriginalModels
|
|
2409
2687
|
});
|
|
2410
2688
|
|
|
2411
2689
|
const cleanupLogger$1 = logger.scope('CleanupCompressedTiles');
|
|
@@ -2962,7 +3240,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
2962
3240
|
// Collect all flattened sources, layers from imports
|
|
2963
3241
|
const flattenedSources = {};
|
|
2964
3242
|
const flattenedLayers = [];
|
|
2965
|
-
|
|
3243
|
+
const importSprites = [];
|
|
2966
3244
|
let importedGlyphs;
|
|
2967
3245
|
let importedModels;
|
|
2968
3246
|
for (const importEntry of imports) {
|
|
@@ -3014,6 +3292,7 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3014
3292
|
// Build config lookup: schema defaults + import config overrides
|
|
3015
3293
|
const configValues = buildConfigLookup(importedStyle, importEntry.config);
|
|
3016
3294
|
// Flatten layers: prefix layer IDs, update source references, resolve config expressions
|
|
3295
|
+
const importLayers = [];
|
|
3017
3296
|
if (importedStyle.layers && Array.isArray(importedStyle.layers)) {
|
|
3018
3297
|
for (const layer of importedStyle.layers) {
|
|
3019
3298
|
const layerObj = layer;
|
|
@@ -3029,11 +3308,13 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3029
3308
|
// Resolve ["config", "key"] expressions using schema defaults and import overrides.
|
|
3030
3309
|
resolveConfigExpressions(prefixedLayer, configValues);
|
|
3031
3310
|
flattenedLayers.push(prefixedLayer);
|
|
3311
|
+
importLayers.push(prefixedLayer);
|
|
3032
3312
|
}
|
|
3033
3313
|
}
|
|
3034
|
-
// Sprite: collect
|
|
3035
|
-
if
|
|
3036
|
-
|
|
3314
|
+
// Sprite: collect every import's sprite with its layers, so each can be
|
|
3315
|
+
// scoped if the flattened style ends up with more than one sprite.
|
|
3316
|
+
if (importedStyle.sprite) {
|
|
3317
|
+
importSprites.push({ importId, sprite: importedStyle.sprite, layers: importLayers });
|
|
3037
3318
|
}
|
|
3038
3319
|
// Glyphs: collect imported glyphs
|
|
3039
3320
|
if (importedStyle.glyphs && !importedGlyphs) {
|
|
@@ -3050,12 +3331,10 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
|
|
|
3050
3331
|
// Layers: imported layers go first (bottom), outer layers on top
|
|
3051
3332
|
const outerLayers = style.layers || [];
|
|
3052
3333
|
style.layers = [...flattenedLayers, ...outerLayers];
|
|
3053
|
-
// Sprite:
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
else if (!style.sprite && importedSprite) {
|
|
3058
|
-
style.sprite = importedSprite;
|
|
3334
|
+
// Sprite: a single sprite stays as-is; several become an array sprite with
|
|
3335
|
+
// each import's image references scoped to its sprite id.
|
|
3336
|
+
if (importSprites.length > 0) {
|
|
3337
|
+
style.sprite = combineSprites(style.sprite, importSprites);
|
|
3059
3338
|
}
|
|
3060
3339
|
// Glyphs: outer wins; if absent, use imported
|
|
3061
3340
|
if (!style.glyphs && importedGlyphs) {
|
|
@@ -3242,23 +3521,37 @@ function isExpression(value) {
|
|
|
3242
3521
|
return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
|
|
3243
3522
|
}
|
|
3244
3523
|
/**
|
|
3245
|
-
*
|
|
3246
|
-
*
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3524
|
+
* Combine the outer style's sprite with its imports' sprites.
|
|
3525
|
+
*
|
|
3526
|
+
* With one sprite in total it is returned unchanged (a plain string keeps
|
|
3527
|
+
* working in Mapbox GL, which only accepts a string `sprite`). With several,
|
|
3528
|
+
* the result is a MapLibre array sprite: imports first, the outer sprite last
|
|
3529
|
+
* as `default`. Array-sprite images from any entry other than `default` are
|
|
3530
|
+
* only reachable as `{id}:{name}`, so each string-sprite import gets its
|
|
3531
|
+
* import id as sprite id and its layers' image references are prefixed with
|
|
3532
|
+
* `{importId}:`. Imports that already use an array sprite keep their entries
|
|
3533
|
+
* and references unchanged.
|
|
3534
|
+
*/
|
|
3535
|
+
function combineSprites(outer, imports) {
|
|
3536
|
+
if (!outer && imports.length === 1) {
|
|
3537
|
+
return imports[0].sprite;
|
|
3253
3538
|
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3539
|
+
const result = [];
|
|
3540
|
+
for (const { importId, sprite, layers } of imports) {
|
|
3541
|
+
if (typeof sprite === 'string') {
|
|
3542
|
+
result.push({ id: importId, url: sprite });
|
|
3543
|
+
for (const layer of layers) {
|
|
3544
|
+
prefixLayerImages(layer, `${importId}:`);
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
else if (Array.isArray(sprite)) {
|
|
3548
|
+
for (const entry of sprite) {
|
|
3549
|
+
if (entry && typeof entry.url === 'string') {
|
|
3550
|
+
result.push(entry);
|
|
3551
|
+
}
|
|
3258
3552
|
}
|
|
3259
3553
|
}
|
|
3260
3554
|
}
|
|
3261
|
-
// Add outer sprites on top
|
|
3262
3555
|
if (typeof outer === 'string') {
|
|
3263
3556
|
result.push({ id: 'default', url: outer });
|
|
3264
3557
|
}
|
|
@@ -3271,8 +3564,74 @@ function mergeSprites(outer, imported) {
|
|
|
3271
3564
|
}
|
|
3272
3565
|
return result;
|
|
3273
3566
|
}
|
|
3567
|
+
/** Layer properties whose values name sprite images. */
|
|
3568
|
+
const IMAGE_PROPERTIES = [
|
|
3569
|
+
['layout', 'icon-image'],
|
|
3570
|
+
['paint', 'fill-pattern'],
|
|
3571
|
+
['paint', 'line-pattern'],
|
|
3572
|
+
['paint', 'fill-extrusion-pattern'],
|
|
3573
|
+
['paint', 'background-pattern'],
|
|
3574
|
+
];
|
|
3575
|
+
function prefixLayerImages(layer, prefix) {
|
|
3576
|
+
for (const [group, property] of IMAGE_PROPERTIES) {
|
|
3577
|
+
const props = layer[group];
|
|
3578
|
+
if (props && property in props) {
|
|
3579
|
+
props[property] = prefixImageValue(props[property], prefix);
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Prefix the image name(s) a property value resolves to. Handles plain names
|
|
3585
|
+
* (including `{token}` templates), legacy `stops` functions, and expressions:
|
|
3586
|
+
* `image` / `literal` directly, the output branches of `coalesce` / `case` /
|
|
3587
|
+
* `match` / `step`, and anything else string-valued via `concat`.
|
|
3588
|
+
*/
|
|
3589
|
+
function prefixImageValue(value, prefix) {
|
|
3590
|
+
if (typeof value === 'string') {
|
|
3591
|
+
return value === '' ? value : `${prefix}${value}`;
|
|
3592
|
+
}
|
|
3593
|
+
if (Array.isArray(value)) {
|
|
3594
|
+
if (value.length === 0 || typeof value[0] !== 'string')
|
|
3595
|
+
return value;
|
|
3596
|
+
const [op, ...args] = value;
|
|
3597
|
+
const mapAt = (isOutput) => value.map((arg, i) => (i > 0 && isOutput(i) ? prefixImageValue(arg, prefix) : arg));
|
|
3598
|
+
const lastIndex = value.length - 1;
|
|
3599
|
+
switch (op) {
|
|
3600
|
+
case 'image':
|
|
3601
|
+
return ['image', prefixImageValue(args[0], prefix), ...args.slice(1)];
|
|
3602
|
+
case 'literal':
|
|
3603
|
+
return typeof args[0] === 'string' ? ['literal', `${prefix}${args[0]}`] : value;
|
|
3604
|
+
case 'coalesce':
|
|
3605
|
+
return mapAt(() => true);
|
|
3606
|
+
case 'case':
|
|
3607
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3608
|
+
return mapAt(i => i % 2 === 0 || i === lastIndex);
|
|
3609
|
+
case 'match':
|
|
3610
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3611
|
+
return mapAt(i => (i >= 3 && i % 2 === 1) || i === lastIndex);
|
|
3612
|
+
case 'step':
|
|
3613
|
+
// ["step", input, out0, stop, out, ...]
|
|
3614
|
+
return mapAt(i => i >= 2 && i % 2 === 0);
|
|
3615
|
+
default:
|
|
3616
|
+
return ['concat', prefix, value];
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
if (value && typeof value === 'object') {
|
|
3620
|
+
const fn = value;
|
|
3621
|
+
if (Array.isArray(fn.stops)) {
|
|
3622
|
+
return {
|
|
3623
|
+
...fn,
|
|
3624
|
+
stops: fn.stops.map(stop => Array.isArray(stop) ? [stop[0], prefixImageValue(stop[1], prefix)] : stop),
|
|
3625
|
+
...('default' in fn
|
|
3626
|
+
? { default: prefixImageValue(fn.default, prefix) }
|
|
3627
|
+
: {}),
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
return value;
|
|
3632
|
+
}
|
|
3274
3633
|
|
|
3275
|
-
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.
|
|
3634
|
+
const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.15.2/dist/';
|
|
3276
3635
|
let currentConfig = {};
|
|
3277
3636
|
let sqlJsPromise = null;
|
|
3278
3637
|
/**
|
|
@@ -3333,100 +3692,590 @@ async function getSqlJs() {
|
|
|
3333
3692
|
return sqlJsPromise;
|
|
3334
3693
|
}
|
|
3335
3694
|
|
|
3336
|
-
const
|
|
3337
|
-
|
|
3695
|
+
const modelLogger = logger.scope('ModelService');
|
|
3696
|
+
/**
|
|
3697
|
+
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
3698
|
+
* conventions: `{styleId}::model::{modelName}`.
|
|
3699
|
+
*/
|
|
3700
|
+
function modelKey(styleId, modelName) {
|
|
3701
|
+
return `${styleId}::model::${modelName}`;
|
|
3702
|
+
}
|
|
3703
|
+
/** Names of the models stored for a style. */
|
|
3704
|
+
async function storedModelNames(styleId) {
|
|
3705
|
+
const db = await dbPromise;
|
|
3706
|
+
const prefix = modelKey(styleId, '');
|
|
3707
|
+
const keys = await db.getAllKeys('models', IDBKeyRange.bound(prefix, `${prefix}`));
|
|
3708
|
+
return new Set(keys.map(key => String(key).slice(prefix.length)));
|
|
3709
|
+
}
|
|
3710
|
+
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
3711
|
+
function modelKeyBelongsToStyle(key, styleId) {
|
|
3712
|
+
return key.startsWith(`${styleId}::model::`);
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
3716
|
+
*
|
|
3717
|
+
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
3718
|
+
*
|
|
3719
|
+
* ```json
|
|
3720
|
+
* {
|
|
3721
|
+
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
3722
|
+
* ...
|
|
3723
|
+
* }
|
|
3724
|
+
* ```
|
|
3725
|
+
*
|
|
3726
|
+
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
3727
|
+
* render time. For offline use each referenced URL is fetched and stored
|
|
3728
|
+
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
3729
|
+
* `idb://{styleId}/model/{name}` URLs.
|
|
3730
|
+
*/
|
|
3731
|
+
class ModelService {
|
|
3338
3732
|
db = dbPromise;
|
|
3339
|
-
|
|
3733
|
+
/**
|
|
3734
|
+
* Download the set of models referenced by `style.models` for one style.
|
|
3735
|
+
*
|
|
3736
|
+
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
3737
|
+
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
3738
|
+
* @param styleId The owning style's key.
|
|
3739
|
+
*/
|
|
3740
|
+
async downloadModels(models, styleId, options = {}) {
|
|
3340
3741
|
const db = await this.db;
|
|
3341
|
-
const { batchSize =
|
|
3342
|
-
const
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3742
|
+
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, signal, } = options;
|
|
3743
|
+
const entries = Object.entries(models);
|
|
3744
|
+
const progressTracker = createProgressTracker(entries.length);
|
|
3745
|
+
const result = {
|
|
3746
|
+
totalModels: entries.length,
|
|
3747
|
+
downloadedModels: 0,
|
|
3748
|
+
skippedModels: 0,
|
|
3749
|
+
failedModels: 0,
|
|
3750
|
+
totalSize: 0,
|
|
3751
|
+
errors: [],
|
|
3752
|
+
};
|
|
3753
|
+
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
3754
|
+
emit();
|
|
3755
|
+
if (entries.length === 0)
|
|
3756
|
+
return result;
|
|
3757
|
+
// Pre-compute existing keys for skipExisting
|
|
3758
|
+
const existingKeys = new Set();
|
|
3353
3759
|
if (skipExisting) {
|
|
3354
|
-
const
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3357
|
-
while (cursor) {
|
|
3358
|
-
existingFonts.add(cursor.value.key);
|
|
3359
|
-
cursor = await cursor.continue();
|
|
3760
|
+
const tx = db.transaction('models', 'readonly');
|
|
3761
|
+
for await (const cursor of tx.store) {
|
|
3762
|
+
existingKeys.add(cursor.value.key);
|
|
3360
3763
|
}
|
|
3361
|
-
urlsToDownload = fontUrls.filter(url => {
|
|
3362
|
-
const key = this.createFontKey(url, styleName);
|
|
3363
|
-
return !existingFonts.has(key);
|
|
3364
|
-
});
|
|
3365
|
-
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
3366
3764
|
}
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
const
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3765
|
+
await processBatch(entries, async ([modelName, url]) => {
|
|
3766
|
+
// Cancelled: let the remaining models go by without fetching them.
|
|
3767
|
+
if (signal?.aborted)
|
|
3768
|
+
return;
|
|
3769
|
+
const key = modelKey(styleId, modelName);
|
|
3770
|
+
const label = `${styleId}::${modelName}`;
|
|
3771
|
+
if (skipExisting && existingKeys.has(key)) {
|
|
3772
|
+
result.skippedModels++;
|
|
3773
|
+
progressTracker.update(1, label);
|
|
3774
|
+
emit();
|
|
3775
|
+
return;
|
|
3375
3776
|
}
|
|
3376
|
-
}
|
|
3377
|
-
// Process fonts in batches with concurrency control
|
|
3378
|
-
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
3379
3777
|
try {
|
|
3380
|
-
const
|
|
3381
|
-
const response = await fetchResourceWithRetry(fontUrl, {
|
|
3778
|
+
const response = await fetchResourceWithRetry(url, {
|
|
3382
3779
|
retries: maxRetries,
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
proxyType: 'fonts',
|
|
3780
|
+
timeout: timeoutMs,
|
|
3781
|
+
proxyType: 'tiles',
|
|
3386
3782
|
});
|
|
3387
3783
|
if (response.type === 'json') {
|
|
3388
|
-
throw new Error(
|
|
3389
|
-
}
|
|
3390
|
-
const fontData = response.data;
|
|
3391
|
-
const contentType = response.contentType ||
|
|
3392
|
-
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
3393
|
-
// Validate font if enabled
|
|
3394
|
-
if (validateFonts) {
|
|
3395
|
-
await this.validateFont(fontData, contentType);
|
|
3784
|
+
throw new Error('Unexpected JSON response for model');
|
|
3396
3785
|
}
|
|
3397
|
-
|
|
3398
|
-
const
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
data: fontData,
|
|
3786
|
+
const data = response.data;
|
|
3787
|
+
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
3788
|
+
const entry = {
|
|
3789
|
+
key,
|
|
3790
|
+
data,
|
|
3403
3791
|
contentType,
|
|
3404
|
-
|
|
3405
|
-
|
|
3792
|
+
size: data.byteLength,
|
|
3793
|
+
url,
|
|
3794
|
+
styleId,
|
|
3795
|
+
modelName,
|
|
3406
3796
|
lastModified: Date.now(),
|
|
3407
3797
|
downloadedAt: new Date().toISOString(),
|
|
3408
|
-
downloadId: styleName,
|
|
3409
|
-
metadata: {
|
|
3410
|
-
userAgent: navigator.userAgent,
|
|
3411
|
-
downloadTimestamp: Date.now(),
|
|
3412
|
-
},
|
|
3413
3798
|
expires: response.expires,
|
|
3414
3799
|
};
|
|
3415
|
-
await db.put('
|
|
3416
|
-
|
|
3417
|
-
totalSize +=
|
|
3418
|
-
|
|
3419
|
-
// Track font types
|
|
3420
|
-
const fontType = this.detectFontType(contentType, fontUrl);
|
|
3421
|
-
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
3800
|
+
await db.put('models', entry);
|
|
3801
|
+
result.downloadedModels++;
|
|
3802
|
+
result.totalSize += data.byteLength;
|
|
3803
|
+
progressTracker.update(1, label);
|
|
3422
3804
|
}
|
|
3423
|
-
catch (
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
errors.push({
|
|
3427
|
-
|
|
3428
|
-
|
|
3805
|
+
catch (err) {
|
|
3806
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3807
|
+
result.failedModels++;
|
|
3808
|
+
result.errors.push({ url, error: message });
|
|
3809
|
+
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
3810
|
+
progressTracker.update(1, label, message);
|
|
3811
|
+
}
|
|
3812
|
+
emit();
|
|
3813
|
+
}, { batchSize });
|
|
3814
|
+
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
3815
|
+
return result;
|
|
3816
|
+
}
|
|
3817
|
+
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
3818
|
+
async getModel(styleId, modelName) {
|
|
3819
|
+
const db = await this.db;
|
|
3820
|
+
return db.get('models', modelKey(styleId, modelName));
|
|
3821
|
+
}
|
|
3822
|
+
/** Aggregate stats across all stored models. */
|
|
3823
|
+
async getModelStats() {
|
|
3824
|
+
const db = await this.db;
|
|
3825
|
+
const stats = {
|
|
3826
|
+
count: 0,
|
|
3827
|
+
totalSize: 0,
|
|
3828
|
+
averageSize: 0,
|
|
3829
|
+
models: [],
|
|
3830
|
+
modelsByStyle: {},
|
|
3831
|
+
};
|
|
3832
|
+
const tx = db.transaction('models', 'readonly');
|
|
3833
|
+
for await (const cursor of tx.store) {
|
|
3834
|
+
const m = cursor.value;
|
|
3835
|
+
stats.count++;
|
|
3836
|
+
stats.totalSize += m.size;
|
|
3837
|
+
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
3838
|
+
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
3839
|
+
}
|
|
3840
|
+
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
3841
|
+
return stats;
|
|
3842
|
+
}
|
|
3843
|
+
/**
|
|
3844
|
+
* Delete models older than `maxAge` days. Defaults to 30.
|
|
3845
|
+
*/
|
|
3846
|
+
async cleanupOldModels(maxAge = 30) {
|
|
3847
|
+
const db = await this.db;
|
|
3848
|
+
const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
3849
|
+
let deleted = 0;
|
|
3850
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3851
|
+
for await (const cursor of tx.store) {
|
|
3852
|
+
if (cursor.value.lastModified < cutoff) {
|
|
3853
|
+
await cursor.delete();
|
|
3854
|
+
deleted++;
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
return deleted;
|
|
3858
|
+
}
|
|
3859
|
+
/**
|
|
3860
|
+
* Basic integrity check: remove entries with empty/missing data.
|
|
3861
|
+
*/
|
|
3862
|
+
async verifyAndRepairModels() {
|
|
3863
|
+
const db = await this.db;
|
|
3864
|
+
let verified = 0;
|
|
3865
|
+
let removed = 0;
|
|
3866
|
+
const tx = db.transaction('models', 'readwrite');
|
|
3867
|
+
for await (const cursor of tx.store) {
|
|
3868
|
+
const m = cursor.value;
|
|
3869
|
+
if (!m.data || m.data.byteLength === 0) {
|
|
3870
|
+
await cursor.delete();
|
|
3871
|
+
removed++;
|
|
3872
|
+
}
|
|
3873
|
+
else {
|
|
3874
|
+
verified++;
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
return { verified, repaired: 0, removed };
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
// Singleton + convenience exports, matching other service modules.
|
|
3881
|
+
const modelService = new ModelService();
|
|
3882
|
+
const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
|
|
3883
|
+
const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
|
|
3884
|
+
const getModelStats = () => modelService.getModelStats();
|
|
3885
|
+
const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
|
|
3886
|
+
const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
|
|
3887
|
+
|
|
3888
|
+
var modelService$1 = /*#__PURE__*/Object.freeze({
|
|
3889
|
+
__proto__: null,
|
|
3890
|
+
ModelService: ModelService,
|
|
3891
|
+
cleanupOldModels: cleanupOldModels,
|
|
3892
|
+
downloadModels: downloadModels,
|
|
3893
|
+
getModel: getModel,
|
|
3894
|
+
getModelStats: getModelStats,
|
|
3895
|
+
modelKeyBelongsToStyle: modelKeyBelongsToStyle,
|
|
3896
|
+
modelService: modelService,
|
|
3897
|
+
storedModelNames: storedModelNames,
|
|
3898
|
+
verifyAndRepairModels: verifyAndRepairModels
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
/**
|
|
3902
|
+
* Mapbox GL v3 style-config helpers.
|
|
3903
|
+
*
|
|
3904
|
+
* Mapbox Standard is one style with switchable features: most of its layers set
|
|
3905
|
+
* `layout.visibility` from `["config", key]` expressions backed by `style.schema`
|
|
3906
|
+
* defaults. These helpers work out which sources the style actually needs for
|
|
3907
|
+
* the current config, so features that are switched off aren't downloaded and
|
|
3908
|
+
* features that are switched on aren't skipped.
|
|
3909
|
+
*/
|
|
3910
|
+
const configLogger = logger.scope('StyleConfig');
|
|
3911
|
+
/** Sentinel for an expression that can't be decided from config alone. */
|
|
3912
|
+
const UNKNOWN = Symbol('unknown');
|
|
3913
|
+
/**
|
|
3914
|
+
* True for styles whose feature set is config-driven: they declare a `schema`,
|
|
3915
|
+
* or were flattened from `imports` (whose config is already resolved into the
|
|
3916
|
+
* layers). Other styles are never filtered by config.
|
|
3917
|
+
*/
|
|
3918
|
+
function isConfigDrivenStyle(style) {
|
|
3919
|
+
const s = style;
|
|
3920
|
+
if (!s)
|
|
3921
|
+
return false;
|
|
3922
|
+
return ((s.schema !== undefined && Object.keys(s.schema).length > 0) ||
|
|
3923
|
+
(Array.isArray(s.imports) && s.imports.length > 0));
|
|
3924
|
+
}
|
|
3925
|
+
function evaluate(expr, config) {
|
|
3926
|
+
if (!Array.isArray(expr))
|
|
3927
|
+
return expr;
|
|
3928
|
+
const [op, ...args] = expr;
|
|
3929
|
+
switch (op) {
|
|
3930
|
+
case 'literal':
|
|
3931
|
+
return args[0];
|
|
3932
|
+
case 'config': {
|
|
3933
|
+
const key = args[0];
|
|
3934
|
+
return typeof key === 'string' && key in config ? config[key] : UNKNOWN;
|
|
3935
|
+
}
|
|
3936
|
+
case '!': {
|
|
3937
|
+
const value = evaluate(args[0], config);
|
|
3938
|
+
return typeof value === 'boolean' ? !value : UNKNOWN;
|
|
3939
|
+
}
|
|
3940
|
+
case 'all':
|
|
3941
|
+
case 'any': {
|
|
3942
|
+
const decisive = op === 'any';
|
|
3943
|
+
let sawUnknown = false;
|
|
3944
|
+
for (const arg of args) {
|
|
3945
|
+
const value = evaluate(arg, config);
|
|
3946
|
+
if (value === decisive)
|
|
3947
|
+
return decisive;
|
|
3948
|
+
if (typeof value !== 'boolean')
|
|
3949
|
+
sawUnknown = true;
|
|
3950
|
+
}
|
|
3951
|
+
return sawUnknown ? UNKNOWN : !decisive;
|
|
3952
|
+
}
|
|
3953
|
+
case '==':
|
|
3954
|
+
case '!=': {
|
|
3955
|
+
const a = evaluate(args[0], config);
|
|
3956
|
+
const b = evaluate(args[1], config);
|
|
3957
|
+
if (a === UNKNOWN || b === UNKNOWN)
|
|
3958
|
+
return UNKNOWN;
|
|
3959
|
+
return op === '==' ? a === b : a !== b;
|
|
3960
|
+
}
|
|
3961
|
+
case 'case': {
|
|
3962
|
+
// ["case", cond, out, cond, out, ..., fallback]
|
|
3963
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
3964
|
+
const condition = evaluate(args[i], config);
|
|
3965
|
+
if (typeof condition !== 'boolean')
|
|
3966
|
+
return UNKNOWN;
|
|
3967
|
+
if (condition)
|
|
3968
|
+
return evaluate(args[i + 1], config);
|
|
3969
|
+
}
|
|
3970
|
+
return evaluate(args[args.length - 1], config);
|
|
3971
|
+
}
|
|
3972
|
+
case 'match': {
|
|
3973
|
+
// ["match", input, label, out, label, out, ..., fallback]
|
|
3974
|
+
const input = evaluate(args[0], config);
|
|
3975
|
+
if (input === UNKNOWN)
|
|
3976
|
+
return UNKNOWN;
|
|
3977
|
+
for (let i = 1; i < args.length - 1; i += 2) {
|
|
3978
|
+
const label = args[i];
|
|
3979
|
+
if (Array.isArray(label) ? label.includes(input) : label === input) {
|
|
3980
|
+
return evaluate(args[i + 1], config);
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
return evaluate(args[args.length - 1], config);
|
|
3984
|
+
}
|
|
3985
|
+
default:
|
|
3986
|
+
return UNKNOWN;
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
function collectIconsetIds(value, ids) {
|
|
3990
|
+
if (Array.isArray(value)) {
|
|
3991
|
+
for (const item of value)
|
|
3992
|
+
collectIconsetIds(item, ids);
|
|
3993
|
+
}
|
|
3994
|
+
else if (value && typeof value === 'object') {
|
|
3995
|
+
const record = value;
|
|
3996
|
+
const iconset = record.iconset;
|
|
3997
|
+
if (iconset && typeof iconset.id === 'string')
|
|
3998
|
+
ids.add(iconset.id);
|
|
3999
|
+
for (const item of Object.values(record))
|
|
4000
|
+
collectIconsetIds(item, ids);
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
/**
|
|
4004
|
+
* Sources a config-driven style uses under its current config (`active`), and
|
|
4005
|
+
* those it references but only from hidden layers or unused iconsets (`inactive`).
|
|
4006
|
+
*
|
|
4007
|
+
* Deliberately errs towards keeping sources: a visibility expression that can't
|
|
4008
|
+
* be decided counts as visible. Sources nothing in the style references
|
|
4009
|
+
* (`extraSources`, ones a caller adds at runtime) are in neither set.
|
|
4010
|
+
*/
|
|
4011
|
+
function classifySources(style) {
|
|
4012
|
+
const inactive = new Set();
|
|
4013
|
+
const active = new Set();
|
|
4014
|
+
if (!isConfigDrivenStyle(style))
|
|
4015
|
+
return { active, inactive };
|
|
4016
|
+
const s = style;
|
|
4017
|
+
const config = {};
|
|
4018
|
+
for (const [key, entry] of Object.entries(s.schema ?? {})) {
|
|
4019
|
+
if (entry && typeof entry === 'object' && 'default' in entry)
|
|
4020
|
+
config[key] = entry.default;
|
|
4021
|
+
}
|
|
4022
|
+
const activeIconsets = new Set();
|
|
4023
|
+
for (const layer of (s.layers ?? [])) {
|
|
4024
|
+
const visible = evaluate(layer?.layout?.visibility ?? 'visible', config) !== 'none';
|
|
4025
|
+
if (typeof layer?.source === 'string') {
|
|
4026
|
+
inactive.add(layer.source);
|
|
4027
|
+
if (visible)
|
|
4028
|
+
active.add(layer.source);
|
|
4029
|
+
}
|
|
4030
|
+
if (visible) {
|
|
4031
|
+
// The whole layer, since `appearances` can also name an iconset.
|
|
4032
|
+
collectIconsetIds(layer, activeIconsets);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
for (const [id, iconset] of Object.entries(s.iconsets ?? {})) {
|
|
4036
|
+
if (typeof iconset?.source !== 'string')
|
|
4037
|
+
continue;
|
|
4038
|
+
inactive.add(iconset.source);
|
|
4039
|
+
if (activeIconsets.has(id))
|
|
4040
|
+
active.add(iconset.source);
|
|
4041
|
+
}
|
|
4042
|
+
if (typeof s.terrain?.source === 'string')
|
|
4043
|
+
active.add(s.terrain.source);
|
|
4044
|
+
for (const id of active)
|
|
4045
|
+
inactive.delete(id);
|
|
4046
|
+
return { active, inactive };
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* Write config values into a style's `schema` defaults. `["config", key]` reads
|
|
4050
|
+
* those defaults at render time, so this one edit drives both what gets
|
|
4051
|
+
* downloaded and what the stored style shows offline. Keys the schema doesn't
|
|
4052
|
+
* declare are ignored with a warning.
|
|
4053
|
+
*/
|
|
4054
|
+
function applyStyleConfig(style, config) {
|
|
4055
|
+
const schema = style.schema;
|
|
4056
|
+
for (const [key, value] of Object.entries(config)) {
|
|
4057
|
+
const entry = schema?.[key];
|
|
4058
|
+
if (entry && typeof entry === 'object') {
|
|
4059
|
+
entry.default = value;
|
|
4060
|
+
}
|
|
4061
|
+
else {
|
|
4062
|
+
configLogger.warn(`Ignoring styleConfig "${key}": the style's schema doesn't declare it`);
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
/**
|
|
4068
|
+
* XYZ tile range covering `bounds` at zoom `z`. Shared by the download
|
|
4069
|
+
* pipeline (which fetches exactly this range) and region-scoped consumers
|
|
4070
|
+
* like MBTiles export, so both agree on which tiles belong to a region.
|
|
4071
|
+
*/
|
|
4072
|
+
function getTileRangeAtZoom(bounds, z) {
|
|
4073
|
+
const minTile = pointToTileClamped(bounds[0][0], bounds[0][1], z);
|
|
4074
|
+
const maxTile = pointToTileClamped(bounds[1][0], bounds[1][1], z);
|
|
4075
|
+
return {
|
|
4076
|
+
minX: Math.min(minTile[0], maxTile[0]),
|
|
4077
|
+
maxX: Math.max(minTile[0], maxTile[0]),
|
|
4078
|
+
minY: Math.min(minTile[1], maxTile[1]),
|
|
4079
|
+
maxY: Math.max(minTile[1], maxTile[1]),
|
|
4080
|
+
};
|
|
4081
|
+
}
|
|
4082
|
+
/** Web Mercator's latitude limit; tiles don't exist beyond it. */
|
|
4083
|
+
const MAX_MERCATOR_LAT = 85.0511287798066;
|
|
4084
|
+
/**
|
|
4085
|
+
* `tilebelt.pointToTile`, kept on the tile grid. tilebelt wraps longitude 180
|
|
4086
|
+
* to x = 0, so a region whose east edge is the antimeridian became the range
|
|
4087
|
+
* 0..maxX — nearly the whole world at every zoom. Latitudes beyond the
|
|
4088
|
+
* Mercator limit likewise fall off the grid.
|
|
4089
|
+
*/
|
|
4090
|
+
function pointToTileClamped(lon, lat, z) {
|
|
4091
|
+
const maxIndex = 2 ** z - 1;
|
|
4092
|
+
const clampedLon = Math.max(-180, Math.min(180, lon));
|
|
4093
|
+
const clampedLat = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat));
|
|
4094
|
+
const [x, y] = tilebelt.pointToTile(clampedLon, clampedLat, z);
|
|
4095
|
+
return [
|
|
4096
|
+
clampedLon === 180 ? maxIndex : Math.max(0, Math.min(maxIndex, x)),
|
|
4097
|
+
Math.max(0, Math.min(maxIndex, y)),
|
|
4098
|
+
];
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* Whether tile `z/x/y` falls inside `bounds` and `[minZoom, maxZoom]`.
|
|
4102
|
+
*/
|
|
4103
|
+
function isTileInRegion(z, x, y, region) {
|
|
4104
|
+
if (z < region.minZoom || z > region.maxZoom)
|
|
4105
|
+
return false;
|
|
4106
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
4107
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
4108
|
+
}
|
|
4109
|
+
/**
|
|
4110
|
+
* Zoom levels to download for one source in `region`: the overlap of the
|
|
4111
|
+
* region's and the source's zoom ranges. When they don't overlap, the source
|
|
4112
|
+
* still gets the zooms the map needs from it:
|
|
4113
|
+
* - source ends below the region (`maxzoom` < region min) → its `maxzoom`,
|
|
4114
|
+
* which the renderer overzooms for the region's zooms;
|
|
4115
|
+
* - source starts above the region (`minzoom` > region max) → `minzoom`
|
|
4116
|
+
* through `maxzoom` (or just `minzoom` if unbounded).
|
|
4117
|
+
*/
|
|
4118
|
+
function sourceZoomRange(region, sourceMinZoom, sourceMaxZoom) {
|
|
4119
|
+
const srcMin = sourceMinZoom !== undefined ? Math.ceil(sourceMinZoom) : undefined;
|
|
4120
|
+
const srcMax = sourceMaxZoom !== undefined ? Math.floor(sourceMaxZoom) : undefined;
|
|
4121
|
+
if (srcMax !== undefined && srcMax < region.minZoom) {
|
|
4122
|
+
return { min: srcMax, max: srcMax };
|
|
4123
|
+
}
|
|
4124
|
+
if (srcMin !== undefined && srcMin > region.maxZoom) {
|
|
4125
|
+
return { min: srcMin, max: srcMax ?? srcMin };
|
|
4126
|
+
}
|
|
4127
|
+
return {
|
|
4128
|
+
min: Math.max(region.minZoom, srcMin ?? region.minZoom),
|
|
4129
|
+
max: Math.min(region.maxZoom, srcMax ?? region.maxZoom),
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
/**
|
|
4133
|
+
* A stored source's upstream `maxzoom`. Patched styles cap `maxzoom` for
|
|
4134
|
+
* rendering and stash the upstream value under `__originalMaxzoom`.
|
|
4135
|
+
*/
|
|
4136
|
+
function upstreamMaxzoom(source) {
|
|
4137
|
+
if (source?.__originalMaxzoom !== undefined)
|
|
4138
|
+
return source.__originalMaxzoom ?? undefined;
|
|
4139
|
+
return source?.maxzoom;
|
|
4140
|
+
}
|
|
4141
|
+
function hasValidBounds(region) {
|
|
4142
|
+
const b = region.bounds;
|
|
4143
|
+
return (Array.isArray(b) &&
|
|
4144
|
+
b.length === 2 &&
|
|
4145
|
+
b.every(corner => Array.isArray(corner) && corner.length === 2));
|
|
4146
|
+
}
|
|
4147
|
+
/**
|
|
4148
|
+
* Whether a stored tile was downloaded for `region`: inside its bounds, at a
|
|
4149
|
+
* zoom the download pipeline fetches for the tile's source (which can fall
|
|
4150
|
+
* outside the region's own range — see `sourceZoomRange`). `sources` is the
|
|
4151
|
+
* stored style's `sources`. Regions with malformed bounds match nothing.
|
|
4152
|
+
*/
|
|
4153
|
+
function tileBelongsToRegion(tile, region, sources) {
|
|
4154
|
+
if (!hasValidBounds(region))
|
|
4155
|
+
return false;
|
|
4156
|
+
const source = tile.sourceId !== undefined ? sources[tile.sourceId] : undefined;
|
|
4157
|
+
const { min, max } = sourceZoomRange(region, source?.minzoom, upstreamMaxzoom(source));
|
|
4158
|
+
return isTileInRegion(tile.z, tile.x, tile.y, {
|
|
4159
|
+
bounds: region.bounds,
|
|
4160
|
+
minZoom: min,
|
|
4161
|
+
maxZoom: max,
|
|
4162
|
+
});
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
const fontLogger = logger.scope('FontService');
|
|
4166
|
+
class FontService {
|
|
4167
|
+
db = dbPromise;
|
|
4168
|
+
async downloadFonts(fontUrls, styleName, options = {}) {
|
|
4169
|
+
const db = await this.db;
|
|
4170
|
+
const { batchSize = 10, maxRetries = 3, skipExisting = true, retryDelay = 1000, timeout = 30000, validateFonts = true, storageQuotaCheck = true, quietMode = false, onProgress, } = options;
|
|
4171
|
+
const startTime = Date.now();
|
|
4172
|
+
let totalSize = 0;
|
|
4173
|
+
let downloadedFonts = 0;
|
|
4174
|
+
let skippedFonts = 0;
|
|
4175
|
+
let failedFonts = 0;
|
|
4176
|
+
const errors = [];
|
|
4177
|
+
const fontsByType = {};
|
|
4178
|
+
// Create progress tracker
|
|
4179
|
+
const progressTracker = createProgressTracker(fontUrls.length);
|
|
4180
|
+
const reportProgress = (message) => {
|
|
4181
|
+
if (!onProgress)
|
|
4182
|
+
return;
|
|
4183
|
+
const progress = progressTracker.getProgress();
|
|
4184
|
+
onProgress({
|
|
4185
|
+
completed: progress.completed,
|
|
4186
|
+
total: progress.total,
|
|
4187
|
+
percentage: progress.percentage,
|
|
4188
|
+
message,
|
|
4189
|
+
errors: errors.map(e => e.error),
|
|
4190
|
+
});
|
|
4191
|
+
};
|
|
4192
|
+
// Filter existing fonts if skipExisting is true
|
|
4193
|
+
let urlsToDownload = fontUrls;
|
|
4194
|
+
if (skipExisting) {
|
|
4195
|
+
const existingFonts = new Set();
|
|
4196
|
+
const tx = db.transaction(['fonts'], 'readonly');
|
|
4197
|
+
let cursor = await tx.objectStore('fonts').openCursor();
|
|
4198
|
+
while (cursor) {
|
|
4199
|
+
existingFonts.add(cursor.value.key);
|
|
4200
|
+
cursor = await cursor.continue();
|
|
4201
|
+
}
|
|
4202
|
+
urlsToDownload = fontUrls.filter(url => {
|
|
4203
|
+
const key = this.createFontKey(url, styleName);
|
|
4204
|
+
return !existingFonts.has(key);
|
|
4205
|
+
});
|
|
4206
|
+
skippedFonts = fontUrls.length - urlsToDownload.length;
|
|
4207
|
+
// Skipped fonts count toward the total, so mark them done up front.
|
|
4208
|
+
if (skippedFonts > 0) {
|
|
4209
|
+
progressTracker.update(skippedFonts);
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
// Check storage quota if enabled
|
|
4213
|
+
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
4214
|
+
const estimate = await navigator.storage.estimate();
|
|
4215
|
+
const usedSpace = estimate.usage || 0;
|
|
4216
|
+
const availableSpace = (estimate.quota || 0) - usedSpace;
|
|
4217
|
+
if (availableSpace < 50 * 1024 * 1024) {
|
|
4218
|
+
// Less than 50MB
|
|
4219
|
+
throw new Error('Insufficient storage space for font download');
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
// Process fonts in batches with concurrency control
|
|
4223
|
+
await processBatch(urlsToDownload, async (fontUrl) => {
|
|
4224
|
+
try {
|
|
4225
|
+
const fontKey = this.createFontKey(fontUrl, styleName);
|
|
4226
|
+
const response = await fetchResourceWithRetry(fontUrl, {
|
|
4227
|
+
retries: maxRetries,
|
|
4228
|
+
retryDelay,
|
|
4229
|
+
timeout,
|
|
4230
|
+
proxyType: 'fonts',
|
|
4231
|
+
});
|
|
4232
|
+
if (response.type === 'json') {
|
|
4233
|
+
throw new Error(`Unexpected JSON response when fetching font: ${fontUrl}`);
|
|
4234
|
+
}
|
|
4235
|
+
const fontData = response.data;
|
|
4236
|
+
const contentType = response.contentType ||
|
|
4237
|
+
(response.type === 'other' ? 'font/opentype' : `font/${response.type}`);
|
|
4238
|
+
// Validate font if enabled
|
|
4239
|
+
if (validateFonts) {
|
|
4240
|
+
await this.validateFont(fontData, contentType);
|
|
4241
|
+
}
|
|
4242
|
+
// Store font in database
|
|
4243
|
+
const fontEntry = {
|
|
4244
|
+
key: fontKey,
|
|
4245
|
+
url: fontUrl,
|
|
4246
|
+
originalUrl: fontUrl,
|
|
4247
|
+
data: fontData,
|
|
4248
|
+
contentType,
|
|
4249
|
+
type: this.detectFontType(contentType, fontUrl),
|
|
4250
|
+
size: fontData.byteLength,
|
|
4251
|
+
lastModified: Date.now(),
|
|
4252
|
+
downloadedAt: new Date().toISOString(),
|
|
4253
|
+
downloadId: styleName,
|
|
4254
|
+
metadata: {
|
|
4255
|
+
userAgent: navigator.userAgent,
|
|
4256
|
+
downloadTimestamp: Date.now(),
|
|
4257
|
+
},
|
|
4258
|
+
expires: response.expires,
|
|
4259
|
+
};
|
|
4260
|
+
await db.put('fonts', fontEntry);
|
|
4261
|
+
progressTracker.update(1, fontKey);
|
|
4262
|
+
reportProgress(`Downloaded ${fontKey}`);
|
|
4263
|
+
totalSize += fontData.byteLength;
|
|
4264
|
+
downloadedFonts++;
|
|
4265
|
+
// Track font types
|
|
4266
|
+
const fontType = this.detectFontType(contentType, fontUrl);
|
|
4267
|
+
fontsByType[fontType] = (fontsByType[fontType] || 0) + 1;
|
|
4268
|
+
}
|
|
4269
|
+
catch (error) {
|
|
4270
|
+
failedFonts++;
|
|
4271
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4272
|
+
errors.push({
|
|
4273
|
+
url: fontUrl,
|
|
4274
|
+
error: errorMessage,
|
|
3429
4275
|
});
|
|
4276
|
+
// A failed font is finished too; don't let the bar stall on it.
|
|
4277
|
+
progressTracker.update(1, fontUrl);
|
|
4278
|
+
reportProgress(`Failed: ${fontUrl}`);
|
|
3430
4279
|
// Only log detailed errors if not in quiet mode
|
|
3431
4280
|
if (!quietMode) {
|
|
3432
4281
|
fontLogger.error(`Failed to download font ${fontUrl}:`, errorMessage);
|
|
@@ -3439,6 +4288,7 @@ class FontService {
|
|
|
3439
4288
|
}, { batchSize });
|
|
3440
4289
|
const downloadTime = Date.now() - startTime;
|
|
3441
4290
|
const averageSpeed = downloadTime > 0 ? (totalSize / downloadTime) * 1000 : 0;
|
|
4291
|
+
reportProgress(`Font download complete: ${downloadedFonts} downloaded, ${skippedFonts} skipped, ${failedFonts} failed`);
|
|
3442
4292
|
return {
|
|
3443
4293
|
totalFonts: fontUrls.length,
|
|
3444
4294
|
downloadedFonts,
|
|
@@ -3717,7 +4567,7 @@ class SpriteService {
|
|
|
3717
4567
|
*/
|
|
3718
4568
|
async downloadSprites(spriteUrls, styleName, options = {}) {
|
|
3719
4569
|
const db = await this.db;
|
|
3720
|
-
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeoutMs = 15000, storageQuotaCheck = true, prioritySprites = [], bandwidthLimit, enableValidation = true, namePrefix, } = options;
|
|
4570
|
+
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeoutMs = 15000, storageQuotaCheck = true, prioritySprites = [], bandwidthLimit, enableValidation = true, namePrefix, signal, } = options;
|
|
3721
4571
|
const startTime = Date.now();
|
|
3722
4572
|
let totalSize = 0;
|
|
3723
4573
|
let downloadedSprites = 0;
|
|
@@ -3765,6 +4615,10 @@ class SpriteService {
|
|
|
3765
4615
|
return !existingSprites.has(key);
|
|
3766
4616
|
});
|
|
3767
4617
|
skippedSprites = spriteUrls.length - urlsToDownload.length;
|
|
4618
|
+
// Skipped sprites count toward the total, so mark them done up front.
|
|
4619
|
+
if (skippedSprites > 0) {
|
|
4620
|
+
progressTracker.update(skippedSprites);
|
|
4621
|
+
}
|
|
3768
4622
|
}
|
|
3769
4623
|
// Check storage quota if enabled
|
|
3770
4624
|
if (storageQuotaCheck && 'storage' in navigator && 'estimate' in navigator.storage) {
|
|
@@ -3785,6 +4639,9 @@ class SpriteService {
|
|
|
3785
4639
|
spriteLogger.debug(` ${url} -> ${key}`);
|
|
3786
4640
|
});
|
|
3787
4641
|
await processBatch(urlsToDownload, async (spriteUrl) => {
|
|
4642
|
+
// Cancelled: let the remaining sprites go by without fetching them.
|
|
4643
|
+
if (signal?.aborted)
|
|
4644
|
+
return;
|
|
3788
4645
|
try {
|
|
3789
4646
|
const spriteName = namePrefix
|
|
3790
4647
|
? this.buildSpriteName(spriteUrl, namePrefix)
|
|
@@ -3880,7 +4737,9 @@ class SpriteService {
|
|
|
3880
4737
|
error: errorMessage,
|
|
3881
4738
|
});
|
|
3882
4739
|
spriteLogger.error(`Failed to download sprite ${spriteUrl}:`, error);
|
|
3883
|
-
//
|
|
4740
|
+
// Advance progress on failure too — a failed item is finished, and
|
|
4741
|
+
// otherwise the bar stalls, then jumps to 100% at the end.
|
|
4742
|
+
progressTracker.update(1, this.extractSpriteName(spriteUrl));
|
|
3884
4743
|
if (onProgress) {
|
|
3885
4744
|
const progress = progressTracker.getProgress();
|
|
3886
4745
|
onProgress({
|
|
@@ -4194,7 +5053,74 @@ var spriteService$1 = /*#__PURE__*/Object.freeze({
|
|
|
4194
5053
|
verifyAndRepairSprites: verifyAndRepairSprites
|
|
4195
5054
|
});
|
|
4196
5055
|
|
|
4197
|
-
|
|
5056
|
+
/** A style URL's identity, ignoring the access token (same style, different token). */
|
|
5057
|
+
function styleUrlIdentity(url) {
|
|
5058
|
+
try {
|
|
5059
|
+
const parsed = new URL(url);
|
|
5060
|
+
parsed.searchParams.delete('access_token');
|
|
5061
|
+
return parsed.toString();
|
|
5062
|
+
}
|
|
5063
|
+
catch {
|
|
5064
|
+
return url;
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
/**
|
|
5068
|
+
* Pick the storage key for a style downloaded from `styleUrl`. `baseId` comes
|
|
5069
|
+
* from the style's `id`/`name`, which unrelated styles can share ("Streets"),
|
|
5070
|
+
* so a key already held by a style from a different URL is never reused:
|
|
5071
|
+
* fall back to the key this URL was stored under before, else a free
|
|
5072
|
+
* `${baseId}-N`. Entries without `originalUrl` (saved with
|
|
5073
|
+
* `includeMetadata: false`) are assumed to be the same style, so re-downloads
|
|
5074
|
+
* don't pile up duplicates; `carryOverRegions` keeps their regions either way.
|
|
5075
|
+
*/
|
|
5076
|
+
async function resolveStyleKey(db, baseId, styleUrl) {
|
|
5077
|
+
const target = styleUrlIdentity(styleUrl);
|
|
5078
|
+
const isSameStyle = (entry) => !!entry.originalUrl && styleUrlIdentity(entry.originalUrl) === target;
|
|
5079
|
+
const existing = await db.get('styles', baseId);
|
|
5080
|
+
if (!existing || !existing.originalUrl || isSameStyle(existing))
|
|
5081
|
+
return baseId;
|
|
5082
|
+
const all = await db.getAll('styles');
|
|
5083
|
+
const previous = all.find(isSameStyle);
|
|
5084
|
+
if (previous)
|
|
5085
|
+
return previous.key;
|
|
5086
|
+
const taken = new Set(all.map(entry => entry.key));
|
|
5087
|
+
let n = 2;
|
|
5088
|
+
while (taken.has(`${baseId}-${n}`))
|
|
5089
|
+
n++;
|
|
5090
|
+
logger.warn(`Style key "${baseId}" is used by a style from another URL; storing ${styleUrl} as "${baseId}-${n}"`);
|
|
5091
|
+
return `${baseId}-${n}`;
|
|
5092
|
+
}
|
|
5093
|
+
/**
|
|
5094
|
+
* Re-downloading a style replaces its stored entry. Carry over the regions
|
|
5095
|
+
* already attached to it and re-patch the fresh style for them — otherwise
|
|
5096
|
+
* every existing region is dropped and their tiles are orphaned. Patches a
|
|
5097
|
+
* copy, so callers can keep using the unpatched style for resource downloads.
|
|
5098
|
+
*/
|
|
5099
|
+
async function carryOverRegions(db, entry) {
|
|
5100
|
+
const existing = await db.get('styles', entry.key);
|
|
5101
|
+
const regions = existing?.regions;
|
|
5102
|
+
// Config set through `styleConfig` decides what renders and what's planned,
|
|
5103
|
+
// so it has to survive a fresh copy of the style.
|
|
5104
|
+
if (existing?.styleConfig) {
|
|
5105
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5106
|
+
applyStyleConfig(entry.style, existing.styleConfig);
|
|
5107
|
+
entry.styleConfig = existing.styleConfig;
|
|
5108
|
+
}
|
|
5109
|
+
if (!Array.isArray(regions) || regions.length === 0)
|
|
5110
|
+
return;
|
|
5111
|
+
entry.regions = regions;
|
|
5112
|
+
entry.style = JSON.parse(JSON.stringify(entry.style));
|
|
5113
|
+
// Regions' extra sources were added to the old copy, so add them to this one.
|
|
5114
|
+
for (const region of regions) {
|
|
5115
|
+
injectExtraSources(entry.style, region.extraSources);
|
|
5116
|
+
}
|
|
5117
|
+
stashOriginalModels(entry);
|
|
5118
|
+
patchStyleForOffline(entry.style, entry.key, Math.max(...regions.map(region => region.maxZoom)), undefined, entry.key);
|
|
5119
|
+
const { storedModelNames } = await Promise.resolve().then(function () { return modelService$1; });
|
|
5120
|
+
restoreUnstoredModels(entry, await storedModelNames(entry.key));
|
|
5121
|
+
logger.debug(`Carried ${regions.length} region(s) over to re-downloaded style ${entry.key}`);
|
|
5122
|
+
}
|
|
5123
|
+
// Helper functions to work with StyleEntry structure
|
|
4198
5124
|
function createStyleEntry(key, style, provider = 'auto', metadata = {}) {
|
|
4199
5125
|
return {
|
|
4200
5126
|
key,
|
|
@@ -4296,8 +5222,11 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4296
5222
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4297
5223
|
});
|
|
4298
5224
|
}
|
|
5225
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5226
|
+
sanitizeIndoorExpressions(style);
|
|
4299
5227
|
// Check if style already exists in the database
|
|
4300
5228
|
const db = await dbPromise;
|
|
5229
|
+
style.id = await resolveStyleKey(db, style.id, stylesUrl);
|
|
4301
5230
|
if (skipExisting) {
|
|
4302
5231
|
const existingStyle = await db.get('styles', style.id);
|
|
4303
5232
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
@@ -4361,7 +5290,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4361
5290
|
timeout: timeoutMs,
|
|
4362
5291
|
retryDelay: 1000,
|
|
4363
5292
|
});
|
|
4364
|
-
const sourceData = await sourceResponse.json();
|
|
5293
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
4365
5294
|
style.sources[sourceKey] = { ...source, ...sourceData }; // Embed source data
|
|
4366
5295
|
sourcesEmbedded++;
|
|
4367
5296
|
logger.debug(`Embedded source data for ${sourceKey}`);
|
|
@@ -4428,6 +5357,7 @@ async function downloadStyles(stylesUrl, options = {}) {
|
|
|
4428
5357
|
}
|
|
4429
5358
|
: {});
|
|
4430
5359
|
// Save the style
|
|
5360
|
+
await carryOverRegions(db, styleStorageItem);
|
|
4431
5361
|
await db.put('styles', styleStorageItem);
|
|
4432
5362
|
logger.debug('Style with sources saved successfully');
|
|
4433
5363
|
onProgress?.({
|
|
@@ -4701,18 +5631,26 @@ async function loadStyleById(styleId) {
|
|
|
4701
5631
|
return null;
|
|
4702
5632
|
}
|
|
4703
5633
|
}
|
|
5634
|
+
/** Delete every style and everything stored for them. */
|
|
4704
5635
|
async function deleteStyles() {
|
|
4705
5636
|
try {
|
|
4706
5637
|
const db = await dbPromise;
|
|
4707
|
-
await
|
|
5638
|
+
await Promise.all(['tiles', 'fonts', 'glyphs', 'sprites', 'models', 'styles'].map(store => db.clear(store)));
|
|
5639
|
+
clearAllCaches();
|
|
4708
5640
|
logger.debug('All styles deleted successfully');
|
|
4709
5641
|
}
|
|
4710
5642
|
catch (error) {
|
|
4711
5643
|
logger.error('Error deleting styles:', error);
|
|
4712
5644
|
}
|
|
4713
5645
|
}
|
|
5646
|
+
/**
|
|
5647
|
+
* Delete a style, its regions and everything stored for it. The entry goes last,
|
|
5648
|
+
* so a failure part-way leaves the style listed and able to be deleted again.
|
|
5649
|
+
*/
|
|
4714
5650
|
async function deleteStyleById(styleId) {
|
|
4715
5651
|
try {
|
|
5652
|
+
const { RegionService } = await Promise.resolve().then(function () { return regionService; });
|
|
5653
|
+
await new RegionService().deleteStyleData(styleId);
|
|
4716
5654
|
const db = await dbPromise;
|
|
4717
5655
|
await db.delete('styles', styleId);
|
|
4718
5656
|
logger.debug(`Style with ID ${styleId} deleted successfully`);
|
|
@@ -4721,6 +5659,24 @@ async function deleteStyleById(styleId) {
|
|
|
4721
5659
|
logger.error(`Error deleting style with ID ${styleId}:`, error);
|
|
4722
5660
|
}
|
|
4723
5661
|
}
|
|
5662
|
+
/**
|
|
5663
|
+
* Bytes stored for each style, by style id: its tiles, and its other resources
|
|
5664
|
+
* (glyphs, sprites, models and fonts). Tiles shared by regions count once.
|
|
5665
|
+
*/
|
|
5666
|
+
async function getStyleStorageSizes() {
|
|
5667
|
+
const db = await dbPromise;
|
|
5668
|
+
const sizes = {};
|
|
5669
|
+
for (const storeName of ['tiles', 'glyphs', 'sprites', 'models', 'fonts']) {
|
|
5670
|
+
for await (const cursor of db.transaction(storeName).store) {
|
|
5671
|
+
const { styleId, size } = cursor.value;
|
|
5672
|
+
if (!styleId)
|
|
5673
|
+
continue;
|
|
5674
|
+
sizes[styleId] ??= { tiles: 0, resources: 0 };
|
|
5675
|
+
sizes[styleId][storeName === 'tiles' ? 'tiles' : 'resources'] += size ?? 0;
|
|
5676
|
+
}
|
|
5677
|
+
}
|
|
5678
|
+
return sizes;
|
|
5679
|
+
}
|
|
4724
5680
|
/**
|
|
4725
5681
|
* Get enhanced style statistics
|
|
4726
5682
|
*/
|
|
@@ -4946,9 +5902,10 @@ async function isStyleDownloaded(styleId, styleUrl) {
|
|
|
4946
5902
|
* Enhanced style download that supports both Mapbox GL and MapLibre GL
|
|
4947
5903
|
*/
|
|
4948
5904
|
async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
4949
|
-
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, } = options;
|
|
5905
|
+
const { provider: explicitProvider, accessToken: explicitAccessToken, forceProvider = false, skipExisting = false, enableSourceEmbedding = true, maxRetries = 3, timeoutMs = 30000, onProgress, } = options;
|
|
4950
5906
|
const startTime = Date.now();
|
|
4951
5907
|
let sourcesEmbedded = 0;
|
|
5908
|
+
const emit = (percentage, message) => onProgress?.({ completed: percentage, total: 100, percentage, message, errors: [] });
|
|
4952
5909
|
logger.debug(`Downloading style from: ${styleUrl}`);
|
|
4953
5910
|
try {
|
|
4954
5911
|
// Auto-detect provider if not explicitly set
|
|
@@ -4969,6 +5926,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4969
5926
|
fetchUrl = normalizeStyleUrl(styleUrl, extractedToken);
|
|
4970
5927
|
}
|
|
4971
5928
|
// Fetch the style
|
|
5929
|
+
emit(0, 'Fetching style');
|
|
4972
5930
|
const response = await fetchWithRetry(fetchUrl, {
|
|
4973
5931
|
timeout: timeoutMs,
|
|
4974
5932
|
retries: maxRetries,
|
|
@@ -4977,6 +5935,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4977
5935
|
throw new Error(`Failed to fetch style: ${response.status} ${response.statusText}`);
|
|
4978
5936
|
}
|
|
4979
5937
|
const style = (await response.json());
|
|
5938
|
+
emit(20, 'Style fetched');
|
|
4980
5939
|
// Resolve imports (Mapbox Standard / compositional styles)
|
|
4981
5940
|
if (hasImports(style)) {
|
|
4982
5941
|
const importToken = extractedToken || '';
|
|
@@ -4990,13 +5949,19 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
4990
5949
|
layerCount: style.layers ? style.layers.length : 0,
|
|
4991
5950
|
});
|
|
4992
5951
|
}
|
|
5952
|
+
// Mapbox Standard now arrives flat, so resolveImports may not have run.
|
|
5953
|
+
sanitizeIndoorExpressions(style);
|
|
5954
|
+
emit(40, 'Processing style sources');
|
|
4993
5955
|
// Process style for the detected provider
|
|
4994
5956
|
const processedStyle = processStyleSources(style, detectedProvider, extractedToken);
|
|
4995
5957
|
// Embed TileJSON data into sources for reliable offline tile downloads
|
|
4996
5958
|
// This fetches each source's TileJSON URL and merges the result (including tiles array,
|
|
4997
5959
|
// minzoom, maxzoom) into the source config, so tile downloads don't need extra fetches
|
|
4998
5960
|
if (enableSourceEmbedding && processedStyle.sources) {
|
|
4999
|
-
|
|
5961
|
+
const sourceEntries = Object.entries(processedStyle.sources);
|
|
5962
|
+
let sourcesVisited = 0;
|
|
5963
|
+
for (const [sourceKey, sourceConfig] of sourceEntries) {
|
|
5964
|
+
emit(40 + Math.round((sourcesVisited++ / Math.max(1, sourceEntries.length)) * 40), `Embedding source ${sourceKey}`);
|
|
5000
5965
|
const source = sourceConfig;
|
|
5001
5966
|
if (source.url && typeof source.url === 'string' && !source.tiles) {
|
|
5002
5967
|
const sourceUrl = source.url;
|
|
@@ -5009,7 +5974,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5009
5974
|
timeout: timeoutMs,
|
|
5010
5975
|
retryDelay: 1000,
|
|
5011
5976
|
});
|
|
5012
|
-
const sourceData = await sourceResponse.json();
|
|
5977
|
+
const sourceData = selectTileJsonVariant(await sourceResponse.json());
|
|
5013
5978
|
// Only copy valid MapLibre/Mapbox source properties from TileJSON
|
|
5014
5979
|
// Copying unknown properties (like "name", "tilejson") causes
|
|
5015
5980
|
// MapLibre to throw "unknown property" errors on setStyle()
|
|
@@ -5043,6 +6008,7 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5043
6008
|
}
|
|
5044
6009
|
}
|
|
5045
6010
|
}
|
|
6011
|
+
emit(80, 'Saving style');
|
|
5046
6012
|
// Set default glyphs URL if not present
|
|
5047
6013
|
if (!processedStyle.glyphs) {
|
|
5048
6014
|
processedStyle.glyphs = GLYPH_CONFIG.DEFAULT_URL;
|
|
@@ -5054,13 +6020,17 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5054
6020
|
throw new Error(`Style validation failed: ${validation.errors.join(', ')}`);
|
|
5055
6021
|
}
|
|
5056
6022
|
// Generate style ID
|
|
5057
|
-
const styleId = processedStyle.name?.toLowerCase().replace(/\s+/g, '-') || `style-${Date.now()}`;
|
|
5058
|
-
// Check if style already exists
|
|
5059
6023
|
const db = await dbPromise;
|
|
6024
|
+
const styleId = await resolveStyleKey(db,
|
|
6025
|
+
// The key goes into resource keys ("{key}:...") and idb:// URLs, so ":" or "/"
|
|
6026
|
+
// in a style's name would make its data look like another style's or break its URLs.
|
|
6027
|
+
processedStyle.name?.toLowerCase().replace(/[\s:/\\?#%]+/g, '-') || `style-${Date.now()}`, styleUrl);
|
|
6028
|
+
// Check if style already exists
|
|
5060
6029
|
if (skipExisting) {
|
|
5061
6030
|
const existingStyle = await db.get('styles', styleId);
|
|
5062
6031
|
if (existingStyle) {
|
|
5063
6032
|
logger.debug(`Style ${styleId} already exists, skipping`);
|
|
6033
|
+
emit(100, 'Style already exists');
|
|
5064
6034
|
return {
|
|
5065
6035
|
styleId,
|
|
5066
6036
|
success: true,
|
|
@@ -5091,7 +6061,9 @@ async function downloadStyleWithProvider(styleUrl, options = {}) {
|
|
|
5091
6061
|
accessToken: extractedToken,
|
|
5092
6062
|
});
|
|
5093
6063
|
// Save style
|
|
6064
|
+
await carryOverRegions(db, styleEntry);
|
|
5094
6065
|
await db.put('styles', styleEntry);
|
|
6066
|
+
emit(100, 'Style saved');
|
|
5095
6067
|
const downloadTime = Date.now() - startTime;
|
|
5096
6068
|
logger.info(`Style ${styleId} downloaded in ${downloadTime}ms`);
|
|
5097
6069
|
return {
|
|
@@ -5140,12 +6112,38 @@ var styleService = /*#__PURE__*/Object.freeze({
|
|
|
5140
6112
|
downloadStyleWithProvider: downloadStyleWithProvider,
|
|
5141
6113
|
downloadStyles: downloadStyles,
|
|
5142
6114
|
getStyleStats: getStyleStats,
|
|
6115
|
+
getStyleStorageSizes: getStyleStorageSizes,
|
|
5143
6116
|
isStyleDownloaded: isStyleDownloaded,
|
|
5144
6117
|
loadStyleById: loadStyleById,
|
|
5145
6118
|
loadStyles: loadStyles
|
|
5146
6119
|
});
|
|
5147
6120
|
|
|
5148
6121
|
const regionLogger$1 = logger.scope('RegionService');
|
|
6122
|
+
/**
|
|
6123
|
+
* Where each phase sits in a download's overall progress, as [start, end] in
|
|
6124
|
+
* percent. Tiles get the most because they're usually most of the bytes.
|
|
6125
|
+
*/
|
|
6126
|
+
const PHASE_PROGRESS_RANGES = {
|
|
6127
|
+
style: [0, 5],
|
|
6128
|
+
sprites: [5, 10],
|
|
6129
|
+
glyphs: [10, 25],
|
|
6130
|
+
models: [25, 30],
|
|
6131
|
+
tiles: [30, 99],
|
|
6132
|
+
metadata: [99, 100],
|
|
6133
|
+
};
|
|
6134
|
+
/** True for a Mapbox-hosted style sprite, which has an `iconset.pbf` beside it. */
|
|
6135
|
+
function hasMapboxIconset(spriteUrl) {
|
|
6136
|
+
try {
|
|
6137
|
+
const { hostname, pathname } = new URL(spriteUrl);
|
|
6138
|
+
return (hostname === 'api.mapbox.com' &&
|
|
6139
|
+
pathname.startsWith('/styles/v1/') &&
|
|
6140
|
+
pathname.endsWith('/sprite'));
|
|
6141
|
+
}
|
|
6142
|
+
catch {
|
|
6143
|
+
// Not an absolute URL, so not a Mapbox-hosted sprite.
|
|
6144
|
+
return false;
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
5149
6147
|
/**
|
|
5150
6148
|
* True when `key` belongs to the given styleId.
|
|
5151
6149
|
*
|
|
@@ -5162,44 +6160,10 @@ function resourceKeyBelongsToStyle(key, styleId) {
|
|
|
5162
6160
|
}
|
|
5163
6161
|
class RegionService {
|
|
5164
6162
|
/**
|
|
5165
|
-
*
|
|
5166
|
-
|
|
5167
|
-
tileOverlapsWithRegion(tileX, tileY, tileZ, region) {
|
|
5168
|
-
// Validate region bounds exist and have correct structure
|
|
5169
|
-
if (!region.bounds ||
|
|
5170
|
-
!Array.isArray(region.bounds) ||
|
|
5171
|
-
region.bounds.length !== 2 ||
|
|
5172
|
-
!Array.isArray(region.bounds[0]) ||
|
|
5173
|
-
!Array.isArray(region.bounds[1]) ||
|
|
5174
|
-
region.bounds[0].length !== 2 ||
|
|
5175
|
-
region.bounds[1].length !== 2) {
|
|
5176
|
-
regionLogger$1.warn('Invalid region bounds structure:', region.bounds);
|
|
5177
|
-
return false;
|
|
5178
|
-
}
|
|
5179
|
-
// Get tile bounds using tilebelt
|
|
5180
|
-
const tileBounds = tilebelt.tileToBBOX([tileX, tileY, tileZ]);
|
|
5181
|
-
const [tileWest, tileSouth, tileEast, tileNorth] = tileBounds;
|
|
5182
|
-
// Region bounds format: [[west, south], [east, north]]
|
|
5183
|
-
const regionWest = region.bounds[0][0];
|
|
5184
|
-
const regionSouth = region.bounds[0][1];
|
|
5185
|
-
const regionEast = region.bounds[1][0];
|
|
5186
|
-
const regionNorth = region.bounds[1][1];
|
|
5187
|
-
// Check if bounding boxes overlap
|
|
5188
|
-
return !(tileEast < regionWest ||
|
|
5189
|
-
tileWest > regionEast ||
|
|
5190
|
-
tileNorth < regionSouth ||
|
|
5191
|
-
tileSouth > regionNorth);
|
|
5192
|
-
}
|
|
5193
|
-
/**
|
|
5194
|
-
* Check if a tile overlaps with any of the given regions
|
|
5195
|
-
*/
|
|
5196
|
-
tileOverlapsWithAnyRegion(tileX, tileY, tileZ, regions) {
|
|
5197
|
-
return regions.some(region => this.tileOverlapsWithRegion(tileX, tileY, tileZ, region));
|
|
5198
|
-
}
|
|
5199
|
-
/**
|
|
5200
|
-
* Delete tiles that don't overlap with any remaining regions
|
|
6163
|
+
* Delete tiles no remaining region needs — outside every remaining region's
|
|
6164
|
+
* bounds, or at a zoom none of them downloads for that tile's source.
|
|
5201
6165
|
*/
|
|
5202
|
-
async deleteNonOverlappingTiles(styleId, remainingRegions) {
|
|
6166
|
+
async deleteNonOverlappingTiles(styleId, remainingRegions, sources) {
|
|
5203
6167
|
const db = await dbPromise;
|
|
5204
6168
|
let deletedCount = 0;
|
|
5205
6169
|
regionLogger$1.debug(`Checking tiles for style ${styleId} against ${remainingRegions.length} remaining regions`);
|
|
@@ -5216,9 +6180,13 @@ class RegionService {
|
|
|
5216
6180
|
regionLogger$1.warn(`Invalid tile key format: ${tileEntry.key}`);
|
|
5217
6181
|
continue;
|
|
5218
6182
|
}
|
|
5219
|
-
const
|
|
5220
|
-
|
|
5221
|
-
|
|
6183
|
+
const tile = {
|
|
6184
|
+
sourceId: tileEntry.sourceId ?? parsed.sourceId,
|
|
6185
|
+
z: parsed.z,
|
|
6186
|
+
x: parsed.x,
|
|
6187
|
+
y: parsed.y,
|
|
6188
|
+
};
|
|
6189
|
+
if (!remainingRegions.some(region => tileBelongsToRegion(tile, region, sources))) {
|
|
5222
6190
|
regionLogger$1.debug(`Deleting non-overlapping tile: ${tileEntry.key}`);
|
|
5223
6191
|
await cursor.delete();
|
|
5224
6192
|
deletedCount++;
|
|
@@ -5272,6 +6240,46 @@ class RegionService {
|
|
|
5272
6240
|
/**
|
|
5273
6241
|
* Delete all tiles for a style
|
|
5274
6242
|
*/
|
|
6243
|
+
/**
|
|
6244
|
+
* Delete everything stored for a style except its own entry: tiles, fonts,
|
|
6245
|
+
* glyphs, sprites and models.
|
|
6246
|
+
*/
|
|
6247
|
+
/**
|
|
6248
|
+
* Undo what a failed or cancelled download left behind: its partly stored
|
|
6249
|
+
* tiles and its config change. A style this download created, which no other
|
|
6250
|
+
* region uses, goes entirely, and so does a style deleted during the download.
|
|
6251
|
+
*/
|
|
6252
|
+
async cleanUpFailedDownload(styleId, createdStyle, configBefore) {
|
|
6253
|
+
try {
|
|
6254
|
+
const db = await dbPromise;
|
|
6255
|
+
const entry = await db.get('styles', styleId);
|
|
6256
|
+
if (!entry) {
|
|
6257
|
+
await this.deleteStyleData(styleId);
|
|
6258
|
+
return;
|
|
6259
|
+
}
|
|
6260
|
+
const regions = Array.isArray(entry.regions) ? entry.regions : [];
|
|
6261
|
+
if (createdStyle && regions.length === 0) {
|
|
6262
|
+
await this.deleteStyleData(styleId);
|
|
6263
|
+
await db.delete('styles', styleId);
|
|
6264
|
+
return;
|
|
6265
|
+
}
|
|
6266
|
+
if (configBefore) {
|
|
6267
|
+
entry.style.schema = configBefore.schema;
|
|
6268
|
+
entry.styleConfig = configBefore.values;
|
|
6269
|
+
await db.put('styles', entry);
|
|
6270
|
+
}
|
|
6271
|
+
await this.deleteNonOverlappingTiles(styleId, regions, (entry.style?.sources ?? {}));
|
|
6272
|
+
clearAllCaches();
|
|
6273
|
+
}
|
|
6274
|
+
catch (error) {
|
|
6275
|
+
regionLogger$1.warn(`Could not clean up after the failed download on ${styleId}:`, error);
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
async deleteStyleData(styleId) {
|
|
6279
|
+
await this.deleteAllStyleTiles(styleId);
|
|
6280
|
+
await this.deleteStyleResources(styleId);
|
|
6281
|
+
clearAllCaches();
|
|
6282
|
+
}
|
|
5275
6283
|
async deleteAllStyleTiles(styleId) {
|
|
5276
6284
|
const db = await dbPromise;
|
|
5277
6285
|
let deletedCount = 0;
|
|
@@ -5318,23 +6326,15 @@ class RegionService {
|
|
|
5318
6326
|
styleEntry.regions = [];
|
|
5319
6327
|
}
|
|
5320
6328
|
// Inject extra sources into the style so they get patched for offline use
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
};
|
|
5331
|
-
regionLogger$1.debug(`Injected extra source into style: ${extra.id}`);
|
|
5332
|
-
}
|
|
5333
|
-
}
|
|
5334
|
-
}
|
|
5335
|
-
// Patch style for offline use with the region's maxZoom and tileExtension
|
|
5336
|
-
// Pass styleId for sprites since they're stored with the style ID, not region ID
|
|
5337
|
-
patchStyleForOffline(styleEntry.style, region.id, region.maxZoom, region.tileExtension, styleId);
|
|
6329
|
+
injectExtraSources(styleEntry.style, region.extraSources);
|
|
6330
|
+
stashOriginalModels(styleEntry);
|
|
6331
|
+
// Patch style for offline use. URLs are keyed by styleId (resources are
|
|
6332
|
+
// stored per style), and maxzoom is capped at the deepest region on the
|
|
6333
|
+
// style — not just this one — so adding a shallow region doesn't hide a
|
|
6334
|
+
// deeper sibling's tiles.
|
|
6335
|
+
const otherRegions = styleEntry.regions.filter((r) => r.id !== region.id && r.regionId !== region.id);
|
|
6336
|
+
patchStyleForOffline(styleEntry.style, region.id, Math.max(region.maxZoom, ...otherRegions.map(r => r.maxZoom)), region.tileExtension, styleId);
|
|
6337
|
+
restoreUnstoredModels(styleEntry, await storedModelNames(styleId));
|
|
5338
6338
|
// Add or update region metadata on the style entry.
|
|
5339
6339
|
// `expiry` is stored as an absolute timestamp (per the type doc and all
|
|
5340
6340
|
// readers in cleanupService/cleanupManagement/RegionList). If the caller
|
|
@@ -5362,6 +6362,8 @@ class RegionService {
|
|
|
5362
6362
|
styleEntry.regions.push(regionWithMeta);
|
|
5363
6363
|
}
|
|
5364
6364
|
await db.put('styles', styleEntry);
|
|
6365
|
+
// Drop in-memory region→style / tile caches so the fetch handler sees the new region.
|
|
6366
|
+
clearAllCaches();
|
|
5365
6367
|
}
|
|
5366
6368
|
async loadRegion(region, options = {}) {
|
|
5367
6369
|
return this.downloadRegion(region, options);
|
|
@@ -5375,17 +6377,22 @@ class RegionService {
|
|
|
5375
6377
|
if (!region.styleUrl) {
|
|
5376
6378
|
throw new Error('Region must have a styleUrl');
|
|
5377
6379
|
}
|
|
5378
|
-
|
|
6380
|
+
validateZoomLevels(region.minZoom, region.maxZoom);
|
|
6381
|
+
const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, styleConfig, signal, } = options;
|
|
6382
|
+
let overallPercentage = 0;
|
|
5379
6383
|
const emit = (phase, completed, total, message) => {
|
|
5380
6384
|
if (!onProgress)
|
|
5381
6385
|
return;
|
|
5382
6386
|
const safeTotal = total > 0 ? total : 1;
|
|
5383
6387
|
const clamped = Math.min(completed, safeTotal);
|
|
6388
|
+
const [start, end] = PHASE_PROGRESS_RANGES[phase];
|
|
6389
|
+
overallPercentage = Math.max(overallPercentage, start + (end - start) * (clamped / safeTotal));
|
|
5384
6390
|
onProgress({
|
|
5385
6391
|
phase,
|
|
5386
6392
|
completed: clamped,
|
|
5387
|
-
total
|
|
6393
|
+
total,
|
|
5388
6394
|
percentage: (clamped / safeTotal) * 100,
|
|
6395
|
+
overallPercentage,
|
|
5389
6396
|
message,
|
|
5390
6397
|
});
|
|
5391
6398
|
};
|
|
@@ -5424,9 +6431,22 @@ class RegionService {
|
|
|
5424
6431
|
const originalSpriteUrl = (styleEntry.originalSpriteUrl ??
|
|
5425
6432
|
storedStyle.sprite);
|
|
5426
6433
|
const originalGlyphsUrl = styleEntry.originalGlyphsUrl ?? storedStyle.glyphs;
|
|
6434
|
+
// Applied before the resource phases, which all plan from the style's config.
|
|
6435
|
+
// Kept so a failed download can put the config back as it was.
|
|
6436
|
+
const configBefore = styleConfig
|
|
6437
|
+
? JSON.parse(JSON.stringify({ schema: storedStyle.schema, values: styleEntry.styleConfig }))
|
|
6438
|
+
: undefined;
|
|
6439
|
+
if (styleConfig) {
|
|
6440
|
+
applyStyleConfig(storedStyle, styleConfig);
|
|
6441
|
+
styleEntry.styleConfig = { ...styleEntry.styleConfig, ...styleConfig };
|
|
6442
|
+
}
|
|
6443
|
+
// A reused style may predate download-time sanitizing.
|
|
6444
|
+
sanitizeIndoorExpressions(storedStyle);
|
|
6445
|
+
await (await dbPromise).put('styles', styleEntry);
|
|
5427
6446
|
// 2. Sprites
|
|
5428
6447
|
const spriteResults = [];
|
|
5429
|
-
|
|
6448
|
+
// After a cancel, later phases don't start; the tile step then cleans up.
|
|
6449
|
+
if (!skipSprites && !signal?.aborted) {
|
|
5430
6450
|
const spriteSources = normalizeSpriteProperty(originalSpriteUrl);
|
|
5431
6451
|
if (spriteSources.length > 0) {
|
|
5432
6452
|
const { downloadSprites } = await Promise.resolve().then(function () { return spriteService$1; });
|
|
@@ -5434,16 +6454,17 @@ class RegionService {
|
|
|
5434
6454
|
// sibling is also served under the same /styles/v1/.../<hash>/ path
|
|
5435
6455
|
// — we detect that case per-source below and append it to the list.
|
|
5436
6456
|
const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
6457
|
+
const resolveSpriteBase = (url) => isMapboxProtocol(url) && effectiveAccessToken
|
|
6458
|
+
? resolveMapboxUrl(url, effectiveAccessToken)
|
|
6459
|
+
: url;
|
|
6460
|
+
const totalFiles = spriteSources.reduce((sum, source) => {
|
|
6461
|
+
const base = resolveSpriteBase(source.url).split('?')[0];
|
|
6462
|
+
return sum + baseSuffixes.length + (hasMapboxIconset(base) ? 1 : 0);
|
|
6463
|
+
}, 0);
|
|
5440
6464
|
let completed = 0;
|
|
5441
6465
|
emit('sprites', 0, totalFiles, 'Downloading sprites');
|
|
5442
6466
|
for (const source of spriteSources) {
|
|
5443
|
-
|
|
5444
|
-
if (isMapboxProtocol(spriteBase) && effectiveAccessToken) {
|
|
5445
|
-
spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
|
|
5446
|
-
}
|
|
6467
|
+
const spriteBase = resolveSpriteBase(source.url);
|
|
5447
6468
|
const qIndex = spriteBase.indexOf('?');
|
|
5448
6469
|
const suffixes = [...baseSuffixes];
|
|
5449
6470
|
// Mapbox Standard serves an iconset.pbf alongside the sprite under
|
|
@@ -5451,18 +6472,7 @@ class RegionService {
|
|
|
5451
6472
|
// /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
|
|
5452
6473
|
// segment is `sprite`, so replacing it with `iconset.pbf` works.
|
|
5453
6474
|
const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
|
|
5454
|
-
|
|
5455
|
-
try {
|
|
5456
|
-
const parsed = new URL(pathWithoutQuery);
|
|
5457
|
-
isMapboxStandardSprite =
|
|
5458
|
-
parsed.hostname === 'api.mapbox.com' &&
|
|
5459
|
-
parsed.pathname.startsWith('/styles/v1/') &&
|
|
5460
|
-
parsed.pathname.endsWith('/sprite');
|
|
5461
|
-
}
|
|
5462
|
-
catch {
|
|
5463
|
-
// Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
|
|
5464
|
-
}
|
|
5465
|
-
if (isMapboxStandardSprite) {
|
|
6475
|
+
if (hasMapboxIconset(pathWithoutQuery)) {
|
|
5466
6476
|
// The path-rewrite suffix replaces the trailing `sprite` segment.
|
|
5467
6477
|
suffixes.push('__ICONSET__');
|
|
5468
6478
|
}
|
|
@@ -5478,6 +6488,7 @@ class RegionService {
|
|
|
5478
6488
|
});
|
|
5479
6489
|
try {
|
|
5480
6490
|
const result = await downloadSprites(spriteUrls, styleId, {
|
|
6491
|
+
signal,
|
|
5481
6492
|
enableValidation: true,
|
|
5482
6493
|
skipExisting: false,
|
|
5483
6494
|
namePrefix: source.id !== 'sprite' ? source.id : undefined,
|
|
@@ -5497,7 +6508,10 @@ class RegionService {
|
|
|
5497
6508
|
}
|
|
5498
6509
|
// 3. Glyphs
|
|
5499
6510
|
let glyphResult;
|
|
5500
|
-
if (!skipGlyphs &&
|
|
6511
|
+
if (!skipGlyphs &&
|
|
6512
|
+
!signal?.aborted &&
|
|
6513
|
+
originalGlyphsUrl &&
|
|
6514
|
+
!originalGlyphsUrl.startsWith('idb://')) {
|
|
5501
6515
|
const fontFamilies = new Set(extractAllFontNames(storedStyle));
|
|
5502
6516
|
if (fontFamilies.size > 0) {
|
|
5503
6517
|
const { downloadGlyphs } = await Promise.resolve().then(function () { return glyphService$1; });
|
|
@@ -5511,6 +6525,7 @@ class RegionService {
|
|
|
5511
6525
|
// jump when the real total came in).
|
|
5512
6526
|
try {
|
|
5513
6527
|
glyphResult = await downloadGlyphs(glyphsUrl, Array.from(fontFamilies), styleId, ranges, {
|
|
6528
|
+
signal,
|
|
5514
6529
|
onProgress: (progress) => {
|
|
5515
6530
|
emit('glyphs', progress.completed, progress.total, 'Downloading glyphs');
|
|
5516
6531
|
},
|
|
@@ -5525,15 +6540,17 @@ class RegionService {
|
|
|
5525
6540
|
// turbine .glb assets. Two value shapes exist in the wild
|
|
5526
6541
|
// (plain string or `{ uri }`) — we accept both.
|
|
5527
6542
|
let modelResult;
|
|
5528
|
-
if (!skipModels && storedStyle.models) {
|
|
5529
|
-
|
|
6543
|
+
if (!skipModels && !signal?.aborted && storedStyle.models) {
|
|
6544
|
+
// Upstream URLs, from before patching, so models that failed last time are retried.
|
|
6545
|
+
const rawModels = (styleEntry.originalModels ?? storedStyle.models);
|
|
6546
|
+
const alreadyStored = await storedModelNames(styleId);
|
|
5530
6547
|
const resolved = {};
|
|
5531
6548
|
for (const [name, value] of Object.entries(rawModels)) {
|
|
5532
6549
|
const uri = typeof value === 'string' ? value : value?.uri;
|
|
5533
|
-
if (!uri)
|
|
6550
|
+
if (!uri || alreadyStored.has(name))
|
|
5534
6551
|
continue;
|
|
5535
6552
|
if (uri.startsWith('idb://'))
|
|
5536
|
-
continue; //
|
|
6553
|
+
continue; // patched before upstream URLs were kept
|
|
5537
6554
|
const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
|
|
5538
6555
|
? resolveMapboxUrl(uri, effectiveAccessToken)
|
|
5539
6556
|
: uri;
|
|
@@ -5546,6 +6563,7 @@ class RegionService {
|
|
|
5546
6563
|
emit('models', 0, Object.keys(resolved).length, 'Downloading 3D models');
|
|
5547
6564
|
try {
|
|
5548
6565
|
modelResult = await downloadModels(resolved, styleId, {
|
|
6566
|
+
signal,
|
|
5549
6567
|
onProgress: (progress) => {
|
|
5550
6568
|
emit('models', progress.completed, progress.total, 'Downloading 3D models');
|
|
5551
6569
|
},
|
|
@@ -5556,29 +6574,57 @@ class RegionService {
|
|
|
5556
6574
|
}
|
|
5557
6575
|
}
|
|
5558
6576
|
}
|
|
6577
|
+
// Renderer code fetched from api.mapbox.com at render time (3D decoders,
|
|
6578
|
+
// RTL text, procedural buildings). Never throws; see cacheMapboxRuntimeAssets.
|
|
6579
|
+
let runtimeAssetsCached;
|
|
6580
|
+
if (!signal?.aborted &&
|
|
6581
|
+
/mapbox:\/\/|api\.mapbox\.com/.test(`${region.styleUrl ?? ''} ${originalGlyphsUrl ?? ''}`)) {
|
|
6582
|
+
const { cacheMapboxRuntimeAssets } = await Promise.resolve().then(function () { return runtimeAssetService; });
|
|
6583
|
+
runtimeAssetsCached = await cacheMapboxRuntimeAssets();
|
|
6584
|
+
}
|
|
5559
6585
|
// 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
6586
|
+
let tileResult;
|
|
6587
|
+
try {
|
|
6588
|
+
signal?.throwIfAborted();
|
|
6589
|
+
const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
|
|
6590
|
+
const regionForTiles = { ...region, styleId };
|
|
6591
|
+
// The tile count isn't known until the plan is made, which can take a while.
|
|
6592
|
+
emit('tiles', 0, 0, 'Planning tiles');
|
|
6593
|
+
tileResult = await downloadTiles(regionForTiles, storedStyle, styleId, {
|
|
6594
|
+
skipExisting: false,
|
|
6595
|
+
batchSize: 20,
|
|
6596
|
+
...tileOptions,
|
|
6597
|
+
signal,
|
|
6598
|
+
onProgress: progress => {
|
|
6599
|
+
emit('tiles', progress.completed, progress.total, progress.message ?? 'Downloading tiles');
|
|
6600
|
+
tileOptions?.onProgress?.(progress);
|
|
6601
|
+
},
|
|
6602
|
+
});
|
|
6603
|
+
signal?.throwIfAborted();
|
|
6604
|
+
// A region with no tiles at all isn't worth saving as if it had worked, as happens
|
|
6605
|
+
// with an expired token. Partial failures still save: tileResult reports them.
|
|
6606
|
+
const { failedTiles, downloadedTiles, skippedTiles } = tileResult;
|
|
6607
|
+
if (failedTiles > 0 && downloadedTiles === 0 && skippedTiles === 0) {
|
|
6608
|
+
throw new Error(`None of the ${failedTiles} tiles could be downloaded. Check your connection and access token.`);
|
|
6609
|
+
}
|
|
6610
|
+
// 6. Metadata — must run last, since addRegion patches style URLs to idb://.
|
|
6611
|
+
// Do NOT auto-fill tileExtension from tileResult: that's only the first
|
|
6612
|
+
// source's extension, and addRegion feeds it to patchStyleForOffline which
|
|
6613
|
+
// would override ALL sources — breaking mixed raster+vector styles. The
|
|
6614
|
+
// patcher extracts per-source extensions from tile URL patterns when
|
|
6615
|
+
// tileExtension is undefined. Callers who need to force an extension can
|
|
6616
|
+
// still pass `region.tileExtension` explicitly.
|
|
6617
|
+
emit('metadata', 0, 1, 'Saving region metadata');
|
|
6618
|
+
if (!(await (await dbPromise).get('styles', styleId))) {
|
|
6619
|
+
throw new Error('The style was deleted while this region was downloading.');
|
|
6620
|
+
}
|
|
6621
|
+
await this.addRegion({ ...region, styleId });
|
|
6622
|
+
emit('metadata', 1, 1, 'Region ready');
|
|
6623
|
+
}
|
|
6624
|
+
catch (error) {
|
|
6625
|
+
await this.cleanUpFailedDownload(styleId, Boolean(styleResult), configBefore);
|
|
6626
|
+
throw error;
|
|
6627
|
+
}
|
|
5582
6628
|
return {
|
|
5583
6629
|
regionId: region.id,
|
|
5584
6630
|
styleId,
|
|
@@ -5586,6 +6632,7 @@ class RegionService {
|
|
|
5586
6632
|
spriteResults,
|
|
5587
6633
|
glyphResult,
|
|
5588
6634
|
modelResult,
|
|
6635
|
+
runtimeAssetsCached,
|
|
5589
6636
|
tileResult,
|
|
5590
6637
|
};
|
|
5591
6638
|
}
|
|
@@ -5610,12 +6657,20 @@ class RegionService {
|
|
|
5610
6657
|
}
|
|
5611
6658
|
return undefined;
|
|
5612
6659
|
}
|
|
5613
|
-
|
|
6660
|
+
/**
|
|
6661
|
+
* Delete a region and the tiles/resources only it needed.
|
|
6662
|
+
* @param styleId - The style holding the region. Region ids are only unique
|
|
6663
|
+
* within a style, so pass it whenever known; without it the first style
|
|
6664
|
+
* containing `regionId` is used.
|
|
6665
|
+
*/
|
|
6666
|
+
async deleteRegion(regionId, styleId) {
|
|
5614
6667
|
const db = await dbPromise;
|
|
5615
|
-
regionLogger$1.debug(`Deleting region: ${regionId}`);
|
|
6668
|
+
regionLogger$1.debug(`Deleting region: ${regionId}${styleId ? ` from style ${styleId}` : ''}`);
|
|
5616
6669
|
try {
|
|
5617
6670
|
// Find which style contains this region
|
|
5618
|
-
const allStyles =
|
|
6671
|
+
const allStyles = styleId
|
|
6672
|
+
? [await db.get('styles', styleId)].filter((entry) => entry !== undefined)
|
|
6673
|
+
: await db.getAll('styles');
|
|
5619
6674
|
regionLogger$1.debug(`Found ${allStyles.length} styles to search through`);
|
|
5620
6675
|
let foundStyle = null;
|
|
5621
6676
|
let foundRegion = null;
|
|
@@ -5634,7 +6689,7 @@ class RegionService {
|
|
|
5634
6689
|
}
|
|
5635
6690
|
}
|
|
5636
6691
|
if (!foundStyle || !foundRegion) {
|
|
5637
|
-
regionLogger$1.warn(`Region ${regionId} not found in any style`);
|
|
6692
|
+
regionLogger$1.warn(`Region ${regionId} not found in ${styleId ? `style ${styleId}` : 'any style'}`);
|
|
5638
6693
|
return;
|
|
5639
6694
|
}
|
|
5640
6695
|
regionLogger$1.debug(`Found region in style: ${foundStyle.key}`, foundRegion);
|
|
@@ -5645,11 +6700,7 @@ class RegionService {
|
|
|
5645
6700
|
// If this was the last region for the style, delete the entire style and all its resources
|
|
5646
6701
|
if (remainingRegions.length === 0) {
|
|
5647
6702
|
regionLogger$1.info(`Style ${foundStyle.key} has no remaining regions, deleting entire style and all resources`);
|
|
5648
|
-
|
|
5649
|
-
const deletedTileCount = await this.deleteAllStyleTiles(foundStyle.key);
|
|
5650
|
-
regionLogger$1.debug(`Deleted ${deletedTileCount} tiles`);
|
|
5651
|
-
// Delete all style resources (fonts, glyphs, sprites)
|
|
5652
|
-
await this.deleteStyleResources(foundStyle.key);
|
|
6703
|
+
await this.deleteStyleData(foundStyle.key);
|
|
5653
6704
|
// Delete the style entry itself
|
|
5654
6705
|
await db.delete('styles', foundStyle.key);
|
|
5655
6706
|
regionLogger$1.info(`Completely deleted style ${foundStyle.key} and all associated resources`);
|
|
@@ -5657,11 +6708,18 @@ class RegionService {
|
|
|
5657
6708
|
else {
|
|
5658
6709
|
regionLogger$1.debug(`Style ${foundStyle.key} has ${remainingRegions.length} remaining regions, cleaning up non-overlapping tiles`);
|
|
5659
6710
|
// Delete tiles that don't overlap with any remaining regions
|
|
5660
|
-
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions);
|
|
6711
|
+
const deletedTileCount = await this.deleteNonOverlappingTiles(foundStyle.key, remainingRegions, (foundStyle.style?.sources ?? {}));
|
|
6712
|
+
// Re-patch for the remaining regions: shrink maxzoom to the deepest
|
|
6713
|
+
// survivor, and re-key URLs by styleId. Styles patched before URLs
|
|
6714
|
+
// were style-keyed still point at `idb://{deletedRegionId}/...`,
|
|
6715
|
+
// which no longer resolves once the region is gone.
|
|
6716
|
+
patchStyleForOffline(foundStyle.style, foundStyle.key, Math.max(...remainingRegions.map((r) => r.maxZoom)), undefined, foundStyle.key);
|
|
6717
|
+
restoreUnstoredModels(foundStyle, await storedModelNames(foundStyle.key));
|
|
5661
6718
|
// Update the style entry with the remaining regions
|
|
5662
6719
|
await db.put('styles', foundStyle);
|
|
5663
6720
|
regionLogger$1.info(`Region ${regionId} deleted successfully from style ${foundStyle.key}, cleaned up ${deletedTileCount} non-overlapping tiles`);
|
|
5664
6721
|
}
|
|
6722
|
+
clearAllCaches();
|
|
5665
6723
|
regionLogger$1.info(`Region deletion completed successfully for: ${regionId}`);
|
|
5666
6724
|
}
|
|
5667
6725
|
catch (error) {
|
|
@@ -5712,6 +6770,13 @@ async function loadAllStoredRegions() {
|
|
|
5712
6770
|
}
|
|
5713
6771
|
}
|
|
5714
6772
|
|
|
6773
|
+
var regionService = /*#__PURE__*/Object.freeze({
|
|
6774
|
+
__proto__: null,
|
|
6775
|
+
RegionService: RegionService,
|
|
6776
|
+
loadAllStoredRegions: loadAllStoredRegions,
|
|
6777
|
+
resourceKeyBelongsToStyle: resourceKeyBelongsToStyle
|
|
6778
|
+
});
|
|
6779
|
+
|
|
5715
6780
|
const cleanupLogger = logger.scope('CleanupService');
|
|
5716
6781
|
class CleanupService {
|
|
5717
6782
|
db = dbPromise;
|
|
@@ -5810,9 +6875,9 @@ class CleanupService {
|
|
|
5810
6875
|
let deletedCount = 0;
|
|
5811
6876
|
for (const region of uniqueRegionsToDelete) {
|
|
5812
6877
|
try {
|
|
5813
|
-
const
|
|
6878
|
+
const bytesBefore = await this.getStyleStoredBytes(region.styleId);
|
|
5814
6879
|
await this.deleteRegionCallback(region.id, region.styleId);
|
|
5815
|
-
result.freedSpace +=
|
|
6880
|
+
result.freedSpace += Math.max(0, bytesBefore - (await this.getStyleStoredBytes(region.styleId)));
|
|
5816
6881
|
deletedCount++;
|
|
5817
6882
|
onProgress?.({
|
|
5818
6883
|
phase: 'cleaning',
|
|
@@ -5876,7 +6941,7 @@ class CleanupService {
|
|
|
5876
6941
|
neverExpiring: 0,
|
|
5877
6942
|
};
|
|
5878
6943
|
for (const region of regions) {
|
|
5879
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
6944
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
5880
6945
|
totalSize += regionSize;
|
|
5881
6946
|
// Track by style
|
|
5882
6947
|
const styleId = region.styleId || 'unknown';
|
|
@@ -5978,8 +7043,8 @@ class CleanupService {
|
|
|
5978
7043
|
async getExpiredResourceCount() {
|
|
5979
7044
|
const db = await this.db;
|
|
5980
7045
|
const now = Date.now();
|
|
5981
|
-
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, total: 0 };
|
|
5982
|
-
const stores = ['tiles', 'fonts', 'sprites', 'glyphs'];
|
|
7046
|
+
const counts = { tiles: 0, fonts: 0, sprites: 0, glyphs: 0, models: 0, total: 0 };
|
|
7047
|
+
const stores = ['tiles', 'fonts', 'sprites', 'glyphs', 'models'];
|
|
5983
7048
|
for (const storeName of stores) {
|
|
5984
7049
|
try {
|
|
5985
7050
|
const tx = db.transaction([storeName], 'readonly');
|
|
@@ -6025,46 +7090,77 @@ class CleanupService {
|
|
|
6025
7090
|
async getAllRegions() {
|
|
6026
7091
|
return loadAllStoredRegions();
|
|
6027
7092
|
}
|
|
7093
|
+
/**
|
|
7094
|
+
* Bytes of the tiles stored for a region: tiles on its style inside its
|
|
7095
|
+
* bounds, at the zooms the download pipeline fetched for each source. Tiles
|
|
7096
|
+
* are stored per style, so regions that overlap share (and both count)
|
|
7097
|
+
* their common tiles; shared style resources are not included.
|
|
7098
|
+
*/
|
|
6028
7099
|
async getRegionSize(regionId, styleIdParam) {
|
|
6029
7100
|
const db = await this.db;
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
let
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
const found = styleEntry.regions.find(r => r.id === regionId);
|
|
6041
|
-
if (found) {
|
|
6042
|
-
styleId = styleEntry.key;
|
|
6043
|
-
regionMinZoom = found.minZoom ?? 0;
|
|
6044
|
-
regionMaxZoom = found.maxZoom ?? 22;
|
|
6045
|
-
break;
|
|
6046
|
-
}
|
|
6047
|
-
}
|
|
7101
|
+
const candidates = styleIdParam
|
|
7102
|
+
? [await db.get('styles', styleIdParam)]
|
|
7103
|
+
: await db.getAll('styles');
|
|
7104
|
+
let styleEntry;
|
|
7105
|
+
let region;
|
|
7106
|
+
for (const entry of candidates) {
|
|
7107
|
+
region = entry?.regions?.find((r) => r.id === regionId || r.regionId === regionId);
|
|
7108
|
+
if (region) {
|
|
7109
|
+
styleEntry = entry;
|
|
7110
|
+
break;
|
|
6048
7111
|
}
|
|
6049
7112
|
}
|
|
6050
|
-
if (!
|
|
7113
|
+
if (!styleEntry || !region) {
|
|
6051
7114
|
return 0;
|
|
6052
7115
|
}
|
|
6053
|
-
|
|
7116
|
+
const styleId = styleEntry.key;
|
|
7117
|
+
const sources = (styleEntry.style?.sources ?? {});
|
|
7118
|
+
let totalSize = 0;
|
|
6054
7119
|
const tx = db.transaction(['tiles'], 'readonly');
|
|
6055
7120
|
for await (const cursor of tx.objectStore('tiles')) {
|
|
6056
7121
|
const tile = cursor.value;
|
|
6057
7122
|
if (tile.styleId !== styleId)
|
|
6058
7123
|
continue;
|
|
6059
|
-
// Filter by zoom range when possible
|
|
6060
7124
|
const parsed = parseTileKey(tile.key);
|
|
6061
|
-
if (parsed
|
|
7125
|
+
if (!parsed)
|
|
6062
7126
|
continue;
|
|
7127
|
+
const coords = {
|
|
7128
|
+
sourceId: tile.sourceId ?? parsed.sourceId,
|
|
7129
|
+
z: parsed.z,
|
|
7130
|
+
x: parsed.x,
|
|
7131
|
+
y: parsed.y,
|
|
7132
|
+
};
|
|
7133
|
+
if (tileBelongsToRegion(coords, region, sources)) {
|
|
7134
|
+
totalSize += tile.size || 0;
|
|
6063
7135
|
}
|
|
6064
|
-
totalSize += tile.size || 0;
|
|
6065
7136
|
}
|
|
6066
7137
|
return totalSize;
|
|
6067
7138
|
}
|
|
7139
|
+
/**
|
|
7140
|
+
* Bytes stored for a style: its tiles plus the fonts, glyphs, sprites and
|
|
7141
|
+
* models keyed to it. Measured before and after a deletion to report the
|
|
7142
|
+
* space actually freed (a region's own size over-counts tiles it shares).
|
|
7143
|
+
*/
|
|
7144
|
+
async getStyleStoredBytes(styleId) {
|
|
7145
|
+
if (!styleId)
|
|
7146
|
+
return 0;
|
|
7147
|
+
const db = await this.db;
|
|
7148
|
+
let total = 0;
|
|
7149
|
+
const tx = db.transaction(['tiles'], 'readonly');
|
|
7150
|
+
for await (const cursor of tx.objectStore('tiles')) {
|
|
7151
|
+
if (cursor.value.styleId === styleId)
|
|
7152
|
+
total += cursor.value.size || 0;
|
|
7153
|
+
}
|
|
7154
|
+
for (const storeName of ['fonts', 'glyphs', 'sprites', 'models']) {
|
|
7155
|
+
const storeTx = db.transaction([storeName], 'readonly');
|
|
7156
|
+
for await (const cursor of storeTx.objectStore(storeName)) {
|
|
7157
|
+
const entry = cursor.value;
|
|
7158
|
+
if (resourceKeyBelongsToStyle(entry.key, styleId))
|
|
7159
|
+
total += entry.size || 0;
|
|
7160
|
+
}
|
|
7161
|
+
}
|
|
7162
|
+
return total;
|
|
7163
|
+
}
|
|
6068
7164
|
async calculateTotalStorageSize() {
|
|
6069
7165
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
6070
7166
|
const estimate = await navigator.storage.estimate();
|
|
@@ -6078,6 +7174,7 @@ class CleanupService {
|
|
|
6078
7174
|
'fonts',
|
|
6079
7175
|
'sprites',
|
|
6080
7176
|
'glyphs',
|
|
7177
|
+
'models',
|
|
6081
7178
|
'styles',
|
|
6082
7179
|
];
|
|
6083
7180
|
for (const storeName of stores) {
|
|
@@ -6119,7 +7216,7 @@ class CleanupService {
|
|
|
6119
7216
|
if (currentSize >= targetSize)
|
|
6120
7217
|
break;
|
|
6121
7218
|
selected.push(region);
|
|
6122
|
-
const regionSize = await this.getRegionSize(region.id);
|
|
7219
|
+
const regionSize = await this.getRegionSize(region.id, region.styleId);
|
|
6123
7220
|
currentSize += regionSize > 0 ? regionSize : 10 * 1024 * 1024; // Fallback to 10MB if size unknown
|
|
6124
7221
|
}
|
|
6125
7222
|
return selected;
|
|
@@ -6210,7 +7307,7 @@ class TileService {
|
|
|
6210
7307
|
*/
|
|
6211
7308
|
async downloadTiles(region, style, styleId, options = {}) {
|
|
6212
7309
|
const db = await this.db;
|
|
6213
|
-
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, } = options;
|
|
7310
|
+
const { onProgress, batchSize = 10, maxRetries = 3, skipExisting = true, timeout = 10000, retryDelay = 1000, priorityZoomLevels = [], storageQuotaCheck = true, validateTiles = false, compressTiles = false, bandwidthLimit, probeSourcesBeforeDownload = true, skipKnownSparseSources = true, skipInactiveSources = true, signal, } = options;
|
|
6214
7311
|
const startTime = Date.now();
|
|
6215
7312
|
let totalSize = 0;
|
|
6216
7313
|
let downloadedTiles = 0;
|
|
@@ -6251,31 +7348,31 @@ class TileService {
|
|
|
6251
7348
|
if (tileSources.size === 0) {
|
|
6252
7349
|
throw new Error('No valid tile sources found in style definition');
|
|
6253
7350
|
}
|
|
6254
|
-
//
|
|
6255
|
-
//
|
|
7351
|
+
// Features a config-driven style has switched off (Mapbox Standard's HD
|
|
7352
|
+
// roads, indoor, landmark icons...) don't need their tiles.
|
|
7353
|
+
const { active: activeSources, inactive: inactiveSources } = classifySources(style);
|
|
7354
|
+
if (skipInactiveSources) {
|
|
7355
|
+
for (const sourceId of inactiveSources) {
|
|
7356
|
+
if (tileSources.delete(sourceId)) {
|
|
7357
|
+
tileLogger.info(`Skipping source "${sourceId}" — nothing visible uses it under the style's config`);
|
|
7358
|
+
}
|
|
7359
|
+
}
|
|
7360
|
+
}
|
|
7361
|
+
// Some sources have zoom ranges entirely outside the user's region (e.g.
|
|
7362
|
+
// procedural-buildings at z15 when the user requested z0-z14, or a z14-capped
|
|
7363
|
+
// basemap for a z15-z17 region). Generate the extra coordinates they need so
|
|
6256
7364
|
// these sources aren't silently skipped.
|
|
7365
|
+
const generatedZooms = new Set();
|
|
7366
|
+
for (let z = region.minZoom; z <= region.maxZoom; z++)
|
|
7367
|
+
generatedZooms.add(z);
|
|
6257
7368
|
for (const [sourceId, sourceConfig] of tileSources) {
|
|
6258
|
-
const
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
if (extraMinZ !== null) {
|
|
6266
|
-
const upperBound = srcMax !== undefined ? srcMax : extraMinZ;
|
|
6267
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${extraMinZ}-${upperBound} beyond region max ${region.maxZoom}, generating extra tiles`);
|
|
6268
|
-
const extraRegion = { ...region, minZoom: extraMinZ, maxZoom: upperBound };
|
|
6269
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6270
|
-
tileCoords.push(...extraCoords);
|
|
6271
|
-
}
|
|
6272
|
-
// Extend downward (lower zoom) — sources that need zooms below region.minZoom
|
|
6273
|
-
if (extraMaxZ !== null) {
|
|
6274
|
-
const lowerBound = srcMin !== undefined ? srcMin : extraMaxZ;
|
|
6275
|
-
tileLogger.debug(`Source ${sourceId} needs zoom ${lowerBound}-${extraMaxZ} below region min ${region.minZoom}, generating extra tiles`);
|
|
6276
|
-
const extraRegion = { ...region, minZoom: lowerBound, maxZoom: extraMaxZ };
|
|
6277
|
-
const extraCoords = this.generateTileCoordinates(extraRegion);
|
|
6278
|
-
tileCoords.push(...extraCoords);
|
|
7369
|
+
const { min, max } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
7370
|
+
for (let z = min; z <= max; z++) {
|
|
7371
|
+
if (generatedZooms.has(z))
|
|
7372
|
+
continue;
|
|
7373
|
+
generatedZooms.add(z);
|
|
7374
|
+
tileLogger.debug(`Source ${sourceId} needs zoom ${z} outside region ${region.minZoom}-${region.maxZoom}, generating extra tiles`);
|
|
7375
|
+
tileCoords.push(...this.generateTileCoordinates({ ...region, minZoom: z, maxZoom: z }));
|
|
6279
7376
|
}
|
|
6280
7377
|
}
|
|
6281
7378
|
// Sort by priority zoom levels if requested (lower zoom first)
|
|
@@ -6318,14 +7415,16 @@ class TileService {
|
|
|
6318
7415
|
// issuing any network request. They 404 for nearly every coord in a
|
|
6319
7416
|
// typical region; the downstream probe step would catch them, but the
|
|
6320
7417
|
// probe itself logs 404s in devtools. Matching here keeps the console
|
|
6321
|
-
// clean.
|
|
6322
|
-
|
|
6323
|
-
|
|
7418
|
+
// clean. A sparse source a switched-on feature needs is kept instead, and
|
|
7419
|
+
// not probed, because its tiles cluster and the three probe tiles could
|
|
7420
|
+
// easily all miss.
|
|
7421
|
+
const knownSparse = tiles.some(template => urlReferencesKnownSparseTileset(template));
|
|
7422
|
+
const neededSparseSource = knownSparse && activeSources.has(sourceId);
|
|
7423
|
+
if (skipKnownSparseSources && knownSparse && !neededSparseSource) {
|
|
6324
7424
|
tileLogger.info(`Skipping source "${sourceId}" — references a known sparse Mapbox Standard sub-tileset (no probe/download issued)`);
|
|
6325
7425
|
continue;
|
|
6326
7426
|
}
|
|
6327
|
-
const sourceMinZ =
|
|
6328
|
-
const sourceMaxZ = Math.floor(sourceConfig.maxzoom ?? region.maxZoom);
|
|
7427
|
+
const { min: sourceMinZ, max: sourceMaxZ } = sourceZoomRange(region, sourceConfig.minzoom, sourceConfig.maxzoom);
|
|
6329
7428
|
let coordsForSource = tileCoords.filter(coord => coord.z >= sourceMinZ && coord.z <= sourceMaxZ);
|
|
6330
7429
|
tileLogger.debug(`After zoom filter (${sourceMinZ}-${sourceMaxZ}): ${coordsForSource.length} tiles`);
|
|
6331
7430
|
if (coordsForSource.length === 0) {
|
|
@@ -6359,7 +7458,7 @@ class TileService {
|
|
|
6359
7458
|
// A single probe on the wrong coord would false-pass and cause a
|
|
6360
7459
|
// flood of 404s during the download. Probing start/middle/end of
|
|
6361
7460
|
// the plan catches this case.
|
|
6362
|
-
if (probeSourcesBeforeDownload) {
|
|
7461
|
+
if (probeSourcesBeforeDownload && !neededSparseSource) {
|
|
6363
7462
|
const picks = [
|
|
6364
7463
|
coordsForSource[0],
|
|
6365
7464
|
coordsForSource[Math.floor(coordsForSource.length / 2)],
|
|
@@ -6418,6 +7517,9 @@ class TileService {
|
|
|
6418
7517
|
.map(([z, count]) => `Z${z}: ${count} tiles`)
|
|
6419
7518
|
.join(', '));
|
|
6420
7519
|
await processBatch(plan.coords, async (coord) => {
|
|
7520
|
+
// Cancelled: let the remaining tiles go by without fetching them.
|
|
7521
|
+
if (signal?.aborted)
|
|
7522
|
+
return;
|
|
6421
7523
|
const { x, y, z } = coord;
|
|
6422
7524
|
const label = `${plan.sourceId}:${z}/${x}/${y}`;
|
|
6423
7525
|
let tileUrl = '';
|
|
@@ -6527,7 +7629,9 @@ class TileService {
|
|
|
6527
7629
|
const errorObject = _error;
|
|
6528
7630
|
errorMessage = errorObject instanceof Error ? errorObject.message : String(errorObject);
|
|
6529
7631
|
// 404s on sparse tilesets (landmarks, POIs) are expected — don't count as failures
|
|
6530
|
-
|
|
7632
|
+
// Match the start only: the message goes on to include the URL, whose
|
|
7633
|
+
// coordinates or token can contain "404".
|
|
7634
|
+
const is404 = /^(Resource not found \(404\)|HTTP 404\b)/.test(errorMessage);
|
|
6531
7635
|
const isExpected = errorMessage.includes('NonRetryableError');
|
|
6532
7636
|
if (is404) {
|
|
6533
7637
|
skippedTiles++;
|
|
@@ -6705,13 +7809,7 @@ class TileService {
|
|
|
6705
7809
|
tileLogger.debug(`Approximate area: ${areaApproxKm2.toFixed(2)} km² (improved calculation)`);
|
|
6706
7810
|
tileLogger.debug(`Region dimensions: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
|
|
6707
7811
|
for (let z = region.minZoom; z <= region.maxZoom; z++) {
|
|
6708
|
-
const
|
|
6709
|
-
const minTile = tilebelt.pointToTile(bounds[0][0], bounds[0][1], z);
|
|
6710
|
-
const maxTile = tilebelt.pointToTile(bounds[1][0], bounds[1][1], z);
|
|
6711
|
-
const minX = Math.min(minTile[0], maxTile[0]);
|
|
6712
|
-
const maxX = Math.max(minTile[0], maxTile[0]);
|
|
6713
|
-
const minY = Math.min(minTile[1], maxTile[1]);
|
|
6714
|
-
const maxY = Math.max(minTile[1], maxTile[1]);
|
|
7812
|
+
const { minX, maxX, minY, maxY } = getTileRangeAtZoom(region.bounds, z);
|
|
6715
7813
|
const tilesAtZoom = (maxX - minX + 1) * (maxY - minY + 1);
|
|
6716
7814
|
tilesByZoom[z] = tilesAtZoom;
|
|
6717
7815
|
tileLogger.debug(`Zoom ${z}: ${tilesAtZoom} tiles (X: ${minX}-${maxX}, Y: ${minY}-${maxY})`);
|
|
@@ -6745,7 +7843,10 @@ class TileService {
|
|
|
6745
7843
|
}
|
|
6746
7844
|
tileLogger.debug('Processing sources in extractTileSources:', Object.keys(style.sources));
|
|
6747
7845
|
for (const [sourceId, sourceConfig] of Object.entries(style.sources)) {
|
|
6748
|
-
|
|
7846
|
+
// Shallow copy: the maxzoom override below must not mutate the caller's style.
|
|
7847
|
+
const config = {
|
|
7848
|
+
...sourceConfig,
|
|
7849
|
+
};
|
|
6749
7850
|
tileLogger.debug(`Source ${sourceId} RAW DATA:`, JSON.stringify(config, null, 2));
|
|
6750
7851
|
tileLogger.debug(`Processing source ${sourceId}:`, {
|
|
6751
7852
|
type: config.type,
|
|
@@ -6756,18 +7857,26 @@ class TileService {
|
|
|
6756
7857
|
});
|
|
6757
7858
|
// Handle tile-based sources (vector, raster, raster-dem, batched-model,
|
|
6758
7859
|
// raster-array). `raster-array` is used by Mapbox Standard for layers
|
|
6759
|
-
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1)
|
|
6760
|
-
//
|
|
6761
|
-
// TileJSON
|
|
7860
|
+
// like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1). Each type has
|
|
7861
|
+
// its own tile endpoint (/v4/, /raster/v1/, /rasterarrays/v1/, /3dtiles/v1/),
|
|
7862
|
+
// but TileJSON gives the URL for all of them, so the resolution path below
|
|
7863
|
+
// handles them uniformly.
|
|
6762
7864
|
if (config.type === 'vector' ||
|
|
6763
7865
|
config.type === 'raster' ||
|
|
6764
7866
|
config.type === 'raster-dem' ||
|
|
6765
7867
|
config.type === 'raster-array' ||
|
|
6766
7868
|
config.type === 'batched-model') {
|
|
7869
|
+
// A style already patched for offline (every region after the first)
|
|
7870
|
+
// has idb:// tiles and a maxzoom capped to earlier regions; plan from
|
|
7871
|
+
// the upstream values patchStyleForOffline stashed instead.
|
|
7872
|
+
const sourceTiles = config.__originalTiles ?? config.tiles;
|
|
7873
|
+
if (config.__originalMaxzoom !== undefined) {
|
|
7874
|
+
config.maxzoom = config.__originalMaxzoom ?? undefined;
|
|
7875
|
+
}
|
|
6767
7876
|
// Handle direct tile URLs in the source config
|
|
6768
|
-
if (
|
|
7877
|
+
if (sourceTiles && Array.isArray(sourceTiles) && sourceTiles.length > 0) {
|
|
6769
7878
|
// Resolve mapbox:// tile URLs to HTTPS, then filter for HTTP(S) URLs
|
|
6770
|
-
const resolvedTiles =
|
|
7879
|
+
const resolvedTiles = sourceTiles.map((tile) => {
|
|
6771
7880
|
if (isMapboxProtocol(tile)) {
|
|
6772
7881
|
// Try to find an access token from the style or source URL
|
|
6773
7882
|
const accessToken = this.extractAccessTokenFromStyle(style);
|
|
@@ -6839,7 +7948,9 @@ class TileService {
|
|
|
6839
7948
|
});
|
|
6840
7949
|
tileLogger.debug(`TileJSON fetch response type: ${response.type}`);
|
|
6841
7950
|
if (response.type === 'json') {
|
|
6842
|
-
const jsonData = response.data
|
|
7951
|
+
const jsonData = response.data
|
|
7952
|
+
? selectTileJsonVariant(response.data)
|
|
7953
|
+
: null;
|
|
6843
7954
|
tileLogger.debug(`TileJSON data keys: ${Object.keys(jsonData || {}).join(', ')}`);
|
|
6844
7955
|
if (jsonData &&
|
|
6845
7956
|
typeof jsonData === 'object' &&
|
|
@@ -6943,16 +8054,9 @@ class TileService {
|
|
|
6943
8054
|
}
|
|
6944
8055
|
}
|
|
6945
8056
|
if (tileSources.size === 0) {
|
|
8057
|
+
// No fabricated fallback: a relative '{z}/{x}/{y}.pbf' template resolves
|
|
8058
|
+
// against the page and stores whatever the app server returns as tiles.
|
|
6946
8059
|
tileLogger.warn('No valid tile sources found in style', Object.keys(style.sources));
|
|
6947
|
-
// As a last resort, try to use a common vector tile source pattern if we can't extract any
|
|
6948
|
-
if (style.sources && Object.keys(style.sources).length > 0) {
|
|
6949
|
-
const firstSourceId = Object.keys(style.sources)[0];
|
|
6950
|
-
tileLogger.debug(`Attempting to create fallback source from ${firstSourceId}`);
|
|
6951
|
-
tileSources.set(firstSourceId, {
|
|
6952
|
-
type: 'vector',
|
|
6953
|
-
tiles: ['{z}/{x}/{y}.pbf'],
|
|
6954
|
-
});
|
|
6955
|
-
}
|
|
6956
8060
|
}
|
|
6957
8061
|
return tileSources;
|
|
6958
8062
|
}
|
|
@@ -7110,7 +8214,7 @@ class GlyphService {
|
|
|
7110
8214
|
db = dbPromise;
|
|
7111
8215
|
async downloadGlyphs(glyphUrl, fontstacks, styleName, ranges = ['0-255'], options = {}) {
|
|
7112
8216
|
const db = await this.db;
|
|
7113
|
-
const { maxConcurrency = 5, retries = 3, timeout = 10000, onProgress, enableValidation = true, priorityFonts = [], } = options;
|
|
8217
|
+
const { maxConcurrency = 5, retries = 3, timeout = 10000, onProgress, enableValidation = true, priorityFonts = [], signal, } = options;
|
|
7114
8218
|
const startTime = Date.now();
|
|
7115
8219
|
let totalSize = 0;
|
|
7116
8220
|
let downloadedGlyphs = 0;
|
|
@@ -7142,7 +8246,8 @@ class GlyphService {
|
|
|
7142
8246
|
// Process glyphs with concurrency control
|
|
7143
8247
|
const semaphore = new Array(maxConcurrency).fill(0);
|
|
7144
8248
|
const promises = semaphore.map(async () => {
|
|
7145
|
-
|
|
8249
|
+
// Once cancelled, workers stop taking tasks; those in flight finish.
|
|
8250
|
+
while (glyphTasks.length > 0 && !signal?.aborted) {
|
|
7146
8251
|
const task = glyphTasks.shift();
|
|
7147
8252
|
if (!task)
|
|
7148
8253
|
break;
|
|
@@ -7362,339 +8467,144 @@ class GlyphService {
|
|
|
7362
8467
|
corruptionRate: stats.count > 0 ? (stats.corruptedGlyphs.length / stats.count) * 100 : 0,
|
|
7363
8468
|
},
|
|
7364
8469
|
temporal: {
|
|
7365
|
-
oldestGlyph: stats.oldestGlyph,
|
|
7366
|
-
newestGlyph: stats.newestGlyph,
|
|
7367
|
-
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
7368
|
-
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
7369
|
-
: 0,
|
|
7370
|
-
},
|
|
7371
|
-
};
|
|
7372
|
-
}
|
|
7373
|
-
/**
|
|
7374
|
-
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
7375
|
-
* provided, only glyphs belonging to that style (per
|
|
7376
|
-
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7377
|
-
*/
|
|
7378
|
-
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7379
|
-
const db = await this.db;
|
|
7380
|
-
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
7381
|
-
const { styleId } = options;
|
|
7382
|
-
let deletedCount = 0;
|
|
7383
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7384
|
-
for await (const cursor of tx.store) {
|
|
7385
|
-
const glyphEntry = cursor.value;
|
|
7386
|
-
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
7387
|
-
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7388
|
-
await cursor.delete();
|
|
7389
|
-
deletedCount++;
|
|
7390
|
-
}
|
|
7391
|
-
}
|
|
7392
|
-
return deletedCount;
|
|
7393
|
-
}
|
|
7394
|
-
async verifyAndRepairGlyphs() {
|
|
7395
|
-
const db = await this.db;
|
|
7396
|
-
let verified = 0;
|
|
7397
|
-
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
7398
|
-
let repaired = 0;
|
|
7399
|
-
let removed = 0;
|
|
7400
|
-
const tx = db.transaction('glyphs', 'readwrite');
|
|
7401
|
-
for await (const cursor of tx.store) {
|
|
7402
|
-
const glyphEntry = cursor.value;
|
|
7403
|
-
try {
|
|
7404
|
-
// Basic validation
|
|
7405
|
-
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
7406
|
-
await cursor.delete();
|
|
7407
|
-
removed++;
|
|
7408
|
-
}
|
|
7409
|
-
else {
|
|
7410
|
-
this.validateGlyphData(glyphEntry.data);
|
|
7411
|
-
verified++;
|
|
7412
|
-
}
|
|
7413
|
-
}
|
|
7414
|
-
catch {
|
|
7415
|
-
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7416
|
-
await cursor.delete();
|
|
7417
|
-
removed++;
|
|
7418
|
-
}
|
|
7419
|
-
}
|
|
7420
|
-
return { verified, repaired, removed };
|
|
7421
|
-
}
|
|
7422
|
-
validateGlyphData(data) {
|
|
7423
|
-
if (data.byteLength < 10) {
|
|
7424
|
-
throw new Error('Glyph data too short');
|
|
7425
|
-
}
|
|
7426
|
-
// Basic Protocol Buffer validation
|
|
7427
|
-
const view = new Uint8Array(data);
|
|
7428
|
-
// Check for valid protobuf structure (basic check)
|
|
7429
|
-
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
7430
|
-
throw new Error('Invalid glyph data - appears to be empty');
|
|
7431
|
-
}
|
|
7432
|
-
}
|
|
7433
|
-
normalizeFontstack(fontstack) {
|
|
7434
|
-
try {
|
|
7435
|
-
return decodeURIComponent(fontstack);
|
|
7436
|
-
}
|
|
7437
|
-
catch {
|
|
7438
|
-
return fontstack;
|
|
7439
|
-
}
|
|
7440
|
-
}
|
|
7441
|
-
buildGlyphPath(fontstack, range) {
|
|
7442
|
-
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
7443
|
-
return `${normalizedFontstack}/${range}.pbf`;
|
|
7444
|
-
}
|
|
7445
|
-
parseGlyphKey(key) {
|
|
7446
|
-
let remaining = key;
|
|
7447
|
-
let styleId;
|
|
7448
|
-
if (remaining.includes('::')) {
|
|
7449
|
-
const [prefix, ...rest] = remaining.split('::');
|
|
7450
|
-
styleId = prefix;
|
|
7451
|
-
remaining = rest.join('::');
|
|
7452
|
-
}
|
|
7453
|
-
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
7454
|
-
const [prefix, rest] = remaining.split(':');
|
|
7455
|
-
styleId = styleId ?? prefix;
|
|
7456
|
-
remaining = rest;
|
|
7457
|
-
}
|
|
7458
|
-
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
7459
|
-
if (trimmed.includes('/')) {
|
|
7460
|
-
const [fontstack, range] = trimmed.split('/');
|
|
7461
|
-
if (fontstack && range) {
|
|
7462
|
-
return { styleId, fontstack, range };
|
|
7463
|
-
}
|
|
7464
|
-
}
|
|
7465
|
-
if (trimmed.includes('_')) {
|
|
7466
|
-
const [fontstack, range] = trimmed.split('_');
|
|
7467
|
-
if (fontstack && range) {
|
|
7468
|
-
return { styleId, fontstack, range };
|
|
7469
|
-
}
|
|
7470
|
-
}
|
|
7471
|
-
return { styleId, fontstack: trimmed, range: '' };
|
|
7472
|
-
}
|
|
7473
|
-
createGlyphKey(fontstack, range, styleName) {
|
|
7474
|
-
// Create a consistent key from the style name, fontstack and range
|
|
7475
|
-
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
7476
|
-
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
7477
|
-
if (styleName) {
|
|
7478
|
-
return `${styleName}::${glyphPath}`;
|
|
7479
|
-
}
|
|
7480
|
-
// Fallback to original format for backward compatibility
|
|
7481
|
-
return glyphPath;
|
|
7482
|
-
}
|
|
7483
|
-
}
|
|
7484
|
-
// Export singleton instance and functions for backward compatibility
|
|
7485
|
-
const glyphService = new GlyphService();
|
|
7486
|
-
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
7487
|
-
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
7488
|
-
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
7489
|
-
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
7490
|
-
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
7491
|
-
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7492
|
-
|
|
7493
|
-
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7494
|
-
__proto__: null,
|
|
7495
|
-
GlyphService: GlyphService,
|
|
7496
|
-
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
7497
|
-
downloadGlyphs: downloadGlyphs,
|
|
7498
|
-
getGlyphAnalytics: getGlyphAnalytics,
|
|
7499
|
-
getGlyphStats: getGlyphStats,
|
|
7500
|
-
glyphService: glyphService,
|
|
7501
|
-
loadGlyphs: loadGlyphs,
|
|
7502
|
-
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7503
|
-
});
|
|
7504
|
-
|
|
7505
|
-
const modelLogger = logger.scope('ModelService');
|
|
7506
|
-
/**
|
|
7507
|
-
* Build the storage key for a model. Kept consistent with sprite/glyph
|
|
7508
|
-
* conventions: `{styleId}::model::{modelName}`.
|
|
7509
|
-
*/
|
|
7510
|
-
function modelKey(styleId, modelName) {
|
|
7511
|
-
return `${styleId}::model::${modelName}`;
|
|
7512
|
-
}
|
|
7513
|
-
/** True when the given key belongs to the given styleId's model store prefix. */
|
|
7514
|
-
function modelKeyBelongsToStyle(key, styleId) {
|
|
7515
|
-
return key.startsWith(`${styleId}::model::`);
|
|
7516
|
-
}
|
|
7517
|
-
/**
|
|
7518
|
-
* Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
|
|
7519
|
-
*
|
|
7520
|
-
* Mapbox Standard declares 32 models at the top of `style.models`:
|
|
7521
|
-
*
|
|
7522
|
-
* ```json
|
|
7523
|
-
* {
|
|
7524
|
-
* "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
|
|
7525
|
-
* ...
|
|
7526
|
-
* }
|
|
7527
|
-
* ```
|
|
7528
|
-
*
|
|
7529
|
-
* `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
|
|
7530
|
-
* render time. For offline use each referenced URL is fetched and stored
|
|
7531
|
-
* here, and `patchStyleForOffline` rewrites the dictionary entries to
|
|
7532
|
-
* `idb://{styleId}/model/{name}` URLs.
|
|
7533
|
-
*/
|
|
7534
|
-
class ModelService {
|
|
7535
|
-
db = dbPromise;
|
|
7536
|
-
/**
|
|
7537
|
-
* Download the set of models referenced by `style.models` for one style.
|
|
7538
|
-
*
|
|
7539
|
-
* @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
|
|
7540
|
-
* resolved (mapbox:// URLs should be resolved by the caller).
|
|
7541
|
-
* @param styleId The owning style's key.
|
|
7542
|
-
*/
|
|
7543
|
-
async downloadModels(models, styleId, options = {}) {
|
|
7544
|
-
const db = await this.db;
|
|
7545
|
-
const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
|
|
7546
|
-
const entries = Object.entries(models);
|
|
7547
|
-
const progressTracker = createProgressTracker(entries.length);
|
|
7548
|
-
const result = {
|
|
7549
|
-
totalModels: entries.length,
|
|
7550
|
-
downloadedModels: 0,
|
|
7551
|
-
skippedModels: 0,
|
|
7552
|
-
failedModels: 0,
|
|
7553
|
-
totalSize: 0,
|
|
7554
|
-
errors: [],
|
|
7555
|
-
};
|
|
7556
|
-
const emit = () => onProgress?.(progressTracker.getProgress());
|
|
7557
|
-
emit();
|
|
7558
|
-
if (entries.length === 0)
|
|
7559
|
-
return result;
|
|
7560
|
-
// Pre-compute existing keys for skipExisting
|
|
7561
|
-
const existingKeys = new Set();
|
|
7562
|
-
if (skipExisting) {
|
|
7563
|
-
const tx = db.transaction('models', 'readonly');
|
|
7564
|
-
for await (const cursor of tx.store) {
|
|
7565
|
-
existingKeys.add(cursor.value.key);
|
|
7566
|
-
}
|
|
7567
|
-
}
|
|
7568
|
-
await processBatch(entries, async ([modelName, url]) => {
|
|
7569
|
-
const key = modelKey(styleId, modelName);
|
|
7570
|
-
const label = `${styleId}::${modelName}`;
|
|
7571
|
-
if (skipExisting && existingKeys.has(key)) {
|
|
7572
|
-
result.skippedModels++;
|
|
7573
|
-
progressTracker.update(1, label);
|
|
7574
|
-
emit();
|
|
7575
|
-
return;
|
|
7576
|
-
}
|
|
7577
|
-
try {
|
|
7578
|
-
const response = await fetchResourceWithRetry(url, {
|
|
7579
|
-
retries: maxRetries,
|
|
7580
|
-
timeout: timeoutMs,
|
|
7581
|
-
proxyType: 'tiles',
|
|
7582
|
-
});
|
|
7583
|
-
if (response.type === 'json') {
|
|
7584
|
-
throw new Error('Unexpected JSON response for model');
|
|
7585
|
-
}
|
|
7586
|
-
const data = response.data;
|
|
7587
|
-
const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
|
|
7588
|
-
const entry = {
|
|
7589
|
-
key,
|
|
7590
|
-
data,
|
|
7591
|
-
contentType,
|
|
7592
|
-
size: data.byteLength,
|
|
7593
|
-
url,
|
|
7594
|
-
styleId,
|
|
7595
|
-
modelName,
|
|
7596
|
-
lastModified: Date.now(),
|
|
7597
|
-
downloadedAt: new Date().toISOString(),
|
|
7598
|
-
expires: response.expires,
|
|
7599
|
-
};
|
|
7600
|
-
await db.put('models', entry);
|
|
7601
|
-
result.downloadedModels++;
|
|
7602
|
-
result.totalSize += data.byteLength;
|
|
7603
|
-
progressTracker.update(1, label);
|
|
7604
|
-
}
|
|
7605
|
-
catch (err) {
|
|
7606
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7607
|
-
result.failedModels++;
|
|
7608
|
-
result.errors.push({ url, error: message });
|
|
7609
|
-
modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
|
|
7610
|
-
progressTracker.update(1, label, message);
|
|
7611
|
-
}
|
|
7612
|
-
emit();
|
|
7613
|
-
}, { batchSize });
|
|
7614
|
-
modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
|
|
7615
|
-
return result;
|
|
7616
|
-
}
|
|
7617
|
-
/** Retrieve a single model by `{styleId, modelName}`. */
|
|
7618
|
-
async getModel(styleId, modelName) {
|
|
7619
|
-
const db = await this.db;
|
|
7620
|
-
return db.get('models', modelKey(styleId, modelName));
|
|
7621
|
-
}
|
|
7622
|
-
/** Aggregate stats across all stored models. */
|
|
7623
|
-
async getModelStats() {
|
|
7624
|
-
const db = await this.db;
|
|
7625
|
-
const stats = {
|
|
7626
|
-
count: 0,
|
|
7627
|
-
totalSize: 0,
|
|
7628
|
-
averageSize: 0,
|
|
7629
|
-
models: [],
|
|
7630
|
-
modelsByStyle: {},
|
|
8470
|
+
oldestGlyph: stats.oldestGlyph,
|
|
8471
|
+
newestGlyph: stats.newestGlyph,
|
|
8472
|
+
ageSpan: stats.oldestGlyph && stats.newestGlyph
|
|
8473
|
+
? stats.newestGlyph.lastModified - stats.oldestGlyph.lastModified
|
|
8474
|
+
: 0,
|
|
8475
|
+
},
|
|
7631
8476
|
};
|
|
7632
|
-
const tx = db.transaction('models', 'readonly');
|
|
7633
|
-
for await (const cursor of tx.store) {
|
|
7634
|
-
const m = cursor.value;
|
|
7635
|
-
stats.count++;
|
|
7636
|
-
stats.totalSize += m.size;
|
|
7637
|
-
stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
|
|
7638
|
-
stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
|
|
7639
|
-
}
|
|
7640
|
-
stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
|
|
7641
|
-
return stats;
|
|
7642
8477
|
}
|
|
7643
8478
|
/**
|
|
7644
|
-
*
|
|
8479
|
+
* Remove glyphs older than the specified age. When `options.styleId` is
|
|
8480
|
+
* provided, only glyphs belonging to that style (per
|
|
8481
|
+
* `resourceKeyBelongsToStyle`) are eligible.
|
|
7645
8482
|
*/
|
|
7646
|
-
async
|
|
8483
|
+
async cleanupOldGlyphs(maxAge = 30, options = {}) {
|
|
7647
8484
|
const db = await this.db;
|
|
7648
|
-
const
|
|
7649
|
-
|
|
7650
|
-
|
|
8485
|
+
const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
|
|
8486
|
+
const { styleId } = options;
|
|
8487
|
+
let deletedCount = 0;
|
|
8488
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7651
8489
|
for await (const cursor of tx.store) {
|
|
7652
|
-
|
|
8490
|
+
const glyphEntry = cursor.value;
|
|
8491
|
+
const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
|
|
8492
|
+
if (belongs && glyphEntry.lastModified < cutoffTime) {
|
|
7653
8493
|
await cursor.delete();
|
|
7654
|
-
|
|
8494
|
+
deletedCount++;
|
|
7655
8495
|
}
|
|
7656
8496
|
}
|
|
7657
|
-
return
|
|
8497
|
+
return deletedCount;
|
|
7658
8498
|
}
|
|
7659
|
-
|
|
7660
|
-
* Basic integrity check: remove entries with empty/missing data.
|
|
7661
|
-
*/
|
|
7662
|
-
async verifyAndRepairModels() {
|
|
8499
|
+
async verifyAndRepairGlyphs() {
|
|
7663
8500
|
const db = await this.db;
|
|
7664
8501
|
let verified = 0;
|
|
8502
|
+
// eslint-disable-next-line prefer-const -- will be incremented when repair logic is added
|
|
8503
|
+
let repaired = 0;
|
|
7665
8504
|
let removed = 0;
|
|
7666
|
-
const tx = db.transaction('
|
|
8505
|
+
const tx = db.transaction('glyphs', 'readwrite');
|
|
7667
8506
|
for await (const cursor of tx.store) {
|
|
7668
|
-
const
|
|
7669
|
-
|
|
8507
|
+
const glyphEntry = cursor.value;
|
|
8508
|
+
try {
|
|
8509
|
+
// Basic validation
|
|
8510
|
+
if (!glyphEntry.data || glyphEntry.size === 0) {
|
|
8511
|
+
await cursor.delete();
|
|
8512
|
+
removed++;
|
|
8513
|
+
}
|
|
8514
|
+
else {
|
|
8515
|
+
this.validateGlyphData(glyphEntry.data);
|
|
8516
|
+
verified++;
|
|
8517
|
+
}
|
|
8518
|
+
}
|
|
8519
|
+
catch {
|
|
8520
|
+
// Remove corrupted glyphs (they can be re-downloaded)
|
|
7670
8521
|
await cursor.delete();
|
|
7671
8522
|
removed++;
|
|
7672
8523
|
}
|
|
7673
|
-
|
|
7674
|
-
|
|
8524
|
+
}
|
|
8525
|
+
return { verified, repaired, removed };
|
|
8526
|
+
}
|
|
8527
|
+
validateGlyphData(data) {
|
|
8528
|
+
if (data.byteLength < 10) {
|
|
8529
|
+
throw new Error('Glyph data too short');
|
|
8530
|
+
}
|
|
8531
|
+
// Basic Protocol Buffer validation
|
|
8532
|
+
const view = new Uint8Array(data);
|
|
8533
|
+
// Check for valid protobuf structure (basic check)
|
|
8534
|
+
if (view[0] === 0x00 && view[1] === 0x00 && view[2] === 0x00 && view[3] === 0x00) {
|
|
8535
|
+
throw new Error('Invalid glyph data - appears to be empty');
|
|
8536
|
+
}
|
|
8537
|
+
}
|
|
8538
|
+
normalizeFontstack(fontstack) {
|
|
8539
|
+
try {
|
|
8540
|
+
return decodeURIComponent(fontstack);
|
|
8541
|
+
}
|
|
8542
|
+
catch {
|
|
8543
|
+
return fontstack;
|
|
8544
|
+
}
|
|
8545
|
+
}
|
|
8546
|
+
buildGlyphPath(fontstack, range) {
|
|
8547
|
+
const normalizedFontstack = this.normalizeFontstack(fontstack);
|
|
8548
|
+
return `${normalizedFontstack}/${range}.pbf`;
|
|
8549
|
+
}
|
|
8550
|
+
parseGlyphKey(key) {
|
|
8551
|
+
let remaining = key;
|
|
8552
|
+
let styleId;
|
|
8553
|
+
if (remaining.includes('::')) {
|
|
8554
|
+
const [prefix, ...rest] = remaining.split('::');
|
|
8555
|
+
styleId = prefix;
|
|
8556
|
+
remaining = rest.join('::');
|
|
8557
|
+
}
|
|
8558
|
+
else if (remaining.includes(':') && !remaining.includes('/')) {
|
|
8559
|
+
const [prefix, rest] = remaining.split(':');
|
|
8560
|
+
styleId = styleId ?? prefix;
|
|
8561
|
+
remaining = rest;
|
|
8562
|
+
}
|
|
8563
|
+
const trimmed = remaining.endsWith('.pbf') ? remaining.slice(0, -4) : remaining;
|
|
8564
|
+
if (trimmed.includes('/')) {
|
|
8565
|
+
const [fontstack, range] = trimmed.split('/');
|
|
8566
|
+
if (fontstack && range) {
|
|
8567
|
+
return { styleId, fontstack, range };
|
|
7675
8568
|
}
|
|
7676
8569
|
}
|
|
7677
|
-
|
|
8570
|
+
if (trimmed.includes('_')) {
|
|
8571
|
+
const [fontstack, range] = trimmed.split('_');
|
|
8572
|
+
if (fontstack && range) {
|
|
8573
|
+
return { styleId, fontstack, range };
|
|
8574
|
+
}
|
|
8575
|
+
}
|
|
8576
|
+
return { styleId, fontstack: trimmed, range: '' };
|
|
8577
|
+
}
|
|
8578
|
+
createGlyphKey(fontstack, range, styleName) {
|
|
8579
|
+
// Create a consistent key from the style name, fontstack and range
|
|
8580
|
+
// Format: stylename::fontstack/range.pbf (if styleName provided) or fontstack/range.pbf
|
|
8581
|
+
const glyphPath = this.buildGlyphPath(fontstack, range);
|
|
8582
|
+
if (styleName) {
|
|
8583
|
+
return `${styleName}::${glyphPath}`;
|
|
8584
|
+
}
|
|
8585
|
+
// Fallback to original format for backward compatibility
|
|
8586
|
+
return glyphPath;
|
|
7678
8587
|
}
|
|
7679
8588
|
}
|
|
7680
|
-
//
|
|
7681
|
-
const
|
|
7682
|
-
const
|
|
7683
|
-
const
|
|
7684
|
-
const
|
|
7685
|
-
const
|
|
7686
|
-
const
|
|
8589
|
+
// Export singleton instance and functions for backward compatibility
|
|
8590
|
+
const glyphService = new GlyphService();
|
|
8591
|
+
const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => glyphService.downloadGlyphs(glyphUrl, fontstacks, styleName, ranges, options);
|
|
8592
|
+
const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
|
|
8593
|
+
const getGlyphStats = () => glyphService.getGlyphStats();
|
|
8594
|
+
const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
|
|
8595
|
+
const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
|
|
8596
|
+
const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
|
|
7687
8597
|
|
|
7688
|
-
var
|
|
8598
|
+
var glyphService$1 = /*#__PURE__*/Object.freeze({
|
|
7689
8599
|
__proto__: null,
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
8600
|
+
GlyphService: GlyphService,
|
|
8601
|
+
cleanupOldGlyphs: cleanupOldGlyphs,
|
|
8602
|
+
downloadGlyphs: downloadGlyphs,
|
|
8603
|
+
getGlyphAnalytics: getGlyphAnalytics,
|
|
8604
|
+
getGlyphStats: getGlyphStats,
|
|
8605
|
+
glyphService: glyphService,
|
|
8606
|
+
loadGlyphs: loadGlyphs,
|
|
8607
|
+
verifyAndRepairGlyphs: verifyAndRepairGlyphs
|
|
7698
8608
|
});
|
|
7699
8609
|
|
|
7700
8610
|
class ResourceService {
|
|
@@ -7794,23 +8704,26 @@ class AnalyticsService {
|
|
|
7794
8704
|
return getGlyphStats();
|
|
7795
8705
|
}
|
|
7796
8706
|
async getComprehensiveStorageAnalytics(getRegionAnalytics) {
|
|
7797
|
-
const [tileStats, fontStats, spriteStats, glyphStats, regionAnalytics] = await Promise.all([
|
|
8707
|
+
const [tileStats, fontStats, spriteStats, glyphStats, modelStats, styleStats, regionAnalytics] = await Promise.all([
|
|
7798
8708
|
this.getAllTileStats(),
|
|
7799
8709
|
this.getAllFontStats(),
|
|
7800
8710
|
this.getAllSpriteStats(),
|
|
7801
8711
|
this.getAllGlyphStats(),
|
|
8712
|
+
getModelStats(),
|
|
8713
|
+
getStyleStats(),
|
|
7802
8714
|
getRegionAnalytics(),
|
|
7803
8715
|
]);
|
|
7804
|
-
const totalStorageSize = (tileStats.totalSize || 0) +
|
|
7805
|
-
(fontStats.totalSize || 0) +
|
|
7806
|
-
(spriteStats.totalSize || 0) +
|
|
7807
|
-
(glyphStats.totalSize || 0);
|
|
7808
8716
|
const storageByType = {
|
|
8717
|
+
// The style documents themselves, which the panel's per-style totals include.
|
|
8718
|
+
styles: styleStats.totalSize || 0,
|
|
7809
8719
|
tiles: tileStats.totalSize || 0,
|
|
7810
8720
|
fonts: fontStats.totalSize || 0,
|
|
7811
8721
|
sprites: spriteStats.totalSize || 0,
|
|
7812
8722
|
glyphs: glyphStats.totalSize || 0,
|
|
8723
|
+
// Mapbox Standard 3D models (.glb) — often several MB each.
|
|
8724
|
+
models: modelStats.totalSize || 0,
|
|
7813
8725
|
};
|
|
8726
|
+
const totalStorageSize = Object.values(storageByType).reduce((sum, size) => sum + size, 0);
|
|
7814
8727
|
const recommendations = this.generateStorageRecommendations({
|
|
7815
8728
|
tiles: tileStats,
|
|
7816
8729
|
fonts: fontStats,
|
|
@@ -7824,6 +8737,7 @@ class AnalyticsService {
|
|
|
7824
8737
|
fonts: fontStats,
|
|
7825
8738
|
sprites: spriteStats,
|
|
7826
8739
|
glyphs: glyphStats,
|
|
8740
|
+
models: modelStats,
|
|
7827
8741
|
regions: regionAnalytics,
|
|
7828
8742
|
totalStorageSize,
|
|
7829
8743
|
storageByType,
|
|
@@ -7955,6 +8869,53 @@ function buildVectorJsonMetadata(style, sourceIds) {
|
|
|
7955
8869
|
return JSON.stringify({ vector_layers: merged });
|
|
7956
8870
|
}
|
|
7957
8871
|
const serviceLogger = logger.scope('ImportExportService');
|
|
8872
|
+
/** Map stored tile extensions to MBTiles 1.3 `format` values (`pbf`, `jpg`, ...). */
|
|
8873
|
+
function normalizeMBTilesFormat(ext) {
|
|
8874
|
+
const lower = ext.toLowerCase();
|
|
8875
|
+
if (lower === 'mvt')
|
|
8876
|
+
return 'pbf';
|
|
8877
|
+
if (lower === 'jpeg')
|
|
8878
|
+
return 'jpg';
|
|
8879
|
+
return lower;
|
|
8880
|
+
}
|
|
8881
|
+
/**
|
|
8882
|
+
* MBTiles holds one tile per z/x/y, so tiles from several style sources can't
|
|
8883
|
+
* share a file — they'd overwrite each other at every shared coordinate.
|
|
8884
|
+
* Keep only `sourceId` when given, otherwise the source with the most tiles
|
|
8885
|
+
* (the basemap in practice), and warn about what was left out.
|
|
8886
|
+
*/
|
|
8887
|
+
function selectSingleSource(tiles, sourceId) {
|
|
8888
|
+
const bySource = new Map();
|
|
8889
|
+
for (const tile of tiles) {
|
|
8890
|
+
const group = bySource.get(tile.sourceId);
|
|
8891
|
+
if (group)
|
|
8892
|
+
group.push(tile);
|
|
8893
|
+
else
|
|
8894
|
+
bySource.set(tile.sourceId, [tile]);
|
|
8895
|
+
}
|
|
8896
|
+
if (sourceId !== undefined) {
|
|
8897
|
+
const selected = bySource.get(sourceId);
|
|
8898
|
+
if (!selected) {
|
|
8899
|
+
const available = [...bySource.keys()].join(', ') || 'none';
|
|
8900
|
+
throw new Error(`Source "${sourceId}" has no tiles in this region (available: ${available})`);
|
|
8901
|
+
}
|
|
8902
|
+
return selected;
|
|
8903
|
+
}
|
|
8904
|
+
if (bySource.size <= 1)
|
|
8905
|
+
return tiles;
|
|
8906
|
+
let chosen = '';
|
|
8907
|
+
let chosenTiles = [];
|
|
8908
|
+
for (const [id, group] of bySource) {
|
|
8909
|
+
if (group.length > chosenTiles.length) {
|
|
8910
|
+
chosen = id;
|
|
8911
|
+
chosenTiles = group;
|
|
8912
|
+
}
|
|
8913
|
+
}
|
|
8914
|
+
const skipped = [...bySource.keys()].filter(id => id !== chosen);
|
|
8915
|
+
serviceLogger.warn(`Region has tiles from ${bySource.size} sources; MBTiles holds one. Exporting "${chosen}", ` +
|
|
8916
|
+
`skipping ${skipped.join(', ')}. Pass { sourceId } to choose another.`);
|
|
8917
|
+
return chosenTiles;
|
|
8918
|
+
}
|
|
7958
8919
|
class ImportExportService {
|
|
7959
8920
|
db = dbPromise;
|
|
7960
8921
|
constructor() {
|
|
@@ -7979,10 +8940,11 @@ class ImportExportService {
|
|
|
7979
8940
|
if (!region) {
|
|
7980
8941
|
throw new Error(`Region ${regionId} not found`);
|
|
7981
8942
|
}
|
|
7982
|
-
const
|
|
7983
|
-
|
|
7984
|
-
//
|
|
7985
|
-
|
|
8943
|
+
const regionTiles = await this.exportTiles(region, onProgress);
|
|
8944
|
+
const tiles = selectSingleSource(regionTiles, options.sourceId);
|
|
8945
|
+
// Pick format: caller override → stored tile extension → region.tileExtension
|
|
8946
|
+
// → default pbf. Drives both the metadata row and whether tile bytes get gzipped.
|
|
8947
|
+
const format = normalizeMBTilesFormat(String(options.format || tiles[0]?.format || region.tileExtension || 'pbf'));
|
|
7986
8948
|
const isVector = VECTOR_FORMATS.has(format);
|
|
7987
8949
|
onProgress({
|
|
7988
8950
|
stage: 'processing',
|
|
@@ -8207,11 +9169,19 @@ class ImportExportService {
|
|
|
8207
9169
|
/**
|
|
8208
9170
|
* Export tiles data
|
|
8209
9171
|
*/
|
|
8210
|
-
async exportTiles(
|
|
9172
|
+
async exportTiles(region, onProgress) {
|
|
8211
9173
|
const db = await this.db;
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
9174
|
+
const styleId = region.styleId || region.id;
|
|
9175
|
+
// Tiles are stored per style, not per region. When the style holds other
|
|
9176
|
+
// regions too, keep only tiles inside this region's bounds and the zooms
|
|
9177
|
+
// the download pipeline fetches for each source (which can fall outside
|
|
9178
|
+
// the region's own range — see sourceZoomRange), or sibling regions' tiles
|
|
9179
|
+
// leak into the export. A sole region owns all of its style's tiles — and
|
|
9180
|
+
// an imported region's bounds come from MBTiles metadata, which can be
|
|
9181
|
+
// tighter than the tiles — so don't filter then.
|
|
9182
|
+
const styleEntry = await db.get('styles', styleId);
|
|
9183
|
+
const sharesStyle = (styleEntry?.regions?.length ?? 0) > 1;
|
|
9184
|
+
const styleSources = (styleEntry?.style?.sources ?? {});
|
|
8215
9185
|
const transaction = db.transaction(['tiles'], 'readonly');
|
|
8216
9186
|
const store = transaction.objectStore('tiles');
|
|
8217
9187
|
const tiles = [];
|
|
@@ -8220,22 +9190,31 @@ class ImportExportService {
|
|
|
8220
9190
|
let processed = 0;
|
|
8221
9191
|
while (cursor) {
|
|
8222
9192
|
const tile = cursor.value;
|
|
8223
|
-
|
|
8224
|
-
|
|
9193
|
+
const parsed = parseTileKey(String(tile.key));
|
|
9194
|
+
const z = tile.z ?? parsed?.z;
|
|
9195
|
+
const x = tile.x ?? parsed?.x;
|
|
9196
|
+
const y = tile.y ?? parsed?.y;
|
|
9197
|
+
if (tile.styleId === styleId &&
|
|
9198
|
+
z !== undefined &&
|
|
9199
|
+
x !== undefined &&
|
|
9200
|
+
y !== undefined &&
|
|
9201
|
+
(!sharesStyle ||
|
|
9202
|
+
tileBelongsToRegion({ sourceId: tile.sourceId ?? parsed?.sourceId, z, x, y }, region, styleSources))) {
|
|
8225
9203
|
tiles.push({
|
|
8226
|
-
z
|
|
8227
|
-
x
|
|
8228
|
-
y
|
|
9204
|
+
z,
|
|
9205
|
+
x,
|
|
9206
|
+
y,
|
|
8229
9207
|
data: tile.data,
|
|
8230
|
-
format: 'pbf',
|
|
8231
|
-
sourceId: tile.sourceId ?? 'default',
|
|
9208
|
+
format: (parsed?.ext || 'pbf').toLowerCase(),
|
|
9209
|
+
sourceId: tile.sourceId ?? parsed?.sourceId ?? 'default',
|
|
8232
9210
|
});
|
|
8233
9211
|
}
|
|
8234
9212
|
processed++;
|
|
8235
9213
|
if (onProgress && processed % 100 === 0) {
|
|
8236
9214
|
onProgress({
|
|
8237
9215
|
stage: 'exporting',
|
|
8238
|
-
|
|
9216
|
+
// Total is unknown while walking the cursor; approach 70% asymptotically.
|
|
9217
|
+
percentage: 30 + 40 * (1 - 1 / (1 + processed / 1000)),
|
|
8239
9218
|
message: `Exported ${processed} tiles...`,
|
|
8240
9219
|
currentItem: `${tile.z ?? 0}/${tile.x ?? 0}/${tile.y ?? 0}`,
|
|
8241
9220
|
completedItems: processed,
|
|
@@ -8416,7 +9395,7 @@ class ImportExportService {
|
|
|
8416
9395
|
sourceId,
|
|
8417
9396
|
downloadedAt: new Date().toISOString(),
|
|
8418
9397
|
size: tile.data instanceof ArrayBuffer ? tile.data.byteLength : 0,
|
|
8419
|
-
type: 'vector',
|
|
9398
|
+
type: VECTOR_FORMATS.has(ext) ? 'vector' : 'raster',
|
|
8420
9399
|
url: `tile://${tile.z}/${tile.x}/${tile.y}`,
|
|
8421
9400
|
lastModified: Date.now(),
|
|
8422
9401
|
});
|
|
@@ -8460,7 +9439,7 @@ const createRegionManagement = (services) => ({
|
|
|
8460
9439
|
addRegion: async (region) => services.regionService.addRegion(region),
|
|
8461
9440
|
downloadRegion: async (region, options) => services.regionService.downloadRegion(region, options),
|
|
8462
9441
|
loadRegion: async (region, options) => services.regionService.loadRegion(region, options),
|
|
8463
|
-
deleteRegion: async (regionId) => services.regionService.deleteRegion(regionId),
|
|
9442
|
+
deleteRegion: async (regionId, styleId) => services.regionService.deleteRegion(regionId, styleId),
|
|
8464
9443
|
listRegions: async () => services.regionService.listRegions(),
|
|
8465
9444
|
listStoredRegions: async () => services.regionService.listStoredRegions(),
|
|
8466
9445
|
getStoredRegion: async (regionId) => {
|
|
@@ -8482,7 +9461,7 @@ const createCleanupManagement = (services) => ({
|
|
|
8482
9461
|
let deletedCount = 0;
|
|
8483
9462
|
for (const region of allRegions) {
|
|
8484
9463
|
if (region.expiry && region.expiry < now) {
|
|
8485
|
-
await services.regionService.deleteRegion(region.id);
|
|
9464
|
+
await services.regionService.deleteRegion(region.id, region.styleId);
|
|
8486
9465
|
deletedCount++;
|
|
8487
9466
|
}
|
|
8488
9467
|
}
|
|
@@ -8933,7 +9912,7 @@ const en = {
|
|
|
8933
9912
|
'language.ar': 'Arabic',
|
|
8934
9913
|
// Header
|
|
8935
9914
|
'header.title': 'Offline Manager',
|
|
8936
|
-
'header.subtitle': '{{
|
|
9915
|
+
'header.subtitle': '{{regions}} • {{size}} total',
|
|
8937
9916
|
// Actions
|
|
8938
9917
|
'actions.addRegion': 'Add Region',
|
|
8939
9918
|
'actions.refresh': 'Refresh',
|
|
@@ -8949,6 +9928,8 @@ const en = {
|
|
|
8949
9928
|
'regionForm.namePlaceholder': 'e.g., Downtown District',
|
|
8950
9929
|
'regionForm.minZoom': 'Min Zoom',
|
|
8951
9930
|
'regionForm.maxZoom': 'Max Zoom',
|
|
9931
|
+
'regionForm.buildingsHint': "Mapbox Standard's buildings start at zoom 15. Set Max Zoom to 15 or more to include them.",
|
|
9932
|
+
'regionForm.zoomRangeError': 'Max Zoom must be at least Min Zoom.',
|
|
8952
9933
|
'regionForm.styleUrl': 'Style URL',
|
|
8953
9934
|
'regionForm.provider': 'Style Provider',
|
|
8954
9935
|
'regionForm.providerAuto': 'Auto-detect',
|
|
@@ -8978,7 +9959,10 @@ const en = {
|
|
|
8978
9959
|
'regionList.downloaded': 'Downloaded',
|
|
8979
9960
|
'regionList.size': 'Size',
|
|
8980
9961
|
'regionList.tiles': 'Tiles',
|
|
9962
|
+
'regionList.resources': 'Fonts, icons and models',
|
|
8981
9963
|
'regionList.styleData': 'Style',
|
|
9964
|
+
'regionList.regionCountOne': '1 region',
|
|
9965
|
+
'regionList.regionCount': '{{count}} regions',
|
|
8982
9966
|
'regionList.totalSize': 'Total',
|
|
8983
9967
|
'regionList.styleId': 'Style ID',
|
|
8984
9968
|
// Region Details
|
|
@@ -9064,6 +10048,14 @@ const en = {
|
|
|
9064
10048
|
'mbtiles.importFailed': 'Import failed. Please try again.',
|
|
9065
10049
|
// Active Downloads
|
|
9066
10050
|
'download.activeCount': 'Active Downloads ({{count}})',
|
|
10051
|
+
'download.status.style': 'Preparing style',
|
|
10052
|
+
'download.status.sprites': 'Sprites: {{completed}} of {{total}}',
|
|
10053
|
+
'download.status.glyphs': 'Fonts: {{completed}} of {{total}}',
|
|
10054
|
+
'download.status.models': '3D models: {{completed}} of {{total}}',
|
|
10055
|
+
'download.status.tiles': 'Tiles: {{completed}} of {{total}}',
|
|
10056
|
+
'download.status.planning': 'Planning tiles',
|
|
10057
|
+
'download.cancelRegion': 'Cancel downloading {{name}}',
|
|
10058
|
+
'download.status.saving': 'Saving region',
|
|
9067
10059
|
// Panel Manager additional strings
|
|
9068
10060
|
'panel.downloadedRegions': 'Downloaded Regions',
|
|
9069
10061
|
'panel.noExpiry': 'No expiry',
|
|
@@ -9085,6 +10077,8 @@ const en = {
|
|
|
9085
10077
|
'control.saveSelectedRegion': 'Save Selected Region',
|
|
9086
10078
|
// Alerts/Messages
|
|
9087
10079
|
'alert.unknownError': 'Unknown error occurred',
|
|
10080
|
+
'alert.downloadFailed': 'Download failed',
|
|
10081
|
+
'alert.needsMapboxGl': 'This style uses Mapbox GL JS features, such as 3D models or style config, so only a Mapbox GL map can show it.',
|
|
9088
10082
|
'alert.downloadNotImplemented': 'Download feature not implemented in this refactored version',
|
|
9089
10083
|
'alert.mapNotAvailable': 'Map not available',
|
|
9090
10084
|
'alert.invalidBounds': 'Invalid bounds for region',
|
|
@@ -9141,7 +10135,7 @@ const ar = {
|
|
|
9141
10135
|
'language.ar': 'العربية',
|
|
9142
10136
|
// Header - الرأس
|
|
9143
10137
|
'header.title': 'مدير الخرائط غير المتصلة',
|
|
9144
|
-
'header.subtitle': '{{
|
|
10138
|
+
'header.subtitle': '{{regions}} • {{size}} إجمالي',
|
|
9145
10139
|
// Actions - الإجراءات
|
|
9146
10140
|
'actions.addRegion': 'إضافة منطقة',
|
|
9147
10141
|
'actions.refresh': 'تحديث',
|
|
@@ -9157,6 +10151,8 @@ const ar = {
|
|
|
9157
10151
|
'regionForm.namePlaceholder': 'مثال: وسط المدينة',
|
|
9158
10152
|
'regionForm.minZoom': 'أدنى تكبير',
|
|
9159
10153
|
'regionForm.maxZoom': 'أقصى تكبير',
|
|
10154
|
+
'regionForm.buildingsHint': 'تبدأ مباني Mapbox Standard عند مستوى التكبير 15. اضبط أقصى تكبير على 15 أو أكثر لتضمينها.',
|
|
10155
|
+
'regionForm.zoomRangeError': 'يجب ألا يقل أقصى تكبير عن أدنى تكبير.',
|
|
9160
10156
|
'regionForm.styleUrl': 'رابط النمط',
|
|
9161
10157
|
'regionForm.provider': 'مزود النمط',
|
|
9162
10158
|
'regionForm.providerAuto': 'اكتشاف تلقائي',
|
|
@@ -9186,7 +10182,10 @@ const ar = {
|
|
|
9186
10182
|
'regionList.downloaded': 'تم التحميل',
|
|
9187
10183
|
'regionList.size': 'الحجم',
|
|
9188
10184
|
'regionList.tiles': 'البلاطات',
|
|
10185
|
+
'regionList.resources': 'الخطوط والرموز والنماذج',
|
|
9189
10186
|
'regionList.styleData': 'النمط',
|
|
10187
|
+
'regionList.regionCountOne': 'منطقة واحدة',
|
|
10188
|
+
'regionList.regionCount': '{{count}} منطقة',
|
|
9190
10189
|
'regionList.totalSize': 'الإجمالي',
|
|
9191
10190
|
'regionList.styleId': 'معرف النمط',
|
|
9192
10191
|
// Region Details - تفاصيل المنطقة
|
|
@@ -9272,6 +10271,14 @@ const ar = {
|
|
|
9272
10271
|
'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
|
|
9273
10272
|
// Active Downloads - التحميلات النشطة
|
|
9274
10273
|
'download.activeCount': 'التحميلات النشطة ({{count}})',
|
|
10274
|
+
'download.status.style': 'جارٍ تجهيز النمط',
|
|
10275
|
+
'download.status.sprites': 'الرموز: {{completed}} من {{total}}',
|
|
10276
|
+
'download.status.glyphs': 'الخطوط: {{completed}} من {{total}}',
|
|
10277
|
+
'download.status.models': 'النماذج ثلاثية الأبعاد: {{completed}} من {{total}}',
|
|
10278
|
+
'download.status.tiles': 'البلاطات: {{completed}} من {{total}}',
|
|
10279
|
+
'download.status.planning': 'جارٍ تخطيط البلاطات',
|
|
10280
|
+
'download.cancelRegion': 'إلغاء تحميل {{name}}',
|
|
10281
|
+
'download.status.saving': 'جارٍ حفظ المنطقة',
|
|
9275
10282
|
// Panel Manager additional strings - سلاسل إضافية للوحة
|
|
9276
10283
|
'panel.downloadedRegions': 'المناطق المحملة',
|
|
9277
10284
|
'panel.noExpiry': 'بدون انتهاء',
|
|
@@ -9293,6 +10300,8 @@ const ar = {
|
|
|
9293
10300
|
'control.saveSelectedRegion': 'حفظ المنطقة المحددة',
|
|
9294
10301
|
// Alerts/Messages - التنبيهات/الرسائل
|
|
9295
10302
|
'alert.unknownError': 'حدث خطأ غير معروف',
|
|
10303
|
+
'alert.downloadFailed': 'فشل التحميل',
|
|
10304
|
+
'alert.needsMapboxGl': 'يستخدم هذا النمط ميزات Mapbox GL JS، مثل النماذج ثلاثية الأبعاد أو إعدادات النمط، لذا لا يمكن عرضه إلا على خريطة Mapbox GL.',
|
|
9296
10305
|
'alert.downloadNotImplemented': 'ميزة التحميل غير مطبقة في هذه النسخة',
|
|
9297
10306
|
'alert.mapNotAvailable': 'الخريطة غير متاحة',
|
|
9298
10307
|
'alert.invalidBounds': 'حدود غير صالحة للمنطقة',
|
|
@@ -9427,6 +10436,9 @@ const i18n = new I18nManager();
|
|
|
9427
10436
|
const t = (key, replacements) => {
|
|
9428
10437
|
return i18n.t(key, replacements);
|
|
9429
10438
|
};
|
|
10439
|
+
// Re-export i18next for advanced usage
|
|
10440
|
+
/** "1 region" or "3 regions", in the current language. */
|
|
10441
|
+
const regionCountLabel = (count) => count === 1 ? t('regionList.regionCountOne') : t('regionList.regionCount', { count });
|
|
9430
10442
|
|
|
9431
10443
|
/**
|
|
9432
10444
|
* Base Component Class
|
|
@@ -9647,7 +10659,7 @@ class Button extends BaseComponent {
|
|
|
9647
10659
|
createProgressBadge() {
|
|
9648
10660
|
this.progressBadge = document.createElement('span');
|
|
9649
10661
|
this.progressBadge.className =
|
|
9650
|
-
'absolute
|
|
10662
|
+
'progress-badge absolute top-1/2 right-full mr-1.5 -translate-y-1/2 whitespace-nowrap bg-blue-600 text-white rounded-md px-1.5 py-0.5 text-xs font-semibold shadow-md pointer-events-none hidden';
|
|
9651
10663
|
// Accessibility: badge ID for aria-describedby
|
|
9652
10664
|
const badgeId = `btn-badge-${Date.now()}`;
|
|
9653
10665
|
this.progressBadge.id = badgeId;
|
|
@@ -9664,6 +10676,20 @@ class Button extends BaseComponent {
|
|
|
9664
10676
|
else {
|
|
9665
10677
|
this.element.textContent = text;
|
|
9666
10678
|
}
|
|
10679
|
+
this.restoreProgressBadge();
|
|
10680
|
+
}
|
|
10681
|
+
/**
|
|
10682
|
+
* Replace the button's content, keeping its progress badge.
|
|
10683
|
+
*/
|
|
10684
|
+
setContent(html) {
|
|
10685
|
+
this.element.innerHTML = html;
|
|
10686
|
+
this.restoreProgressBadge();
|
|
10687
|
+
}
|
|
10688
|
+
/** Replacing the button's content removes the badge, so put it back. */
|
|
10689
|
+
restoreProgressBadge() {
|
|
10690
|
+
if (this.progressBadge) {
|
|
10691
|
+
this.element.appendChild(this.progressBadge);
|
|
10692
|
+
}
|
|
9667
10693
|
}
|
|
9668
10694
|
/**
|
|
9669
10695
|
* Update button state
|
|
@@ -9759,7 +10785,7 @@ class ButtonManager {
|
|
|
9759
10785
|
* Reset button to default state
|
|
9760
10786
|
*/
|
|
9761
10787
|
resetToDefault() {
|
|
9762
|
-
this.button.
|
|
10788
|
+
this.button.setContent(icons.cloud({ size: 20, color: 'black' }));
|
|
9763
10789
|
this.button.setDisabled(false);
|
|
9764
10790
|
this.button.getElement().title = 'Offline Map Manager';
|
|
9765
10791
|
this.button.hideProgressBadge();
|
|
@@ -9872,8 +10898,7 @@ class Modal extends BaseComponent {
|
|
|
9872
10898
|
titleSection.className = 'flex flex-col';
|
|
9873
10899
|
if (this.config.title) {
|
|
9874
10900
|
const title = document.createElement('h2');
|
|
9875
|
-
title.className =
|
|
9876
|
-
'text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300';
|
|
10901
|
+
title.className = 'text-xl font-semibold text-gray-900 dark:text-white';
|
|
9877
10902
|
title.textContent = this.config.title;
|
|
9878
10903
|
titleSection.appendChild(title);
|
|
9879
10904
|
}
|
|
@@ -10052,14 +11077,14 @@ class RegionDetailsModal {
|
|
|
10052
11077
|
}
|
|
10053
11078
|
content.innerHTML = `
|
|
10054
11079
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50">
|
|
10055
|
-
<h3 class="m-0 text-xl font-
|
|
11080
|
+
<h3 class="m-0 text-xl font-semibold text-gray-900 dark:text-white">
|
|
10056
11081
|
${region.name}
|
|
10057
11082
|
</h3>
|
|
10058
11083
|
</div>
|
|
10059
11084
|
|
|
10060
11085
|
<div class="grid grid-cols-2 gap-4">
|
|
10061
11086
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10062
|
-
<label class="block mb-1.5 text-
|
|
11087
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10063
11088
|
${t('regionDetails.bounds')}
|
|
10064
11089
|
</label>
|
|
10065
11090
|
<div class="text-sm font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
|
|
@@ -10069,7 +11094,7 @@ class RegionDetailsModal {
|
|
|
10069
11094
|
</div>
|
|
10070
11095
|
|
|
10071
11096
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10072
|
-
<label class="block mb-1.5 text-
|
|
11097
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10073
11098
|
${t('regionDetails.zoomRange')}
|
|
10074
11099
|
</label>
|
|
10075
11100
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
@@ -10081,7 +11106,7 @@ class RegionDetailsModal {
|
|
|
10081
11106
|
${region.created
|
|
10082
11107
|
? `
|
|
10083
11108
|
<div class="glass-input p-4 rounded-xl border-0 bg-gray-50/30 dark:bg-gray-800/30">
|
|
10084
|
-
<label class="block mb-1.5 text-
|
|
11109
|
+
<label class="block mb-1.5 text-sm font-semibold text-gray-500 dark:text-gray-400">
|
|
10085
11110
|
${t('regionDetails.created')}
|
|
10086
11111
|
</label>
|
|
10087
11112
|
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
@@ -10836,11 +11861,13 @@ class PanelRenderer extends BaseComponent {
|
|
|
10836
11861
|
headerContainer;
|
|
10837
11862
|
actionButtonsContainer;
|
|
10838
11863
|
regionsList;
|
|
11864
|
+
awaitingProgressRender = false;
|
|
10839
11865
|
downloadProgressContainer;
|
|
10840
11866
|
languageSelector;
|
|
10841
11867
|
// Debounce mechanism
|
|
10842
11868
|
refreshTimeout;
|
|
10843
11869
|
isRefreshing = false;
|
|
11870
|
+
refreshQueued = false;
|
|
10844
11871
|
// Language change unsubscribe function
|
|
10845
11872
|
unsubscribeLanguage;
|
|
10846
11873
|
// Stored event handler for cleanup
|
|
@@ -10909,7 +11936,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10909
11936
|
titleSection.innerHTML = `
|
|
10910
11937
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">${t('header.title')}</h2>
|
|
10911
11938
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
10912
|
-
${t('header.subtitle', {
|
|
11939
|
+
${t('header.subtitle', { regions: regionCountLabel(regions.length), size: formatBytes(analytics.totalStorageSize) })}
|
|
10913
11940
|
</p>
|
|
10914
11941
|
`;
|
|
10915
11942
|
// Actions section
|
|
@@ -10983,6 +12010,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
10983
12010
|
this.element.removeChild(this.downloadProgressContainer);
|
|
10984
12011
|
this.downloadProgressContainer = undefined;
|
|
10985
12012
|
}
|
|
12013
|
+
this.awaitingProgressRender = false;
|
|
10986
12014
|
// Only show if there are active downloads
|
|
10987
12015
|
if (this.downloadManager?.hasActiveDownloads()) {
|
|
10988
12016
|
this.downloadProgressContainer = document.createElement('div');
|
|
@@ -10991,6 +12019,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
10991
12019
|
const downloads = this.downloadManager.getCurrentDownloads();
|
|
10992
12020
|
const progressHTML = this.createDownloadProgressHTML(downloads);
|
|
10993
12021
|
this.downloadProgressContainer.innerHTML = progressHTML;
|
|
12022
|
+
// The rows are redrawn on every update, so listen on the container.
|
|
12023
|
+
this.downloadProgressContainer.addEventListener('click', event => {
|
|
12024
|
+
const button = event.target.closest('[data-action="cancel-download"]');
|
|
12025
|
+
const regionId = button?.dataset.regionId;
|
|
12026
|
+
if (!regionId)
|
|
12027
|
+
return;
|
|
12028
|
+
this.downloadManager.cancelDownload(regionId);
|
|
12029
|
+
void this.refresh();
|
|
12030
|
+
});
|
|
10994
12031
|
this.element.appendChild(this.downloadProgressContainer);
|
|
10995
12032
|
}
|
|
10996
12033
|
}
|
|
@@ -11005,9 +12042,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11005
12042
|
}
|
|
11006
12043
|
try {
|
|
11007
12044
|
// Load styles from IndexedDB and get stats
|
|
11008
|
-
const { loadStyles, getStyleStats } = await Promise.resolve().then(function () { return styleService; });
|
|
12045
|
+
const { loadStyles, getStyleStats, getStyleStorageSizes } = await Promise.resolve().then(function () { return styleService; });
|
|
11009
12046
|
const styles = await loadStyles();
|
|
11010
12047
|
const statsResult = await getStyleStats();
|
|
12048
|
+
const storageSizes = await getStyleStorageSizes();
|
|
11011
12049
|
panelLogger.debug('Loaded styles:', styles);
|
|
11012
12050
|
panelLogger.debug('Style stats:', statsResult);
|
|
11013
12051
|
// Build style size map from stats
|
|
@@ -11030,12 +12068,14 @@ class PanelRenderer extends BaseComponent {
|
|
|
11030
12068
|
for (const style of styles) {
|
|
11031
12069
|
const styleRegions = regionsByStyle[style.key] || [];
|
|
11032
12070
|
const styleJsonSize = styleSizeMap[style.key] || 0;
|
|
11033
|
-
const regionTilesSize =
|
|
11034
|
-
const
|
|
12071
|
+
const regionTilesSize = storageSizes[style.key]?.tiles ?? 0;
|
|
12072
|
+
const resourcesSize = storageSizes[style.key]?.resources ?? 0;
|
|
12073
|
+
const totalSize = styleJsonSize + regionTilesSize + resourcesSize;
|
|
11035
12074
|
// Add style item with computed sizes
|
|
11036
12075
|
const styleWithSizes = {
|
|
11037
12076
|
...style,
|
|
11038
12077
|
styleJsonSize,
|
|
12078
|
+
resourcesSize,
|
|
11039
12079
|
regionTilesSize,
|
|
11040
12080
|
totalSize,
|
|
11041
12081
|
};
|
|
@@ -11151,7 +12191,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11151
12191
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11152
12192
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11153
12193
|
</div>
|
|
11154
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12194
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11155
12195
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11156
12196
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11157
12197
|
${region.size ? `<span>${t('regionList.size')}: ${formatBytes(region.size)}</span>` : ''}
|
|
@@ -11174,16 +12214,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11174
12214
|
<div class="flex items-center justify-between">
|
|
11175
12215
|
<div class="flex-1">
|
|
11176
12216
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
|
11177
|
-
${escapeHtml$1(
|
|
12217
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11178
12218
|
</h3>
|
|
11179
12219
|
<p class="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
|
11180
12220
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11181
12221
|
</p>
|
|
11182
12222
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
11183
|
-
${regionCount}
|
|
12223
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11184
12224
|
</p>
|
|
11185
|
-
<p class="text-xs text-slate-
|
|
11186
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12225
|
+
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
12226
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11187
12227
|
</p>
|
|
11188
12228
|
</div>
|
|
11189
12229
|
<div class="flex items-center gap-2">
|
|
@@ -11217,7 +12257,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11217
12257
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
11218
12258
|
${region.bounds ? `<span class="font-medium">SW:</span> ${region.bounds[0][1].toFixed(4)}, ${region.bounds[0][0].toFixed(4)} <span class="mx-1">→</span> <span class="font-medium">NE:</span> ${region.bounds[1][1].toFixed(4)}, ${region.bounds[1][0].toFixed(4)}` : t('panel.noBounds')}
|
|
11219
12259
|
</div>
|
|
11220
|
-
<div class="flex items-center gap-4 text-xs text-slate-
|
|
12260
|
+
<div class="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
|
|
11221
12261
|
<span>${t('regionList.zoom')}: ${region.minZoom}-${region.maxZoom}</span>
|
|
11222
12262
|
${region.downloadedAt ? `<span>${t('regionList.downloaded')}: ${new Date(region.downloadedAt).toLocaleDateString()}</span>` : ''}
|
|
11223
12263
|
${region.tileSize ? `<span>${t('regionList.tiles')}: ${formatBytes(region.tileSize)}</span>` : ''}
|
|
@@ -11256,16 +12296,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
11256
12296
|
<div class="flex items-center justify-between">
|
|
11257
12297
|
<div class="flex-1">
|
|
11258
12298
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 text-base">
|
|
11259
|
-
${escapeHtml$1(
|
|
12299
|
+
${escapeHtml$1(styleDisplayName(style))}
|
|
11260
12300
|
</h3>
|
|
11261
12301
|
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
|
11262
12302
|
${t('regionList.styleId')}: ${escapeHtml$1(style.key)}
|
|
11263
12303
|
</p>
|
|
11264
12304
|
<p class="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
|
11265
|
-
${regionCount}
|
|
12305
|
+
${regionCountLabel(regionCount)} • ${t('regionList.totalSize')}: ${formatBytes(style.totalSize)}
|
|
11266
12306
|
</p>
|
|
11267
12307
|
<p class="text-xs text-blue-500 dark:text-blue-500 mt-1">
|
|
11268
|
-
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
12308
|
+
${t('regionList.tiles')}: ${formatBytes(style.regionTilesSize)} • ${t('regionList.resources')}: ${formatBytes(style.resourcesSize)} • ${t('regionList.styleData')}: ${formatBytes(style.styleJsonSize)}
|
|
11269
12309
|
</p>
|
|
11270
12310
|
</div>
|
|
11271
12311
|
<div class="flex items-center gap-2">
|
|
@@ -11305,6 +12345,39 @@ class PanelRenderer extends BaseComponent {
|
|
|
11305
12345
|
</div>
|
|
11306
12346
|
`;
|
|
11307
12347
|
}
|
|
12348
|
+
/**
|
|
12349
|
+
* Show the latest download progress. Called on every update, so it only
|
|
12350
|
+
* redraws the progress section rather than the whole panel.
|
|
12351
|
+
*/
|
|
12352
|
+
updateDownloadProgress(downloads) {
|
|
12353
|
+
if (downloads.size === 0)
|
|
12354
|
+
return;
|
|
12355
|
+
const container = this.downloadProgressContainer;
|
|
12356
|
+
if (container?.isConnected) {
|
|
12357
|
+
container.innerHTML = this.createDownloadProgressHTML(downloads);
|
|
12358
|
+
return;
|
|
12359
|
+
}
|
|
12360
|
+
// The download started while the panel was open, so it has no section yet.
|
|
12361
|
+
if (this.element.isConnected && !this.awaitingProgressRender) {
|
|
12362
|
+
this.awaitingProgressRender = true;
|
|
12363
|
+
void this.refresh();
|
|
12364
|
+
}
|
|
12365
|
+
}
|
|
12366
|
+
/** What the current phase is doing, such as "Tiles: 64 of 128". */
|
|
12367
|
+
describeDownloadPhase(download) {
|
|
12368
|
+
const phase = download.phase;
|
|
12369
|
+
if (phase === 'style')
|
|
12370
|
+
return t('download.status.style');
|
|
12371
|
+
if (phase === 'tiles' && !download.total)
|
|
12372
|
+
return t('download.status.planning');
|
|
12373
|
+
if (phase === 'sprites' || phase === 'glyphs' || phase === 'models' || phase === 'tiles') {
|
|
12374
|
+
return t(`download.status.${phase}`, {
|
|
12375
|
+
completed: Number(download.completed) || 0,
|
|
12376
|
+
total: Number(download.total) || 0,
|
|
12377
|
+
});
|
|
12378
|
+
}
|
|
12379
|
+
return t('download.status.saving');
|
|
12380
|
+
}
|
|
11308
12381
|
/**
|
|
11309
12382
|
* Create download progress HTML
|
|
11310
12383
|
*/
|
|
@@ -11318,13 +12391,17 @@ class PanelRenderer extends BaseComponent {
|
|
|
11318
12391
|
<div class="space-y-2">
|
|
11319
12392
|
${downloadArray
|
|
11320
12393
|
.map(download => `
|
|
11321
|
-
<div class="flex items-center justify-between text-sm">
|
|
11322
|
-
<
|
|
11323
|
-
|
|
11324
|
-
<div class="
|
|
11325
|
-
|
|
12394
|
+
<div class="flex items-center justify-between gap-4 text-sm">
|
|
12395
|
+
<div class="min-w-0">
|
|
12396
|
+
<div class="truncate text-blue-800 dark:text-blue-200">${escapeHtml$1(download.regionName || download.regionId)}</div>
|
|
12397
|
+
<div class="text-xs text-gray-600 dark:text-gray-300">${escapeHtml$1(this.describeDownloadPhase(download))}</div>
|
|
12398
|
+
</div>
|
|
12399
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
12400
|
+
<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)}">
|
|
12401
|
+
<div class="h-full bg-blue-600" style="width: ${download.percentage || 0}%"></div>
|
|
11326
12402
|
</div>
|
|
11327
12403
|
<span class="text-blue-700 dark:text-blue-300 min-w-12">${Math.round(download.percentage || 0)}%</span>
|
|
12404
|
+
<button type="button" class="px-2 py-1 rounded-md text-xs font-medium cursor-pointer text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600" data-action="cancel-download" data-region-id="${escapeHtml$1(download.regionId)}" aria-label="${t('download.cancelRegion', { name: String(download.regionName || download.regionId) })}">${t('app.cancel')}</button>
|
|
11328
12405
|
</div>
|
|
11329
12406
|
</div>
|
|
11330
12407
|
`)
|
|
@@ -11399,7 +12476,15 @@ class PanelRenderer extends BaseComponent {
|
|
|
11399
12476
|
cancelText: t('app.cancel'),
|
|
11400
12477
|
onConfirm: async () => {
|
|
11401
12478
|
try {
|
|
11402
|
-
|
|
12479
|
+
// Deleting a style's last region deletes the style too.
|
|
12480
|
+
const { loadStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
12481
|
+
const shownEntry = this.isStyleShown(region.styleId)
|
|
12482
|
+
? await loadStyleById(region.styleId)
|
|
12483
|
+
: null;
|
|
12484
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
12485
|
+
if (shownEntry && !(await loadStyleById(region.styleId))) {
|
|
12486
|
+
this.showOnlineStyle(shownEntry);
|
|
12487
|
+
}
|
|
11403
12488
|
this.modalManager.close();
|
|
11404
12489
|
// Refresh the panel to show updated regions
|
|
11405
12490
|
await this.refresh();
|
|
@@ -11452,7 +12537,7 @@ class PanelRenderer extends BaseComponent {
|
|
|
11452
12537
|
}
|
|
11453
12538
|
// Delete the existing region (and its tiles/resources)
|
|
11454
12539
|
panelLogger.debug('Deleting region before re-download:', regionId);
|
|
11455
|
-
await this.offlineManager.deleteRegion(regionId);
|
|
12540
|
+
await this.offlineManager.deleteRegion(regionId, region.styleId);
|
|
11456
12541
|
// Prepare form data for re-download
|
|
11457
12542
|
const formData = {
|
|
11458
12543
|
name: region.name,
|
|
@@ -11673,7 +12758,8 @@ class PanelRenderer extends BaseComponent {
|
|
|
11673
12758
|
clearTimeout(this.refreshTimeout);
|
|
11674
12759
|
}
|
|
11675
12760
|
if (this.isRefreshing) {
|
|
11676
|
-
|
|
12761
|
+
// Run again once this one finishes, or the panel misses what changed meanwhile.
|
|
12762
|
+
this.refreshQueued = true;
|
|
11677
12763
|
return;
|
|
11678
12764
|
}
|
|
11679
12765
|
this.refreshTimeout = setTimeout(async () => {
|
|
@@ -11717,6 +12803,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11717
12803
|
}
|
|
11718
12804
|
finally {
|
|
11719
12805
|
this.isRefreshing = false;
|
|
12806
|
+
if (this.refreshQueued) {
|
|
12807
|
+
this.refreshQueued = false;
|
|
12808
|
+
void this.refresh();
|
|
12809
|
+
}
|
|
11720
12810
|
}
|
|
11721
12811
|
}
|
|
11722
12812
|
/**
|
|
@@ -11854,6 +12944,20 @@ class PanelRenderer extends BaseComponent {
|
|
|
11854
12944
|
/**
|
|
11855
12945
|
* Handle loading a style to the map
|
|
11856
12946
|
*/
|
|
12947
|
+
/** Whether the map is drawing this stored style, through `idb://` or the Service Worker. */
|
|
12948
|
+
isStyleShown(styleKey) {
|
|
12949
|
+
const shown = this.map?.getStyle?.();
|
|
12950
|
+
if (!shown)
|
|
12951
|
+
return false;
|
|
12952
|
+
const text = JSON.stringify(shown);
|
|
12953
|
+
return text.includes(`idb://${styleKey}/`) || text.includes(`/__offline__/${styleKey}/`);
|
|
12954
|
+
}
|
|
12955
|
+
/** After its stored copy is deleted, show a style from its original URL, not a blank map. */
|
|
12956
|
+
showOnlineStyle(entry) {
|
|
12957
|
+
if (!entry.originalUrl)
|
|
12958
|
+
return;
|
|
12959
|
+
this.map?.setStyle?.(entry.originalUrl);
|
|
12960
|
+
}
|
|
11857
12961
|
async handleLoadStyle(styleData) {
|
|
11858
12962
|
if (!this.map) {
|
|
11859
12963
|
panelLogger.warn('Map not available for loading style');
|
|
@@ -11872,6 +12976,10 @@ class PanelRenderer extends BaseComponent {
|
|
|
11872
12976
|
alert(t('error.invalidStyleData'));
|
|
11873
12977
|
return;
|
|
11874
12978
|
}
|
|
12979
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
12980
|
+
alert(t('alert.needsMapboxGl'));
|
|
12981
|
+
return;
|
|
12982
|
+
}
|
|
11875
12983
|
panelLogger.debug('Loading style from IndexedDB:', {
|
|
11876
12984
|
key: styleEntry.key,
|
|
11877
12985
|
provider: styleEntry.provider,
|
|
@@ -12056,13 +13164,16 @@ class PanelRenderer extends BaseComponent {
|
|
|
12056
13164
|
const style = styleData;
|
|
12057
13165
|
const confirmModal = new ConfirmationModal({
|
|
12058
13166
|
title: t('delete.styleTitle'),
|
|
12059
|
-
message: t('delete.styleMessage', { name: style
|
|
13167
|
+
message: t('delete.styleMessage', { name: styleDisplayName(style) }),
|
|
12060
13168
|
confirmText: t('actions.deleteStyle'),
|
|
12061
13169
|
cancelText: t('app.cancel'),
|
|
12062
13170
|
onConfirm: async () => {
|
|
12063
13171
|
try {
|
|
12064
13172
|
const { deleteStyleById } = await Promise.resolve().then(function () { return styleService; });
|
|
13173
|
+
const wasShown = this.isStyleShown(style.key);
|
|
12065
13174
|
await deleteStyleById(style.key);
|
|
13175
|
+
if (wasShown)
|
|
13176
|
+
this.showOnlineStyle(styleData);
|
|
12066
13177
|
this.modalManager.close();
|
|
12067
13178
|
// Refresh the panel
|
|
12068
13179
|
await this.refresh();
|
|
@@ -12167,7 +13278,7 @@ class PolygonControl {
|
|
|
12167
13278
|
this.saveButton = document.createElement('button');
|
|
12168
13279
|
this.saveButton.type = 'button';
|
|
12169
13280
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12170
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
13281
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12171
13282
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12172
13283
|
this.saveButton.title = 'Save Selected Region';
|
|
12173
13284
|
this.saveButton.addEventListener('click', () => this.handleSave());
|
|
@@ -12234,6 +13345,33 @@ class PolygonControl {
|
|
|
12234
13345
|
handleMapMove = () => {
|
|
12235
13346
|
this.updateVisualization();
|
|
12236
13347
|
};
|
|
13348
|
+
/**
|
|
13349
|
+
* The area to select: the middle of what's on screen. On a tilted or rotated map
|
|
13350
|
+
* `getBounds()` reaches far past the view, so insetting it would miss the part
|
|
13351
|
+
* nearest the camera. There, measure from the map's centre to the screen's edges.
|
|
13352
|
+
*/
|
|
13353
|
+
getSelectionBbox(minLng, minLat, maxLng, maxLat) {
|
|
13354
|
+
if (!this.map.getPitch() && !this.map.getBearing()) {
|
|
13355
|
+
const lngInset = (maxLng - minLng) * 0.2;
|
|
13356
|
+
const latInset = (maxLat - minLat) * 0.2;
|
|
13357
|
+
return [minLng + lngInset, minLat + latInset, maxLng - lngInset, maxLat - latInset];
|
|
13358
|
+
}
|
|
13359
|
+
const center = this.map.getCenter();
|
|
13360
|
+
const { clientWidth, clientHeight } = this.map.getContainer();
|
|
13361
|
+
const lngScale = Math.cos((center.lat * Math.PI) / 180);
|
|
13362
|
+
const degreesFromCenter = (point) => {
|
|
13363
|
+
const { lng, lat } = this.map.unproject(point);
|
|
13364
|
+
return Math.hypot((lng - center.lng) * lngScale, lat - center.lat);
|
|
13365
|
+
};
|
|
13366
|
+
const halfSize = 0.6 *
|
|
13367
|
+
Math.min(degreesFromCenter([0, clientHeight / 2]), degreesFromCenter([clientWidth / 2, clientHeight]));
|
|
13368
|
+
return [
|
|
13369
|
+
center.lng - halfSize / lngScale,
|
|
13370
|
+
center.lat - halfSize,
|
|
13371
|
+
center.lng + halfSize / lngScale,
|
|
13372
|
+
center.lat + halfSize,
|
|
13373
|
+
];
|
|
13374
|
+
}
|
|
12237
13375
|
/**
|
|
12238
13376
|
* Update polygon visualization based on current map bounds
|
|
12239
13377
|
*/
|
|
@@ -12243,9 +13381,7 @@ class PolygonControl {
|
|
|
12243
13381
|
return;
|
|
12244
13382
|
const [minLng, minLat] = bboxArray[0];
|
|
12245
13383
|
const [maxLng, maxLat] = bboxArray[1];
|
|
12246
|
-
const
|
|
12247
|
-
const latDiff = (maxLat - minLat) * 0.2;
|
|
12248
|
-
const clippedBbox = [minLng + lngDiff, minLat + latDiff, maxLng - lngDiff, maxLat - latDiff];
|
|
13384
|
+
const clippedBbox = this.getSelectionBbox(minLng, minLat, maxLng, maxLat);
|
|
12249
13385
|
// Store current bounds for later use
|
|
12250
13386
|
this.currentBounds = clippedBbox;
|
|
12251
13387
|
const clippedPolygon = bboxPolygon([
|
|
@@ -12312,7 +13448,7 @@ class PolygonControl {
|
|
|
12312
13448
|
source: 'selectionPolygon',
|
|
12313
13449
|
layout: {},
|
|
12314
13450
|
paint: {
|
|
12315
|
-
'line-color': '#
|
|
13451
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
12316
13452
|
'line-width': 3,
|
|
12317
13453
|
'line-dasharray': [2, 2],
|
|
12318
13454
|
},
|
|
@@ -12346,6 +13482,30 @@ class PolygonControl {
|
|
|
12346
13482
|
*/
|
|
12347
13483
|
// import { MaplibreMap } from 'maplibre-gl';
|
|
12348
13484
|
const formLogger = logger.scope('RegionFormModal');
|
|
13485
|
+
/** Mapbox Standard's buildings start at this zoom. */
|
|
13486
|
+
const STANDARD_BUILDINGS_MIN_ZOOM = 15;
|
|
13487
|
+
let fieldCount = 0;
|
|
13488
|
+
/**
|
|
13489
|
+
* Point each label at the one field beside it, so screen readers name the field
|
|
13490
|
+
* and clicking the label focuses it. Groups with several fields are left alone.
|
|
13491
|
+
*/
|
|
13492
|
+
function linkLabelsToFields(form) {
|
|
13493
|
+
for (const label of Array.from(form.querySelectorAll('label'))) {
|
|
13494
|
+
const fields = label.parentElement?.querySelectorAll('input, select, textarea');
|
|
13495
|
+
if (label.htmlFor || fields?.length !== 1)
|
|
13496
|
+
continue;
|
|
13497
|
+
const field = fields[0];
|
|
13498
|
+
field.id ||= `map-gl-offline-field-${++fieldCount}`;
|
|
13499
|
+
label.htmlFor = field.id;
|
|
13500
|
+
}
|
|
13501
|
+
}
|
|
13502
|
+
/**
|
|
13503
|
+
* True when a region of Mapbox Standard stops below the zoom where its buildings
|
|
13504
|
+
* start. Standard Satellite has no buildings, so it doesn't count.
|
|
13505
|
+
*/
|
|
13506
|
+
function regionMissesBuildings(styleUrl, maxZoom) {
|
|
13507
|
+
return /mapbox\/standard(?![\w-])/.test(styleUrl) && maxZoom < STANDARD_BUILDINGS_MIN_ZOOM;
|
|
13508
|
+
}
|
|
12349
13509
|
class RegionFormModal {
|
|
12350
13510
|
options;
|
|
12351
13511
|
modal;
|
|
@@ -12353,6 +13513,7 @@ class RegionFormModal {
|
|
|
12353
13513
|
minZoomInput;
|
|
12354
13514
|
maxZoomInput;
|
|
12355
13515
|
styleUrlInput;
|
|
13516
|
+
buildingsHint;
|
|
12356
13517
|
// Enhanced Mapbox GL support
|
|
12357
13518
|
providerSelect;
|
|
12358
13519
|
accessTokenInput;
|
|
@@ -12403,13 +13564,13 @@ class RegionFormModal {
|
|
|
12403
13564
|
'grid grid-cols-2 gap-4 p-4 rounded-xl glass-input border border-gray-100/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50';
|
|
12404
13565
|
infoGroup.innerHTML = `
|
|
12405
13566
|
<div class="flex flex-col justify-center">
|
|
12406
|
-
<strong class="text-
|
|
12407
|
-
<div class="text-xl font-
|
|
13567
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.area')}</strong>
|
|
13568
|
+
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
12408
13569
|
${area.toLocaleString()} <span class="text-sm font-medium text-gray-500">km²</span>
|
|
12409
13570
|
</div>
|
|
12410
13571
|
</div>
|
|
12411
13572
|
<div>
|
|
12412
|
-
<strong class="text-
|
|
13573
|
+
<strong class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">${t('regionForm.bounds')}</strong>
|
|
12413
13574
|
<div class="text-xs font-mono text-gray-600 dark:text-gray-300 leading-relaxed bg-white/50 dark:bg-black/20 p-2 rounded-lg">
|
|
12414
13575
|
${west.toFixed(4)}, ${south.toFixed(4)}<br>
|
|
12415
13576
|
${east.toFixed(4)}, ${north.toFixed(4)}
|
|
@@ -12465,6 +13626,12 @@ class RegionFormModal {
|
|
|
12465
13626
|
zoomGroup.appendChild(minZoomDiv);
|
|
12466
13627
|
zoomGroup.appendChild(maxZoomDiv);
|
|
12467
13628
|
form.appendChild(zoomGroup);
|
|
13629
|
+
this.buildingsHint = document.createElement('p');
|
|
13630
|
+
this.buildingsHint.className = 'm-0 text-sm text-amber-700 dark:text-amber-400 hidden';
|
|
13631
|
+
this.buildingsHint.setAttribute('role', 'status');
|
|
13632
|
+
this.buildingsHint.textContent = t('regionForm.buildingsHint');
|
|
13633
|
+
form.appendChild(this.buildingsHint);
|
|
13634
|
+
this.maxZoomInput.addEventListener('input', () => this.updateBuildingsHint());
|
|
12468
13635
|
// Style URL input
|
|
12469
13636
|
const styleGroup = document.createElement('div');
|
|
12470
13637
|
styleGroup.innerHTML = `
|
|
@@ -12477,9 +13644,13 @@ class RegionFormModal {
|
|
|
12477
13644
|
this.styleUrlInput.value = styleUrl;
|
|
12478
13645
|
this.styleUrlInput.className =
|
|
12479
13646
|
'w-full px-4 py-3 rounded-xl text-base glass-input text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all shadow-sm';
|
|
12480
|
-
this.styleUrlInput.addEventListener('input', () =>
|
|
13647
|
+
this.styleUrlInput.addEventListener('input', () => {
|
|
13648
|
+
this.handleStyleUrlChange();
|
|
13649
|
+
this.updateBuildingsHint();
|
|
13650
|
+
});
|
|
12481
13651
|
styleGroup.appendChild(this.styleUrlInput);
|
|
12482
13652
|
form.appendChild(styleGroup);
|
|
13653
|
+
this.updateBuildingsHint();
|
|
12483
13654
|
// Provider selection
|
|
12484
13655
|
const providerGroup = document.createElement('div');
|
|
12485
13656
|
providerGroup.innerHTML = `
|
|
@@ -12537,6 +13708,7 @@ class RegionFormModal {
|
|
|
12537
13708
|
}
|
|
12538
13709
|
// Initialize provider detection
|
|
12539
13710
|
this.detectProviderFromUrl();
|
|
13711
|
+
linkLabelsToFields(form);
|
|
12540
13712
|
return form;
|
|
12541
13713
|
}
|
|
12542
13714
|
/**
|
|
@@ -12651,6 +13823,11 @@ class RegionFormModal {
|
|
|
12651
13823
|
/**
|
|
12652
13824
|
* Handle style URL changes to auto-detect provider
|
|
12653
13825
|
*/
|
|
13826
|
+
/** Warn when a region would be saved without the style's buildings. */
|
|
13827
|
+
updateBuildingsHint() {
|
|
13828
|
+
const missesBuildings = regionMissesBuildings(this.styleUrlInput?.value ?? '', Number(this.maxZoomInput?.value));
|
|
13829
|
+
this.buildingsHint?.classList.toggle('hidden', !missesBuildings);
|
|
13830
|
+
}
|
|
12654
13831
|
handleStyleUrlChange() {
|
|
12655
13832
|
this.detectProviderFromUrl();
|
|
12656
13833
|
}
|
|
@@ -12727,10 +13904,21 @@ class RegionFormModal {
|
|
|
12727
13904
|
footer.appendChild(saveButton.getElement());
|
|
12728
13905
|
return footer;
|
|
12729
13906
|
}
|
|
13907
|
+
/** Check the zoom fields, showing the browser's message on the first bad one. */
|
|
13908
|
+
zoomRangeIsValid() {
|
|
13909
|
+
const { minZoomInput: min, maxZoomInput: max } = this;
|
|
13910
|
+
if (!min || !max)
|
|
13911
|
+
return true;
|
|
13912
|
+
const reversed = min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value);
|
|
13913
|
+
max.setCustomValidity(reversed ? t('regionForm.zoomRangeError') : '');
|
|
13914
|
+
return min.reportValidity() && max.reportValidity();
|
|
13915
|
+
}
|
|
12730
13916
|
/**
|
|
12731
13917
|
* Handle form save
|
|
12732
13918
|
*/
|
|
12733
13919
|
async handleSave() {
|
|
13920
|
+
if (!this.zoomRangeIsValid())
|
|
13921
|
+
return;
|
|
12734
13922
|
try {
|
|
12735
13923
|
const selectedSources = this.getSelectedExtraSources();
|
|
12736
13924
|
const formData = {
|
|
@@ -12847,14 +14035,14 @@ class RegionControl {
|
|
|
12847
14035
|
this.cancelButton = document.createElement('button');
|
|
12848
14036
|
this.cancelButton.type = 'button';
|
|
12849
14037
|
const prefix = this.options.cssPrefix || 'maplibregl';
|
|
12850
|
-
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14038
|
+
this.cancelButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-red-600 border border-red-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-red-700 transition-all duration-200`;
|
|
12851
14039
|
this.cancelButton.innerHTML = icons.x({ size: 16, color: 'white' });
|
|
12852
14040
|
this.cancelButton.title = 'Cancel Selection';
|
|
12853
14041
|
this.cancelButton.addEventListener('click', () => this.cancelSelection());
|
|
12854
14042
|
// Create save button (green checkmark)
|
|
12855
14043
|
this.saveButton = document.createElement('button');
|
|
12856
14044
|
this.saveButton.type = 'button';
|
|
12857
|
-
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 bg-
|
|
14045
|
+
this.saveButton.className = `${prefix}-ctrl-icon offline-manager-control mt-0.5 !bg-green-600 border border-green-700 rounded-sm cursor-pointer relative w-[29px] h-[29px] flex items-center justify-center hover:!bg-green-700 transition-all duration-200`;
|
|
12858
14046
|
this.saveButton.innerHTML = icons.check({ size: 16, color: 'white' });
|
|
12859
14047
|
this.saveButton.title = 'Save Selected Region';
|
|
12860
14048
|
this.saveButton.addEventListener('click', () => this.handleSaveClick());
|
|
@@ -13084,6 +14272,8 @@ class DownloadManager {
|
|
|
13084
14272
|
offlineManager;
|
|
13085
14273
|
/** Map of region IDs to their download progress */
|
|
13086
14274
|
currentDownloads = new Map();
|
|
14275
|
+
/** Aborts each running download, by region id. */
|
|
14276
|
+
controllers = new Map();
|
|
13087
14277
|
options;
|
|
13088
14278
|
/**
|
|
13089
14279
|
* Create a new DownloadManager instance.
|
|
@@ -13098,14 +14288,16 @@ class DownloadManager {
|
|
|
13098
14288
|
* Calculates percentage and notifies listeners via the onProgressUpdate callback.
|
|
13099
14289
|
* @internal
|
|
13100
14290
|
*/
|
|
13101
|
-
updateProgress(regionId, regionName, phase, completed, total, currentResource) {
|
|
13102
|
-
const percentage =
|
|
14291
|
+
updateProgress(regionId, regionName, phase, completed, total, currentResource, overallPercentage) {
|
|
14292
|
+
const percentage = Math.round(overallPercentage);
|
|
14293
|
+
const phasePercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
13103
14294
|
this.currentDownloads.set(regionId, {
|
|
13104
14295
|
regionId,
|
|
13105
14296
|
regionName,
|
|
13106
14297
|
completed,
|
|
13107
14298
|
total,
|
|
13108
14299
|
percentage,
|
|
14300
|
+
phasePercentage,
|
|
13109
14301
|
currentResource,
|
|
13110
14302
|
phase,
|
|
13111
14303
|
});
|
|
@@ -13140,14 +14332,20 @@ class DownloadManager {
|
|
|
13140
14332
|
extraSources: formData.extraSources,
|
|
13141
14333
|
};
|
|
13142
14334
|
const regionId = regionConfig.id;
|
|
14335
|
+
const controller = new AbortController();
|
|
14336
|
+
this.controllers.set(regionId, controller);
|
|
13143
14337
|
this.updateUIForDownloadStart();
|
|
13144
14338
|
try {
|
|
13145
14339
|
await this.offlineManager.downloadRegion(regionConfig, {
|
|
13146
14340
|
provider: formData.provider || 'auto',
|
|
13147
14341
|
accessToken: formData.accessToken,
|
|
14342
|
+
signal: controller.signal,
|
|
13148
14343
|
onProgress: progress => {
|
|
14344
|
+
// A cancelled download keeps reporting until it stops; don't show it again.
|
|
14345
|
+
if (controller.signal.aborted)
|
|
14346
|
+
return;
|
|
13149
14347
|
const uiPhase = progress.phase === 'metadata' ? undefined : progress.phase;
|
|
13150
|
-
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}
|
|
14348
|
+
this.updateProgress(regionId, regionConfig.name, uiPhase, progress.completed, progress.total, progress.message ?? `Downloading ${progress.phase}`, progress.overallPercentage);
|
|
13151
14349
|
},
|
|
13152
14350
|
tileOptions: {
|
|
13153
14351
|
maxConcurrency: 10,
|
|
@@ -13156,8 +14354,16 @@ class DownloadManager {
|
|
|
13156
14354
|
this.handleDownloadComplete(regionId);
|
|
13157
14355
|
}
|
|
13158
14356
|
catch (error) {
|
|
13159
|
-
|
|
13160
|
-
|
|
14357
|
+
if (controller.signal.aborted) {
|
|
14358
|
+
downloadLogger.debug(`Download of ${regionId} was cancelled`);
|
|
14359
|
+
}
|
|
14360
|
+
else {
|
|
14361
|
+
downloadLogger.error('Error downloading region:', error);
|
|
14362
|
+
this.handleDownloadError(regionId, error);
|
|
14363
|
+
}
|
|
14364
|
+
}
|
|
14365
|
+
finally {
|
|
14366
|
+
this.controllers.delete(regionId);
|
|
13161
14367
|
}
|
|
13162
14368
|
}
|
|
13163
14369
|
/**
|
|
@@ -13180,6 +14386,8 @@ class DownloadManager {
|
|
|
13180
14386
|
* @param regionId - The ID of the region download to cancel
|
|
13181
14387
|
*/
|
|
13182
14388
|
cancelDownload(regionId) {
|
|
14389
|
+
// Stops the download; downloadRegion then saves nothing and removes its partial tiles.
|
|
14390
|
+
this.controllers.get(regionId)?.abort();
|
|
13183
14391
|
this.currentDownloads.delete(regionId);
|
|
13184
14392
|
if (this.currentDownloads.size === 0) {
|
|
13185
14393
|
this.resetUI();
|
|
@@ -13191,6 +14399,8 @@ class DownloadManager {
|
|
|
13191
14399
|
* Clears all download tracking and resets the UI.
|
|
13192
14400
|
*/
|
|
13193
14401
|
cancelAllDownloads() {
|
|
14402
|
+
for (const controller of this.controllers.values())
|
|
14403
|
+
controller.abort();
|
|
13194
14404
|
this.currentDownloads.clear();
|
|
13195
14405
|
this.resetUI();
|
|
13196
14406
|
this.options.onProgressUpdate?.(this.currentDownloads);
|
|
@@ -13340,6 +14550,9 @@ class OfflineManagerControl {
|
|
|
13340
14550
|
regionControl;
|
|
13341
14551
|
downloadManager;
|
|
13342
14552
|
modalManager = new ModalManager();
|
|
14553
|
+
/** Percentages of this batch's downloads, finished ones at 100, for the badge. */
|
|
14554
|
+
badgeBatch = new Map();
|
|
14555
|
+
badgePercentage = 0;
|
|
13343
14556
|
/** Whether the bounding box visualization layer has been added to the map */
|
|
13344
14557
|
bboxLayerAdded = false;
|
|
13345
14558
|
/** Map library module for protocol registration (null if not provided) */
|
|
@@ -13394,8 +14607,11 @@ class OfflineManagerControl {
|
|
|
13394
14607
|
this.mapLib.addProtocol('idb', async (params) => {
|
|
13395
14608
|
const response = await idbFetchHandler(params.url);
|
|
13396
14609
|
if (!response.ok) {
|
|
13397
|
-
|
|
13398
|
-
|
|
14610
|
+
// MapLibre treats a 404 as "no tile here", which is normal once the map
|
|
14611
|
+
// moves outside a saved region. Any other error gets logged for every tile.
|
|
14612
|
+
throw Object.assign(new Error(`IDB fetch failed (${response.status}): ${params.url}`), {
|
|
14613
|
+
status: response.status,
|
|
14614
|
+
});
|
|
13399
14615
|
}
|
|
13400
14616
|
// MapLibre's makeRequest bypasses JSON parsing for protocol handlers,
|
|
13401
14617
|
// so we must parse JSON responses ourselves (e.g. sprite atlas, TileJSON).
|
|
@@ -13651,43 +14867,27 @@ class OfflineManagerControl {
|
|
|
13651
14867
|
* Handle progress updates from download manager
|
|
13652
14868
|
*/
|
|
13653
14869
|
handleProgressUpdate(downloads) {
|
|
13654
|
-
//
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
13661
|
-
|
|
13662
|
-
|
|
13663
|
-
|
|
13664
|
-
|
|
13665
|
-
|
|
13666
|
-
|
|
13667
|
-
|
|
13668
|
-
|
|
13669
|
-
|
|
13670
|
-
|
|
13671
|
-
|
|
13672
|
-
|
|
13673
|
-
case 'tiles':
|
|
13674
|
-
displayText = `${percentage}%`;
|
|
13675
|
-
break;
|
|
13676
|
-
default:
|
|
13677
|
-
displayText = `${percentage}%`;
|
|
13678
|
-
}
|
|
13679
|
-
}
|
|
13680
|
-
else {
|
|
13681
|
-
// Multiple downloads - show average
|
|
13682
|
-
let totalPercentage = 0;
|
|
13683
|
-
for (const download of downloads.values()) {
|
|
13684
|
-
totalPercentage += download.percentage;
|
|
13685
|
-
}
|
|
13686
|
-
const avgPercentage = Math.round(totalPercentage / downloads.size);
|
|
13687
|
-
displayText = `${avgPercentage}%`;
|
|
13688
|
-
}
|
|
13689
|
-
this.updateProgressBadge(displayText, true);
|
|
14870
|
+
// The panel has the detail for each phase; the badge only has room for one
|
|
14871
|
+
// figure. With several downloads it averages all of this batch's, counting
|
|
14872
|
+
// finished ones as 100, so one finishing doesn't drop the figure, and never
|
|
14873
|
+
// goes back: a download added mid-batch holds it until the average catches up.
|
|
14874
|
+
for (const [regionId, download] of downloads) {
|
|
14875
|
+
this.badgeBatch.set(regionId, download.percentage);
|
|
14876
|
+
}
|
|
14877
|
+
for (const regionId of this.badgeBatch.keys()) {
|
|
14878
|
+
if (!downloads.has(regionId))
|
|
14879
|
+
this.badgeBatch.set(regionId, 100);
|
|
14880
|
+
}
|
|
14881
|
+
if (downloads.size === 0) {
|
|
14882
|
+
this.badgeBatch.clear();
|
|
14883
|
+
this.badgePercentage = 0;
|
|
14884
|
+
}
|
|
14885
|
+
else {
|
|
14886
|
+
const total = [...this.badgeBatch.values()].reduce((sum, value) => sum + value, 0);
|
|
14887
|
+
this.badgePercentage = Math.max(this.badgePercentage, Math.round(total / this.badgeBatch.size));
|
|
14888
|
+
this.updateProgressBadge(`${this.badgePercentage}%`, true);
|
|
13690
14889
|
}
|
|
14890
|
+
this.panelRenderer?.updateDownloadProgress(downloads);
|
|
13691
14891
|
}
|
|
13692
14892
|
/**
|
|
13693
14893
|
* Handle download completion
|
|
@@ -13703,6 +14903,7 @@ class OfflineManagerControl {
|
|
|
13703
14903
|
*/
|
|
13704
14904
|
handleDownloadError(regionId, error) {
|
|
13705
14905
|
controlLogger.error(`Download error for region ${regionId}:`, error);
|
|
14906
|
+
alert(`${t('alert.downloadFailed')}: ${error instanceof Error ? error.message : t('alert.unknownError')}`);
|
|
13706
14907
|
// Use the panel's refresh method instead of renderPanel to avoid conflicts
|
|
13707
14908
|
if (this.panelRenderer) {
|
|
13708
14909
|
this.panelRenderer.refresh();
|
|
@@ -13846,7 +15047,7 @@ class OfflineManagerControl {
|
|
|
13846
15047
|
type: 'line',
|
|
13847
15048
|
source: sourceId,
|
|
13848
15049
|
paint: {
|
|
13849
|
-
'line-color': '#
|
|
15050
|
+
'line-color': '#A8116F', // Revision magenta, the UI's accent
|
|
13850
15051
|
'line-width': 3,
|
|
13851
15052
|
'line-opacity': 0.8,
|
|
13852
15053
|
},
|
|
@@ -13899,6 +15100,10 @@ class OfflineManagerControl {
|
|
|
13899
15100
|
controlLogger.error(`Style ${styleId} not found in IndexedDB`);
|
|
13900
15101
|
return;
|
|
13901
15102
|
}
|
|
15103
|
+
if (needsMapboxGl(styleEntry.style) && !isMapboxGlMap(this.map)) {
|
|
15104
|
+
controlLogger.error(`Style ${styleId} needs Mapbox GL JS, so this map can't show it`);
|
|
15105
|
+
return;
|
|
15106
|
+
}
|
|
13902
15107
|
// The style is already patched with idb:// URLs by addRegion() in regionService.
|
|
13903
15108
|
// DO NOT call patchStyleForOffline() again — double-patching rewrites the downloadId
|
|
13904
15109
|
// in URLs and can break tile/glyph lookups.
|
|
@@ -13957,9 +15162,9 @@ class OfflineManagerControl {
|
|
|
13957
15162
|
${styles
|
|
13958
15163
|
.map(style => `
|
|
13959
15164
|
<button class="w-full p-3 text-left border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-style-id="${escapeHtml$1(style.key)}">
|
|
13960
|
-
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(style
|
|
15165
|
+
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml$1(styleDisplayName(style))}</div>
|
|
13961
15166
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
13962
|
-
${style.regions?.length || 0}
|
|
15167
|
+
${regionCountLabel(style.regions?.length || 0)} •
|
|
13963
15168
|
${Object.keys(style.style.sources || {}).length} sources
|
|
13964
15169
|
</div>
|
|
13965
15170
|
</button>
|
|
@@ -14057,5 +15262,51 @@ class OfflineManagerControl {
|
|
|
14057
15262
|
}
|
|
14058
15263
|
}
|
|
14059
15264
|
|
|
14060
|
-
|
|
15265
|
+
const assetLogger = logger.scope('RuntimeAssets');
|
|
15266
|
+
const ASSET_TIMEOUT_MS = 30000;
|
|
15267
|
+
/**
|
|
15268
|
+
* Cache the code Mapbox GL fetches from api.mapbox.com while rendering: the
|
|
15269
|
+
* Draco and meshopt decoders 3D models need, plus the RTL text and procedural
|
|
15270
|
+
* building modules. The Service Worker serves them back from this cache. They
|
|
15271
|
+
* live in the Cache API rather than IndexedDB because they belong to the
|
|
15272
|
+
* renderer, not to any style or region.
|
|
15273
|
+
*
|
|
15274
|
+
* Failures only log: the rest of a region is still usable without these.
|
|
15275
|
+
*
|
|
15276
|
+
* @returns how many assets are now cached
|
|
15277
|
+
*/
|
|
15278
|
+
async function cacheMapboxRuntimeAssets() {
|
|
15279
|
+
if (typeof caches === 'undefined')
|
|
15280
|
+
return 0;
|
|
15281
|
+
let cache;
|
|
15282
|
+
try {
|
|
15283
|
+
cache = await caches.open(MAPBOX_RUNTIME_CACHE);
|
|
15284
|
+
}
|
|
15285
|
+
catch (error) {
|
|
15286
|
+
assetLogger.warn('Cache API unavailable, skipping runtime assets:', error);
|
|
15287
|
+
return 0;
|
|
15288
|
+
}
|
|
15289
|
+
const results = await Promise.all(MAPBOX_RUNTIME_ASSETS.map(async (path) => {
|
|
15290
|
+
const url = `${MAPBOX_RUNTIME_ORIGIN}${path}`;
|
|
15291
|
+
try {
|
|
15292
|
+
if (await cache.match(url))
|
|
15293
|
+
return true;
|
|
15294
|
+
const response = await fetchWithRetry(url, { retries: 0, timeout: ASSET_TIMEOUT_MS });
|
|
15295
|
+
await cache.put(url, response);
|
|
15296
|
+
return true;
|
|
15297
|
+
}
|
|
15298
|
+
catch (error) {
|
|
15299
|
+
assetLogger.warn(`Failed to cache ${url}:`, error);
|
|
15300
|
+
return false;
|
|
15301
|
+
}
|
|
15302
|
+
}));
|
|
15303
|
+
return results.filter(Boolean).length;
|
|
15304
|
+
}
|
|
15305
|
+
|
|
15306
|
+
var runtimeAssetService = /*#__PURE__*/Object.freeze({
|
|
15307
|
+
__proto__: null,
|
|
15308
|
+
cacheMapboxRuntimeAssets: cacheMapboxRuntimeAssets
|
|
15309
|
+
});
|
|
15310
|
+
|
|
15311
|
+
export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_BLOCK_SIZE, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAPBOX_STANDARD_SPARSE_TILESETS, MAP_PROVIDERS, MAX_GLYPH_CODEPOINT, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, configureSqlJs, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, extractTileExtensionFromUrl, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getSqlJs, getStyleStats, getStyleStorageSizes, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, injectExtraSources, isMapboxGlMap, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, needsMapboxGl, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, restoreUnstoredModels, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, selectTileJsonVariant, setupAutoCleanup, spriteService, stashOriginalModels, stopAutoCleanup, storedModelNames, styleDisplayName, t, tileService, unregisterOfflineServiceWorker, urlReferencesKnownSparseTileset, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
|
|
14061
15312
|
//# sourceMappingURL=index.esm.js.map
|