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.esm.js CHANGED
@@ -1167,6 +1167,22 @@ function parseTileKey(key) {
1167
1167
  }
1168
1168
  return { styleId, sourceId, z, x, y, ext };
1169
1169
  }
1170
+ /**
1171
+ * Extract the extension (the last dotted segment before `?`, `#`, or end) from
1172
+ * a tile URL or tile URL template. Defaults to `"pbf"` when no extension can
1173
+ * be parsed. For multi-extension URLs like Mapbox v4's `{y}.vector.pbf` this
1174
+ * returns `"pbf"`, matching the key used when the tile is stored.
1175
+ *
1176
+ * Keeping extraction logic in one place ensures patchStyleForOffline (which
1177
+ * rewrites tile URLs to `idb://` at load time) derives the same extension
1178
+ * that tileService.extractExtension used at store time — otherwise the
1179
+ * first-try lookup in idbFetchHandler misses and has to fall through its
1180
+ * pbf/mvt/png/jpg/webp fallback loop.
1181
+ */
1182
+ function extractTileExtensionFromUrl(url) {
1183
+ const match = url.match(/\.([\w]+)(?:[?#]|$)/i);
1184
+ return match ? match[1] : 'pbf';
1185
+ }
1170
1186
  /**
1171
1187
  * Derive tile extension from tile URL templates
1172
1188
  */
@@ -1174,15 +1190,210 @@ function deriveTileExtension(tiles) {
1174
1190
  if (Array.isArray(tiles) && tiles.length > 0) {
1175
1191
  const firstTile = tiles[0];
1176
1192
  if (typeof firstTile === 'string') {
1177
- const match = firstTile.match(/\.([\w]+)(?:\?|$)/i);
1178
- if (match) {
1179
- return match[1];
1180
- }
1193
+ return extractTileExtensionFromUrl(firstTile);
1181
1194
  }
1182
1195
  }
1183
1196
  return 'pbf';
1184
1197
  }
1185
1198
 
1199
+ /**
1200
+ * Pure helpers shared between the main-thread offline fetch handler
1201
+ * (`src/utils/idbFetchHandler.ts`) and the offline Service Worker
1202
+ * (`src/sw/offline-sw.ts`, compiled to `public/idb-offline-sw.js`).
1203
+ *
1204
+ * Keeping these in one place means the SW and the main-thread handler
1205
+ * can't drift — adding a new `model` handler, changing the fallback
1206
+ * order, or tweaking the tilejson-source matcher happens once.
1207
+ *
1208
+ * Nothing in here touches IndexedDB directly. Each helper takes already-
1209
+ * resolved inputs and returns the list of candidate keys (or the
1210
+ * resolved output) that the caller feeds into its own IDB lookup.
1211
+ *
1212
+ * The corresponding IDB access layer is:
1213
+ * - main thread: `idb` library via `dbPromise`
1214
+ * - service worker: raw `indexedDB.open` (see `offline-sw.ts`)
1215
+ *
1216
+ * They have different shapes so cannot be shared; the key computation
1217
+ * can be and is.
1218
+ */
1219
+ /**
1220
+ * Extensions to try in order when the requested extension misses. `glb` is
1221
+ * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
1222
+ * their source URL template ended in `.vector` or similar and the actual
1223
+ * tile body was stored as glb.
1224
+ */
1225
+ const TILE_FALLBACK_EXTENSIONS = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'];
1226
+ /** Extensions minus the one the caller already tried. */
1227
+ function tileFallbackExtensions(requested) {
1228
+ return TILE_FALLBACK_EXTENSIONS.filter(e => e !== requested);
1229
+ }
1230
+ // ---------------------------------------------------------------------------
1231
+ // Region → style lookup
1232
+ // ---------------------------------------------------------------------------
1233
+ /**
1234
+ * Given an already-fetched list of style entries, find the first one whose
1235
+ * `regions` array contains the given ID. Pure — the caller is responsible for
1236
+ * loading the entries and for caching. Used by both `findStyleByRegionId`
1237
+ * implementations to keep the match rule identical.
1238
+ */
1239
+ function findStyleByRegionIdIn(styles, regionId) {
1240
+ for (const entry of styles) {
1241
+ const regions = entry.regions;
1242
+ if (!Array.isArray(regions))
1243
+ continue;
1244
+ for (const r of regions) {
1245
+ if (r?.regionId === regionId || r?.id === regionId) {
1246
+ return entry;
1247
+ }
1248
+ }
1249
+ }
1250
+ return null;
1251
+ }
1252
+ // ---------------------------------------------------------------------------
1253
+ // Glyph candidate keys
1254
+ // ---------------------------------------------------------------------------
1255
+ /**
1256
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1257
+ * requests a comma-joined font-family fallback chain; each glyph is stored
1258
+ * individually, so the caller tries each fontstack in order.
1259
+ */
1260
+ function parseGlyphPath(decodedPath) {
1261
+ const pathParts = decodedPath.split('/');
1262
+ const fontstackPart = pathParts[0] ?? '';
1263
+ const rangePart = pathParts[1] || '0-255.pbf';
1264
+ const fontstacks = fontstackPart
1265
+ .split(',')
1266
+ .map(f => f.trim())
1267
+ .filter(Boolean);
1268
+ return { fontstacks, rangePart };
1269
+ }
1270
+ /**
1271
+ * Build the list of keys to try for a single (fontstack, range) pair.
1272
+ * Order: actualStyleId variants first (most common), then downloadId,
1273
+ * then the bare path. Normalized and raw `.pbf`-less forms are both tried
1274
+ * to cover stored-key variants from older versions.
1275
+ */
1276
+ function glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart) {
1277
+ const glyphPath = `${fontstack}/${rangePart}`;
1278
+ const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1279
+ return dedupe([
1280
+ `${actualStyleId}::${normalizedPath}`,
1281
+ `${actualStyleId}::${glyphPath}`,
1282
+ `${downloadId}::${normalizedPath}`,
1283
+ `${downloadId}::${glyphPath}`,
1284
+ normalizedPath,
1285
+ glyphPath,
1286
+ ]);
1287
+ }
1288
+ // ---------------------------------------------------------------------------
1289
+ // Sprite candidate keys
1290
+ // ---------------------------------------------------------------------------
1291
+ /**
1292
+ * Sprite keys have historically used both `::` and `:` as the separator, and
1293
+ * both the full filename (`sprite.json`) and the bare name (`sprite`). Return
1294
+ * every variant in priority order; the caller stops at the first hit.
1295
+ */
1296
+ function spriteCandidateKeys(actualStyleId, downloadId, decodedPath) {
1297
+ const stripExt = decodedPath.replace(/\.(json|png)$/i, '');
1298
+ return dedupe([
1299
+ `${actualStyleId}::${decodedPath}`,
1300
+ `${actualStyleId}:${decodedPath}`,
1301
+ `${actualStyleId}::${stripExt}`,
1302
+ `${actualStyleId}:${stripExt}`,
1303
+ `${downloadId}::${decodedPath}`,
1304
+ `${downloadId}:${decodedPath}`,
1305
+ `${downloadId}::${stripExt}`,
1306
+ `${downloadId}:${stripExt}`,
1307
+ decodedPath,
1308
+ ]);
1309
+ }
1310
+ // ---------------------------------------------------------------------------
1311
+ // Model candidate keys
1312
+ // ---------------------------------------------------------------------------
1313
+ /**
1314
+ * Model keys are `{styleId}::model::{name}`. Try the resolved style id first,
1315
+ * then the bare downloadId in case the request came through the region-scoped
1316
+ * URL form (`idb://{regionId}/model/{name}`).
1317
+ */
1318
+ function modelCandidateKeys(actualStyleId, downloadId, decodedPath) {
1319
+ return dedupe([
1320
+ `${actualStyleId}::model::${decodedPath}`,
1321
+ `${downloadId}::model::${decodedPath}`,
1322
+ ]);
1323
+ }
1324
+ // ---------------------------------------------------------------------------
1325
+ // TileJSON source matching
1326
+ // ---------------------------------------------------------------------------
1327
+ /**
1328
+ * Mapbox GL requests tilejson via `idb://{downloadId}/tilesjson/{path}` where
1329
+ * `{path}` may be the source id, the original TileJSON URL, or the URL we
1330
+ * stashed under `__originalTilesetUrl` when patching for offline. Try all
1331
+ * three; return the matching source id + its config, or null.
1332
+ */
1333
+ function matchTileJsonSource(sources, decodedPath) {
1334
+ const asConfig = (v) => v && typeof v === 'object' ? v : null;
1335
+ if (decodedPath in sources) {
1336
+ const config = asConfig(sources[decodedPath]);
1337
+ if (config)
1338
+ return { sourceId: decodedPath, config };
1339
+ }
1340
+ for (const [sourceId, raw] of Object.entries(sources)) {
1341
+ const config = asConfig(raw);
1342
+ if (!config)
1343
+ continue;
1344
+ const url = typeof config.url === 'string' ? config.url : undefined;
1345
+ const original = typeof config.__originalTilesetUrl === 'string'
1346
+ ? config.__originalTilesetUrl
1347
+ : undefined;
1348
+ if (url === decodedPath || original === decodedPath) {
1349
+ return { sourceId, config };
1350
+ }
1351
+ }
1352
+ return null;
1353
+ }
1354
+ /**
1355
+ * Build the offline TileJSON payload that replaces the one Mapbox would
1356
+ * have fetched from the network. `tiles` is rewritten to serve from the SW
1357
+ * (the caller supplies the scheme via `tileUrlScheme`); copyable TileJSON
1358
+ * fields are preserved.
1359
+ */
1360
+ function buildOfflineTileJson(sourceConfig, downloadId, sourceId, extension, tileUrlScheme, origin) {
1361
+ const base = `idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`
1362
+ ;
1363
+ const tileJson = {
1364
+ tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1365
+ name: sourceConfig.name ?? sourceId,
1366
+ tiles: [base],
1367
+ minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1368
+ maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1369
+ };
1370
+ const copyable = [
1371
+ 'bounds',
1372
+ 'center',
1373
+ 'vector_layers',
1374
+ 'scheme',
1375
+ 'attribution',
1376
+ 'encoding',
1377
+ 'format',
1378
+ 'grids',
1379
+ 'data',
1380
+ 'template',
1381
+ 'version',
1382
+ ];
1383
+ for (const field of copyable) {
1384
+ if (field in sourceConfig && sourceConfig[field] !== undefined) {
1385
+ tileJson[field] = sourceConfig[field];
1386
+ }
1387
+ }
1388
+ return tileJson;
1389
+ }
1390
+ // ---------------------------------------------------------------------------
1391
+ // Internal helpers
1392
+ // ---------------------------------------------------------------------------
1393
+ function dedupe(values) {
1394
+ return Array.from(new Set(values));
1395
+ }
1396
+
1186
1397
  // idbFetchHandler.ts
1187
1398
  // Intercepts idb:// URLs and serves resources from IndexedDB for MapLibre GL offline mode
1188
1399
  const idbLogger = logger.scope('IDBFetch');
@@ -1239,16 +1450,11 @@ async function findStyleByRegionId(db, regionId) {
1239
1450
  }
1240
1451
  try {
1241
1452
  const allStyles = await db.getAll('styles');
1242
- for (const styleEntry of allStyles) {
1243
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
1244
- const hasRegion = styleEntry.regions.some((r) => r.regionId === regionId || r.id === regionId);
1245
- if (hasRegion) {
1246
- idbLogger.debug(`Found style "${styleEntry.key}" containing region: ${regionId}`);
1247
- // Cache the result
1248
- regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
1249
- return styleEntry;
1250
- }
1251
- }
1453
+ const hit = findStyleByRegionIdIn(allStyles, regionId);
1454
+ if (hit) {
1455
+ idbLogger.debug(`Found style "${hit.key}" containing region: ${regionId}`);
1456
+ regionToStyleCache.set(regionId, { styleEntry: hit, timestamp: Date.now() });
1457
+ return hit;
1252
1458
  }
1253
1459
  idbLogger.debug(`No style found containing region: ${regionId}`);
1254
1460
  // Don't cache negative results — the region may be stored moments later
@@ -1260,36 +1466,6 @@ async function findStyleByRegionId(db, regionId) {
1260
1466
  return null;
1261
1467
  }
1262
1468
  }
1263
- function buildOfflineTileJson(sourceConfig, downloadId, sourceId) {
1264
- const extension = deriveTileExtension(sourceConfig.tiles);
1265
- const offlineTiles = [`idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`];
1266
- const tileJson = {
1267
- tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1268
- name: sourceConfig.name ?? sourceId,
1269
- tiles: offlineTiles,
1270
- minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1271
- maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1272
- };
1273
- const fieldsToCopy = [
1274
- 'bounds',
1275
- 'center',
1276
- 'vector_layers',
1277
- 'scheme',
1278
- 'attribution',
1279
- 'encoding',
1280
- 'format',
1281
- 'grids',
1282
- 'data',
1283
- 'template',
1284
- 'version',
1285
- ];
1286
- for (const field of fieldsToCopy) {
1287
- if (field in sourceConfig && sourceConfig[field] !== undefined) {
1288
- tileJson[field] = sourceConfig[field];
1289
- }
1290
- }
1291
- return tileJson;
1292
- }
1293
1469
  async function createTileResponse(resource) {
1294
1470
  const headers = {};
1295
1471
  // Set proper content type for vector tiles (PBF/MVT format)
@@ -1384,7 +1560,7 @@ async function idbFetchHandler(url, init) {
1384
1560
  // but tiles are stored with integer zoom levels, so floor the value
1385
1561
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1386
1562
  const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1387
- const yMatch = yExt.match(/(\d+)\.(\w+)/);
1563
+ const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1388
1564
  if (yMatch) {
1389
1565
  const y = parseInt(yMatch[1]);
1390
1566
  const requestedExt = yMatch[2]; // Extension from URL (for logging only)
@@ -1399,7 +1575,7 @@ async function idbFetchHandler(url, init) {
1399
1575
  }
1400
1576
  idbLogger.debug(`Tile not found: ${tileKey}`);
1401
1577
  // Fallback: try common alternative extensions
1402
- const fallbackExtensions = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'].filter(ext => ext !== requestedExt);
1578
+ const fallbackExtensions = tileFallbackExtensions(requestedExt);
1403
1579
  for (const fallbackExt of fallbackExtensions) {
1404
1580
  const fallbackKey = createTileKey(x, y, z, actualStyleId, sourceKey, fallbackExt);
1405
1581
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1441,7 +1617,7 @@ async function idbFetchHandler(url, init) {
1441
1617
  return await createTileResponse(resource);
1442
1618
  }
1443
1619
  // Try alternative extensions
1444
- const fallbackExts = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter(e => e !== ext);
1620
+ const fallbackExts = tileFallbackExtensions(ext);
1445
1621
  for (const fallbackExt of fallbackExts) {
1446
1622
  const fallbackKey = createTileKey(parseInt(x), parseInt(y), parseInt(z), actualStyleId, fallbackSourceKey, fallbackExt);
1447
1623
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1461,46 +1637,19 @@ async function idbFetchHandler(url, init) {
1461
1637
  }
1462
1638
  case 'glyph': {
1463
1639
  idbLogger.debug(`Looking for glyph with key: ${key}`);
1464
- // Find which style this region belongs to
1465
1640
  const styleEntry = await findStyleByRegionId(db, downloadId);
1466
1641
  const actualStyleId = styleEntry?.key || downloadId;
1467
- if (styleEntry && downloadId !== actualStyleId) {
1468
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1469
- }
1470
- // Parse the resource path: "FontA,FontB,FontC/0-255.pbf"
1471
- // MapLibre requests glyphs with comma-separated fallback fonts
1472
- // but glyphs are stored individually per font
1473
- const pathParts = decodedResourcePath.split('/');
1474
- const fontstackPart = pathParts[0]; // "FontA,FontB,FontC"
1475
- const rangePart = pathParts[1] || '0-255.pbf'; // "0-255.pbf"
1476
- // Split comma-separated fonts
1477
- const fontstacks = fontstackPart.split(',').map(f => f.trim());
1642
+ const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1478
1643
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1479
- // Try each font in order (this is how font fallbacks work)
1480
1644
  for (const fontstack of fontstacks) {
1481
- const glyphPath = `${fontstack}/${rangePart}`;
1482
- const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1483
- const glyphCandidateKeys = [
1484
- // Try with actual style ID first
1485
- `${actualStyleId}::${normalizedPath}`,
1486
- `${actualStyleId}::${glyphPath}`,
1487
- // Then try with download ID
1488
- `${downloadId}::${normalizedPath}`,
1489
- `${downloadId}::${glyphPath}`,
1490
- // Just paths
1491
- normalizedPath,
1492
- glyphPath,
1493
- ];
1494
- idbLogger.debug(`Trying keys for font "${fontstack}":`, glyphCandidateKeys);
1495
- for (const candidateKey of glyphCandidateKeys) {
1645
+ const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1646
+ for (const candidateKey of candidateKeys) {
1496
1647
  const resource = await db.get('glyphs', candidateKey);
1497
1648
  if (resource?.data) {
1498
1649
  idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1499
1650
  return new Response(resource.data, {
1500
1651
  status: 200,
1501
- headers: {
1502
- 'Content-Type': 'application/x-protobuf',
1503
- },
1652
+ headers: { 'Content-Type': 'application/x-protobuf' },
1504
1653
  });
1505
1654
  }
1506
1655
  }
@@ -1510,33 +1659,11 @@ async function idbFetchHandler(url, init) {
1510
1659
  }
1511
1660
  case 'sprite': {
1512
1661
  idbLogger.debug(`Looking for sprite with key: ${key}`);
1513
- // Find which style this region belongs to
1514
1662
  const styleEntry = await findStyleByRegionId(db, downloadId);
1515
1663
  const actualStyleId = styleEntry?.key || downloadId;
1516
- if (styleEntry && downloadId !== actualStyleId) {
1517
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1518
- }
1519
- // The sprite service stores sprites with keys like: "voyager::sprite.json", "voyager::sprite@2x.json"
1520
- // MapLibre requests sprites as: "idb://region_XXX/sprite/sprite@2x.json"
1521
- // So we need to map the region ID to the style ID
1522
- const spriteCandidateKeys = Array.from(new Set([
1523
- // Try with actual style ID first (most likely to work)
1524
- `${actualStyleId}::${decodedResourcePath}`,
1525
- `${actualStyleId}:${decodedResourcePath}`,
1526
- `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1527
- `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1528
- // Then try with download ID (in case it's a direct style download)
1529
- `${downloadId}::${decodedResourcePath}`,
1530
- `${downloadId}:${decodedResourcePath}`,
1531
- `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1532
- `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1533
- // Just the path itself
1534
- decodedResourcePath,
1535
- // Original key format
1536
- key,
1537
- ]));
1538
- idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, spriteCandidateKeys);
1539
- for (const candidateKey of spriteCandidateKeys) {
1664
+ const candidates = spriteCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1665
+ idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, candidates);
1666
+ for (const candidateKey of candidates) {
1540
1667
  const resource = await db.get('sprites', candidateKey);
1541
1668
  if (resource?.data) {
1542
1669
  idbLogger.debug(`Found sprite using key: ${candidateKey}`);
@@ -1546,7 +1673,7 @@ async function idbFetchHandler(url, init) {
1546
1673
  });
1547
1674
  }
1548
1675
  }
1549
- idbLogger.warn(`Sprite not found, tried keys: ${spriteCandidateKeys.join(', ')}`);
1676
+ idbLogger.warn(`Sprite not found, tried keys: ${candidates.join(', ')}`);
1550
1677
  break;
1551
1678
  }
1552
1679
  case 'font': {
@@ -1562,18 +1689,12 @@ async function idbFetchHandler(url, init) {
1562
1689
  break;
1563
1690
  }
1564
1691
  case 'model': {
1565
- // Model URLs are rewritten by patchStyleForOffline to:
1692
+ // Model URLs are rewritten by patchStyleForOffline to
1566
1693
  // idb://{styleId}/model/{modelName}
1567
1694
  // Models are keyed by {styleId}::model::{modelName} in the store.
1568
- // Mirror the sprite resolution fallback: try the style ID first,
1569
- // then the download/region ID (in case the request came through a
1570
- // region-scoped URL).
1571
1695
  const styleEntry = await findStyleByRegionId(db, downloadId);
1572
1696
  const actualStyleId = styleEntry?.key || downloadId;
1573
- const candidates = Array.from(new Set([
1574
- `${actualStyleId}::model::${decodedResourcePath}`,
1575
- `${downloadId}::model::${decodedResourcePath}`,
1576
- ]));
1697
+ const candidates = modelCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1577
1698
  idbLogger.debug(`Model candidates for "${decodedResourcePath}":`, candidates);
1578
1699
  for (const candidateKey of candidates) {
1579
1700
  const resource = await db.get('models', candidateKey);
@@ -1590,9 +1711,9 @@ async function idbFetchHandler(url, init) {
1590
1711
  }
1591
1712
  case 'tilesjson': {
1592
1713
  idbLogger.debug(`Looking for tilejson with downloadId: ${downloadId}, resourcePath: ${decodedResourcePath}`);
1593
- // First try direct lookup (for style-level downloads)
1714
+ // First try direct lookup (for style-level downloads), then fall back
1715
+ // to searching by region ID (for region-level downloads).
1594
1716
  let styleEntry = await db.get('styles', downloadId);
1595
- // If not found, search by region ID (for region-level downloads)
1596
1717
  if (!styleEntry || !styleEntry.style?.sources) {
1597
1718
  idbLogger.debug(`Style not found with key "${downloadId}", searching by region ID...`);
1598
1719
  const foundStyle = await findStyleByRegionId(db, downloadId);
@@ -1600,41 +1721,23 @@ async function idbFetchHandler(url, init) {
1600
1721
  styleEntry = foundStyle;
1601
1722
  }
1602
1723
  }
1603
- if (styleEntry?.style?.sources) {
1604
- const sources = styleEntry.style.sources;
1605
- let matchedSourceId;
1606
- let matchedSourceConfig;
1607
- if (decodedResourcePath in sources) {
1608
- matchedSourceId = decodedResourcePath;
1609
- matchedSourceConfig = sources[decodedResourcePath];
1610
- }
1611
- else {
1612
- for (const [sourceId, sourceValue] of Object.entries(sources)) {
1613
- const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
1614
- const originalUrl = typeof sourceValue.__originalTilesetUrl === 'string'
1615
- ? sourceValue.__originalTilesetUrl
1616
- : undefined;
1617
- if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
1618
- matchedSourceId = sourceId;
1619
- matchedSourceConfig = sourceValue;
1620
- break;
1621
- }
1622
- }
1623
- }
1624
- if (matchedSourceId && matchedSourceConfig) {
1625
- const tileJson = buildOfflineTileJson(matchedSourceConfig, downloadId, matchedSourceId);
1626
- idbLogger.debug(`Serving offline tilejson for source: ${matchedSourceId}`);
1627
- return new Response(JSON.stringify(tileJson), {
1628
- status: 200,
1629
- headers: { 'Content-Type': 'application/json' },
1630
- });
1631
- }
1632
- idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1633
- }
1634
- else {
1724
+ if (!styleEntry?.style?.sources) {
1635
1725
  idbLogger.warn(`Style not found or missing sources for downloadId: ${downloadId}`);
1726
+ break;
1636
1727
  }
1637
- break;
1728
+ const sources = styleEntry.style.sources;
1729
+ const matched = matchTileJsonSource(sources, decodedResourcePath);
1730
+ if (!matched) {
1731
+ idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1732
+ break;
1733
+ }
1734
+ const extension = deriveTileExtension(matched.config.tiles);
1735
+ const tileJson = buildOfflineTileJson(matched.config, downloadId, matched.sourceId, extension, 'idb');
1736
+ idbLogger.debug(`Serving offline tilejson for source: ${matched.sourceId}`);
1737
+ return new Response(JSON.stringify(tileJson), {
1738
+ status: 200,
1739
+ headers: { 'Content-Type': 'application/json' },
1740
+ });
1638
1741
  }
1639
1742
  default:
1640
1743
  idbLogger.warn(`Unknown resource type: ${type}`);
@@ -1658,6 +1761,37 @@ async function idbFetchHandler(url, init) {
1658
1761
  function isMapboxProtocol(url) {
1659
1762
  return url.startsWith(MAPBOX_API.PROTOCOL);
1660
1763
  }
1764
+ /**
1765
+ * Parse a URL and return its hostname, or null if the URL is malformed.
1766
+ * Accepts relative URLs when `base` is provided.
1767
+ */
1768
+ function getUrlHostname(url, base) {
1769
+ try {
1770
+ return new URL(url, base).hostname.toLowerCase();
1771
+ }
1772
+ catch {
1773
+ return null;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * True if `url`'s hostname equals `host` or is a subdomain of `host`.
1778
+ * Uses URL parsing (not substring matching) to avoid false positives like
1779
+ * `https://evil.com/?x=mapbox.com` matching `mapbox.com`.
1780
+ */
1781
+ function hostMatches(url, host, base) {
1782
+ const hostname = getUrlHostname(url, base);
1783
+ if (hostname === null)
1784
+ return false;
1785
+ const target = host.toLowerCase();
1786
+ return hostname === target || hostname.endsWith('.' + target);
1787
+ }
1788
+ /**
1789
+ * True for any host under the mapbox.com domain (including api.mapbox.com,
1790
+ * *.tiles.mapbox.com, etc.). Used by provider detection.
1791
+ */
1792
+ function isMapboxHost(url, base) {
1793
+ return hostMatches(url, 'mapbox.com', base);
1794
+ }
1661
1795
  /**
1662
1796
  * Resolve a mapbox:// URL to its HTTPS API equivalent
1663
1797
  *
@@ -1731,9 +1865,7 @@ function rewriteMapboxCdnTileUrl(tileUrl) {
1731
1865
  */
1732
1866
  function detectStyleProvider(styleUrl, style) {
1733
1867
  // Check URL patterns
1734
- if (isMapboxProtocol(styleUrl) ||
1735
- styleUrl.includes('mapbox.com') ||
1736
- styleUrl.includes('api.mapbox.com')) {
1868
+ if (isMapboxProtocol(styleUrl) || isMapboxHost(styleUrl)) {
1737
1869
  return 'mapbox';
1738
1870
  }
1739
1871
  if (styleUrl.includes('maplibre') ||
@@ -1752,7 +1884,7 @@ function detectStyleProvider(styleUrl, style) {
1752
1884
  const sources = style.sources || {};
1753
1885
  for (const [, sourceConfig] of Object.entries(sources)) {
1754
1886
  const source = sourceConfig;
1755
- if (source.url && (source.url.includes('mapbox.com') || isMapboxProtocol(source.url))) {
1887
+ if (source.url && (isMapboxProtocol(source.url) || isMapboxHost(source.url))) {
1756
1888
  return 'mapbox';
1757
1889
  }
1758
1890
  }
@@ -1844,7 +1976,7 @@ function processStyleSources(style, provider, accessToken) {
1844
1976
  if (isMapboxProtocol(tileUrl) && accessToken) {
1845
1977
  return resolveMapboxUrl(tileUrl, accessToken);
1846
1978
  }
1847
- if (provider === 'mapbox' && accessToken && tileUrl.includes('mapbox.com')) {
1979
+ if (provider === 'mapbox' && accessToken && isMapboxHost(tileUrl)) {
1848
1980
  return normalizeStyleUrl(tileUrl, accessToken);
1849
1981
  }
1850
1982
  return tileUrl;
@@ -1859,7 +1991,7 @@ function processStyleSources(style, provider, accessToken) {
1859
1991
  if (isMapboxProtocol(processedStyle.sprite)) {
1860
1992
  processedStyle.sprite = resolveMapboxUrl(processedStyle.sprite, accessToken);
1861
1993
  }
1862
- else if (provider === 'mapbox' && processedStyle.sprite.includes('mapbox.com')) {
1994
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.sprite)) {
1863
1995
  processedStyle.sprite = normalizeStyleUrl(processedStyle.sprite, accessToken);
1864
1996
  }
1865
1997
  }
@@ -1870,7 +2002,7 @@ function processStyleSources(style, provider, accessToken) {
1870
2002
  if (isMapboxProtocol(entry.url)) {
1871
2003
  return { ...entry, url: resolveMapboxUrl(entry.url, accessToken) };
1872
2004
  }
1873
- else if (provider === 'mapbox' && entry.url.includes('mapbox.com')) {
2005
+ else if (provider === 'mapbox' && isMapboxHost(entry.url)) {
1874
2006
  return { ...entry, url: normalizeStyleUrl(entry.url, accessToken) };
1875
2007
  }
1876
2008
  }
@@ -1883,7 +2015,7 @@ function processStyleSources(style, provider, accessToken) {
1883
2015
  if (isMapboxProtocol(processedStyle.glyphs)) {
1884
2016
  processedStyle.glyphs = resolveMapboxUrl(processedStyle.glyphs, accessToken);
1885
2017
  }
1886
- else if (provider === 'mapbox' && processedStyle.glyphs.includes('mapbox.com')) {
2018
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.glyphs)) {
1887
2019
  processedStyle.glyphs = normalizeStyleUrl(processedStyle.glyphs, accessToken);
1888
2020
  }
1889
2021
  }
@@ -1923,13 +2055,20 @@ function validateStyleForProvider(style, provider) {
1923
2055
  // Check for Mapbox-specific requirements
1924
2056
  const hasMapboxSources = Object.values(style.sources || {}).some((source) => {
1925
2057
  const s = source;
1926
- return s.url && s.url.includes('mapbox.com');
2058
+ return !!s.url && isMapboxHost(s.url);
1927
2059
  });
1928
2060
  if (hasMapboxSources) {
1929
2061
  // Check if access token might be needed
1930
2062
  const hasAccessToken = Object.values(style.sources || {}).some((source) => {
1931
2063
  const s = source;
1932
- return s.url && s.url.includes('access_token');
2064
+ if (!s.url)
2065
+ return false;
2066
+ try {
2067
+ return new URL(s.url).searchParams.has('access_token');
2068
+ }
2069
+ catch {
2070
+ return false;
2071
+ }
1933
2072
  });
1934
2073
  if (!hasAccessToken) {
1935
2074
  warnings.push('Mapbox sources detected but no access token found - authentication may be required');
@@ -1963,14 +2102,15 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
1963
2102
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
1964
2103
  if (source.tiles) {
1965
2104
  const originalTiles = [...source.tiles];
1966
- // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext
2105
+ // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2106
+ // Extension extraction goes through the shared extractTileExtensionFromUrl
2107
+ // helper so the patched URL's extension matches what tileService used when
2108
+ // storing — otherwise Mapbox v4 tile URLs (`{y}.vector.pbf`) produced a
2109
+ // stored key under `.pbf` but a patched URL with `.vector`, forcing
2110
+ // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2111
+ // on every tile.
1967
2112
  source.tiles = source.tiles.map((url) => {
1968
- // Use stored tileExtension if available, otherwise try to extract from URL
1969
- let ext = tileExtension;
1970
- if (!ext) {
1971
- const extMatch = url.match(/\{z\}\/\{x\}\/\{y\}\.(\w+)/);
1972
- ext = extMatch ? extMatch[1] : 'pbf';
1973
- }
2113
+ const ext = tileExtension ?? extractTileExtensionFromUrl(url);
1974
2114
  return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
1975
2115
  });
1976
2116
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
@@ -2848,10 +2988,8 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2848
2988
  if (typeof prefixedLayer.source === 'string') {
2849
2989
  prefixedLayer.source = `${importId}/${prefixedLayer.source}`;
2850
2990
  }
2851
- // Resolve ["config", "key"] expressions using schema defaults and import overrides
2852
- if (Object.keys(configValues).length > 0) {
2853
- resolveConfigExpressions(prefixedLayer, configValues);
2854
- }
2991
+ // Resolve ["config", "key"] expressions using schema defaults and import overrides.
2992
+ resolveConfigExpressions(prefixedLayer, configValues);
2855
2993
  flattenedLayers.push(prefixedLayer);
2856
2994
  }
2857
2995
  }
@@ -2889,8 +3027,48 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2889
3027
  if (!style.models && importedModels) {
2890
3028
  style.models = importedModels;
2891
3029
  }
3030
+ // Rewrite indoor-only expressions so the flattened style validates without
3031
+ // the `imports` wrapper at render time — see sanitizeIndoorExpressions.
3032
+ sanitizeIndoorExpressions(style);
2892
3033
  return style;
2893
3034
  }
3035
+ /**
3036
+ * Rewrite indoor-only expressions in a style's layers to their outdoor no-op
3037
+ * constants. See the in-line comment in `resolveValue` for why this is needed
3038
+ * for Mapbox Standard when the `imports` wrapper is stripped.
3039
+ *
3040
+ * Safe to call multiple times and on already-downloaded stored styles — the
3041
+ * rewrites are idempotent (after the first pass there are no more
3042
+ * `is-active-floor` / `floor-level` expressions to rewrite).
3043
+ */
3044
+ function sanitizeIndoorExpressions(style) {
3045
+ const layers = style.layers;
3046
+ if (!Array.isArray(layers))
3047
+ return;
3048
+ for (const layer of layers) {
3049
+ if (layer && typeof layer === 'object') {
3050
+ rewriteIndoor(layer);
3051
+ }
3052
+ }
3053
+ }
3054
+ function rewriteIndoor(obj) {
3055
+ for (const key of Object.keys(obj)) {
3056
+ obj[key] = rewriteIndoorValue(obj[key]);
3057
+ }
3058
+ }
3059
+ function rewriteIndoorValue(value) {
3060
+ if (!Array.isArray(value)) {
3061
+ if (value && typeof value === 'object' && !ArrayBuffer.isView(value)) {
3062
+ rewriteIndoor(value);
3063
+ }
3064
+ return value;
3065
+ }
3066
+ if (value[0] === 'is-active-floor')
3067
+ return false;
3068
+ if (value[0] === 'floor-level' && value.length === 1)
3069
+ return 0;
3070
+ return value.map(rewriteIndoorValue);
3071
+ }
2894
3072
  /**
2895
3073
  * Deep clone a plain object/array (JSON-safe values only).
2896
3074
  */
@@ -3056,6 +3234,40 @@ function mergeSprites(outer, imported) {
3056
3234
  return result;
3057
3235
  }
3058
3236
 
3237
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3238
+ let currentConfig = {};
3239
+ let sqlJsPromise = null;
3240
+ /**
3241
+ * Override how `sql.js` loads its WebAssembly. Call once before any MBTiles
3242
+ * import/export is invoked. Resets any cached init.
3243
+ */
3244
+ function configureSqlJs(config) {
3245
+ currentConfig = { ...config };
3246
+ sqlJsPromise = null;
3247
+ }
3248
+ /**
3249
+ * Lazily initialise `sql.js`. The underlying module is loaded via dynamic
3250
+ * `import()` so it only ships with bundles that actually call MBTiles code.
3251
+ */
3252
+ async function getSqlJs() {
3253
+ if (sqlJsPromise)
3254
+ return sqlJsPromise;
3255
+ sqlJsPromise = (async () => {
3256
+ const mod = (await import('sql.js'));
3257
+ const initSqlJs = mod.default;
3258
+ const options = {};
3259
+ if (currentConfig.wasmBinary) {
3260
+ options.wasmBinary = currentConfig.wasmBinary;
3261
+ }
3262
+ else {
3263
+ const base = currentConfig.wasmUrl ?? DEFAULT_WASM_URL;
3264
+ options.locateFile = (file) => base.endsWith('/') ? `${base}${file}` : `${base}/${file}`;
3265
+ }
3266
+ return initSqlJs(options);
3267
+ })();
3268
+ return sqlJsPromise;
3269
+ }
3270
+
3059
3271
  const fontLogger = logger.scope('FontService');
3060
3272
  class FontService {
3061
3273
  db = dbPromise;
@@ -3239,15 +3451,23 @@ class FontService {
3239
3451
  },
3240
3452
  };
3241
3453
  }
3242
- async cleanupOldFonts(maxAge = 30) {
3454
+ /**
3455
+ * Delete fonts older than `maxAge` days. When `options.styleId` is
3456
+ * provided, only fonts belonging to that style (per the delimiter-aware
3457
+ * `resourceKeyBelongsToStyle` match) are eligible — callers relying on
3458
+ * a styleId filter previously got a silent full-store wipe.
3459
+ */
3460
+ async cleanupOldFonts(maxAge = 30, options = {}) {
3243
3461
  const db = await this.db;
3244
3462
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3463
+ const { styleId } = options;
3245
3464
  const tx = db.transaction(['fonts'], 'readwrite');
3246
3465
  let deletedCount = 0;
3247
3466
  let cursor = await tx.objectStore('fonts').openCursor();
3248
3467
  while (cursor) {
3249
3468
  const fontEntry = cursor.value;
3250
- if (fontEntry.lastModified < cutoffTime) {
3469
+ const belongs = !styleId || resourceKeyBelongsToStyle(fontEntry.key, styleId);
3470
+ if (belongs && fontEntry.lastModified < cutoffTime) {
3251
3471
  await cursor.delete();
3252
3472
  deletedCount++;
3253
3473
  }
@@ -3413,7 +3633,7 @@ const fontService = new FontService();
3413
3633
  const downloadFonts = (fontUrls, styleName, options) => fontService.downloadFonts(fontUrls, styleName, options);
3414
3634
  const getFontStats = () => fontService.getFontStats();
3415
3635
  const getFontAnalytics = () => fontService.getFontAnalytics();
3416
- const cleanupOldFonts = (maxAge) => fontService.cleanupOldFonts(maxAge);
3636
+ const cleanupOldFonts = (maxAge, options) => fontService.cleanupOldFonts(maxAge, options);
3417
3637
  const verifyAndRepairFonts = () => fontService.verifyAndRepairFonts();
3418
3638
 
3419
3639
  const spriteLogger = logger.scope('SpriteService');
@@ -3724,19 +3944,24 @@ class SpriteService {
3724
3944
  };
3725
3945
  }
3726
3946
  /**
3727
- * Removes sprites older than the specified age
3947
+ * Remove sprites older than the specified age. When `options.styleId` is
3948
+ * provided, only sprites belonging to that style (per
3949
+ * `resourceKeyBelongsToStyle`) are eligible.
3728
3950
  * @param maxAge - Maximum age in days (default: 30)
3951
+ * @param options.styleId - Optional style filter; omit to scan all styles
3729
3952
  * @returns Promise resolving to number of deleted sprites
3730
3953
  */
3731
- async cleanupOldSprites(maxAge = 30) {
3954
+ async cleanupOldSprites(maxAge = 30, options = {}) {
3732
3955
  const db = await this.db;
3733
3956
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3957
+ const { styleId } = options;
3734
3958
  const tx = db.transaction(['sprites'], 'readwrite');
3735
3959
  let deletedCount = 0;
3736
3960
  let cursor = await tx.objectStore('sprites').openCursor();
3737
3961
  while (cursor) {
3738
3962
  const spriteEntry = cursor.value;
3739
- if (spriteEntry.lastModified < cutoffTime) {
3963
+ const belongs = !styleId || resourceKeyBelongsToStyle(spriteEntry.key, styleId);
3964
+ if (belongs && spriteEntry.lastModified < cutoffTime) {
3740
3965
  await cursor.delete();
3741
3966
  deletedCount++;
3742
3967
  }
@@ -3890,7 +4115,7 @@ const spriteService = new SpriteService();
3890
4115
  const downloadSprites = (spriteUrls, styleName, options) => spriteService.downloadSprites(spriteUrls, styleName, options);
3891
4116
  const getSpriteStats = () => spriteService.getSpriteStats();
3892
4117
  const getSpriteAnalytics = () => spriteService.getSpriteAnalytics();
3893
- const cleanupOldSprites = (maxAge) => spriteService.cleanupOldSprites(maxAge);
4118
+ const cleanupOldSprites = (maxAge, options) => spriteService.cleanupOldSprites(maxAge, options);
3894
4119
  const verifyAndRepairSprites = () => spriteService.verifyAndRepairSprites();
3895
4120
 
3896
4121
  var spriteService$1 = /*#__PURE__*/Object.freeze({
@@ -5161,7 +5386,17 @@ class RegionService {
5161
5386
  // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5162
5387
  // segment is `sprite`, so replacing it with `iconset.pbf` works.
5163
5388
  const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5164
- const isMapboxStandardSprite = /api\.mapbox\.com\/styles\/v1\/.+\/sprite$/.test(pathWithoutQuery);
5389
+ let isMapboxStandardSprite = false;
5390
+ try {
5391
+ const parsed = new URL(pathWithoutQuery);
5392
+ isMapboxStandardSprite =
5393
+ parsed.hostname === 'api.mapbox.com' &&
5394
+ parsed.pathname.startsWith('/styles/v1/') &&
5395
+ parsed.pathname.endsWith('/sprite');
5396
+ }
5397
+ catch {
5398
+ // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5399
+ }
5165
5400
  if (isMapboxStandardSprite) {
5166
5401
  // The path-rewrite suffix replaces the trailing `sprite` segment.
5167
5402
  suffixes.push('__ICONSET__');
@@ -6657,8 +6892,7 @@ class TileService {
6657
6892
  }
6658
6893
  }
6659
6894
  extractExtension(template) {
6660
- const extMatch = template.match(/\.([\w]+)(?:\?|$)/i);
6661
- return extMatch ? extMatch[1] : 'pbf';
6895
+ return extractTileExtensionFromUrl(template);
6662
6896
  }
6663
6897
  selectTileTemplate(templates, coord) {
6664
6898
  if (templates.length === 1) {
@@ -7040,14 +7274,21 @@ class GlyphService {
7040
7274
  },
7041
7275
  };
7042
7276
  }
7043
- async cleanupOldGlyphs(maxAge = 30) {
7277
+ /**
7278
+ * Remove glyphs older than the specified age. When `options.styleId` is
7279
+ * provided, only glyphs belonging to that style (per
7280
+ * `resourceKeyBelongsToStyle`) are eligible.
7281
+ */
7282
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
7044
7283
  const db = await this.db;
7045
7284
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7285
+ const { styleId } = options;
7046
7286
  let deletedCount = 0;
7047
7287
  const tx = db.transaction('glyphs', 'readwrite');
7048
7288
  for await (const cursor of tx.store) {
7049
7289
  const glyphEntry = cursor.value;
7050
- if (glyphEntry.lastModified < cutoffTime) {
7290
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7291
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
7051
7292
  await cursor.delete();
7052
7293
  deletedCount++;
7053
7294
  }
@@ -7150,7 +7391,7 @@ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => gly
7150
7391
  const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7151
7392
  const getGlyphStats = () => glyphService.getGlyphStats();
7152
7393
  const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7153
- const cleanupOldGlyphs = (maxAge) => glyphService.cleanupOldGlyphs(maxAge);
7394
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7154
7395
  const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7155
7396
 
7156
7397
  var glyphService$1 = /*#__PURE__*/Object.freeze({
@@ -7385,8 +7626,7 @@ class ResourceService {
7385
7626
  return getFontAnalytics();
7386
7627
  }
7387
7628
  async cleanupOldFonts(styleId, options) {
7388
- const maxAge = options?.maxAge;
7389
- return cleanupOldFonts(maxAge);
7629
+ return cleanupOldFonts(options?.maxAge, { styleId });
7390
7630
  }
7391
7631
  async verifyAndRepairFonts() {
7392
7632
  return verifyAndRepairFonts();
@@ -7402,8 +7642,7 @@ class ResourceService {
7402
7642
  return getSpriteAnalytics();
7403
7643
  }
7404
7644
  async cleanupOldSprites(styleId, options) {
7405
- const maxAge = options?.maxAge;
7406
- return cleanupOldSprites(maxAge);
7645
+ return cleanupOldSprites(options?.maxAge, { styleId });
7407
7646
  }
7408
7647
  async verifyAndRepairSprites() {
7409
7648
  return verifyAndRepairSprites();
@@ -7422,8 +7661,7 @@ class ResourceService {
7422
7661
  return loadGlyphs(fontstack, ranges, styleId);
7423
7662
  }
7424
7663
  async cleanupOldGlyphs(styleId, options) {
7425
- const maxAge = options?.maxAge;
7426
- return cleanupOldGlyphs(maxAge);
7664
+ return cleanupOldGlyphs(options?.maxAge, { styleId });
7427
7665
  }
7428
7666
  async verifyAndRepairGlyphs() {
7429
7667
  return verifyAndRepairGlyphs();
@@ -7538,6 +7776,88 @@ class AnalyticsService {
7538
7776
  }
7539
7777
  }
7540
7778
 
7779
+ /**
7780
+ * MBTiles uses TMS tile_row ordering; our storage uses XYZ y. Flip across
7781
+ * either direction with the same formula.
7782
+ */
7783
+ function flipY(y, z) {
7784
+ return (1 << z) - 1 - y;
7785
+ }
7786
+ /** Vector tile formats that downstream consumers (QGIS, maplibre-native) expect gzipped. */
7787
+ const VECTOR_FORMATS = new Set(['pbf', 'mvt']);
7788
+ function hasGzipMagic(bytes) {
7789
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
7790
+ }
7791
+ async function drainReadable(readable) {
7792
+ const reader = readable.getReader();
7793
+ const chunks = [];
7794
+ let total = 0;
7795
+ while (true) {
7796
+ const { done, value } = await reader.read();
7797
+ if (done)
7798
+ break;
7799
+ if (value) {
7800
+ chunks.push(value);
7801
+ total += value.byteLength;
7802
+ }
7803
+ }
7804
+ const out = new Uint8Array(total);
7805
+ let offset = 0;
7806
+ for (const chunk of chunks) {
7807
+ out.set(chunk, offset);
7808
+ offset += chunk.byteLength;
7809
+ }
7810
+ return out;
7811
+ }
7812
+ async function transformBytes(bytes, transform) {
7813
+ const writer = transform.writable.getWriter();
7814
+ // Don't await — the read loop below drives the pipe and we only want
7815
+ // the final bytes, not back-pressure handling for a single chunk.
7816
+ void writer.write(bytes);
7817
+ void writer.close();
7818
+ return drainReadable(transform.readable);
7819
+ }
7820
+ async function gzipBytes(bytes) {
7821
+ return transformBytes(bytes, new CompressionStream('gzip'));
7822
+ }
7823
+ async function gunzipBytes(bytes) {
7824
+ return transformBytes(bytes, new DecompressionStream('gzip'));
7825
+ }
7826
+ /**
7827
+ * Build the MBTiles `json` metadata payload. For vector tiles this is
7828
+ * mandatory for tippecanoe/QGIS/maplibre-native to render — they read
7829
+ * `vector_layers` from here.
7830
+ *
7831
+ * `vector_layers` is inferred from the offline style's vector sources
7832
+ * (populated by the TileJSON expansion step in styleService). Multiple
7833
+ * vector sources are merged; duplicates de-duped by id, first wins.
7834
+ */
7835
+ function buildVectorJsonMetadata(style, sourceIds) {
7836
+ if (!style || typeof style !== 'object')
7837
+ return null;
7838
+ const sources = style.sources;
7839
+ if (!sources)
7840
+ return null;
7841
+ const merged = [];
7842
+ const seen = new Set();
7843
+ for (const [id, src] of Object.entries(sources)) {
7844
+ if (sourceIds.size > 0 && !sourceIds.has(id))
7845
+ continue;
7846
+ const layers = src?.vector_layers;
7847
+ if (!Array.isArray(layers))
7848
+ continue;
7849
+ for (const layer of layers) {
7850
+ const layerId = typeof layer?.id === 'string' ? layer.id : null;
7851
+ if (!layerId || seen.has(layerId))
7852
+ continue;
7853
+ seen.add(layerId);
7854
+ merged.push(layer);
7855
+ }
7856
+ }
7857
+ if (merged.length === 0)
7858
+ return null;
7859
+ return JSON.stringify({ vector_layers: merged });
7860
+ }
7541
7861
  const serviceLogger = logger.scope('ImportExportService');
7542
7862
  class ImportExportService {
7543
7863
  db = dbPromise;
@@ -7545,270 +7865,173 @@ class ImportExportService {
7545
7865
  // No need for initialization since dbPromise is already available
7546
7866
  }
7547
7867
  /**
7548
- * Export a region to JSON format
7868
+ * Export region as a real binary MBTiles SQLite file.
7869
+ *
7870
+ * Produces a v1.3-compliant MBTiles archive: `metadata` + `tiles` tables,
7871
+ * with `tile_row` flipped to TMS ordering. The resulting blob can be read
7872
+ * by tippecanoe, QGIS, maplibre-native, etc.
7549
7873
  */
7550
- async exportRegionAsJSON(regionId, options = {}) {
7874
+ async exportRegionAsMBTiles(regionId, options = {}) {
7551
7875
  const onProgress = options.onProgress || (() => { });
7552
7876
  try {
7553
7877
  onProgress({
7554
7878
  stage: 'preparing',
7555
7879
  percentage: 0,
7556
- message: 'Preparing export...',
7880
+ message: 'Preparing MBTiles export...',
7557
7881
  });
7558
- // Get region metadata
7559
7882
  const region = await this.getRegionMetadata(regionId);
7560
7883
  if (!region) {
7561
7884
  throw new Error(`Region ${regionId} not found`);
7562
7885
  }
7886
+ const tiles = await this.exportTiles(regionId, onProgress);
7887
+ // Pick format: caller override → region.tileExtension → default pbf.
7888
+ // Drives both the metadata row and whether tile bytes get gzipped.
7889
+ const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
7890
+ const isVector = VECTOR_FORMATS.has(format);
7563
7891
  onProgress({
7564
- stage: 'exporting',
7565
- percentage: 10,
7566
- message: 'Collecting region data...',
7892
+ stage: 'processing',
7893
+ percentage: 75,
7894
+ message: isVector ? 'Compressing vector tiles...' : 'Packing SQLite database...',
7567
7895
  });
7568
- const exportData = {
7569
- metadata: {
7570
- id: region.id,
7571
- name: region.name || region.id,
7572
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7573
- bounds: region.bounds,
7574
- minZoom: region.minZoom,
7575
- maxZoom: region.maxZoom,
7576
- styleUrl: region.styleUrl || '',
7577
- createdAt: region.created, // StoredRegion uses 'created' not 'createdAt'
7578
- exportedAt: Date.now(),
7579
- version: '1.0.0',
7580
- format: 'json',
7581
- },
7582
- style: {},
7583
- tiles: [],
7584
- sprites: [],
7585
- fonts: [],
7586
- };
7587
- // Export style if requested
7588
- if (options.includeStyle !== false) {
7589
- onProgress({
7590
- stage: 'exporting',
7591
- percentage: 20,
7592
- message: 'Exporting style data...',
7593
- });
7594
- exportData.style = await this.exportStyle(regionId);
7896
+ // Gzip vector tiles. Idempotent: skip tiles already gzipped (downloaded
7897
+ // with their original gzip wrapper intact).
7898
+ const packedTiles = [];
7899
+ for (const tile of tiles) {
7900
+ const raw = tile.data instanceof ArrayBuffer
7901
+ ? new Uint8Array(tile.data)
7902
+ : new Uint8Array(tile.data);
7903
+ const data = isVector && !hasGzipMagic(raw) ? await gzipBytes(raw) : raw;
7904
+ packedTiles.push({ z: tile.z, x: tile.x, y: tile.y, data });
7595
7905
  }
7596
- // Export tiles if requested
7597
- if (options.includeTiles !== false) {
7598
- onProgress({
7599
- stage: 'exporting',
7600
- percentage: 30,
7601
- message: 'Exporting tiles...',
7906
+ onProgress({
7907
+ stage: 'processing',
7908
+ percentage: 85,
7909
+ message: 'Packing SQLite database...',
7910
+ });
7911
+ const SQL = await getSqlJs();
7912
+ const db = new SQL.Database();
7913
+ try {
7914
+ db.run(`
7915
+ CREATE TABLE metadata (name TEXT, value TEXT);
7916
+ CREATE TABLE tiles (
7917
+ zoom_level INTEGER NOT NULL,
7918
+ tile_column INTEGER NOT NULL,
7919
+ tile_row INTEGER NOT NULL,
7920
+ tile_data BLOB
7921
+ );
7922
+ CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
7923
+ CREATE UNIQUE INDEX name ON metadata (name);
7924
+ `);
7925
+ const [[west, south], [east, north]] = region.bounds;
7926
+ const centerLon = (west + east) / 2;
7927
+ const centerLat = (south + north) / 2;
7928
+ const centerZoom = Math.max(region.minZoom, Math.min(region.maxZoom, Math.round((region.minZoom + region.maxZoom) / 2)));
7929
+ const metadataRows = {
7930
+ name: region.name || region.id,
7931
+ // MBTiles 1.3 type: 'overlay' or 'baselayer'. Baselayer matches how
7932
+ // QGIS treats the dataset (full-coverage map rather than overlay).
7933
+ type: isVector ? 'baselayer' : 'overlay',
7934
+ version: '1.0',
7935
+ description: region.name || region.id,
7936
+ format,
7937
+ bounds: `${west},${south},${east},${north}`,
7938
+ center: `${centerLon},${centerLat},${centerZoom}`,
7939
+ minzoom: String(region.minZoom),
7940
+ maxzoom: String(region.maxZoom),
7941
+ };
7942
+ // For vector tiles, the `json` field with `vector_layers` is required
7943
+ // by the MBTiles 1.3 spec and by every vector tile consumer worth
7944
+ // opening the file in. Derive it from the offline style.
7945
+ if (isVector) {
7946
+ const style = await this.exportStyle(regionId);
7947
+ const sourceIds = new Set(tiles.map(t => t.sourceId).filter(Boolean));
7948
+ const json = buildVectorJsonMetadata(style.style ?? style, sourceIds);
7949
+ if (json)
7950
+ metadataRows.json = json;
7951
+ }
7952
+ for (const [k, v] of Object.entries(options.metadata || {})) {
7953
+ metadataRows[k] = typeof v === 'string' ? v : JSON.stringify(v);
7954
+ }
7955
+ const insertMeta = db.prepare(`INSERT INTO metadata (name, value) VALUES (?, ?)`);
7956
+ try {
7957
+ for (const [name, value] of Object.entries(metadataRows)) {
7958
+ insertMeta.run([name, value]);
7959
+ }
7960
+ }
7961
+ finally {
7962
+ insertMeta.free();
7963
+ }
7964
+ const insertTile = db.prepare(`INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data)
7965
+ VALUES (?, ?, ?, ?)`);
7966
+ try {
7967
+ db.run('BEGIN');
7968
+ for (const tile of packedTiles) {
7969
+ insertTile.run([tile.z, tile.x, flipY(tile.y, tile.z), tile.data]);
7970
+ }
7971
+ db.run('COMMIT');
7972
+ }
7973
+ finally {
7974
+ insertTile.free();
7975
+ }
7976
+ const binary = db.export();
7977
+ const blob = new Blob([binary.buffer], {
7978
+ type: 'application/x-sqlite3',
7602
7979
  });
7603
- exportData.tiles = await this.exportTiles(regionId, onProgress);
7604
- }
7605
- // Export sprites if requested
7606
- if (options.includeSprites !== false) {
7607
7980
  onProgress({
7608
- stage: 'exporting',
7609
- percentage: 70,
7610
- message: 'Exporting sprites...',
7981
+ stage: 'complete',
7982
+ percentage: 100,
7983
+ message: 'MBTiles export complete!',
7611
7984
  });
7612
- exportData.sprites = await this.exportSprites(regionId);
7985
+ return {
7986
+ success: true,
7987
+ format: 'mbtiles',
7988
+ filename: `${region.name || region.id}.mbtiles`,
7989
+ blob,
7990
+ size: blob.size,
7991
+ statistics: {
7992
+ tilesExported: tiles.length,
7993
+ spritesExported: 0,
7994
+ fontsExported: 0,
7995
+ },
7996
+ };
7613
7997
  }
7614
- // Export fonts if requested
7615
- if (options.includeFonts !== false) {
7616
- onProgress({
7617
- stage: 'exporting',
7618
- percentage: 85,
7619
- message: 'Exporting fonts...',
7620
- });
7621
- exportData.fonts = await this.exportFonts(regionId);
7998
+ finally {
7999
+ db.close();
7622
8000
  }
7623
- onProgress({
7624
- stage: 'processing',
7625
- percentage: 95,
7626
- message: 'Creating export file...',
7627
- });
7628
- // Create JSON blob
7629
- const jsonString = JSON.stringify(exportData, null, 2);
7630
- const blob = new Blob([jsonString], { type: 'application/json' });
7631
- onProgress({
7632
- stage: 'complete',
7633
- percentage: 100,
7634
- message: 'Export complete!',
7635
- });
7636
- return {
7637
- success: true,
7638
- format: 'json',
7639
- filename: `${region.name || region.id}_export.json`,
7640
- blob,
7641
- size: blob.size,
7642
- statistics: {
7643
- tilesExported: exportData.tiles.length,
7644
- spritesExported: exportData.sprites.length,
7645
- fontsExported: exportData.fonts.length,
7646
- },
7647
- };
7648
8001
  }
7649
8002
  catch (error) {
7650
8003
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7651
- throw new Error(`Export failed: ${errorMessage}`);
8004
+ throw new Error(`MBTiles export failed: ${errorMessage}`);
7652
8005
  }
7653
8006
  }
7654
8007
  /**
7655
- * Export region as PMTiles format
8008
+ * Import region from a binary MBTiles (SQLite) file.
7656
8009
  */
7657
- async exportRegionAsPMTiles(regionId, options = {}) {
7658
- const onProgress = options.onProgress || (() => { });
8010
+ async importRegion(importData) {
8011
+ const onProgress = importData.onProgress || (() => { });
7659
8012
  try {
7660
8013
  onProgress({
7661
8014
  stage: 'preparing',
7662
8015
  percentage: 0,
7663
- message: 'Preparing PMTiles export...',
8016
+ message: 'Reading file...',
7664
8017
  });
7665
- // Note: This is a simplified implementation
7666
- // In a real implementation, you would use the PMTiles library
7667
- // to create a proper PMTiles file format
7668
- const region = await this.getRegionMetadata(regionId);
7669
- if (!region) {
7670
- throw new Error(`Region ${regionId} not found`);
8018
+ if (importData.format !== 'mbtiles') {
8019
+ throw new Error(`Unsupported format: ${importData.format}`);
7671
8020
  }
7672
- // Get tiles data
7673
- const tiles = await this.exportTiles(regionId, onProgress);
7674
- // Create PMTiles header and data structure
7675
- const pmtilesData = {
7676
- header: {
7677
- version: 3,
7678
- type: 'mvt',
7679
- compression: options.compression || 'gzip',
7680
- bounds: region.bounds,
7681
- minZoom: region.minZoom,
7682
- maxZoom: region.maxZoom,
7683
- metadata: {
7684
- name: region.name,
7685
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7686
- ...options.metadata,
7687
- },
7688
- },
7689
- tiles: tiles,
7690
- };
7691
- // Convert to binary format (simplified)
7692
- const jsonString = JSON.stringify(pmtilesData);
7693
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
7694
- onProgress({
7695
- stage: 'complete',
7696
- percentage: 100,
7697
- message: 'PMTiles export complete!',
7698
- });
7699
- return {
7700
- success: true,
7701
- format: 'pmtiles',
7702
- filename: `${region.name || region.id}.pmtiles`,
7703
- blob,
7704
- size: blob.size,
7705
- statistics: {
7706
- tilesExported: tiles.length,
7707
- spritesExported: 0,
7708
- fontsExported: 0,
7709
- },
7710
- };
7711
- }
7712
- catch (error) {
7713
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7714
- throw new Error(`PMTiles export failed: ${errorMessage}`);
7715
- }
7716
- }
7717
- /**
7718
- * Export region as MBTiles format
7719
- */
7720
- async exportRegionAsMBTiles(regionId, options = {}) {
7721
- const onProgress = options.onProgress || (() => { });
7722
- try {
8021
+ const buffer = await this.readFileAsArrayBuffer(importData.file);
8022
+ onProgress({ stage: 'importing', percentage: 40, message: 'Parsing MBTiles...' });
8023
+ const regionData = await this.parseMBTiles(buffer);
7723
8024
  onProgress({
7724
- stage: 'preparing',
7725
- percentage: 0,
7726
- message: 'Preparing MBTiles export...',
8025
+ stage: 'importing',
8026
+ percentage: 70,
8027
+ message: `Importing ${regionData.tiles?.length ?? 0} tiles...`,
7727
8028
  });
7728
- // Note: This is a simplified implementation
7729
- // In a real implementation, you would use SQLite/SQL.js
7730
- // to create a proper MBTiles SQLite database
7731
- const region = await this.getRegionMetadata(regionId);
7732
- if (!region) {
7733
- throw new Error(`Region ${regionId} not found`);
7734
- }
7735
- // Get tiles data
7736
- const tiles = await this.exportTiles(regionId, onProgress);
7737
- // Create MBTiles structure (simplified as JSON for now)
7738
- const mbtilesData = {
7739
- metadata: {
7740
- name: region.name,
7741
- type: 'overlay',
7742
- version: '1.0',
7743
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7744
- format: options.format || 'pbf',
7745
- bounds: region.bounds.flat().join(','),
7746
- minzoom: region.minZoom,
7747
- maxzoom: region.maxZoom,
7748
- ...options.metadata,
7749
- },
7750
- tiles: tiles.map(tile => ({
7751
- zoom_level: tile.z,
7752
- tile_column: tile.x,
7753
- tile_row: tile.y,
7754
- tile_data: tile.data,
7755
- })),
7756
- };
7757
- // Convert to binary format (simplified)
7758
- const jsonString = JSON.stringify(mbtilesData);
7759
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
8029
+ const result = await this.importRegionData(regionData, importData);
7760
8030
  onProgress({
7761
8031
  stage: 'complete',
7762
8032
  percentage: 100,
7763
- message: 'MBTiles export complete!',
8033
+ message: result.success ? 'Import complete!' : result.message,
7764
8034
  });
7765
- return {
7766
- success: true,
7767
- format: 'mbtiles',
7768
- filename: `${region.name || region.id}.mbtiles`,
7769
- blob,
7770
- size: blob.size,
7771
- statistics: {
7772
- tilesExported: tiles.length,
7773
- spritesExported: 0,
7774
- fontsExported: 0,
7775
- },
7776
- };
7777
- }
7778
- catch (error) {
7779
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7780
- throw new Error(`MBTiles export failed: ${errorMessage}`);
7781
- }
7782
- }
7783
- /**
7784
- * Import region from file
7785
- */
7786
- async importRegion(importData) {
7787
- try {
7788
- let regionData;
7789
- switch (importData.format) {
7790
- case 'json': {
7791
- const textContent = await this.readFileAsText(importData.file);
7792
- regionData = JSON.parse(textContent);
7793
- break;
7794
- }
7795
- case 'pmtiles': {
7796
- // PMTiles is a binary format; currently parsed as JSON (simplified impl)
7797
- const textContent = await this.readFileAsText(importData.file);
7798
- regionData = await this.parsePMTiles(textContent);
7799
- break;
7800
- }
7801
- case 'mbtiles': {
7802
- // MBTiles is a binary format; currently parsed as JSON (simplified impl)
7803
- const textContent = await this.readFileAsText(importData.file);
7804
- regionData = await this.parseMBTiles(textContent);
7805
- break;
7806
- }
7807
- default:
7808
- throw new Error(`Unsupported format: ${importData.format}`);
7809
- }
7810
- // Import the region data
7811
- const result = await this.importRegionData(regionData, importData);
7812
8035
  return result;
7813
8036
  }
7814
8037
  catch (error) {
@@ -7932,151 +8155,113 @@ class ImportExportService {
7932
8155
  }
7933
8156
  }
7934
8157
  /**
7935
- * Export sprites data
7936
- */
7937
- async exportSprites(_regionId) {
7938
- const db = await this.db;
7939
- const transaction = db.transaction(['sprites'], 'readonly');
7940
- const store = transaction.objectStore('sprites');
7941
- const sprites = [];
7942
- try {
7943
- let cursor = await store.openCursor();
7944
- while (cursor) {
7945
- const sprite = cursor.value;
7946
- // Include sprites that match the styleId, or all sprites if keys don't contain styleId
7947
- // (sprite keys may or may not be prefixed with styleId depending on how they were stored)
7948
- sprites.push({
7949
- url: sprite.url,
7950
- data: sprite.data,
7951
- type: sprite.url.endsWith('.json') ? 'json' : 'png',
7952
- resolution: sprite.url.includes('@2x') ? '2x' : '1x',
7953
- });
7954
- cursor = await cursor.continue();
7955
- }
7956
- return sprites;
7957
- }
7958
- catch (error) {
7959
- serviceLogger.error('Error exporting sprites:', error);
7960
- return [];
7961
- }
7962
- }
7963
- /**
7964
- * Export fonts data
8158
+ * Read file content as ArrayBuffer (for the binary MBTiles file).
7965
8159
  */
7966
- async exportFonts(_regionId) {
7967
- const db = await this.db;
7968
- const transaction = db.transaction(['fonts'], 'readonly');
7969
- const store = transaction.objectStore('fonts');
7970
- const fonts = [];
7971
- try {
7972
- let cursor = await store.openCursor();
7973
- while (cursor) {
7974
- const font = cursor.value;
7975
- // Include fonts that match the styleId, or all fonts if keys don't contain styleId
7976
- // (font keys may or may not be prefixed with styleId depending on how they were stored)
7977
- fonts.push({
7978
- fontStack: font.key, // Use key as fontstack identifier
7979
- range: '0-255', // Default range since FontEntry doesn't store this
7980
- data: font.data,
7981
- });
7982
- cursor = await cursor.continue();
7983
- }
7984
- return fonts;
7985
- }
7986
- catch (error) {
7987
- serviceLogger.error('Error exporting fonts:', error);
7988
- return [];
7989
- }
7990
- }
7991
- /**
7992
- * Read file content as text (for JSON files)
7993
- */
7994
- async readFileAsText(file) {
8160
+ async readFileAsArrayBuffer(file) {
7995
8161
  return new Promise((resolve, reject) => {
7996
8162
  const reader = new FileReader();
7997
8163
  reader.onload = () => resolve(reader.result);
7998
8164
  reader.onerror = () => reject(new Error('Failed to read file'));
7999
- reader.readAsText(file);
8165
+ reader.readAsArrayBuffer(file);
8000
8166
  });
8001
8167
  }
8002
8168
  /**
8003
- * Parse PMTiles file (simplified)
8169
+ * Parse a real binary MBTiles (SQLite) file into our import-data shape.
8170
+ * Un-flips the TMS tile_row back to XYZ y.
8004
8171
  */
8005
- async parsePMTiles(content) {
8006
- // This is a simplified implementation
8007
- // In reality, you would use the PMTiles library to parse the binary format
8008
- const data = JSON.parse(content);
8009
- const header = data?.header || {};
8010
- const metadata = header?.metadata || {};
8011
- return {
8012
- metadata: {
8013
- id: metadata.name || 'imported-region',
8014
- name: metadata.name || 'Imported Region',
8015
- description: metadata.description || '',
8016
- bounds: header.bounds || [
8017
- [0, 0],
8018
- [0, 0],
8019
- ],
8020
- minZoom: header.minZoom || 0,
8021
- maxZoom: header.maxZoom || 14,
8022
- styleUrl: '',
8023
- createdAt: Date.now(),
8024
- exportedAt: Date.now(),
8025
- version: '1.0.0',
8026
- format: 'pmtiles',
8027
- },
8028
- style: {},
8029
- tiles: data.tiles || [],
8030
- sprites: [],
8031
- fonts: [],
8032
- };
8033
- }
8034
- /**
8035
- * Parse MBTiles file (simplified)
8036
- */
8037
- async parseMBTiles(content) {
8038
- // This is a simplified implementation
8039
- // In reality, you would use SQL.js to parse the SQLite database
8040
- const data = JSON.parse(content);
8041
- const rawBounds = data.metadata?.bounds
8042
- ? data.metadata.bounds.split(',').map(Number)
8043
- : [0, 0, 0, 0];
8044
- // Ensure we have exactly 4 valid numbers
8045
- const bounds = [
8046
- isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8047
- isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8048
- isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8049
- isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8050
- ];
8051
- return {
8052
- metadata: {
8053
- id: data.metadata.name || 'imported-region',
8054
- name: data.metadata.name || 'Imported Region',
8055
- description: data.metadata.description,
8056
- bounds: [
8057
- [bounds[0], bounds[1]],
8058
- [bounds[2], bounds[3]],
8059
- ],
8060
- minZoom: data.metadata.minzoom || 0,
8061
- maxZoom: data.metadata.maxzoom || 14,
8062
- styleUrl: '',
8063
- createdAt: Date.now(),
8064
- exportedAt: Date.now(),
8065
- version: '1.0.0',
8066
- format: 'mbtiles',
8067
- },
8068
- style: {},
8069
- tiles: data.tiles.map((tile) => ({
8070
- z: tile.zoom_level,
8071
- x: tile.tile_column,
8072
- y: tile.tile_row,
8073
- data: tile.tile_data,
8074
- format: 'pbf',
8075
- sourceId: 'imported',
8076
- })) || [],
8077
- sprites: [],
8078
- fonts: [],
8079
- };
8172
+ async parseMBTiles(buffer) {
8173
+ const bytes = new Uint8Array(buffer);
8174
+ // SQLite header: "SQLite format 3\0" (16 bytes). Validate up front so
8175
+ // non-MBTiles files (e.g. a JSON renamed to .mbtiles) surface a clear
8176
+ // error instead of the opaque "file is not a database" from sql.js.
8177
+ if (bytes.byteLength < 16) {
8178
+ throw new Error('Not a valid MBTiles file: file is too small');
8179
+ }
8180
+ const magic = String.fromCharCode(...bytes.slice(0, 15));
8181
+ if (magic !== 'SQLite format 3') {
8182
+ throw new Error('Not a valid MBTiles file: missing SQLite header');
8183
+ }
8184
+ const SQL = await getSqlJs();
8185
+ const db = new SQL.Database(bytes);
8186
+ try {
8187
+ const tablesResult = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('metadata', 'tiles')");
8188
+ const tableNames = (tablesResult[0]?.values || []).map(r => r[0]);
8189
+ if (!tableNames.includes('metadata') || !tableNames.includes('tiles')) {
8190
+ throw new Error('Not a valid MBTiles file: missing required metadata/tiles tables');
8191
+ }
8192
+ const metadata = {};
8193
+ const metaStmt = db.prepare('SELECT name, value FROM metadata');
8194
+ try {
8195
+ while (metaStmt.step()) {
8196
+ const row = metaStmt.get();
8197
+ metadata[row[0]] = row[1];
8198
+ }
8199
+ }
8200
+ finally {
8201
+ metaStmt.free();
8202
+ }
8203
+ const rawBounds = metadata.bounds ? metadata.bounds.split(',').map(Number) : [0, 0, 0, 0];
8204
+ const bounds = [
8205
+ isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8206
+ isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8207
+ isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8208
+ isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8209
+ ];
8210
+ const format = (metadata.format || 'pbf');
8211
+ const isVector = VECTOR_FORMATS.has(format);
8212
+ const tiles = [];
8213
+ const tilesStmt = db.prepare('SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles');
8214
+ try {
8215
+ while (tilesStmt.step()) {
8216
+ const row = tilesStmt.get();
8217
+ const [z, x, tmsRow, data] = row;
8218
+ // Sliced copy so the buffer is detached from sql.js's heap.
8219
+ const copy = new Uint8Array(data.byteLength);
8220
+ copy.set(data);
8221
+ // Our IndexedDB stores vector tiles decompressed (tileService
8222
+ // inflates on download). MBTiles vector tiles are gzipped by
8223
+ // convention — un-gzip on the way in so the stored tile matches
8224
+ // what the fetch handler expects to serve.
8225
+ const storedBytes = isVector && hasGzipMagic(copy) ? await gunzipBytes(copy) : copy;
8226
+ tiles.push({
8227
+ z,
8228
+ x,
8229
+ y: flipY(tmsRow, z),
8230
+ data: storedBytes.buffer,
8231
+ format,
8232
+ sourceId: 'imported',
8233
+ });
8234
+ }
8235
+ }
8236
+ finally {
8237
+ tilesStmt.free();
8238
+ }
8239
+ const minZoom = metadata.minzoom !== undefined ? Number(metadata.minzoom) : 0;
8240
+ const maxZoom = metadata.maxzoom !== undefined ? Number(metadata.maxzoom) : 14;
8241
+ return {
8242
+ metadata: {
8243
+ id: metadata.name || 'imported-region',
8244
+ name: metadata.name || 'Imported Region',
8245
+ description: metadata.description,
8246
+ bounds: [
8247
+ [bounds[0], bounds[1]],
8248
+ [bounds[2], bounds[3]],
8249
+ ],
8250
+ minZoom,
8251
+ maxZoom,
8252
+ styleUrl: '',
8253
+ createdAt: Date.now(),
8254
+ exportedAt: Date.now(),
8255
+ version: '1.0.0',
8256
+ format: 'mbtiles',
8257
+ },
8258
+ style: {},
8259
+ tiles,
8260
+ };
8261
+ }
8262
+ finally {
8263
+ db.close();
8264
+ }
8080
8265
  }
8081
8266
  /**
8082
8267
  * Import region data to database
@@ -8141,16 +8326,15 @@ class ImportExportService {
8141
8326
  });
8142
8327
  }
8143
8328
  }
8144
- // Import sprites and fonts similarly...
8145
8329
  return {
8146
8330
  success: true,
8147
8331
  regionId,
8148
8332
  message: 'Region imported successfully',
8149
8333
  statistics: {
8150
8334
  tilesImported: regionData.tiles?.length || 0,
8151
- spritesImported: regionData.sprites?.length || 0,
8152
- fontsImported: regionData.fonts?.length || 0,
8153
- totalSize: 0, // Calculate if needed
8335
+ spritesImported: 0,
8336
+ fontsImported: 0,
8337
+ totalSize: 0,
8154
8338
  },
8155
8339
  };
8156
8340
  }
@@ -8379,8 +8563,6 @@ const createMaintenanceManagement = (services, deps) => {
8379
8563
  };
8380
8564
 
8381
8565
  const createImportExportManagement = (services) => ({
8382
- exportRegionAsJSON: async (regionId, options = {}) => services.importExportService.exportRegionAsJSON(regionId, options),
8383
- exportRegionAsPMTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsPMTiles(regionId, options),
8384
8566
  exportRegionAsMBTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsMBTiles(regionId, options),
8385
8567
  importRegion: async (importData) => services.importExportService.importRegion(importData),
8386
8568
  downloadExportedRegion: (exportResult) => {
@@ -8736,10 +8918,6 @@ const en = {
8736
8918
  'styleSelection.title': 'Select Offline Style',
8737
8919
  'styleSelection.message': 'Choose which offline style to load:',
8738
8920
  'styleSelection.sources': 'sources',
8739
- // Import/Export
8740
- 'importExport.title': 'Import/Export',
8741
- 'importExport.export': 'Export',
8742
- 'importExport.import': 'Import',
8743
8921
  // Errors
8744
8922
  'error.loadingContent': 'Error loading content',
8745
8923
  'error.tryAgain': 'Please try again',
@@ -8764,41 +8942,30 @@ const en = {
8764
8942
  'regionDetails.bounds': 'Bounds',
8765
8943
  'regionDetails.zoomRange': 'Zoom Range',
8766
8944
  'regionDetails.created': 'Created',
8767
- // Import/Export Modal
8768
- 'importExport.regionTitle': 'Import/Export Region',
8769
- 'importExport.regionInfo': 'Region Information',
8770
- 'importExport.id': 'ID',
8771
- 'importExport.name': 'Name',
8772
- 'importExport.unnamed': 'Unnamed',
8773
- 'importExport.zoom': 'Zoom',
8774
- 'importExport.created': 'Created',
8775
- 'importExport.exportRegion': 'Export Region',
8776
- 'importExport.exportFormat': 'Export Format',
8777
- 'importExport.formatJson': 'JSON - Complete data (recommended)',
8778
- 'importExport.formatPmtiles': 'PMTiles - Web optimized tiles',
8779
- 'importExport.formatMbtiles': 'MBTiles - Industry standard',
8780
- 'importExport.formatHint': 'Choose format based on your use case',
8781
- 'importExport.includeComponents': 'Include Components',
8782
- 'importExport.styleConfig': 'Style Configuration',
8783
- 'importExport.mapTiles': 'Map Tiles',
8784
- 'importExport.spritesIcons': 'Sprites & Icons',
8785
- 'importExport.fontsGlyphs': 'Fonts & Glyphs',
8786
- 'importExport.preparingExport': 'Preparing export...',
8787
- 'importExport.exportComplete': 'Export complete!',
8788
- 'importExport.exportFailed': 'Export failed. Please try again.',
8789
- 'importExport.importRegion': 'Import Region',
8790
- 'importExport.selectFile': 'Select File',
8791
- 'importExport.fileFormatsHint': 'Supports JSON, PMTiles, and MBTiles formats',
8792
- 'importExport.newRegionName': 'New Region Name (Optional)',
8793
- 'importExport.newRegionNamePlaceholder': 'Leave empty to use original name',
8794
- 'importExport.overwriteIfExists': 'Overwrite if region exists',
8795
- 'importExport.preparingImport': 'Preparing import...',
8796
- 'importExport.importComplete': 'Import complete!',
8797
- 'importExport.importFailed': 'Import failed. Please try again.',
8798
- 'importExport.formatGuide': 'Format Guide',
8799
- 'importExport.jsonDesc': 'Complete data, human-readable, best for development',
8800
- 'importExport.pmtilesDesc': 'Web-optimized, efficient serving, cloud-friendly',
8801
- 'importExport.mbtilesDesc': 'Industry standard, SQLite-based, cross-platform',
8945
+ // MBTiles Modal
8946
+ 'mbtiles.title': 'MBTiles Import / Export',
8947
+ 'mbtiles.regionInfo': 'Region Information',
8948
+ 'mbtiles.id': 'ID',
8949
+ 'mbtiles.name': 'Name',
8950
+ 'mbtiles.unnamed': 'Unnamed',
8951
+ 'mbtiles.zoom': 'Zoom',
8952
+ 'mbtiles.created': 'Created',
8953
+ 'mbtiles.exportTitle': 'Export as MBTiles',
8954
+ 'mbtiles.exportHint': 'Package the tiles in this region into a standard SQLite MBTiles archive that opens in QGIS, tippecanoe, and other tools.',
8955
+ 'mbtiles.exportButton': 'Download .mbtiles',
8956
+ 'mbtiles.preparingExport': 'Preparing export...',
8957
+ 'mbtiles.exportComplete': 'Export complete!',
8958
+ 'mbtiles.exportFailed': 'Export failed. Please try again.',
8959
+ 'mbtiles.importTitle': 'Import from MBTiles',
8960
+ 'mbtiles.selectFile': 'Select an .mbtiles file',
8961
+ 'mbtiles.fileHint': 'Only SQLite-format .mbtiles files are supported.',
8962
+ 'mbtiles.newRegionName': 'New Region Name (optional)',
8963
+ 'mbtiles.newRegionNamePlaceholder': 'Leave empty to use the name from the file',
8964
+ 'mbtiles.overwriteIfExists': 'Overwrite if a region with the same id exists',
8965
+ 'mbtiles.importButton': 'Import .mbtiles',
8966
+ 'mbtiles.preparingImport': 'Preparing import...',
8967
+ 'mbtiles.importComplete': 'Import complete!',
8968
+ 'mbtiles.importFailed': 'Import failed. Please try again.',
8802
8969
  // Active Downloads
8803
8970
  'download.activeCount': 'Active Downloads ({{count}})',
8804
8971
  // Panel Manager additional strings
@@ -8959,10 +9126,6 @@ const ar = {
8959
9126
  'styleSelection.title': 'اختر نمط غير متصل',
8960
9127
  'styleSelection.message': 'اختر النمط غير المتصل الذي تريد تحميله:',
8961
9128
  'styleSelection.sources': 'مصادر',
8962
- // Import/Export - استيراد/تصدير
8963
- 'importExport.title': 'استيراد/تصدير',
8964
- 'importExport.export': 'تصدير',
8965
- 'importExport.import': 'استيراد',
8966
9129
  // Errors - الأخطاء
8967
9130
  'error.loadingContent': 'خطأ في تحميل المحتوى',
8968
9131
  'error.tryAgain': 'يرجى المحاولة مرة أخرى',
@@ -8987,41 +9150,30 @@ const ar = {
8987
9150
  'regionDetails.bounds': 'الحدود',
8988
9151
  'regionDetails.zoomRange': 'نطاق التكبير',
8989
9152
  'regionDetails.created': 'تاريخ الإنشاء',
8990
- // Import/Export Modal - نافذة الاستيراد/التصدير
8991
- 'importExport.regionTitle': 'استيراد/تصدير المنطقة',
8992
- 'importExport.regionInfo': 'معلومات المنطقة',
8993
- 'importExport.id': 'المعرف',
8994
- 'importExport.name': 'الاسم',
8995
- 'importExport.unnamed': 'بدون اسم',
8996
- 'importExport.zoom': 'التكبير',
8997
- 'importExport.created': 'تاريخ الإنشاء',
8998
- 'importExport.exportRegion': 'تصدير المنطقة',
8999
- 'importExport.exportFormat': 'تنسيق التصدير',
9000
- 'importExport.formatJson': 'JSON - بيانات كاملة (موصى به)',
9001
- 'importExport.formatPmtiles': 'PMTiles - بلاطات محسنة للويب',
9002
- 'importExport.formatMbtiles': 'MBTiles - معيار الصناعة',
9003
- 'importExport.formatHint': 'اختر التنسيق بناءً على حالة استخدامك',
9004
- 'importExport.includeComponents': 'تضمين المكونات',
9005
- 'importExport.styleConfig': 'إعدادات النمط',
9006
- 'importExport.mapTiles': 'بلاطات الخريطة',
9007
- 'importExport.spritesIcons': 'الرموز والأيقونات',
9008
- 'importExport.fontsGlyphs': 'الخطوط والحروف',
9009
- 'importExport.preparingExport': 'جاري تجهيز التصدير...',
9010
- 'importExport.exportComplete': 'اكتمل التصدير!',
9011
- 'importExport.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9012
- 'importExport.importRegion': 'استيراد المنطقة',
9013
- 'importExport.selectFile': 'اختر ملف',
9014
- 'importExport.fileFormatsHint': 'يدعم تنسيقات JSON و PMTiles و MBTiles',
9015
- 'importExport.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9016
- 'importExport.newRegionNamePlaceholder': 'اتركه فارغاً لاستخدام الاسم الأصلي',
9017
- 'importExport.overwriteIfExists': 'الكتابة فوق المنطقة الموجودة',
9018
- 'importExport.preparingImport': 'جاري تجهيز الاستيراد...',
9019
- 'importExport.importComplete': 'اكتمل الاستيراد!',
9020
- 'importExport.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9021
- 'importExport.formatGuide': 'دليل التنسيقات',
9022
- 'importExport.jsonDesc': 'بيانات كاملة، قابلة للقراءة، الأفضل للتطوير',
9023
- 'importExport.pmtilesDesc': 'محسن للويب، خدمة فعالة، متوافق مع السحابة',
9024
- 'importExport.mbtilesDesc': 'معيار الصناعة، قائم على SQLite، متعدد المنصات',
9153
+ // MBTiles Modal - نافذة MBTiles
9154
+ 'mbtiles.title': 'استيراد / تصدير MBTiles',
9155
+ 'mbtiles.regionInfo': 'معلومات المنطقة',
9156
+ 'mbtiles.id': 'المعرف',
9157
+ 'mbtiles.name': 'الاسم',
9158
+ 'mbtiles.unnamed': 'بدون اسم',
9159
+ 'mbtiles.zoom': 'التكبير',
9160
+ 'mbtiles.created': 'تاريخ الإنشاء',
9161
+ 'mbtiles.exportTitle': 'التصدير كـ MBTiles',
9162
+ 'mbtiles.exportHint': 'احزم بلاطات هذه المنطقة داخل أرشيف MBTiles (SQLite) يمكن فتحه في QGIS و tippecanoe وأدوات أخرى.',
9163
+ 'mbtiles.exportButton': 'تنزيل ملف mbtiles.',
9164
+ 'mbtiles.preparingExport': 'جاري تجهيز التصدير...',
9165
+ 'mbtiles.exportComplete': 'اكتمل التصدير!',
9166
+ 'mbtiles.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9167
+ 'mbtiles.importTitle': 'الاستيراد من MBTiles',
9168
+ 'mbtiles.selectFile': 'اختر ملف mbtiles.',
9169
+ 'mbtiles.fileHint': 'تُدعم ملفات mbtiles. بتنسيق SQLite فقط.',
9170
+ 'mbtiles.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9171
+ 'mbtiles.newRegionNamePlaceholder': 'اتركه فارغًا لاستخدام الاسم من الملف',
9172
+ 'mbtiles.overwriteIfExists': 'الكتابة فوق المنطقة إذا كان المعرف موجودًا',
9173
+ 'mbtiles.importButton': 'استيراد ملف mbtiles.',
9174
+ 'mbtiles.preparingImport': 'جاري تجهيز الاستيراد...',
9175
+ 'mbtiles.importComplete': 'اكتمل الاستيراد!',
9176
+ 'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9025
9177
  // Active Downloads - التحميلات النشطة
9026
9178
  'download.activeCount': 'التحميلات النشطة ({{count}})',
9027
9179
  // Panel Manager additional strings - سلاسل إضافية للوحة
@@ -9960,49 +10112,42 @@ class ConfirmationModal {
9960
10112
  }
9961
10113
 
9962
10114
  /**
9963
- * Import/Export Modal Component
9964
- * Handles import/export operations for regions
9965
- * Refactored to use modular Modal component for consistency
10115
+ * MBTiles Import/Export Modal
10116
+ *
10117
+ * Focused modal for exchanging regions as binary SQLite MBTiles archives.
10118
+ * Replaces the previous multi-format import/export modal.
9966
10119
  */
9967
- const modalLogger = logger.scope('ImportExportModal');
9968
- class ImportExportModal {
10120
+ const modalLogger = logger.scope('MBTilesModal');
10121
+ class MBTilesModal {
9969
10122
  modal;
9970
10123
  options;
9971
10124
  isExporting = false;
9972
10125
  isImporting = false;
9973
- // Form elements
9974
- exportFormatSelect;
9975
- includeStyleCheckbox;
9976
- includeTilesCheckbox;
9977
- includeSpritesCheckbox;
9978
- includeFontsCheckbox;
9979
10126
  exportProgressBar;
9980
10127
  exportProgressText;
10128
+ exportProgressContainer;
9981
10129
  exportButton;
9982
10130
  importFileInput;
9983
10131
  importNameInput;
9984
10132
  importOverwriteCheckbox;
9985
10133
  importProgressBar;
9986
10134
  importProgressText;
10135
+ importProgressContainer;
9987
10136
  importButton;
9988
10137
  constructor(options) {
9989
10138
  this.options = options;
9990
10139
  }
9991
10140
  show() {
9992
10141
  const modalConfig = {
9993
- title: t('importExport.regionTitle'),
10142
+ title: t('mbtiles.title'),
9994
10143
  subtitle: this.options.region.name || this.options.region.id,
9995
10144
  size: 'md',
9996
10145
  closable: true,
9997
10146
  onClose: () => this.hide(),
9998
10147
  };
9999
10148
  this.modal = new Modal(modalConfig);
10000
- // Create content
10001
- const content = this.createContent();
10002
- this.modal.setContent(content);
10003
- // Create footer with close button
10004
- const footer = this.createFooter();
10005
- this.modal.setFooter(footer);
10149
+ this.modal.setContent(this.createContent());
10150
+ this.modal.setFooter(this.createFooter());
10006
10151
  this.modal.show();
10007
10152
  this.attachEventListeners();
10008
10153
  return this.modal.getElement();
@@ -10011,217 +10156,96 @@ class ImportExportModal {
10011
10156
  this.modal?.hide();
10012
10157
  this.options.onClose();
10013
10158
  }
10159
+ destroy() {
10160
+ this.modal?.destroy();
10161
+ }
10014
10162
  createContent() {
10015
10163
  const content = document.createElement('div');
10016
- content.className = 'flex flex-col gap-6';
10164
+ content.className = 'flex flex-col gap-6 py-2';
10017
10165
  if (i18n.isRTL()) {
10018
10166
  content.setAttribute('dir', 'rtl');
10019
10167
  }
10020
- // Region Info Card
10021
- const infoCard = this.createRegionInfoCard();
10022
- content.appendChild(infoCard);
10023
- // Export/Import Grid
10024
- const gridContainer = document.createElement('div');
10025
- gridContainer.className = 'grid grid-cols-1 gap-6';
10026
- // Export Section
10027
- const exportSection = this.createExportSection();
10028
- gridContainer.appendChild(exportSection);
10029
- // Import Section
10030
- const importSection = this.createImportSection();
10031
- gridContainer.appendChild(importSection);
10032
- content.appendChild(gridContainer);
10033
- // Format Guide
10034
- const formatGuide = this.createFormatGuide();
10035
- content.appendChild(formatGuide);
10168
+ content.appendChild(this.createRegionInfoLine());
10169
+ content.appendChild(this.createExportSection());
10170
+ content.appendChild(this.createImportSection());
10036
10171
  return content;
10037
10172
  }
10038
- createRegionInfoCard() {
10039
- const card = document.createElement('div');
10040
- card.className = 'p-5 glass-input rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50';
10041
- card.innerHTML = `
10042
- <h4 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-white mb-4 flex items-center gap-2">
10043
- ${icons.mapPin({ size: 16, color: 'currentColor' })}
10044
- ${t('importExport.regionInfo')}
10045
- </h4>
10046
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
10047
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10048
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.id')}</span>
10049
- <div class="text-gray-900 dark:text-white font-mono text-xs break-all mt-1">${escapeHtml$1(this.options.region.id)}</div>
10050
- </div>
10051
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10052
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.name')}</span>
10053
- <div class="text-gray-900 dark:text-white font-medium mt-1">${escapeHtml$1(this.options.region.name || t('importExport.unnamed'))}</div>
10054
- </div>
10055
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10056
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.zoom')}</span>
10057
- <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>
10058
- </div>
10059
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10060
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.created')}</span>
10061
- <div class="text-gray-900 dark:text-white font-medium mt-1">${new Date(this.options.region.created).toLocaleDateString()}</div>
10062
- </div>
10063
- </div>
10173
+ createRegionInfoLine() {
10174
+ const { region } = this.options;
10175
+ const line = document.createElement('div');
10176
+ line.className =
10177
+ 'flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400';
10178
+ line.innerHTML = `
10179
+ <span class="flex items-center gap-1">
10180
+ ${icons.mapPin({ size: 12, color: 'currentColor' })}
10181
+ <span class="font-mono">${escapeHtml$1(region.id)}</span>
10182
+ </span>
10183
+ <span>Z${escapeHtml$1(region.minZoom)}-${escapeHtml$1(region.maxZoom)}</span>
10184
+ <span>${new Date(region.created).toLocaleDateString()}</span>
10064
10185
  `;
10065
- return card;
10186
+ return line;
10066
10187
  }
10067
10188
  createExportSection() {
10068
- const section = document.createElement('div');
10069
- section.className =
10070
- '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';
10071
- // Gradient accent
10072
- const accent = document.createElement('div');
10073
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-blue-500 opacity-50`;
10074
- section.appendChild(accent);
10075
- const header = document.createElement('h3');
10076
- header.className =
10077
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10078
- header.innerHTML = `
10079
- <div class="p-2 bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400">
10080
- ${icons.upload({ size: 20, color: 'currentColor' })}
10081
- </div>
10082
- ${t('importExport.exportRegion')}
10083
- `;
10084
- section.appendChild(header);
10085
- const formContainer = document.createElement('div');
10086
- formContainer.className = 'space-y-5';
10087
- // Format Selection
10088
- const formatGroup = document.createElement('div');
10089
- const formatLabel = document.createElement('label');
10090
- formatLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10091
- formatLabel.textContent = t('importExport.exportFormat');
10092
- this.exportFormatSelect = document.createElement('select');
10093
- this.exportFormatSelect.className =
10094
- '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';
10095
- this.exportFormatSelect.innerHTML = `
10096
- <option value="json">${t('importExport.formatJson')}</option>
10097
- <option value="pmtiles">${t('importExport.formatPmtiles')}</option>
10098
- <option value="mbtiles">${t('importExport.formatMbtiles')}</option>
10099
- `;
10100
- const formatHint = document.createElement('p');
10101
- formatHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10102
- formatHint.textContent = t('importExport.formatHint');
10103
- formatGroup.appendChild(formatLabel);
10104
- formatGroup.appendChild(this.exportFormatSelect);
10105
- formatGroup.appendChild(formatHint);
10106
- formContainer.appendChild(formatGroup);
10107
- // Export Options
10108
- const optionsGroup = document.createElement('div');
10109
- const optionsLabel = document.createElement('label');
10110
- optionsLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3';
10111
- optionsLabel.textContent = t('importExport.includeComponents');
10112
- const checkboxContainer = document.createElement('div');
10113
- checkboxContainer.className = 'grid grid-cols-1 sm:grid-cols-2 gap-3';
10114
- const createCheckbox = (text, checked = true) => {
10115
- const label = document.createElement('label');
10116
- label.className =
10117
- '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';
10118
- const input = document.createElement('input');
10119
- input.type = 'checkbox';
10120
- input.checked = checked;
10121
- input.className =
10122
- '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';
10123
- const span = document.createElement('span');
10124
- span.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10125
- span.textContent = text;
10126
- label.appendChild(input);
10127
- label.appendChild(span);
10128
- return { label, input };
10129
- };
10130
- const styleCheck = createCheckbox(t('importExport.styleConfig'));
10131
- this.includeStyleCheckbox = styleCheck.input;
10132
- checkboxContainer.appendChild(styleCheck.label);
10133
- const tilesCheck = createCheckbox(t('importExport.mapTiles'));
10134
- this.includeTilesCheckbox = tilesCheck.input;
10135
- checkboxContainer.appendChild(tilesCheck.label);
10136
- const spritesCheck = createCheckbox(t('importExport.spritesIcons'));
10137
- this.includeSpritesCheckbox = spritesCheck.input;
10138
- checkboxContainer.appendChild(spritesCheck.label);
10139
- const fontsCheck = createCheckbox(t('importExport.fontsGlyphs'));
10140
- this.includeFontsCheckbox = fontsCheck.input;
10141
- checkboxContainer.appendChild(fontsCheck.label);
10142
- optionsGroup.appendChild(optionsLabel);
10143
- optionsGroup.appendChild(checkboxContainer);
10144
- formContainer.appendChild(optionsGroup);
10145
- // Export Progress (hidden by default)
10146
- const progressContainer = document.createElement('div');
10147
- progressContainer.className = 'hidden';
10148
- const progressBarContainer = document.createElement('div');
10149
- progressBarContainer.className =
10150
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10151
- this.exportProgressBar = document.createElement('div');
10152
- this.exportProgressBar.className = 'bg-blue-600 h-2 rounded-full transition-all duration-300';
10153
- this.exportProgressBar.style.width = '0%';
10154
- progressBarContainer.appendChild(this.exportProgressBar);
10155
- this.exportProgressText = document.createElement('p');
10156
- this.exportProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10157
- this.exportProgressText.textContent = t('importExport.preparingExport');
10158
- progressContainer.appendChild(progressBarContainer);
10159
- progressContainer.appendChild(this.exportProgressText);
10160
- formContainer.appendChild(progressContainer);
10161
- // Export Button
10162
- const exportButton = new Button({
10163
- text: t('importExport.exportRegion'),
10189
+ const section = this.createSection(t('mbtiles.exportTitle'), 'blue', icons.download({ size: 20, color: 'currentColor' }));
10190
+ const form = document.createElement('div');
10191
+ form.className = 'space-y-5';
10192
+ const hint = document.createElement('p');
10193
+ hint.className = 'text-sm text-gray-600 dark:text-gray-400';
10194
+ hint.textContent = t('mbtiles.exportHint');
10195
+ form.appendChild(hint);
10196
+ // Progress (hidden by default)
10197
+ const progress = this.createProgressBlock('blue', t('mbtiles.preparingExport'));
10198
+ this.exportProgressContainer = progress.container;
10199
+ this.exportProgressBar = progress.bar;
10200
+ this.exportProgressText = progress.text;
10201
+ form.appendChild(progress.container);
10202
+ const exportBtn = new Button({
10203
+ text: t('mbtiles.exportButton'),
10164
10204
  variant: 'primary',
10165
10205
  icon: icons.download({ size: 16, color: 'white' }),
10166
- className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20', // Premium button styles
10206
+ className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20',
10167
10207
  onClick: () => this.handleExport(),
10168
10208
  });
10169
- this.exportButton = exportButton.getElement();
10170
- formContainer.appendChild(this.exportButton);
10171
- section.appendChild(formContainer);
10209
+ this.exportButton = exportBtn.getElement();
10210
+ form.appendChild(this.exportButton);
10211
+ section.appendChild(form);
10172
10212
  return section;
10173
10213
  }
10174
10214
  createImportSection() {
10175
- const section = document.createElement('div');
10176
- section.className =
10177
- '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';
10178
- // Gradient accent
10179
- const accent = document.createElement('div');
10180
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-green-500 opacity-50`;
10181
- section.appendChild(accent);
10182
- const header = document.createElement('h3');
10183
- header.className =
10184
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10185
- header.innerHTML = `
10186
- <div class="p-2 bg-green-500/10 rounded-lg text-green-600 dark:text-green-400">
10187
- ${icons.upload({ size: 20, color: 'currentColor' })}
10188
- </div>
10189
- ${t('importExport.importRegion')}
10190
- `;
10191
- section.appendChild(header);
10192
- const formContainer = document.createElement('div');
10193
- formContainer.className = 'space-y-5';
10194
- // File Selection
10215
+ const section = this.createSection(t('mbtiles.importTitle'), 'green', icons.upload({ size: 20, color: 'currentColor' }));
10216
+ const form = document.createElement('div');
10217
+ form.className = 'space-y-5';
10218
+ // File input
10195
10219
  const fileGroup = document.createElement('div');
10196
10220
  const fileLabel = document.createElement('label');
10197
10221
  fileLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10198
- fileLabel.textContent = t('importExport.selectFile');
10222
+ fileLabel.textContent = t('mbtiles.selectFile');
10199
10223
  this.importFileInput = document.createElement('input');
10200
10224
  this.importFileInput.type = 'file';
10201
- this.importFileInput.accept = '.json,.pmtiles,.mbtiles';
10225
+ this.importFileInput.accept = '.mbtiles,application/vnd.sqlite3,application/x-sqlite3';
10202
10226
  this.importFileInput.className =
10203
10227
  'w-full text-sm text-gray-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/20 dark:file:text-primary-400 glass-input transition-all cursor-pointer';
10204
10228
  const fileHint = document.createElement('p');
10205
10229
  fileHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10206
- fileHint.textContent = t('importExport.fileFormatsHint');
10230
+ fileHint.textContent = t('mbtiles.fileHint');
10207
10231
  fileGroup.appendChild(fileLabel);
10208
10232
  fileGroup.appendChild(this.importFileInput);
10209
10233
  fileGroup.appendChild(fileHint);
10210
- formContainer.appendChild(fileGroup);
10211
- // New Name
10234
+ form.appendChild(fileGroup);
10235
+ // New region name
10212
10236
  const nameGroup = document.createElement('div');
10213
10237
  const nameLabel = document.createElement('label');
10214
10238
  nameLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10215
- nameLabel.textContent = t('importExport.newRegionName');
10239
+ nameLabel.textContent = t('mbtiles.newRegionName');
10216
10240
  this.importNameInput = document.createElement('input');
10217
10241
  this.importNameInput.type = 'text';
10218
- this.importNameInput.placeholder = t('importExport.newRegionNamePlaceholder');
10242
+ this.importNameInput.placeholder = t('mbtiles.newRegionNamePlaceholder');
10219
10243
  this.importNameInput.className =
10220
10244
  'w-full px-4 py-3 rounded-xl text-sm glass-input text-gray-900 dark:text-white bg-white/50 dark:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all';
10221
10245
  nameGroup.appendChild(nameLabel);
10222
10246
  nameGroup.appendChild(this.importNameInput);
10223
- formContainer.appendChild(nameGroup);
10224
- // Import Options
10247
+ form.appendChild(nameGroup);
10248
+ // Overwrite toggle
10225
10249
  const overwriteLabel = document.createElement('label');
10226
10250
  overwriteLabel.className =
10227
10251
  'flex items-center gap-3 p-3 rounded-lg bg-gray-50/50 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors';
@@ -10231,79 +10255,85 @@ class ImportExportModal {
10231
10255
  'w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500 focus:ring-2 dark:bg-gray-700';
10232
10256
  const overwriteSpan = document.createElement('span');
10233
10257
  overwriteSpan.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10234
- overwriteSpan.textContent = t('importExport.overwriteIfExists');
10258
+ overwriteSpan.textContent = t('mbtiles.overwriteIfExists');
10235
10259
  overwriteLabel.appendChild(this.importOverwriteCheckbox);
10236
10260
  overwriteLabel.appendChild(overwriteSpan);
10237
- formContainer.appendChild(overwriteLabel);
10238
- // Import Progress (hidden by default)
10239
- const progressContainer = document.createElement('div');
10240
- progressContainer.className = 'hidden';
10241
- const progressBarContainer = document.createElement('div');
10242
- progressBarContainer.className =
10243
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10244
- this.importProgressBar = document.createElement('div');
10245
- this.importProgressBar.className = 'bg-green-600 h-2 rounded-full transition-all duration-300';
10246
- this.importProgressBar.style.width = '0%';
10247
- progressBarContainer.appendChild(this.importProgressBar);
10248
- this.importProgressText = document.createElement('p');
10249
- this.importProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10250
- this.importProgressText.textContent = t('importExport.preparingImport');
10251
- progressContainer.appendChild(progressBarContainer);
10252
- progressContainer.appendChild(this.importProgressText);
10253
- formContainer.appendChild(progressContainer);
10254
- // Import Button
10255
- const importButton = new Button({
10256
- text: t('importExport.importRegion'),
10257
- variant: 'success', // Assuming 'success' variant exists in Button component, if not might need style adjustment. Assuming it works based on previous code.
10261
+ form.appendChild(overwriteLabel);
10262
+ // Progress
10263
+ const progress = this.createProgressBlock('green', t('mbtiles.preparingImport'));
10264
+ this.importProgressContainer = progress.container;
10265
+ this.importProgressBar = progress.bar;
10266
+ this.importProgressText = progress.text;
10267
+ form.appendChild(progress.container);
10268
+ // Import button (disabled until a file is selected)
10269
+ const importBtn = new Button({
10270
+ text: t('mbtiles.importButton'),
10271
+ variant: 'success',
10258
10272
  icon: icons.upload({ size: 16, color: 'white' }),
10259
10273
  className: 'w-full py-2.5 text-base shadow-lg shadow-green-500/20',
10260
10274
  disabled: true,
10261
10275
  onClick: () => this.handleImport(),
10262
10276
  });
10263
- this.importButton = importButton.getElement();
10264
- formContainer.appendChild(this.importButton);
10265
- section.appendChild(formContainer);
10277
+ this.importButton = importBtn.getElement();
10278
+ form.appendChild(this.importButton);
10279
+ section.appendChild(form);
10266
10280
  return section;
10267
10281
  }
10268
- createFormatGuide() {
10269
- const guide = document.createElement('div');
10270
- guide.className = 'p-5 mt-4 glass-input rounded-xl border-0 bg-blue-50/40 dark:bg-blue-900/20';
10271
- guide.innerHTML = `
10272
- <h4 class="text-sm font-bold uppercase tracking-wider text-blue-900 dark:text-blue-300 mb-3 flex items-center gap-2">
10273
- ${icons.infoCircle({ size: 16, color: 'currentColor' })}
10274
- ${t('importExport.formatGuide')}
10275
- </h4>
10276
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
10277
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10278
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">JSON</div>
10279
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.jsonDesc')}</div>
10280
- </div>
10281
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10282
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">PMTiles</div>
10283
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.pmtilesDesc')}</div>
10284
- </div>
10285
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10286
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">MBTiles</div>
10287
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.mbtilesDesc')}</div>
10288
- </div>
10282
+ createSection(title, accentColor, iconHtml) {
10283
+ const section = document.createElement('div');
10284
+ section.className =
10285
+ 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden';
10286
+ const accent = document.createElement('div');
10287
+ accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-${accentColor}-500 opacity-50`;
10288
+ section.appendChild(accent);
10289
+ const header = document.createElement('h3');
10290
+ header.className =
10291
+ 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10292
+ header.innerHTML = `
10293
+ <div class="p-2 bg-${accentColor}-500/10 rounded-lg text-${accentColor}-600 dark:text-${accentColor}-400">
10294
+ ${iconHtml}
10289
10295
  </div>
10296
+ ${title}
10290
10297
  `;
10291
- return guide;
10298
+ section.appendChild(header);
10299
+ return section;
10300
+ }
10301
+ createProgressBlock(accentColor, initialText) {
10302
+ const container = document.createElement('div');
10303
+ container.className = 'hidden';
10304
+ const barWrap = document.createElement('div');
10305
+ barWrap.className = 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10306
+ const bar = document.createElement('div');
10307
+ bar.className = `bg-${accentColor}-600 h-2 rounded-full transition-all duration-300`;
10308
+ bar.style.width = '0%';
10309
+ barWrap.appendChild(bar);
10310
+ const text = document.createElement('p');
10311
+ text.className = 'text-sm text-gray-600 dark:text-gray-400';
10312
+ text.textContent = initialText;
10313
+ container.appendChild(barWrap);
10314
+ container.appendChild(text);
10315
+ return { container, bar, text };
10316
+ }
10317
+ createFooter() {
10318
+ const footer = document.createElement('div');
10319
+ footer.className = 'flex gap-3 justify-end';
10320
+ if (i18n.isRTL()) {
10321
+ footer.setAttribute('dir', 'rtl');
10322
+ }
10323
+ const close = new Button({
10324
+ text: t('app.close'),
10325
+ variant: 'secondary',
10326
+ onClick: () => this.hide(),
10327
+ });
10328
+ footer.appendChild(close.getElement());
10329
+ return footer;
10292
10330
  }
10293
10331
  attachEventListeners() {
10294
- // Enable import button when file is selected
10295
10332
  if (this.importFileInput && this.importButton) {
10296
10333
  this.importFileInput.addEventListener('change', () => {
10297
- if (this.importFileInput?.files && this.importFileInput.files.length > 0) {
10298
- if (this.importButton) {
10299
- this.importButton.disabled = false;
10300
- }
10301
- }
10302
- else {
10303
- if (this.importButton) {
10304
- this.importButton.disabled = true;
10305
- }
10306
- }
10334
+ const hasFile = !!(this.importFileInput?.files && this.importFileInput.files.length > 0);
10335
+ if (this.importButton)
10336
+ this.importButton.disabled = !hasFile;
10307
10337
  });
10308
10338
  }
10309
10339
  }
@@ -10313,34 +10343,27 @@ class ImportExportModal {
10313
10343
  this.isExporting = true;
10314
10344
  if (this.exportButton)
10315
10345
  this.exportButton.disabled = true;
10346
+ this.exportProgressContainer?.classList.remove('hidden');
10316
10347
  try {
10317
- const format = this.exportFormatSelect?.value;
10318
- const options = {
10319
- includeStyle: this.includeStyleCheckbox?.checked ?? true,
10320
- includeTiles: this.includeTilesCheckbox?.checked ?? true,
10321
- includeSprites: this.includeSpritesCheckbox?.checked ?? true,
10322
- includeFonts: this.includeFontsCheckbox?.checked ?? true,
10323
- };
10324
- // Show progress
10325
- const progressContainer = this.exportProgressBar?.parentElement?.parentElement;
10326
- if (progressContainer) {
10327
- progressContainer.classList.remove('hidden');
10328
- }
10329
- const result = await this.options.exportRegion(this.options.region.id, format, options);
10330
- if (this.exportProgressBar) {
10348
+ const result = await this.options.exportRegion(this.options.region.id, {
10349
+ onProgress: p => {
10350
+ if (this.exportProgressBar)
10351
+ this.exportProgressBar.style.width = `${p.percentage}%`;
10352
+ if (this.exportProgressText)
10353
+ this.exportProgressText.textContent = p.message;
10354
+ },
10355
+ });
10356
+ if (this.exportProgressBar)
10331
10357
  this.exportProgressBar.style.width = '100%';
10332
- }
10333
- if (this.exportProgressText) {
10334
- this.exportProgressText.textContent = t('importExport.exportComplete');
10335
- }
10358
+ if (this.exportProgressText)
10359
+ this.exportProgressText.textContent = t('mbtiles.exportComplete');
10336
10360
  this.options.onExport?.(result);
10337
- // Hide modal after short delay
10338
- setTimeout(() => this.hide(), 1500);
10361
+ setTimeout(() => this.hide(), 1200);
10339
10362
  }
10340
10363
  catch (error) {
10341
10364
  modalLogger.error('Export error:', error instanceof Error ? error.message : String(error));
10342
10365
  if (this.exportProgressText) {
10343
- this.exportProgressText.textContent = t('importExport.exportFailed');
10366
+ this.exportProgressText.textContent = t('mbtiles.exportFailed');
10344
10367
  this.exportProgressText.classList.add('text-red-600', 'dark:text-red-400');
10345
10368
  }
10346
10369
  }
@@ -10351,45 +10374,47 @@ class ImportExportModal {
10351
10374
  }
10352
10375
  }
10353
10376
  async handleImport() {
10354
- if (this.isImporting || !this.options.importRegion || !this.importFileInput?.files?.[0])
10377
+ if (this.isImporting || !this.options.importRegion)
10378
+ return;
10379
+ const file = this.importFileInput?.files?.[0];
10380
+ if (!file)
10355
10381
  return;
10356
10382
  this.isImporting = true;
10357
10383
  if (this.importButton)
10358
10384
  this.importButton.disabled = true;
10385
+ this.importProgressContainer?.classList.remove('hidden');
10359
10386
  try {
10360
- const file = this.importFileInput.files[0];
10361
- const overwrite = this.importOverwriteCheckbox?.checked ?? false;
10362
- // Show progress
10363
- const progressContainer = this.importProgressBar?.parentElement?.parentElement;
10364
- if (progressContainer) {
10365
- progressContainer.classList.remove('hidden');
10366
- }
10367
- // Determine format from file extension
10368
- const format = file.name.endsWith('.pmtiles')
10369
- ? 'pmtiles'
10370
- : file.name.endsWith('.mbtiles')
10371
- ? 'mbtiles'
10372
- : 'json';
10373
10387
  const data = {
10374
10388
  file,
10375
- format,
10376
- overwrite,
10389
+ format: 'mbtiles',
10390
+ overwrite: this.importOverwriteCheckbox?.checked ?? false,
10391
+ newRegionName: this.importNameInput?.value.trim() || undefined,
10392
+ onProgress: p => {
10393
+ if (this.importProgressBar)
10394
+ this.importProgressBar.style.width = `${p.percentage}%`;
10395
+ if (this.importProgressText)
10396
+ this.importProgressText.textContent = p.message;
10397
+ },
10377
10398
  };
10378
10399
  const result = await this.options.importRegion(data);
10379
- if (this.importProgressBar) {
10400
+ if (this.importProgressBar)
10380
10401
  this.importProgressBar.style.width = '100%';
10381
- }
10382
10402
  if (this.importProgressText) {
10383
- this.importProgressText.textContent = t('importExport.importComplete');
10403
+ this.importProgressText.textContent = result.success
10404
+ ? t('mbtiles.importComplete')
10405
+ : result.message;
10406
+ if (!result.success) {
10407
+ this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10408
+ }
10384
10409
  }
10385
10410
  this.options.onImport?.(result);
10386
- // Hide modal after short delay
10387
- setTimeout(() => this.hide(), 1500);
10411
+ if (result.success)
10412
+ setTimeout(() => this.hide(), 1200);
10388
10413
  }
10389
10414
  catch (error) {
10390
10415
  modalLogger.error('Import error:', error instanceof Error ? error.message : String(error));
10391
10416
  if (this.importProgressText) {
10392
- this.importProgressText.textContent = t('importExport.importFailed');
10417
+ this.importProgressText.textContent = t('mbtiles.importFailed');
10393
10418
  this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10394
10419
  }
10395
10420
  }
@@ -10399,23 +10424,6 @@ class ImportExportModal {
10399
10424
  this.importButton.disabled = false;
10400
10425
  }
10401
10426
  }
10402
- createFooter() {
10403
- const footer = document.createElement('div');
10404
- footer.className = 'flex gap-3 justify-end';
10405
- if (i18n.isRTL()) {
10406
- footer.setAttribute('dir', 'rtl');
10407
- }
10408
- const closeButton = new Button({
10409
- text: t('app.close'),
10410
- variant: 'secondary',
10411
- onClick: () => this.hide(),
10412
- });
10413
- footer.appendChild(closeButton.getElement());
10414
- return footer;
10415
- }
10416
- destroy() {
10417
- this.modal?.destroy();
10418
- }
10419
10427
  }
10420
10428
 
10421
10429
  /**
@@ -11129,9 +11137,9 @@ class PanelRenderer extends BaseComponent {
11129
11137
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 transition-colors duration-150" data-action="redownload-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.redownload')}">
11130
11138
  ${icons.download({ size: 14, color: 'currentColor' })}
11131
11139
  </button>
11132
- <!-- <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
11140
+ <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
11133
11141
  ${icons.deviceFloppy({ size: 14, color: 'currentColor' })}
11134
- </button> -->
11142
+ </button>
11135
11143
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 transition-colors duration-150" data-action="delete-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('app.delete')}">
11136
11144
  ${icons.trash({ size: 14, color: 'currentColor' })}
11137
11145
  </button>
@@ -11393,7 +11401,7 @@ class PanelRenderer extends BaseComponent {
11393
11401
  }
11394
11402
  }
11395
11403
  /**
11396
- * Handle import/export functionality
11404
+ * Show the MBTiles import/export modal for a region.
11397
11405
  */
11398
11406
  async handleImportExport(regionId, _regionData) {
11399
11407
  try {
@@ -11401,44 +11409,26 @@ class PanelRenderer extends BaseComponent {
11401
11409
  const region = regions.find((r) => r.id === regionId);
11402
11410
  if (!region)
11403
11411
  return;
11404
- const importExportModal = new ImportExportModal({
11412
+ const mbtilesModal = new MBTilesModal({
11405
11413
  region,
11406
- onClose: () => {
11407
- this.modalManager.close();
11408
- },
11414
+ onClose: () => this.modalManager.close(),
11409
11415
  onExport: result => {
11410
- panelLogger.debug('Export completed:', result);
11411
- // Handle export result - could show success message
11416
+ panelLogger.debug('MBTiles export completed:', result);
11412
11417
  this.offlineManager.downloadExportedRegion(result);
11413
11418
  },
11414
11419
  onImport: result => {
11415
- panelLogger.debug('Import completed:', result);
11416
- // Refresh the panel to show updated regions
11417
- this.refresh();
11418
- },
11419
- exportRegion: async (regionId, format, options) => {
11420
- // Delegate to offline manager's export functionality
11421
- switch (format) {
11422
- case 'json':
11423
- return await this.offlineManager.exportRegionAsJSON(regionId, options);
11424
- case 'pmtiles':
11425
- return await this.offlineManager.exportRegionAsPMTiles(regionId, options);
11426
- case 'mbtiles':
11427
- return await this.offlineManager.exportRegionAsMBTiles(regionId, options);
11428
- default:
11429
- throw new Error(`Unsupported export format: ${format}`);
11430
- }
11431
- },
11432
- importRegion: async (data) => {
11433
- // Delegate to offline manager's import functionality
11434
- return await this.offlineManager.importRegion(data);
11420
+ panelLogger.debug('MBTiles import completed:', result);
11421
+ if (result.success)
11422
+ this.refresh();
11435
11423
  },
11424
+ exportRegion: (id, options) => this.offlineManager.exportRegionAsMBTiles(id, options),
11425
+ importRegion: data => this.offlineManager.importRegion(data),
11436
11426
  });
11437
- const modal = importExportModal.show();
11427
+ const modal = mbtilesModal.show();
11438
11428
  this.modalManager.show(modal);
11439
11429
  }
11440
11430
  catch (error) {
11441
- panelLogger.error('Error showing import/export modal:', error);
11431
+ panelLogger.error('Error showing MBTiles modal:', error);
11442
11432
  }
11443
11433
  }
11444
11434
  /**
@@ -11837,6 +11827,9 @@ class PanelRenderer extends BaseComponent {
11837
11827
  delete patchedStyle.imports;
11838
11828
  panelLogger.debug('Stripped imports from offline style (already flattened)');
11839
11829
  }
11830
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
11831
+ // downloaded before resolveImports learned to rewrite them.
11832
+ sanitizeIndoorExpressions(patchedStyle);
11840
11833
  // Enforce maxzoom for all tile sources to prevent requesting non-existent tiles
11841
11834
  // Find the maximum zoom level from all regions using this style
11842
11835
  let maxZoom = 14; // Default fallback
@@ -12578,9 +12571,7 @@ class RegionFormModal {
12578
12571
  detectProviderFromUrl() {
12579
12572
  const styleUrl = this.styleUrlInput?.value || '';
12580
12573
  // Simple detection logic
12581
- if (styleUrl.startsWith('mapbox://') ||
12582
- styleUrl.includes('mapbox.com') ||
12583
- styleUrl.includes('api.mapbox.com')) {
12574
+ if (styleUrl.startsWith('mapbox://') || isMapboxHost(styleUrl)) {
12584
12575
  if (this.providerSelect)
12585
12576
  this.providerSelect.value = 'mapbox';
12586
12577
  this.toggleAccessTokenVisibility(true);
@@ -13344,39 +13335,39 @@ class OfflineManagerControl {
13344
13335
  }
13345
13336
  // Development proxy for CORS issues (when running on localhost)
13346
13337
  if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
13347
- // Proxy Carto tile requests (tiles and TileJSON)
13348
- const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(url);
13349
- const isTileJsonRequest = url.includes('.json') && url.includes('basemaps.cartocdn.com');
13350
- if (isTileRequest && url.includes('tiles-a.basemaps.cartocdn.com')) {
13351
- const proxyUrl = url.replace('https://tiles-a.basemaps.cartocdn.com', '/tiles/carto-a');
13352
- return originalFetch(proxyUrl, init);
13353
- }
13354
- if (isTileRequest && url.includes('tiles-b.basemaps.cartocdn.com')) {
13355
- const proxyUrl = url.replace('https://tiles-b.basemaps.cartocdn.com', '/tiles/carto-b');
13356
- return originalFetch(proxyUrl, init);
13338
+ let parsed = null;
13339
+ try {
13340
+ parsed = new URL(url, location.origin);
13357
13341
  }
13358
- if (isTileRequest && url.includes('tiles-c.basemaps.cartocdn.com')) {
13359
- const proxyUrl = url.replace('https://tiles-c.basemaps.cartocdn.com', '/tiles/carto-c');
13360
- return originalFetch(proxyUrl, init);
13342
+ catch {
13343
+ parsed = null;
13361
13344
  }
13362
- if (isTileRequest && url.includes('tiles-d.basemaps.cartocdn.com')) {
13363
- const proxyUrl = url.replace('https://tiles-d.basemaps.cartocdn.com', '/tiles/carto-d');
13364
- return originalFetch(proxyUrl, init);
13345
+ const hostname = parsed?.hostname ?? '';
13346
+ const pathAndQuery = parsed ? parsed.pathname + parsed.search : '';
13347
+ // Proxy Carto tile requests (tiles and TileJSON)
13348
+ const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(parsed?.pathname ?? '');
13349
+ const isTileJsonRequest = (parsed?.pathname.endsWith('.json') ?? false) &&
13350
+ (hostname === 'basemaps.cartocdn.com' || hostname.endsWith('.basemaps.cartocdn.com'));
13351
+ const cartoSubdomainProxy = {
13352
+ 'tiles-a.basemaps.cartocdn.com': '/tiles/carto-a',
13353
+ 'tiles-b.basemaps.cartocdn.com': '/tiles/carto-b',
13354
+ 'tiles-c.basemaps.cartocdn.com': '/tiles/carto-c',
13355
+ 'tiles-d.basemaps.cartocdn.com': '/tiles/carto-d',
13356
+ };
13357
+ if (isTileRequest && cartoSubdomainProxy[hostname]) {
13358
+ return originalFetch(cartoSubdomainProxy[hostname] + pathAndQuery, init);
13365
13359
  }
13366
13360
  // Proxy TileJSON requests from tiles.basemaps.cartocdn.com
13367
- if (isTileJsonRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13368
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/carto-api');
13369
- return originalFetch(proxyUrl, init);
13361
+ if (isTileJsonRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13362
+ return originalFetch('/carto-api' + pathAndQuery, init);
13370
13363
  }
13371
13364
  // Fallback for old format (tiles without subdomain)
13372
- if (isTileRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13373
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/tiles/carto-a');
13374
- return originalFetch(proxyUrl, init);
13365
+ if (isTileRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13366
+ return originalFetch('/tiles/carto-a' + pathAndQuery, init);
13375
13367
  }
13376
13368
  // Proxy OpenStreetMap tile requests
13377
- if (url.includes('tile.openstreetmap.org')) {
13378
- const proxyUrl = url.replace('https://tile.openstreetmap.org', '/tiles/osm');
13379
- return originalFetch(proxyUrl, init);
13369
+ if (hostname === 'tile.openstreetmap.org') {
13370
+ return originalFetch('/tiles/osm' + pathAndQuery, init);
13380
13371
  }
13381
13372
  }
13382
13373
  return originalFetch(input, init);
@@ -13809,6 +13800,9 @@ class OfflineManagerControl {
13809
13800
  if (patchedStyle.imports) {
13810
13801
  delete patchedStyle.imports;
13811
13802
  }
13803
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
13804
+ // downloaded before resolveImports learned to rewrite them.
13805
+ sanitizeIndoorExpressions(patchedStyle);
13812
13806
  // If using Service Worker (Mapbox GL JS), convert idb:// to /__offline__/ URLs
13813
13807
  if (this.useServiceWorker) {
13814
13808
  if (this.swReadyPromise) {
@@ -13953,5 +13947,5 @@ class OfflineManagerControl {
13953
13947
  }
13954
13948
  }
13955
13949
 
13956
- export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAP_PROVIDERS, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getStyleStats, getTileAnalytics, getTileStats, getUserErrorMessage, glyphService, hasImports, i18n, icons, idbFetchHandler, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, rewriteMapboxCdnTileUrl, safeExecute, setupAutoCleanup, spriteService, stopAutoCleanup, t, tileService, unregisterOfflineServiceWorker, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
13950
+ export { AnalyticsService, CONTENT_TYPES, CategorizedError, CleanupService, DB_NAME, DB_VERSION, DOWNLOAD_DEFAULTS, ERROR_MESSAGES, ErrorType, FontService, GLYPH_CONFIG, GZIP_MAGIC_BYTES, GlyphService, ImportExportService, LogLevel, MAPBOX_API, MAPBOX_CACHE_TTL, MAPBOX_CLASSIC_STYLES, MAP_PROVIDERS, MaintenanceService, ModelService, OfflineManagerControl, OfflineMapDBVersionError, OfflineMapManager, RESOURCE_TYPES, RegionService, ResourceService, STORAGE_CONFIG, STORE_NAMES, STYLE_CONFIG, SUCCESS_MESSAGES, ScopedLogger, SpriteService, TILE_CONFIG, TileService, URL_SCHEMES, VALIDATION_PATTERNS, applyProxy, categorizeError, cleanupCompressedTiles, cleanupExpiredTiles, cleanupOldFonts, cleanupOldGlyphs, cleanupOldModels, cleanupOldSprites, cleanupOldStyles, cleanupOldTiles, cleanupService, clearAllCaches, configureLogger, configureProxy, configureSqlJs, convertStyleForServiceWorker, countCompressedTiles, createProgressTracker, createTileKey, dbPromise, OfflineMapManager as default, deleteStyleById, deleteStyles, deriveTileExtension, detectCssPrefix, detectStyleProvider, downloadFonts, downloadGlyphs, downloadModels, downloadSprites, downloadStyleWithProvider, downloadStyles, downloadTiles, escapeHtml$1 as escapeHtml, extractAccessToken, extractAllFontNames, extractFontNamesFromTextField, extractTileExtensionFromUrl, fetchResourceWithRetry, fetchWithRetry, fontService, formatBytes, formatDate, generateGlyphUrlsFromStyle, getExpiredResourceCount, getFontAnalytics, getFontStats, getGlyphAnalytics, getGlyphStats, getIcon, getModel, getModelStats, getRegionAnalytics, getSpriteAnalytics, getSpriteStats, getSqlJs, getStyleStats, getTileAnalytics, getTileStats, getUrlHostname, getUserErrorMessage, glyphService, hasImports, hostMatches, i18n, icons, idbFetchHandler, isMapboxHost, isMapboxProtocol, isStyleDownloaded, loadAllStoredRegions, loadGlyphs, loadStyleById, loadStyles, logger, modelKeyBelongsToStyle, modelService, normalizeSpriteProperty, normalizeStyleUrl, optimizeStorage, parseCacheExpiry, parseTileKey, patchStyleForOffline, performCleanup, processBatch, processStyleSources, registerOfflineServiceWorker, resetOfflineMapDB, resolveImports, resolveMapboxUrl, resourceKeyBelongsToStyle, rewriteMapboxCdnTileUrl, safeExecute, sanitizeIndoorExpressions, setupAutoCleanup, spriteService, stopAutoCleanup, t, tileService, unregisterOfflineServiceWorker, validateBounds, validateRegionOptions, validateResource, validateStyleForProvider, validateZoomLevels, verifyAndRepairFonts, verifyAndRepairGlyphs, verifyAndRepairModels, verifyAndRepairSprites };
13957
13951
  //# sourceMappingURL=index.esm.js.map