map-gl-offline 0.7.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.
package/dist/index.js CHANGED
@@ -1190,6 +1190,22 @@ function parseTileKey(key) {
1190
1190
  }
1191
1191
  return { styleId, sourceId, z, x, y, ext };
1192
1192
  }
1193
+ /**
1194
+ * Extract the extension (the last dotted segment before `?`, `#`, or end) from
1195
+ * a tile URL or tile URL template. Defaults to `"pbf"` when no extension can
1196
+ * be parsed. For multi-extension URLs like Mapbox v4's `{y}.vector.pbf` this
1197
+ * returns `"pbf"`, matching the key used when the tile is stored.
1198
+ *
1199
+ * Keeping extraction logic in one place ensures patchStyleForOffline (which
1200
+ * rewrites tile URLs to `idb://` at load time) derives the same extension
1201
+ * that tileService.extractExtension used at store time — otherwise the
1202
+ * first-try lookup in idbFetchHandler misses and has to fall through its
1203
+ * pbf/mvt/png/jpg/webp fallback loop.
1204
+ */
1205
+ function extractTileExtensionFromUrl(url) {
1206
+ const match = url.match(/\.([\w]+)(?:[?#]|$)/i);
1207
+ return match ? match[1] : 'pbf';
1208
+ }
1193
1209
  /**
1194
1210
  * Derive tile extension from tile URL templates
1195
1211
  */
@@ -1197,15 +1213,210 @@ function deriveTileExtension(tiles) {
1197
1213
  if (Array.isArray(tiles) && tiles.length > 0) {
1198
1214
  const firstTile = tiles[0];
1199
1215
  if (typeof firstTile === 'string') {
1200
- const match = firstTile.match(/\.([\w]+)(?:\?|$)/i);
1201
- if (match) {
1202
- return match[1];
1203
- }
1216
+ return extractTileExtensionFromUrl(firstTile);
1204
1217
  }
1205
1218
  }
1206
1219
  return 'pbf';
1207
1220
  }
1208
1221
 
1222
+ /**
1223
+ * Pure helpers shared between the main-thread offline fetch handler
1224
+ * (`src/utils/idbFetchHandler.ts`) and the offline Service Worker
1225
+ * (`src/sw/offline-sw.ts`, compiled to `public/idb-offline-sw.js`).
1226
+ *
1227
+ * Keeping these in one place means the SW and the main-thread handler
1228
+ * can't drift — adding a new `model` handler, changing the fallback
1229
+ * order, or tweaking the tilejson-source matcher happens once.
1230
+ *
1231
+ * Nothing in here touches IndexedDB directly. Each helper takes already-
1232
+ * resolved inputs and returns the list of candidate keys (or the
1233
+ * resolved output) that the caller feeds into its own IDB lookup.
1234
+ *
1235
+ * The corresponding IDB access layer is:
1236
+ * - main thread: `idb` library via `dbPromise`
1237
+ * - service worker: raw `indexedDB.open` (see `offline-sw.ts`)
1238
+ *
1239
+ * They have different shapes so cannot be shared; the key computation
1240
+ * can be and is.
1241
+ */
1242
+ /**
1243
+ * Extensions to try in order when the requested extension misses. `glb` is
1244
+ * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
1245
+ * their source URL template ended in `.vector` or similar and the actual
1246
+ * tile body was stored as glb.
1247
+ */
1248
+ const TILE_FALLBACK_EXTENSIONS = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'];
1249
+ /** Extensions minus the one the caller already tried. */
1250
+ function tileFallbackExtensions(requested) {
1251
+ return TILE_FALLBACK_EXTENSIONS.filter(e => e !== requested);
1252
+ }
1253
+ // ---------------------------------------------------------------------------
1254
+ // Region → style lookup
1255
+ // ---------------------------------------------------------------------------
1256
+ /**
1257
+ * Given an already-fetched list of style entries, find the first one whose
1258
+ * `regions` array contains the given ID. Pure — the caller is responsible for
1259
+ * loading the entries and for caching. Used by both `findStyleByRegionId`
1260
+ * implementations to keep the match rule identical.
1261
+ */
1262
+ function findStyleByRegionIdIn(styles, regionId) {
1263
+ for (const entry of styles) {
1264
+ const regions = entry.regions;
1265
+ if (!Array.isArray(regions))
1266
+ continue;
1267
+ for (const r of regions) {
1268
+ if (r?.regionId === regionId || r?.id === regionId) {
1269
+ return entry;
1270
+ }
1271
+ }
1272
+ }
1273
+ return null;
1274
+ }
1275
+ // ---------------------------------------------------------------------------
1276
+ // Glyph candidate keys
1277
+ // ---------------------------------------------------------------------------
1278
+ /**
1279
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1280
+ * requests a comma-joined font-family fallback chain; each glyph is stored
1281
+ * individually, so the caller tries each fontstack in order.
1282
+ */
1283
+ function parseGlyphPath(decodedPath) {
1284
+ const pathParts = decodedPath.split('/');
1285
+ const fontstackPart = pathParts[0] ?? '';
1286
+ const rangePart = pathParts[1] || '0-255.pbf';
1287
+ const fontstacks = fontstackPart
1288
+ .split(',')
1289
+ .map(f => f.trim())
1290
+ .filter(Boolean);
1291
+ return { fontstacks, rangePart };
1292
+ }
1293
+ /**
1294
+ * Build the list of keys to try for a single (fontstack, range) pair.
1295
+ * Order: actualStyleId variants first (most common), then downloadId,
1296
+ * then the bare path. Normalized and raw `.pbf`-less forms are both tried
1297
+ * to cover stored-key variants from older versions.
1298
+ */
1299
+ function glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart) {
1300
+ const glyphPath = `${fontstack}/${rangePart}`;
1301
+ const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1302
+ return dedupe([
1303
+ `${actualStyleId}::${normalizedPath}`,
1304
+ `${actualStyleId}::${glyphPath}`,
1305
+ `${downloadId}::${normalizedPath}`,
1306
+ `${downloadId}::${glyphPath}`,
1307
+ normalizedPath,
1308
+ glyphPath,
1309
+ ]);
1310
+ }
1311
+ // ---------------------------------------------------------------------------
1312
+ // Sprite candidate keys
1313
+ // ---------------------------------------------------------------------------
1314
+ /**
1315
+ * Sprite keys have historically used both `::` and `:` as the separator, and
1316
+ * both the full filename (`sprite.json`) and the bare name (`sprite`). Return
1317
+ * every variant in priority order; the caller stops at the first hit.
1318
+ */
1319
+ function spriteCandidateKeys(actualStyleId, downloadId, decodedPath) {
1320
+ const stripExt = decodedPath.replace(/\.(json|png)$/i, '');
1321
+ return dedupe([
1322
+ `${actualStyleId}::${decodedPath}`,
1323
+ `${actualStyleId}:${decodedPath}`,
1324
+ `${actualStyleId}::${stripExt}`,
1325
+ `${actualStyleId}:${stripExt}`,
1326
+ `${downloadId}::${decodedPath}`,
1327
+ `${downloadId}:${decodedPath}`,
1328
+ `${downloadId}::${stripExt}`,
1329
+ `${downloadId}:${stripExt}`,
1330
+ decodedPath,
1331
+ ]);
1332
+ }
1333
+ // ---------------------------------------------------------------------------
1334
+ // Model candidate keys
1335
+ // ---------------------------------------------------------------------------
1336
+ /**
1337
+ * Model keys are `{styleId}::model::{name}`. Try the resolved style id first,
1338
+ * then the bare downloadId in case the request came through the region-scoped
1339
+ * URL form (`idb://{regionId}/model/{name}`).
1340
+ */
1341
+ function modelCandidateKeys(actualStyleId, downloadId, decodedPath) {
1342
+ return dedupe([
1343
+ `${actualStyleId}::model::${decodedPath}`,
1344
+ `${downloadId}::model::${decodedPath}`,
1345
+ ]);
1346
+ }
1347
+ // ---------------------------------------------------------------------------
1348
+ // TileJSON source matching
1349
+ // ---------------------------------------------------------------------------
1350
+ /**
1351
+ * Mapbox GL requests tilejson via `idb://{downloadId}/tilesjson/{path}` where
1352
+ * `{path}` may be the source id, the original TileJSON URL, or the URL we
1353
+ * stashed under `__originalTilesetUrl` when patching for offline. Try all
1354
+ * three; return the matching source id + its config, or null.
1355
+ */
1356
+ function matchTileJsonSource(sources, decodedPath) {
1357
+ const asConfig = (v) => v && typeof v === 'object' ? v : null;
1358
+ if (decodedPath in sources) {
1359
+ const config = asConfig(sources[decodedPath]);
1360
+ if (config)
1361
+ return { sourceId: decodedPath, config };
1362
+ }
1363
+ for (const [sourceId, raw] of Object.entries(sources)) {
1364
+ const config = asConfig(raw);
1365
+ if (!config)
1366
+ continue;
1367
+ const url = typeof config.url === 'string' ? config.url : undefined;
1368
+ const original = typeof config.__originalTilesetUrl === 'string'
1369
+ ? config.__originalTilesetUrl
1370
+ : undefined;
1371
+ if (url === decodedPath || original === decodedPath) {
1372
+ return { sourceId, config };
1373
+ }
1374
+ }
1375
+ return null;
1376
+ }
1377
+ /**
1378
+ * Build the offline TileJSON payload that replaces the one Mapbox would
1379
+ * have fetched from the network. `tiles` is rewritten to serve from the SW
1380
+ * (the caller supplies the scheme via `tileUrlScheme`); copyable TileJSON
1381
+ * fields are preserved.
1382
+ */
1383
+ function buildOfflineTileJson(sourceConfig, downloadId, sourceId, extension, tileUrlScheme, origin) {
1384
+ const base = `idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`
1385
+ ;
1386
+ const tileJson = {
1387
+ tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1388
+ name: sourceConfig.name ?? sourceId,
1389
+ tiles: [base],
1390
+ minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1391
+ maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1392
+ };
1393
+ const copyable = [
1394
+ 'bounds',
1395
+ 'center',
1396
+ 'vector_layers',
1397
+ 'scheme',
1398
+ 'attribution',
1399
+ 'encoding',
1400
+ 'format',
1401
+ 'grids',
1402
+ 'data',
1403
+ 'template',
1404
+ 'version',
1405
+ ];
1406
+ for (const field of copyable) {
1407
+ if (field in sourceConfig && sourceConfig[field] !== undefined) {
1408
+ tileJson[field] = sourceConfig[field];
1409
+ }
1410
+ }
1411
+ return tileJson;
1412
+ }
1413
+ // ---------------------------------------------------------------------------
1414
+ // Internal helpers
1415
+ // ---------------------------------------------------------------------------
1416
+ function dedupe(values) {
1417
+ return Array.from(new Set(values));
1418
+ }
1419
+
1209
1420
  // idbFetchHandler.ts
1210
1421
  // Intercepts idb:// URLs and serves resources from IndexedDB for MapLibre GL offline mode
1211
1422
  const idbLogger = logger.scope('IDBFetch');
@@ -1262,16 +1473,11 @@ async function findStyleByRegionId(db, regionId) {
1262
1473
  }
1263
1474
  try {
1264
1475
  const allStyles = await db.getAll('styles');
1265
- for (const styleEntry of allStyles) {
1266
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
1267
- const hasRegion = styleEntry.regions.some((r) => r.regionId === regionId || r.id === regionId);
1268
- if (hasRegion) {
1269
- idbLogger.debug(`Found style "${styleEntry.key}" containing region: ${regionId}`);
1270
- // Cache the result
1271
- regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
1272
- return styleEntry;
1273
- }
1274
- }
1476
+ const hit = findStyleByRegionIdIn(allStyles, regionId);
1477
+ if (hit) {
1478
+ idbLogger.debug(`Found style "${hit.key}" containing region: ${regionId}`);
1479
+ regionToStyleCache.set(regionId, { styleEntry: hit, timestamp: Date.now() });
1480
+ return hit;
1275
1481
  }
1276
1482
  idbLogger.debug(`No style found containing region: ${regionId}`);
1277
1483
  // Don't cache negative results — the region may be stored moments later
@@ -1283,36 +1489,6 @@ async function findStyleByRegionId(db, regionId) {
1283
1489
  return null;
1284
1490
  }
1285
1491
  }
1286
- function buildOfflineTileJson(sourceConfig, downloadId, sourceId) {
1287
- const extension = deriveTileExtension(sourceConfig.tiles);
1288
- const offlineTiles = [`idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`];
1289
- const tileJson = {
1290
- tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1291
- name: sourceConfig.name ?? sourceId,
1292
- tiles: offlineTiles,
1293
- minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1294
- maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1295
- };
1296
- const fieldsToCopy = [
1297
- 'bounds',
1298
- 'center',
1299
- 'vector_layers',
1300
- 'scheme',
1301
- 'attribution',
1302
- 'encoding',
1303
- 'format',
1304
- 'grids',
1305
- 'data',
1306
- 'template',
1307
- 'version',
1308
- ];
1309
- for (const field of fieldsToCopy) {
1310
- if (field in sourceConfig && sourceConfig[field] !== undefined) {
1311
- tileJson[field] = sourceConfig[field];
1312
- }
1313
- }
1314
- return tileJson;
1315
- }
1316
1492
  async function createTileResponse(resource) {
1317
1493
  const headers = {};
1318
1494
  // Set proper content type for vector tiles (PBF/MVT format)
@@ -1407,7 +1583,7 @@ async function idbFetchHandler(url, init) {
1407
1583
  // but tiles are stored with integer zoom levels, so floor the value
1408
1584
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1409
1585
  const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1410
- const yMatch = yExt.match(/(\d+)\.(\w+)/);
1586
+ const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1411
1587
  if (yMatch) {
1412
1588
  const y = parseInt(yMatch[1]);
1413
1589
  const requestedExt = yMatch[2]; // Extension from URL (for logging only)
@@ -1422,7 +1598,7 @@ async function idbFetchHandler(url, init) {
1422
1598
  }
1423
1599
  idbLogger.debug(`Tile not found: ${tileKey}`);
1424
1600
  // Fallback: try common alternative extensions
1425
- const fallbackExtensions = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'].filter(ext => ext !== requestedExt);
1601
+ const fallbackExtensions = tileFallbackExtensions(requestedExt);
1426
1602
  for (const fallbackExt of fallbackExtensions) {
1427
1603
  const fallbackKey = createTileKey(x, y, z, actualStyleId, sourceKey, fallbackExt);
1428
1604
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1464,7 +1640,7 @@ async function idbFetchHandler(url, init) {
1464
1640
  return await createTileResponse(resource);
1465
1641
  }
1466
1642
  // Try alternative extensions
1467
- const fallbackExts = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter(e => e !== ext);
1643
+ const fallbackExts = tileFallbackExtensions(ext);
1468
1644
  for (const fallbackExt of fallbackExts) {
1469
1645
  const fallbackKey = createTileKey(parseInt(x), parseInt(y), parseInt(z), actualStyleId, fallbackSourceKey, fallbackExt);
1470
1646
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1484,46 +1660,19 @@ async function idbFetchHandler(url, init) {
1484
1660
  }
1485
1661
  case 'glyph': {
1486
1662
  idbLogger.debug(`Looking for glyph with key: ${key}`);
1487
- // Find which style this region belongs to
1488
1663
  const styleEntry = await findStyleByRegionId(db, downloadId);
1489
1664
  const actualStyleId = styleEntry?.key || downloadId;
1490
- if (styleEntry && downloadId !== actualStyleId) {
1491
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1492
- }
1493
- // Parse the resource path: "FontA,FontB,FontC/0-255.pbf"
1494
- // MapLibre requests glyphs with comma-separated fallback fonts
1495
- // but glyphs are stored individually per font
1496
- const pathParts = decodedResourcePath.split('/');
1497
- const fontstackPart = pathParts[0]; // "FontA,FontB,FontC"
1498
- const rangePart = pathParts[1] || '0-255.pbf'; // "0-255.pbf"
1499
- // Split comma-separated fonts
1500
- const fontstacks = fontstackPart.split(',').map(f => f.trim());
1665
+ const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1501
1666
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1502
- // Try each font in order (this is how font fallbacks work)
1503
1667
  for (const fontstack of fontstacks) {
1504
- const glyphPath = `${fontstack}/${rangePart}`;
1505
- const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1506
- const glyphCandidateKeys = [
1507
- // Try with actual style ID first
1508
- `${actualStyleId}::${normalizedPath}`,
1509
- `${actualStyleId}::${glyphPath}`,
1510
- // Then try with download ID
1511
- `${downloadId}::${normalizedPath}`,
1512
- `${downloadId}::${glyphPath}`,
1513
- // Just paths
1514
- normalizedPath,
1515
- glyphPath,
1516
- ];
1517
- idbLogger.debug(`Trying keys for font "${fontstack}":`, glyphCandidateKeys);
1518
- for (const candidateKey of glyphCandidateKeys) {
1668
+ const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1669
+ for (const candidateKey of candidateKeys) {
1519
1670
  const resource = await db.get('glyphs', candidateKey);
1520
1671
  if (resource?.data) {
1521
1672
  idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1522
1673
  return new Response(resource.data, {
1523
1674
  status: 200,
1524
- headers: {
1525
- 'Content-Type': 'application/x-protobuf',
1526
- },
1675
+ headers: { 'Content-Type': 'application/x-protobuf' },
1527
1676
  });
1528
1677
  }
1529
1678
  }
@@ -1533,33 +1682,11 @@ async function idbFetchHandler(url, init) {
1533
1682
  }
1534
1683
  case 'sprite': {
1535
1684
  idbLogger.debug(`Looking for sprite with key: ${key}`);
1536
- // Find which style this region belongs to
1537
1685
  const styleEntry = await findStyleByRegionId(db, downloadId);
1538
1686
  const actualStyleId = styleEntry?.key || downloadId;
1539
- if (styleEntry && downloadId !== actualStyleId) {
1540
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1541
- }
1542
- // The sprite service stores sprites with keys like: "voyager::sprite.json", "voyager::sprite@2x.json"
1543
- // MapLibre requests sprites as: "idb://region_XXX/sprite/sprite@2x.json"
1544
- // So we need to map the region ID to the style ID
1545
- const spriteCandidateKeys = Array.from(new Set([
1546
- // Try with actual style ID first (most likely to work)
1547
- `${actualStyleId}::${decodedResourcePath}`,
1548
- `${actualStyleId}:${decodedResourcePath}`,
1549
- `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1550
- `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1551
- // Then try with download ID (in case it's a direct style download)
1552
- `${downloadId}::${decodedResourcePath}`,
1553
- `${downloadId}:${decodedResourcePath}`,
1554
- `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1555
- `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1556
- // Just the path itself
1557
- decodedResourcePath,
1558
- // Original key format
1559
- key,
1560
- ]));
1561
- idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, spriteCandidateKeys);
1562
- for (const candidateKey of spriteCandidateKeys) {
1687
+ const candidates = spriteCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1688
+ idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, candidates);
1689
+ for (const candidateKey of candidates) {
1563
1690
  const resource = await db.get('sprites', candidateKey);
1564
1691
  if (resource?.data) {
1565
1692
  idbLogger.debug(`Found sprite using key: ${candidateKey}`);
@@ -1569,7 +1696,7 @@ async function idbFetchHandler(url, init) {
1569
1696
  });
1570
1697
  }
1571
1698
  }
1572
- idbLogger.warn(`Sprite not found, tried keys: ${spriteCandidateKeys.join(', ')}`);
1699
+ idbLogger.warn(`Sprite not found, tried keys: ${candidates.join(', ')}`);
1573
1700
  break;
1574
1701
  }
1575
1702
  case 'font': {
@@ -1585,18 +1712,12 @@ async function idbFetchHandler(url, init) {
1585
1712
  break;
1586
1713
  }
1587
1714
  case 'model': {
1588
- // Model URLs are rewritten by patchStyleForOffline to:
1715
+ // Model URLs are rewritten by patchStyleForOffline to
1589
1716
  // idb://{styleId}/model/{modelName}
1590
1717
  // Models are keyed by {styleId}::model::{modelName} in the store.
1591
- // Mirror the sprite resolution fallback: try the style ID first,
1592
- // then the download/region ID (in case the request came through a
1593
- // region-scoped URL).
1594
1718
  const styleEntry = await findStyleByRegionId(db, downloadId);
1595
1719
  const actualStyleId = styleEntry?.key || downloadId;
1596
- const candidates = Array.from(new Set([
1597
- `${actualStyleId}::model::${decodedResourcePath}`,
1598
- `${downloadId}::model::${decodedResourcePath}`,
1599
- ]));
1720
+ const candidates = modelCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1600
1721
  idbLogger.debug(`Model candidates for "${decodedResourcePath}":`, candidates);
1601
1722
  for (const candidateKey of candidates) {
1602
1723
  const resource = await db.get('models', candidateKey);
@@ -1613,9 +1734,9 @@ async function idbFetchHandler(url, init) {
1613
1734
  }
1614
1735
  case 'tilesjson': {
1615
1736
  idbLogger.debug(`Looking for tilejson with downloadId: ${downloadId}, resourcePath: ${decodedResourcePath}`);
1616
- // First try direct lookup (for style-level downloads)
1737
+ // First try direct lookup (for style-level downloads), then fall back
1738
+ // to searching by region ID (for region-level downloads).
1617
1739
  let styleEntry = await db.get('styles', downloadId);
1618
- // If not found, search by region ID (for region-level downloads)
1619
1740
  if (!styleEntry || !styleEntry.style?.sources) {
1620
1741
  idbLogger.debug(`Style not found with key "${downloadId}", searching by region ID...`);
1621
1742
  const foundStyle = await findStyleByRegionId(db, downloadId);
@@ -1623,41 +1744,23 @@ async function idbFetchHandler(url, init) {
1623
1744
  styleEntry = foundStyle;
1624
1745
  }
1625
1746
  }
1626
- if (styleEntry?.style?.sources) {
1627
- const sources = styleEntry.style.sources;
1628
- let matchedSourceId;
1629
- let matchedSourceConfig;
1630
- if (decodedResourcePath in sources) {
1631
- matchedSourceId = decodedResourcePath;
1632
- matchedSourceConfig = sources[decodedResourcePath];
1633
- }
1634
- else {
1635
- for (const [sourceId, sourceValue] of Object.entries(sources)) {
1636
- const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
1637
- const originalUrl = typeof sourceValue.__originalTilesetUrl === 'string'
1638
- ? sourceValue.__originalTilesetUrl
1639
- : undefined;
1640
- if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
1641
- matchedSourceId = sourceId;
1642
- matchedSourceConfig = sourceValue;
1643
- break;
1644
- }
1645
- }
1646
- }
1647
- if (matchedSourceId && matchedSourceConfig) {
1648
- const tileJson = buildOfflineTileJson(matchedSourceConfig, downloadId, matchedSourceId);
1649
- idbLogger.debug(`Serving offline tilejson for source: ${matchedSourceId}`);
1650
- return new Response(JSON.stringify(tileJson), {
1651
- status: 200,
1652
- headers: { 'Content-Type': 'application/json' },
1653
- });
1654
- }
1655
- idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1656
- }
1657
- else {
1747
+ if (!styleEntry?.style?.sources) {
1658
1748
  idbLogger.warn(`Style not found or missing sources for downloadId: ${downloadId}`);
1749
+ break;
1659
1750
  }
1660
- break;
1751
+ const sources = styleEntry.style.sources;
1752
+ const matched = matchTileJsonSource(sources, decodedResourcePath);
1753
+ if (!matched) {
1754
+ idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1755
+ break;
1756
+ }
1757
+ const extension = deriveTileExtension(matched.config.tiles);
1758
+ const tileJson = buildOfflineTileJson(matched.config, downloadId, matched.sourceId, extension, 'idb');
1759
+ idbLogger.debug(`Serving offline tilejson for source: ${matched.sourceId}`);
1760
+ return new Response(JSON.stringify(tileJson), {
1761
+ status: 200,
1762
+ headers: { 'Content-Type': 'application/json' },
1763
+ });
1661
1764
  }
1662
1765
  default:
1663
1766
  idbLogger.warn(`Unknown resource type: ${type}`);
@@ -1681,6 +1784,37 @@ async function idbFetchHandler(url, init) {
1681
1784
  function isMapboxProtocol(url) {
1682
1785
  return url.startsWith(MAPBOX_API.PROTOCOL);
1683
1786
  }
1787
+ /**
1788
+ * Parse a URL and return its hostname, or null if the URL is malformed.
1789
+ * Accepts relative URLs when `base` is provided.
1790
+ */
1791
+ function getUrlHostname(url, base) {
1792
+ try {
1793
+ return new URL(url, base).hostname.toLowerCase();
1794
+ }
1795
+ catch {
1796
+ return null;
1797
+ }
1798
+ }
1799
+ /**
1800
+ * True if `url`'s hostname equals `host` or is a subdomain of `host`.
1801
+ * Uses URL parsing (not substring matching) to avoid false positives like
1802
+ * `https://evil.com/?x=mapbox.com` matching `mapbox.com`.
1803
+ */
1804
+ function hostMatches(url, host, base) {
1805
+ const hostname = getUrlHostname(url, base);
1806
+ if (hostname === null)
1807
+ return false;
1808
+ const target = host.toLowerCase();
1809
+ return hostname === target || hostname.endsWith('.' + target);
1810
+ }
1811
+ /**
1812
+ * True for any host under the mapbox.com domain (including api.mapbox.com,
1813
+ * *.tiles.mapbox.com, etc.). Used by provider detection.
1814
+ */
1815
+ function isMapboxHost(url, base) {
1816
+ return hostMatches(url, 'mapbox.com', base);
1817
+ }
1684
1818
  /**
1685
1819
  * Resolve a mapbox:// URL to its HTTPS API equivalent
1686
1820
  *
@@ -1754,9 +1888,7 @@ function rewriteMapboxCdnTileUrl(tileUrl) {
1754
1888
  */
1755
1889
  function detectStyleProvider(styleUrl, style) {
1756
1890
  // Check URL patterns
1757
- if (isMapboxProtocol(styleUrl) ||
1758
- styleUrl.includes('mapbox.com') ||
1759
- styleUrl.includes('api.mapbox.com')) {
1891
+ if (isMapboxProtocol(styleUrl) || isMapboxHost(styleUrl)) {
1760
1892
  return 'mapbox';
1761
1893
  }
1762
1894
  if (styleUrl.includes('maplibre') ||
@@ -1775,7 +1907,7 @@ function detectStyleProvider(styleUrl, style) {
1775
1907
  const sources = style.sources || {};
1776
1908
  for (const [, sourceConfig] of Object.entries(sources)) {
1777
1909
  const source = sourceConfig;
1778
- if (source.url && (source.url.includes('mapbox.com') || isMapboxProtocol(source.url))) {
1910
+ if (source.url && (isMapboxProtocol(source.url) || isMapboxHost(source.url))) {
1779
1911
  return 'mapbox';
1780
1912
  }
1781
1913
  }
@@ -1867,7 +1999,7 @@ function processStyleSources(style, provider, accessToken) {
1867
1999
  if (isMapboxProtocol(tileUrl) && accessToken) {
1868
2000
  return resolveMapboxUrl(tileUrl, accessToken);
1869
2001
  }
1870
- if (provider === 'mapbox' && accessToken && tileUrl.includes('mapbox.com')) {
2002
+ if (provider === 'mapbox' && accessToken && isMapboxHost(tileUrl)) {
1871
2003
  return normalizeStyleUrl(tileUrl, accessToken);
1872
2004
  }
1873
2005
  return tileUrl;
@@ -1882,7 +2014,7 @@ function processStyleSources(style, provider, accessToken) {
1882
2014
  if (isMapboxProtocol(processedStyle.sprite)) {
1883
2015
  processedStyle.sprite = resolveMapboxUrl(processedStyle.sprite, accessToken);
1884
2016
  }
1885
- else if (provider === 'mapbox' && processedStyle.sprite.includes('mapbox.com')) {
2017
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.sprite)) {
1886
2018
  processedStyle.sprite = normalizeStyleUrl(processedStyle.sprite, accessToken);
1887
2019
  }
1888
2020
  }
@@ -1893,7 +2025,7 @@ function processStyleSources(style, provider, accessToken) {
1893
2025
  if (isMapboxProtocol(entry.url)) {
1894
2026
  return { ...entry, url: resolveMapboxUrl(entry.url, accessToken) };
1895
2027
  }
1896
- else if (provider === 'mapbox' && entry.url.includes('mapbox.com')) {
2028
+ else if (provider === 'mapbox' && isMapboxHost(entry.url)) {
1897
2029
  return { ...entry, url: normalizeStyleUrl(entry.url, accessToken) };
1898
2030
  }
1899
2031
  }
@@ -1906,7 +2038,7 @@ function processStyleSources(style, provider, accessToken) {
1906
2038
  if (isMapboxProtocol(processedStyle.glyphs)) {
1907
2039
  processedStyle.glyphs = resolveMapboxUrl(processedStyle.glyphs, accessToken);
1908
2040
  }
1909
- else if (provider === 'mapbox' && processedStyle.glyphs.includes('mapbox.com')) {
2041
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.glyphs)) {
1910
2042
  processedStyle.glyphs = normalizeStyleUrl(processedStyle.glyphs, accessToken);
1911
2043
  }
1912
2044
  }
@@ -1946,13 +2078,20 @@ function validateStyleForProvider(style, provider) {
1946
2078
  // Check for Mapbox-specific requirements
1947
2079
  const hasMapboxSources = Object.values(style.sources || {}).some((source) => {
1948
2080
  const s = source;
1949
- return s.url && s.url.includes('mapbox.com');
2081
+ return !!s.url && isMapboxHost(s.url);
1950
2082
  });
1951
2083
  if (hasMapboxSources) {
1952
2084
  // Check if access token might be needed
1953
2085
  const hasAccessToken = Object.values(style.sources || {}).some((source) => {
1954
2086
  const s = source;
1955
- return s.url && s.url.includes('access_token');
2087
+ if (!s.url)
2088
+ return false;
2089
+ try {
2090
+ return new URL(s.url).searchParams.has('access_token');
2091
+ }
2092
+ catch {
2093
+ return false;
2094
+ }
1956
2095
  });
1957
2096
  if (!hasAccessToken) {
1958
2097
  warnings.push('Mapbox sources detected but no access token found - authentication may be required');
@@ -1986,14 +2125,15 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
1986
2125
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
1987
2126
  if (source.tiles) {
1988
2127
  const originalTiles = [...source.tiles];
1989
- // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext
2128
+ // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2129
+ // Extension extraction goes through the shared extractTileExtensionFromUrl
2130
+ // helper so the patched URL's extension matches what tileService used when
2131
+ // storing — otherwise Mapbox v4 tile URLs (`{y}.vector.pbf`) produced a
2132
+ // stored key under `.pbf` but a patched URL with `.vector`, forcing
2133
+ // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2134
+ // on every tile.
1990
2135
  source.tiles = source.tiles.map((url) => {
1991
- // Use stored tileExtension if available, otherwise try to extract from URL
1992
- let ext = tileExtension;
1993
- if (!ext) {
1994
- const extMatch = url.match(/\{z\}\/\{x\}\/\{y\}\.(\w+)/);
1995
- ext = extMatch ? extMatch[1] : 'pbf';
1996
- }
2136
+ const ext = tileExtension ?? extractTileExtensionFromUrl(url);
1997
2137
  return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
1998
2138
  });
1999
2139
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
@@ -2871,10 +3011,8 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2871
3011
  if (typeof prefixedLayer.source === 'string') {
2872
3012
  prefixedLayer.source = `${importId}/${prefixedLayer.source}`;
2873
3013
  }
2874
- // Resolve ["config", "key"] expressions using schema defaults and import overrides
2875
- if (Object.keys(configValues).length > 0) {
2876
- resolveConfigExpressions(prefixedLayer, configValues);
2877
- }
3014
+ // Resolve ["config", "key"] expressions using schema defaults and import overrides.
3015
+ resolveConfigExpressions(prefixedLayer, configValues);
2878
3016
  flattenedLayers.push(prefixedLayer);
2879
3017
  }
2880
3018
  }
@@ -2912,8 +3050,48 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2912
3050
  if (!style.models && importedModels) {
2913
3051
  style.models = importedModels;
2914
3052
  }
3053
+ // Rewrite indoor-only expressions so the flattened style validates without
3054
+ // the `imports` wrapper at render time — see sanitizeIndoorExpressions.
3055
+ sanitizeIndoorExpressions(style);
2915
3056
  return style;
2916
3057
  }
3058
+ /**
3059
+ * Rewrite indoor-only expressions in a style's layers to their outdoor no-op
3060
+ * constants. See the in-line comment in `resolveValue` for why this is needed
3061
+ * for Mapbox Standard when the `imports` wrapper is stripped.
3062
+ *
3063
+ * Safe to call multiple times and on already-downloaded stored styles — the
3064
+ * rewrites are idempotent (after the first pass there are no more
3065
+ * `is-active-floor` / `floor-level` expressions to rewrite).
3066
+ */
3067
+ function sanitizeIndoorExpressions(style) {
3068
+ const layers = style.layers;
3069
+ if (!Array.isArray(layers))
3070
+ return;
3071
+ for (const layer of layers) {
3072
+ if (layer && typeof layer === 'object') {
3073
+ rewriteIndoor(layer);
3074
+ }
3075
+ }
3076
+ }
3077
+ function rewriteIndoor(obj) {
3078
+ for (const key of Object.keys(obj)) {
3079
+ obj[key] = rewriteIndoorValue(obj[key]);
3080
+ }
3081
+ }
3082
+ function rewriteIndoorValue(value) {
3083
+ if (!Array.isArray(value)) {
3084
+ if (value && typeof value === 'object' && !ArrayBuffer.isView(value)) {
3085
+ rewriteIndoor(value);
3086
+ }
3087
+ return value;
3088
+ }
3089
+ if (value[0] === 'is-active-floor')
3090
+ return false;
3091
+ if (value[0] === 'floor-level' && value.length === 1)
3092
+ return 0;
3093
+ return value.map(rewriteIndoorValue);
3094
+ }
2917
3095
  /**
2918
3096
  * Deep clone a plain object/array (JSON-safe values only).
2919
3097
  */
@@ -3079,6 +3257,40 @@ function mergeSprites(outer, imported) {
3079
3257
  return result;
3080
3258
  }
3081
3259
 
3260
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3261
+ let currentConfig = {};
3262
+ let sqlJsPromise = null;
3263
+ /**
3264
+ * Override how `sql.js` loads its WebAssembly. Call once before any MBTiles
3265
+ * import/export is invoked. Resets any cached init.
3266
+ */
3267
+ function configureSqlJs(config) {
3268
+ currentConfig = { ...config };
3269
+ sqlJsPromise = null;
3270
+ }
3271
+ /**
3272
+ * Lazily initialise `sql.js`. The underlying module is loaded via dynamic
3273
+ * `import()` so it only ships with bundles that actually call MBTiles code.
3274
+ */
3275
+ async function getSqlJs() {
3276
+ if (sqlJsPromise)
3277
+ return sqlJsPromise;
3278
+ sqlJsPromise = (async () => {
3279
+ const mod = (await import('sql.js'));
3280
+ const initSqlJs = mod.default;
3281
+ const options = {};
3282
+ if (currentConfig.wasmBinary) {
3283
+ options.wasmBinary = currentConfig.wasmBinary;
3284
+ }
3285
+ else {
3286
+ const base = currentConfig.wasmUrl ?? DEFAULT_WASM_URL;
3287
+ options.locateFile = (file) => base.endsWith('/') ? `${base}${file}` : `${base}/${file}`;
3288
+ }
3289
+ return initSqlJs(options);
3290
+ })();
3291
+ return sqlJsPromise;
3292
+ }
3293
+
3082
3294
  const fontLogger = logger.scope('FontService');
3083
3295
  class FontService {
3084
3296
  db = dbPromise;
@@ -3262,15 +3474,23 @@ class FontService {
3262
3474
  },
3263
3475
  };
3264
3476
  }
3265
- async cleanupOldFonts(maxAge = 30) {
3477
+ /**
3478
+ * Delete fonts older than `maxAge` days. When `options.styleId` is
3479
+ * provided, only fonts belonging to that style (per the delimiter-aware
3480
+ * `resourceKeyBelongsToStyle` match) are eligible — callers relying on
3481
+ * a styleId filter previously got a silent full-store wipe.
3482
+ */
3483
+ async cleanupOldFonts(maxAge = 30, options = {}) {
3266
3484
  const db = await this.db;
3267
3485
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3486
+ const { styleId } = options;
3268
3487
  const tx = db.transaction(['fonts'], 'readwrite');
3269
3488
  let deletedCount = 0;
3270
3489
  let cursor = await tx.objectStore('fonts').openCursor();
3271
3490
  while (cursor) {
3272
3491
  const fontEntry = cursor.value;
3273
- if (fontEntry.lastModified < cutoffTime) {
3492
+ const belongs = !styleId || resourceKeyBelongsToStyle(fontEntry.key, styleId);
3493
+ if (belongs && fontEntry.lastModified < cutoffTime) {
3274
3494
  await cursor.delete();
3275
3495
  deletedCount++;
3276
3496
  }
@@ -3436,7 +3656,7 @@ const fontService = new FontService();
3436
3656
  const downloadFonts = (fontUrls, styleName, options) => fontService.downloadFonts(fontUrls, styleName, options);
3437
3657
  const getFontStats = () => fontService.getFontStats();
3438
3658
  const getFontAnalytics = () => fontService.getFontAnalytics();
3439
- const cleanupOldFonts = (maxAge) => fontService.cleanupOldFonts(maxAge);
3659
+ const cleanupOldFonts = (maxAge, options) => fontService.cleanupOldFonts(maxAge, options);
3440
3660
  const verifyAndRepairFonts = () => fontService.verifyAndRepairFonts();
3441
3661
 
3442
3662
  const spriteLogger = logger.scope('SpriteService');
@@ -3747,19 +3967,24 @@ class SpriteService {
3747
3967
  };
3748
3968
  }
3749
3969
  /**
3750
- * Removes sprites older than the specified age
3970
+ * Remove sprites older than the specified age. When `options.styleId` is
3971
+ * provided, only sprites belonging to that style (per
3972
+ * `resourceKeyBelongsToStyle`) are eligible.
3751
3973
  * @param maxAge - Maximum age in days (default: 30)
3974
+ * @param options.styleId - Optional style filter; omit to scan all styles
3752
3975
  * @returns Promise resolving to number of deleted sprites
3753
3976
  */
3754
- async cleanupOldSprites(maxAge = 30) {
3977
+ async cleanupOldSprites(maxAge = 30, options = {}) {
3755
3978
  const db = await this.db;
3756
3979
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3980
+ const { styleId } = options;
3757
3981
  const tx = db.transaction(['sprites'], 'readwrite');
3758
3982
  let deletedCount = 0;
3759
3983
  let cursor = await tx.objectStore('sprites').openCursor();
3760
3984
  while (cursor) {
3761
3985
  const spriteEntry = cursor.value;
3762
- if (spriteEntry.lastModified < cutoffTime) {
3986
+ const belongs = !styleId || resourceKeyBelongsToStyle(spriteEntry.key, styleId);
3987
+ if (belongs && spriteEntry.lastModified < cutoffTime) {
3763
3988
  await cursor.delete();
3764
3989
  deletedCount++;
3765
3990
  }
@@ -3913,7 +4138,7 @@ const spriteService = new SpriteService();
3913
4138
  const downloadSprites = (spriteUrls, styleName, options) => spriteService.downloadSprites(spriteUrls, styleName, options);
3914
4139
  const getSpriteStats = () => spriteService.getSpriteStats();
3915
4140
  const getSpriteAnalytics = () => spriteService.getSpriteAnalytics();
3916
- const cleanupOldSprites = (maxAge) => spriteService.cleanupOldSprites(maxAge);
4141
+ const cleanupOldSprites = (maxAge, options) => spriteService.cleanupOldSprites(maxAge, options);
3917
4142
  const verifyAndRepairSprites = () => spriteService.verifyAndRepairSprites();
3918
4143
 
3919
4144
  var spriteService$1 = /*#__PURE__*/Object.freeze({
@@ -5184,7 +5409,17 @@ class RegionService {
5184
5409
  // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5185
5410
  // segment is `sprite`, so replacing it with `iconset.pbf` works.
5186
5411
  const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5187
- const isMapboxStandardSprite = /api\.mapbox\.com\/styles\/v1\/.+\/sprite$/.test(pathWithoutQuery);
5412
+ let isMapboxStandardSprite = false;
5413
+ try {
5414
+ const parsed = new URL(pathWithoutQuery);
5415
+ isMapboxStandardSprite =
5416
+ parsed.hostname === 'api.mapbox.com' &&
5417
+ parsed.pathname.startsWith('/styles/v1/') &&
5418
+ parsed.pathname.endsWith('/sprite');
5419
+ }
5420
+ catch {
5421
+ // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5422
+ }
5188
5423
  if (isMapboxStandardSprite) {
5189
5424
  // The path-rewrite suffix replaces the trailing `sprite` segment.
5190
5425
  suffixes.push('__ICONSET__');
@@ -6680,8 +6915,7 @@ class TileService {
6680
6915
  }
6681
6916
  }
6682
6917
  extractExtension(template) {
6683
- const extMatch = template.match(/\.([\w]+)(?:\?|$)/i);
6684
- return extMatch ? extMatch[1] : 'pbf';
6918
+ return extractTileExtensionFromUrl(template);
6685
6919
  }
6686
6920
  selectTileTemplate(templates, coord) {
6687
6921
  if (templates.length === 1) {
@@ -7063,14 +7297,21 @@ class GlyphService {
7063
7297
  },
7064
7298
  };
7065
7299
  }
7066
- async cleanupOldGlyphs(maxAge = 30) {
7300
+ /**
7301
+ * Remove glyphs older than the specified age. When `options.styleId` is
7302
+ * provided, only glyphs belonging to that style (per
7303
+ * `resourceKeyBelongsToStyle`) are eligible.
7304
+ */
7305
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
7067
7306
  const db = await this.db;
7068
7307
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7308
+ const { styleId } = options;
7069
7309
  let deletedCount = 0;
7070
7310
  const tx = db.transaction('glyphs', 'readwrite');
7071
7311
  for await (const cursor of tx.store) {
7072
7312
  const glyphEntry = cursor.value;
7073
- if (glyphEntry.lastModified < cutoffTime) {
7313
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7314
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
7074
7315
  await cursor.delete();
7075
7316
  deletedCount++;
7076
7317
  }
@@ -7173,7 +7414,7 @@ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => gly
7173
7414
  const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7174
7415
  const getGlyphStats = () => glyphService.getGlyphStats();
7175
7416
  const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7176
- const cleanupOldGlyphs = (maxAge) => glyphService.cleanupOldGlyphs(maxAge);
7417
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7177
7418
  const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7178
7419
 
7179
7420
  var glyphService$1 = /*#__PURE__*/Object.freeze({
@@ -7408,8 +7649,7 @@ class ResourceService {
7408
7649
  return getFontAnalytics();
7409
7650
  }
7410
7651
  async cleanupOldFonts(styleId, options) {
7411
- const maxAge = options?.maxAge;
7412
- return cleanupOldFonts(maxAge);
7652
+ return cleanupOldFonts(options?.maxAge, { styleId });
7413
7653
  }
7414
7654
  async verifyAndRepairFonts() {
7415
7655
  return verifyAndRepairFonts();
@@ -7425,8 +7665,7 @@ class ResourceService {
7425
7665
  return getSpriteAnalytics();
7426
7666
  }
7427
7667
  async cleanupOldSprites(styleId, options) {
7428
- const maxAge = options?.maxAge;
7429
- return cleanupOldSprites(maxAge);
7668
+ return cleanupOldSprites(options?.maxAge, { styleId });
7430
7669
  }
7431
7670
  async verifyAndRepairSprites() {
7432
7671
  return verifyAndRepairSprites();
@@ -7445,8 +7684,7 @@ class ResourceService {
7445
7684
  return loadGlyphs(fontstack, ranges, styleId);
7446
7685
  }
7447
7686
  async cleanupOldGlyphs(styleId, options) {
7448
- const maxAge = options?.maxAge;
7449
- return cleanupOldGlyphs(maxAge);
7687
+ return cleanupOldGlyphs(options?.maxAge, { styleId });
7450
7688
  }
7451
7689
  async verifyAndRepairGlyphs() {
7452
7690
  return verifyAndRepairGlyphs();
@@ -7561,6 +7799,88 @@ class AnalyticsService {
7561
7799
  }
7562
7800
  }
7563
7801
 
7802
+ /**
7803
+ * MBTiles uses TMS tile_row ordering; our storage uses XYZ y. Flip across
7804
+ * either direction with the same formula.
7805
+ */
7806
+ function flipY(y, z) {
7807
+ return (1 << z) - 1 - y;
7808
+ }
7809
+ /** Vector tile formats that downstream consumers (QGIS, maplibre-native) expect gzipped. */
7810
+ const VECTOR_FORMATS = new Set(['pbf', 'mvt']);
7811
+ function hasGzipMagic(bytes) {
7812
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
7813
+ }
7814
+ async function drainReadable(readable) {
7815
+ const reader = readable.getReader();
7816
+ const chunks = [];
7817
+ let total = 0;
7818
+ while (true) {
7819
+ const { done, value } = await reader.read();
7820
+ if (done)
7821
+ break;
7822
+ if (value) {
7823
+ chunks.push(value);
7824
+ total += value.byteLength;
7825
+ }
7826
+ }
7827
+ const out = new Uint8Array(total);
7828
+ let offset = 0;
7829
+ for (const chunk of chunks) {
7830
+ out.set(chunk, offset);
7831
+ offset += chunk.byteLength;
7832
+ }
7833
+ return out;
7834
+ }
7835
+ async function transformBytes(bytes, transform) {
7836
+ const writer = transform.writable.getWriter();
7837
+ // Don't await — the read loop below drives the pipe and we only want
7838
+ // the final bytes, not back-pressure handling for a single chunk.
7839
+ void writer.write(bytes);
7840
+ void writer.close();
7841
+ return drainReadable(transform.readable);
7842
+ }
7843
+ async function gzipBytes(bytes) {
7844
+ return transformBytes(bytes, new CompressionStream('gzip'));
7845
+ }
7846
+ async function gunzipBytes(bytes) {
7847
+ return transformBytes(bytes, new DecompressionStream('gzip'));
7848
+ }
7849
+ /**
7850
+ * Build the MBTiles `json` metadata payload. For vector tiles this is
7851
+ * mandatory for tippecanoe/QGIS/maplibre-native to render — they read
7852
+ * `vector_layers` from here.
7853
+ *
7854
+ * `vector_layers` is inferred from the offline style's vector sources
7855
+ * (populated by the TileJSON expansion step in styleService). Multiple
7856
+ * vector sources are merged; duplicates de-duped by id, first wins.
7857
+ */
7858
+ function buildVectorJsonMetadata(style, sourceIds) {
7859
+ if (!style || typeof style !== 'object')
7860
+ return null;
7861
+ const sources = style.sources;
7862
+ if (!sources)
7863
+ return null;
7864
+ const merged = [];
7865
+ const seen = new Set();
7866
+ for (const [id, src] of Object.entries(sources)) {
7867
+ if (sourceIds.size > 0 && !sourceIds.has(id))
7868
+ continue;
7869
+ const layers = src?.vector_layers;
7870
+ if (!Array.isArray(layers))
7871
+ continue;
7872
+ for (const layer of layers) {
7873
+ const layerId = typeof layer?.id === 'string' ? layer.id : null;
7874
+ if (!layerId || seen.has(layerId))
7875
+ continue;
7876
+ seen.add(layerId);
7877
+ merged.push(layer);
7878
+ }
7879
+ }
7880
+ if (merged.length === 0)
7881
+ return null;
7882
+ return JSON.stringify({ vector_layers: merged });
7883
+ }
7564
7884
  const serviceLogger = logger.scope('ImportExportService');
7565
7885
  class ImportExportService {
7566
7886
  db = dbPromise;
@@ -7568,270 +7888,173 @@ class ImportExportService {
7568
7888
  // No need for initialization since dbPromise is already available
7569
7889
  }
7570
7890
  /**
7571
- * Export a region to JSON format
7891
+ * Export region as a real binary MBTiles SQLite file.
7892
+ *
7893
+ * Produces a v1.3-compliant MBTiles archive: `metadata` + `tiles` tables,
7894
+ * with `tile_row` flipped to TMS ordering. The resulting blob can be read
7895
+ * by tippecanoe, QGIS, maplibre-native, etc.
7572
7896
  */
7573
- async exportRegionAsJSON(regionId, options = {}) {
7897
+ async exportRegionAsMBTiles(regionId, options = {}) {
7574
7898
  const onProgress = options.onProgress || (() => { });
7575
7899
  try {
7576
7900
  onProgress({
7577
7901
  stage: 'preparing',
7578
7902
  percentage: 0,
7579
- message: 'Preparing export...',
7903
+ message: 'Preparing MBTiles export...',
7580
7904
  });
7581
- // Get region metadata
7582
7905
  const region = await this.getRegionMetadata(regionId);
7583
7906
  if (!region) {
7584
7907
  throw new Error(`Region ${regionId} not found`);
7585
7908
  }
7909
+ const tiles = await this.exportTiles(regionId, onProgress);
7910
+ // Pick format: caller override → region.tileExtension → default pbf.
7911
+ // Drives both the metadata row and whether tile bytes get gzipped.
7912
+ const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
7913
+ const isVector = VECTOR_FORMATS.has(format);
7586
7914
  onProgress({
7587
- stage: 'exporting',
7588
- percentage: 10,
7589
- message: 'Collecting region data...',
7915
+ stage: 'processing',
7916
+ percentage: 75,
7917
+ message: isVector ? 'Compressing vector tiles...' : 'Packing SQLite database...',
7590
7918
  });
7591
- const exportData = {
7592
- metadata: {
7593
- id: region.id,
7594
- name: region.name || region.id,
7595
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7596
- bounds: region.bounds,
7597
- minZoom: region.minZoom,
7598
- maxZoom: region.maxZoom,
7599
- styleUrl: region.styleUrl || '',
7600
- createdAt: region.created, // StoredRegion uses 'created' not 'createdAt'
7601
- exportedAt: Date.now(),
7602
- version: '1.0.0',
7603
- format: 'json',
7604
- },
7605
- style: {},
7606
- tiles: [],
7607
- sprites: [],
7608
- fonts: [],
7609
- };
7610
- // Export style if requested
7611
- if (options.includeStyle !== false) {
7612
- onProgress({
7613
- stage: 'exporting',
7614
- percentage: 20,
7615
- message: 'Exporting style data...',
7616
- });
7617
- exportData.style = await this.exportStyle(regionId);
7919
+ // Gzip vector tiles. Idempotent: skip tiles already gzipped (downloaded
7920
+ // with their original gzip wrapper intact).
7921
+ const packedTiles = [];
7922
+ for (const tile of tiles) {
7923
+ const raw = tile.data instanceof ArrayBuffer
7924
+ ? new Uint8Array(tile.data)
7925
+ : new Uint8Array(tile.data);
7926
+ const data = isVector && !hasGzipMagic(raw) ? await gzipBytes(raw) : raw;
7927
+ packedTiles.push({ z: tile.z, x: tile.x, y: tile.y, data });
7618
7928
  }
7619
- // Export tiles if requested
7620
- if (options.includeTiles !== false) {
7621
- onProgress({
7622
- stage: 'exporting',
7623
- percentage: 30,
7624
- message: 'Exporting tiles...',
7929
+ onProgress({
7930
+ stage: 'processing',
7931
+ percentage: 85,
7932
+ message: 'Packing SQLite database...',
7933
+ });
7934
+ const SQL = await getSqlJs();
7935
+ const db = new SQL.Database();
7936
+ try {
7937
+ db.run(`
7938
+ CREATE TABLE metadata (name TEXT, value TEXT);
7939
+ CREATE TABLE tiles (
7940
+ zoom_level INTEGER NOT NULL,
7941
+ tile_column INTEGER NOT NULL,
7942
+ tile_row INTEGER NOT NULL,
7943
+ tile_data BLOB
7944
+ );
7945
+ CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
7946
+ CREATE UNIQUE INDEX name ON metadata (name);
7947
+ `);
7948
+ const [[west, south], [east, north]] = region.bounds;
7949
+ const centerLon = (west + east) / 2;
7950
+ const centerLat = (south + north) / 2;
7951
+ const centerZoom = Math.max(region.minZoom, Math.min(region.maxZoom, Math.round((region.minZoom + region.maxZoom) / 2)));
7952
+ const metadataRows = {
7953
+ name: region.name || region.id,
7954
+ // MBTiles 1.3 type: 'overlay' or 'baselayer'. Baselayer matches how
7955
+ // QGIS treats the dataset (full-coverage map rather than overlay).
7956
+ type: isVector ? 'baselayer' : 'overlay',
7957
+ version: '1.0',
7958
+ description: region.name || region.id,
7959
+ format,
7960
+ bounds: `${west},${south},${east},${north}`,
7961
+ center: `${centerLon},${centerLat},${centerZoom}`,
7962
+ minzoom: String(region.minZoom),
7963
+ maxzoom: String(region.maxZoom),
7964
+ };
7965
+ // For vector tiles, the `json` field with `vector_layers` is required
7966
+ // by the MBTiles 1.3 spec and by every vector tile consumer worth
7967
+ // opening the file in. Derive it from the offline style.
7968
+ if (isVector) {
7969
+ const style = await this.exportStyle(regionId);
7970
+ const sourceIds = new Set(tiles.map(t => t.sourceId).filter(Boolean));
7971
+ const json = buildVectorJsonMetadata(style.style ?? style, sourceIds);
7972
+ if (json)
7973
+ metadataRows.json = json;
7974
+ }
7975
+ for (const [k, v] of Object.entries(options.metadata || {})) {
7976
+ metadataRows[k] = typeof v === 'string' ? v : JSON.stringify(v);
7977
+ }
7978
+ const insertMeta = db.prepare(`INSERT INTO metadata (name, value) VALUES (?, ?)`);
7979
+ try {
7980
+ for (const [name, value] of Object.entries(metadataRows)) {
7981
+ insertMeta.run([name, value]);
7982
+ }
7983
+ }
7984
+ finally {
7985
+ insertMeta.free();
7986
+ }
7987
+ const insertTile = db.prepare(`INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data)
7988
+ VALUES (?, ?, ?, ?)`);
7989
+ try {
7990
+ db.run('BEGIN');
7991
+ for (const tile of packedTiles) {
7992
+ insertTile.run([tile.z, tile.x, flipY(tile.y, tile.z), tile.data]);
7993
+ }
7994
+ db.run('COMMIT');
7995
+ }
7996
+ finally {
7997
+ insertTile.free();
7998
+ }
7999
+ const binary = db.export();
8000
+ const blob = new Blob([binary.buffer], {
8001
+ type: 'application/x-sqlite3',
7625
8002
  });
7626
- exportData.tiles = await this.exportTiles(regionId, onProgress);
7627
- }
7628
- // Export sprites if requested
7629
- if (options.includeSprites !== false) {
7630
8003
  onProgress({
7631
- stage: 'exporting',
7632
- percentage: 70,
7633
- message: 'Exporting sprites...',
8004
+ stage: 'complete',
8005
+ percentage: 100,
8006
+ message: 'MBTiles export complete!',
7634
8007
  });
7635
- exportData.sprites = await this.exportSprites(regionId);
8008
+ return {
8009
+ success: true,
8010
+ format: 'mbtiles',
8011
+ filename: `${region.name || region.id}.mbtiles`,
8012
+ blob,
8013
+ size: blob.size,
8014
+ statistics: {
8015
+ tilesExported: tiles.length,
8016
+ spritesExported: 0,
8017
+ fontsExported: 0,
8018
+ },
8019
+ };
7636
8020
  }
7637
- // Export fonts if requested
7638
- if (options.includeFonts !== false) {
7639
- onProgress({
7640
- stage: 'exporting',
7641
- percentage: 85,
7642
- message: 'Exporting fonts...',
7643
- });
7644
- exportData.fonts = await this.exportFonts(regionId);
8021
+ finally {
8022
+ db.close();
7645
8023
  }
7646
- onProgress({
7647
- stage: 'processing',
7648
- percentage: 95,
7649
- message: 'Creating export file...',
7650
- });
7651
- // Create JSON blob
7652
- const jsonString = JSON.stringify(exportData, null, 2);
7653
- const blob = new Blob([jsonString], { type: 'application/json' });
7654
- onProgress({
7655
- stage: 'complete',
7656
- percentage: 100,
7657
- message: 'Export complete!',
7658
- });
7659
- return {
7660
- success: true,
7661
- format: 'json',
7662
- filename: `${region.name || region.id}_export.json`,
7663
- blob,
7664
- size: blob.size,
7665
- statistics: {
7666
- tilesExported: exportData.tiles.length,
7667
- spritesExported: exportData.sprites.length,
7668
- fontsExported: exportData.fonts.length,
7669
- },
7670
- };
7671
8024
  }
7672
8025
  catch (error) {
7673
8026
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7674
- throw new Error(`Export failed: ${errorMessage}`);
8027
+ throw new Error(`MBTiles export failed: ${errorMessage}`);
7675
8028
  }
7676
8029
  }
7677
8030
  /**
7678
- * Export region as PMTiles format
8031
+ * Import region from a binary MBTiles (SQLite) file.
7679
8032
  */
7680
- async exportRegionAsPMTiles(regionId, options = {}) {
7681
- const onProgress = options.onProgress || (() => { });
8033
+ async importRegion(importData) {
8034
+ const onProgress = importData.onProgress || (() => { });
7682
8035
  try {
7683
8036
  onProgress({
7684
8037
  stage: 'preparing',
7685
8038
  percentage: 0,
7686
- message: 'Preparing PMTiles export...',
8039
+ message: 'Reading file...',
7687
8040
  });
7688
- // Note: This is a simplified implementation
7689
- // In a real implementation, you would use the PMTiles library
7690
- // to create a proper PMTiles file format
7691
- const region = await this.getRegionMetadata(regionId);
7692
- if (!region) {
7693
- throw new Error(`Region ${regionId} not found`);
8041
+ if (importData.format !== 'mbtiles') {
8042
+ throw new Error(`Unsupported format: ${importData.format}`);
7694
8043
  }
7695
- // Get tiles data
7696
- const tiles = await this.exportTiles(regionId, onProgress);
7697
- // Create PMTiles header and data structure
7698
- const pmtilesData = {
7699
- header: {
7700
- version: 3,
7701
- type: 'mvt',
7702
- compression: options.compression || 'gzip',
7703
- bounds: region.bounds,
7704
- minZoom: region.minZoom,
7705
- maxZoom: region.maxZoom,
7706
- metadata: {
7707
- name: region.name,
7708
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7709
- ...options.metadata,
7710
- },
7711
- },
7712
- tiles: tiles,
7713
- };
7714
- // Convert to binary format (simplified)
7715
- const jsonString = JSON.stringify(pmtilesData);
7716
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
7717
- onProgress({
7718
- stage: 'complete',
7719
- percentage: 100,
7720
- message: 'PMTiles export complete!',
7721
- });
7722
- return {
7723
- success: true,
7724
- format: 'pmtiles',
7725
- filename: `${region.name || region.id}.pmtiles`,
7726
- blob,
7727
- size: blob.size,
7728
- statistics: {
7729
- tilesExported: tiles.length,
7730
- spritesExported: 0,
7731
- fontsExported: 0,
7732
- },
7733
- };
7734
- }
7735
- catch (error) {
7736
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7737
- throw new Error(`PMTiles export failed: ${errorMessage}`);
7738
- }
7739
- }
7740
- /**
7741
- * Export region as MBTiles format
7742
- */
7743
- async exportRegionAsMBTiles(regionId, options = {}) {
7744
- const onProgress = options.onProgress || (() => { });
7745
- try {
8044
+ const buffer = await this.readFileAsArrayBuffer(importData.file);
8045
+ onProgress({ stage: 'importing', percentage: 40, message: 'Parsing MBTiles...' });
8046
+ const regionData = await this.parseMBTiles(buffer);
7746
8047
  onProgress({
7747
- stage: 'preparing',
7748
- percentage: 0,
7749
- message: 'Preparing MBTiles export...',
8048
+ stage: 'importing',
8049
+ percentage: 70,
8050
+ message: `Importing ${regionData.tiles?.length ?? 0} tiles...`,
7750
8051
  });
7751
- // Note: This is a simplified implementation
7752
- // In a real implementation, you would use SQLite/SQL.js
7753
- // to create a proper MBTiles SQLite database
7754
- const region = await this.getRegionMetadata(regionId);
7755
- if (!region) {
7756
- throw new Error(`Region ${regionId} not found`);
7757
- }
7758
- // Get tiles data
7759
- const tiles = await this.exportTiles(regionId, onProgress);
7760
- // Create MBTiles structure (simplified as JSON for now)
7761
- const mbtilesData = {
7762
- metadata: {
7763
- name: region.name,
7764
- type: 'overlay',
7765
- version: '1.0',
7766
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7767
- format: options.format || 'pbf',
7768
- bounds: region.bounds.flat().join(','),
7769
- minzoom: region.minZoom,
7770
- maxzoom: region.maxZoom,
7771
- ...options.metadata,
7772
- },
7773
- tiles: tiles.map(tile => ({
7774
- zoom_level: tile.z,
7775
- tile_column: tile.x,
7776
- tile_row: tile.y,
7777
- tile_data: tile.data,
7778
- })),
7779
- };
7780
- // Convert to binary format (simplified)
7781
- const jsonString = JSON.stringify(mbtilesData);
7782
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
8052
+ const result = await this.importRegionData(regionData, importData);
7783
8053
  onProgress({
7784
8054
  stage: 'complete',
7785
8055
  percentage: 100,
7786
- message: 'MBTiles export complete!',
8056
+ message: result.success ? 'Import complete!' : result.message,
7787
8057
  });
7788
- return {
7789
- success: true,
7790
- format: 'mbtiles',
7791
- filename: `${region.name || region.id}.mbtiles`,
7792
- blob,
7793
- size: blob.size,
7794
- statistics: {
7795
- tilesExported: tiles.length,
7796
- spritesExported: 0,
7797
- fontsExported: 0,
7798
- },
7799
- };
7800
- }
7801
- catch (error) {
7802
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7803
- throw new Error(`MBTiles export failed: ${errorMessage}`);
7804
- }
7805
- }
7806
- /**
7807
- * Import region from file
7808
- */
7809
- async importRegion(importData) {
7810
- try {
7811
- let regionData;
7812
- switch (importData.format) {
7813
- case 'json': {
7814
- const textContent = await this.readFileAsText(importData.file);
7815
- regionData = JSON.parse(textContent);
7816
- break;
7817
- }
7818
- case 'pmtiles': {
7819
- // PMTiles is a binary format; currently parsed as JSON (simplified impl)
7820
- const textContent = await this.readFileAsText(importData.file);
7821
- regionData = await this.parsePMTiles(textContent);
7822
- break;
7823
- }
7824
- case 'mbtiles': {
7825
- // MBTiles is a binary format; currently parsed as JSON (simplified impl)
7826
- const textContent = await this.readFileAsText(importData.file);
7827
- regionData = await this.parseMBTiles(textContent);
7828
- break;
7829
- }
7830
- default:
7831
- throw new Error(`Unsupported format: ${importData.format}`);
7832
- }
7833
- // Import the region data
7834
- const result = await this.importRegionData(regionData, importData);
7835
8058
  return result;
7836
8059
  }
7837
8060
  catch (error) {
@@ -7955,151 +8178,113 @@ class ImportExportService {
7955
8178
  }
7956
8179
  }
7957
8180
  /**
7958
- * Export sprites data
7959
- */
7960
- async exportSprites(_regionId) {
7961
- const db = await this.db;
7962
- const transaction = db.transaction(['sprites'], 'readonly');
7963
- const store = transaction.objectStore('sprites');
7964
- const sprites = [];
7965
- try {
7966
- let cursor = await store.openCursor();
7967
- while (cursor) {
7968
- const sprite = cursor.value;
7969
- // Include sprites that match the styleId, or all sprites if keys don't contain styleId
7970
- // (sprite keys may or may not be prefixed with styleId depending on how they were stored)
7971
- sprites.push({
7972
- url: sprite.url,
7973
- data: sprite.data,
7974
- type: sprite.url.endsWith('.json') ? 'json' : 'png',
7975
- resolution: sprite.url.includes('@2x') ? '2x' : '1x',
7976
- });
7977
- cursor = await cursor.continue();
7978
- }
7979
- return sprites;
7980
- }
7981
- catch (error) {
7982
- serviceLogger.error('Error exporting sprites:', error);
7983
- return [];
7984
- }
7985
- }
7986
- /**
7987
- * Export fonts data
8181
+ * Read file content as ArrayBuffer (for the binary MBTiles file).
7988
8182
  */
7989
- async exportFonts(_regionId) {
7990
- const db = await this.db;
7991
- const transaction = db.transaction(['fonts'], 'readonly');
7992
- const store = transaction.objectStore('fonts');
7993
- const fonts = [];
7994
- try {
7995
- let cursor = await store.openCursor();
7996
- while (cursor) {
7997
- const font = cursor.value;
7998
- // Include fonts that match the styleId, or all fonts if keys don't contain styleId
7999
- // (font keys may or may not be prefixed with styleId depending on how they were stored)
8000
- fonts.push({
8001
- fontStack: font.key, // Use key as fontstack identifier
8002
- range: '0-255', // Default range since FontEntry doesn't store this
8003
- data: font.data,
8004
- });
8005
- cursor = await cursor.continue();
8006
- }
8007
- return fonts;
8008
- }
8009
- catch (error) {
8010
- serviceLogger.error('Error exporting fonts:', error);
8011
- return [];
8012
- }
8013
- }
8014
- /**
8015
- * Read file content as text (for JSON files)
8016
- */
8017
- async readFileAsText(file) {
8183
+ async readFileAsArrayBuffer(file) {
8018
8184
  return new Promise((resolve, reject) => {
8019
8185
  const reader = new FileReader();
8020
8186
  reader.onload = () => resolve(reader.result);
8021
8187
  reader.onerror = () => reject(new Error('Failed to read file'));
8022
- reader.readAsText(file);
8188
+ reader.readAsArrayBuffer(file);
8023
8189
  });
8024
8190
  }
8025
8191
  /**
8026
- * Parse PMTiles file (simplified)
8192
+ * Parse a real binary MBTiles (SQLite) file into our import-data shape.
8193
+ * Un-flips the TMS tile_row back to XYZ y.
8027
8194
  */
8028
- async parsePMTiles(content) {
8029
- // This is a simplified implementation
8030
- // In reality, you would use the PMTiles library to parse the binary format
8031
- const data = JSON.parse(content);
8032
- const header = data?.header || {};
8033
- const metadata = header?.metadata || {};
8034
- return {
8035
- metadata: {
8036
- id: metadata.name || 'imported-region',
8037
- name: metadata.name || 'Imported Region',
8038
- description: metadata.description || '',
8039
- bounds: header.bounds || [
8040
- [0, 0],
8041
- [0, 0],
8042
- ],
8043
- minZoom: header.minZoom || 0,
8044
- maxZoom: header.maxZoom || 14,
8045
- styleUrl: '',
8046
- createdAt: Date.now(),
8047
- exportedAt: Date.now(),
8048
- version: '1.0.0',
8049
- format: 'pmtiles',
8050
- },
8051
- style: {},
8052
- tiles: data.tiles || [],
8053
- sprites: [],
8054
- fonts: [],
8055
- };
8056
- }
8057
- /**
8058
- * Parse MBTiles file (simplified)
8059
- */
8060
- async parseMBTiles(content) {
8061
- // This is a simplified implementation
8062
- // In reality, you would use SQL.js to parse the SQLite database
8063
- const data = JSON.parse(content);
8064
- const rawBounds = data.metadata?.bounds
8065
- ? data.metadata.bounds.split(',').map(Number)
8066
- : [0, 0, 0, 0];
8067
- // Ensure we have exactly 4 valid numbers
8068
- const bounds = [
8069
- isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8070
- isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8071
- isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8072
- isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8073
- ];
8074
- return {
8075
- metadata: {
8076
- id: data.metadata.name || 'imported-region',
8077
- name: data.metadata.name || 'Imported Region',
8078
- description: data.metadata.description,
8079
- bounds: [
8080
- [bounds[0], bounds[1]],
8081
- [bounds[2], bounds[3]],
8082
- ],
8083
- minZoom: data.metadata.minzoom || 0,
8084
- maxZoom: data.metadata.maxzoom || 14,
8085
- styleUrl: '',
8086
- createdAt: Date.now(),
8087
- exportedAt: Date.now(),
8088
- version: '1.0.0',
8089
- format: 'mbtiles',
8090
- },
8091
- style: {},
8092
- tiles: data.tiles.map((tile) => ({
8093
- z: tile.zoom_level,
8094
- x: tile.tile_column,
8095
- y: tile.tile_row,
8096
- data: tile.tile_data,
8097
- format: 'pbf',
8098
- sourceId: 'imported',
8099
- })) || [],
8100
- sprites: [],
8101
- fonts: [],
8102
- };
8195
+ async parseMBTiles(buffer) {
8196
+ const bytes = new Uint8Array(buffer);
8197
+ // SQLite header: "SQLite format 3\0" (16 bytes). Validate up front so
8198
+ // non-MBTiles files (e.g. a JSON renamed to .mbtiles) surface a clear
8199
+ // error instead of the opaque "file is not a database" from sql.js.
8200
+ if (bytes.byteLength < 16) {
8201
+ throw new Error('Not a valid MBTiles file: file is too small');
8202
+ }
8203
+ const magic = String.fromCharCode(...bytes.slice(0, 15));
8204
+ if (magic !== 'SQLite format 3') {
8205
+ throw new Error('Not a valid MBTiles file: missing SQLite header');
8206
+ }
8207
+ const SQL = await getSqlJs();
8208
+ const db = new SQL.Database(bytes);
8209
+ try {
8210
+ const tablesResult = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('metadata', 'tiles')");
8211
+ const tableNames = (tablesResult[0]?.values || []).map(r => r[0]);
8212
+ if (!tableNames.includes('metadata') || !tableNames.includes('tiles')) {
8213
+ throw new Error('Not a valid MBTiles file: missing required metadata/tiles tables');
8214
+ }
8215
+ const metadata = {};
8216
+ const metaStmt = db.prepare('SELECT name, value FROM metadata');
8217
+ try {
8218
+ while (metaStmt.step()) {
8219
+ const row = metaStmt.get();
8220
+ metadata[row[0]] = row[1];
8221
+ }
8222
+ }
8223
+ finally {
8224
+ metaStmt.free();
8225
+ }
8226
+ const rawBounds = metadata.bounds ? metadata.bounds.split(',').map(Number) : [0, 0, 0, 0];
8227
+ const bounds = [
8228
+ isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8229
+ isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8230
+ isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8231
+ isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8232
+ ];
8233
+ const format = (metadata.format || 'pbf');
8234
+ const isVector = VECTOR_FORMATS.has(format);
8235
+ const tiles = [];
8236
+ const tilesStmt = db.prepare('SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles');
8237
+ try {
8238
+ while (tilesStmt.step()) {
8239
+ const row = tilesStmt.get();
8240
+ const [z, x, tmsRow, data] = row;
8241
+ // Sliced copy so the buffer is detached from sql.js's heap.
8242
+ const copy = new Uint8Array(data.byteLength);
8243
+ copy.set(data);
8244
+ // Our IndexedDB stores vector tiles decompressed (tileService
8245
+ // inflates on download). MBTiles vector tiles are gzipped by
8246
+ // convention — un-gzip on the way in so the stored tile matches
8247
+ // what the fetch handler expects to serve.
8248
+ const storedBytes = isVector && hasGzipMagic(copy) ? await gunzipBytes(copy) : copy;
8249
+ tiles.push({
8250
+ z,
8251
+ x,
8252
+ y: flipY(tmsRow, z),
8253
+ data: storedBytes.buffer,
8254
+ format,
8255
+ sourceId: 'imported',
8256
+ });
8257
+ }
8258
+ }
8259
+ finally {
8260
+ tilesStmt.free();
8261
+ }
8262
+ const minZoom = metadata.minzoom !== undefined ? Number(metadata.minzoom) : 0;
8263
+ const maxZoom = metadata.maxzoom !== undefined ? Number(metadata.maxzoom) : 14;
8264
+ return {
8265
+ metadata: {
8266
+ id: metadata.name || 'imported-region',
8267
+ name: metadata.name || 'Imported Region',
8268
+ description: metadata.description,
8269
+ bounds: [
8270
+ [bounds[0], bounds[1]],
8271
+ [bounds[2], bounds[3]],
8272
+ ],
8273
+ minZoom,
8274
+ maxZoom,
8275
+ styleUrl: '',
8276
+ createdAt: Date.now(),
8277
+ exportedAt: Date.now(),
8278
+ version: '1.0.0',
8279
+ format: 'mbtiles',
8280
+ },
8281
+ style: {},
8282
+ tiles,
8283
+ };
8284
+ }
8285
+ finally {
8286
+ db.close();
8287
+ }
8103
8288
  }
8104
8289
  /**
8105
8290
  * Import region data to database
@@ -8164,16 +8349,15 @@ class ImportExportService {
8164
8349
  });
8165
8350
  }
8166
8351
  }
8167
- // Import sprites and fonts similarly...
8168
8352
  return {
8169
8353
  success: true,
8170
8354
  regionId,
8171
8355
  message: 'Region imported successfully',
8172
8356
  statistics: {
8173
8357
  tilesImported: regionData.tiles?.length || 0,
8174
- spritesImported: regionData.sprites?.length || 0,
8175
- fontsImported: regionData.fonts?.length || 0,
8176
- totalSize: 0, // Calculate if needed
8358
+ spritesImported: 0,
8359
+ fontsImported: 0,
8360
+ totalSize: 0,
8177
8361
  },
8178
8362
  };
8179
8363
  }
@@ -8402,8 +8586,6 @@ const createMaintenanceManagement = (services, deps) => {
8402
8586
  };
8403
8587
 
8404
8588
  const createImportExportManagement = (services) => ({
8405
- exportRegionAsJSON: async (regionId, options = {}) => services.importExportService.exportRegionAsJSON(regionId, options),
8406
- exportRegionAsPMTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsPMTiles(regionId, options),
8407
8589
  exportRegionAsMBTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsMBTiles(regionId, options),
8408
8590
  importRegion: async (importData) => services.importExportService.importRegion(importData),
8409
8591
  downloadExportedRegion: (exportResult) => {
@@ -8759,10 +8941,6 @@ const en = {
8759
8941
  'styleSelection.title': 'Select Offline Style',
8760
8942
  'styleSelection.message': 'Choose which offline style to load:',
8761
8943
  'styleSelection.sources': 'sources',
8762
- // Import/Export
8763
- 'importExport.title': 'Import/Export',
8764
- 'importExport.export': 'Export',
8765
- 'importExport.import': 'Import',
8766
8944
  // Errors
8767
8945
  'error.loadingContent': 'Error loading content',
8768
8946
  'error.tryAgain': 'Please try again',
@@ -8787,41 +8965,30 @@ const en = {
8787
8965
  'regionDetails.bounds': 'Bounds',
8788
8966
  'regionDetails.zoomRange': 'Zoom Range',
8789
8967
  'regionDetails.created': 'Created',
8790
- // Import/Export Modal
8791
- 'importExport.regionTitle': 'Import/Export Region',
8792
- 'importExport.regionInfo': 'Region Information',
8793
- 'importExport.id': 'ID',
8794
- 'importExport.name': 'Name',
8795
- 'importExport.unnamed': 'Unnamed',
8796
- 'importExport.zoom': 'Zoom',
8797
- 'importExport.created': 'Created',
8798
- 'importExport.exportRegion': 'Export Region',
8799
- 'importExport.exportFormat': 'Export Format',
8800
- 'importExport.formatJson': 'JSON - Complete data (recommended)',
8801
- 'importExport.formatPmtiles': 'PMTiles - Web optimized tiles',
8802
- 'importExport.formatMbtiles': 'MBTiles - Industry standard',
8803
- 'importExport.formatHint': 'Choose format based on your use case',
8804
- 'importExport.includeComponents': 'Include Components',
8805
- 'importExport.styleConfig': 'Style Configuration',
8806
- 'importExport.mapTiles': 'Map Tiles',
8807
- 'importExport.spritesIcons': 'Sprites & Icons',
8808
- 'importExport.fontsGlyphs': 'Fonts & Glyphs',
8809
- 'importExport.preparingExport': 'Preparing export...',
8810
- 'importExport.exportComplete': 'Export complete!',
8811
- 'importExport.exportFailed': 'Export failed. Please try again.',
8812
- 'importExport.importRegion': 'Import Region',
8813
- 'importExport.selectFile': 'Select File',
8814
- 'importExport.fileFormatsHint': 'Supports JSON, PMTiles, and MBTiles formats',
8815
- 'importExport.newRegionName': 'New Region Name (Optional)',
8816
- 'importExport.newRegionNamePlaceholder': 'Leave empty to use original name',
8817
- 'importExport.overwriteIfExists': 'Overwrite if region exists',
8818
- 'importExport.preparingImport': 'Preparing import...',
8819
- 'importExport.importComplete': 'Import complete!',
8820
- 'importExport.importFailed': 'Import failed. Please try again.',
8821
- 'importExport.formatGuide': 'Format Guide',
8822
- 'importExport.jsonDesc': 'Complete data, human-readable, best for development',
8823
- 'importExport.pmtilesDesc': 'Web-optimized, efficient serving, cloud-friendly',
8824
- 'importExport.mbtilesDesc': 'Industry standard, SQLite-based, cross-platform',
8968
+ // MBTiles Modal
8969
+ 'mbtiles.title': 'MBTiles Import / Export',
8970
+ 'mbtiles.regionInfo': 'Region Information',
8971
+ 'mbtiles.id': 'ID',
8972
+ 'mbtiles.name': 'Name',
8973
+ 'mbtiles.unnamed': 'Unnamed',
8974
+ 'mbtiles.zoom': 'Zoom',
8975
+ 'mbtiles.created': 'Created',
8976
+ 'mbtiles.exportTitle': 'Export as MBTiles',
8977
+ 'mbtiles.exportHint': 'Package the tiles in this region into a standard SQLite MBTiles archive that opens in QGIS, tippecanoe, and other tools.',
8978
+ 'mbtiles.exportButton': 'Download .mbtiles',
8979
+ 'mbtiles.preparingExport': 'Preparing export...',
8980
+ 'mbtiles.exportComplete': 'Export complete!',
8981
+ 'mbtiles.exportFailed': 'Export failed. Please try again.',
8982
+ 'mbtiles.importTitle': 'Import from MBTiles',
8983
+ 'mbtiles.selectFile': 'Select an .mbtiles file',
8984
+ 'mbtiles.fileHint': 'Only SQLite-format .mbtiles files are supported.',
8985
+ 'mbtiles.newRegionName': 'New Region Name (optional)',
8986
+ 'mbtiles.newRegionNamePlaceholder': 'Leave empty to use the name from the file',
8987
+ 'mbtiles.overwriteIfExists': 'Overwrite if a region with the same id exists',
8988
+ 'mbtiles.importButton': 'Import .mbtiles',
8989
+ 'mbtiles.preparingImport': 'Preparing import...',
8990
+ 'mbtiles.importComplete': 'Import complete!',
8991
+ 'mbtiles.importFailed': 'Import failed. Please try again.',
8825
8992
  // Active Downloads
8826
8993
  'download.activeCount': 'Active Downloads ({{count}})',
8827
8994
  // Panel Manager additional strings
@@ -8982,10 +9149,6 @@ const ar = {
8982
9149
  'styleSelection.title': 'اختر نمط غير متصل',
8983
9150
  'styleSelection.message': 'اختر النمط غير المتصل الذي تريد تحميله:',
8984
9151
  'styleSelection.sources': 'مصادر',
8985
- // Import/Export - استيراد/تصدير
8986
- 'importExport.title': 'استيراد/تصدير',
8987
- 'importExport.export': 'تصدير',
8988
- 'importExport.import': 'استيراد',
8989
9152
  // Errors - الأخطاء
8990
9153
  'error.loadingContent': 'خطأ في تحميل المحتوى',
8991
9154
  'error.tryAgain': 'يرجى المحاولة مرة أخرى',
@@ -9010,41 +9173,30 @@ const ar = {
9010
9173
  'regionDetails.bounds': 'الحدود',
9011
9174
  'regionDetails.zoomRange': 'نطاق التكبير',
9012
9175
  'regionDetails.created': 'تاريخ الإنشاء',
9013
- // Import/Export Modal - نافذة الاستيراد/التصدير
9014
- 'importExport.regionTitle': 'استيراد/تصدير المنطقة',
9015
- 'importExport.regionInfo': 'معلومات المنطقة',
9016
- 'importExport.id': 'المعرف',
9017
- 'importExport.name': 'الاسم',
9018
- 'importExport.unnamed': 'بدون اسم',
9019
- 'importExport.zoom': 'التكبير',
9020
- 'importExport.created': 'تاريخ الإنشاء',
9021
- 'importExport.exportRegion': 'تصدير المنطقة',
9022
- 'importExport.exportFormat': 'تنسيق التصدير',
9023
- 'importExport.formatJson': 'JSON - بيانات كاملة (موصى به)',
9024
- 'importExport.formatPmtiles': 'PMTiles - بلاطات محسنة للويب',
9025
- 'importExport.formatMbtiles': 'MBTiles - معيار الصناعة',
9026
- 'importExport.formatHint': 'اختر التنسيق بناءً على حالة استخدامك',
9027
- 'importExport.includeComponents': 'تضمين المكونات',
9028
- 'importExport.styleConfig': 'إعدادات النمط',
9029
- 'importExport.mapTiles': 'بلاطات الخريطة',
9030
- 'importExport.spritesIcons': 'الرموز والأيقونات',
9031
- 'importExport.fontsGlyphs': 'الخطوط والحروف',
9032
- 'importExport.preparingExport': 'جاري تجهيز التصدير...',
9033
- 'importExport.exportComplete': 'اكتمل التصدير!',
9034
- 'importExport.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9035
- 'importExport.importRegion': 'استيراد المنطقة',
9036
- 'importExport.selectFile': 'اختر ملف',
9037
- 'importExport.fileFormatsHint': 'يدعم تنسيقات JSON و PMTiles و MBTiles',
9038
- 'importExport.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9039
- 'importExport.newRegionNamePlaceholder': 'اتركه فارغاً لاستخدام الاسم الأصلي',
9040
- 'importExport.overwriteIfExists': 'الكتابة فوق المنطقة الموجودة',
9041
- 'importExport.preparingImport': 'جاري تجهيز الاستيراد...',
9042
- 'importExport.importComplete': 'اكتمل الاستيراد!',
9043
- 'importExport.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9044
- 'importExport.formatGuide': 'دليل التنسيقات',
9045
- 'importExport.jsonDesc': 'بيانات كاملة، قابلة للقراءة، الأفضل للتطوير',
9046
- 'importExport.pmtilesDesc': 'محسن للويب، خدمة فعالة، متوافق مع السحابة',
9047
- 'importExport.mbtilesDesc': 'معيار الصناعة، قائم على SQLite، متعدد المنصات',
9176
+ // MBTiles Modal - نافذة MBTiles
9177
+ 'mbtiles.title': 'استيراد / تصدير MBTiles',
9178
+ 'mbtiles.regionInfo': 'معلومات المنطقة',
9179
+ 'mbtiles.id': 'المعرف',
9180
+ 'mbtiles.name': 'الاسم',
9181
+ 'mbtiles.unnamed': 'بدون اسم',
9182
+ 'mbtiles.zoom': 'التكبير',
9183
+ 'mbtiles.created': 'تاريخ الإنشاء',
9184
+ 'mbtiles.exportTitle': 'التصدير كـ MBTiles',
9185
+ 'mbtiles.exportHint': 'احزم بلاطات هذه المنطقة داخل أرشيف MBTiles (SQLite) يمكن فتحه في QGIS و tippecanoe وأدوات أخرى.',
9186
+ 'mbtiles.exportButton': 'تنزيل ملف mbtiles.',
9187
+ 'mbtiles.preparingExport': 'جاري تجهيز التصدير...',
9188
+ 'mbtiles.exportComplete': 'اكتمل التصدير!',
9189
+ 'mbtiles.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9190
+ 'mbtiles.importTitle': 'الاستيراد من MBTiles',
9191
+ 'mbtiles.selectFile': 'اختر ملف mbtiles.',
9192
+ 'mbtiles.fileHint': 'تُدعم ملفات mbtiles. بتنسيق SQLite فقط.',
9193
+ 'mbtiles.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9194
+ 'mbtiles.newRegionNamePlaceholder': 'اتركه فارغًا لاستخدام الاسم من الملف',
9195
+ 'mbtiles.overwriteIfExists': 'الكتابة فوق المنطقة إذا كان المعرف موجودًا',
9196
+ 'mbtiles.importButton': 'استيراد ملف mbtiles.',
9197
+ 'mbtiles.preparingImport': 'جاري تجهيز الاستيراد...',
9198
+ 'mbtiles.importComplete': 'اكتمل الاستيراد!',
9199
+ 'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9048
9200
  // Active Downloads - التحميلات النشطة
9049
9201
  'download.activeCount': 'التحميلات النشطة ({{count}})',
9050
9202
  // Panel Manager additional strings - سلاسل إضافية للوحة
@@ -9983,49 +10135,42 @@ class ConfirmationModal {
9983
10135
  }
9984
10136
 
9985
10137
  /**
9986
- * Import/Export Modal Component
9987
- * Handles import/export operations for regions
9988
- * Refactored to use modular Modal component for consistency
10138
+ * MBTiles Import/Export Modal
10139
+ *
10140
+ * Focused modal for exchanging regions as binary SQLite MBTiles archives.
10141
+ * Replaces the previous multi-format import/export modal.
9989
10142
  */
9990
- const modalLogger = logger.scope('ImportExportModal');
9991
- class ImportExportModal {
10143
+ const modalLogger = logger.scope('MBTilesModal');
10144
+ class MBTilesModal {
9992
10145
  modal;
9993
10146
  options;
9994
10147
  isExporting = false;
9995
10148
  isImporting = false;
9996
- // Form elements
9997
- exportFormatSelect;
9998
- includeStyleCheckbox;
9999
- includeTilesCheckbox;
10000
- includeSpritesCheckbox;
10001
- includeFontsCheckbox;
10002
10149
  exportProgressBar;
10003
10150
  exportProgressText;
10151
+ exportProgressContainer;
10004
10152
  exportButton;
10005
10153
  importFileInput;
10006
10154
  importNameInput;
10007
10155
  importOverwriteCheckbox;
10008
10156
  importProgressBar;
10009
10157
  importProgressText;
10158
+ importProgressContainer;
10010
10159
  importButton;
10011
10160
  constructor(options) {
10012
10161
  this.options = options;
10013
10162
  }
10014
10163
  show() {
10015
10164
  const modalConfig = {
10016
- title: t('importExport.regionTitle'),
10165
+ title: t('mbtiles.title'),
10017
10166
  subtitle: this.options.region.name || this.options.region.id,
10018
10167
  size: 'md',
10019
10168
  closable: true,
10020
10169
  onClose: () => this.hide(),
10021
10170
  };
10022
10171
  this.modal = new Modal(modalConfig);
10023
- // Create content
10024
- const content = this.createContent();
10025
- this.modal.setContent(content);
10026
- // Create footer with close button
10027
- const footer = this.createFooter();
10028
- this.modal.setFooter(footer);
10172
+ this.modal.setContent(this.createContent());
10173
+ this.modal.setFooter(this.createFooter());
10029
10174
  this.modal.show();
10030
10175
  this.attachEventListeners();
10031
10176
  return this.modal.getElement();
@@ -10034,217 +10179,96 @@ class ImportExportModal {
10034
10179
  this.modal?.hide();
10035
10180
  this.options.onClose();
10036
10181
  }
10182
+ destroy() {
10183
+ this.modal?.destroy();
10184
+ }
10037
10185
  createContent() {
10038
10186
  const content = document.createElement('div');
10039
- content.className = 'flex flex-col gap-6';
10187
+ content.className = 'flex flex-col gap-6 py-2';
10040
10188
  if (i18n.isRTL()) {
10041
10189
  content.setAttribute('dir', 'rtl');
10042
10190
  }
10043
- // Region Info Card
10044
- const infoCard = this.createRegionInfoCard();
10045
- content.appendChild(infoCard);
10046
- // Export/Import Grid
10047
- const gridContainer = document.createElement('div');
10048
- gridContainer.className = 'grid grid-cols-1 gap-6';
10049
- // Export Section
10050
- const exportSection = this.createExportSection();
10051
- gridContainer.appendChild(exportSection);
10052
- // Import Section
10053
- const importSection = this.createImportSection();
10054
- gridContainer.appendChild(importSection);
10055
- content.appendChild(gridContainer);
10056
- // Format Guide
10057
- const formatGuide = this.createFormatGuide();
10058
- content.appendChild(formatGuide);
10191
+ content.appendChild(this.createRegionInfoLine());
10192
+ content.appendChild(this.createExportSection());
10193
+ content.appendChild(this.createImportSection());
10059
10194
  return content;
10060
10195
  }
10061
- createRegionInfoCard() {
10062
- const card = document.createElement('div');
10063
- card.className = 'p-5 glass-input rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50';
10064
- card.innerHTML = `
10065
- <h4 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-white mb-4 flex items-center gap-2">
10066
- ${icons.mapPin({ size: 16, color: 'currentColor' })}
10067
- ${t('importExport.regionInfo')}
10068
- </h4>
10069
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
10070
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10071
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.id')}</span>
10072
- <div class="text-gray-900 dark:text-white font-mono text-xs break-all mt-1">${escapeHtml$1(this.options.region.id)}</div>
10073
- </div>
10074
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10075
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.name')}</span>
10076
- <div class="text-gray-900 dark:text-white font-medium mt-1">${escapeHtml$1(this.options.region.name || t('importExport.unnamed'))}</div>
10077
- </div>
10078
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10079
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.zoom')}</span>
10080
- <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>
10081
- </div>
10082
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10083
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.created')}</span>
10084
- <div class="text-gray-900 dark:text-white font-medium mt-1">${new Date(this.options.region.created).toLocaleDateString()}</div>
10085
- </div>
10086
- </div>
10196
+ createRegionInfoLine() {
10197
+ const { region } = this.options;
10198
+ const line = document.createElement('div');
10199
+ line.className =
10200
+ 'flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400';
10201
+ line.innerHTML = `
10202
+ <span class="flex items-center gap-1">
10203
+ ${icons.mapPin({ size: 12, color: 'currentColor' })}
10204
+ <span class="font-mono">${escapeHtml$1(region.id)}</span>
10205
+ </span>
10206
+ <span>Z${escapeHtml$1(region.minZoom)}-${escapeHtml$1(region.maxZoom)}</span>
10207
+ <span>${new Date(region.created).toLocaleDateString()}</span>
10087
10208
  `;
10088
- return card;
10209
+ return line;
10089
10210
  }
10090
10211
  createExportSection() {
10091
- const section = document.createElement('div');
10092
- section.className =
10093
- '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';
10094
- // Gradient accent
10095
- const accent = document.createElement('div');
10096
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-blue-500 opacity-50`;
10097
- section.appendChild(accent);
10098
- const header = document.createElement('h3');
10099
- header.className =
10100
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10101
- header.innerHTML = `
10102
- <div class="p-2 bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400">
10103
- ${icons.upload({ size: 20, color: 'currentColor' })}
10104
- </div>
10105
- ${t('importExport.exportRegion')}
10106
- `;
10107
- section.appendChild(header);
10108
- const formContainer = document.createElement('div');
10109
- formContainer.className = 'space-y-5';
10110
- // Format Selection
10111
- const formatGroup = document.createElement('div');
10112
- const formatLabel = document.createElement('label');
10113
- formatLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10114
- formatLabel.textContent = t('importExport.exportFormat');
10115
- this.exportFormatSelect = document.createElement('select');
10116
- this.exportFormatSelect.className =
10117
- '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';
10118
- this.exportFormatSelect.innerHTML = `
10119
- <option value="json">${t('importExport.formatJson')}</option>
10120
- <option value="pmtiles">${t('importExport.formatPmtiles')}</option>
10121
- <option value="mbtiles">${t('importExport.formatMbtiles')}</option>
10122
- `;
10123
- const formatHint = document.createElement('p');
10124
- formatHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10125
- formatHint.textContent = t('importExport.formatHint');
10126
- formatGroup.appendChild(formatLabel);
10127
- formatGroup.appendChild(this.exportFormatSelect);
10128
- formatGroup.appendChild(formatHint);
10129
- formContainer.appendChild(formatGroup);
10130
- // Export Options
10131
- const optionsGroup = document.createElement('div');
10132
- const optionsLabel = document.createElement('label');
10133
- optionsLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3';
10134
- optionsLabel.textContent = t('importExport.includeComponents');
10135
- const checkboxContainer = document.createElement('div');
10136
- checkboxContainer.className = 'grid grid-cols-1 sm:grid-cols-2 gap-3';
10137
- const createCheckbox = (text, checked = true) => {
10138
- const label = document.createElement('label');
10139
- label.className =
10140
- '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';
10141
- const input = document.createElement('input');
10142
- input.type = 'checkbox';
10143
- input.checked = checked;
10144
- input.className =
10145
- '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';
10146
- const span = document.createElement('span');
10147
- span.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10148
- span.textContent = text;
10149
- label.appendChild(input);
10150
- label.appendChild(span);
10151
- return { label, input };
10152
- };
10153
- const styleCheck = createCheckbox(t('importExport.styleConfig'));
10154
- this.includeStyleCheckbox = styleCheck.input;
10155
- checkboxContainer.appendChild(styleCheck.label);
10156
- const tilesCheck = createCheckbox(t('importExport.mapTiles'));
10157
- this.includeTilesCheckbox = tilesCheck.input;
10158
- checkboxContainer.appendChild(tilesCheck.label);
10159
- const spritesCheck = createCheckbox(t('importExport.spritesIcons'));
10160
- this.includeSpritesCheckbox = spritesCheck.input;
10161
- checkboxContainer.appendChild(spritesCheck.label);
10162
- const fontsCheck = createCheckbox(t('importExport.fontsGlyphs'));
10163
- this.includeFontsCheckbox = fontsCheck.input;
10164
- checkboxContainer.appendChild(fontsCheck.label);
10165
- optionsGroup.appendChild(optionsLabel);
10166
- optionsGroup.appendChild(checkboxContainer);
10167
- formContainer.appendChild(optionsGroup);
10168
- // Export Progress (hidden by default)
10169
- const progressContainer = document.createElement('div');
10170
- progressContainer.className = 'hidden';
10171
- const progressBarContainer = document.createElement('div');
10172
- progressBarContainer.className =
10173
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10174
- this.exportProgressBar = document.createElement('div');
10175
- this.exportProgressBar.className = 'bg-blue-600 h-2 rounded-full transition-all duration-300';
10176
- this.exportProgressBar.style.width = '0%';
10177
- progressBarContainer.appendChild(this.exportProgressBar);
10178
- this.exportProgressText = document.createElement('p');
10179
- this.exportProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10180
- this.exportProgressText.textContent = t('importExport.preparingExport');
10181
- progressContainer.appendChild(progressBarContainer);
10182
- progressContainer.appendChild(this.exportProgressText);
10183
- formContainer.appendChild(progressContainer);
10184
- // Export Button
10185
- const exportButton = new Button({
10186
- text: t('importExport.exportRegion'),
10212
+ const section = this.createSection(t('mbtiles.exportTitle'), 'blue', icons.download({ size: 20, color: 'currentColor' }));
10213
+ const form = document.createElement('div');
10214
+ form.className = 'space-y-5';
10215
+ const hint = document.createElement('p');
10216
+ hint.className = 'text-sm text-gray-600 dark:text-gray-400';
10217
+ hint.textContent = t('mbtiles.exportHint');
10218
+ form.appendChild(hint);
10219
+ // Progress (hidden by default)
10220
+ const progress = this.createProgressBlock('blue', t('mbtiles.preparingExport'));
10221
+ this.exportProgressContainer = progress.container;
10222
+ this.exportProgressBar = progress.bar;
10223
+ this.exportProgressText = progress.text;
10224
+ form.appendChild(progress.container);
10225
+ const exportBtn = new Button({
10226
+ text: t('mbtiles.exportButton'),
10187
10227
  variant: 'primary',
10188
10228
  icon: icons.download({ size: 16, color: 'white' }),
10189
- className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20', // Premium button styles
10229
+ className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20',
10190
10230
  onClick: () => this.handleExport(),
10191
10231
  });
10192
- this.exportButton = exportButton.getElement();
10193
- formContainer.appendChild(this.exportButton);
10194
- section.appendChild(formContainer);
10232
+ this.exportButton = exportBtn.getElement();
10233
+ form.appendChild(this.exportButton);
10234
+ section.appendChild(form);
10195
10235
  return section;
10196
10236
  }
10197
10237
  createImportSection() {
10198
- const section = document.createElement('div');
10199
- section.className =
10200
- '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';
10201
- // Gradient accent
10202
- const accent = document.createElement('div');
10203
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-green-500 opacity-50`;
10204
- section.appendChild(accent);
10205
- const header = document.createElement('h3');
10206
- header.className =
10207
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10208
- header.innerHTML = `
10209
- <div class="p-2 bg-green-500/10 rounded-lg text-green-600 dark:text-green-400">
10210
- ${icons.upload({ size: 20, color: 'currentColor' })}
10211
- </div>
10212
- ${t('importExport.importRegion')}
10213
- `;
10214
- section.appendChild(header);
10215
- const formContainer = document.createElement('div');
10216
- formContainer.className = 'space-y-5';
10217
- // File Selection
10238
+ const section = this.createSection(t('mbtiles.importTitle'), 'green', icons.upload({ size: 20, color: 'currentColor' }));
10239
+ const form = document.createElement('div');
10240
+ form.className = 'space-y-5';
10241
+ // File input
10218
10242
  const fileGroup = document.createElement('div');
10219
10243
  const fileLabel = document.createElement('label');
10220
10244
  fileLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10221
- fileLabel.textContent = t('importExport.selectFile');
10245
+ fileLabel.textContent = t('mbtiles.selectFile');
10222
10246
  this.importFileInput = document.createElement('input');
10223
10247
  this.importFileInput.type = 'file';
10224
- this.importFileInput.accept = '.json,.pmtiles,.mbtiles';
10248
+ this.importFileInput.accept = '.mbtiles,application/vnd.sqlite3,application/x-sqlite3';
10225
10249
  this.importFileInput.className =
10226
10250
  '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';
10227
10251
  const fileHint = document.createElement('p');
10228
10252
  fileHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10229
- fileHint.textContent = t('importExport.fileFormatsHint');
10253
+ fileHint.textContent = t('mbtiles.fileHint');
10230
10254
  fileGroup.appendChild(fileLabel);
10231
10255
  fileGroup.appendChild(this.importFileInput);
10232
10256
  fileGroup.appendChild(fileHint);
10233
- formContainer.appendChild(fileGroup);
10234
- // New Name
10257
+ form.appendChild(fileGroup);
10258
+ // New region name
10235
10259
  const nameGroup = document.createElement('div');
10236
10260
  const nameLabel = document.createElement('label');
10237
10261
  nameLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10238
- nameLabel.textContent = t('importExport.newRegionName');
10262
+ nameLabel.textContent = t('mbtiles.newRegionName');
10239
10263
  this.importNameInput = document.createElement('input');
10240
10264
  this.importNameInput.type = 'text';
10241
- this.importNameInput.placeholder = t('importExport.newRegionNamePlaceholder');
10265
+ this.importNameInput.placeholder = t('mbtiles.newRegionNamePlaceholder');
10242
10266
  this.importNameInput.className =
10243
10267
  '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';
10244
10268
  nameGroup.appendChild(nameLabel);
10245
10269
  nameGroup.appendChild(this.importNameInput);
10246
- formContainer.appendChild(nameGroup);
10247
- // Import Options
10270
+ form.appendChild(nameGroup);
10271
+ // Overwrite toggle
10248
10272
  const overwriteLabel = document.createElement('label');
10249
10273
  overwriteLabel.className =
10250
10274
  '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';
@@ -10254,79 +10278,85 @@ class ImportExportModal {
10254
10278
  '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';
10255
10279
  const overwriteSpan = document.createElement('span');
10256
10280
  overwriteSpan.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10257
- overwriteSpan.textContent = t('importExport.overwriteIfExists');
10281
+ overwriteSpan.textContent = t('mbtiles.overwriteIfExists');
10258
10282
  overwriteLabel.appendChild(this.importOverwriteCheckbox);
10259
10283
  overwriteLabel.appendChild(overwriteSpan);
10260
- formContainer.appendChild(overwriteLabel);
10261
- // Import Progress (hidden by default)
10262
- const progressContainer = document.createElement('div');
10263
- progressContainer.className = 'hidden';
10264
- const progressBarContainer = document.createElement('div');
10265
- progressBarContainer.className =
10266
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10267
- this.importProgressBar = document.createElement('div');
10268
- this.importProgressBar.className = 'bg-green-600 h-2 rounded-full transition-all duration-300';
10269
- this.importProgressBar.style.width = '0%';
10270
- progressBarContainer.appendChild(this.importProgressBar);
10271
- this.importProgressText = document.createElement('p');
10272
- this.importProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10273
- this.importProgressText.textContent = t('importExport.preparingImport');
10274
- progressContainer.appendChild(progressBarContainer);
10275
- progressContainer.appendChild(this.importProgressText);
10276
- formContainer.appendChild(progressContainer);
10277
- // Import Button
10278
- const importButton = new Button({
10279
- text: t('importExport.importRegion'),
10280
- variant: 'success', // Assuming 'success' variant exists in Button component, if not might need style adjustment. Assuming it works based on previous code.
10284
+ form.appendChild(overwriteLabel);
10285
+ // Progress
10286
+ const progress = this.createProgressBlock('green', t('mbtiles.preparingImport'));
10287
+ this.importProgressContainer = progress.container;
10288
+ this.importProgressBar = progress.bar;
10289
+ this.importProgressText = progress.text;
10290
+ form.appendChild(progress.container);
10291
+ // Import button (disabled until a file is selected)
10292
+ const importBtn = new Button({
10293
+ text: t('mbtiles.importButton'),
10294
+ variant: 'success',
10281
10295
  icon: icons.upload({ size: 16, color: 'white' }),
10282
10296
  className: 'w-full py-2.5 text-base shadow-lg shadow-green-500/20',
10283
10297
  disabled: true,
10284
10298
  onClick: () => this.handleImport(),
10285
10299
  });
10286
- this.importButton = importButton.getElement();
10287
- formContainer.appendChild(this.importButton);
10288
- section.appendChild(formContainer);
10300
+ this.importButton = importBtn.getElement();
10301
+ form.appendChild(this.importButton);
10302
+ section.appendChild(form);
10289
10303
  return section;
10290
10304
  }
10291
- createFormatGuide() {
10292
- const guide = document.createElement('div');
10293
- guide.className = 'p-5 mt-4 glass-input rounded-xl border-0 bg-blue-50/40 dark:bg-blue-900/20';
10294
- guide.innerHTML = `
10295
- <h4 class="text-sm font-bold uppercase tracking-wider text-blue-900 dark:text-blue-300 mb-3 flex items-center gap-2">
10296
- ${icons.infoCircle({ size: 16, color: 'currentColor' })}
10297
- ${t('importExport.formatGuide')}
10298
- </h4>
10299
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
10300
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10301
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">JSON</div>
10302
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.jsonDesc')}</div>
10303
- </div>
10304
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10305
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">PMTiles</div>
10306
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.pmtilesDesc')}</div>
10307
- </div>
10308
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10309
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">MBTiles</div>
10310
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.mbtilesDesc')}</div>
10311
- </div>
10305
+ createSection(title, accentColor, iconHtml) {
10306
+ const section = document.createElement('div');
10307
+ section.className =
10308
+ 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden';
10309
+ const accent = document.createElement('div');
10310
+ accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-${accentColor}-500 opacity-50`;
10311
+ section.appendChild(accent);
10312
+ const header = document.createElement('h3');
10313
+ header.className =
10314
+ 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10315
+ header.innerHTML = `
10316
+ <div class="p-2 bg-${accentColor}-500/10 rounded-lg text-${accentColor}-600 dark:text-${accentColor}-400">
10317
+ ${iconHtml}
10312
10318
  </div>
10319
+ ${title}
10313
10320
  `;
10314
- return guide;
10321
+ section.appendChild(header);
10322
+ return section;
10323
+ }
10324
+ createProgressBlock(accentColor, initialText) {
10325
+ const container = document.createElement('div');
10326
+ container.className = 'hidden';
10327
+ const barWrap = document.createElement('div');
10328
+ barWrap.className = 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10329
+ const bar = document.createElement('div');
10330
+ bar.className = `bg-${accentColor}-600 h-2 rounded-full transition-all duration-300`;
10331
+ bar.style.width = '0%';
10332
+ barWrap.appendChild(bar);
10333
+ const text = document.createElement('p');
10334
+ text.className = 'text-sm text-gray-600 dark:text-gray-400';
10335
+ text.textContent = initialText;
10336
+ container.appendChild(barWrap);
10337
+ container.appendChild(text);
10338
+ return { container, bar, text };
10339
+ }
10340
+ createFooter() {
10341
+ const footer = document.createElement('div');
10342
+ footer.className = 'flex gap-3 justify-end';
10343
+ if (i18n.isRTL()) {
10344
+ footer.setAttribute('dir', 'rtl');
10345
+ }
10346
+ const close = new Button({
10347
+ text: t('app.close'),
10348
+ variant: 'secondary',
10349
+ onClick: () => this.hide(),
10350
+ });
10351
+ footer.appendChild(close.getElement());
10352
+ return footer;
10315
10353
  }
10316
10354
  attachEventListeners() {
10317
- // Enable import button when file is selected
10318
10355
  if (this.importFileInput && this.importButton) {
10319
10356
  this.importFileInput.addEventListener('change', () => {
10320
- if (this.importFileInput?.files && this.importFileInput.files.length > 0) {
10321
- if (this.importButton) {
10322
- this.importButton.disabled = false;
10323
- }
10324
- }
10325
- else {
10326
- if (this.importButton) {
10327
- this.importButton.disabled = true;
10328
- }
10329
- }
10357
+ const hasFile = !!(this.importFileInput?.files && this.importFileInput.files.length > 0);
10358
+ if (this.importButton)
10359
+ this.importButton.disabled = !hasFile;
10330
10360
  });
10331
10361
  }
10332
10362
  }
@@ -10336,34 +10366,27 @@ class ImportExportModal {
10336
10366
  this.isExporting = true;
10337
10367
  if (this.exportButton)
10338
10368
  this.exportButton.disabled = true;
10369
+ this.exportProgressContainer?.classList.remove('hidden');
10339
10370
  try {
10340
- const format = this.exportFormatSelect?.value;
10341
- const options = {
10342
- includeStyle: this.includeStyleCheckbox?.checked ?? true,
10343
- includeTiles: this.includeTilesCheckbox?.checked ?? true,
10344
- includeSprites: this.includeSpritesCheckbox?.checked ?? true,
10345
- includeFonts: this.includeFontsCheckbox?.checked ?? true,
10346
- };
10347
- // Show progress
10348
- const progressContainer = this.exportProgressBar?.parentElement?.parentElement;
10349
- if (progressContainer) {
10350
- progressContainer.classList.remove('hidden');
10351
- }
10352
- const result = await this.options.exportRegion(this.options.region.id, format, options);
10353
- if (this.exportProgressBar) {
10371
+ const result = await this.options.exportRegion(this.options.region.id, {
10372
+ onProgress: p => {
10373
+ if (this.exportProgressBar)
10374
+ this.exportProgressBar.style.width = `${p.percentage}%`;
10375
+ if (this.exportProgressText)
10376
+ this.exportProgressText.textContent = p.message;
10377
+ },
10378
+ });
10379
+ if (this.exportProgressBar)
10354
10380
  this.exportProgressBar.style.width = '100%';
10355
- }
10356
- if (this.exportProgressText) {
10357
- this.exportProgressText.textContent = t('importExport.exportComplete');
10358
- }
10381
+ if (this.exportProgressText)
10382
+ this.exportProgressText.textContent = t('mbtiles.exportComplete');
10359
10383
  this.options.onExport?.(result);
10360
- // Hide modal after short delay
10361
- setTimeout(() => this.hide(), 1500);
10384
+ setTimeout(() => this.hide(), 1200);
10362
10385
  }
10363
10386
  catch (error) {
10364
10387
  modalLogger.error('Export error:', error instanceof Error ? error.message : String(error));
10365
10388
  if (this.exportProgressText) {
10366
- this.exportProgressText.textContent = t('importExport.exportFailed');
10389
+ this.exportProgressText.textContent = t('mbtiles.exportFailed');
10367
10390
  this.exportProgressText.classList.add('text-red-600', 'dark:text-red-400');
10368
10391
  }
10369
10392
  }
@@ -10374,45 +10397,47 @@ class ImportExportModal {
10374
10397
  }
10375
10398
  }
10376
10399
  async handleImport() {
10377
- if (this.isImporting || !this.options.importRegion || !this.importFileInput?.files?.[0])
10400
+ if (this.isImporting || !this.options.importRegion)
10401
+ return;
10402
+ const file = this.importFileInput?.files?.[0];
10403
+ if (!file)
10378
10404
  return;
10379
10405
  this.isImporting = true;
10380
10406
  if (this.importButton)
10381
10407
  this.importButton.disabled = true;
10408
+ this.importProgressContainer?.classList.remove('hidden');
10382
10409
  try {
10383
- const file = this.importFileInput.files[0];
10384
- const overwrite = this.importOverwriteCheckbox?.checked ?? false;
10385
- // Show progress
10386
- const progressContainer = this.importProgressBar?.parentElement?.parentElement;
10387
- if (progressContainer) {
10388
- progressContainer.classList.remove('hidden');
10389
- }
10390
- // Determine format from file extension
10391
- const format = file.name.endsWith('.pmtiles')
10392
- ? 'pmtiles'
10393
- : file.name.endsWith('.mbtiles')
10394
- ? 'mbtiles'
10395
- : 'json';
10396
10410
  const data = {
10397
10411
  file,
10398
- format,
10399
- overwrite,
10412
+ format: 'mbtiles',
10413
+ overwrite: this.importOverwriteCheckbox?.checked ?? false,
10414
+ newRegionName: this.importNameInput?.value.trim() || undefined,
10415
+ onProgress: p => {
10416
+ if (this.importProgressBar)
10417
+ this.importProgressBar.style.width = `${p.percentage}%`;
10418
+ if (this.importProgressText)
10419
+ this.importProgressText.textContent = p.message;
10420
+ },
10400
10421
  };
10401
10422
  const result = await this.options.importRegion(data);
10402
- if (this.importProgressBar) {
10423
+ if (this.importProgressBar)
10403
10424
  this.importProgressBar.style.width = '100%';
10404
- }
10405
10425
  if (this.importProgressText) {
10406
- this.importProgressText.textContent = t('importExport.importComplete');
10426
+ this.importProgressText.textContent = result.success
10427
+ ? t('mbtiles.importComplete')
10428
+ : result.message;
10429
+ if (!result.success) {
10430
+ this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10431
+ }
10407
10432
  }
10408
10433
  this.options.onImport?.(result);
10409
- // Hide modal after short delay
10410
- setTimeout(() => this.hide(), 1500);
10434
+ if (result.success)
10435
+ setTimeout(() => this.hide(), 1200);
10411
10436
  }
10412
10437
  catch (error) {
10413
10438
  modalLogger.error('Import error:', error instanceof Error ? error.message : String(error));
10414
10439
  if (this.importProgressText) {
10415
- this.importProgressText.textContent = t('importExport.importFailed');
10440
+ this.importProgressText.textContent = t('mbtiles.importFailed');
10416
10441
  this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10417
10442
  }
10418
10443
  }
@@ -10422,23 +10447,6 @@ class ImportExportModal {
10422
10447
  this.importButton.disabled = false;
10423
10448
  }
10424
10449
  }
10425
- createFooter() {
10426
- const footer = document.createElement('div');
10427
- footer.className = 'flex gap-3 justify-end';
10428
- if (i18n.isRTL()) {
10429
- footer.setAttribute('dir', 'rtl');
10430
- }
10431
- const closeButton = new Button({
10432
- text: t('app.close'),
10433
- variant: 'secondary',
10434
- onClick: () => this.hide(),
10435
- });
10436
- footer.appendChild(closeButton.getElement());
10437
- return footer;
10438
- }
10439
- destroy() {
10440
- this.modal?.destroy();
10441
- }
10442
10450
  }
10443
10451
 
10444
10452
  /**
@@ -11152,9 +11160,9 @@ class PanelRenderer extends BaseComponent {
11152
11160
  <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')}">
11153
11161
  ${icons.download({ size: 14, color: 'currentColor' })}
11154
11162
  </button>
11155
- <!-- <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')}">
11163
+ <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')}">
11156
11164
  ${icons.deviceFloppy({ size: 14, color: 'currentColor' })}
11157
- </button> -->
11165
+ </button>
11158
11166
  <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')}">
11159
11167
  ${icons.trash({ size: 14, color: 'currentColor' })}
11160
11168
  </button>
@@ -11416,7 +11424,7 @@ class PanelRenderer extends BaseComponent {
11416
11424
  }
11417
11425
  }
11418
11426
  /**
11419
- * Handle import/export functionality
11427
+ * Show the MBTiles import/export modal for a region.
11420
11428
  */
11421
11429
  async handleImportExport(regionId, _regionData) {
11422
11430
  try {
@@ -11424,44 +11432,26 @@ class PanelRenderer extends BaseComponent {
11424
11432
  const region = regions.find((r) => r.id === regionId);
11425
11433
  if (!region)
11426
11434
  return;
11427
- const importExportModal = new ImportExportModal({
11435
+ const mbtilesModal = new MBTilesModal({
11428
11436
  region,
11429
- onClose: () => {
11430
- this.modalManager.close();
11431
- },
11437
+ onClose: () => this.modalManager.close(),
11432
11438
  onExport: result => {
11433
- panelLogger.debug('Export completed:', result);
11434
- // Handle export result - could show success message
11439
+ panelLogger.debug('MBTiles export completed:', result);
11435
11440
  this.offlineManager.downloadExportedRegion(result);
11436
11441
  },
11437
11442
  onImport: result => {
11438
- panelLogger.debug('Import completed:', result);
11439
- // Refresh the panel to show updated regions
11440
- this.refresh();
11441
- },
11442
- exportRegion: async (regionId, format, options) => {
11443
- // Delegate to offline manager's export functionality
11444
- switch (format) {
11445
- case 'json':
11446
- return await this.offlineManager.exportRegionAsJSON(regionId, options);
11447
- case 'pmtiles':
11448
- return await this.offlineManager.exportRegionAsPMTiles(regionId, options);
11449
- case 'mbtiles':
11450
- return await this.offlineManager.exportRegionAsMBTiles(regionId, options);
11451
- default:
11452
- throw new Error(`Unsupported export format: ${format}`);
11453
- }
11454
- },
11455
- importRegion: async (data) => {
11456
- // Delegate to offline manager's import functionality
11457
- return await this.offlineManager.importRegion(data);
11443
+ panelLogger.debug('MBTiles import completed:', result);
11444
+ if (result.success)
11445
+ this.refresh();
11458
11446
  },
11447
+ exportRegion: (id, options) => this.offlineManager.exportRegionAsMBTiles(id, options),
11448
+ importRegion: data => this.offlineManager.importRegion(data),
11459
11449
  });
11460
- const modal = importExportModal.show();
11450
+ const modal = mbtilesModal.show();
11461
11451
  this.modalManager.show(modal);
11462
11452
  }
11463
11453
  catch (error) {
11464
- panelLogger.error('Error showing import/export modal:', error);
11454
+ panelLogger.error('Error showing MBTiles modal:', error);
11465
11455
  }
11466
11456
  }
11467
11457
  /**
@@ -11860,6 +11850,9 @@ class PanelRenderer extends BaseComponent {
11860
11850
  delete patchedStyle.imports;
11861
11851
  panelLogger.debug('Stripped imports from offline style (already flattened)');
11862
11852
  }
11853
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
11854
+ // downloaded before resolveImports learned to rewrite them.
11855
+ sanitizeIndoorExpressions(patchedStyle);
11863
11856
  // Enforce maxzoom for all tile sources to prevent requesting non-existent tiles
11864
11857
  // Find the maximum zoom level from all regions using this style
11865
11858
  let maxZoom = 14; // Default fallback
@@ -12601,9 +12594,7 @@ class RegionFormModal {
12601
12594
  detectProviderFromUrl() {
12602
12595
  const styleUrl = this.styleUrlInput?.value || '';
12603
12596
  // Simple detection logic
12604
- if (styleUrl.startsWith('mapbox://') ||
12605
- styleUrl.includes('mapbox.com') ||
12606
- styleUrl.includes('api.mapbox.com')) {
12597
+ if (styleUrl.startsWith('mapbox://') || isMapboxHost(styleUrl)) {
12607
12598
  if (this.providerSelect)
12608
12599
  this.providerSelect.value = 'mapbox';
12609
12600
  this.toggleAccessTokenVisibility(true);
@@ -13367,39 +13358,39 @@ class OfflineManagerControl {
13367
13358
  }
13368
13359
  // Development proxy for CORS issues (when running on localhost)
13369
13360
  if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
13370
- // Proxy Carto tile requests (tiles and TileJSON)
13371
- const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(url);
13372
- const isTileJsonRequest = url.includes('.json') && url.includes('basemaps.cartocdn.com');
13373
- if (isTileRequest && url.includes('tiles-a.basemaps.cartocdn.com')) {
13374
- const proxyUrl = url.replace('https://tiles-a.basemaps.cartocdn.com', '/tiles/carto-a');
13375
- return originalFetch(proxyUrl, init);
13376
- }
13377
- if (isTileRequest && url.includes('tiles-b.basemaps.cartocdn.com')) {
13378
- const proxyUrl = url.replace('https://tiles-b.basemaps.cartocdn.com', '/tiles/carto-b');
13379
- return originalFetch(proxyUrl, init);
13361
+ let parsed = null;
13362
+ try {
13363
+ parsed = new URL(url, location.origin);
13380
13364
  }
13381
- if (isTileRequest && url.includes('tiles-c.basemaps.cartocdn.com')) {
13382
- const proxyUrl = url.replace('https://tiles-c.basemaps.cartocdn.com', '/tiles/carto-c');
13383
- return originalFetch(proxyUrl, init);
13365
+ catch {
13366
+ parsed = null;
13384
13367
  }
13385
- if (isTileRequest && url.includes('tiles-d.basemaps.cartocdn.com')) {
13386
- const proxyUrl = url.replace('https://tiles-d.basemaps.cartocdn.com', '/tiles/carto-d');
13387
- return originalFetch(proxyUrl, init);
13368
+ const hostname = parsed?.hostname ?? '';
13369
+ const pathAndQuery = parsed ? parsed.pathname + parsed.search : '';
13370
+ // Proxy Carto tile requests (tiles and TileJSON)
13371
+ const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(parsed?.pathname ?? '');
13372
+ const isTileJsonRequest = (parsed?.pathname.endsWith('.json') ?? false) &&
13373
+ (hostname === 'basemaps.cartocdn.com' || hostname.endsWith('.basemaps.cartocdn.com'));
13374
+ const cartoSubdomainProxy = {
13375
+ 'tiles-a.basemaps.cartocdn.com': '/tiles/carto-a',
13376
+ 'tiles-b.basemaps.cartocdn.com': '/tiles/carto-b',
13377
+ 'tiles-c.basemaps.cartocdn.com': '/tiles/carto-c',
13378
+ 'tiles-d.basemaps.cartocdn.com': '/tiles/carto-d',
13379
+ };
13380
+ if (isTileRequest && cartoSubdomainProxy[hostname]) {
13381
+ return originalFetch(cartoSubdomainProxy[hostname] + pathAndQuery, init);
13388
13382
  }
13389
13383
  // Proxy TileJSON requests from tiles.basemaps.cartocdn.com
13390
- if (isTileJsonRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13391
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/carto-api');
13392
- return originalFetch(proxyUrl, init);
13384
+ if (isTileJsonRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13385
+ return originalFetch('/carto-api' + pathAndQuery, init);
13393
13386
  }
13394
13387
  // Fallback for old format (tiles without subdomain)
13395
- if (isTileRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13396
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/tiles/carto-a');
13397
- return originalFetch(proxyUrl, init);
13388
+ if (isTileRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13389
+ return originalFetch('/tiles/carto-a' + pathAndQuery, init);
13398
13390
  }
13399
13391
  // Proxy OpenStreetMap tile requests
13400
- if (url.includes('tile.openstreetmap.org')) {
13401
- const proxyUrl = url.replace('https://tile.openstreetmap.org', '/tiles/osm');
13402
- return originalFetch(proxyUrl, init);
13392
+ if (hostname === 'tile.openstreetmap.org') {
13393
+ return originalFetch('/tiles/osm' + pathAndQuery, init);
13403
13394
  }
13404
13395
  }
13405
13396
  return originalFetch(input, init);
@@ -13832,6 +13823,9 @@ class OfflineManagerControl {
13832
13823
  if (patchedStyle.imports) {
13833
13824
  delete patchedStyle.imports;
13834
13825
  }
13826
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
13827
+ // downloaded before resolveImports learned to rewrite them.
13828
+ sanitizeIndoorExpressions(patchedStyle);
13835
13829
  // If using Service Worker (Mapbox GL JS), convert idb:// to /__offline__/ URLs
13836
13830
  if (this.useServiceWorker) {
13837
13831
  if (this.swReadyPromise) {
@@ -14025,6 +14019,7 @@ exports.cleanupService = cleanupService;
14025
14019
  exports.clearAllCaches = clearAllCaches;
14026
14020
  exports.configureLogger = configureLogger;
14027
14021
  exports.configureProxy = configureProxy;
14022
+ exports.configureSqlJs = configureSqlJs;
14028
14023
  exports.convertStyleForServiceWorker = convertStyleForServiceWorker;
14029
14024
  exports.countCompressedTiles = countCompressedTiles;
14030
14025
  exports.createProgressTracker = createProgressTracker;
@@ -14047,6 +14042,7 @@ exports.escapeHtml = escapeHtml$1;
14047
14042
  exports.extractAccessToken = extractAccessToken;
14048
14043
  exports.extractAllFontNames = extractAllFontNames;
14049
14044
  exports.extractFontNamesFromTextField = extractFontNamesFromTextField;
14045
+ exports.extractTileExtensionFromUrl = extractTileExtensionFromUrl;
14050
14046
  exports.fetchResourceWithRetry = fetchResourceWithRetry;
14051
14047
  exports.fetchWithRetry = fetchWithRetry;
14052
14048
  exports.fontService = fontService;
@@ -14064,15 +14060,19 @@ exports.getModelStats = getModelStats;
14064
14060
  exports.getRegionAnalytics = getRegionAnalytics;
14065
14061
  exports.getSpriteAnalytics = getSpriteAnalytics;
14066
14062
  exports.getSpriteStats = getSpriteStats;
14063
+ exports.getSqlJs = getSqlJs;
14067
14064
  exports.getStyleStats = getStyleStats;
14068
14065
  exports.getTileAnalytics = getTileAnalytics;
14069
14066
  exports.getTileStats = getTileStats;
14067
+ exports.getUrlHostname = getUrlHostname;
14070
14068
  exports.getUserErrorMessage = getUserErrorMessage;
14071
14069
  exports.glyphService = glyphService;
14072
14070
  exports.hasImports = hasImports;
14071
+ exports.hostMatches = hostMatches;
14073
14072
  exports.i18n = i18n;
14074
14073
  exports.icons = icons;
14075
14074
  exports.idbFetchHandler = idbFetchHandler;
14075
+ exports.isMapboxHost = isMapboxHost;
14076
14076
  exports.isMapboxProtocol = isMapboxProtocol;
14077
14077
  exports.isStyleDownloaded = isStyleDownloaded;
14078
14078
  exports.loadAllStoredRegions = loadAllStoredRegions;
@@ -14098,6 +14098,7 @@ exports.resolveMapboxUrl = resolveMapboxUrl;
14098
14098
  exports.resourceKeyBelongsToStyle = resourceKeyBelongsToStyle;
14099
14099
  exports.rewriteMapboxCdnTileUrl = rewriteMapboxCdnTileUrl;
14100
14100
  exports.safeExecute = safeExecute;
14101
+ exports.sanitizeIndoorExpressions = sanitizeIndoorExpressions;
14101
14102
  exports.setupAutoCleanup = setupAutoCleanup;
14102
14103
  exports.spriteService = spriteService;
14103
14104
  exports.stopAutoCleanup = stopAutoCleanup;