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