map-gl-offline 0.6.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -1
  2. package/dist/idb-offline-sw.js +313 -360
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.esm.js +1359 -1021
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/index.js +1373 -1020
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.umd.js +1373 -1020
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/managers/offlineMapManager/importExportManagement.d.ts +5 -3
  11. package/dist/managers/offlineMapManager/resourceManagement.d.ts +4 -0
  12. package/dist/services/fontService.d.ts +12 -2
  13. package/dist/services/glyphService.d.ts +11 -2
  14. package/dist/services/importExportService.d.ts +11 -26
  15. package/dist/services/modelService.d.ts +57 -0
  16. package/dist/services/resourceService.d.ts +11 -1
  17. package/dist/services/spriteService.d.ts +10 -3
  18. package/dist/style.css +1 -1
  19. package/dist/sw/offline-sw.d.ts +17 -0
  20. package/dist/sw/shared.d.ts +108 -0
  21. package/dist/types/database.d.ts +9 -0
  22. package/dist/types/import-export.d.ts +7 -28
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/model.d.ts +62 -0
  25. package/dist/types/region.d.ts +11 -1
  26. package/dist/types/style.d.ts +11 -2
  27. package/dist/ui/components/shared/PanelContent.d.ts +0 -1
  28. package/dist/ui/managers/PanelManager.d.ts +1 -1
  29. package/dist/ui/managers/downloadManager.d.ts +1 -1
  30. package/dist/ui/modals/{importExportModal.d.ts → mbtilesModal.d.ts} +18 -17
  31. package/dist/ui/translations/ar.d.ts +23 -37
  32. package/dist/ui/translations/en.d.ts +23 -37
  33. package/dist/utils/constants.d.ts +2 -1
  34. package/dist/utils/importResolver.d.ts +10 -0
  35. package/dist/utils/index.d.ts +1 -0
  36. package/dist/utils/sqlJsLoader.d.ts +17 -0
  37. package/dist/utils/styleProviderUtils.d.ts +16 -0
  38. package/dist/utils/tileKey.d.ts +13 -0
  39. package/package.json +7 -4
package/dist/index.esm.js CHANGED
@@ -12,7 +12,7 @@ import { polygon, convertArea, featureCollection } from '@turf/helpers';
12
12
  */
13
13
  // IndexedDB Configuration
14
14
  const DB_NAME = 'offline-map-db';
15
- const DB_VERSION = 3;
15
+ const DB_VERSION = 4;
16
16
  // Store Names (regions are stored inside styles.regions[], not as a separate store)
17
17
  const STORE_NAMES = {
18
18
  TILES: 'tiles',
@@ -20,6 +20,7 @@ const STORE_NAMES = {
20
20
  SPRITES: 'sprites',
21
21
  GLYPHS: 'glyphs',
22
22
  FONTS: 'fonts',
23
+ MODELS: 'models',
23
24
  };
24
25
  // Download Configuration
25
26
  const DOWNLOAD_DEFAULTS = {
@@ -227,7 +228,7 @@ async function resetOfflineMapDB() {
227
228
  * Called during initial database creation or when stores are missing.
228
229
  */
229
230
  function createStores(db) {
230
- const stores = ['regions', 'tiles', 'styles', 'sprites', 'glyphs', 'fonts'];
231
+ const stores = ['regions', 'tiles', 'styles', 'sprites', 'glyphs', 'fonts', 'models'];
231
232
  for (const storeName of stores) {
232
233
  if (!db.objectStoreNames.contains(storeName)) {
233
234
  db.createObjectStore(storeName, { keyPath: 'key' });
@@ -309,6 +310,7 @@ function migrateRegionsToStyles(transaction) {
309
310
  * - sprites: Sprite images and JSON
310
311
  * - glyphs: Font glyph data
311
312
  * - fonts: Font files
313
+ * - models: 3D model files (.glb) for Mapbox Standard tree/turbine layers
312
314
  * - regions: (deprecated) Legacy region storage, migrated to styles.regions[]
313
315
  *
314
316
  * @example
@@ -328,6 +330,9 @@ async function openOfflineMapDB() {
328
330
  if (oldVersion > 0 && oldVersion < 3) {
329
331
  migrateRegionsToStyles(transaction);
330
332
  }
333
+ // Migration: v3 -> v4
334
+ // Adds the `models` store for Mapbox Standard 3D model assets.
335
+ // No data migration needed — createStores above handles it.
331
336
  },
332
337
  });
333
338
  }
@@ -1162,6 +1167,22 @@ function parseTileKey(key) {
1162
1167
  }
1163
1168
  return { styleId, sourceId, z, x, y, ext };
1164
1169
  }
1170
+ /**
1171
+ * Extract the extension (the last dotted segment before `?`, `#`, or end) from
1172
+ * a tile URL or tile URL template. Defaults to `"pbf"` when no extension can
1173
+ * be parsed. For multi-extension URLs like Mapbox v4's `{y}.vector.pbf` this
1174
+ * returns `"pbf"`, matching the key used when the tile is stored.
1175
+ *
1176
+ * Keeping extraction logic in one place ensures patchStyleForOffline (which
1177
+ * rewrites tile URLs to `idb://` at load time) derives the same extension
1178
+ * that tileService.extractExtension used at store time — otherwise the
1179
+ * first-try lookup in idbFetchHandler misses and has to fall through its
1180
+ * pbf/mvt/png/jpg/webp fallback loop.
1181
+ */
1182
+ function extractTileExtensionFromUrl(url) {
1183
+ const match = url.match(/\.([\w]+)(?:[?#]|$)/i);
1184
+ return match ? match[1] : 'pbf';
1185
+ }
1165
1186
  /**
1166
1187
  * Derive tile extension from tile URL templates
1167
1188
  */
@@ -1169,21 +1190,217 @@ function deriveTileExtension(tiles) {
1169
1190
  if (Array.isArray(tiles) && tiles.length > 0) {
1170
1191
  const firstTile = tiles[0];
1171
1192
  if (typeof firstTile === 'string') {
1172
- const match = firstTile.match(/\.([\w]+)(?:\?|$)/i);
1173
- if (match) {
1174
- return match[1];
1175
- }
1193
+ return extractTileExtensionFromUrl(firstTile);
1176
1194
  }
1177
1195
  }
1178
1196
  return 'pbf';
1179
1197
  }
1180
1198
 
1199
+ /**
1200
+ * Pure helpers shared between the main-thread offline fetch handler
1201
+ * (`src/utils/idbFetchHandler.ts`) and the offline Service Worker
1202
+ * (`src/sw/offline-sw.ts`, compiled to `public/idb-offline-sw.js`).
1203
+ *
1204
+ * Keeping these in one place means the SW and the main-thread handler
1205
+ * can't drift — adding a new `model` handler, changing the fallback
1206
+ * order, or tweaking the tilejson-source matcher happens once.
1207
+ *
1208
+ * Nothing in here touches IndexedDB directly. Each helper takes already-
1209
+ * resolved inputs and returns the list of candidate keys (or the
1210
+ * resolved output) that the caller feeds into its own IDB lookup.
1211
+ *
1212
+ * The corresponding IDB access layer is:
1213
+ * - main thread: `idb` library via `dbPromise`
1214
+ * - service worker: raw `indexedDB.open` (see `offline-sw.ts`)
1215
+ *
1216
+ * They have different shapes so cannot be shared; the key computation
1217
+ * can be and is.
1218
+ */
1219
+ /**
1220
+ * Extensions to try in order when the requested extension misses. `glb` is
1221
+ * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
1222
+ * their source URL template ended in `.vector` or similar and the actual
1223
+ * tile body was stored as glb.
1224
+ */
1225
+ const TILE_FALLBACK_EXTENSIONS = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'];
1226
+ /** Extensions minus the one the caller already tried. */
1227
+ function tileFallbackExtensions(requested) {
1228
+ return TILE_FALLBACK_EXTENSIONS.filter(e => e !== requested);
1229
+ }
1230
+ // ---------------------------------------------------------------------------
1231
+ // Region → style lookup
1232
+ // ---------------------------------------------------------------------------
1233
+ /**
1234
+ * Given an already-fetched list of style entries, find the first one whose
1235
+ * `regions` array contains the given ID. Pure — the caller is responsible for
1236
+ * loading the entries and for caching. Used by both `findStyleByRegionId`
1237
+ * implementations to keep the match rule identical.
1238
+ */
1239
+ function findStyleByRegionIdIn(styles, regionId) {
1240
+ for (const entry of styles) {
1241
+ const regions = entry.regions;
1242
+ if (!Array.isArray(regions))
1243
+ continue;
1244
+ for (const r of regions) {
1245
+ if (r?.regionId === regionId || r?.id === regionId) {
1246
+ return entry;
1247
+ }
1248
+ }
1249
+ }
1250
+ return null;
1251
+ }
1252
+ // ---------------------------------------------------------------------------
1253
+ // Glyph candidate keys
1254
+ // ---------------------------------------------------------------------------
1255
+ /**
1256
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1257
+ * requests a comma-joined font-family fallback chain; each glyph is stored
1258
+ * individually, so the caller tries each fontstack in order.
1259
+ */
1260
+ function parseGlyphPath(decodedPath) {
1261
+ const pathParts = decodedPath.split('/');
1262
+ const fontstackPart = pathParts[0] ?? '';
1263
+ const rangePart = pathParts[1] || '0-255.pbf';
1264
+ const fontstacks = fontstackPart
1265
+ .split(',')
1266
+ .map(f => f.trim())
1267
+ .filter(Boolean);
1268
+ return { fontstacks, rangePart };
1269
+ }
1270
+ /**
1271
+ * Build the list of keys to try for a single (fontstack, range) pair.
1272
+ * Order: actualStyleId variants first (most common), then downloadId,
1273
+ * then the bare path. Normalized and raw `.pbf`-less forms are both tried
1274
+ * to cover stored-key variants from older versions.
1275
+ */
1276
+ function glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart) {
1277
+ const glyphPath = `${fontstack}/${rangePart}`;
1278
+ const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1279
+ return dedupe([
1280
+ `${actualStyleId}::${normalizedPath}`,
1281
+ `${actualStyleId}::${glyphPath}`,
1282
+ `${downloadId}::${normalizedPath}`,
1283
+ `${downloadId}::${glyphPath}`,
1284
+ normalizedPath,
1285
+ glyphPath,
1286
+ ]);
1287
+ }
1288
+ // ---------------------------------------------------------------------------
1289
+ // Sprite candidate keys
1290
+ // ---------------------------------------------------------------------------
1291
+ /**
1292
+ * Sprite keys have historically used both `::` and `:` as the separator, and
1293
+ * both the full filename (`sprite.json`) and the bare name (`sprite`). Return
1294
+ * every variant in priority order; the caller stops at the first hit.
1295
+ */
1296
+ function spriteCandidateKeys(actualStyleId, downloadId, decodedPath) {
1297
+ const stripExt = decodedPath.replace(/\.(json|png)$/i, '');
1298
+ return dedupe([
1299
+ `${actualStyleId}::${decodedPath}`,
1300
+ `${actualStyleId}:${decodedPath}`,
1301
+ `${actualStyleId}::${stripExt}`,
1302
+ `${actualStyleId}:${stripExt}`,
1303
+ `${downloadId}::${decodedPath}`,
1304
+ `${downloadId}:${decodedPath}`,
1305
+ `${downloadId}::${stripExt}`,
1306
+ `${downloadId}:${stripExt}`,
1307
+ decodedPath,
1308
+ ]);
1309
+ }
1310
+ // ---------------------------------------------------------------------------
1311
+ // Model candidate keys
1312
+ // ---------------------------------------------------------------------------
1313
+ /**
1314
+ * Model keys are `{styleId}::model::{name}`. Try the resolved style id first,
1315
+ * then the bare downloadId in case the request came through the region-scoped
1316
+ * URL form (`idb://{regionId}/model/{name}`).
1317
+ */
1318
+ function modelCandidateKeys(actualStyleId, downloadId, decodedPath) {
1319
+ return dedupe([
1320
+ `${actualStyleId}::model::${decodedPath}`,
1321
+ `${downloadId}::model::${decodedPath}`,
1322
+ ]);
1323
+ }
1324
+ // ---------------------------------------------------------------------------
1325
+ // TileJSON source matching
1326
+ // ---------------------------------------------------------------------------
1327
+ /**
1328
+ * Mapbox GL requests tilejson via `idb://{downloadId}/tilesjson/{path}` where
1329
+ * `{path}` may be the source id, the original TileJSON URL, or the URL we
1330
+ * stashed under `__originalTilesetUrl` when patching for offline. Try all
1331
+ * three; return the matching source id + its config, or null.
1332
+ */
1333
+ function matchTileJsonSource(sources, decodedPath) {
1334
+ const asConfig = (v) => v && typeof v === 'object' ? v : null;
1335
+ if (decodedPath in sources) {
1336
+ const config = asConfig(sources[decodedPath]);
1337
+ if (config)
1338
+ return { sourceId: decodedPath, config };
1339
+ }
1340
+ for (const [sourceId, raw] of Object.entries(sources)) {
1341
+ const config = asConfig(raw);
1342
+ if (!config)
1343
+ continue;
1344
+ const url = typeof config.url === 'string' ? config.url : undefined;
1345
+ const original = typeof config.__originalTilesetUrl === 'string'
1346
+ ? config.__originalTilesetUrl
1347
+ : undefined;
1348
+ if (url === decodedPath || original === decodedPath) {
1349
+ return { sourceId, config };
1350
+ }
1351
+ }
1352
+ return null;
1353
+ }
1354
+ /**
1355
+ * Build the offline TileJSON payload that replaces the one Mapbox would
1356
+ * have fetched from the network. `tiles` is rewritten to serve from the SW
1357
+ * (the caller supplies the scheme via `tileUrlScheme`); copyable TileJSON
1358
+ * fields are preserved.
1359
+ */
1360
+ function buildOfflineTileJson(sourceConfig, downloadId, sourceId, extension, tileUrlScheme, origin) {
1361
+ const base = `idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`
1362
+ ;
1363
+ const tileJson = {
1364
+ tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1365
+ name: sourceConfig.name ?? sourceId,
1366
+ tiles: [base],
1367
+ minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1368
+ maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1369
+ };
1370
+ const copyable = [
1371
+ 'bounds',
1372
+ 'center',
1373
+ 'vector_layers',
1374
+ 'scheme',
1375
+ 'attribution',
1376
+ 'encoding',
1377
+ 'format',
1378
+ 'grids',
1379
+ 'data',
1380
+ 'template',
1381
+ 'version',
1382
+ ];
1383
+ for (const field of copyable) {
1384
+ if (field in sourceConfig && sourceConfig[field] !== undefined) {
1385
+ tileJson[field] = sourceConfig[field];
1386
+ }
1387
+ }
1388
+ return tileJson;
1389
+ }
1390
+ // ---------------------------------------------------------------------------
1391
+ // Internal helpers
1392
+ // ---------------------------------------------------------------------------
1393
+ function dedupe(values) {
1394
+ return Array.from(new Set(values));
1395
+ }
1396
+
1181
1397
  // idbFetchHandler.ts
1182
1398
  // Intercepts idb:// URLs and serves resources from IndexedDB for MapLibre GL offline mode
1183
1399
  const idbLogger = logger.scope('IDBFetch');
1184
1400
  // idb://{downloadId}/tile/{sourceKey}/{url}
1185
1401
  // idb://{downloadId}/glyph/{fontstack}/{range}.pbf
1186
1402
  // idb://{downloadId}/sprite/{spriteName}
1403
+ // idb://{styleId}/model/{modelName}
1187
1404
  // idb://{downloadId}/tilesjson/{url}
1188
1405
  // Cache for region ID to style mapping to avoid repeated DB queries
1189
1406
  const regionToStyleCache = new Map();
@@ -1233,16 +1450,11 @@ async function findStyleByRegionId(db, regionId) {
1233
1450
  }
1234
1451
  try {
1235
1452
  const allStyles = await db.getAll('styles');
1236
- for (const styleEntry of allStyles) {
1237
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
1238
- const hasRegion = styleEntry.regions.some((r) => r.regionId === regionId || r.id === regionId);
1239
- if (hasRegion) {
1240
- idbLogger.debug(`Found style "${styleEntry.key}" containing region: ${regionId}`);
1241
- // Cache the result
1242
- regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
1243
- return styleEntry;
1244
- }
1245
- }
1453
+ const hit = findStyleByRegionIdIn(allStyles, regionId);
1454
+ if (hit) {
1455
+ idbLogger.debug(`Found style "${hit.key}" containing region: ${regionId}`);
1456
+ regionToStyleCache.set(regionId, { styleEntry: hit, timestamp: Date.now() });
1457
+ return hit;
1246
1458
  }
1247
1459
  idbLogger.debug(`No style found containing region: ${regionId}`);
1248
1460
  // Don't cache negative results — the region may be stored moments later
@@ -1254,36 +1466,6 @@ async function findStyleByRegionId(db, regionId) {
1254
1466
  return null;
1255
1467
  }
1256
1468
  }
1257
- function buildOfflineTileJson(sourceConfig, downloadId, sourceId) {
1258
- const extension = deriveTileExtension(sourceConfig.tiles);
1259
- const offlineTiles = [`idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`];
1260
- const tileJson = {
1261
- tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1262
- name: sourceConfig.name ?? sourceId,
1263
- tiles: offlineTiles,
1264
- minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1265
- maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1266
- };
1267
- const fieldsToCopy = [
1268
- 'bounds',
1269
- 'center',
1270
- 'vector_layers',
1271
- 'scheme',
1272
- 'attribution',
1273
- 'encoding',
1274
- 'format',
1275
- 'grids',
1276
- 'data',
1277
- 'template',
1278
- 'version',
1279
- ];
1280
- for (const field of fieldsToCopy) {
1281
- if (field in sourceConfig && sourceConfig[field] !== undefined) {
1282
- tileJson[field] = sourceConfig[field];
1283
- }
1284
- }
1285
- return tileJson;
1286
- }
1287
1469
  async function createTileResponse(resource) {
1288
1470
  const headers = {};
1289
1471
  // Set proper content type for vector tiles (PBF/MVT format)
@@ -1378,7 +1560,7 @@ async function idbFetchHandler(url, init) {
1378
1560
  // but tiles are stored with integer zoom levels, so floor the value
1379
1561
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1380
1562
  const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1381
- const yMatch = yExt.match(/(\d+)\.(\w+)/);
1563
+ const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1382
1564
  if (yMatch) {
1383
1565
  const y = parseInt(yMatch[1]);
1384
1566
  const requestedExt = yMatch[2]; // Extension from URL (for logging only)
@@ -1393,7 +1575,7 @@ async function idbFetchHandler(url, init) {
1393
1575
  }
1394
1576
  idbLogger.debug(`Tile not found: ${tileKey}`);
1395
1577
  // Fallback: try common alternative extensions
1396
- const fallbackExtensions = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'].filter(ext => ext !== requestedExt);
1578
+ const fallbackExtensions = tileFallbackExtensions(requestedExt);
1397
1579
  for (const fallbackExt of fallbackExtensions) {
1398
1580
  const fallbackKey = createTileKey(x, y, z, actualStyleId, sourceKey, fallbackExt);
1399
1581
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1435,7 +1617,7 @@ async function idbFetchHandler(url, init) {
1435
1617
  return await createTileResponse(resource);
1436
1618
  }
1437
1619
  // Try alternative extensions
1438
- const fallbackExts = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter(e => e !== ext);
1620
+ const fallbackExts = tileFallbackExtensions(ext);
1439
1621
  for (const fallbackExt of fallbackExts) {
1440
1622
  const fallbackKey = createTileKey(parseInt(x), parseInt(y), parseInt(z), actualStyleId, fallbackSourceKey, fallbackExt);
1441
1623
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1455,46 +1637,19 @@ async function idbFetchHandler(url, init) {
1455
1637
  }
1456
1638
  case 'glyph': {
1457
1639
  idbLogger.debug(`Looking for glyph with key: ${key}`);
1458
- // Find which style this region belongs to
1459
1640
  const styleEntry = await findStyleByRegionId(db, downloadId);
1460
1641
  const actualStyleId = styleEntry?.key || downloadId;
1461
- if (styleEntry && downloadId !== actualStyleId) {
1462
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1463
- }
1464
- // Parse the resource path: "FontA,FontB,FontC/0-255.pbf"
1465
- // MapLibre requests glyphs with comma-separated fallback fonts
1466
- // but glyphs are stored individually per font
1467
- const pathParts = decodedResourcePath.split('/');
1468
- const fontstackPart = pathParts[0]; // "FontA,FontB,FontC"
1469
- const rangePart = pathParts[1] || '0-255.pbf'; // "0-255.pbf"
1470
- // Split comma-separated fonts
1471
- const fontstacks = fontstackPart.split(',').map(f => f.trim());
1642
+ const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1472
1643
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1473
- // Try each font in order (this is how font fallbacks work)
1474
1644
  for (const fontstack of fontstacks) {
1475
- const glyphPath = `${fontstack}/${rangePart}`;
1476
- const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1477
- const glyphCandidateKeys = [
1478
- // Try with actual style ID first
1479
- `${actualStyleId}::${normalizedPath}`,
1480
- `${actualStyleId}::${glyphPath}`,
1481
- // Then try with download ID
1482
- `${downloadId}::${normalizedPath}`,
1483
- `${downloadId}::${glyphPath}`,
1484
- // Just paths
1485
- normalizedPath,
1486
- glyphPath,
1487
- ];
1488
- idbLogger.debug(`Trying keys for font "${fontstack}":`, glyphCandidateKeys);
1489
- for (const candidateKey of glyphCandidateKeys) {
1645
+ const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1646
+ for (const candidateKey of candidateKeys) {
1490
1647
  const resource = await db.get('glyphs', candidateKey);
1491
1648
  if (resource?.data) {
1492
1649
  idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1493
1650
  return new Response(resource.data, {
1494
1651
  status: 200,
1495
- headers: {
1496
- 'Content-Type': 'application/x-protobuf',
1497
- },
1652
+ headers: { 'Content-Type': 'application/x-protobuf' },
1498
1653
  });
1499
1654
  }
1500
1655
  }
@@ -1504,33 +1659,11 @@ async function idbFetchHandler(url, init) {
1504
1659
  }
1505
1660
  case 'sprite': {
1506
1661
  idbLogger.debug(`Looking for sprite with key: ${key}`);
1507
- // Find which style this region belongs to
1508
1662
  const styleEntry = await findStyleByRegionId(db, downloadId);
1509
1663
  const actualStyleId = styleEntry?.key || downloadId;
1510
- if (styleEntry && downloadId !== actualStyleId) {
1511
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1512
- }
1513
- // The sprite service stores sprites with keys like: "voyager::sprite.json", "voyager::sprite@2x.json"
1514
- // MapLibre requests sprites as: "idb://region_XXX/sprite/sprite@2x.json"
1515
- // So we need to map the region ID to the style ID
1516
- const spriteCandidateKeys = Array.from(new Set([
1517
- // Try with actual style ID first (most likely to work)
1518
- `${actualStyleId}::${decodedResourcePath}`,
1519
- `${actualStyleId}:${decodedResourcePath}`,
1520
- `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1521
- `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1522
- // Then try with download ID (in case it's a direct style download)
1523
- `${downloadId}::${decodedResourcePath}`,
1524
- `${downloadId}:${decodedResourcePath}`,
1525
- `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1526
- `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1527
- // Just the path itself
1528
- decodedResourcePath,
1529
- // Original key format
1530
- key,
1531
- ]));
1532
- idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, spriteCandidateKeys);
1533
- for (const candidateKey of spriteCandidateKeys) {
1664
+ const candidates = spriteCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1665
+ idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, candidates);
1666
+ for (const candidateKey of candidates) {
1534
1667
  const resource = await db.get('sprites', candidateKey);
1535
1668
  if (resource?.data) {
1536
1669
  idbLogger.debug(`Found sprite using key: ${candidateKey}`);
@@ -1540,7 +1673,7 @@ async function idbFetchHandler(url, init) {
1540
1673
  });
1541
1674
  }
1542
1675
  }
1543
- idbLogger.warn(`Sprite not found, tried keys: ${spriteCandidateKeys.join(', ')}`);
1676
+ idbLogger.warn(`Sprite not found, tried keys: ${candidates.join(', ')}`);
1544
1677
  break;
1545
1678
  }
1546
1679
  case 'font': {
@@ -1555,11 +1688,32 @@ async function idbFetchHandler(url, init) {
1555
1688
  }
1556
1689
  break;
1557
1690
  }
1691
+ case 'model': {
1692
+ // Model URLs are rewritten by patchStyleForOffline to
1693
+ // idb://{styleId}/model/{modelName}
1694
+ // Models are keyed by {styleId}::model::{modelName} in the store.
1695
+ const styleEntry = await findStyleByRegionId(db, downloadId);
1696
+ const actualStyleId = styleEntry?.key || downloadId;
1697
+ const candidates = modelCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1698
+ idbLogger.debug(`Model candidates for "${decodedResourcePath}":`, candidates);
1699
+ for (const candidateKey of candidates) {
1700
+ const resource = await db.get('models', candidateKey);
1701
+ if (resource?.data) {
1702
+ idbLogger.debug(`Found model using key: ${candidateKey}`);
1703
+ return new Response(resource.data, {
1704
+ status: 200,
1705
+ headers: { 'Content-Type': resource.contentType || 'model/gltf-binary' },
1706
+ });
1707
+ }
1708
+ }
1709
+ idbLogger.warn(`Model not found, tried keys: ${candidates.join(', ')}`);
1710
+ break;
1711
+ }
1558
1712
  case 'tilesjson': {
1559
1713
  idbLogger.debug(`Looking for tilejson with downloadId: ${downloadId}, resourcePath: ${decodedResourcePath}`);
1560
- // First try direct lookup (for style-level downloads)
1714
+ // First try direct lookup (for style-level downloads), then fall back
1715
+ // to searching by region ID (for region-level downloads).
1561
1716
  let styleEntry = await db.get('styles', downloadId);
1562
- // If not found, search by region ID (for region-level downloads)
1563
1717
  if (!styleEntry || !styleEntry.style?.sources) {
1564
1718
  idbLogger.debug(`Style not found with key "${downloadId}", searching by region ID...`);
1565
1719
  const foundStyle = await findStyleByRegionId(db, downloadId);
@@ -1567,41 +1721,23 @@ async function idbFetchHandler(url, init) {
1567
1721
  styleEntry = foundStyle;
1568
1722
  }
1569
1723
  }
1570
- if (styleEntry?.style?.sources) {
1571
- const sources = styleEntry.style.sources;
1572
- let matchedSourceId;
1573
- let matchedSourceConfig;
1574
- if (decodedResourcePath in sources) {
1575
- matchedSourceId = decodedResourcePath;
1576
- matchedSourceConfig = sources[decodedResourcePath];
1577
- }
1578
- else {
1579
- for (const [sourceId, sourceValue] of Object.entries(sources)) {
1580
- const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
1581
- const originalUrl = typeof sourceValue.__originalTilesetUrl === 'string'
1582
- ? sourceValue.__originalTilesetUrl
1583
- : undefined;
1584
- if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
1585
- matchedSourceId = sourceId;
1586
- matchedSourceConfig = sourceValue;
1587
- break;
1588
- }
1589
- }
1590
- }
1591
- if (matchedSourceId && matchedSourceConfig) {
1592
- const tileJson = buildOfflineTileJson(matchedSourceConfig, downloadId, matchedSourceId);
1593
- idbLogger.debug(`Serving offline tilejson for source: ${matchedSourceId}`);
1594
- return new Response(JSON.stringify(tileJson), {
1595
- status: 200,
1596
- headers: { 'Content-Type': 'application/json' },
1597
- });
1598
- }
1599
- idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1600
- }
1601
- else {
1724
+ if (!styleEntry?.style?.sources) {
1602
1725
  idbLogger.warn(`Style not found or missing sources for downloadId: ${downloadId}`);
1726
+ break;
1603
1727
  }
1604
- break;
1728
+ const sources = styleEntry.style.sources;
1729
+ const matched = matchTileJsonSource(sources, decodedResourcePath);
1730
+ if (!matched) {
1731
+ idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1732
+ break;
1733
+ }
1734
+ const extension = deriveTileExtension(matched.config.tiles);
1735
+ const tileJson = buildOfflineTileJson(matched.config, downloadId, matched.sourceId, extension, 'idb');
1736
+ idbLogger.debug(`Serving offline tilejson for source: ${matched.sourceId}`);
1737
+ return new Response(JSON.stringify(tileJson), {
1738
+ status: 200,
1739
+ headers: { 'Content-Type': 'application/json' },
1740
+ });
1605
1741
  }
1606
1742
  default:
1607
1743
  idbLogger.warn(`Unknown resource type: ${type}`);
@@ -1625,6 +1761,37 @@ async function idbFetchHandler(url, init) {
1625
1761
  function isMapboxProtocol(url) {
1626
1762
  return url.startsWith(MAPBOX_API.PROTOCOL);
1627
1763
  }
1764
+ /**
1765
+ * Parse a URL and return its hostname, or null if the URL is malformed.
1766
+ * Accepts relative URLs when `base` is provided.
1767
+ */
1768
+ function getUrlHostname(url, base) {
1769
+ try {
1770
+ return new URL(url, base).hostname.toLowerCase();
1771
+ }
1772
+ catch {
1773
+ return null;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * True if `url`'s hostname equals `host` or is a subdomain of `host`.
1778
+ * Uses URL parsing (not substring matching) to avoid false positives like
1779
+ * `https://evil.com/?x=mapbox.com` matching `mapbox.com`.
1780
+ */
1781
+ function hostMatches(url, host, base) {
1782
+ const hostname = getUrlHostname(url, base);
1783
+ if (hostname === null)
1784
+ return false;
1785
+ const target = host.toLowerCase();
1786
+ return hostname === target || hostname.endsWith('.' + target);
1787
+ }
1788
+ /**
1789
+ * True for any host under the mapbox.com domain (including api.mapbox.com,
1790
+ * *.tiles.mapbox.com, etc.). Used by provider detection.
1791
+ */
1792
+ function isMapboxHost(url, base) {
1793
+ return hostMatches(url, 'mapbox.com', base);
1794
+ }
1628
1795
  /**
1629
1796
  * Resolve a mapbox:// URL to its HTTPS API equivalent
1630
1797
  *
@@ -1698,9 +1865,7 @@ function rewriteMapboxCdnTileUrl(tileUrl) {
1698
1865
  */
1699
1866
  function detectStyleProvider(styleUrl, style) {
1700
1867
  // Check URL patterns
1701
- if (isMapboxProtocol(styleUrl) ||
1702
- styleUrl.includes('mapbox.com') ||
1703
- styleUrl.includes('api.mapbox.com')) {
1868
+ if (isMapboxProtocol(styleUrl) || isMapboxHost(styleUrl)) {
1704
1869
  return 'mapbox';
1705
1870
  }
1706
1871
  if (styleUrl.includes('maplibre') ||
@@ -1719,7 +1884,7 @@ function detectStyleProvider(styleUrl, style) {
1719
1884
  const sources = style.sources || {};
1720
1885
  for (const [, sourceConfig] of Object.entries(sources)) {
1721
1886
  const source = sourceConfig;
1722
- if (source.url && (source.url.includes('mapbox.com') || isMapboxProtocol(source.url))) {
1887
+ if (source.url && (isMapboxProtocol(source.url) || isMapboxHost(source.url))) {
1723
1888
  return 'mapbox';
1724
1889
  }
1725
1890
  }
@@ -1811,7 +1976,7 @@ function processStyleSources(style, provider, accessToken) {
1811
1976
  if (isMapboxProtocol(tileUrl) && accessToken) {
1812
1977
  return resolveMapboxUrl(tileUrl, accessToken);
1813
1978
  }
1814
- if (provider === 'mapbox' && accessToken && tileUrl.includes('mapbox.com')) {
1979
+ if (provider === 'mapbox' && accessToken && isMapboxHost(tileUrl)) {
1815
1980
  return normalizeStyleUrl(tileUrl, accessToken);
1816
1981
  }
1817
1982
  return tileUrl;
@@ -1826,7 +1991,7 @@ function processStyleSources(style, provider, accessToken) {
1826
1991
  if (isMapboxProtocol(processedStyle.sprite)) {
1827
1992
  processedStyle.sprite = resolveMapboxUrl(processedStyle.sprite, accessToken);
1828
1993
  }
1829
- else if (provider === 'mapbox' && processedStyle.sprite.includes('mapbox.com')) {
1994
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.sprite)) {
1830
1995
  processedStyle.sprite = normalizeStyleUrl(processedStyle.sprite, accessToken);
1831
1996
  }
1832
1997
  }
@@ -1837,7 +2002,7 @@ function processStyleSources(style, provider, accessToken) {
1837
2002
  if (isMapboxProtocol(entry.url)) {
1838
2003
  return { ...entry, url: resolveMapboxUrl(entry.url, accessToken) };
1839
2004
  }
1840
- else if (provider === 'mapbox' && entry.url.includes('mapbox.com')) {
2005
+ else if (provider === 'mapbox' && isMapboxHost(entry.url)) {
1841
2006
  return { ...entry, url: normalizeStyleUrl(entry.url, accessToken) };
1842
2007
  }
1843
2008
  }
@@ -1850,7 +2015,7 @@ function processStyleSources(style, provider, accessToken) {
1850
2015
  if (isMapboxProtocol(processedStyle.glyphs)) {
1851
2016
  processedStyle.glyphs = resolveMapboxUrl(processedStyle.glyphs, accessToken);
1852
2017
  }
1853
- else if (provider === 'mapbox' && processedStyle.glyphs.includes('mapbox.com')) {
2018
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.glyphs)) {
1854
2019
  processedStyle.glyphs = normalizeStyleUrl(processedStyle.glyphs, accessToken);
1855
2020
  }
1856
2021
  }
@@ -1890,13 +2055,20 @@ function validateStyleForProvider(style, provider) {
1890
2055
  // Check for Mapbox-specific requirements
1891
2056
  const hasMapboxSources = Object.values(style.sources || {}).some((source) => {
1892
2057
  const s = source;
1893
- return s.url && s.url.includes('mapbox.com');
2058
+ return !!s.url && isMapboxHost(s.url);
1894
2059
  });
1895
2060
  if (hasMapboxSources) {
1896
2061
  // Check if access token might be needed
1897
2062
  const hasAccessToken = Object.values(style.sources || {}).some((source) => {
1898
2063
  const s = source;
1899
- return s.url && s.url.includes('access_token');
2064
+ if (!s.url)
2065
+ return false;
2066
+ try {
2067
+ return new URL(s.url).searchParams.has('access_token');
2068
+ }
2069
+ catch {
2070
+ return false;
2071
+ }
1900
2072
  });
1901
2073
  if (!hasAccessToken) {
1902
2074
  warnings.push('Mapbox sources detected but no access token found - authentication may be required');
@@ -1930,14 +2102,15 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
1930
2102
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
1931
2103
  if (source.tiles) {
1932
2104
  const originalTiles = [...source.tiles];
1933
- // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext
2105
+ // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2106
+ // Extension extraction goes through the shared extractTileExtensionFromUrl
2107
+ // helper so the patched URL's extension matches what tileService used when
2108
+ // storing — otherwise Mapbox v4 tile URLs (`{y}.vector.pbf`) produced a
2109
+ // stored key under `.pbf` but a patched URL with `.vector`, forcing
2110
+ // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2111
+ // on every tile.
1934
2112
  source.tiles = source.tiles.map((url) => {
1935
- // Use stored tileExtension if available, otherwise try to extract from URL
1936
- let ext = tileExtension;
1937
- if (!ext) {
1938
- const extMatch = url.match(/\{z\}\/\{x\}\/\{y\}\.(\w+)/);
1939
- ext = extMatch ? extMatch[1] : 'pbf';
1940
- }
2113
+ const ext = tileExtension ?? extractTileExtensionFromUrl(url);
1941
2114
  return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
1942
2115
  });
1943
2116
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
@@ -2010,14 +2183,29 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
2010
2183
  });
2011
2184
  }
2012
2185
  }
2013
- // Patch top-level models (Mapbox Standard 3D landmarks)
2186
+ // Patch top-level models (Mapbox Standard 3D trees / wind turbines).
2187
+ // Two shapes exist in the wild:
2188
+ // - Mapbox Standard: `{ "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb" }` (string values)
2189
+ // - Older/generic: `{ "name": { "uri": "mapbox://..." } }` (object values)
2190
+ // Models are keyed on the style ID (like sprites) so they can be shared
2191
+ // across regions.
2014
2192
  if (style.models) {
2015
- for (const [modelId, modelConfig] of Object.entries(style.models)) {
2016
- if (modelConfig.uri) {
2017
- modelConfig.uri = `idb://${downloadId}/model/${modelId}`;
2193
+ const modelBaseId = styleId || downloadId;
2194
+ const models = style.models;
2195
+ let patchedCount = 0;
2196
+ for (const [modelId, value] of Object.entries(models)) {
2197
+ if (typeof value === 'string') {
2198
+ models[modelId] = `idb://${modelBaseId}/model/${modelId}`;
2199
+ patchedCount++;
2200
+ }
2201
+ else if (value && typeof value === 'object' && 'uri' in value && value.uri) {
2202
+ value.uri = `idb://${modelBaseId}/model/${modelId}`;
2203
+ patchedCount++;
2018
2204
  }
2019
2205
  }
2020
- styleLogger.debug(`Patched ${Object.keys(style.models).length} model URIs`);
2206
+ if (patchedCount > 0) {
2207
+ styleLogger.debug(`Patched ${patchedCount} model URIs (styleId: ${modelBaseId})`);
2208
+ }
2021
2209
  }
2022
2210
  styleLogger.debug(`Final patched style:`, style);
2023
2211
  return style;
@@ -2585,12 +2773,22 @@ function convertStyleForServiceWorker(style) {
2585
2773
  });
2586
2774
  }
2587
2775
  }
2588
- // Convert models
2776
+ // Convert models. Two shapes in the wild:
2777
+ // - Mapbox Standard: `{ name: "idb://..." }` (string value)
2778
+ // - Older/generic: `{ name: { uri: "idb://..." } }` (object value)
2589
2779
  if (converted.models) {
2590
- for (const modelKey of Object.keys(converted.models)) {
2591
- const model = converted.models[modelKey];
2592
- if (model.uri && typeof model.uri === 'string' && model.uri.startsWith('idb://')) {
2593
- model.uri = replace(model.uri);
2780
+ const models = converted.models;
2781
+ for (const modelKey of Object.keys(models)) {
2782
+ const value = models[modelKey];
2783
+ if (typeof value === 'string') {
2784
+ if (value.startsWith('idb://')) {
2785
+ models[modelKey] = replace(value);
2786
+ }
2787
+ }
2788
+ else if (value && typeof value === 'object') {
2789
+ if (typeof value.uri === 'string' && value.uri.startsWith('idb://')) {
2790
+ value.uri = replace(value.uri);
2791
+ }
2594
2792
  }
2595
2793
  }
2596
2794
  }
@@ -2790,10 +2988,8 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2790
2988
  if (typeof prefixedLayer.source === 'string') {
2791
2989
  prefixedLayer.source = `${importId}/${prefixedLayer.source}`;
2792
2990
  }
2793
- // Resolve ["config", "key"] expressions using schema defaults and import overrides
2794
- if (Object.keys(configValues).length > 0) {
2795
- resolveConfigExpressions(prefixedLayer, configValues);
2796
- }
2991
+ // Resolve ["config", "key"] expressions using schema defaults and import overrides.
2992
+ resolveConfigExpressions(prefixedLayer, configValues);
2797
2993
  flattenedLayers.push(prefixedLayer);
2798
2994
  }
2799
2995
  }
@@ -2831,8 +3027,48 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2831
3027
  if (!style.models && importedModels) {
2832
3028
  style.models = importedModels;
2833
3029
  }
3030
+ // Rewrite indoor-only expressions so the flattened style validates without
3031
+ // the `imports` wrapper at render time — see sanitizeIndoorExpressions.
3032
+ sanitizeIndoorExpressions(style);
2834
3033
  return style;
2835
3034
  }
3035
+ /**
3036
+ * Rewrite indoor-only expressions in a style's layers to their outdoor no-op
3037
+ * constants. See the in-line comment in `resolveValue` for why this is needed
3038
+ * for Mapbox Standard when the `imports` wrapper is stripped.
3039
+ *
3040
+ * Safe to call multiple times and on already-downloaded stored styles — the
3041
+ * rewrites are idempotent (after the first pass there are no more
3042
+ * `is-active-floor` / `floor-level` expressions to rewrite).
3043
+ */
3044
+ function sanitizeIndoorExpressions(style) {
3045
+ const layers = style.layers;
3046
+ if (!Array.isArray(layers))
3047
+ return;
3048
+ for (const layer of layers) {
3049
+ if (layer && typeof layer === 'object') {
3050
+ rewriteIndoor(layer);
3051
+ }
3052
+ }
3053
+ }
3054
+ function rewriteIndoor(obj) {
3055
+ for (const key of Object.keys(obj)) {
3056
+ obj[key] = rewriteIndoorValue(obj[key]);
3057
+ }
3058
+ }
3059
+ function rewriteIndoorValue(value) {
3060
+ if (!Array.isArray(value)) {
3061
+ if (value && typeof value === 'object' && !ArrayBuffer.isView(value)) {
3062
+ rewriteIndoor(value);
3063
+ }
3064
+ return value;
3065
+ }
3066
+ if (value[0] === 'is-active-floor')
3067
+ return false;
3068
+ if (value[0] === 'floor-level' && value.length === 1)
3069
+ return 0;
3070
+ return value.map(rewriteIndoorValue);
3071
+ }
2836
3072
  /**
2837
3073
  * Deep clone a plain object/array (JSON-safe values only).
2838
3074
  */
@@ -2998,6 +3234,40 @@ function mergeSprites(outer, imported) {
2998
3234
  return result;
2999
3235
  }
3000
3236
 
3237
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3238
+ let currentConfig = {};
3239
+ let sqlJsPromise = null;
3240
+ /**
3241
+ * Override how `sql.js` loads its WebAssembly. Call once before any MBTiles
3242
+ * import/export is invoked. Resets any cached init.
3243
+ */
3244
+ function configureSqlJs(config) {
3245
+ currentConfig = { ...config };
3246
+ sqlJsPromise = null;
3247
+ }
3248
+ /**
3249
+ * Lazily initialise `sql.js`. The underlying module is loaded via dynamic
3250
+ * `import()` so it only ships with bundles that actually call MBTiles code.
3251
+ */
3252
+ async function getSqlJs() {
3253
+ if (sqlJsPromise)
3254
+ return sqlJsPromise;
3255
+ sqlJsPromise = (async () => {
3256
+ const mod = (await import('sql.js'));
3257
+ const initSqlJs = mod.default;
3258
+ const options = {};
3259
+ if (currentConfig.wasmBinary) {
3260
+ options.wasmBinary = currentConfig.wasmBinary;
3261
+ }
3262
+ else {
3263
+ const base = currentConfig.wasmUrl ?? DEFAULT_WASM_URL;
3264
+ options.locateFile = (file) => base.endsWith('/') ? `${base}${file}` : `${base}/${file}`;
3265
+ }
3266
+ return initSqlJs(options);
3267
+ })();
3268
+ return sqlJsPromise;
3269
+ }
3270
+
3001
3271
  const fontLogger = logger.scope('FontService');
3002
3272
  class FontService {
3003
3273
  db = dbPromise;
@@ -3181,15 +3451,23 @@ class FontService {
3181
3451
  },
3182
3452
  };
3183
3453
  }
3184
- async cleanupOldFonts(maxAge = 30) {
3454
+ /**
3455
+ * Delete fonts older than `maxAge` days. When `options.styleId` is
3456
+ * provided, only fonts belonging to that style (per the delimiter-aware
3457
+ * `resourceKeyBelongsToStyle` match) are eligible — callers relying on
3458
+ * a styleId filter previously got a silent full-store wipe.
3459
+ */
3460
+ async cleanupOldFonts(maxAge = 30, options = {}) {
3185
3461
  const db = await this.db;
3186
3462
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3463
+ const { styleId } = options;
3187
3464
  const tx = db.transaction(['fonts'], 'readwrite');
3188
3465
  let deletedCount = 0;
3189
3466
  let cursor = await tx.objectStore('fonts').openCursor();
3190
3467
  while (cursor) {
3191
3468
  const fontEntry = cursor.value;
3192
- if (fontEntry.lastModified < cutoffTime) {
3469
+ const belongs = !styleId || resourceKeyBelongsToStyle(fontEntry.key, styleId);
3470
+ if (belongs && fontEntry.lastModified < cutoffTime) {
3193
3471
  await cursor.delete();
3194
3472
  deletedCount++;
3195
3473
  }
@@ -3355,7 +3633,7 @@ const fontService = new FontService();
3355
3633
  const downloadFonts = (fontUrls, styleName, options) => fontService.downloadFonts(fontUrls, styleName, options);
3356
3634
  const getFontStats = () => fontService.getFontStats();
3357
3635
  const getFontAnalytics = () => fontService.getFontAnalytics();
3358
- const cleanupOldFonts = (maxAge) => fontService.cleanupOldFonts(maxAge);
3636
+ const cleanupOldFonts = (maxAge, options) => fontService.cleanupOldFonts(maxAge, options);
3359
3637
  const verifyAndRepairFonts = () => fontService.verifyAndRepairFonts();
3360
3638
 
3361
3639
  const spriteLogger = logger.scope('SpriteService');
@@ -3666,19 +3944,24 @@ class SpriteService {
3666
3944
  };
3667
3945
  }
3668
3946
  /**
3669
- * Removes sprites older than the specified age
3947
+ * Remove sprites older than the specified age. When `options.styleId` is
3948
+ * provided, only sprites belonging to that style (per
3949
+ * `resourceKeyBelongsToStyle`) are eligible.
3670
3950
  * @param maxAge - Maximum age in days (default: 30)
3951
+ * @param options.styleId - Optional style filter; omit to scan all styles
3671
3952
  * @returns Promise resolving to number of deleted sprites
3672
3953
  */
3673
- async cleanupOldSprites(maxAge = 30) {
3954
+ async cleanupOldSprites(maxAge = 30, options = {}) {
3674
3955
  const db = await this.db;
3675
3956
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3957
+ const { styleId } = options;
3676
3958
  const tx = db.transaction(['sprites'], 'readwrite');
3677
3959
  let deletedCount = 0;
3678
3960
  let cursor = await tx.objectStore('sprites').openCursor();
3679
3961
  while (cursor) {
3680
3962
  const spriteEntry = cursor.value;
3681
- if (spriteEntry.lastModified < cutoffTime) {
3963
+ const belongs = !styleId || resourceKeyBelongsToStyle(spriteEntry.key, styleId);
3964
+ if (belongs && spriteEntry.lastModified < cutoffTime) {
3682
3965
  await cursor.delete();
3683
3966
  deletedCount++;
3684
3967
  }
@@ -3832,7 +4115,7 @@ const spriteService = new SpriteService();
3832
4115
  const downloadSprites = (spriteUrls, styleName, options) => spriteService.downloadSprites(spriteUrls, styleName, options);
3833
4116
  const getSpriteStats = () => spriteService.getSpriteStats();
3834
4117
  const getSpriteAnalytics = () => spriteService.getSpriteAnalytics();
3835
- const cleanupOldSprites = (maxAge) => spriteService.cleanupOldSprites(maxAge);
4118
+ const cleanupOldSprites = (maxAge, options) => spriteService.cleanupOldSprites(maxAge, options);
3836
4119
  const verifyAndRepairSprites = () => spriteService.verifyAndRepairSprites();
3837
4120
 
3838
4121
  var spriteService$1 = /*#__PURE__*/Object.freeze({
@@ -4911,7 +5194,15 @@ class RegionService {
4911
5194
  deletedSprites++;
4912
5195
  }
4913
5196
  }
4914
- regionLogger$1.info(`Deleted style resources: ${deletedFonts} fonts, ${deletedGlyphs} glyphs, ${deletedSprites} sprites`);
5197
+ let deletedModels = 0;
5198
+ const modelTx = db.transaction('models', 'readwrite');
5199
+ for await (const cursor of modelTx.store) {
5200
+ if (resourceKeyBelongsToStyle(cursor.value.key, styleId)) {
5201
+ await cursor.delete();
5202
+ deletedModels++;
5203
+ }
5204
+ }
5205
+ regionLogger$1.info(`Deleted style resources: ${deletedFonts} fonts, ${deletedGlyphs} glyphs, ${deletedSprites} sprites, ${deletedModels} models`);
4915
5206
  }
4916
5207
  /**
4917
5208
  * Delete all tiles for a style
@@ -5019,7 +5310,7 @@ class RegionService {
5019
5310
  if (!region.styleUrl) {
5020
5311
  throw new Error('Region must have a styleUrl');
5021
5312
  }
5022
- const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, glyphRanges, tileOptions, } = options;
5313
+ const { onProgress, provider = 'auto', accessToken, skipGlyphs = false, skipSprites = false, skipModels = false, glyphRanges, tileOptions, } = options;
5023
5314
  const emit = (phase, completed, total, message) => {
5024
5315
  if (!onProgress)
5025
5316
  return;
@@ -5074,8 +5365,13 @@ class RegionService {
5074
5365
  const spriteSources = normalizeSpriteProperty(originalSpriteUrl);
5075
5366
  if (spriteSources.length > 0) {
5076
5367
  const { downloadSprites } = await Promise.resolve().then(function () { return spriteService$1; });
5077
- const suffixes = ['.json', '.png', '@2x.json', '@2x.png'];
5078
- const totalFiles = spriteSources.length * suffixes.length;
5368
+ // Standard four sprite variants. For Mapbox Standard, an `iconset.pbf`
5369
+ // sibling is also served under the same /styles/v1/.../<hash>/ path
5370
+ // — we detect that case per-source below and append it to the list.
5371
+ const baseSuffixes = ['.json', '.png', '@2x.json', '@2x.png'];
5372
+ // Estimate total files assuming iconset is always included (the actual
5373
+ // number may be smaller; the emit helper clamps progress to total).
5374
+ const totalFiles = spriteSources.length * (baseSuffixes.length + 1);
5079
5375
  let completed = 0;
5080
5376
  emit('sprites', 0, totalFiles, 'Downloading sprites');
5081
5377
  for (const source of spriteSources) {
@@ -5084,9 +5380,37 @@ class RegionService {
5084
5380
  spriteBase = resolveMapboxUrl(spriteBase, effectiveAccessToken);
5085
5381
  }
5086
5382
  const qIndex = spriteBase.indexOf('?');
5087
- const spriteUrls = suffixes.map(suffix => qIndex !== -1
5088
- ? spriteBase.slice(0, qIndex) + suffix + spriteBase.slice(qIndex)
5089
- : spriteBase + suffix);
5383
+ const suffixes = [...baseSuffixes];
5384
+ // Mapbox Standard serves an iconset.pbf alongside the sprite under
5385
+ // /styles/v1/{owner}/{style}/{hash}/sprite the sibling file is
5386
+ // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5387
+ // segment is `sprite`, so replacing it with `iconset.pbf` works.
5388
+ const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5389
+ let isMapboxStandardSprite = false;
5390
+ try {
5391
+ const parsed = new URL(pathWithoutQuery);
5392
+ isMapboxStandardSprite =
5393
+ parsed.hostname === 'api.mapbox.com' &&
5394
+ parsed.pathname.startsWith('/styles/v1/') &&
5395
+ parsed.pathname.endsWith('/sprite');
5396
+ }
5397
+ catch {
5398
+ // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5399
+ }
5400
+ if (isMapboxStandardSprite) {
5401
+ // The path-rewrite suffix replaces the trailing `sprite` segment.
5402
+ suffixes.push('__ICONSET__');
5403
+ }
5404
+ const spriteUrls = suffixes.map(suffix => {
5405
+ if (suffix === '__ICONSET__') {
5406
+ // Replace trailing `sprite` with `iconset.pbf`, preserving query.
5407
+ const base = pathWithoutQuery.replace(/sprite$/, 'iconset.pbf');
5408
+ return qIndex !== -1 ? base + spriteBase.slice(qIndex) : base;
5409
+ }
5410
+ return qIndex !== -1
5411
+ ? spriteBase.slice(0, qIndex) + suffix + spriteBase.slice(qIndex)
5412
+ : spriteBase + suffix;
5413
+ });
5090
5414
  try {
5091
5415
  const result = await downloadSprites(spriteUrls, styleId, {
5092
5416
  enableValidation: true,
@@ -5132,7 +5456,42 @@ class RegionService {
5132
5456
  }
5133
5457
  }
5134
5458
  }
5135
- // 4. Tilesuse the stored (source-embedded) style, which still has HTTP tile URLs
5459
+ // 4. ModelsMapbox Standard's `style.models` references 3D tree /
5460
+ // turbine .glb assets. Two value shapes exist in the wild
5461
+ // (plain string or `{ uri }`) — we accept both.
5462
+ let modelResult;
5463
+ if (!skipModels && storedStyle.models) {
5464
+ const rawModels = storedStyle.models;
5465
+ const resolved = {};
5466
+ for (const [name, value] of Object.entries(rawModels)) {
5467
+ const uri = typeof value === 'string' ? value : value?.uri;
5468
+ if (!uri)
5469
+ continue;
5470
+ if (uri.startsWith('idb://'))
5471
+ continue; // already patched
5472
+ const httpUrl = isMapboxProtocol(uri) && effectiveAccessToken
5473
+ ? resolveMapboxUrl(uri, effectiveAccessToken)
5474
+ : uri;
5475
+ if (httpUrl.startsWith('http://') || httpUrl.startsWith('https://')) {
5476
+ resolved[name] = httpUrl;
5477
+ }
5478
+ }
5479
+ if (Object.keys(resolved).length > 0) {
5480
+ const { downloadModels } = await Promise.resolve().then(function () { return modelService$1; });
5481
+ emit('models', 0, Object.keys(resolved).length, 'Downloading 3D models');
5482
+ try {
5483
+ modelResult = await downloadModels(resolved, styleId, {
5484
+ onProgress: (progress) => {
5485
+ emit('models', progress.completed, progress.total, 'Downloading 3D models');
5486
+ },
5487
+ });
5488
+ }
5489
+ catch (error) {
5490
+ regionLogger$1.warn('Model download failed (non-fatal):', error);
5491
+ }
5492
+ }
5493
+ }
5494
+ // 5. Tiles — use the stored (source-embedded) style, which still has HTTP tile URLs
5136
5495
  const { downloadTiles } = await Promise.resolve().then(function () { return tileService$1; });
5137
5496
  const regionForTiles = { ...region, styleId };
5138
5497
  emit('tiles', 0, 100, 'Downloading tiles');
@@ -5145,7 +5504,7 @@ class RegionService {
5145
5504
  tileOptions?.onProgress?.(progress);
5146
5505
  },
5147
5506
  });
5148
- // 5. Metadata — must run last, since addRegion patches style URLs to idb://.
5507
+ // 6. Metadata — must run last, since addRegion patches style URLs to idb://.
5149
5508
  // Do NOT auto-fill tileExtension from tileResult: that's only the first
5150
5509
  // source's extension, and addRegion feeds it to patchStyleForOffline which
5151
5510
  // would override ALL sources — breaking mixed raster+vector styles. The
@@ -5161,6 +5520,7 @@ class RegionService {
5161
5520
  styleResult,
5162
5521
  spriteResults,
5163
5522
  glyphResult,
5523
+ modelResult,
5164
5524
  tileResult,
5165
5525
  };
5166
5526
  }
@@ -6299,10 +6659,15 @@ class TileService {
6299
6659
  tilesLength: config.tiles ? config.tiles.length : 0,
6300
6660
  url: config.url,
6301
6661
  });
6302
- // Handle tile-based sources (vector, raster, raster-dem, batched-model)
6662
+ // Handle tile-based sources (vector, raster, raster-dem, batched-model,
6663
+ // raster-array). `raster-array` is used by Mapbox Standard for layers
6664
+ // like `mapbox-landmarks` (mapbox.mapbox-landmark-icons-v1) — the tiles
6665
+ // are fetched from the same /v4/ endpoint as other tilesets, so the
6666
+ // TileJSON resolution path below handles them uniformly.
6303
6667
  if (config.type === 'vector' ||
6304
6668
  config.type === 'raster' ||
6305
6669
  config.type === 'raster-dem' ||
6670
+ config.type === 'raster-array' ||
6306
6671
  config.type === 'batched-model') {
6307
6672
  // Handle direct tile URLs in the source config
6308
6673
  if (config.tiles && Array.isArray(config.tiles) && config.tiles.length > 0) {
@@ -6527,8 +6892,7 @@ class TileService {
6527
6892
  }
6528
6893
  }
6529
6894
  extractExtension(template) {
6530
- const extMatch = template.match(/\.([\w]+)(?:\?|$)/i);
6531
- return extMatch ? extMatch[1] : 'pbf';
6895
+ return extractTileExtensionFromUrl(template);
6532
6896
  }
6533
6897
  selectTileTemplate(templates, coord) {
6534
6898
  if (templates.length === 1) {
@@ -6910,14 +7274,21 @@ class GlyphService {
6910
7274
  },
6911
7275
  };
6912
7276
  }
6913
- async cleanupOldGlyphs(maxAge = 30) {
7277
+ /**
7278
+ * Remove glyphs older than the specified age. When `options.styleId` is
7279
+ * provided, only glyphs belonging to that style (per
7280
+ * `resourceKeyBelongsToStyle`) are eligible.
7281
+ */
7282
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
6914
7283
  const db = await this.db;
6915
7284
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7285
+ const { styleId } = options;
6916
7286
  let deletedCount = 0;
6917
7287
  const tx = db.transaction('glyphs', 'readwrite');
6918
7288
  for await (const cursor of tx.store) {
6919
7289
  const glyphEntry = cursor.value;
6920
- if (glyphEntry.lastModified < cutoffTime) {
7290
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7291
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
6921
7292
  await cursor.delete();
6922
7293
  deletedCount++;
6923
7294
  }
@@ -7020,7 +7391,7 @@ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => gly
7020
7391
  const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7021
7392
  const getGlyphStats = () => glyphService.getGlyphStats();
7022
7393
  const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7023
- const cleanupOldGlyphs = (maxAge) => glyphService.cleanupOldGlyphs(maxAge);
7394
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7024
7395
  const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7025
7396
 
7026
7397
  var glyphService$1 = /*#__PURE__*/Object.freeze({
@@ -7035,12 +7406,207 @@ var glyphService$1 = /*#__PURE__*/Object.freeze({
7035
7406
  verifyAndRepairGlyphs: verifyAndRepairGlyphs
7036
7407
  });
7037
7408
 
7038
- class ResourceService {
7039
- // Tile Management Methods
7040
- async downloadTilesWithOptions(region, style, styleId, options = {}) {
7041
- return downloadTiles(region, style, styleId, options);
7409
+ const modelLogger = logger.scope('ModelService');
7410
+ /**
7411
+ * Build the storage key for a model. Kept consistent with sprite/glyph
7412
+ * conventions: `{styleId}::model::{modelName}`.
7413
+ */
7414
+ function modelKey(styleId, modelName) {
7415
+ return `${styleId}::model::${modelName}`;
7416
+ }
7417
+ /** True when the given key belongs to the given styleId's model store prefix. */
7418
+ function modelKeyBelongsToStyle(key, styleId) {
7419
+ return key.startsWith(`${styleId}::model::`);
7420
+ }
7421
+ /**
7422
+ * Service for downloading, storing, and serving Mapbox 3D model (.glb) files.
7423
+ *
7424
+ * Mapbox Standard declares 32 models at the top of `style.models`:
7425
+ *
7426
+ * ```json
7427
+ * {
7428
+ * "maple1-lod1": "mapbox://models/mapbox/maple1-v4-lod1.glb",
7429
+ * ...
7430
+ * }
7431
+ * ```
7432
+ *
7433
+ * `model` layers (e.g. `trees`, `wind-turbine-towers`) pick one by name at
7434
+ * render time. For offline use each referenced URL is fetched and stored
7435
+ * here, and `patchStyleForOffline` rewrites the dictionary entries to
7436
+ * `idb://{styleId}/model/{name}` URLs.
7437
+ */
7438
+ class ModelService {
7439
+ db = dbPromise;
7440
+ /**
7441
+ * Download the set of models referenced by `style.models` for one style.
7442
+ *
7443
+ * @param models `{ modelName: resolvedHttpUrl }` — URLs must already be
7444
+ * resolved (mapbox:// URLs should be resolved by the caller).
7445
+ * @param styleId The owning style's key.
7446
+ */
7447
+ async downloadModels(models, styleId, options = {}) {
7448
+ const db = await this.db;
7449
+ const { onProgress, batchSize = 4, maxRetries = 3, skipExisting = true, timeoutMs = 30000, } = options;
7450
+ const entries = Object.entries(models);
7451
+ const progressTracker = createProgressTracker(entries.length);
7452
+ const result = {
7453
+ totalModels: entries.length,
7454
+ downloadedModels: 0,
7455
+ skippedModels: 0,
7456
+ failedModels: 0,
7457
+ totalSize: 0,
7458
+ errors: [],
7459
+ };
7460
+ const emit = () => onProgress?.(progressTracker.getProgress());
7461
+ emit();
7462
+ if (entries.length === 0)
7463
+ return result;
7464
+ // Pre-compute existing keys for skipExisting
7465
+ const existingKeys = new Set();
7466
+ if (skipExisting) {
7467
+ const tx = db.transaction('models', 'readonly');
7468
+ for await (const cursor of tx.store) {
7469
+ existingKeys.add(cursor.value.key);
7470
+ }
7471
+ }
7472
+ await processBatch(entries, async ([modelName, url]) => {
7473
+ const key = modelKey(styleId, modelName);
7474
+ const label = `${styleId}::${modelName}`;
7475
+ if (skipExisting && existingKeys.has(key)) {
7476
+ result.skippedModels++;
7477
+ progressTracker.update(1, label);
7478
+ emit();
7479
+ return;
7480
+ }
7481
+ try {
7482
+ const response = await fetchResourceWithRetry(url, {
7483
+ retries: maxRetries,
7484
+ timeout: timeoutMs,
7485
+ proxyType: 'tiles',
7486
+ });
7487
+ if (response.type === 'json') {
7488
+ throw new Error('Unexpected JSON response for model');
7489
+ }
7490
+ const data = response.data;
7491
+ const contentType = ('contentType' in response && response.contentType) || 'model/gltf-binary';
7492
+ const entry = {
7493
+ key,
7494
+ data,
7495
+ contentType,
7496
+ size: data.byteLength,
7497
+ url,
7498
+ styleId,
7499
+ modelName,
7500
+ lastModified: Date.now(),
7501
+ downloadedAt: new Date().toISOString(),
7502
+ expires: response.expires,
7503
+ };
7504
+ await db.put('models', entry);
7505
+ result.downloadedModels++;
7506
+ result.totalSize += data.byteLength;
7507
+ progressTracker.update(1, label);
7508
+ }
7509
+ catch (err) {
7510
+ const message = err instanceof Error ? err.message : String(err);
7511
+ result.failedModels++;
7512
+ result.errors.push({ url, error: message });
7513
+ modelLogger.warn(`Failed to download model "${modelName}" from ${url}:`, err);
7514
+ progressTracker.update(1, label, message);
7515
+ }
7516
+ emit();
7517
+ }, { batchSize });
7518
+ modelLogger.info(`Models downloaded for style ${styleId}: ${result.downloadedModels} new, ${result.skippedModels} skipped, ${result.failedModels} failed`);
7519
+ return result;
7042
7520
  }
7043
- async getTileStats(styleId) {
7521
+ /** Retrieve a single model by `{styleId, modelName}`. */
7522
+ async getModel(styleId, modelName) {
7523
+ const db = await this.db;
7524
+ return db.get('models', modelKey(styleId, modelName));
7525
+ }
7526
+ /** Aggregate stats across all stored models. */
7527
+ async getModelStats() {
7528
+ const db = await this.db;
7529
+ const stats = {
7530
+ count: 0,
7531
+ totalSize: 0,
7532
+ averageSize: 0,
7533
+ models: [],
7534
+ modelsByStyle: {},
7535
+ };
7536
+ const tx = db.transaction('models', 'readonly');
7537
+ for await (const cursor of tx.store) {
7538
+ const m = cursor.value;
7539
+ stats.count++;
7540
+ stats.totalSize += m.size;
7541
+ stats.models.push({ name: m.modelName, size: m.size, lastModified: m.lastModified });
7542
+ stats.modelsByStyle[m.styleId] = (stats.modelsByStyle[m.styleId] ?? 0) + 1;
7543
+ }
7544
+ stats.averageSize = stats.count > 0 ? stats.totalSize / stats.count : 0;
7545
+ return stats;
7546
+ }
7547
+ /**
7548
+ * Delete models older than `maxAge` days. Defaults to 30.
7549
+ */
7550
+ async cleanupOldModels(maxAge = 30) {
7551
+ const db = await this.db;
7552
+ const cutoff = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7553
+ let deleted = 0;
7554
+ const tx = db.transaction('models', 'readwrite');
7555
+ for await (const cursor of tx.store) {
7556
+ if (cursor.value.lastModified < cutoff) {
7557
+ await cursor.delete();
7558
+ deleted++;
7559
+ }
7560
+ }
7561
+ return deleted;
7562
+ }
7563
+ /**
7564
+ * Basic integrity check: remove entries with empty/missing data.
7565
+ */
7566
+ async verifyAndRepairModels() {
7567
+ const db = await this.db;
7568
+ let verified = 0;
7569
+ let removed = 0;
7570
+ const tx = db.transaction('models', 'readwrite');
7571
+ for await (const cursor of tx.store) {
7572
+ const m = cursor.value;
7573
+ if (!m.data || m.data.byteLength === 0) {
7574
+ await cursor.delete();
7575
+ removed++;
7576
+ }
7577
+ else {
7578
+ verified++;
7579
+ }
7580
+ }
7581
+ return { verified, repaired: 0, removed };
7582
+ }
7583
+ }
7584
+ // Singleton + convenience exports, matching other service modules.
7585
+ const modelService = new ModelService();
7586
+ const downloadModels = (models, styleId, options) => modelService.downloadModels(models, styleId, options);
7587
+ const getModel = (styleId, modelName) => modelService.getModel(styleId, modelName);
7588
+ const getModelStats = () => modelService.getModelStats();
7589
+ const cleanupOldModels = (maxAge) => modelService.cleanupOldModels(maxAge);
7590
+ const verifyAndRepairModels = () => modelService.verifyAndRepairModels();
7591
+
7592
+ var modelService$1 = /*#__PURE__*/Object.freeze({
7593
+ __proto__: null,
7594
+ ModelService: ModelService,
7595
+ cleanupOldModels: cleanupOldModels,
7596
+ downloadModels: downloadModels,
7597
+ getModel: getModel,
7598
+ getModelStats: getModelStats,
7599
+ modelKeyBelongsToStyle: modelKeyBelongsToStyle,
7600
+ modelService: modelService,
7601
+ verifyAndRepairModels: verifyAndRepairModels
7602
+ });
7603
+
7604
+ class ResourceService {
7605
+ // Tile Management Methods
7606
+ async downloadTilesWithOptions(region, style, styleId, options = {}) {
7607
+ return downloadTiles(region, style, styleId, options);
7608
+ }
7609
+ async getTileStats(styleId) {
7044
7610
  return getTileStats(styleId);
7045
7611
  }
7046
7612
  async getTileAnalytics(styleId) {
@@ -7060,8 +7626,7 @@ class ResourceService {
7060
7626
  return getFontAnalytics();
7061
7627
  }
7062
7628
  async cleanupOldFonts(styleId, options) {
7063
- const maxAge = options?.maxAge;
7064
- return cleanupOldFonts(maxAge);
7629
+ return cleanupOldFonts(options?.maxAge, { styleId });
7065
7630
  }
7066
7631
  async verifyAndRepairFonts() {
7067
7632
  return verifyAndRepairFonts();
@@ -7077,8 +7642,7 @@ class ResourceService {
7077
7642
  return getSpriteAnalytics();
7078
7643
  }
7079
7644
  async cleanupOldSprites(styleId, options) {
7080
- const maxAge = options?.maxAge;
7081
- return cleanupOldSprites(maxAge);
7645
+ return cleanupOldSprites(options?.maxAge, { styleId });
7082
7646
  }
7083
7647
  async verifyAndRepairSprites() {
7084
7648
  return verifyAndRepairSprites();
@@ -7097,12 +7661,24 @@ class ResourceService {
7097
7661
  return loadGlyphs(fontstack, ranges, styleId);
7098
7662
  }
7099
7663
  async cleanupOldGlyphs(styleId, options) {
7100
- const maxAge = options?.maxAge;
7101
- return cleanupOldGlyphs(maxAge);
7664
+ return cleanupOldGlyphs(options?.maxAge, { styleId });
7102
7665
  }
7103
7666
  async verifyAndRepairGlyphs() {
7104
7667
  return verifyAndRepairGlyphs();
7105
7668
  }
7669
+ // 3D Model Management Methods
7670
+ async downloadModelsWithOptions(models, styleId, options = {}) {
7671
+ return downloadModels(models, styleId, options);
7672
+ }
7673
+ async getModelStats() {
7674
+ return getModelStats();
7675
+ }
7676
+ async cleanupOldModels(options) {
7677
+ return cleanupOldModels(options?.maxAge);
7678
+ }
7679
+ async verifyAndRepairModels() {
7680
+ return verifyAndRepairModels();
7681
+ }
7106
7682
  }
7107
7683
 
7108
7684
  // The underlying stats functions already iterate every entry in their
@@ -7200,6 +7776,88 @@ class AnalyticsService {
7200
7776
  }
7201
7777
  }
7202
7778
 
7779
+ /**
7780
+ * MBTiles uses TMS tile_row ordering; our storage uses XYZ y. Flip across
7781
+ * either direction with the same formula.
7782
+ */
7783
+ function flipY(y, z) {
7784
+ return (1 << z) - 1 - y;
7785
+ }
7786
+ /** Vector tile formats that downstream consumers (QGIS, maplibre-native) expect gzipped. */
7787
+ const VECTOR_FORMATS = new Set(['pbf', 'mvt']);
7788
+ function hasGzipMagic(bytes) {
7789
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
7790
+ }
7791
+ async function drainReadable(readable) {
7792
+ const reader = readable.getReader();
7793
+ const chunks = [];
7794
+ let total = 0;
7795
+ while (true) {
7796
+ const { done, value } = await reader.read();
7797
+ if (done)
7798
+ break;
7799
+ if (value) {
7800
+ chunks.push(value);
7801
+ total += value.byteLength;
7802
+ }
7803
+ }
7804
+ const out = new Uint8Array(total);
7805
+ let offset = 0;
7806
+ for (const chunk of chunks) {
7807
+ out.set(chunk, offset);
7808
+ offset += chunk.byteLength;
7809
+ }
7810
+ return out;
7811
+ }
7812
+ async function transformBytes(bytes, transform) {
7813
+ const writer = transform.writable.getWriter();
7814
+ // Don't await — the read loop below drives the pipe and we only want
7815
+ // the final bytes, not back-pressure handling for a single chunk.
7816
+ void writer.write(bytes);
7817
+ void writer.close();
7818
+ return drainReadable(transform.readable);
7819
+ }
7820
+ async function gzipBytes(bytes) {
7821
+ return transformBytes(bytes, new CompressionStream('gzip'));
7822
+ }
7823
+ async function gunzipBytes(bytes) {
7824
+ return transformBytes(bytes, new DecompressionStream('gzip'));
7825
+ }
7826
+ /**
7827
+ * Build the MBTiles `json` metadata payload. For vector tiles this is
7828
+ * mandatory for tippecanoe/QGIS/maplibre-native to render — they read
7829
+ * `vector_layers` from here.
7830
+ *
7831
+ * `vector_layers` is inferred from the offline style's vector sources
7832
+ * (populated by the TileJSON expansion step in styleService). Multiple
7833
+ * vector sources are merged; duplicates de-duped by id, first wins.
7834
+ */
7835
+ function buildVectorJsonMetadata(style, sourceIds) {
7836
+ if (!style || typeof style !== 'object')
7837
+ return null;
7838
+ const sources = style.sources;
7839
+ if (!sources)
7840
+ return null;
7841
+ const merged = [];
7842
+ const seen = new Set();
7843
+ for (const [id, src] of Object.entries(sources)) {
7844
+ if (sourceIds.size > 0 && !sourceIds.has(id))
7845
+ continue;
7846
+ const layers = src?.vector_layers;
7847
+ if (!Array.isArray(layers))
7848
+ continue;
7849
+ for (const layer of layers) {
7850
+ const layerId = typeof layer?.id === 'string' ? layer.id : null;
7851
+ if (!layerId || seen.has(layerId))
7852
+ continue;
7853
+ seen.add(layerId);
7854
+ merged.push(layer);
7855
+ }
7856
+ }
7857
+ if (merged.length === 0)
7858
+ return null;
7859
+ return JSON.stringify({ vector_layers: merged });
7860
+ }
7203
7861
  const serviceLogger = logger.scope('ImportExportService');
7204
7862
  class ImportExportService {
7205
7863
  db = dbPromise;
@@ -7207,270 +7865,173 @@ class ImportExportService {
7207
7865
  // No need for initialization since dbPromise is already available
7208
7866
  }
7209
7867
  /**
7210
- * Export a region to JSON format
7868
+ * Export region as a real binary MBTiles SQLite file.
7869
+ *
7870
+ * Produces a v1.3-compliant MBTiles archive: `metadata` + `tiles` tables,
7871
+ * with `tile_row` flipped to TMS ordering. The resulting blob can be read
7872
+ * by tippecanoe, QGIS, maplibre-native, etc.
7211
7873
  */
7212
- async exportRegionAsJSON(regionId, options = {}) {
7874
+ async exportRegionAsMBTiles(regionId, options = {}) {
7213
7875
  const onProgress = options.onProgress || (() => { });
7214
7876
  try {
7215
7877
  onProgress({
7216
7878
  stage: 'preparing',
7217
7879
  percentage: 0,
7218
- message: 'Preparing export...',
7880
+ message: 'Preparing MBTiles export...',
7219
7881
  });
7220
- // Get region metadata
7221
7882
  const region = await this.getRegionMetadata(regionId);
7222
7883
  if (!region) {
7223
7884
  throw new Error(`Region ${regionId} not found`);
7224
7885
  }
7886
+ const tiles = await this.exportTiles(regionId, onProgress);
7887
+ // Pick format: caller override → region.tileExtension → default pbf.
7888
+ // Drives both the metadata row and whether tile bytes get gzipped.
7889
+ const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
7890
+ const isVector = VECTOR_FORMATS.has(format);
7225
7891
  onProgress({
7226
- stage: 'exporting',
7227
- percentage: 10,
7228
- message: 'Collecting region data...',
7892
+ stage: 'processing',
7893
+ percentage: 75,
7894
+ message: isVector ? 'Compressing vector tiles...' : 'Packing SQLite database...',
7229
7895
  });
7230
- const exportData = {
7231
- metadata: {
7232
- id: region.id,
7233
- name: region.name || region.id,
7234
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7235
- bounds: region.bounds,
7236
- minZoom: region.minZoom,
7237
- maxZoom: region.maxZoom,
7238
- styleUrl: region.styleUrl || '',
7239
- createdAt: region.created, // StoredRegion uses 'created' not 'createdAt'
7240
- exportedAt: Date.now(),
7241
- version: '1.0.0',
7242
- format: 'json',
7243
- },
7244
- style: {},
7245
- tiles: [],
7246
- sprites: [],
7247
- fonts: [],
7248
- };
7249
- // Export style if requested
7250
- if (options.includeStyle !== false) {
7251
- onProgress({
7252
- stage: 'exporting',
7253
- percentage: 20,
7254
- message: 'Exporting style data...',
7255
- });
7256
- exportData.style = await this.exportStyle(regionId);
7896
+ // Gzip vector tiles. Idempotent: skip tiles already gzipped (downloaded
7897
+ // with their original gzip wrapper intact).
7898
+ const packedTiles = [];
7899
+ for (const tile of tiles) {
7900
+ const raw = tile.data instanceof ArrayBuffer
7901
+ ? new Uint8Array(tile.data)
7902
+ : new Uint8Array(tile.data);
7903
+ const data = isVector && !hasGzipMagic(raw) ? await gzipBytes(raw) : raw;
7904
+ packedTiles.push({ z: tile.z, x: tile.x, y: tile.y, data });
7257
7905
  }
7258
- // Export tiles if requested
7259
- if (options.includeTiles !== false) {
7260
- onProgress({
7261
- stage: 'exporting',
7262
- percentage: 30,
7263
- message: 'Exporting tiles...',
7906
+ onProgress({
7907
+ stage: 'processing',
7908
+ percentage: 85,
7909
+ message: 'Packing SQLite database...',
7910
+ });
7911
+ const SQL = await getSqlJs();
7912
+ const db = new SQL.Database();
7913
+ try {
7914
+ db.run(`
7915
+ CREATE TABLE metadata (name TEXT, value TEXT);
7916
+ CREATE TABLE tiles (
7917
+ zoom_level INTEGER NOT NULL,
7918
+ tile_column INTEGER NOT NULL,
7919
+ tile_row INTEGER NOT NULL,
7920
+ tile_data BLOB
7921
+ );
7922
+ CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
7923
+ CREATE UNIQUE INDEX name ON metadata (name);
7924
+ `);
7925
+ const [[west, south], [east, north]] = region.bounds;
7926
+ const centerLon = (west + east) / 2;
7927
+ const centerLat = (south + north) / 2;
7928
+ const centerZoom = Math.max(region.minZoom, Math.min(region.maxZoom, Math.round((region.minZoom + region.maxZoom) / 2)));
7929
+ const metadataRows = {
7930
+ name: region.name || region.id,
7931
+ // MBTiles 1.3 type: 'overlay' or 'baselayer'. Baselayer matches how
7932
+ // QGIS treats the dataset (full-coverage map rather than overlay).
7933
+ type: isVector ? 'baselayer' : 'overlay',
7934
+ version: '1.0',
7935
+ description: region.name || region.id,
7936
+ format,
7937
+ bounds: `${west},${south},${east},${north}`,
7938
+ center: `${centerLon},${centerLat},${centerZoom}`,
7939
+ minzoom: String(region.minZoom),
7940
+ maxzoom: String(region.maxZoom),
7941
+ };
7942
+ // For vector tiles, the `json` field with `vector_layers` is required
7943
+ // by the MBTiles 1.3 spec and by every vector tile consumer worth
7944
+ // opening the file in. Derive it from the offline style.
7945
+ if (isVector) {
7946
+ const style = await this.exportStyle(regionId);
7947
+ const sourceIds = new Set(tiles.map(t => t.sourceId).filter(Boolean));
7948
+ const json = buildVectorJsonMetadata(style.style ?? style, sourceIds);
7949
+ if (json)
7950
+ metadataRows.json = json;
7951
+ }
7952
+ for (const [k, v] of Object.entries(options.metadata || {})) {
7953
+ metadataRows[k] = typeof v === 'string' ? v : JSON.stringify(v);
7954
+ }
7955
+ const insertMeta = db.prepare(`INSERT INTO metadata (name, value) VALUES (?, ?)`);
7956
+ try {
7957
+ for (const [name, value] of Object.entries(metadataRows)) {
7958
+ insertMeta.run([name, value]);
7959
+ }
7960
+ }
7961
+ finally {
7962
+ insertMeta.free();
7963
+ }
7964
+ const insertTile = db.prepare(`INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data)
7965
+ VALUES (?, ?, ?, ?)`);
7966
+ try {
7967
+ db.run('BEGIN');
7968
+ for (const tile of packedTiles) {
7969
+ insertTile.run([tile.z, tile.x, flipY(tile.y, tile.z), tile.data]);
7970
+ }
7971
+ db.run('COMMIT');
7972
+ }
7973
+ finally {
7974
+ insertTile.free();
7975
+ }
7976
+ const binary = db.export();
7977
+ const blob = new Blob([binary.buffer], {
7978
+ type: 'application/x-sqlite3',
7264
7979
  });
7265
- exportData.tiles = await this.exportTiles(regionId, onProgress);
7266
- }
7267
- // Export sprites if requested
7268
- if (options.includeSprites !== false) {
7269
7980
  onProgress({
7270
- stage: 'exporting',
7271
- percentage: 70,
7272
- message: 'Exporting sprites...',
7981
+ stage: 'complete',
7982
+ percentage: 100,
7983
+ message: 'MBTiles export complete!',
7273
7984
  });
7274
- exportData.sprites = await this.exportSprites(regionId);
7985
+ return {
7986
+ success: true,
7987
+ format: 'mbtiles',
7988
+ filename: `${region.name || region.id}.mbtiles`,
7989
+ blob,
7990
+ size: blob.size,
7991
+ statistics: {
7992
+ tilesExported: tiles.length,
7993
+ spritesExported: 0,
7994
+ fontsExported: 0,
7995
+ },
7996
+ };
7275
7997
  }
7276
- // Export fonts if requested
7277
- if (options.includeFonts !== false) {
7278
- onProgress({
7279
- stage: 'exporting',
7280
- percentage: 85,
7281
- message: 'Exporting fonts...',
7282
- });
7283
- exportData.fonts = await this.exportFonts(regionId);
7998
+ finally {
7999
+ db.close();
7284
8000
  }
7285
- onProgress({
7286
- stage: 'processing',
7287
- percentage: 95,
7288
- message: 'Creating export file...',
7289
- });
7290
- // Create JSON blob
7291
- const jsonString = JSON.stringify(exportData, null, 2);
7292
- const blob = new Blob([jsonString], { type: 'application/json' });
7293
- onProgress({
7294
- stage: 'complete',
7295
- percentage: 100,
7296
- message: 'Export complete!',
7297
- });
7298
- return {
7299
- success: true,
7300
- format: 'json',
7301
- filename: `${region.name || region.id}_export.json`,
7302
- blob,
7303
- size: blob.size,
7304
- statistics: {
7305
- tilesExported: exportData.tiles.length,
7306
- spritesExported: exportData.sprites.length,
7307
- fontsExported: exportData.fonts.length,
7308
- },
7309
- };
7310
8001
  }
7311
8002
  catch (error) {
7312
8003
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7313
- throw new Error(`Export failed: ${errorMessage}`);
8004
+ throw new Error(`MBTiles export failed: ${errorMessage}`);
7314
8005
  }
7315
8006
  }
7316
8007
  /**
7317
- * Export region as PMTiles format
8008
+ * Import region from a binary MBTiles (SQLite) file.
7318
8009
  */
7319
- async exportRegionAsPMTiles(regionId, options = {}) {
7320
- const onProgress = options.onProgress || (() => { });
8010
+ async importRegion(importData) {
8011
+ const onProgress = importData.onProgress || (() => { });
7321
8012
  try {
7322
8013
  onProgress({
7323
8014
  stage: 'preparing',
7324
8015
  percentage: 0,
7325
- message: 'Preparing PMTiles export...',
8016
+ message: 'Reading file...',
7326
8017
  });
7327
- // Note: This is a simplified implementation
7328
- // In a real implementation, you would use the PMTiles library
7329
- // to create a proper PMTiles file format
7330
- const region = await this.getRegionMetadata(regionId);
7331
- if (!region) {
7332
- throw new Error(`Region ${regionId} not found`);
8018
+ if (importData.format !== 'mbtiles') {
8019
+ throw new Error(`Unsupported format: ${importData.format}`);
7333
8020
  }
7334
- // Get tiles data
7335
- const tiles = await this.exportTiles(regionId, onProgress);
7336
- // Create PMTiles header and data structure
7337
- const pmtilesData = {
7338
- header: {
7339
- version: 3,
7340
- type: 'mvt',
7341
- compression: options.compression || 'gzip',
7342
- bounds: region.bounds,
7343
- minZoom: region.minZoom,
7344
- maxZoom: region.maxZoom,
7345
- metadata: {
7346
- name: region.name,
7347
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7348
- ...options.metadata,
7349
- },
7350
- },
7351
- tiles: tiles,
7352
- };
7353
- // Convert to binary format (simplified)
7354
- const jsonString = JSON.stringify(pmtilesData);
7355
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
7356
- onProgress({
7357
- stage: 'complete',
7358
- percentage: 100,
7359
- message: 'PMTiles export complete!',
7360
- });
7361
- return {
7362
- success: true,
7363
- format: 'pmtiles',
7364
- filename: `${region.name || region.id}.pmtiles`,
7365
- blob,
7366
- size: blob.size,
7367
- statistics: {
7368
- tilesExported: tiles.length,
7369
- spritesExported: 0,
7370
- fontsExported: 0,
7371
- },
7372
- };
7373
- }
7374
- catch (error) {
7375
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7376
- throw new Error(`PMTiles export failed: ${errorMessage}`);
7377
- }
7378
- }
7379
- /**
7380
- * Export region as MBTiles format
7381
- */
7382
- async exportRegionAsMBTiles(regionId, options = {}) {
7383
- const onProgress = options.onProgress || (() => { });
7384
- try {
8021
+ const buffer = await this.readFileAsArrayBuffer(importData.file);
8022
+ onProgress({ stage: 'importing', percentage: 40, message: 'Parsing MBTiles...' });
8023
+ const regionData = await this.parseMBTiles(buffer);
7385
8024
  onProgress({
7386
- stage: 'preparing',
7387
- percentage: 0,
7388
- message: 'Preparing MBTiles export...',
8025
+ stage: 'importing',
8026
+ percentage: 70,
8027
+ message: `Importing ${regionData.tiles?.length ?? 0} tiles...`,
7389
8028
  });
7390
- // Note: This is a simplified implementation
7391
- // In a real implementation, you would use SQLite/SQL.js
7392
- // to create a proper MBTiles SQLite database
7393
- const region = await this.getRegionMetadata(regionId);
7394
- if (!region) {
7395
- throw new Error(`Region ${regionId} not found`);
7396
- }
7397
- // Get tiles data
7398
- const tiles = await this.exportTiles(regionId, onProgress);
7399
- // Create MBTiles structure (simplified as JSON for now)
7400
- const mbtilesData = {
7401
- metadata: {
7402
- name: region.name,
7403
- type: 'overlay',
7404
- version: '1.0',
7405
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7406
- format: options.format || 'pbf',
7407
- bounds: region.bounds.flat().join(','),
7408
- minzoom: region.minZoom,
7409
- maxzoom: region.maxZoom,
7410
- ...options.metadata,
7411
- },
7412
- tiles: tiles.map(tile => ({
7413
- zoom_level: tile.z,
7414
- tile_column: tile.x,
7415
- tile_row: tile.y,
7416
- tile_data: tile.data,
7417
- })),
7418
- };
7419
- // Convert to binary format (simplified)
7420
- const jsonString = JSON.stringify(mbtilesData);
7421
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
8029
+ const result = await this.importRegionData(regionData, importData);
7422
8030
  onProgress({
7423
8031
  stage: 'complete',
7424
8032
  percentage: 100,
7425
- message: 'MBTiles export complete!',
8033
+ message: result.success ? 'Import complete!' : result.message,
7426
8034
  });
7427
- return {
7428
- success: true,
7429
- format: 'mbtiles',
7430
- filename: `${region.name || region.id}.mbtiles`,
7431
- blob,
7432
- size: blob.size,
7433
- statistics: {
7434
- tilesExported: tiles.length,
7435
- spritesExported: 0,
7436
- fontsExported: 0,
7437
- },
7438
- };
7439
- }
7440
- catch (error) {
7441
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7442
- throw new Error(`MBTiles export failed: ${errorMessage}`);
7443
- }
7444
- }
7445
- /**
7446
- * Import region from file
7447
- */
7448
- async importRegion(importData) {
7449
- try {
7450
- let regionData;
7451
- switch (importData.format) {
7452
- case 'json': {
7453
- const textContent = await this.readFileAsText(importData.file);
7454
- regionData = JSON.parse(textContent);
7455
- break;
7456
- }
7457
- case 'pmtiles': {
7458
- // PMTiles is a binary format; currently parsed as JSON (simplified impl)
7459
- const textContent = await this.readFileAsText(importData.file);
7460
- regionData = await this.parsePMTiles(textContent);
7461
- break;
7462
- }
7463
- case 'mbtiles': {
7464
- // MBTiles is a binary format; currently parsed as JSON (simplified impl)
7465
- const textContent = await this.readFileAsText(importData.file);
7466
- regionData = await this.parseMBTiles(textContent);
7467
- break;
7468
- }
7469
- default:
7470
- throw new Error(`Unsupported format: ${importData.format}`);
7471
- }
7472
- // Import the region data
7473
- const result = await this.importRegionData(regionData, importData);
7474
8035
  return result;
7475
8036
  }
7476
8037
  catch (error) {
@@ -7594,151 +8155,113 @@ class ImportExportService {
7594
8155
  }
7595
8156
  }
7596
8157
  /**
7597
- * Export sprites data
7598
- */
7599
- async exportSprites(_regionId) {
7600
- const db = await this.db;
7601
- const transaction = db.transaction(['sprites'], 'readonly');
7602
- const store = transaction.objectStore('sprites');
7603
- const sprites = [];
7604
- try {
7605
- let cursor = await store.openCursor();
7606
- while (cursor) {
7607
- const sprite = cursor.value;
7608
- // Include sprites that match the styleId, or all sprites if keys don't contain styleId
7609
- // (sprite keys may or may not be prefixed with styleId depending on how they were stored)
7610
- sprites.push({
7611
- url: sprite.url,
7612
- data: sprite.data,
7613
- type: sprite.url.endsWith('.json') ? 'json' : 'png',
7614
- resolution: sprite.url.includes('@2x') ? '2x' : '1x',
7615
- });
7616
- cursor = await cursor.continue();
7617
- }
7618
- return sprites;
7619
- }
7620
- catch (error) {
7621
- serviceLogger.error('Error exporting sprites:', error);
7622
- return [];
7623
- }
7624
- }
7625
- /**
7626
- * Export fonts data
8158
+ * Read file content as ArrayBuffer (for the binary MBTiles file).
7627
8159
  */
7628
- async exportFonts(_regionId) {
7629
- const db = await this.db;
7630
- const transaction = db.transaction(['fonts'], 'readonly');
7631
- const store = transaction.objectStore('fonts');
7632
- const fonts = [];
7633
- try {
7634
- let cursor = await store.openCursor();
7635
- while (cursor) {
7636
- const font = cursor.value;
7637
- // Include fonts that match the styleId, or all fonts if keys don't contain styleId
7638
- // (font keys may or may not be prefixed with styleId depending on how they were stored)
7639
- fonts.push({
7640
- fontStack: font.key, // Use key as fontstack identifier
7641
- range: '0-255', // Default range since FontEntry doesn't store this
7642
- data: font.data,
7643
- });
7644
- cursor = await cursor.continue();
7645
- }
7646
- return fonts;
7647
- }
7648
- catch (error) {
7649
- serviceLogger.error('Error exporting fonts:', error);
7650
- return [];
7651
- }
7652
- }
7653
- /**
7654
- * Read file content as text (for JSON files)
7655
- */
7656
- async readFileAsText(file) {
8160
+ async readFileAsArrayBuffer(file) {
7657
8161
  return new Promise((resolve, reject) => {
7658
8162
  const reader = new FileReader();
7659
8163
  reader.onload = () => resolve(reader.result);
7660
8164
  reader.onerror = () => reject(new Error('Failed to read file'));
7661
- reader.readAsText(file);
8165
+ reader.readAsArrayBuffer(file);
7662
8166
  });
7663
8167
  }
7664
8168
  /**
7665
- * Parse PMTiles file (simplified)
8169
+ * Parse a real binary MBTiles (SQLite) file into our import-data shape.
8170
+ * Un-flips the TMS tile_row back to XYZ y.
7666
8171
  */
7667
- async parsePMTiles(content) {
7668
- // This is a simplified implementation
7669
- // In reality, you would use the PMTiles library to parse the binary format
7670
- const data = JSON.parse(content);
7671
- const header = data?.header || {};
7672
- const metadata = header?.metadata || {};
7673
- return {
7674
- metadata: {
7675
- id: metadata.name || 'imported-region',
7676
- name: metadata.name || 'Imported Region',
7677
- description: metadata.description || '',
7678
- bounds: header.bounds || [
7679
- [0, 0],
7680
- [0, 0],
7681
- ],
7682
- minZoom: header.minZoom || 0,
7683
- maxZoom: header.maxZoom || 14,
7684
- styleUrl: '',
7685
- createdAt: Date.now(),
7686
- exportedAt: Date.now(),
7687
- version: '1.0.0',
7688
- format: 'pmtiles',
7689
- },
7690
- style: {},
7691
- tiles: data.tiles || [],
7692
- sprites: [],
7693
- fonts: [],
7694
- };
7695
- }
7696
- /**
7697
- * Parse MBTiles file (simplified)
7698
- */
7699
- async parseMBTiles(content) {
7700
- // This is a simplified implementation
7701
- // In reality, you would use SQL.js to parse the SQLite database
7702
- const data = JSON.parse(content);
7703
- const rawBounds = data.metadata?.bounds
7704
- ? data.metadata.bounds.split(',').map(Number)
7705
- : [0, 0, 0, 0];
7706
- // Ensure we have exactly 4 valid numbers
7707
- const bounds = [
7708
- isFinite(rawBounds[0]) ? rawBounds[0] : 0,
7709
- isFinite(rawBounds[1]) ? rawBounds[1] : 0,
7710
- isFinite(rawBounds[2]) ? rawBounds[2] : 0,
7711
- isFinite(rawBounds[3]) ? rawBounds[3] : 0,
7712
- ];
7713
- return {
7714
- metadata: {
7715
- id: data.metadata.name || 'imported-region',
7716
- name: data.metadata.name || 'Imported Region',
7717
- description: data.metadata.description,
7718
- bounds: [
7719
- [bounds[0], bounds[1]],
7720
- [bounds[2], bounds[3]],
7721
- ],
7722
- minZoom: data.metadata.minzoom || 0,
7723
- maxZoom: data.metadata.maxzoom || 14,
7724
- styleUrl: '',
7725
- createdAt: Date.now(),
7726
- exportedAt: Date.now(),
7727
- version: '1.0.0',
7728
- format: 'mbtiles',
7729
- },
7730
- style: {},
7731
- tiles: data.tiles.map((tile) => ({
7732
- z: tile.zoom_level,
7733
- x: tile.tile_column,
7734
- y: tile.tile_row,
7735
- data: tile.tile_data,
7736
- format: 'pbf',
7737
- sourceId: 'imported',
7738
- })) || [],
7739
- sprites: [],
7740
- fonts: [],
7741
- };
8172
+ async parseMBTiles(buffer) {
8173
+ const bytes = new Uint8Array(buffer);
8174
+ // SQLite header: "SQLite format 3\0" (16 bytes). Validate up front so
8175
+ // non-MBTiles files (e.g. a JSON renamed to .mbtiles) surface a clear
8176
+ // error instead of the opaque "file is not a database" from sql.js.
8177
+ if (bytes.byteLength < 16) {
8178
+ throw new Error('Not a valid MBTiles file: file is too small');
8179
+ }
8180
+ const magic = String.fromCharCode(...bytes.slice(0, 15));
8181
+ if (magic !== 'SQLite format 3') {
8182
+ throw new Error('Not a valid MBTiles file: missing SQLite header');
8183
+ }
8184
+ const SQL = await getSqlJs();
8185
+ const db = new SQL.Database(bytes);
8186
+ try {
8187
+ const tablesResult = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('metadata', 'tiles')");
8188
+ const tableNames = (tablesResult[0]?.values || []).map(r => r[0]);
8189
+ if (!tableNames.includes('metadata') || !tableNames.includes('tiles')) {
8190
+ throw new Error('Not a valid MBTiles file: missing required metadata/tiles tables');
8191
+ }
8192
+ const metadata = {};
8193
+ const metaStmt = db.prepare('SELECT name, value FROM metadata');
8194
+ try {
8195
+ while (metaStmt.step()) {
8196
+ const row = metaStmt.get();
8197
+ metadata[row[0]] = row[1];
8198
+ }
8199
+ }
8200
+ finally {
8201
+ metaStmt.free();
8202
+ }
8203
+ const rawBounds = metadata.bounds ? metadata.bounds.split(',').map(Number) : [0, 0, 0, 0];
8204
+ const bounds = [
8205
+ isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8206
+ isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8207
+ isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8208
+ isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8209
+ ];
8210
+ const format = (metadata.format || 'pbf');
8211
+ const isVector = VECTOR_FORMATS.has(format);
8212
+ const tiles = [];
8213
+ const tilesStmt = db.prepare('SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles');
8214
+ try {
8215
+ while (tilesStmt.step()) {
8216
+ const row = tilesStmt.get();
8217
+ const [z, x, tmsRow, data] = row;
8218
+ // Sliced copy so the buffer is detached from sql.js's heap.
8219
+ const copy = new Uint8Array(data.byteLength);
8220
+ copy.set(data);
8221
+ // Our IndexedDB stores vector tiles decompressed (tileService
8222
+ // inflates on download). MBTiles vector tiles are gzipped by
8223
+ // convention — un-gzip on the way in so the stored tile matches
8224
+ // what the fetch handler expects to serve.
8225
+ const storedBytes = isVector && hasGzipMagic(copy) ? await gunzipBytes(copy) : copy;
8226
+ tiles.push({
8227
+ z,
8228
+ x,
8229
+ y: flipY(tmsRow, z),
8230
+ data: storedBytes.buffer,
8231
+ format,
8232
+ sourceId: 'imported',
8233
+ });
8234
+ }
8235
+ }
8236
+ finally {
8237
+ tilesStmt.free();
8238
+ }
8239
+ const minZoom = metadata.minzoom !== undefined ? Number(metadata.minzoom) : 0;
8240
+ const maxZoom = metadata.maxzoom !== undefined ? Number(metadata.maxzoom) : 14;
8241
+ return {
8242
+ metadata: {
8243
+ id: metadata.name || 'imported-region',
8244
+ name: metadata.name || 'Imported Region',
8245
+ description: metadata.description,
8246
+ bounds: [
8247
+ [bounds[0], bounds[1]],
8248
+ [bounds[2], bounds[3]],
8249
+ ],
8250
+ minZoom,
8251
+ maxZoom,
8252
+ styleUrl: '',
8253
+ createdAt: Date.now(),
8254
+ exportedAt: Date.now(),
8255
+ version: '1.0.0',
8256
+ format: 'mbtiles',
8257
+ },
8258
+ style: {},
8259
+ tiles,
8260
+ };
8261
+ }
8262
+ finally {
8263
+ db.close();
8264
+ }
7742
8265
  }
7743
8266
  /**
7744
8267
  * Import region data to database
@@ -7803,16 +8326,15 @@ class ImportExportService {
7803
8326
  });
7804
8327
  }
7805
8328
  }
7806
- // Import sprites and fonts similarly...
7807
8329
  return {
7808
8330
  success: true,
7809
8331
  regionId,
7810
8332
  message: 'Region imported successfully',
7811
8333
  statistics: {
7812
8334
  tilesImported: regionData.tiles?.length || 0,
7813
- spritesImported: regionData.sprites?.length || 0,
7814
- fontsImported: regionData.fonts?.length || 0,
7815
- totalSize: 0, // Calculate if needed
8335
+ spritesImported: 0,
8336
+ fontsImported: 0,
8337
+ totalSize: 0,
7816
8338
  },
7817
8339
  };
7818
8340
  }
@@ -7897,6 +8419,10 @@ const createResourceManagement = (services) => ({
7897
8419
  loadGlyphsForStyle: (...args) => services.resourceService.loadGlyphsForStyle(...args),
7898
8420
  cleanupOldGlyphs: (...args) => services.resourceService.cleanupOldGlyphs(...args),
7899
8421
  verifyAndRepairGlyphs: (...args) => services.resourceService.verifyAndRepairGlyphs(...args),
8422
+ downloadModelsWithOptions: (...args) => services.resourceService.downloadModelsWithOptions(...args),
8423
+ getModelStats: (...args) => services.resourceService.getModelStats(...args),
8424
+ cleanupOldModels: (...args) => services.resourceService.cleanupOldModels(...args),
8425
+ verifyAndRepairModels: (...args) => services.resourceService.verifyAndRepairModels(...args),
7900
8426
  });
7901
8427
 
7902
8428
  const createAnalyticsManagement = (services, deps) => ({
@@ -8037,8 +8563,6 @@ const createMaintenanceManagement = (services, deps) => {
8037
8563
  };
8038
8564
 
8039
8565
  const createImportExportManagement = (services) => ({
8040
- exportRegionAsJSON: async (regionId, options = {}) => services.importExportService.exportRegionAsJSON(regionId, options),
8041
- exportRegionAsPMTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsPMTiles(regionId, options),
8042
8566
  exportRegionAsMBTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsMBTiles(regionId, options),
8043
8567
  importRegion: async (importData) => services.importExportService.importRegion(importData),
8044
8568
  downloadExportedRegion: (exportResult) => {
@@ -8394,10 +8918,6 @@ const en = {
8394
8918
  'styleSelection.title': 'Select Offline Style',
8395
8919
  'styleSelection.message': 'Choose which offline style to load:',
8396
8920
  'styleSelection.sources': 'sources',
8397
- // Import/Export
8398
- 'importExport.title': 'Import/Export',
8399
- 'importExport.export': 'Export',
8400
- 'importExport.import': 'Import',
8401
8921
  // Errors
8402
8922
  'error.loadingContent': 'Error loading content',
8403
8923
  'error.tryAgain': 'Please try again',
@@ -8422,41 +8942,30 @@ const en = {
8422
8942
  'regionDetails.bounds': 'Bounds',
8423
8943
  'regionDetails.zoomRange': 'Zoom Range',
8424
8944
  'regionDetails.created': 'Created',
8425
- // Import/Export Modal
8426
- 'importExport.regionTitle': 'Import/Export Region',
8427
- 'importExport.regionInfo': 'Region Information',
8428
- 'importExport.id': 'ID',
8429
- 'importExport.name': 'Name',
8430
- 'importExport.unnamed': 'Unnamed',
8431
- 'importExport.zoom': 'Zoom',
8432
- 'importExport.created': 'Created',
8433
- 'importExport.exportRegion': 'Export Region',
8434
- 'importExport.exportFormat': 'Export Format',
8435
- 'importExport.formatJson': 'JSON - Complete data (recommended)',
8436
- 'importExport.formatPmtiles': 'PMTiles - Web optimized tiles',
8437
- 'importExport.formatMbtiles': 'MBTiles - Industry standard',
8438
- 'importExport.formatHint': 'Choose format based on your use case',
8439
- 'importExport.includeComponents': 'Include Components',
8440
- 'importExport.styleConfig': 'Style Configuration',
8441
- 'importExport.mapTiles': 'Map Tiles',
8442
- 'importExport.spritesIcons': 'Sprites & Icons',
8443
- 'importExport.fontsGlyphs': 'Fonts & Glyphs',
8444
- 'importExport.preparingExport': 'Preparing export...',
8445
- 'importExport.exportComplete': 'Export complete!',
8446
- 'importExport.exportFailed': 'Export failed. Please try again.',
8447
- 'importExport.importRegion': 'Import Region',
8448
- 'importExport.selectFile': 'Select File',
8449
- 'importExport.fileFormatsHint': 'Supports JSON, PMTiles, and MBTiles formats',
8450
- 'importExport.newRegionName': 'New Region Name (Optional)',
8451
- 'importExport.newRegionNamePlaceholder': 'Leave empty to use original name',
8452
- 'importExport.overwriteIfExists': 'Overwrite if region exists',
8453
- 'importExport.preparingImport': 'Preparing import...',
8454
- 'importExport.importComplete': 'Import complete!',
8455
- 'importExport.importFailed': 'Import failed. Please try again.',
8456
- 'importExport.formatGuide': 'Format Guide',
8457
- 'importExport.jsonDesc': 'Complete data, human-readable, best for development',
8458
- 'importExport.pmtilesDesc': 'Web-optimized, efficient serving, cloud-friendly',
8459
- 'importExport.mbtilesDesc': 'Industry standard, SQLite-based, cross-platform',
8945
+ // MBTiles Modal
8946
+ 'mbtiles.title': 'MBTiles Import / Export',
8947
+ 'mbtiles.regionInfo': 'Region Information',
8948
+ 'mbtiles.id': 'ID',
8949
+ 'mbtiles.name': 'Name',
8950
+ 'mbtiles.unnamed': 'Unnamed',
8951
+ 'mbtiles.zoom': 'Zoom',
8952
+ 'mbtiles.created': 'Created',
8953
+ 'mbtiles.exportTitle': 'Export as MBTiles',
8954
+ 'mbtiles.exportHint': 'Package the tiles in this region into a standard SQLite MBTiles archive that opens in QGIS, tippecanoe, and other tools.',
8955
+ 'mbtiles.exportButton': 'Download .mbtiles',
8956
+ 'mbtiles.preparingExport': 'Preparing export...',
8957
+ 'mbtiles.exportComplete': 'Export complete!',
8958
+ 'mbtiles.exportFailed': 'Export failed. Please try again.',
8959
+ 'mbtiles.importTitle': 'Import from MBTiles',
8960
+ 'mbtiles.selectFile': 'Select an .mbtiles file',
8961
+ 'mbtiles.fileHint': 'Only SQLite-format .mbtiles files are supported.',
8962
+ 'mbtiles.newRegionName': 'New Region Name (optional)',
8963
+ 'mbtiles.newRegionNamePlaceholder': 'Leave empty to use the name from the file',
8964
+ 'mbtiles.overwriteIfExists': 'Overwrite if a region with the same id exists',
8965
+ 'mbtiles.importButton': 'Import .mbtiles',
8966
+ 'mbtiles.preparingImport': 'Preparing import...',
8967
+ 'mbtiles.importComplete': 'Import complete!',
8968
+ 'mbtiles.importFailed': 'Import failed. Please try again.',
8460
8969
  // Active Downloads
8461
8970
  'download.activeCount': 'Active Downloads ({{count}})',
8462
8971
  // Panel Manager additional strings
@@ -8617,10 +9126,6 @@ const ar = {
8617
9126
  'styleSelection.title': 'اختر نمط غير متصل',
8618
9127
  'styleSelection.message': 'اختر النمط غير المتصل الذي تريد تحميله:',
8619
9128
  'styleSelection.sources': 'مصادر',
8620
- // Import/Export - استيراد/تصدير
8621
- 'importExport.title': 'استيراد/تصدير',
8622
- 'importExport.export': 'تصدير',
8623
- 'importExport.import': 'استيراد',
8624
9129
  // Errors - الأخطاء
8625
9130
  'error.loadingContent': 'خطأ في تحميل المحتوى',
8626
9131
  'error.tryAgain': 'يرجى المحاولة مرة أخرى',
@@ -8645,41 +9150,30 @@ const ar = {
8645
9150
  'regionDetails.bounds': 'الحدود',
8646
9151
  'regionDetails.zoomRange': 'نطاق التكبير',
8647
9152
  'regionDetails.created': 'تاريخ الإنشاء',
8648
- // Import/Export Modal - نافذة الاستيراد/التصدير
8649
- 'importExport.regionTitle': 'استيراد/تصدير المنطقة',
8650
- 'importExport.regionInfo': 'معلومات المنطقة',
8651
- 'importExport.id': 'المعرف',
8652
- 'importExport.name': 'الاسم',
8653
- 'importExport.unnamed': 'بدون اسم',
8654
- 'importExport.zoom': 'التكبير',
8655
- 'importExport.created': 'تاريخ الإنشاء',
8656
- 'importExport.exportRegion': 'تصدير المنطقة',
8657
- 'importExport.exportFormat': 'تنسيق التصدير',
8658
- 'importExport.formatJson': 'JSON - بيانات كاملة (موصى به)',
8659
- 'importExport.formatPmtiles': 'PMTiles - بلاطات محسنة للويب',
8660
- 'importExport.formatMbtiles': 'MBTiles - معيار الصناعة',
8661
- 'importExport.formatHint': 'اختر التنسيق بناءً على حالة استخدامك',
8662
- 'importExport.includeComponents': 'تضمين المكونات',
8663
- 'importExport.styleConfig': 'إعدادات النمط',
8664
- 'importExport.mapTiles': 'بلاطات الخريطة',
8665
- 'importExport.spritesIcons': 'الرموز والأيقونات',
8666
- 'importExport.fontsGlyphs': 'الخطوط والحروف',
8667
- 'importExport.preparingExport': 'جاري تجهيز التصدير...',
8668
- 'importExport.exportComplete': 'اكتمل التصدير!',
8669
- 'importExport.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
8670
- 'importExport.importRegion': 'استيراد المنطقة',
8671
- 'importExport.selectFile': 'اختر ملف',
8672
- 'importExport.fileFormatsHint': 'يدعم تنسيقات JSON و PMTiles و MBTiles',
8673
- 'importExport.newRegionName': 'اسم المنطقة الجديد (اختياري)',
8674
- 'importExport.newRegionNamePlaceholder': 'اتركه فارغاً لاستخدام الاسم الأصلي',
8675
- 'importExport.overwriteIfExists': 'الكتابة فوق المنطقة الموجودة',
8676
- 'importExport.preparingImport': 'جاري تجهيز الاستيراد...',
8677
- 'importExport.importComplete': 'اكتمل الاستيراد!',
8678
- 'importExport.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
8679
- 'importExport.formatGuide': 'دليل التنسيقات',
8680
- 'importExport.jsonDesc': 'بيانات كاملة، قابلة للقراءة، الأفضل للتطوير',
8681
- 'importExport.pmtilesDesc': 'محسن للويب، خدمة فعالة، متوافق مع السحابة',
8682
- 'importExport.mbtilesDesc': 'معيار الصناعة، قائم على SQLite، متعدد المنصات',
9153
+ // MBTiles Modal - نافذة MBTiles
9154
+ 'mbtiles.title': 'استيراد / تصدير MBTiles',
9155
+ 'mbtiles.regionInfo': 'معلومات المنطقة',
9156
+ 'mbtiles.id': 'المعرف',
9157
+ 'mbtiles.name': 'الاسم',
9158
+ 'mbtiles.unnamed': 'بدون اسم',
9159
+ 'mbtiles.zoom': 'التكبير',
9160
+ 'mbtiles.created': 'تاريخ الإنشاء',
9161
+ 'mbtiles.exportTitle': 'التصدير كـ MBTiles',
9162
+ 'mbtiles.exportHint': 'احزم بلاطات هذه المنطقة داخل أرشيف MBTiles (SQLite) يمكن فتحه في QGIS و tippecanoe وأدوات أخرى.',
9163
+ 'mbtiles.exportButton': 'تنزيل ملف mbtiles.',
9164
+ 'mbtiles.preparingExport': 'جاري تجهيز التصدير...',
9165
+ 'mbtiles.exportComplete': 'اكتمل التصدير!',
9166
+ 'mbtiles.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9167
+ 'mbtiles.importTitle': 'الاستيراد من MBTiles',
9168
+ 'mbtiles.selectFile': 'اختر ملف mbtiles.',
9169
+ 'mbtiles.fileHint': 'تُدعم ملفات mbtiles. بتنسيق SQLite فقط.',
9170
+ 'mbtiles.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9171
+ 'mbtiles.newRegionNamePlaceholder': 'اتركه فارغًا لاستخدام الاسم من الملف',
9172
+ 'mbtiles.overwriteIfExists': 'الكتابة فوق المنطقة إذا كان المعرف موجودًا',
9173
+ 'mbtiles.importButton': 'استيراد ملف mbtiles.',
9174
+ 'mbtiles.preparingImport': 'جاري تجهيز الاستيراد...',
9175
+ 'mbtiles.importComplete': 'اكتمل الاستيراد!',
9176
+ 'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
8683
9177
  // Active Downloads - التحميلات النشطة
8684
9178
  'download.activeCount': 'التحميلات النشطة ({{count}})',
8685
9179
  // Panel Manager additional strings - سلاسل إضافية للوحة
@@ -9618,49 +10112,42 @@ class ConfirmationModal {
9618
10112
  }
9619
10113
 
9620
10114
  /**
9621
- * Import/Export Modal Component
9622
- * Handles import/export operations for regions
9623
- * Refactored to use modular Modal component for consistency
10115
+ * MBTiles Import/Export Modal
10116
+ *
10117
+ * Focused modal for exchanging regions as binary SQLite MBTiles archives.
10118
+ * Replaces the previous multi-format import/export modal.
9624
10119
  */
9625
- const modalLogger = logger.scope('ImportExportModal');
9626
- class ImportExportModal {
10120
+ const modalLogger = logger.scope('MBTilesModal');
10121
+ class MBTilesModal {
9627
10122
  modal;
9628
10123
  options;
9629
10124
  isExporting = false;
9630
10125
  isImporting = false;
9631
- // Form elements
9632
- exportFormatSelect;
9633
- includeStyleCheckbox;
9634
- includeTilesCheckbox;
9635
- includeSpritesCheckbox;
9636
- includeFontsCheckbox;
9637
10126
  exportProgressBar;
9638
10127
  exportProgressText;
10128
+ exportProgressContainer;
9639
10129
  exportButton;
9640
10130
  importFileInput;
9641
10131
  importNameInput;
9642
10132
  importOverwriteCheckbox;
9643
10133
  importProgressBar;
9644
10134
  importProgressText;
10135
+ importProgressContainer;
9645
10136
  importButton;
9646
10137
  constructor(options) {
9647
10138
  this.options = options;
9648
10139
  }
9649
10140
  show() {
9650
10141
  const modalConfig = {
9651
- title: t('importExport.regionTitle'),
10142
+ title: t('mbtiles.title'),
9652
10143
  subtitle: this.options.region.name || this.options.region.id,
9653
10144
  size: 'md',
9654
10145
  closable: true,
9655
10146
  onClose: () => this.hide(),
9656
10147
  };
9657
10148
  this.modal = new Modal(modalConfig);
9658
- // Create content
9659
- const content = this.createContent();
9660
- this.modal.setContent(content);
9661
- // Create footer with close button
9662
- const footer = this.createFooter();
9663
- this.modal.setFooter(footer);
10149
+ this.modal.setContent(this.createContent());
10150
+ this.modal.setFooter(this.createFooter());
9664
10151
  this.modal.show();
9665
10152
  this.attachEventListeners();
9666
10153
  return this.modal.getElement();
@@ -9669,217 +10156,96 @@ class ImportExportModal {
9669
10156
  this.modal?.hide();
9670
10157
  this.options.onClose();
9671
10158
  }
10159
+ destroy() {
10160
+ this.modal?.destroy();
10161
+ }
9672
10162
  createContent() {
9673
10163
  const content = document.createElement('div');
9674
- content.className = 'flex flex-col gap-6';
10164
+ content.className = 'flex flex-col gap-6 py-2';
9675
10165
  if (i18n.isRTL()) {
9676
10166
  content.setAttribute('dir', 'rtl');
9677
10167
  }
9678
- // Region Info Card
9679
- const infoCard = this.createRegionInfoCard();
9680
- content.appendChild(infoCard);
9681
- // Export/Import Grid
9682
- const gridContainer = document.createElement('div');
9683
- gridContainer.className = 'grid grid-cols-1 gap-6';
9684
- // Export Section
9685
- const exportSection = this.createExportSection();
9686
- gridContainer.appendChild(exportSection);
9687
- // Import Section
9688
- const importSection = this.createImportSection();
9689
- gridContainer.appendChild(importSection);
9690
- content.appendChild(gridContainer);
9691
- // Format Guide
9692
- const formatGuide = this.createFormatGuide();
9693
- content.appendChild(formatGuide);
10168
+ content.appendChild(this.createRegionInfoLine());
10169
+ content.appendChild(this.createExportSection());
10170
+ content.appendChild(this.createImportSection());
9694
10171
  return content;
9695
10172
  }
9696
- createRegionInfoCard() {
9697
- const card = document.createElement('div');
9698
- card.className = 'p-5 glass-input rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50';
9699
- card.innerHTML = `
9700
- <h4 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-white mb-4 flex items-center gap-2">
9701
- ${icons.mapPin({ size: 16, color: 'currentColor' })}
9702
- ${t('importExport.regionInfo')}
9703
- </h4>
9704
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
9705
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
9706
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.id')}</span>
9707
- <div class="text-gray-900 dark:text-white font-mono text-xs break-all mt-1">${escapeHtml$1(this.options.region.id)}</div>
9708
- </div>
9709
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
9710
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.name')}</span>
9711
- <div class="text-gray-900 dark:text-white font-medium mt-1">${escapeHtml$1(this.options.region.name || t('importExport.unnamed'))}</div>
9712
- </div>
9713
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
9714
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.zoom')}</span>
9715
- <div class="text-gray-900 dark:text-white font-medium mt-1">Z${escapeHtml$1(this.options.region.minZoom)}-${escapeHtml$1(this.options.region.maxZoom)}</div>
9716
- </div>
9717
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
9718
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.created')}</span>
9719
- <div class="text-gray-900 dark:text-white font-medium mt-1">${new Date(this.options.region.created).toLocaleDateString()}</div>
9720
- </div>
9721
- </div>
10173
+ createRegionInfoLine() {
10174
+ const { region } = this.options;
10175
+ const line = document.createElement('div');
10176
+ line.className =
10177
+ 'flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400';
10178
+ line.innerHTML = `
10179
+ <span class="flex items-center gap-1">
10180
+ ${icons.mapPin({ size: 12, color: 'currentColor' })}
10181
+ <span class="font-mono">${escapeHtml$1(region.id)}</span>
10182
+ </span>
10183
+ <span>Z${escapeHtml$1(region.minZoom)}-${escapeHtml$1(region.maxZoom)}</span>
10184
+ <span>${new Date(region.created).toLocaleDateString()}</span>
9722
10185
  `;
9723
- return card;
10186
+ return line;
9724
10187
  }
9725
10188
  createExportSection() {
9726
- const section = document.createElement('div');
9727
- section.className =
9728
- 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden group hover:scale-[1.01] transition-transform duration-300';
9729
- // Gradient accent
9730
- const accent = document.createElement('div');
9731
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-blue-500 opacity-50`;
9732
- section.appendChild(accent);
9733
- const header = document.createElement('h3');
9734
- header.className =
9735
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
9736
- header.innerHTML = `
9737
- <div class="p-2 bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400">
9738
- ${icons.upload({ size: 20, color: 'currentColor' })}
9739
- </div>
9740
- ${t('importExport.exportRegion')}
9741
- `;
9742
- section.appendChild(header);
9743
- const formContainer = document.createElement('div');
9744
- formContainer.className = 'space-y-5';
9745
- // Format Selection
9746
- const formatGroup = document.createElement('div');
9747
- const formatLabel = document.createElement('label');
9748
- formatLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
9749
- formatLabel.textContent = t('importExport.exportFormat');
9750
- this.exportFormatSelect = document.createElement('select');
9751
- this.exportFormatSelect.className =
9752
- 'w-full px-4 py-3 rounded-xl text-sm glass-input text-gray-900 dark:text-white bg-white/50 dark:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all';
9753
- this.exportFormatSelect.innerHTML = `
9754
- <option value="json">${t('importExport.formatJson')}</option>
9755
- <option value="pmtiles">${t('importExport.formatPmtiles')}</option>
9756
- <option value="mbtiles">${t('importExport.formatMbtiles')}</option>
9757
- `;
9758
- const formatHint = document.createElement('p');
9759
- formatHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
9760
- formatHint.textContent = t('importExport.formatHint');
9761
- formatGroup.appendChild(formatLabel);
9762
- formatGroup.appendChild(this.exportFormatSelect);
9763
- formatGroup.appendChild(formatHint);
9764
- formContainer.appendChild(formatGroup);
9765
- // Export Options
9766
- const optionsGroup = document.createElement('div');
9767
- const optionsLabel = document.createElement('label');
9768
- optionsLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3';
9769
- optionsLabel.textContent = t('importExport.includeComponents');
9770
- const checkboxContainer = document.createElement('div');
9771
- checkboxContainer.className = 'grid grid-cols-1 sm:grid-cols-2 gap-3';
9772
- const createCheckbox = (text, checked = true) => {
9773
- const label = document.createElement('label');
9774
- label.className =
9775
- 'flex items-center gap-3 p-3 rounded-lg bg-gray-50/50 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer';
9776
- const input = document.createElement('input');
9777
- input.type = 'checkbox';
9778
- input.checked = checked;
9779
- input.className =
9780
- 'w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2 dark:bg-gray-700';
9781
- const span = document.createElement('span');
9782
- span.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
9783
- span.textContent = text;
9784
- label.appendChild(input);
9785
- label.appendChild(span);
9786
- return { label, input };
9787
- };
9788
- const styleCheck = createCheckbox(t('importExport.styleConfig'));
9789
- this.includeStyleCheckbox = styleCheck.input;
9790
- checkboxContainer.appendChild(styleCheck.label);
9791
- const tilesCheck = createCheckbox(t('importExport.mapTiles'));
9792
- this.includeTilesCheckbox = tilesCheck.input;
9793
- checkboxContainer.appendChild(tilesCheck.label);
9794
- const spritesCheck = createCheckbox(t('importExport.spritesIcons'));
9795
- this.includeSpritesCheckbox = spritesCheck.input;
9796
- checkboxContainer.appendChild(spritesCheck.label);
9797
- const fontsCheck = createCheckbox(t('importExport.fontsGlyphs'));
9798
- this.includeFontsCheckbox = fontsCheck.input;
9799
- checkboxContainer.appendChild(fontsCheck.label);
9800
- optionsGroup.appendChild(optionsLabel);
9801
- optionsGroup.appendChild(checkboxContainer);
9802
- formContainer.appendChild(optionsGroup);
9803
- // Export Progress (hidden by default)
9804
- const progressContainer = document.createElement('div');
9805
- progressContainer.className = 'hidden';
9806
- const progressBarContainer = document.createElement('div');
9807
- progressBarContainer.className =
9808
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
9809
- this.exportProgressBar = document.createElement('div');
9810
- this.exportProgressBar.className = 'bg-blue-600 h-2 rounded-full transition-all duration-300';
9811
- this.exportProgressBar.style.width = '0%';
9812
- progressBarContainer.appendChild(this.exportProgressBar);
9813
- this.exportProgressText = document.createElement('p');
9814
- this.exportProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
9815
- this.exportProgressText.textContent = t('importExport.preparingExport');
9816
- progressContainer.appendChild(progressBarContainer);
9817
- progressContainer.appendChild(this.exportProgressText);
9818
- formContainer.appendChild(progressContainer);
9819
- // Export Button
9820
- const exportButton = new Button({
9821
- text: t('importExport.exportRegion'),
10189
+ const section = this.createSection(t('mbtiles.exportTitle'), 'blue', icons.download({ size: 20, color: 'currentColor' }));
10190
+ const form = document.createElement('div');
10191
+ form.className = 'space-y-5';
10192
+ const hint = document.createElement('p');
10193
+ hint.className = 'text-sm text-gray-600 dark:text-gray-400';
10194
+ hint.textContent = t('mbtiles.exportHint');
10195
+ form.appendChild(hint);
10196
+ // Progress (hidden by default)
10197
+ const progress = this.createProgressBlock('blue', t('mbtiles.preparingExport'));
10198
+ this.exportProgressContainer = progress.container;
10199
+ this.exportProgressBar = progress.bar;
10200
+ this.exportProgressText = progress.text;
10201
+ form.appendChild(progress.container);
10202
+ const exportBtn = new Button({
10203
+ text: t('mbtiles.exportButton'),
9822
10204
  variant: 'primary',
9823
10205
  icon: icons.download({ size: 16, color: 'white' }),
9824
- className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20', // Premium button styles
10206
+ className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20',
9825
10207
  onClick: () => this.handleExport(),
9826
10208
  });
9827
- this.exportButton = exportButton.getElement();
9828
- formContainer.appendChild(this.exportButton);
9829
- section.appendChild(formContainer);
10209
+ this.exportButton = exportBtn.getElement();
10210
+ form.appendChild(this.exportButton);
10211
+ section.appendChild(form);
9830
10212
  return section;
9831
10213
  }
9832
10214
  createImportSection() {
9833
- const section = document.createElement('div');
9834
- section.className =
9835
- 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden group hover:scale-[1.01] transition-transform duration-300';
9836
- // Gradient accent
9837
- const accent = document.createElement('div');
9838
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-green-500 opacity-50`;
9839
- section.appendChild(accent);
9840
- const header = document.createElement('h3');
9841
- header.className =
9842
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
9843
- header.innerHTML = `
9844
- <div class="p-2 bg-green-500/10 rounded-lg text-green-600 dark:text-green-400">
9845
- ${icons.upload({ size: 20, color: 'currentColor' })}
9846
- </div>
9847
- ${t('importExport.importRegion')}
9848
- `;
9849
- section.appendChild(header);
9850
- const formContainer = document.createElement('div');
9851
- formContainer.className = 'space-y-5';
9852
- // File Selection
10215
+ const section = this.createSection(t('mbtiles.importTitle'), 'green', icons.upload({ size: 20, color: 'currentColor' }));
10216
+ const form = document.createElement('div');
10217
+ form.className = 'space-y-5';
10218
+ // File input
9853
10219
  const fileGroup = document.createElement('div');
9854
10220
  const fileLabel = document.createElement('label');
9855
10221
  fileLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
9856
- fileLabel.textContent = t('importExport.selectFile');
10222
+ fileLabel.textContent = t('mbtiles.selectFile');
9857
10223
  this.importFileInput = document.createElement('input');
9858
10224
  this.importFileInput.type = 'file';
9859
- this.importFileInput.accept = '.json,.pmtiles,.mbtiles';
10225
+ this.importFileInput.accept = '.mbtiles,application/vnd.sqlite3,application/x-sqlite3';
9860
10226
  this.importFileInput.className =
9861
10227
  'w-full text-sm text-gray-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/20 dark:file:text-primary-400 glass-input transition-all cursor-pointer';
9862
10228
  const fileHint = document.createElement('p');
9863
10229
  fileHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
9864
- fileHint.textContent = t('importExport.fileFormatsHint');
10230
+ fileHint.textContent = t('mbtiles.fileHint');
9865
10231
  fileGroup.appendChild(fileLabel);
9866
10232
  fileGroup.appendChild(this.importFileInput);
9867
10233
  fileGroup.appendChild(fileHint);
9868
- formContainer.appendChild(fileGroup);
9869
- // New Name
10234
+ form.appendChild(fileGroup);
10235
+ // New region name
9870
10236
  const nameGroup = document.createElement('div');
9871
10237
  const nameLabel = document.createElement('label');
9872
10238
  nameLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
9873
- nameLabel.textContent = t('importExport.newRegionName');
10239
+ nameLabel.textContent = t('mbtiles.newRegionName');
9874
10240
  this.importNameInput = document.createElement('input');
9875
10241
  this.importNameInput.type = 'text';
9876
- this.importNameInput.placeholder = t('importExport.newRegionNamePlaceholder');
10242
+ this.importNameInput.placeholder = t('mbtiles.newRegionNamePlaceholder');
9877
10243
  this.importNameInput.className =
9878
10244
  'w-full px-4 py-3 rounded-xl text-sm glass-input text-gray-900 dark:text-white bg-white/50 dark:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all';
9879
10245
  nameGroup.appendChild(nameLabel);
9880
10246
  nameGroup.appendChild(this.importNameInput);
9881
- formContainer.appendChild(nameGroup);
9882
- // Import Options
10247
+ form.appendChild(nameGroup);
10248
+ // Overwrite toggle
9883
10249
  const overwriteLabel = document.createElement('label');
9884
10250
  overwriteLabel.className =
9885
10251
  'flex items-center gap-3 p-3 rounded-lg bg-gray-50/50 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors';
@@ -9889,79 +10255,85 @@ class ImportExportModal {
9889
10255
  'w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500 focus:ring-2 dark:bg-gray-700';
9890
10256
  const overwriteSpan = document.createElement('span');
9891
10257
  overwriteSpan.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
9892
- overwriteSpan.textContent = t('importExport.overwriteIfExists');
10258
+ overwriteSpan.textContent = t('mbtiles.overwriteIfExists');
9893
10259
  overwriteLabel.appendChild(this.importOverwriteCheckbox);
9894
10260
  overwriteLabel.appendChild(overwriteSpan);
9895
- formContainer.appendChild(overwriteLabel);
9896
- // Import Progress (hidden by default)
9897
- const progressContainer = document.createElement('div');
9898
- progressContainer.className = 'hidden';
9899
- const progressBarContainer = document.createElement('div');
9900
- progressBarContainer.className =
9901
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
9902
- this.importProgressBar = document.createElement('div');
9903
- this.importProgressBar.className = 'bg-green-600 h-2 rounded-full transition-all duration-300';
9904
- this.importProgressBar.style.width = '0%';
9905
- progressBarContainer.appendChild(this.importProgressBar);
9906
- this.importProgressText = document.createElement('p');
9907
- this.importProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
9908
- this.importProgressText.textContent = t('importExport.preparingImport');
9909
- progressContainer.appendChild(progressBarContainer);
9910
- progressContainer.appendChild(this.importProgressText);
9911
- formContainer.appendChild(progressContainer);
9912
- // Import Button
9913
- const importButton = new Button({
9914
- text: t('importExport.importRegion'),
9915
- variant: 'success', // Assuming 'success' variant exists in Button component, if not might need style adjustment. Assuming it works based on previous code.
10261
+ form.appendChild(overwriteLabel);
10262
+ // Progress
10263
+ const progress = this.createProgressBlock('green', t('mbtiles.preparingImport'));
10264
+ this.importProgressContainer = progress.container;
10265
+ this.importProgressBar = progress.bar;
10266
+ this.importProgressText = progress.text;
10267
+ form.appendChild(progress.container);
10268
+ // Import button (disabled until a file is selected)
10269
+ const importBtn = new Button({
10270
+ text: t('mbtiles.importButton'),
10271
+ variant: 'success',
9916
10272
  icon: icons.upload({ size: 16, color: 'white' }),
9917
10273
  className: 'w-full py-2.5 text-base shadow-lg shadow-green-500/20',
9918
10274
  disabled: true,
9919
10275
  onClick: () => this.handleImport(),
9920
10276
  });
9921
- this.importButton = importButton.getElement();
9922
- formContainer.appendChild(this.importButton);
9923
- section.appendChild(formContainer);
10277
+ this.importButton = importBtn.getElement();
10278
+ form.appendChild(this.importButton);
10279
+ section.appendChild(form);
9924
10280
  return section;
9925
10281
  }
9926
- createFormatGuide() {
9927
- const guide = document.createElement('div');
9928
- guide.className = 'p-5 mt-4 glass-input rounded-xl border-0 bg-blue-50/40 dark:bg-blue-900/20';
9929
- guide.innerHTML = `
9930
- <h4 class="text-sm font-bold uppercase tracking-wider text-blue-900 dark:text-blue-300 mb-3 flex items-center gap-2">
9931
- ${icons.infoCircle({ size: 16, color: 'currentColor' })}
9932
- ${t('importExport.formatGuide')}
9933
- </h4>
9934
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
9935
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
9936
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">JSON</div>
9937
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.jsonDesc')}</div>
9938
- </div>
9939
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
9940
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">PMTiles</div>
9941
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.pmtilesDesc')}</div>
9942
- </div>
9943
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
9944
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">MBTiles</div>
9945
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.mbtilesDesc')}</div>
9946
- </div>
10282
+ createSection(title, accentColor, iconHtml) {
10283
+ const section = document.createElement('div');
10284
+ section.className =
10285
+ 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden';
10286
+ const accent = document.createElement('div');
10287
+ accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-${accentColor}-500 opacity-50`;
10288
+ section.appendChild(accent);
10289
+ const header = document.createElement('h3');
10290
+ header.className =
10291
+ 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10292
+ header.innerHTML = `
10293
+ <div class="p-2 bg-${accentColor}-500/10 rounded-lg text-${accentColor}-600 dark:text-${accentColor}-400">
10294
+ ${iconHtml}
9947
10295
  </div>
10296
+ ${title}
9948
10297
  `;
9949
- return guide;
10298
+ section.appendChild(header);
10299
+ return section;
10300
+ }
10301
+ createProgressBlock(accentColor, initialText) {
10302
+ const container = document.createElement('div');
10303
+ container.className = 'hidden';
10304
+ const barWrap = document.createElement('div');
10305
+ barWrap.className = 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10306
+ const bar = document.createElement('div');
10307
+ bar.className = `bg-${accentColor}-600 h-2 rounded-full transition-all duration-300`;
10308
+ bar.style.width = '0%';
10309
+ barWrap.appendChild(bar);
10310
+ const text = document.createElement('p');
10311
+ text.className = 'text-sm text-gray-600 dark:text-gray-400';
10312
+ text.textContent = initialText;
10313
+ container.appendChild(barWrap);
10314
+ container.appendChild(text);
10315
+ return { container, bar, text };
10316
+ }
10317
+ createFooter() {
10318
+ const footer = document.createElement('div');
10319
+ footer.className = 'flex gap-3 justify-end';
10320
+ if (i18n.isRTL()) {
10321
+ footer.setAttribute('dir', 'rtl');
10322
+ }
10323
+ const close = new Button({
10324
+ text: t('app.close'),
10325
+ variant: 'secondary',
10326
+ onClick: () => this.hide(),
10327
+ });
10328
+ footer.appendChild(close.getElement());
10329
+ return footer;
9950
10330
  }
9951
10331
  attachEventListeners() {
9952
- // Enable import button when file is selected
9953
10332
  if (this.importFileInput && this.importButton) {
9954
10333
  this.importFileInput.addEventListener('change', () => {
9955
- if (this.importFileInput?.files && this.importFileInput.files.length > 0) {
9956
- if (this.importButton) {
9957
- this.importButton.disabled = false;
9958
- }
9959
- }
9960
- else {
9961
- if (this.importButton) {
9962
- this.importButton.disabled = true;
9963
- }
9964
- }
10334
+ const hasFile = !!(this.importFileInput?.files && this.importFileInput.files.length > 0);
10335
+ if (this.importButton)
10336
+ this.importButton.disabled = !hasFile;
9965
10337
  });
9966
10338
  }
9967
10339
  }
@@ -9971,34 +10343,27 @@ class ImportExportModal {
9971
10343
  this.isExporting = true;
9972
10344
  if (this.exportButton)
9973
10345
  this.exportButton.disabled = true;
10346
+ this.exportProgressContainer?.classList.remove('hidden');
9974
10347
  try {
9975
- const format = this.exportFormatSelect?.value;
9976
- const options = {
9977
- includeStyle: this.includeStyleCheckbox?.checked ?? true,
9978
- includeTiles: this.includeTilesCheckbox?.checked ?? true,
9979
- includeSprites: this.includeSpritesCheckbox?.checked ?? true,
9980
- includeFonts: this.includeFontsCheckbox?.checked ?? true,
9981
- };
9982
- // Show progress
9983
- const progressContainer = this.exportProgressBar?.parentElement?.parentElement;
9984
- if (progressContainer) {
9985
- progressContainer.classList.remove('hidden');
9986
- }
9987
- const result = await this.options.exportRegion(this.options.region.id, format, options);
9988
- if (this.exportProgressBar) {
10348
+ const result = await this.options.exportRegion(this.options.region.id, {
10349
+ onProgress: p => {
10350
+ if (this.exportProgressBar)
10351
+ this.exportProgressBar.style.width = `${p.percentage}%`;
10352
+ if (this.exportProgressText)
10353
+ this.exportProgressText.textContent = p.message;
10354
+ },
10355
+ });
10356
+ if (this.exportProgressBar)
9989
10357
  this.exportProgressBar.style.width = '100%';
9990
- }
9991
- if (this.exportProgressText) {
9992
- this.exportProgressText.textContent = t('importExport.exportComplete');
9993
- }
10358
+ if (this.exportProgressText)
10359
+ this.exportProgressText.textContent = t('mbtiles.exportComplete');
9994
10360
  this.options.onExport?.(result);
9995
- // Hide modal after short delay
9996
- setTimeout(() => this.hide(), 1500);
10361
+ setTimeout(() => this.hide(), 1200);
9997
10362
  }
9998
10363
  catch (error) {
9999
10364
  modalLogger.error('Export error:', error instanceof Error ? error.message : String(error));
10000
10365
  if (this.exportProgressText) {
10001
- this.exportProgressText.textContent = t('importExport.exportFailed');
10366
+ this.exportProgressText.textContent = t('mbtiles.exportFailed');
10002
10367
  this.exportProgressText.classList.add('text-red-600', 'dark:text-red-400');
10003
10368
  }
10004
10369
  }
@@ -10009,45 +10374,47 @@ class ImportExportModal {
10009
10374
  }
10010
10375
  }
10011
10376
  async handleImport() {
10012
- if (this.isImporting || !this.options.importRegion || !this.importFileInput?.files?.[0])
10377
+ if (this.isImporting || !this.options.importRegion)
10378
+ return;
10379
+ const file = this.importFileInput?.files?.[0];
10380
+ if (!file)
10013
10381
  return;
10014
10382
  this.isImporting = true;
10015
10383
  if (this.importButton)
10016
10384
  this.importButton.disabled = true;
10385
+ this.importProgressContainer?.classList.remove('hidden');
10017
10386
  try {
10018
- const file = this.importFileInput.files[0];
10019
- const overwrite = this.importOverwriteCheckbox?.checked ?? false;
10020
- // Show progress
10021
- const progressContainer = this.importProgressBar?.parentElement?.parentElement;
10022
- if (progressContainer) {
10023
- progressContainer.classList.remove('hidden');
10024
- }
10025
- // Determine format from file extension
10026
- const format = file.name.endsWith('.pmtiles')
10027
- ? 'pmtiles'
10028
- : file.name.endsWith('.mbtiles')
10029
- ? 'mbtiles'
10030
- : 'json';
10031
10387
  const data = {
10032
10388
  file,
10033
- format,
10034
- overwrite,
10389
+ format: 'mbtiles',
10390
+ overwrite: this.importOverwriteCheckbox?.checked ?? false,
10391
+ newRegionName: this.importNameInput?.value.trim() || undefined,
10392
+ onProgress: p => {
10393
+ if (this.importProgressBar)
10394
+ this.importProgressBar.style.width = `${p.percentage}%`;
10395
+ if (this.importProgressText)
10396
+ this.importProgressText.textContent = p.message;
10397
+ },
10035
10398
  };
10036
10399
  const result = await this.options.importRegion(data);
10037
- if (this.importProgressBar) {
10400
+ if (this.importProgressBar)
10038
10401
  this.importProgressBar.style.width = '100%';
10039
- }
10040
10402
  if (this.importProgressText) {
10041
- this.importProgressText.textContent = t('importExport.importComplete');
10403
+ this.importProgressText.textContent = result.success
10404
+ ? t('mbtiles.importComplete')
10405
+ : result.message;
10406
+ if (!result.success) {
10407
+ this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10408
+ }
10042
10409
  }
10043
10410
  this.options.onImport?.(result);
10044
- // Hide modal after short delay
10045
- setTimeout(() => this.hide(), 1500);
10411
+ if (result.success)
10412
+ setTimeout(() => this.hide(), 1200);
10046
10413
  }
10047
10414
  catch (error) {
10048
10415
  modalLogger.error('Import error:', error instanceof Error ? error.message : String(error));
10049
10416
  if (this.importProgressText) {
10050
- this.importProgressText.textContent = t('importExport.importFailed');
10417
+ this.importProgressText.textContent = t('mbtiles.importFailed');
10051
10418
  this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10052
10419
  }
10053
10420
  }
@@ -10057,23 +10424,6 @@ class ImportExportModal {
10057
10424
  this.importButton.disabled = false;
10058
10425
  }
10059
10426
  }
10060
- createFooter() {
10061
- const footer = document.createElement('div');
10062
- footer.className = 'flex gap-3 justify-end';
10063
- if (i18n.isRTL()) {
10064
- footer.setAttribute('dir', 'rtl');
10065
- }
10066
- const closeButton = new Button({
10067
- text: t('app.close'),
10068
- variant: 'secondary',
10069
- onClick: () => this.hide(),
10070
- });
10071
- footer.appendChild(closeButton.getElement());
10072
- return footer;
10073
- }
10074
- destroy() {
10075
- this.modal?.destroy();
10076
- }
10077
10427
  }
10078
10428
 
10079
10429
  /**
@@ -10787,9 +11137,9 @@ class PanelRenderer extends BaseComponent {
10787
11137
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 transition-colors duration-150" data-action="redownload-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.redownload')}">
10788
11138
  ${icons.download({ size: 14, color: 'currentColor' })}
10789
11139
  </button>
10790
- <!-- <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
11140
+ <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
10791
11141
  ${icons.deviceFloppy({ size: 14, color: 'currentColor' })}
10792
- </button> -->
11142
+ </button>
10793
11143
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 transition-colors duration-150" data-action="delete-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('app.delete')}">
10794
11144
  ${icons.trash({ size: 14, color: 'currentColor' })}
10795
11145
  </button>
@@ -11051,7 +11401,7 @@ class PanelRenderer extends BaseComponent {
11051
11401
  }
11052
11402
  }
11053
11403
  /**
11054
- * Handle import/export functionality
11404
+ * Show the MBTiles import/export modal for a region.
11055
11405
  */
11056
11406
  async handleImportExport(regionId, _regionData) {
11057
11407
  try {
@@ -11059,44 +11409,26 @@ class PanelRenderer extends BaseComponent {
11059
11409
  const region = regions.find((r) => r.id === regionId);
11060
11410
  if (!region)
11061
11411
  return;
11062
- const importExportModal = new ImportExportModal({
11412
+ const mbtilesModal = new MBTilesModal({
11063
11413
  region,
11064
- onClose: () => {
11065
- this.modalManager.close();
11066
- },
11414
+ onClose: () => this.modalManager.close(),
11067
11415
  onExport: result => {
11068
- panelLogger.debug('Export completed:', result);
11069
- // Handle export result - could show success message
11416
+ panelLogger.debug('MBTiles export completed:', result);
11070
11417
  this.offlineManager.downloadExportedRegion(result);
11071
11418
  },
11072
11419
  onImport: result => {
11073
- panelLogger.debug('Import completed:', result);
11074
- // Refresh the panel to show updated regions
11075
- this.refresh();
11076
- },
11077
- exportRegion: async (regionId, format, options) => {
11078
- // Delegate to offline manager's export functionality
11079
- switch (format) {
11080
- case 'json':
11081
- return await this.offlineManager.exportRegionAsJSON(regionId, options);
11082
- case 'pmtiles':
11083
- return await this.offlineManager.exportRegionAsPMTiles(regionId, options);
11084
- case 'mbtiles':
11085
- return await this.offlineManager.exportRegionAsMBTiles(regionId, options);
11086
- default:
11087
- throw new Error(`Unsupported export format: ${format}`);
11088
- }
11089
- },
11090
- importRegion: async (data) => {
11091
- // Delegate to offline manager's import functionality
11092
- return await this.offlineManager.importRegion(data);
11420
+ panelLogger.debug('MBTiles import completed:', result);
11421
+ if (result.success)
11422
+ this.refresh();
11093
11423
  },
11424
+ exportRegion: (id, options) => this.offlineManager.exportRegionAsMBTiles(id, options),
11425
+ importRegion: data => this.offlineManager.importRegion(data),
11094
11426
  });
11095
- const modal = importExportModal.show();
11427
+ const modal = mbtilesModal.show();
11096
11428
  this.modalManager.show(modal);
11097
11429
  }
11098
11430
  catch (error) {
11099
- panelLogger.error('Error showing import/export modal:', error);
11431
+ panelLogger.error('Error showing MBTiles modal:', error);
11100
11432
  }
11101
11433
  }
11102
11434
  /**
@@ -11495,6 +11827,9 @@ class PanelRenderer extends BaseComponent {
11495
11827
  delete patchedStyle.imports;
11496
11828
  panelLogger.debug('Stripped imports from offline style (already flattened)');
11497
11829
  }
11830
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
11831
+ // downloaded before resolveImports learned to rewrite them.
11832
+ sanitizeIndoorExpressions(patchedStyle);
11498
11833
  // Enforce maxzoom for all tile sources to prevent requesting non-existent tiles
11499
11834
  // Find the maximum zoom level from all regions using this style
11500
11835
  let maxZoom = 14; // Default fallback
@@ -11543,10 +11878,12 @@ class PanelRenderer extends BaseComponent {
11543
11878
  panelLogger.debug(`Fixed legacy source ${sourceId}: added tiles, removed url`);
11544
11879
  }
11545
11880
  }
11546
- // Apply maxzoom to all tile sources (including batched-model for 3D buildings)
11881
+ // Apply maxzoom to all tile sources (including batched-model for 3D
11882
+ // buildings and raster-array for Mapbox Standard landmark icons).
11547
11883
  if (src.type === 'vector' ||
11548
11884
  src.type === 'raster' ||
11549
11885
  src.type === 'raster-dem' ||
11886
+ src.type === 'raster-array' ||
11550
11887
  src.type === 'batched-model') {
11551
11888
  const originalMaxzoom = src.maxzoom;
11552
11889
  // Use the lower of region maxZoom and source's original maxzoom so we
@@ -12234,9 +12571,7 @@ class RegionFormModal {
12234
12571
  detectProviderFromUrl() {
12235
12572
  const styleUrl = this.styleUrlInput?.value || '';
12236
12573
  // Simple detection logic
12237
- if (styleUrl.startsWith('mapbox://') ||
12238
- styleUrl.includes('mapbox.com') ||
12239
- styleUrl.includes('api.mapbox.com')) {
12574
+ if (styleUrl.startsWith('mapbox://') || isMapboxHost(styleUrl)) {
12240
12575
  if (this.providerSelect)
12241
12576
  this.providerSelect.value = 'mapbox';
12242
12577
  this.toggleAccessTokenVisibility(true);
@@ -13000,39 +13335,39 @@ class OfflineManagerControl {
13000
13335
  }
13001
13336
  // Development proxy for CORS issues (when running on localhost)
13002
13337
  if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
13003
- // Proxy Carto tile requests (tiles and TileJSON)
13004
- const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(url);
13005
- const isTileJsonRequest = url.includes('.json') && url.includes('basemaps.cartocdn.com');
13006
- if (isTileRequest && url.includes('tiles-a.basemaps.cartocdn.com')) {
13007
- const proxyUrl = url.replace('https://tiles-a.basemaps.cartocdn.com', '/tiles/carto-a');
13008
- return originalFetch(proxyUrl, init);
13009
- }
13010
- if (isTileRequest && url.includes('tiles-b.basemaps.cartocdn.com')) {
13011
- const proxyUrl = url.replace('https://tiles-b.basemaps.cartocdn.com', '/tiles/carto-b');
13012
- return originalFetch(proxyUrl, init);
13338
+ let parsed = null;
13339
+ try {
13340
+ parsed = new URL(url, location.origin);
13013
13341
  }
13014
- if (isTileRequest && url.includes('tiles-c.basemaps.cartocdn.com')) {
13015
- const proxyUrl = url.replace('https://tiles-c.basemaps.cartocdn.com', '/tiles/carto-c');
13016
- return originalFetch(proxyUrl, init);
13342
+ catch {
13343
+ parsed = null;
13017
13344
  }
13018
- if (isTileRequest && url.includes('tiles-d.basemaps.cartocdn.com')) {
13019
- const proxyUrl = url.replace('https://tiles-d.basemaps.cartocdn.com', '/tiles/carto-d');
13020
- return originalFetch(proxyUrl, init);
13345
+ const hostname = parsed?.hostname ?? '';
13346
+ const pathAndQuery = parsed ? parsed.pathname + parsed.search : '';
13347
+ // Proxy Carto tile requests (tiles and TileJSON)
13348
+ const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(parsed?.pathname ?? '');
13349
+ const isTileJsonRequest = (parsed?.pathname.endsWith('.json') ?? false) &&
13350
+ (hostname === 'basemaps.cartocdn.com' || hostname.endsWith('.basemaps.cartocdn.com'));
13351
+ const cartoSubdomainProxy = {
13352
+ 'tiles-a.basemaps.cartocdn.com': '/tiles/carto-a',
13353
+ 'tiles-b.basemaps.cartocdn.com': '/tiles/carto-b',
13354
+ 'tiles-c.basemaps.cartocdn.com': '/tiles/carto-c',
13355
+ 'tiles-d.basemaps.cartocdn.com': '/tiles/carto-d',
13356
+ };
13357
+ if (isTileRequest && cartoSubdomainProxy[hostname]) {
13358
+ return originalFetch(cartoSubdomainProxy[hostname] + pathAndQuery, init);
13021
13359
  }
13022
13360
  // Proxy TileJSON requests from tiles.basemaps.cartocdn.com
13023
- if (isTileJsonRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13024
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/carto-api');
13025
- return originalFetch(proxyUrl, init);
13361
+ if (isTileJsonRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13362
+ return originalFetch('/carto-api' + pathAndQuery, init);
13026
13363
  }
13027
13364
  // Fallback for old format (tiles without subdomain)
13028
- if (isTileRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13029
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/tiles/carto-a');
13030
- return originalFetch(proxyUrl, init);
13365
+ if (isTileRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13366
+ return originalFetch('/tiles/carto-a' + pathAndQuery, init);
13031
13367
  }
13032
13368
  // Proxy OpenStreetMap tile requests
13033
- if (url.includes('tile.openstreetmap.org')) {
13034
- const proxyUrl = url.replace('https://tile.openstreetmap.org', '/tiles/osm');
13035
- return originalFetch(proxyUrl, init);
13369
+ if (hostname === 'tile.openstreetmap.org') {
13370
+ return originalFetch('/tiles/osm' + pathAndQuery, init);
13036
13371
  }
13037
13372
  }
13038
13373
  return originalFetch(input, init);
@@ -13465,6 +13800,9 @@ class OfflineManagerControl {
13465
13800
  if (patchedStyle.imports) {
13466
13801
  delete patchedStyle.imports;
13467
13802
  }
13803
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
13804
+ // downloaded before resolveImports learned to rewrite them.
13805
+ sanitizeIndoorExpressions(patchedStyle);
13468
13806
  // If using Service Worker (Mapbox GL JS), convert idb:// to /__offline__/ URLs
13469
13807
  if (this.useServiceWorker) {
13470
13808
  if (this.swReadyPromise) {
@@ -13609,5 +13947,5 @@ class OfflineManagerControl {
13609
13947
  }
13610
13948
  }
13611
13949
 
13612
- export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAP_PROVIDERS, MaintenanceService, 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, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getStyleStats, getTileAnalytics, getTileStats, getUserErrorMessage, glyphService, hasImports, i18n, icons, idbFetchHandler, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, rewriteMapboxCdnTileUrl, safeExecute, setupAutoCleanup, spriteService, stopAutoCleanup, t, tileService, unregisterOfflineServiceWorker, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairSprites };
13950
+ export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAP_PROVIDERS, 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, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, setupAutoCleanup, spriteService, stopAutoCleanup, t, tileService, unregisterOfflineServiceWorker, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
13613
13951
  //# sourceMappingURL=index.esm.js.map