map-gl-offline 0.7.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -61,45 +61,61 @@ const TILE_CONFIG = {
61
61
  SUPPORTED_EXTENSIONS: ['pbf', 'mvt', 'png', 'jpg', 'jpeg', 'webp', 'glb'],
62
62
  };
63
63
  // Glyph Configuration
64
+ //
65
+ // Glyph servers (MapTiler, Mapbox, OpenFreeMap, ...) serve glyphs in fixed
66
+ // 256-codepoint blocks aligned to a multiple of 256: every request must be
67
+ // `${k * 256}-${k * 256 + 255}`. Strict servers (e.g. MapTiler) reject any
68
+ // other range with HTTP 400 "Invalid glyph range"; lenient ones silently
69
+ // accept them, which is how malformed ranges went unnoticed. See issue #37.
70
+ const GLYPH_BLOCK_SIZE = 256;
71
+ const MAX_GLYPH_CODEPOINT = 65535;
72
+ /**
73
+ * Expand an inclusive Unicode codepoint span into the aligned 256-codepoint
74
+ * glyph blocks that cover it, formatted as `"start-end"` request ranges.
75
+ * The span need not be block-aligned — it is snapped out to whole blocks.
76
+ */
77
+ function glyphBlocksForSpan(start, end) {
78
+ const firstBlock = Math.floor(start / GLYPH_BLOCK_SIZE);
79
+ const lastBlock = Math.floor(Math.min(end, MAX_GLYPH_CODEPOINT) / GLYPH_BLOCK_SIZE);
80
+ const blocks = [];
81
+ for (let block = firstBlock; block <= lastBlock; block++) {
82
+ const blockStart = block * GLYPH_BLOCK_SIZE;
83
+ blocks.push(`${blockStart}-${blockStart + GLYPH_BLOCK_SIZE - 1}`);
84
+ }
85
+ return blocks;
86
+ }
87
+ /**
88
+ * Unicode codepoint spans the comprehensive glyph download aims to cover.
89
+ * Each span is snapped to whole 256-codepoint glyph blocks below, so the
90
+ * resulting request ranges are always server-valid regardless of where the
91
+ * underlying Unicode blocks happen to start or end. To extend coverage, add
92
+ * a span here — never hand-write raw `"start-end"` ranges.
93
+ */
94
+ const GLYPH_COVERAGE_SPANS = [
95
+ [0x0000, 0x12ff], // Latin, Greek, Cyrillic, Hebrew, Arabic, Indic, SE Asian, Georgian, Ethiopic, Cherokee
96
+ [0x1e00, 0x21ff], // Latin Extended Additional, punctuation, symbols, arrows
97
+ [0x2e00, 0x31ff], // CJK radicals, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
98
+ [0x4e00, 0x4fff], // CJK Unified Ideographs (common subset)
99
+ [0xa000, 0xa4ff], // Yi Syllables and Radicals
100
+ [0xac00, 0xd7ff], // Hangul Syllables (Korean)
101
+ [0xf900, 0xfbff], // CJK Compatibility Ideographs, Alphabetic Presentation Forms
102
+ [0xfe00, 0xfeff], // Variation Selectors
103
+ [0xff00, 0xffff], // Halfwidth and Fullwidth Forms
104
+ ];
105
+ /** Build the deduped, codepoint-ascending list of comprehensive glyph ranges. */
106
+ function buildComprehensiveRanges() {
107
+ const ranges = new Set();
108
+ for (const [start, end] of GLYPH_COVERAGE_SPANS) {
109
+ for (const range of glyphBlocksForSpan(start, end)) {
110
+ ranges.add(range);
111
+ }
112
+ }
113
+ return Array.from(ranges).sort((a, b) => Number(a.split('-')[0]) - Number(b.split('-')[0]));
114
+ }
64
115
  const GLYPH_CONFIG = {
65
116
  DEFAULT_URL: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
66
117
  DEFAULT_RANGES: ['0-255'],
67
- COMPREHENSIVE_RANGES: [
68
- '0-255', // Basic Latin + Latin-1 Supplement
69
- '256-511', // Latin Extended-A + Latin Extended-B
70
- '512-767', // IPA Extensions + Spacing Modifier Letters
71
- '768-1023', // Combining Diacritical Marks + Greek and Coptic
72
- '1024-1279', // Cyrillic + Cyrillic Supplement
73
- '1280-1535', // Armenian + Hebrew
74
- '1536-1791', // Arabic
75
- '1792-2047', // Syriac + Arabic Supplement + Thaana
76
- '2048-2303', // NKo + Samaritan + Mandaic
77
- '2304-2559', // Devanagari + Bengali
78
- '2560-2815', // Gurmukhi + Gujarati
79
- '2816-3071', // Oriya + Tamil
80
- '3072-3327', // Telugu + Kannada
81
- '3328-3583', // Malayalam + Sinhala
82
- '3584-3839', // Thai + Lao
83
- '3840-4095', // Tibetan + Myanmar
84
- '4096-4351', // Georgian + Hangul Jamo
85
- '4352-4607', // Ethiopic
86
- '4608-4863', // Cherokee + Canadian Aboriginal
87
- '7680-7935', // Latin Extended Additional
88
- '8192-8447', // General Punctuation, Superscripts/Subscripts, Currency Symbols
89
- '8448-8703', // Letterlike Symbols, Number Forms, Arrows
90
- '11904-12031', // CJK Radicals Supplement
91
- '12032-12255', // Kangxi Radicals + CJK Symbols
92
- '12288-12543', // Hiragana + Katakana
93
- '12544-12799', // Bopomofo + Hangul Compatibility Jamo
94
- '19968-20223', // CJK Unified Ideographs (first block)
95
- '20224-20479', // CJK Unified Ideographs
96
- '40960-42127', // Yi Syllables + Yi Radicals
97
- '44032-55203', // Hangul Syllables (Korean)
98
- '63744-64255', // CJK Compatibility Ideographs
99
- '64256-64511', // Alphabetic Presentation Forms
100
- '65024-65279', // Variation Selectors
101
- '65280-65535', // Halfwidth and Fullwidth Forms
102
- ],
118
+ COMPREHENSIVE_RANGES: buildComprehensiveRanges(),
103
119
  };
104
120
  // Style Configuration
105
121
  const STYLE_CONFIG = {
@@ -1190,6 +1206,22 @@ function parseTileKey(key) {
1190
1206
  }
1191
1207
  return { styleId, sourceId, z, x, y, ext };
1192
1208
  }
1209
+ /**
1210
+ * Extract the extension (the last dotted segment before `?`, `#`, or end) from
1211
+ * a tile URL or tile URL template. Defaults to `"pbf"` when no extension can
1212
+ * be parsed. For multi-extension URLs like Mapbox v4's `{y}.vector.pbf` this
1213
+ * returns `"pbf"`, matching the key used when the tile is stored.
1214
+ *
1215
+ * Keeping extraction logic in one place ensures patchStyleForOffline (which
1216
+ * rewrites tile URLs to `idb://` at load time) derives the same extension
1217
+ * that tileService.extractExtension used at store time — otherwise the
1218
+ * first-try lookup in idbFetchHandler misses and has to fall through its
1219
+ * pbf/mvt/png/jpg/webp fallback loop.
1220
+ */
1221
+ function extractTileExtensionFromUrl(url) {
1222
+ const match = url.match(/\.([\w]+)(?:[?#]|$)/i);
1223
+ return match ? match[1] : 'pbf';
1224
+ }
1193
1225
  /**
1194
1226
  * Derive tile extension from tile URL templates
1195
1227
  */
@@ -1197,15 +1229,210 @@ function deriveTileExtension(tiles) {
1197
1229
  if (Array.isArray(tiles) && tiles.length > 0) {
1198
1230
  const firstTile = tiles[0];
1199
1231
  if (typeof firstTile === 'string') {
1200
- const match = firstTile.match(/\.([\w]+)(?:\?|$)/i);
1201
- if (match) {
1202
- return match[1];
1203
- }
1232
+ return extractTileExtensionFromUrl(firstTile);
1204
1233
  }
1205
1234
  }
1206
1235
  return 'pbf';
1207
1236
  }
1208
1237
 
1238
+ /**
1239
+ * Pure helpers shared between the main-thread offline fetch handler
1240
+ * (`src/utils/idbFetchHandler.ts`) and the offline Service Worker
1241
+ * (`src/sw/offline-sw.ts`, compiled to `public/idb-offline-sw.js`).
1242
+ *
1243
+ * Keeping these in one place means the SW and the main-thread handler
1244
+ * can't drift — adding a new `model` handler, changing the fallback
1245
+ * order, or tweaking the tilejson-source matcher happens once.
1246
+ *
1247
+ * Nothing in here touches IndexedDB directly. Each helper takes already-
1248
+ * resolved inputs and returns the list of candidate keys (or the
1249
+ * resolved output) that the caller feeds into its own IDB lookup.
1250
+ *
1251
+ * The corresponding IDB access layer is:
1252
+ * - main thread: `idb` library via `dbPromise`
1253
+ * - service worker: raw `indexedDB.open` (see `offline-sw.ts`)
1254
+ *
1255
+ * They have different shapes so cannot be shared; the key computation
1256
+ * can be and is.
1257
+ */
1258
+ /**
1259
+ * Extensions to try in order when the requested extension misses. `glb` is
1260
+ * last so batched-model sources (Mapbox Standard 3D buildings) resolve when
1261
+ * their source URL template ended in `.vector` or similar and the actual
1262
+ * tile body was stored as glb.
1263
+ */
1264
+ const TILE_FALLBACK_EXTENSIONS = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'];
1265
+ /** Extensions minus the one the caller already tried. */
1266
+ function tileFallbackExtensions(requested) {
1267
+ return TILE_FALLBACK_EXTENSIONS.filter(e => e !== requested);
1268
+ }
1269
+ // ---------------------------------------------------------------------------
1270
+ // Region → style lookup
1271
+ // ---------------------------------------------------------------------------
1272
+ /**
1273
+ * Given an already-fetched list of style entries, find the first one whose
1274
+ * `regions` array contains the given ID. Pure — the caller is responsible for
1275
+ * loading the entries and for caching. Used by both `findStyleByRegionId`
1276
+ * implementations to keep the match rule identical.
1277
+ */
1278
+ function findStyleByRegionIdIn(styles, regionId) {
1279
+ for (const entry of styles) {
1280
+ const regions = entry.regions;
1281
+ if (!Array.isArray(regions))
1282
+ continue;
1283
+ for (const r of regions) {
1284
+ if (r?.regionId === regionId || r?.id === regionId) {
1285
+ return entry;
1286
+ }
1287
+ }
1288
+ }
1289
+ return null;
1290
+ }
1291
+ // ---------------------------------------------------------------------------
1292
+ // Glyph candidate keys
1293
+ // ---------------------------------------------------------------------------
1294
+ /**
1295
+ * Parse `FontA,FontB,FontC/0-255.pbf` into (fontstacks, rangePart). Mapbox
1296
+ * requests a comma-joined font-family fallback chain; each glyph is stored
1297
+ * individually, so the caller tries each fontstack in order.
1298
+ */
1299
+ function parseGlyphPath(decodedPath) {
1300
+ const pathParts = decodedPath.split('/');
1301
+ const fontstackPart = pathParts[0] ?? '';
1302
+ const rangePart = pathParts[1] || '0-255.pbf';
1303
+ const fontstacks = fontstackPart
1304
+ .split(',')
1305
+ .map(f => f.trim())
1306
+ .filter(Boolean);
1307
+ return { fontstacks, rangePart };
1308
+ }
1309
+ /**
1310
+ * Build the list of keys to try for a single (fontstack, range) pair.
1311
+ * Order: actualStyleId variants first (most common), then downloadId,
1312
+ * then the bare path. Normalized and raw `.pbf`-less forms are both tried
1313
+ * to cover stored-key variants from older versions.
1314
+ */
1315
+ function glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart) {
1316
+ const glyphPath = `${fontstack}/${rangePart}`;
1317
+ const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1318
+ return dedupe([
1319
+ `${actualStyleId}::${normalizedPath}`,
1320
+ `${actualStyleId}::${glyphPath}`,
1321
+ `${downloadId}::${normalizedPath}`,
1322
+ `${downloadId}::${glyphPath}`,
1323
+ normalizedPath,
1324
+ glyphPath,
1325
+ ]);
1326
+ }
1327
+ // ---------------------------------------------------------------------------
1328
+ // Sprite candidate keys
1329
+ // ---------------------------------------------------------------------------
1330
+ /**
1331
+ * Sprite keys have historically used both `::` and `:` as the separator, and
1332
+ * both the full filename (`sprite.json`) and the bare name (`sprite`). Return
1333
+ * every variant in priority order; the caller stops at the first hit.
1334
+ */
1335
+ function spriteCandidateKeys(actualStyleId, downloadId, decodedPath) {
1336
+ const stripExt = decodedPath.replace(/\.(json|png)$/i, '');
1337
+ return dedupe([
1338
+ `${actualStyleId}::${decodedPath}`,
1339
+ `${actualStyleId}:${decodedPath}`,
1340
+ `${actualStyleId}::${stripExt}`,
1341
+ `${actualStyleId}:${stripExt}`,
1342
+ `${downloadId}::${decodedPath}`,
1343
+ `${downloadId}:${decodedPath}`,
1344
+ `${downloadId}::${stripExt}`,
1345
+ `${downloadId}:${stripExt}`,
1346
+ decodedPath,
1347
+ ]);
1348
+ }
1349
+ // ---------------------------------------------------------------------------
1350
+ // Model candidate keys
1351
+ // ---------------------------------------------------------------------------
1352
+ /**
1353
+ * Model keys are `{styleId}::model::{name}`. Try the resolved style id first,
1354
+ * then the bare downloadId in case the request came through the region-scoped
1355
+ * URL form (`idb://{regionId}/model/{name}`).
1356
+ */
1357
+ function modelCandidateKeys(actualStyleId, downloadId, decodedPath) {
1358
+ return dedupe([
1359
+ `${actualStyleId}::model::${decodedPath}`,
1360
+ `${downloadId}::model::${decodedPath}`,
1361
+ ]);
1362
+ }
1363
+ // ---------------------------------------------------------------------------
1364
+ // TileJSON source matching
1365
+ // ---------------------------------------------------------------------------
1366
+ /**
1367
+ * Mapbox GL requests tilejson via `idb://{downloadId}/tilesjson/{path}` where
1368
+ * `{path}` may be the source id, the original TileJSON URL, or the URL we
1369
+ * stashed under `__originalTilesetUrl` when patching for offline. Try all
1370
+ * three; return the matching source id + its config, or null.
1371
+ */
1372
+ function matchTileJsonSource(sources, decodedPath) {
1373
+ const asConfig = (v) => v && typeof v === 'object' ? v : null;
1374
+ if (decodedPath in sources) {
1375
+ const config = asConfig(sources[decodedPath]);
1376
+ if (config)
1377
+ return { sourceId: decodedPath, config };
1378
+ }
1379
+ for (const [sourceId, raw] of Object.entries(sources)) {
1380
+ const config = asConfig(raw);
1381
+ if (!config)
1382
+ continue;
1383
+ const url = typeof config.url === 'string' ? config.url : undefined;
1384
+ const original = typeof config.__originalTilesetUrl === 'string'
1385
+ ? config.__originalTilesetUrl
1386
+ : undefined;
1387
+ if (url === decodedPath || original === decodedPath) {
1388
+ return { sourceId, config };
1389
+ }
1390
+ }
1391
+ return null;
1392
+ }
1393
+ /**
1394
+ * Build the offline TileJSON payload that replaces the one Mapbox would
1395
+ * have fetched from the network. `tiles` is rewritten to serve from the SW
1396
+ * (the caller supplies the scheme via `tileUrlScheme`); copyable TileJSON
1397
+ * fields are preserved.
1398
+ */
1399
+ function buildOfflineTileJson(sourceConfig, downloadId, sourceId, extension, tileUrlScheme, origin) {
1400
+ const base = `idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`
1401
+ ;
1402
+ const tileJson = {
1403
+ tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1404
+ name: sourceConfig.name ?? sourceId,
1405
+ tiles: [base],
1406
+ minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1407
+ maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1408
+ };
1409
+ const copyable = [
1410
+ 'bounds',
1411
+ 'center',
1412
+ 'vector_layers',
1413
+ 'scheme',
1414
+ 'attribution',
1415
+ 'encoding',
1416
+ 'format',
1417
+ 'grids',
1418
+ 'data',
1419
+ 'template',
1420
+ 'version',
1421
+ ];
1422
+ for (const field of copyable) {
1423
+ if (field in sourceConfig && sourceConfig[field] !== undefined) {
1424
+ tileJson[field] = sourceConfig[field];
1425
+ }
1426
+ }
1427
+ return tileJson;
1428
+ }
1429
+ // ---------------------------------------------------------------------------
1430
+ // Internal helpers
1431
+ // ---------------------------------------------------------------------------
1432
+ function dedupe(values) {
1433
+ return Array.from(new Set(values));
1434
+ }
1435
+
1209
1436
  // idbFetchHandler.ts
1210
1437
  // Intercepts idb:// URLs and serves resources from IndexedDB for MapLibre GL offline mode
1211
1438
  const idbLogger = logger.scope('IDBFetch');
@@ -1262,16 +1489,11 @@ async function findStyleByRegionId(db, regionId) {
1262
1489
  }
1263
1490
  try {
1264
1491
  const allStyles = await db.getAll('styles');
1265
- for (const styleEntry of allStyles) {
1266
- if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
1267
- const hasRegion = styleEntry.regions.some((r) => r.regionId === regionId || r.id === regionId);
1268
- if (hasRegion) {
1269
- idbLogger.debug(`Found style "${styleEntry.key}" containing region: ${regionId}`);
1270
- // Cache the result
1271
- regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
1272
- return styleEntry;
1273
- }
1274
- }
1492
+ const hit = findStyleByRegionIdIn(allStyles, regionId);
1493
+ if (hit) {
1494
+ idbLogger.debug(`Found style "${hit.key}" containing region: ${regionId}`);
1495
+ regionToStyleCache.set(regionId, { styleEntry: hit, timestamp: Date.now() });
1496
+ return hit;
1275
1497
  }
1276
1498
  idbLogger.debug(`No style found containing region: ${regionId}`);
1277
1499
  // Don't cache negative results — the region may be stored moments later
@@ -1283,36 +1505,6 @@ async function findStyleByRegionId(db, regionId) {
1283
1505
  return null;
1284
1506
  }
1285
1507
  }
1286
- function buildOfflineTileJson(sourceConfig, downloadId, sourceId) {
1287
- const extension = deriveTileExtension(sourceConfig.tiles);
1288
- const offlineTiles = [`idb://${downloadId}/tile/${sourceId}/{z}/{x}/{y}.${extension}`];
1289
- const tileJson = {
1290
- tilejson: typeof sourceConfig.tilejson === 'string' ? sourceConfig.tilejson : '2.2.0',
1291
- name: sourceConfig.name ?? sourceId,
1292
- tiles: offlineTiles,
1293
- minzoom: typeof sourceConfig.minzoom === 'number' ? sourceConfig.minzoom : 0,
1294
- maxzoom: typeof sourceConfig.maxzoom === 'number' ? sourceConfig.maxzoom : 22,
1295
- };
1296
- const fieldsToCopy = [
1297
- 'bounds',
1298
- 'center',
1299
- 'vector_layers',
1300
- 'scheme',
1301
- 'attribution',
1302
- 'encoding',
1303
- 'format',
1304
- 'grids',
1305
- 'data',
1306
- 'template',
1307
- 'version',
1308
- ];
1309
- for (const field of fieldsToCopy) {
1310
- if (field in sourceConfig && sourceConfig[field] !== undefined) {
1311
- tileJson[field] = sourceConfig[field];
1312
- }
1313
- }
1314
- return tileJson;
1315
- }
1316
1508
  async function createTileResponse(resource) {
1317
1509
  const headers = {};
1318
1510
  // Set proper content type for vector tiles (PBF/MVT format)
@@ -1407,7 +1599,7 @@ async function idbFetchHandler(url, init) {
1407
1599
  // but tiles are stored with integer zoom levels, so floor the value
1408
1600
  const z = Math.floor(parseFloat(pathParts[pathParts.length - 3]));
1409
1601
  const sourceKey = pathParts.slice(0, pathParts.length - 3).join('/');
1410
- const yMatch = yExt.match(/(\d+)\.(\w+)/);
1602
+ const yMatch = yExt.match(/^(\d+)\.(\w+)$/);
1411
1603
  if (yMatch) {
1412
1604
  const y = parseInt(yMatch[1]);
1413
1605
  const requestedExt = yMatch[2]; // Extension from URL (for logging only)
@@ -1422,7 +1614,7 @@ async function idbFetchHandler(url, init) {
1422
1614
  }
1423
1615
  idbLogger.debug(`Tile not found: ${tileKey}`);
1424
1616
  // Fallback: try common alternative extensions
1425
- const fallbackExtensions = ['pbf', 'mvt', 'png', 'jpg', 'webp', 'glb'].filter(ext => ext !== requestedExt);
1617
+ const fallbackExtensions = tileFallbackExtensions(requestedExt);
1426
1618
  for (const fallbackExt of fallbackExtensions) {
1427
1619
  const fallbackKey = createTileKey(x, y, z, actualStyleId, sourceKey, fallbackExt);
1428
1620
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1464,7 +1656,7 @@ async function idbFetchHandler(url, init) {
1464
1656
  return await createTileResponse(resource);
1465
1657
  }
1466
1658
  // Try alternative extensions
1467
- const fallbackExts = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter(e => e !== ext);
1659
+ const fallbackExts = tileFallbackExtensions(ext);
1468
1660
  for (const fallbackExt of fallbackExts) {
1469
1661
  const fallbackKey = createTileKey(parseInt(x), parseInt(y), parseInt(z), actualStyleId, fallbackSourceKey, fallbackExt);
1470
1662
  const fallbackResource = await db.get('tiles', fallbackKey);
@@ -1484,46 +1676,19 @@ async function idbFetchHandler(url, init) {
1484
1676
  }
1485
1677
  case 'glyph': {
1486
1678
  idbLogger.debug(`Looking for glyph with key: ${key}`);
1487
- // Find which style this region belongs to
1488
1679
  const styleEntry = await findStyleByRegionId(db, downloadId);
1489
1680
  const actualStyleId = styleEntry?.key || downloadId;
1490
- if (styleEntry && downloadId !== actualStyleId) {
1491
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1492
- }
1493
- // Parse the resource path: "FontA,FontB,FontC/0-255.pbf"
1494
- // MapLibre requests glyphs with comma-separated fallback fonts
1495
- // but glyphs are stored individually per font
1496
- const pathParts = decodedResourcePath.split('/');
1497
- const fontstackPart = pathParts[0]; // "FontA,FontB,FontC"
1498
- const rangePart = pathParts[1] || '0-255.pbf'; // "0-255.pbf"
1499
- // Split comma-separated fonts
1500
- const fontstacks = fontstackPart.split(',').map(f => f.trim());
1681
+ const { fontstacks, rangePart } = parseGlyphPath(decodedResourcePath);
1501
1682
  idbLogger.debug(`Trying ${fontstacks.length} fonts in fallback order: ${fontstacks.join(', ')}`);
1502
- // Try each font in order (this is how font fallbacks work)
1503
1683
  for (const fontstack of fontstacks) {
1504
- const glyphPath = `${fontstack}/${rangePart}`;
1505
- const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
1506
- const glyphCandidateKeys = [
1507
- // Try with actual style ID first
1508
- `${actualStyleId}::${normalizedPath}`,
1509
- `${actualStyleId}::${glyphPath}`,
1510
- // Then try with download ID
1511
- `${downloadId}::${normalizedPath}`,
1512
- `${downloadId}::${glyphPath}`,
1513
- // Just paths
1514
- normalizedPath,
1515
- glyphPath,
1516
- ];
1517
- idbLogger.debug(`Trying keys for font "${fontstack}":`, glyphCandidateKeys);
1518
- for (const candidateKey of glyphCandidateKeys) {
1684
+ const candidateKeys = glyphCandidateKeys(actualStyleId, downloadId, fontstack, rangePart);
1685
+ for (const candidateKey of candidateKeys) {
1519
1686
  const resource = await db.get('glyphs', candidateKey);
1520
1687
  if (resource?.data) {
1521
1688
  idbLogger.debug(`Found glyph using key: ${candidateKey} (font: ${fontstack})`);
1522
1689
  return new Response(resource.data, {
1523
1690
  status: 200,
1524
- headers: {
1525
- 'Content-Type': 'application/x-protobuf',
1526
- },
1691
+ headers: { 'Content-Type': 'application/x-protobuf' },
1527
1692
  });
1528
1693
  }
1529
1694
  }
@@ -1533,33 +1698,11 @@ async function idbFetchHandler(url, init) {
1533
1698
  }
1534
1699
  case 'sprite': {
1535
1700
  idbLogger.debug(`Looking for sprite with key: ${key}`);
1536
- // Find which style this region belongs to
1537
1701
  const styleEntry = await findStyleByRegionId(db, downloadId);
1538
1702
  const actualStyleId = styleEntry?.key || downloadId;
1539
- if (styleEntry && downloadId !== actualStyleId) {
1540
- idbLogger.debug(`Region "${downloadId}" belongs to style "${actualStyleId}", searching with style key`);
1541
- }
1542
- // The sprite service stores sprites with keys like: "voyager::sprite.json", "voyager::sprite@2x.json"
1543
- // MapLibre requests sprites as: "idb://region_XXX/sprite/sprite@2x.json"
1544
- // So we need to map the region ID to the style ID
1545
- const spriteCandidateKeys = Array.from(new Set([
1546
- // Try with actual style ID first (most likely to work)
1547
- `${actualStyleId}::${decodedResourcePath}`,
1548
- `${actualStyleId}:${decodedResourcePath}`,
1549
- `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1550
- `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1551
- // Then try with download ID (in case it's a direct style download)
1552
- `${downloadId}::${decodedResourcePath}`,
1553
- `${downloadId}:${decodedResourcePath}`,
1554
- `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1555
- `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
1556
- // Just the path itself
1557
- decodedResourcePath,
1558
- // Original key format
1559
- key,
1560
- ]));
1561
- idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, spriteCandidateKeys);
1562
- for (const candidateKey of spriteCandidateKeys) {
1703
+ const candidates = spriteCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1704
+ idbLogger.debug(`Sprite candidates for "${decodedResourcePath}":`, candidates);
1705
+ for (const candidateKey of candidates) {
1563
1706
  const resource = await db.get('sprites', candidateKey);
1564
1707
  if (resource?.data) {
1565
1708
  idbLogger.debug(`Found sprite using key: ${candidateKey}`);
@@ -1569,7 +1712,7 @@ async function idbFetchHandler(url, init) {
1569
1712
  });
1570
1713
  }
1571
1714
  }
1572
- idbLogger.warn(`Sprite not found, tried keys: ${spriteCandidateKeys.join(', ')}`);
1715
+ idbLogger.warn(`Sprite not found, tried keys: ${candidates.join(', ')}`);
1573
1716
  break;
1574
1717
  }
1575
1718
  case 'font': {
@@ -1585,18 +1728,12 @@ async function idbFetchHandler(url, init) {
1585
1728
  break;
1586
1729
  }
1587
1730
  case 'model': {
1588
- // Model URLs are rewritten by patchStyleForOffline to:
1731
+ // Model URLs are rewritten by patchStyleForOffline to
1589
1732
  // idb://{styleId}/model/{modelName}
1590
1733
  // Models are keyed by {styleId}::model::{modelName} in the store.
1591
- // Mirror the sprite resolution fallback: try the style ID first,
1592
- // then the download/region ID (in case the request came through a
1593
- // region-scoped URL).
1594
1734
  const styleEntry = await findStyleByRegionId(db, downloadId);
1595
1735
  const actualStyleId = styleEntry?.key || downloadId;
1596
- const candidates = Array.from(new Set([
1597
- `${actualStyleId}::model::${decodedResourcePath}`,
1598
- `${downloadId}::model::${decodedResourcePath}`,
1599
- ]));
1736
+ const candidates = modelCandidateKeys(actualStyleId, downloadId, decodedResourcePath);
1600
1737
  idbLogger.debug(`Model candidates for "${decodedResourcePath}":`, candidates);
1601
1738
  for (const candidateKey of candidates) {
1602
1739
  const resource = await db.get('models', candidateKey);
@@ -1613,9 +1750,9 @@ async function idbFetchHandler(url, init) {
1613
1750
  }
1614
1751
  case 'tilesjson': {
1615
1752
  idbLogger.debug(`Looking for tilejson with downloadId: ${downloadId}, resourcePath: ${decodedResourcePath}`);
1616
- // First try direct lookup (for style-level downloads)
1753
+ // First try direct lookup (for style-level downloads), then fall back
1754
+ // to searching by region ID (for region-level downloads).
1617
1755
  let styleEntry = await db.get('styles', downloadId);
1618
- // If not found, search by region ID (for region-level downloads)
1619
1756
  if (!styleEntry || !styleEntry.style?.sources) {
1620
1757
  idbLogger.debug(`Style not found with key "${downloadId}", searching by region ID...`);
1621
1758
  const foundStyle = await findStyleByRegionId(db, downloadId);
@@ -1623,41 +1760,23 @@ async function idbFetchHandler(url, init) {
1623
1760
  styleEntry = foundStyle;
1624
1761
  }
1625
1762
  }
1626
- if (styleEntry?.style?.sources) {
1627
- const sources = styleEntry.style.sources;
1628
- let matchedSourceId;
1629
- let matchedSourceConfig;
1630
- if (decodedResourcePath in sources) {
1631
- matchedSourceId = decodedResourcePath;
1632
- matchedSourceConfig = sources[decodedResourcePath];
1633
- }
1634
- else {
1635
- for (const [sourceId, sourceValue] of Object.entries(sources)) {
1636
- const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
1637
- const originalUrl = typeof sourceValue.__originalTilesetUrl === 'string'
1638
- ? sourceValue.__originalTilesetUrl
1639
- : undefined;
1640
- if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
1641
- matchedSourceId = sourceId;
1642
- matchedSourceConfig = sourceValue;
1643
- break;
1644
- }
1645
- }
1646
- }
1647
- if (matchedSourceId && matchedSourceConfig) {
1648
- const tileJson = buildOfflineTileJson(matchedSourceConfig, downloadId, matchedSourceId);
1649
- idbLogger.debug(`Serving offline tilejson for source: ${matchedSourceId}`);
1650
- return new Response(JSON.stringify(tileJson), {
1651
- status: 200,
1652
- headers: { 'Content-Type': 'application/json' },
1653
- });
1654
- }
1655
- idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1656
- }
1657
- else {
1763
+ if (!styleEntry?.style?.sources) {
1658
1764
  idbLogger.warn(`Style not found or missing sources for downloadId: ${downloadId}`);
1765
+ break;
1659
1766
  }
1660
- break;
1767
+ const sources = styleEntry.style.sources;
1768
+ const matched = matchTileJsonSource(sources, decodedResourcePath);
1769
+ if (!matched) {
1770
+ idbLogger.warn(`No matching source found for tilejson: ${decodedResourcePath}`);
1771
+ break;
1772
+ }
1773
+ const extension = deriveTileExtension(matched.config.tiles);
1774
+ const tileJson = buildOfflineTileJson(matched.config, downloadId, matched.sourceId, extension, 'idb');
1775
+ idbLogger.debug(`Serving offline tilejson for source: ${matched.sourceId}`);
1776
+ return new Response(JSON.stringify(tileJson), {
1777
+ status: 200,
1778
+ headers: { 'Content-Type': 'application/json' },
1779
+ });
1661
1780
  }
1662
1781
  default:
1663
1782
  idbLogger.warn(`Unknown resource type: ${type}`);
@@ -1681,6 +1800,37 @@ async function idbFetchHandler(url, init) {
1681
1800
  function isMapboxProtocol(url) {
1682
1801
  return url.startsWith(MAPBOX_API.PROTOCOL);
1683
1802
  }
1803
+ /**
1804
+ * Parse a URL and return its hostname, or null if the URL is malformed.
1805
+ * Accepts relative URLs when `base` is provided.
1806
+ */
1807
+ function getUrlHostname(url, base) {
1808
+ try {
1809
+ return new URL(url, base).hostname.toLowerCase();
1810
+ }
1811
+ catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+ /**
1816
+ * True if `url`'s hostname equals `host` or is a subdomain of `host`.
1817
+ * Uses URL parsing (not substring matching) to avoid false positives like
1818
+ * `https://evil.com/?x=mapbox.com` matching `mapbox.com`.
1819
+ */
1820
+ function hostMatches(url, host, base) {
1821
+ const hostname = getUrlHostname(url, base);
1822
+ if (hostname === null)
1823
+ return false;
1824
+ const target = host.toLowerCase();
1825
+ return hostname === target || hostname.endsWith('.' + target);
1826
+ }
1827
+ /**
1828
+ * True for any host under the mapbox.com domain (including api.mapbox.com,
1829
+ * *.tiles.mapbox.com, etc.). Used by provider detection.
1830
+ */
1831
+ function isMapboxHost(url, base) {
1832
+ return hostMatches(url, 'mapbox.com', base);
1833
+ }
1684
1834
  /**
1685
1835
  * Resolve a mapbox:// URL to its HTTPS API equivalent
1686
1836
  *
@@ -1754,9 +1904,7 @@ function rewriteMapboxCdnTileUrl(tileUrl) {
1754
1904
  */
1755
1905
  function detectStyleProvider(styleUrl, style) {
1756
1906
  // Check URL patterns
1757
- if (isMapboxProtocol(styleUrl) ||
1758
- styleUrl.includes('mapbox.com') ||
1759
- styleUrl.includes('api.mapbox.com')) {
1907
+ if (isMapboxProtocol(styleUrl) || isMapboxHost(styleUrl)) {
1760
1908
  return 'mapbox';
1761
1909
  }
1762
1910
  if (styleUrl.includes('maplibre') ||
@@ -1775,7 +1923,7 @@ function detectStyleProvider(styleUrl, style) {
1775
1923
  const sources = style.sources || {};
1776
1924
  for (const [, sourceConfig] of Object.entries(sources)) {
1777
1925
  const source = sourceConfig;
1778
- if (source.url && (source.url.includes('mapbox.com') || isMapboxProtocol(source.url))) {
1926
+ if (source.url && (isMapboxProtocol(source.url) || isMapboxHost(source.url))) {
1779
1927
  return 'mapbox';
1780
1928
  }
1781
1929
  }
@@ -1867,7 +2015,7 @@ function processStyleSources(style, provider, accessToken) {
1867
2015
  if (isMapboxProtocol(tileUrl) && accessToken) {
1868
2016
  return resolveMapboxUrl(tileUrl, accessToken);
1869
2017
  }
1870
- if (provider === 'mapbox' && accessToken && tileUrl.includes('mapbox.com')) {
2018
+ if (provider === 'mapbox' && accessToken && isMapboxHost(tileUrl)) {
1871
2019
  return normalizeStyleUrl(tileUrl, accessToken);
1872
2020
  }
1873
2021
  return tileUrl;
@@ -1882,7 +2030,7 @@ function processStyleSources(style, provider, accessToken) {
1882
2030
  if (isMapboxProtocol(processedStyle.sprite)) {
1883
2031
  processedStyle.sprite = resolveMapboxUrl(processedStyle.sprite, accessToken);
1884
2032
  }
1885
- else if (provider === 'mapbox' && processedStyle.sprite.includes('mapbox.com')) {
2033
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.sprite)) {
1886
2034
  processedStyle.sprite = normalizeStyleUrl(processedStyle.sprite, accessToken);
1887
2035
  }
1888
2036
  }
@@ -1893,7 +2041,7 @@ function processStyleSources(style, provider, accessToken) {
1893
2041
  if (isMapboxProtocol(entry.url)) {
1894
2042
  return { ...entry, url: resolveMapboxUrl(entry.url, accessToken) };
1895
2043
  }
1896
- else if (provider === 'mapbox' && entry.url.includes('mapbox.com')) {
2044
+ else if (provider === 'mapbox' && isMapboxHost(entry.url)) {
1897
2045
  return { ...entry, url: normalizeStyleUrl(entry.url, accessToken) };
1898
2046
  }
1899
2047
  }
@@ -1906,7 +2054,7 @@ function processStyleSources(style, provider, accessToken) {
1906
2054
  if (isMapboxProtocol(processedStyle.glyphs)) {
1907
2055
  processedStyle.glyphs = resolveMapboxUrl(processedStyle.glyphs, accessToken);
1908
2056
  }
1909
- else if (provider === 'mapbox' && processedStyle.glyphs.includes('mapbox.com')) {
2057
+ else if (provider === 'mapbox' && isMapboxHost(processedStyle.glyphs)) {
1910
2058
  processedStyle.glyphs = normalizeStyleUrl(processedStyle.glyphs, accessToken);
1911
2059
  }
1912
2060
  }
@@ -1946,13 +2094,20 @@ function validateStyleForProvider(style, provider) {
1946
2094
  // Check for Mapbox-specific requirements
1947
2095
  const hasMapboxSources = Object.values(style.sources || {}).some((source) => {
1948
2096
  const s = source;
1949
- return s.url && s.url.includes('mapbox.com');
2097
+ return !!s.url && isMapboxHost(s.url);
1950
2098
  });
1951
2099
  if (hasMapboxSources) {
1952
2100
  // Check if access token might be needed
1953
2101
  const hasAccessToken = Object.values(style.sources || {}).some((source) => {
1954
2102
  const s = source;
1955
- return s.url && s.url.includes('access_token');
2103
+ if (!s.url)
2104
+ return false;
2105
+ try {
2106
+ return new URL(s.url).searchParams.has('access_token');
2107
+ }
2108
+ catch {
2109
+ return false;
2110
+ }
1956
2111
  });
1957
2112
  if (!hasAccessToken) {
1958
2113
  warnings.push('Mapbox sources detected but no access token found - authentication may be required');
@@ -1986,14 +2141,15 @@ function patchStyleForOffline(style, downloadId, maxZoom, tileExtension, styleId
1986
2141
  styleLogger.debug(`Patching source: ${sourceKey}`, source);
1987
2142
  if (source.tiles) {
1988
2143
  const originalTiles = [...source.tiles];
1989
- // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext
2144
+ // Patch to idb://{downloadId}/tile/{sourceKey}/{z}/{x}/{y}.ext.
2145
+ // Extension extraction goes through the shared extractTileExtensionFromUrl
2146
+ // helper so the patched URL's extension matches what tileService used when
2147
+ // storing — otherwise Mapbox v4 tile URLs (`{y}.vector.pbf`) produced a
2148
+ // stored key under `.pbf` but a patched URL with `.vector`, forcing
2149
+ // idbFetchHandler to fall through its pbf/mvt/png/jpg/webp fallback loop
2150
+ // on every tile.
1990
2151
  source.tiles = source.tiles.map((url) => {
1991
- // Use stored tileExtension if available, otherwise try to extract from URL
1992
- let ext = tileExtension;
1993
- if (!ext) {
1994
- const extMatch = url.match(/\{z\}\/\{x\}\/\{y\}\.(\w+)/);
1995
- ext = extMatch ? extMatch[1] : 'pbf';
1996
- }
2152
+ const ext = tileExtension ?? extractTileExtensionFromUrl(url);
1997
2153
  return `idb://${downloadId}/tile/${sourceKey}/{z}/{x}/{y}.${ext}`;
1998
2154
  });
1999
2155
  styleLogger.debug(`Patched tiles for ${sourceKey} with extension .${tileExtension || 'pbf'}:`, {
@@ -2871,10 +3027,8 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2871
3027
  if (typeof prefixedLayer.source === 'string') {
2872
3028
  prefixedLayer.source = `${importId}/${prefixedLayer.source}`;
2873
3029
  }
2874
- // Resolve ["config", "key"] expressions using schema defaults and import overrides
2875
- if (Object.keys(configValues).length > 0) {
2876
- resolveConfigExpressions(prefixedLayer, configValues);
2877
- }
3030
+ // Resolve ["config", "key"] expressions using schema defaults and import overrides.
3031
+ resolveConfigExpressions(prefixedLayer, configValues);
2878
3032
  flattenedLayers.push(prefixedLayer);
2879
3033
  }
2880
3034
  }
@@ -2912,8 +3066,48 @@ async function resolveImportsRecursive(style, accessToken, visited, depth, maxRe
2912
3066
  if (!style.models && importedModels) {
2913
3067
  style.models = importedModels;
2914
3068
  }
3069
+ // Rewrite indoor-only expressions so the flattened style validates without
3070
+ // the `imports` wrapper at render time — see sanitizeIndoorExpressions.
3071
+ sanitizeIndoorExpressions(style);
2915
3072
  return style;
2916
3073
  }
3074
+ /**
3075
+ * Rewrite indoor-only expressions in a style's layers to their outdoor no-op
3076
+ * constants. See the in-line comment in `resolveValue` for why this is needed
3077
+ * for Mapbox Standard when the `imports` wrapper is stripped.
3078
+ *
3079
+ * Safe to call multiple times and on already-downloaded stored styles — the
3080
+ * rewrites are idempotent (after the first pass there are no more
3081
+ * `is-active-floor` / `floor-level` expressions to rewrite).
3082
+ */
3083
+ function sanitizeIndoorExpressions(style) {
3084
+ const layers = style.layers;
3085
+ if (!Array.isArray(layers))
3086
+ return;
3087
+ for (const layer of layers) {
3088
+ if (layer && typeof layer === 'object') {
3089
+ rewriteIndoor(layer);
3090
+ }
3091
+ }
3092
+ }
3093
+ function rewriteIndoor(obj) {
3094
+ for (const key of Object.keys(obj)) {
3095
+ obj[key] = rewriteIndoorValue(obj[key]);
3096
+ }
3097
+ }
3098
+ function rewriteIndoorValue(value) {
3099
+ if (!Array.isArray(value)) {
3100
+ if (value && typeof value === 'object' && !ArrayBuffer.isView(value)) {
3101
+ rewriteIndoor(value);
3102
+ }
3103
+ return value;
3104
+ }
3105
+ if (value[0] === 'is-active-floor')
3106
+ return false;
3107
+ if (value[0] === 'floor-level' && value.length === 1)
3108
+ return 0;
3109
+ return value.map(rewriteIndoorValue);
3110
+ }
2917
3111
  /**
2918
3112
  * Deep clone a plain object/array (JSON-safe values only).
2919
3113
  */
@@ -3079,6 +3273,40 @@ function mergeSprites(outer, imported) {
3079
3273
  return result;
3080
3274
  }
3081
3275
 
3276
+ const DEFAULT_WASM_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.14.1/dist/';
3277
+ let currentConfig = {};
3278
+ let sqlJsPromise = null;
3279
+ /**
3280
+ * Override how `sql.js` loads its WebAssembly. Call once before any MBTiles
3281
+ * import/export is invoked. Resets any cached init.
3282
+ */
3283
+ function configureSqlJs(config) {
3284
+ currentConfig = { ...config };
3285
+ sqlJsPromise = null;
3286
+ }
3287
+ /**
3288
+ * Lazily initialise `sql.js`. The underlying module is loaded via dynamic
3289
+ * `import()` so it only ships with bundles that actually call MBTiles code.
3290
+ */
3291
+ async function getSqlJs() {
3292
+ if (sqlJsPromise)
3293
+ return sqlJsPromise;
3294
+ sqlJsPromise = (async () => {
3295
+ const mod = (await import('sql.js'));
3296
+ const initSqlJs = mod.default;
3297
+ const options = {};
3298
+ if (currentConfig.wasmBinary) {
3299
+ options.wasmBinary = currentConfig.wasmBinary;
3300
+ }
3301
+ else {
3302
+ const base = currentConfig.wasmUrl ?? DEFAULT_WASM_URL;
3303
+ options.locateFile = (file) => base.endsWith('/') ? `${base}${file}` : `${base}/${file}`;
3304
+ }
3305
+ return initSqlJs(options);
3306
+ })();
3307
+ return sqlJsPromise;
3308
+ }
3309
+
3082
3310
  const fontLogger = logger.scope('FontService');
3083
3311
  class FontService {
3084
3312
  db = dbPromise;
@@ -3262,15 +3490,23 @@ class FontService {
3262
3490
  },
3263
3491
  };
3264
3492
  }
3265
- async cleanupOldFonts(maxAge = 30) {
3493
+ /**
3494
+ * Delete fonts older than `maxAge` days. When `options.styleId` is
3495
+ * provided, only fonts belonging to that style (per the delimiter-aware
3496
+ * `resourceKeyBelongsToStyle` match) are eligible — callers relying on
3497
+ * a styleId filter previously got a silent full-store wipe.
3498
+ */
3499
+ async cleanupOldFonts(maxAge = 30, options = {}) {
3266
3500
  const db = await this.db;
3267
3501
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3502
+ const { styleId } = options;
3268
3503
  const tx = db.transaction(['fonts'], 'readwrite');
3269
3504
  let deletedCount = 0;
3270
3505
  let cursor = await tx.objectStore('fonts').openCursor();
3271
3506
  while (cursor) {
3272
3507
  const fontEntry = cursor.value;
3273
- if (fontEntry.lastModified < cutoffTime) {
3508
+ const belongs = !styleId || resourceKeyBelongsToStyle(fontEntry.key, styleId);
3509
+ if (belongs && fontEntry.lastModified < cutoffTime) {
3274
3510
  await cursor.delete();
3275
3511
  deletedCount++;
3276
3512
  }
@@ -3436,7 +3672,7 @@ const fontService = new FontService();
3436
3672
  const downloadFonts = (fontUrls, styleName, options) => fontService.downloadFonts(fontUrls, styleName, options);
3437
3673
  const getFontStats = () => fontService.getFontStats();
3438
3674
  const getFontAnalytics = () => fontService.getFontAnalytics();
3439
- const cleanupOldFonts = (maxAge) => fontService.cleanupOldFonts(maxAge);
3675
+ const cleanupOldFonts = (maxAge, options) => fontService.cleanupOldFonts(maxAge, options);
3440
3676
  const verifyAndRepairFonts = () => fontService.verifyAndRepairFonts();
3441
3677
 
3442
3678
  const spriteLogger = logger.scope('SpriteService');
@@ -3747,19 +3983,24 @@ class SpriteService {
3747
3983
  };
3748
3984
  }
3749
3985
  /**
3750
- * Removes sprites older than the specified age
3986
+ * Remove sprites older than the specified age. When `options.styleId` is
3987
+ * provided, only sprites belonging to that style (per
3988
+ * `resourceKeyBelongsToStyle`) are eligible.
3751
3989
  * @param maxAge - Maximum age in days (default: 30)
3990
+ * @param options.styleId - Optional style filter; omit to scan all styles
3752
3991
  * @returns Promise resolving to number of deleted sprites
3753
3992
  */
3754
- async cleanupOldSprites(maxAge = 30) {
3993
+ async cleanupOldSprites(maxAge = 30, options = {}) {
3755
3994
  const db = await this.db;
3756
3995
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
3996
+ const { styleId } = options;
3757
3997
  const tx = db.transaction(['sprites'], 'readwrite');
3758
3998
  let deletedCount = 0;
3759
3999
  let cursor = await tx.objectStore('sprites').openCursor();
3760
4000
  while (cursor) {
3761
4001
  const spriteEntry = cursor.value;
3762
- if (spriteEntry.lastModified < cutoffTime) {
4002
+ const belongs = !styleId || resourceKeyBelongsToStyle(spriteEntry.key, styleId);
4003
+ if (belongs && spriteEntry.lastModified < cutoffTime) {
3763
4004
  await cursor.delete();
3764
4005
  deletedCount++;
3765
4006
  }
@@ -3913,7 +4154,7 @@ const spriteService = new SpriteService();
3913
4154
  const downloadSprites = (spriteUrls, styleName, options) => spriteService.downloadSprites(spriteUrls, styleName, options);
3914
4155
  const getSpriteStats = () => spriteService.getSpriteStats();
3915
4156
  const getSpriteAnalytics = () => spriteService.getSpriteAnalytics();
3916
- const cleanupOldSprites = (maxAge) => spriteService.cleanupOldSprites(maxAge);
4157
+ const cleanupOldSprites = (maxAge, options) => spriteService.cleanupOldSprites(maxAge, options);
3917
4158
  const verifyAndRepairSprites = () => spriteService.verifyAndRepairSprites();
3918
4159
 
3919
4160
  var spriteService$1 = /*#__PURE__*/Object.freeze({
@@ -5184,7 +5425,17 @@ class RegionService {
5184
5425
  // /styles/v1/{owner}/{style}/{hash}/iconset.pbf. The last path
5185
5426
  // segment is `sprite`, so replacing it with `iconset.pbf` works.
5186
5427
  const pathWithoutQuery = qIndex !== -1 ? spriteBase.slice(0, qIndex) : spriteBase;
5187
- const isMapboxStandardSprite = /api\.mapbox\.com\/styles\/v1\/.+\/sprite$/.test(pathWithoutQuery);
5428
+ let isMapboxStandardSprite = false;
5429
+ try {
5430
+ const parsed = new URL(pathWithoutQuery);
5431
+ isMapboxStandardSprite =
5432
+ parsed.hostname === 'api.mapbox.com' &&
5433
+ parsed.pathname.startsWith('/styles/v1/') &&
5434
+ parsed.pathname.endsWith('/sprite');
5435
+ }
5436
+ catch {
5437
+ // Non-URL sprite base (e.g. relative); not a Mapbox Standard sprite.
5438
+ }
5188
5439
  if (isMapboxStandardSprite) {
5189
5440
  // The path-rewrite suffix replaces the trailing `sprite` segment.
5190
5441
  suffixes.push('__ICONSET__');
@@ -6680,8 +6931,7 @@ class TileService {
6680
6931
  }
6681
6932
  }
6682
6933
  extractExtension(template) {
6683
- const extMatch = template.match(/\.([\w]+)(?:\?|$)/i);
6684
- return extMatch ? extMatch[1] : 'pbf';
6934
+ return extractTileExtensionFromUrl(template);
6685
6935
  }
6686
6936
  selectTileTemplate(templates, coord) {
6687
6937
  if (templates.length === 1) {
@@ -7063,14 +7313,21 @@ class GlyphService {
7063
7313
  },
7064
7314
  };
7065
7315
  }
7066
- async cleanupOldGlyphs(maxAge = 30) {
7316
+ /**
7317
+ * Remove glyphs older than the specified age. When `options.styleId` is
7318
+ * provided, only glyphs belonging to that style (per
7319
+ * `resourceKeyBelongsToStyle`) are eligible.
7320
+ */
7321
+ async cleanupOldGlyphs(maxAge = 30, options = {}) {
7067
7322
  const db = await this.db;
7068
7323
  const cutoffTime = Date.now() - maxAge * 24 * 60 * 60 * 1000;
7324
+ const { styleId } = options;
7069
7325
  let deletedCount = 0;
7070
7326
  const tx = db.transaction('glyphs', 'readwrite');
7071
7327
  for await (const cursor of tx.store) {
7072
7328
  const glyphEntry = cursor.value;
7073
- if (glyphEntry.lastModified < cutoffTime) {
7329
+ const belongs = !styleId || resourceKeyBelongsToStyle(glyphEntry.key, styleId);
7330
+ if (belongs && glyphEntry.lastModified < cutoffTime) {
7074
7331
  await cursor.delete();
7075
7332
  deletedCount++;
7076
7333
  }
@@ -7173,7 +7430,7 @@ const downloadGlyphs = (glyphUrl, fontstacks, styleName, ranges, options) => gly
7173
7430
  const loadGlyphs = (fontstack, ranges, styleName) => glyphService.loadGlyphs(fontstack, ranges, styleName);
7174
7431
  const getGlyphStats = () => glyphService.getGlyphStats();
7175
7432
  const getGlyphAnalytics = () => glyphService.getGlyphAnalytics();
7176
- const cleanupOldGlyphs = (maxAge) => glyphService.cleanupOldGlyphs(maxAge);
7433
+ const cleanupOldGlyphs = (maxAge, options) => glyphService.cleanupOldGlyphs(maxAge, options);
7177
7434
  const verifyAndRepairGlyphs = () => glyphService.verifyAndRepairGlyphs();
7178
7435
 
7179
7436
  var glyphService$1 = /*#__PURE__*/Object.freeze({
@@ -7408,8 +7665,7 @@ class ResourceService {
7408
7665
  return getFontAnalytics();
7409
7666
  }
7410
7667
  async cleanupOldFonts(styleId, options) {
7411
- const maxAge = options?.maxAge;
7412
- return cleanupOldFonts(maxAge);
7668
+ return cleanupOldFonts(options?.maxAge, { styleId });
7413
7669
  }
7414
7670
  async verifyAndRepairFonts() {
7415
7671
  return verifyAndRepairFonts();
@@ -7425,8 +7681,7 @@ class ResourceService {
7425
7681
  return getSpriteAnalytics();
7426
7682
  }
7427
7683
  async cleanupOldSprites(styleId, options) {
7428
- const maxAge = options?.maxAge;
7429
- return cleanupOldSprites(maxAge);
7684
+ return cleanupOldSprites(options?.maxAge, { styleId });
7430
7685
  }
7431
7686
  async verifyAndRepairSprites() {
7432
7687
  return verifyAndRepairSprites();
@@ -7445,8 +7700,7 @@ class ResourceService {
7445
7700
  return loadGlyphs(fontstack, ranges, styleId);
7446
7701
  }
7447
7702
  async cleanupOldGlyphs(styleId, options) {
7448
- const maxAge = options?.maxAge;
7449
- return cleanupOldGlyphs(maxAge);
7703
+ return cleanupOldGlyphs(options?.maxAge, { styleId });
7450
7704
  }
7451
7705
  async verifyAndRepairGlyphs() {
7452
7706
  return verifyAndRepairGlyphs();
@@ -7561,6 +7815,88 @@ class AnalyticsService {
7561
7815
  }
7562
7816
  }
7563
7817
 
7818
+ /**
7819
+ * MBTiles uses TMS tile_row ordering; our storage uses XYZ y. Flip across
7820
+ * either direction with the same formula.
7821
+ */
7822
+ function flipY(y, z) {
7823
+ return (1 << z) - 1 - y;
7824
+ }
7825
+ /** Vector tile formats that downstream consumers (QGIS, maplibre-native) expect gzipped. */
7826
+ const VECTOR_FORMATS = new Set(['pbf', 'mvt']);
7827
+ function hasGzipMagic(bytes) {
7828
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
7829
+ }
7830
+ async function drainReadable(readable) {
7831
+ const reader = readable.getReader();
7832
+ const chunks = [];
7833
+ let total = 0;
7834
+ while (true) {
7835
+ const { done, value } = await reader.read();
7836
+ if (done)
7837
+ break;
7838
+ if (value) {
7839
+ chunks.push(value);
7840
+ total += value.byteLength;
7841
+ }
7842
+ }
7843
+ const out = new Uint8Array(total);
7844
+ let offset = 0;
7845
+ for (const chunk of chunks) {
7846
+ out.set(chunk, offset);
7847
+ offset += chunk.byteLength;
7848
+ }
7849
+ return out;
7850
+ }
7851
+ async function transformBytes(bytes, transform) {
7852
+ const writer = transform.writable.getWriter();
7853
+ // Don't await — the read loop below drives the pipe and we only want
7854
+ // the final bytes, not back-pressure handling for a single chunk.
7855
+ void writer.write(bytes);
7856
+ void writer.close();
7857
+ return drainReadable(transform.readable);
7858
+ }
7859
+ async function gzipBytes(bytes) {
7860
+ return transformBytes(bytes, new CompressionStream('gzip'));
7861
+ }
7862
+ async function gunzipBytes(bytes) {
7863
+ return transformBytes(bytes, new DecompressionStream('gzip'));
7864
+ }
7865
+ /**
7866
+ * Build the MBTiles `json` metadata payload. For vector tiles this is
7867
+ * mandatory for tippecanoe/QGIS/maplibre-native to render — they read
7868
+ * `vector_layers` from here.
7869
+ *
7870
+ * `vector_layers` is inferred from the offline style's vector sources
7871
+ * (populated by the TileJSON expansion step in styleService). Multiple
7872
+ * vector sources are merged; duplicates de-duped by id, first wins.
7873
+ */
7874
+ function buildVectorJsonMetadata(style, sourceIds) {
7875
+ if (!style || typeof style !== 'object')
7876
+ return null;
7877
+ const sources = style.sources;
7878
+ if (!sources)
7879
+ return null;
7880
+ const merged = [];
7881
+ const seen = new Set();
7882
+ for (const [id, src] of Object.entries(sources)) {
7883
+ if (sourceIds.size > 0 && !sourceIds.has(id))
7884
+ continue;
7885
+ const layers = src?.vector_layers;
7886
+ if (!Array.isArray(layers))
7887
+ continue;
7888
+ for (const layer of layers) {
7889
+ const layerId = typeof layer?.id === 'string' ? layer.id : null;
7890
+ if (!layerId || seen.has(layerId))
7891
+ continue;
7892
+ seen.add(layerId);
7893
+ merged.push(layer);
7894
+ }
7895
+ }
7896
+ if (merged.length === 0)
7897
+ return null;
7898
+ return JSON.stringify({ vector_layers: merged });
7899
+ }
7564
7900
  const serviceLogger = logger.scope('ImportExportService');
7565
7901
  class ImportExportService {
7566
7902
  db = dbPromise;
@@ -7568,270 +7904,173 @@ class ImportExportService {
7568
7904
  // No need for initialization since dbPromise is already available
7569
7905
  }
7570
7906
  /**
7571
- * Export a region to JSON format
7907
+ * Export region as a real binary MBTiles SQLite file.
7908
+ *
7909
+ * Produces a v1.3-compliant MBTiles archive: `metadata` + `tiles` tables,
7910
+ * with `tile_row` flipped to TMS ordering. The resulting blob can be read
7911
+ * by tippecanoe, QGIS, maplibre-native, etc.
7572
7912
  */
7573
- async exportRegionAsJSON(regionId, options = {}) {
7913
+ async exportRegionAsMBTiles(regionId, options = {}) {
7574
7914
  const onProgress = options.onProgress || (() => { });
7575
7915
  try {
7576
7916
  onProgress({
7577
7917
  stage: 'preparing',
7578
7918
  percentage: 0,
7579
- message: 'Preparing export...',
7919
+ message: 'Preparing MBTiles export...',
7580
7920
  });
7581
- // Get region metadata
7582
7921
  const region = await this.getRegionMetadata(regionId);
7583
7922
  if (!region) {
7584
7923
  throw new Error(`Region ${regionId} not found`);
7585
7924
  }
7925
+ const tiles = await this.exportTiles(regionId, onProgress);
7926
+ // Pick format: caller override → region.tileExtension → default pbf.
7927
+ // Drives both the metadata row and whether tile bytes get gzipped.
7928
+ const format = String(options.format || region.tileExtension || 'pbf').toLowerCase();
7929
+ const isVector = VECTOR_FORMATS.has(format);
7586
7930
  onProgress({
7587
- stage: 'exporting',
7588
- percentage: 10,
7589
- message: 'Collecting region data...',
7931
+ stage: 'processing',
7932
+ percentage: 75,
7933
+ message: isVector ? 'Compressing vector tiles...' : 'Packing SQLite database...',
7590
7934
  });
7591
- const exportData = {
7592
- metadata: {
7593
- id: region.id,
7594
- name: region.name || region.id,
7595
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7596
- bounds: region.bounds,
7597
- minZoom: region.minZoom,
7598
- maxZoom: region.maxZoom,
7599
- styleUrl: region.styleUrl || '',
7600
- createdAt: region.created, // StoredRegion uses 'created' not 'createdAt'
7601
- exportedAt: Date.now(),
7602
- version: '1.0.0',
7603
- format: 'json',
7604
- },
7605
- style: {},
7606
- tiles: [],
7607
- sprites: [],
7608
- fonts: [],
7609
- };
7610
- // Export style if requested
7611
- if (options.includeStyle !== false) {
7612
- onProgress({
7613
- stage: 'exporting',
7614
- percentage: 20,
7615
- message: 'Exporting style data...',
7616
- });
7617
- exportData.style = await this.exportStyle(regionId);
7935
+ // Gzip vector tiles. Idempotent: skip tiles already gzipped (downloaded
7936
+ // with their original gzip wrapper intact).
7937
+ const packedTiles = [];
7938
+ for (const tile of tiles) {
7939
+ const raw = tile.data instanceof ArrayBuffer
7940
+ ? new Uint8Array(tile.data)
7941
+ : new Uint8Array(tile.data);
7942
+ const data = isVector && !hasGzipMagic(raw) ? await gzipBytes(raw) : raw;
7943
+ packedTiles.push({ z: tile.z, x: tile.x, y: tile.y, data });
7618
7944
  }
7619
- // Export tiles if requested
7620
- if (options.includeTiles !== false) {
7621
- onProgress({
7622
- stage: 'exporting',
7623
- percentage: 30,
7624
- message: 'Exporting tiles...',
7945
+ onProgress({
7946
+ stage: 'processing',
7947
+ percentage: 85,
7948
+ message: 'Packing SQLite database...',
7949
+ });
7950
+ const SQL = await getSqlJs();
7951
+ const db = new SQL.Database();
7952
+ try {
7953
+ db.run(`
7954
+ CREATE TABLE metadata (name TEXT, value TEXT);
7955
+ CREATE TABLE tiles (
7956
+ zoom_level INTEGER NOT NULL,
7957
+ tile_column INTEGER NOT NULL,
7958
+ tile_row INTEGER NOT NULL,
7959
+ tile_data BLOB
7960
+ );
7961
+ CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
7962
+ CREATE UNIQUE INDEX name ON metadata (name);
7963
+ `);
7964
+ const [[west, south], [east, north]] = region.bounds;
7965
+ const centerLon = (west + east) / 2;
7966
+ const centerLat = (south + north) / 2;
7967
+ const centerZoom = Math.max(region.minZoom, Math.min(region.maxZoom, Math.round((region.minZoom + region.maxZoom) / 2)));
7968
+ const metadataRows = {
7969
+ name: region.name || region.id,
7970
+ // MBTiles 1.3 type: 'overlay' or 'baselayer'. Baselayer matches how
7971
+ // QGIS treats the dataset (full-coverage map rather than overlay).
7972
+ type: isVector ? 'baselayer' : 'overlay',
7973
+ version: '1.0',
7974
+ description: region.name || region.id,
7975
+ format,
7976
+ bounds: `${west},${south},${east},${north}`,
7977
+ center: `${centerLon},${centerLat},${centerZoom}`,
7978
+ minzoom: String(region.minZoom),
7979
+ maxzoom: String(region.maxZoom),
7980
+ };
7981
+ // For vector tiles, the `json` field with `vector_layers` is required
7982
+ // by the MBTiles 1.3 spec and by every vector tile consumer worth
7983
+ // opening the file in. Derive it from the offline style.
7984
+ if (isVector) {
7985
+ const style = await this.exportStyle(regionId);
7986
+ const sourceIds = new Set(tiles.map(t => t.sourceId).filter(Boolean));
7987
+ const json = buildVectorJsonMetadata(style.style ?? style, sourceIds);
7988
+ if (json)
7989
+ metadataRows.json = json;
7990
+ }
7991
+ for (const [k, v] of Object.entries(options.metadata || {})) {
7992
+ metadataRows[k] = typeof v === 'string' ? v : JSON.stringify(v);
7993
+ }
7994
+ const insertMeta = db.prepare(`INSERT INTO metadata (name, value) VALUES (?, ?)`);
7995
+ try {
7996
+ for (const [name, value] of Object.entries(metadataRows)) {
7997
+ insertMeta.run([name, value]);
7998
+ }
7999
+ }
8000
+ finally {
8001
+ insertMeta.free();
8002
+ }
8003
+ const insertTile = db.prepare(`INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data)
8004
+ VALUES (?, ?, ?, ?)`);
8005
+ try {
8006
+ db.run('BEGIN');
8007
+ for (const tile of packedTiles) {
8008
+ insertTile.run([tile.z, tile.x, flipY(tile.y, tile.z), tile.data]);
8009
+ }
8010
+ db.run('COMMIT');
8011
+ }
8012
+ finally {
8013
+ insertTile.free();
8014
+ }
8015
+ const binary = db.export();
8016
+ const blob = new Blob([binary.buffer], {
8017
+ type: 'application/x-sqlite3',
7625
8018
  });
7626
- exportData.tiles = await this.exportTiles(regionId, onProgress);
7627
- }
7628
- // Export sprites if requested
7629
- if (options.includeSprites !== false) {
7630
8019
  onProgress({
7631
- stage: 'exporting',
7632
- percentage: 70,
7633
- message: 'Exporting sprites...',
8020
+ stage: 'complete',
8021
+ percentage: 100,
8022
+ message: 'MBTiles export complete!',
7634
8023
  });
7635
- exportData.sprites = await this.exportSprites(regionId);
8024
+ return {
8025
+ success: true,
8026
+ format: 'mbtiles',
8027
+ filename: `${region.name || region.id}.mbtiles`,
8028
+ blob,
8029
+ size: blob.size,
8030
+ statistics: {
8031
+ tilesExported: tiles.length,
8032
+ spritesExported: 0,
8033
+ fontsExported: 0,
8034
+ },
8035
+ };
7636
8036
  }
7637
- // Export fonts if requested
7638
- if (options.includeFonts !== false) {
7639
- onProgress({
7640
- stage: 'exporting',
7641
- percentage: 85,
7642
- message: 'Exporting fonts...',
7643
- });
7644
- exportData.fonts = await this.exportFonts(regionId);
8037
+ finally {
8038
+ db.close();
7645
8039
  }
7646
- onProgress({
7647
- stage: 'processing',
7648
- percentage: 95,
7649
- message: 'Creating export file...',
7650
- });
7651
- // Create JSON blob
7652
- const jsonString = JSON.stringify(exportData, null, 2);
7653
- const blob = new Blob([jsonString], { type: 'application/json' });
7654
- onProgress({
7655
- stage: 'complete',
7656
- percentage: 100,
7657
- message: 'Export complete!',
7658
- });
7659
- return {
7660
- success: true,
7661
- format: 'json',
7662
- filename: `${region.name || region.id}_export.json`,
7663
- blob,
7664
- size: blob.size,
7665
- statistics: {
7666
- tilesExported: exportData.tiles.length,
7667
- spritesExported: exportData.sprites.length,
7668
- fontsExported: exportData.fonts.length,
7669
- },
7670
- };
7671
8040
  }
7672
8041
  catch (error) {
7673
8042
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7674
- throw new Error(`Export failed: ${errorMessage}`);
8043
+ throw new Error(`MBTiles export failed: ${errorMessage}`);
7675
8044
  }
7676
8045
  }
7677
8046
  /**
7678
- * Export region as PMTiles format
8047
+ * Import region from a binary MBTiles (SQLite) file.
7679
8048
  */
7680
- async exportRegionAsPMTiles(regionId, options = {}) {
7681
- const onProgress = options.onProgress || (() => { });
8049
+ async importRegion(importData) {
8050
+ const onProgress = importData.onProgress || (() => { });
7682
8051
  try {
7683
8052
  onProgress({
7684
8053
  stage: 'preparing',
7685
8054
  percentage: 0,
7686
- message: 'Preparing PMTiles export...',
8055
+ message: 'Reading file...',
7687
8056
  });
7688
- // Note: This is a simplified implementation
7689
- // In a real implementation, you would use the PMTiles library
7690
- // to create a proper PMTiles file format
7691
- const region = await this.getRegionMetadata(regionId);
7692
- if (!region) {
7693
- throw new Error(`Region ${regionId} not found`);
8057
+ if (importData.format !== 'mbtiles') {
8058
+ throw new Error(`Unsupported format: ${importData.format}`);
7694
8059
  }
7695
- // Get tiles data
7696
- const tiles = await this.exportTiles(regionId, onProgress);
7697
- // Create PMTiles header and data structure
7698
- const pmtilesData = {
7699
- header: {
7700
- version: 3,
7701
- type: 'mvt',
7702
- compression: options.compression || 'gzip',
7703
- bounds: region.bounds,
7704
- minZoom: region.minZoom,
7705
- maxZoom: region.maxZoom,
7706
- metadata: {
7707
- name: region.name,
7708
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7709
- ...options.metadata,
7710
- },
7711
- },
7712
- tiles: tiles,
7713
- };
7714
- // Convert to binary format (simplified)
7715
- const jsonString = JSON.stringify(pmtilesData);
7716
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
8060
+ const buffer = await this.readFileAsArrayBuffer(importData.file);
8061
+ onProgress({ stage: 'importing', percentage: 40, message: 'Parsing MBTiles...' });
8062
+ const regionData = await this.parseMBTiles(buffer);
7717
8063
  onProgress({
7718
- stage: 'complete',
7719
- percentage: 100,
7720
- message: 'PMTiles export complete!',
7721
- });
7722
- return {
7723
- success: true,
7724
- format: 'pmtiles',
7725
- filename: `${region.name || region.id}.pmtiles`,
7726
- blob,
7727
- size: blob.size,
7728
- statistics: {
7729
- tilesExported: tiles.length,
7730
- spritesExported: 0,
7731
- fontsExported: 0,
7732
- },
7733
- };
7734
- }
7735
- catch (error) {
7736
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7737
- throw new Error(`PMTiles export failed: ${errorMessage}`);
7738
- }
7739
- }
7740
- /**
7741
- * Export region as MBTiles format
7742
- */
7743
- async exportRegionAsMBTiles(regionId, options = {}) {
7744
- const onProgress = options.onProgress || (() => { });
7745
- try {
7746
- onProgress({
7747
- stage: 'preparing',
7748
- percentage: 0,
7749
- message: 'Preparing MBTiles export...',
8064
+ stage: 'importing',
8065
+ percentage: 70,
8066
+ message: `Importing ${regionData.tiles?.length ?? 0} tiles...`,
7750
8067
  });
7751
- // Note: This is a simplified implementation
7752
- // In a real implementation, you would use SQLite/SQL.js
7753
- // to create a proper MBTiles SQLite database
7754
- const region = await this.getRegionMetadata(regionId);
7755
- if (!region) {
7756
- throw new Error(`Region ${regionId} not found`);
7757
- }
7758
- // Get tiles data
7759
- const tiles = await this.exportTiles(regionId, onProgress);
7760
- // Create MBTiles structure (simplified as JSON for now)
7761
- const mbtilesData = {
7762
- metadata: {
7763
- name: region.name,
7764
- type: 'overlay',
7765
- version: '1.0',
7766
- description: region.name || region.id, // StoredRegion doesn't have description, use name instead
7767
- format: options.format || 'pbf',
7768
- bounds: region.bounds.flat().join(','),
7769
- minzoom: region.minZoom,
7770
- maxzoom: region.maxZoom,
7771
- ...options.metadata,
7772
- },
7773
- tiles: tiles.map(tile => ({
7774
- zoom_level: tile.z,
7775
- tile_column: tile.x,
7776
- tile_row: tile.y,
7777
- tile_data: tile.data,
7778
- })),
7779
- };
7780
- // Convert to binary format (simplified)
7781
- const jsonString = JSON.stringify(mbtilesData);
7782
- const blob = new Blob([jsonString], { type: 'application/octet-stream' });
8068
+ const result = await this.importRegionData(regionData, importData);
7783
8069
  onProgress({
7784
8070
  stage: 'complete',
7785
8071
  percentage: 100,
7786
- message: 'MBTiles export complete!',
8072
+ message: result.success ? 'Import complete!' : result.message,
7787
8073
  });
7788
- return {
7789
- success: true,
7790
- format: 'mbtiles',
7791
- filename: `${region.name || region.id}.mbtiles`,
7792
- blob,
7793
- size: blob.size,
7794
- statistics: {
7795
- tilesExported: tiles.length,
7796
- spritesExported: 0,
7797
- fontsExported: 0,
7798
- },
7799
- };
7800
- }
7801
- catch (error) {
7802
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
7803
- throw new Error(`MBTiles export failed: ${errorMessage}`);
7804
- }
7805
- }
7806
- /**
7807
- * Import region from file
7808
- */
7809
- async importRegion(importData) {
7810
- try {
7811
- let regionData;
7812
- switch (importData.format) {
7813
- case 'json': {
7814
- const textContent = await this.readFileAsText(importData.file);
7815
- regionData = JSON.parse(textContent);
7816
- break;
7817
- }
7818
- case 'pmtiles': {
7819
- // PMTiles is a binary format; currently parsed as JSON (simplified impl)
7820
- const textContent = await this.readFileAsText(importData.file);
7821
- regionData = await this.parsePMTiles(textContent);
7822
- break;
7823
- }
7824
- case 'mbtiles': {
7825
- // MBTiles is a binary format; currently parsed as JSON (simplified impl)
7826
- const textContent = await this.readFileAsText(importData.file);
7827
- regionData = await this.parseMBTiles(textContent);
7828
- break;
7829
- }
7830
- default:
7831
- throw new Error(`Unsupported format: ${importData.format}`);
7832
- }
7833
- // Import the region data
7834
- const result = await this.importRegionData(regionData, importData);
7835
8074
  return result;
7836
8075
  }
7837
8076
  catch (error) {
@@ -7955,151 +8194,113 @@ class ImportExportService {
7955
8194
  }
7956
8195
  }
7957
8196
  /**
7958
- * Export sprites data
8197
+ * Read file content as ArrayBuffer (for the binary MBTiles file).
7959
8198
  */
7960
- async exportSprites(_regionId) {
7961
- const db = await this.db;
7962
- const transaction = db.transaction(['sprites'], 'readonly');
7963
- const store = transaction.objectStore('sprites');
7964
- const sprites = [];
7965
- try {
7966
- let cursor = await store.openCursor();
7967
- while (cursor) {
7968
- const sprite = cursor.value;
7969
- // Include sprites that match the styleId, or all sprites if keys don't contain styleId
7970
- // (sprite keys may or may not be prefixed with styleId depending on how they were stored)
7971
- sprites.push({
7972
- url: sprite.url,
7973
- data: sprite.data,
7974
- type: sprite.url.endsWith('.json') ? 'json' : 'png',
7975
- resolution: sprite.url.includes('@2x') ? '2x' : '1x',
7976
- });
7977
- cursor = await cursor.continue();
7978
- }
7979
- return sprites;
7980
- }
7981
- catch (error) {
7982
- serviceLogger.error('Error exporting sprites:', error);
7983
- return [];
7984
- }
7985
- }
7986
- /**
7987
- * Export fonts data
7988
- */
7989
- async exportFonts(_regionId) {
7990
- const db = await this.db;
7991
- const transaction = db.transaction(['fonts'], 'readonly');
7992
- const store = transaction.objectStore('fonts');
7993
- const fonts = [];
7994
- try {
7995
- let cursor = await store.openCursor();
7996
- while (cursor) {
7997
- const font = cursor.value;
7998
- // Include fonts that match the styleId, or all fonts if keys don't contain styleId
7999
- // (font keys may or may not be prefixed with styleId depending on how they were stored)
8000
- fonts.push({
8001
- fontStack: font.key, // Use key as fontstack identifier
8002
- range: '0-255', // Default range since FontEntry doesn't store this
8003
- data: font.data,
8004
- });
8005
- cursor = await cursor.continue();
8006
- }
8007
- return fonts;
8008
- }
8009
- catch (error) {
8010
- serviceLogger.error('Error exporting fonts:', error);
8011
- return [];
8012
- }
8013
- }
8014
- /**
8015
- * Read file content as text (for JSON files)
8016
- */
8017
- async readFileAsText(file) {
8199
+ async readFileAsArrayBuffer(file) {
8018
8200
  return new Promise((resolve, reject) => {
8019
8201
  const reader = new FileReader();
8020
8202
  reader.onload = () => resolve(reader.result);
8021
8203
  reader.onerror = () => reject(new Error('Failed to read file'));
8022
- reader.readAsText(file);
8204
+ reader.readAsArrayBuffer(file);
8023
8205
  });
8024
8206
  }
8025
8207
  /**
8026
- * Parse PMTiles file (simplified)
8208
+ * Parse a real binary MBTiles (SQLite) file into our import-data shape.
8209
+ * Un-flips the TMS tile_row back to XYZ y.
8027
8210
  */
8028
- async parsePMTiles(content) {
8029
- // This is a simplified implementation
8030
- // In reality, you would use the PMTiles library to parse the binary format
8031
- const data = JSON.parse(content);
8032
- const header = data?.header || {};
8033
- const metadata = header?.metadata || {};
8034
- return {
8035
- metadata: {
8036
- id: metadata.name || 'imported-region',
8037
- name: metadata.name || 'Imported Region',
8038
- description: metadata.description || '',
8039
- bounds: header.bounds || [
8040
- [0, 0],
8041
- [0, 0],
8042
- ],
8043
- minZoom: header.minZoom || 0,
8044
- maxZoom: header.maxZoom || 14,
8045
- styleUrl: '',
8046
- createdAt: Date.now(),
8047
- exportedAt: Date.now(),
8048
- version: '1.0.0',
8049
- format: 'pmtiles',
8050
- },
8051
- style: {},
8052
- tiles: data.tiles || [],
8053
- sprites: [],
8054
- fonts: [],
8055
- };
8056
- }
8057
- /**
8058
- * Parse MBTiles file (simplified)
8059
- */
8060
- async parseMBTiles(content) {
8061
- // This is a simplified implementation
8062
- // In reality, you would use SQL.js to parse the SQLite database
8063
- const data = JSON.parse(content);
8064
- const rawBounds = data.metadata?.bounds
8065
- ? data.metadata.bounds.split(',').map(Number)
8066
- : [0, 0, 0, 0];
8067
- // Ensure we have exactly 4 valid numbers
8068
- const bounds = [
8069
- isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8070
- isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8071
- isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8072
- isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8073
- ];
8074
- return {
8075
- metadata: {
8076
- id: data.metadata.name || 'imported-region',
8077
- name: data.metadata.name || 'Imported Region',
8078
- description: data.metadata.description,
8079
- bounds: [
8080
- [bounds[0], bounds[1]],
8081
- [bounds[2], bounds[3]],
8082
- ],
8083
- minZoom: data.metadata.minzoom || 0,
8084
- maxZoom: data.metadata.maxzoom || 14,
8085
- styleUrl: '',
8086
- createdAt: Date.now(),
8087
- exportedAt: Date.now(),
8088
- version: '1.0.0',
8089
- format: 'mbtiles',
8090
- },
8091
- style: {},
8092
- tiles: data.tiles.map((tile) => ({
8093
- z: tile.zoom_level,
8094
- x: tile.tile_column,
8095
- y: tile.tile_row,
8096
- data: tile.tile_data,
8097
- format: 'pbf',
8098
- sourceId: 'imported',
8099
- })) || [],
8100
- sprites: [],
8101
- fonts: [],
8102
- };
8211
+ async parseMBTiles(buffer) {
8212
+ const bytes = new Uint8Array(buffer);
8213
+ // SQLite header: "SQLite format 3\0" (16 bytes). Validate up front so
8214
+ // non-MBTiles files (e.g. a JSON renamed to .mbtiles) surface a clear
8215
+ // error instead of the opaque "file is not a database" from sql.js.
8216
+ if (bytes.byteLength < 16) {
8217
+ throw new Error('Not a valid MBTiles file: file is too small');
8218
+ }
8219
+ const magic = String.fromCharCode(...bytes.slice(0, 15));
8220
+ if (magic !== 'SQLite format 3') {
8221
+ throw new Error('Not a valid MBTiles file: missing SQLite header');
8222
+ }
8223
+ const SQL = await getSqlJs();
8224
+ const db = new SQL.Database(bytes);
8225
+ try {
8226
+ const tablesResult = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('metadata', 'tiles')");
8227
+ const tableNames = (tablesResult[0]?.values || []).map(r => r[0]);
8228
+ if (!tableNames.includes('metadata') || !tableNames.includes('tiles')) {
8229
+ throw new Error('Not a valid MBTiles file: missing required metadata/tiles tables');
8230
+ }
8231
+ const metadata = {};
8232
+ const metaStmt = db.prepare('SELECT name, value FROM metadata');
8233
+ try {
8234
+ while (metaStmt.step()) {
8235
+ const row = metaStmt.get();
8236
+ metadata[row[0]] = row[1];
8237
+ }
8238
+ }
8239
+ finally {
8240
+ metaStmt.free();
8241
+ }
8242
+ const rawBounds = metadata.bounds ? metadata.bounds.split(',').map(Number) : [0, 0, 0, 0];
8243
+ const bounds = [
8244
+ isFinite(rawBounds[0]) ? rawBounds[0] : 0,
8245
+ isFinite(rawBounds[1]) ? rawBounds[1] : 0,
8246
+ isFinite(rawBounds[2]) ? rawBounds[2] : 0,
8247
+ isFinite(rawBounds[3]) ? rawBounds[3] : 0,
8248
+ ];
8249
+ const format = (metadata.format || 'pbf');
8250
+ const isVector = VECTOR_FORMATS.has(format);
8251
+ const tiles = [];
8252
+ const tilesStmt = db.prepare('SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles');
8253
+ try {
8254
+ while (tilesStmt.step()) {
8255
+ const row = tilesStmt.get();
8256
+ const [z, x, tmsRow, data] = row;
8257
+ // Sliced copy so the buffer is detached from sql.js's heap.
8258
+ const copy = new Uint8Array(data.byteLength);
8259
+ copy.set(data);
8260
+ // Our IndexedDB stores vector tiles decompressed (tileService
8261
+ // inflates on download). MBTiles vector tiles are gzipped by
8262
+ // convention — un-gzip on the way in so the stored tile matches
8263
+ // what the fetch handler expects to serve.
8264
+ const storedBytes = isVector && hasGzipMagic(copy) ? await gunzipBytes(copy) : copy;
8265
+ tiles.push({
8266
+ z,
8267
+ x,
8268
+ y: flipY(tmsRow, z),
8269
+ data: storedBytes.buffer,
8270
+ format,
8271
+ sourceId: 'imported',
8272
+ });
8273
+ }
8274
+ }
8275
+ finally {
8276
+ tilesStmt.free();
8277
+ }
8278
+ const minZoom = metadata.minzoom !== undefined ? Number(metadata.minzoom) : 0;
8279
+ const maxZoom = metadata.maxzoom !== undefined ? Number(metadata.maxzoom) : 14;
8280
+ return {
8281
+ metadata: {
8282
+ id: metadata.name || 'imported-region',
8283
+ name: metadata.name || 'Imported Region',
8284
+ description: metadata.description,
8285
+ bounds: [
8286
+ [bounds[0], bounds[1]],
8287
+ [bounds[2], bounds[3]],
8288
+ ],
8289
+ minZoom,
8290
+ maxZoom,
8291
+ styleUrl: '',
8292
+ createdAt: Date.now(),
8293
+ exportedAt: Date.now(),
8294
+ version: '1.0.0',
8295
+ format: 'mbtiles',
8296
+ },
8297
+ style: {},
8298
+ tiles,
8299
+ };
8300
+ }
8301
+ finally {
8302
+ db.close();
8303
+ }
8103
8304
  }
8104
8305
  /**
8105
8306
  * Import region data to database
@@ -8164,16 +8365,15 @@ class ImportExportService {
8164
8365
  });
8165
8366
  }
8166
8367
  }
8167
- // Import sprites and fonts similarly...
8168
8368
  return {
8169
8369
  success: true,
8170
8370
  regionId,
8171
8371
  message: 'Region imported successfully',
8172
8372
  statistics: {
8173
8373
  tilesImported: regionData.tiles?.length || 0,
8174
- spritesImported: regionData.sprites?.length || 0,
8175
- fontsImported: regionData.fonts?.length || 0,
8176
- totalSize: 0, // Calculate if needed
8374
+ spritesImported: 0,
8375
+ fontsImported: 0,
8376
+ totalSize: 0,
8177
8377
  },
8178
8378
  };
8179
8379
  }
@@ -8402,8 +8602,6 @@ const createMaintenanceManagement = (services, deps) => {
8402
8602
  };
8403
8603
 
8404
8604
  const createImportExportManagement = (services) => ({
8405
- exportRegionAsJSON: async (regionId, options = {}) => services.importExportService.exportRegionAsJSON(regionId, options),
8406
- exportRegionAsPMTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsPMTiles(regionId, options),
8407
8605
  exportRegionAsMBTiles: async (regionId, options = {}) => services.importExportService.exportRegionAsMBTiles(regionId, options),
8408
8606
  importRegion: async (importData) => services.importExportService.importRegion(importData),
8409
8607
  downloadExportedRegion: (exportResult) => {
@@ -8759,10 +8957,6 @@ const en = {
8759
8957
  'styleSelection.title': 'Select Offline Style',
8760
8958
  'styleSelection.message': 'Choose which offline style to load:',
8761
8959
  'styleSelection.sources': 'sources',
8762
- // Import/Export
8763
- 'importExport.title': 'Import/Export',
8764
- 'importExport.export': 'Export',
8765
- 'importExport.import': 'Import',
8766
8960
  // Errors
8767
8961
  'error.loadingContent': 'Error loading content',
8768
8962
  'error.tryAgain': 'Please try again',
@@ -8787,41 +8981,30 @@ const en = {
8787
8981
  'regionDetails.bounds': 'Bounds',
8788
8982
  'regionDetails.zoomRange': 'Zoom Range',
8789
8983
  'regionDetails.created': 'Created',
8790
- // Import/Export Modal
8791
- 'importExport.regionTitle': 'Import/Export Region',
8792
- 'importExport.regionInfo': 'Region Information',
8793
- 'importExport.id': 'ID',
8794
- 'importExport.name': 'Name',
8795
- 'importExport.unnamed': 'Unnamed',
8796
- 'importExport.zoom': 'Zoom',
8797
- 'importExport.created': 'Created',
8798
- 'importExport.exportRegion': 'Export Region',
8799
- 'importExport.exportFormat': 'Export Format',
8800
- 'importExport.formatJson': 'JSON - Complete data (recommended)',
8801
- 'importExport.formatPmtiles': 'PMTiles - Web optimized tiles',
8802
- 'importExport.formatMbtiles': 'MBTiles - Industry standard',
8803
- 'importExport.formatHint': 'Choose format based on your use case',
8804
- 'importExport.includeComponents': 'Include Components',
8805
- 'importExport.styleConfig': 'Style Configuration',
8806
- 'importExport.mapTiles': 'Map Tiles',
8807
- 'importExport.spritesIcons': 'Sprites & Icons',
8808
- 'importExport.fontsGlyphs': 'Fonts & Glyphs',
8809
- 'importExport.preparingExport': 'Preparing export...',
8810
- 'importExport.exportComplete': 'Export complete!',
8811
- 'importExport.exportFailed': 'Export failed. Please try again.',
8812
- 'importExport.importRegion': 'Import Region',
8813
- 'importExport.selectFile': 'Select File',
8814
- 'importExport.fileFormatsHint': 'Supports JSON, PMTiles, and MBTiles formats',
8815
- 'importExport.newRegionName': 'New Region Name (Optional)',
8816
- 'importExport.newRegionNamePlaceholder': 'Leave empty to use original name',
8817
- 'importExport.overwriteIfExists': 'Overwrite if region exists',
8818
- 'importExport.preparingImport': 'Preparing import...',
8819
- 'importExport.importComplete': 'Import complete!',
8820
- 'importExport.importFailed': 'Import failed. Please try again.',
8821
- 'importExport.formatGuide': 'Format Guide',
8822
- 'importExport.jsonDesc': 'Complete data, human-readable, best for development',
8823
- 'importExport.pmtilesDesc': 'Web-optimized, efficient serving, cloud-friendly',
8824
- 'importExport.mbtilesDesc': 'Industry standard, SQLite-based, cross-platform',
8984
+ // MBTiles Modal
8985
+ 'mbtiles.title': 'MBTiles Import / Export',
8986
+ 'mbtiles.regionInfo': 'Region Information',
8987
+ 'mbtiles.id': 'ID',
8988
+ 'mbtiles.name': 'Name',
8989
+ 'mbtiles.unnamed': 'Unnamed',
8990
+ 'mbtiles.zoom': 'Zoom',
8991
+ 'mbtiles.created': 'Created',
8992
+ 'mbtiles.exportTitle': 'Export as MBTiles',
8993
+ 'mbtiles.exportHint': 'Package the tiles in this region into a standard SQLite MBTiles archive that opens in QGIS, tippecanoe, and other tools.',
8994
+ 'mbtiles.exportButton': 'Download .mbtiles',
8995
+ 'mbtiles.preparingExport': 'Preparing export...',
8996
+ 'mbtiles.exportComplete': 'Export complete!',
8997
+ 'mbtiles.exportFailed': 'Export failed. Please try again.',
8998
+ 'mbtiles.importTitle': 'Import from MBTiles',
8999
+ 'mbtiles.selectFile': 'Select an .mbtiles file',
9000
+ 'mbtiles.fileHint': 'Only SQLite-format .mbtiles files are supported.',
9001
+ 'mbtiles.newRegionName': 'New Region Name (optional)',
9002
+ 'mbtiles.newRegionNamePlaceholder': 'Leave empty to use the name from the file',
9003
+ 'mbtiles.overwriteIfExists': 'Overwrite if a region with the same id exists',
9004
+ 'mbtiles.importButton': 'Import .mbtiles',
9005
+ 'mbtiles.preparingImport': 'Preparing import...',
9006
+ 'mbtiles.importComplete': 'Import complete!',
9007
+ 'mbtiles.importFailed': 'Import failed. Please try again.',
8825
9008
  // Active Downloads
8826
9009
  'download.activeCount': 'Active Downloads ({{count}})',
8827
9010
  // Panel Manager additional strings
@@ -8982,10 +9165,6 @@ const ar = {
8982
9165
  'styleSelection.title': 'اختر نمط غير متصل',
8983
9166
  'styleSelection.message': 'اختر النمط غير المتصل الذي تريد تحميله:',
8984
9167
  'styleSelection.sources': 'مصادر',
8985
- // Import/Export - استيراد/تصدير
8986
- 'importExport.title': 'استيراد/تصدير',
8987
- 'importExport.export': 'تصدير',
8988
- 'importExport.import': 'استيراد',
8989
9168
  // Errors - الأخطاء
8990
9169
  'error.loadingContent': 'خطأ في تحميل المحتوى',
8991
9170
  'error.tryAgain': 'يرجى المحاولة مرة أخرى',
@@ -9010,41 +9189,30 @@ const ar = {
9010
9189
  'regionDetails.bounds': 'الحدود',
9011
9190
  'regionDetails.zoomRange': 'نطاق التكبير',
9012
9191
  'regionDetails.created': 'تاريخ الإنشاء',
9013
- // Import/Export Modal - نافذة الاستيراد/التصدير
9014
- 'importExport.regionTitle': 'استيراد/تصدير المنطقة',
9015
- 'importExport.regionInfo': 'معلومات المنطقة',
9016
- 'importExport.id': 'المعرف',
9017
- 'importExport.name': 'الاسم',
9018
- 'importExport.unnamed': 'بدون اسم',
9019
- 'importExport.zoom': 'التكبير',
9020
- 'importExport.created': 'تاريخ الإنشاء',
9021
- 'importExport.exportRegion': 'تصدير المنطقة',
9022
- 'importExport.exportFormat': 'تنسيق التصدير',
9023
- 'importExport.formatJson': 'JSON - بيانات كاملة (موصى به)',
9024
- 'importExport.formatPmtiles': 'PMTiles - بلاطات محسنة للويب',
9025
- 'importExport.formatMbtiles': 'MBTiles - معيار الصناعة',
9026
- 'importExport.formatHint': 'اختر التنسيق بناءً على حالة استخدامك',
9027
- 'importExport.includeComponents': 'تضمين المكونات',
9028
- 'importExport.styleConfig': 'إعدادات النمط',
9029
- 'importExport.mapTiles': 'بلاطات الخريطة',
9030
- 'importExport.spritesIcons': 'الرموز والأيقونات',
9031
- 'importExport.fontsGlyphs': 'الخطوط والحروف',
9032
- 'importExport.preparingExport': 'جاري تجهيز التصدير...',
9033
- 'importExport.exportComplete': 'اكتمل التصدير!',
9034
- 'importExport.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9035
- 'importExport.importRegion': 'استيراد المنطقة',
9036
- 'importExport.selectFile': 'اختر ملف',
9037
- 'importExport.fileFormatsHint': 'يدعم تنسيقات JSON و PMTiles و MBTiles',
9038
- 'importExport.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9039
- 'importExport.newRegionNamePlaceholder': 'اتركه فارغاً لاستخدام الاسم الأصلي',
9040
- 'importExport.overwriteIfExists': 'الكتابة فوق المنطقة الموجودة',
9041
- 'importExport.preparingImport': 'جاري تجهيز الاستيراد...',
9042
- 'importExport.importComplete': 'اكتمل الاستيراد!',
9043
- 'importExport.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9044
- 'importExport.formatGuide': 'دليل التنسيقات',
9045
- 'importExport.jsonDesc': 'بيانات كاملة، قابلة للقراءة، الأفضل للتطوير',
9046
- 'importExport.pmtilesDesc': 'محسن للويب، خدمة فعالة، متوافق مع السحابة',
9047
- 'importExport.mbtilesDesc': 'معيار الصناعة، قائم على SQLite، متعدد المنصات',
9192
+ // MBTiles Modal - نافذة MBTiles
9193
+ 'mbtiles.title': 'استيراد / تصدير MBTiles',
9194
+ 'mbtiles.regionInfo': 'معلومات المنطقة',
9195
+ 'mbtiles.id': 'المعرف',
9196
+ 'mbtiles.name': 'الاسم',
9197
+ 'mbtiles.unnamed': 'بدون اسم',
9198
+ 'mbtiles.zoom': 'التكبير',
9199
+ 'mbtiles.created': 'تاريخ الإنشاء',
9200
+ 'mbtiles.exportTitle': 'التصدير كـ MBTiles',
9201
+ 'mbtiles.exportHint': 'احزم بلاطات هذه المنطقة داخل أرشيف MBTiles (SQLite) يمكن فتحه في QGIS و tippecanoe وأدوات أخرى.',
9202
+ 'mbtiles.exportButton': 'تنزيل ملف mbtiles.',
9203
+ 'mbtiles.preparingExport': 'جاري تجهيز التصدير...',
9204
+ 'mbtiles.exportComplete': 'اكتمل التصدير!',
9205
+ 'mbtiles.exportFailed': 'فشل التصدير. يرجى المحاولة مرة أخرى.',
9206
+ 'mbtiles.importTitle': 'الاستيراد من MBTiles',
9207
+ 'mbtiles.selectFile': 'اختر ملف mbtiles.',
9208
+ 'mbtiles.fileHint': 'تُدعم ملفات mbtiles. بتنسيق SQLite فقط.',
9209
+ 'mbtiles.newRegionName': 'اسم المنطقة الجديد (اختياري)',
9210
+ 'mbtiles.newRegionNamePlaceholder': 'اتركه فارغًا لاستخدام الاسم من الملف',
9211
+ 'mbtiles.overwriteIfExists': 'الكتابة فوق المنطقة إذا كان المعرف موجودًا',
9212
+ 'mbtiles.importButton': 'استيراد ملف mbtiles.',
9213
+ 'mbtiles.preparingImport': 'جاري تجهيز الاستيراد...',
9214
+ 'mbtiles.importComplete': 'اكتمل الاستيراد!',
9215
+ 'mbtiles.importFailed': 'فشل الاستيراد. يرجى المحاولة مرة أخرى.',
9048
9216
  // Active Downloads - التحميلات النشطة
9049
9217
  'download.activeCount': 'التحميلات النشطة ({{count}})',
9050
9218
  // Panel Manager additional strings - سلاسل إضافية للوحة
@@ -9983,49 +10151,42 @@ class ConfirmationModal {
9983
10151
  }
9984
10152
 
9985
10153
  /**
9986
- * Import/Export Modal Component
9987
- * Handles import/export operations for regions
9988
- * Refactored to use modular Modal component for consistency
10154
+ * MBTiles Import/Export Modal
10155
+ *
10156
+ * Focused modal for exchanging regions as binary SQLite MBTiles archives.
10157
+ * Replaces the previous multi-format import/export modal.
9989
10158
  */
9990
- const modalLogger = logger.scope('ImportExportModal');
9991
- class ImportExportModal {
10159
+ const modalLogger = logger.scope('MBTilesModal');
10160
+ class MBTilesModal {
9992
10161
  modal;
9993
10162
  options;
9994
10163
  isExporting = false;
9995
10164
  isImporting = false;
9996
- // Form elements
9997
- exportFormatSelect;
9998
- includeStyleCheckbox;
9999
- includeTilesCheckbox;
10000
- includeSpritesCheckbox;
10001
- includeFontsCheckbox;
10002
10165
  exportProgressBar;
10003
10166
  exportProgressText;
10167
+ exportProgressContainer;
10004
10168
  exportButton;
10005
10169
  importFileInput;
10006
10170
  importNameInput;
10007
10171
  importOverwriteCheckbox;
10008
10172
  importProgressBar;
10009
10173
  importProgressText;
10174
+ importProgressContainer;
10010
10175
  importButton;
10011
10176
  constructor(options) {
10012
10177
  this.options = options;
10013
10178
  }
10014
10179
  show() {
10015
10180
  const modalConfig = {
10016
- title: t('importExport.regionTitle'),
10181
+ title: t('mbtiles.title'),
10017
10182
  subtitle: this.options.region.name || this.options.region.id,
10018
10183
  size: 'md',
10019
10184
  closable: true,
10020
10185
  onClose: () => this.hide(),
10021
10186
  };
10022
10187
  this.modal = new Modal(modalConfig);
10023
- // Create content
10024
- const content = this.createContent();
10025
- this.modal.setContent(content);
10026
- // Create footer with close button
10027
- const footer = this.createFooter();
10028
- this.modal.setFooter(footer);
10188
+ this.modal.setContent(this.createContent());
10189
+ this.modal.setFooter(this.createFooter());
10029
10190
  this.modal.show();
10030
10191
  this.attachEventListeners();
10031
10192
  return this.modal.getElement();
@@ -10034,217 +10195,96 @@ class ImportExportModal {
10034
10195
  this.modal?.hide();
10035
10196
  this.options.onClose();
10036
10197
  }
10198
+ destroy() {
10199
+ this.modal?.destroy();
10200
+ }
10037
10201
  createContent() {
10038
10202
  const content = document.createElement('div');
10039
- content.className = 'flex flex-col gap-6';
10203
+ content.className = 'flex flex-col gap-6 py-2';
10040
10204
  if (i18n.isRTL()) {
10041
10205
  content.setAttribute('dir', 'rtl');
10042
10206
  }
10043
- // Region Info Card
10044
- const infoCard = this.createRegionInfoCard();
10045
- content.appendChild(infoCard);
10046
- // Export/Import Grid
10047
- const gridContainer = document.createElement('div');
10048
- gridContainer.className = 'grid grid-cols-1 gap-6';
10049
- // Export Section
10050
- const exportSection = this.createExportSection();
10051
- gridContainer.appendChild(exportSection);
10052
- // Import Section
10053
- const importSection = this.createImportSection();
10054
- gridContainer.appendChild(importSection);
10055
- content.appendChild(gridContainer);
10056
- // Format Guide
10057
- const formatGuide = this.createFormatGuide();
10058
- content.appendChild(formatGuide);
10207
+ content.appendChild(this.createRegionInfoLine());
10208
+ content.appendChild(this.createExportSection());
10209
+ content.appendChild(this.createImportSection());
10059
10210
  return content;
10060
10211
  }
10061
- createRegionInfoCard() {
10062
- const card = document.createElement('div');
10063
- card.className = 'p-5 glass-input rounded-xl border-0 bg-gray-50/50 dark:bg-gray-800/50';
10064
- card.innerHTML = `
10065
- <h4 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-white mb-4 flex items-center gap-2">
10066
- ${icons.mapPin({ size: 16, color: 'currentColor' })}
10067
- ${t('importExport.regionInfo')}
10068
- </h4>
10069
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
10070
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10071
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.id')}</span>
10072
- <div class="text-gray-900 dark:text-white font-mono text-xs break-all mt-1">${escapeHtml$1(this.options.region.id)}</div>
10073
- </div>
10074
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10075
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.name')}</span>
10076
- <div class="text-gray-900 dark:text-white font-medium mt-1">${escapeHtml$1(this.options.region.name || t('importExport.unnamed'))}</div>
10077
- </div>
10078
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10079
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.zoom')}</span>
10080
- <div class="text-gray-900 dark:text-white font-medium mt-1">Z${escapeHtml$1(this.options.region.minZoom)}-${escapeHtml$1(this.options.region.maxZoom)}</div>
10081
- </div>
10082
- <div class="p-3 rounded-lg bg-white/40 dark:bg-black/20">
10083
- <span class="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">${t('importExport.created')}</span>
10084
- <div class="text-gray-900 dark:text-white font-medium mt-1">${new Date(this.options.region.created).toLocaleDateString()}</div>
10085
- </div>
10086
- </div>
10212
+ createRegionInfoLine() {
10213
+ const { region } = this.options;
10214
+ const line = document.createElement('div');
10215
+ line.className =
10216
+ 'flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400';
10217
+ line.innerHTML = `
10218
+ <span class="flex items-center gap-1">
10219
+ ${icons.mapPin({ size: 12, color: 'currentColor' })}
10220
+ <span class="font-mono">${escapeHtml$1(region.id)}</span>
10221
+ </span>
10222
+ <span>Z${escapeHtml$1(region.minZoom)}-${escapeHtml$1(region.maxZoom)}</span>
10223
+ <span>${new Date(region.created).toLocaleDateString()}</span>
10087
10224
  `;
10088
- return card;
10225
+ return line;
10089
10226
  }
10090
10227
  createExportSection() {
10091
- const section = document.createElement('div');
10092
- section.className =
10093
- 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden group hover:scale-[1.01] transition-transform duration-300';
10094
- // Gradient accent
10095
- const accent = document.createElement('div');
10096
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-blue-500 opacity-50`;
10097
- section.appendChild(accent);
10098
- const header = document.createElement('h3');
10099
- header.className =
10100
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10101
- header.innerHTML = `
10102
- <div class="p-2 bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400">
10103
- ${icons.upload({ size: 20, color: 'currentColor' })}
10104
- </div>
10105
- ${t('importExport.exportRegion')}
10106
- `;
10107
- section.appendChild(header);
10108
- const formContainer = document.createElement('div');
10109
- formContainer.className = 'space-y-5';
10110
- // Format Selection
10111
- const formatGroup = document.createElement('div');
10112
- const formatLabel = document.createElement('label');
10113
- formatLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10114
- formatLabel.textContent = t('importExport.exportFormat');
10115
- this.exportFormatSelect = document.createElement('select');
10116
- this.exportFormatSelect.className =
10117
- 'w-full px-4 py-3 rounded-xl text-sm glass-input text-gray-900 dark:text-white bg-white/50 dark:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all';
10118
- this.exportFormatSelect.innerHTML = `
10119
- <option value="json">${t('importExport.formatJson')}</option>
10120
- <option value="pmtiles">${t('importExport.formatPmtiles')}</option>
10121
- <option value="mbtiles">${t('importExport.formatMbtiles')}</option>
10122
- `;
10123
- const formatHint = document.createElement('p');
10124
- formatHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10125
- formatHint.textContent = t('importExport.formatHint');
10126
- formatGroup.appendChild(formatLabel);
10127
- formatGroup.appendChild(this.exportFormatSelect);
10128
- formatGroup.appendChild(formatHint);
10129
- formContainer.appendChild(formatGroup);
10130
- // Export Options
10131
- const optionsGroup = document.createElement('div');
10132
- const optionsLabel = document.createElement('label');
10133
- optionsLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3';
10134
- optionsLabel.textContent = t('importExport.includeComponents');
10135
- const checkboxContainer = document.createElement('div');
10136
- checkboxContainer.className = 'grid grid-cols-1 sm:grid-cols-2 gap-3';
10137
- const createCheckbox = (text, checked = true) => {
10138
- const label = document.createElement('label');
10139
- label.className =
10140
- 'flex items-center gap-3 p-3 rounded-lg bg-gray-50/50 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer';
10141
- const input = document.createElement('input');
10142
- input.type = 'checkbox';
10143
- input.checked = checked;
10144
- input.className =
10145
- 'w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2 dark:bg-gray-700';
10146
- const span = document.createElement('span');
10147
- span.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10148
- span.textContent = text;
10149
- label.appendChild(input);
10150
- label.appendChild(span);
10151
- return { label, input };
10152
- };
10153
- const styleCheck = createCheckbox(t('importExport.styleConfig'));
10154
- this.includeStyleCheckbox = styleCheck.input;
10155
- checkboxContainer.appendChild(styleCheck.label);
10156
- const tilesCheck = createCheckbox(t('importExport.mapTiles'));
10157
- this.includeTilesCheckbox = tilesCheck.input;
10158
- checkboxContainer.appendChild(tilesCheck.label);
10159
- const spritesCheck = createCheckbox(t('importExport.spritesIcons'));
10160
- this.includeSpritesCheckbox = spritesCheck.input;
10161
- checkboxContainer.appendChild(spritesCheck.label);
10162
- const fontsCheck = createCheckbox(t('importExport.fontsGlyphs'));
10163
- this.includeFontsCheckbox = fontsCheck.input;
10164
- checkboxContainer.appendChild(fontsCheck.label);
10165
- optionsGroup.appendChild(optionsLabel);
10166
- optionsGroup.appendChild(checkboxContainer);
10167
- formContainer.appendChild(optionsGroup);
10168
- // Export Progress (hidden by default)
10169
- const progressContainer = document.createElement('div');
10170
- progressContainer.className = 'hidden';
10171
- const progressBarContainer = document.createElement('div');
10172
- progressBarContainer.className =
10173
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10174
- this.exportProgressBar = document.createElement('div');
10175
- this.exportProgressBar.className = 'bg-blue-600 h-2 rounded-full transition-all duration-300';
10176
- this.exportProgressBar.style.width = '0%';
10177
- progressBarContainer.appendChild(this.exportProgressBar);
10178
- this.exportProgressText = document.createElement('p');
10179
- this.exportProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10180
- this.exportProgressText.textContent = t('importExport.preparingExport');
10181
- progressContainer.appendChild(progressBarContainer);
10182
- progressContainer.appendChild(this.exportProgressText);
10183
- formContainer.appendChild(progressContainer);
10184
- // Export Button
10185
- const exportButton = new Button({
10186
- text: t('importExport.exportRegion'),
10228
+ const section = this.createSection(t('mbtiles.exportTitle'), 'blue', icons.download({ size: 20, color: 'currentColor' }));
10229
+ const form = document.createElement('div');
10230
+ form.className = 'space-y-5';
10231
+ const hint = document.createElement('p');
10232
+ hint.className = 'text-sm text-gray-600 dark:text-gray-400';
10233
+ hint.textContent = t('mbtiles.exportHint');
10234
+ form.appendChild(hint);
10235
+ // Progress (hidden by default)
10236
+ const progress = this.createProgressBlock('blue', t('mbtiles.preparingExport'));
10237
+ this.exportProgressContainer = progress.container;
10238
+ this.exportProgressBar = progress.bar;
10239
+ this.exportProgressText = progress.text;
10240
+ form.appendChild(progress.container);
10241
+ const exportBtn = new Button({
10242
+ text: t('mbtiles.exportButton'),
10187
10243
  variant: 'primary',
10188
10244
  icon: icons.download({ size: 16, color: 'white' }),
10189
- className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20', // Premium button styles
10245
+ className: 'w-full py-2.5 text-base shadow-lg shadow-blue-500/20',
10190
10246
  onClick: () => this.handleExport(),
10191
10247
  });
10192
- this.exportButton = exportButton.getElement();
10193
- formContainer.appendChild(this.exportButton);
10194
- section.appendChild(formContainer);
10248
+ this.exportButton = exportBtn.getElement();
10249
+ form.appendChild(this.exportButton);
10250
+ section.appendChild(form);
10195
10251
  return section;
10196
10252
  }
10197
10253
  createImportSection() {
10198
- const section = document.createElement('div');
10199
- section.className =
10200
- 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden group hover:scale-[1.01] transition-transform duration-300';
10201
- // Gradient accent
10202
- const accent = document.createElement('div');
10203
- accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-green-500 opacity-50`;
10204
- section.appendChild(accent);
10205
- const header = document.createElement('h3');
10206
- header.className =
10207
- 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10208
- header.innerHTML = `
10209
- <div class="p-2 bg-green-500/10 rounded-lg text-green-600 dark:text-green-400">
10210
- ${icons.upload({ size: 20, color: 'currentColor' })}
10211
- </div>
10212
- ${t('importExport.importRegion')}
10213
- `;
10214
- section.appendChild(header);
10215
- const formContainer = document.createElement('div');
10216
- formContainer.className = 'space-y-5';
10217
- // File Selection
10254
+ const section = this.createSection(t('mbtiles.importTitle'), 'green', icons.upload({ size: 20, color: 'currentColor' }));
10255
+ const form = document.createElement('div');
10256
+ form.className = 'space-y-5';
10257
+ // File input
10218
10258
  const fileGroup = document.createElement('div');
10219
10259
  const fileLabel = document.createElement('label');
10220
10260
  fileLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10221
- fileLabel.textContent = t('importExport.selectFile');
10261
+ fileLabel.textContent = t('mbtiles.selectFile');
10222
10262
  this.importFileInput = document.createElement('input');
10223
10263
  this.importFileInput.type = 'file';
10224
- this.importFileInput.accept = '.json,.pmtiles,.mbtiles';
10264
+ this.importFileInput.accept = '.mbtiles,application/vnd.sqlite3,application/x-sqlite3';
10225
10265
  this.importFileInput.className =
10226
10266
  'w-full text-sm text-gray-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/20 dark:file:text-primary-400 glass-input transition-all cursor-pointer';
10227
10267
  const fileHint = document.createElement('p');
10228
10268
  fileHint.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1';
10229
- fileHint.textContent = t('importExport.fileFormatsHint');
10269
+ fileHint.textContent = t('mbtiles.fileHint');
10230
10270
  fileGroup.appendChild(fileLabel);
10231
10271
  fileGroup.appendChild(this.importFileInput);
10232
10272
  fileGroup.appendChild(fileHint);
10233
- formContainer.appendChild(fileGroup);
10234
- // New Name
10273
+ form.appendChild(fileGroup);
10274
+ // New region name
10235
10275
  const nameGroup = document.createElement('div');
10236
10276
  const nameLabel = document.createElement('label');
10237
10277
  nameLabel.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2';
10238
- nameLabel.textContent = t('importExport.newRegionName');
10278
+ nameLabel.textContent = t('mbtiles.newRegionName');
10239
10279
  this.importNameInput = document.createElement('input');
10240
10280
  this.importNameInput.type = 'text';
10241
- this.importNameInput.placeholder = t('importExport.newRegionNamePlaceholder');
10281
+ this.importNameInput.placeholder = t('mbtiles.newRegionNamePlaceholder');
10242
10282
  this.importNameInput.className =
10243
10283
  'w-full px-4 py-3 rounded-xl text-sm glass-input text-gray-900 dark:text-white bg-white/50 dark:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all';
10244
10284
  nameGroup.appendChild(nameLabel);
10245
10285
  nameGroup.appendChild(this.importNameInput);
10246
- formContainer.appendChild(nameGroup);
10247
- // Import Options
10286
+ form.appendChild(nameGroup);
10287
+ // Overwrite toggle
10248
10288
  const overwriteLabel = document.createElement('label');
10249
10289
  overwriteLabel.className =
10250
10290
  'flex items-center gap-3 p-3 rounded-lg bg-gray-50/50 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors';
@@ -10254,79 +10294,85 @@ class ImportExportModal {
10254
10294
  'w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500 focus:ring-2 dark:bg-gray-700';
10255
10295
  const overwriteSpan = document.createElement('span');
10256
10296
  overwriteSpan.className = 'text-sm text-gray-700 dark:text-gray-300 font-medium';
10257
- overwriteSpan.textContent = t('importExport.overwriteIfExists');
10297
+ overwriteSpan.textContent = t('mbtiles.overwriteIfExists');
10258
10298
  overwriteLabel.appendChild(this.importOverwriteCheckbox);
10259
10299
  overwriteLabel.appendChild(overwriteSpan);
10260
- formContainer.appendChild(overwriteLabel);
10261
- // Import Progress (hidden by default)
10262
- const progressContainer = document.createElement('div');
10263
- progressContainer.className = 'hidden';
10264
- const progressBarContainer = document.createElement('div');
10265
- progressBarContainer.className =
10266
- 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10267
- this.importProgressBar = document.createElement('div');
10268
- this.importProgressBar.className = 'bg-green-600 h-2 rounded-full transition-all duration-300';
10269
- this.importProgressBar.style.width = '0%';
10270
- progressBarContainer.appendChild(this.importProgressBar);
10271
- this.importProgressText = document.createElement('p');
10272
- this.importProgressText.className = 'text-sm text-gray-600 dark:text-gray-400';
10273
- this.importProgressText.textContent = t('importExport.preparingImport');
10274
- progressContainer.appendChild(progressBarContainer);
10275
- progressContainer.appendChild(this.importProgressText);
10276
- formContainer.appendChild(progressContainer);
10277
- // Import Button
10278
- const importButton = new Button({
10279
- text: t('importExport.importRegion'),
10280
- variant: 'success', // Assuming 'success' variant exists in Button component, if not might need style adjustment. Assuming it works based on previous code.
10300
+ form.appendChild(overwriteLabel);
10301
+ // Progress
10302
+ const progress = this.createProgressBlock('green', t('mbtiles.preparingImport'));
10303
+ this.importProgressContainer = progress.container;
10304
+ this.importProgressBar = progress.bar;
10305
+ this.importProgressText = progress.text;
10306
+ form.appendChild(progress.container);
10307
+ // Import button (disabled until a file is selected)
10308
+ const importBtn = new Button({
10309
+ text: t('mbtiles.importButton'),
10310
+ variant: 'success',
10281
10311
  icon: icons.upload({ size: 16, color: 'white' }),
10282
10312
  className: 'w-full py-2.5 text-base shadow-lg shadow-green-500/20',
10283
10313
  disabled: true,
10284
10314
  onClick: () => this.handleImport(),
10285
10315
  });
10286
- this.importButton = importButton.getElement();
10287
- formContainer.appendChild(this.importButton);
10288
- section.appendChild(formContainer);
10316
+ this.importButton = importBtn.getElement();
10317
+ form.appendChild(this.importButton);
10318
+ section.appendChild(form);
10289
10319
  return section;
10290
10320
  }
10291
- createFormatGuide() {
10292
- const guide = document.createElement('div');
10293
- guide.className = 'p-5 mt-4 glass-input rounded-xl border-0 bg-blue-50/40 dark:bg-blue-900/20';
10294
- guide.innerHTML = `
10295
- <h4 class="text-sm font-bold uppercase tracking-wider text-blue-900 dark:text-blue-300 mb-3 flex items-center gap-2">
10296
- ${icons.infoCircle({ size: 16, color: 'currentColor' })}
10297
- ${t('importExport.formatGuide')}
10298
- </h4>
10299
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
10300
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10301
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">JSON</div>
10302
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.jsonDesc')}</div>
10303
- </div>
10304
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10305
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">PMTiles</div>
10306
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.pmtilesDesc')}</div>
10307
- </div>
10308
- <div class="p-3 rounded-lg bg-white/50 dark:bg-black/20">
10309
- <div class="font-bold text-base text-blue-800 dark:text-blue-300 mb-1">MBTiles</div>
10310
- <div class="text-blue-700 dark:text-blue-400 leading-relaxed">${t('importExport.mbtilesDesc')}</div>
10311
- </div>
10321
+ createSection(title, accentColor, iconHtml) {
10322
+ const section = document.createElement('div');
10323
+ section.className =
10324
+ 'glass-input p-6 rounded-xl border-0 bg-white/40 dark:bg-gray-800/40 relative overflow-hidden';
10325
+ const accent = document.createElement('div');
10326
+ accent.className = `absolute top-0 ${i18n.isRTL() ? 'right-0' : 'left-0'} w-1 h-full bg-${accentColor}-500 opacity-50`;
10327
+ section.appendChild(accent);
10328
+ const header = document.createElement('h3');
10329
+ header.className =
10330
+ 'text-lg font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2.5';
10331
+ header.innerHTML = `
10332
+ <div class="p-2 bg-${accentColor}-500/10 rounded-lg text-${accentColor}-600 dark:text-${accentColor}-400">
10333
+ ${iconHtml}
10312
10334
  </div>
10335
+ ${title}
10313
10336
  `;
10314
- return guide;
10337
+ section.appendChild(header);
10338
+ return section;
10339
+ }
10340
+ createProgressBlock(accentColor, initialText) {
10341
+ const container = document.createElement('div');
10342
+ container.className = 'hidden';
10343
+ const barWrap = document.createElement('div');
10344
+ barWrap.className = 'bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2 overflow-hidden';
10345
+ const bar = document.createElement('div');
10346
+ bar.className = `bg-${accentColor}-600 h-2 rounded-full transition-all duration-300`;
10347
+ bar.style.width = '0%';
10348
+ barWrap.appendChild(bar);
10349
+ const text = document.createElement('p');
10350
+ text.className = 'text-sm text-gray-600 dark:text-gray-400';
10351
+ text.textContent = initialText;
10352
+ container.appendChild(barWrap);
10353
+ container.appendChild(text);
10354
+ return { container, bar, text };
10355
+ }
10356
+ createFooter() {
10357
+ const footer = document.createElement('div');
10358
+ footer.className = 'flex gap-3 justify-end';
10359
+ if (i18n.isRTL()) {
10360
+ footer.setAttribute('dir', 'rtl');
10361
+ }
10362
+ const close = new Button({
10363
+ text: t('app.close'),
10364
+ variant: 'secondary',
10365
+ onClick: () => this.hide(),
10366
+ });
10367
+ footer.appendChild(close.getElement());
10368
+ return footer;
10315
10369
  }
10316
10370
  attachEventListeners() {
10317
- // Enable import button when file is selected
10318
10371
  if (this.importFileInput && this.importButton) {
10319
10372
  this.importFileInput.addEventListener('change', () => {
10320
- if (this.importFileInput?.files && this.importFileInput.files.length > 0) {
10321
- if (this.importButton) {
10322
- this.importButton.disabled = false;
10323
- }
10324
- }
10325
- else {
10326
- if (this.importButton) {
10327
- this.importButton.disabled = true;
10328
- }
10329
- }
10373
+ const hasFile = !!(this.importFileInput?.files && this.importFileInput.files.length > 0);
10374
+ if (this.importButton)
10375
+ this.importButton.disabled = !hasFile;
10330
10376
  });
10331
10377
  }
10332
10378
  }
@@ -10336,34 +10382,27 @@ class ImportExportModal {
10336
10382
  this.isExporting = true;
10337
10383
  if (this.exportButton)
10338
10384
  this.exportButton.disabled = true;
10385
+ this.exportProgressContainer?.classList.remove('hidden');
10339
10386
  try {
10340
- const format = this.exportFormatSelect?.value;
10341
- const options = {
10342
- includeStyle: this.includeStyleCheckbox?.checked ?? true,
10343
- includeTiles: this.includeTilesCheckbox?.checked ?? true,
10344
- includeSprites: this.includeSpritesCheckbox?.checked ?? true,
10345
- includeFonts: this.includeFontsCheckbox?.checked ?? true,
10346
- };
10347
- // Show progress
10348
- const progressContainer = this.exportProgressBar?.parentElement?.parentElement;
10349
- if (progressContainer) {
10350
- progressContainer.classList.remove('hidden');
10351
- }
10352
- const result = await this.options.exportRegion(this.options.region.id, format, options);
10353
- if (this.exportProgressBar) {
10387
+ const result = await this.options.exportRegion(this.options.region.id, {
10388
+ onProgress: p => {
10389
+ if (this.exportProgressBar)
10390
+ this.exportProgressBar.style.width = `${p.percentage}%`;
10391
+ if (this.exportProgressText)
10392
+ this.exportProgressText.textContent = p.message;
10393
+ },
10394
+ });
10395
+ if (this.exportProgressBar)
10354
10396
  this.exportProgressBar.style.width = '100%';
10355
- }
10356
- if (this.exportProgressText) {
10357
- this.exportProgressText.textContent = t('importExport.exportComplete');
10358
- }
10397
+ if (this.exportProgressText)
10398
+ this.exportProgressText.textContent = t('mbtiles.exportComplete');
10359
10399
  this.options.onExport?.(result);
10360
- // Hide modal after short delay
10361
- setTimeout(() => this.hide(), 1500);
10400
+ setTimeout(() => this.hide(), 1200);
10362
10401
  }
10363
10402
  catch (error) {
10364
10403
  modalLogger.error('Export error:', error instanceof Error ? error.message : String(error));
10365
10404
  if (this.exportProgressText) {
10366
- this.exportProgressText.textContent = t('importExport.exportFailed');
10405
+ this.exportProgressText.textContent = t('mbtiles.exportFailed');
10367
10406
  this.exportProgressText.classList.add('text-red-600', 'dark:text-red-400');
10368
10407
  }
10369
10408
  }
@@ -10374,45 +10413,47 @@ class ImportExportModal {
10374
10413
  }
10375
10414
  }
10376
10415
  async handleImport() {
10377
- if (this.isImporting || !this.options.importRegion || !this.importFileInput?.files?.[0])
10416
+ if (this.isImporting || !this.options.importRegion)
10417
+ return;
10418
+ const file = this.importFileInput?.files?.[0];
10419
+ if (!file)
10378
10420
  return;
10379
10421
  this.isImporting = true;
10380
10422
  if (this.importButton)
10381
10423
  this.importButton.disabled = true;
10424
+ this.importProgressContainer?.classList.remove('hidden');
10382
10425
  try {
10383
- const file = this.importFileInput.files[0];
10384
- const overwrite = this.importOverwriteCheckbox?.checked ?? false;
10385
- // Show progress
10386
- const progressContainer = this.importProgressBar?.parentElement?.parentElement;
10387
- if (progressContainer) {
10388
- progressContainer.classList.remove('hidden');
10389
- }
10390
- // Determine format from file extension
10391
- const format = file.name.endsWith('.pmtiles')
10392
- ? 'pmtiles'
10393
- : file.name.endsWith('.mbtiles')
10394
- ? 'mbtiles'
10395
- : 'json';
10396
10426
  const data = {
10397
10427
  file,
10398
- format,
10399
- overwrite,
10428
+ format: 'mbtiles',
10429
+ overwrite: this.importOverwriteCheckbox?.checked ?? false,
10430
+ newRegionName: this.importNameInput?.value.trim() || undefined,
10431
+ onProgress: p => {
10432
+ if (this.importProgressBar)
10433
+ this.importProgressBar.style.width = `${p.percentage}%`;
10434
+ if (this.importProgressText)
10435
+ this.importProgressText.textContent = p.message;
10436
+ },
10400
10437
  };
10401
10438
  const result = await this.options.importRegion(data);
10402
- if (this.importProgressBar) {
10439
+ if (this.importProgressBar)
10403
10440
  this.importProgressBar.style.width = '100%';
10404
- }
10405
10441
  if (this.importProgressText) {
10406
- this.importProgressText.textContent = t('importExport.importComplete');
10442
+ this.importProgressText.textContent = result.success
10443
+ ? t('mbtiles.importComplete')
10444
+ : result.message;
10445
+ if (!result.success) {
10446
+ this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10447
+ }
10407
10448
  }
10408
10449
  this.options.onImport?.(result);
10409
- // Hide modal after short delay
10410
- setTimeout(() => this.hide(), 1500);
10450
+ if (result.success)
10451
+ setTimeout(() => this.hide(), 1200);
10411
10452
  }
10412
10453
  catch (error) {
10413
10454
  modalLogger.error('Import error:', error instanceof Error ? error.message : String(error));
10414
10455
  if (this.importProgressText) {
10415
- this.importProgressText.textContent = t('importExport.importFailed');
10456
+ this.importProgressText.textContent = t('mbtiles.importFailed');
10416
10457
  this.importProgressText.classList.add('text-red-600', 'dark:text-red-400');
10417
10458
  }
10418
10459
  }
@@ -10422,23 +10463,6 @@ class ImportExportModal {
10422
10463
  this.importButton.disabled = false;
10423
10464
  }
10424
10465
  }
10425
- createFooter() {
10426
- const footer = document.createElement('div');
10427
- footer.className = 'flex gap-3 justify-end';
10428
- if (i18n.isRTL()) {
10429
- footer.setAttribute('dir', 'rtl');
10430
- }
10431
- const closeButton = new Button({
10432
- text: t('app.close'),
10433
- variant: 'secondary',
10434
- onClick: () => this.hide(),
10435
- });
10436
- footer.appendChild(closeButton.getElement());
10437
- return footer;
10438
- }
10439
- destroy() {
10440
- this.modal?.destroy();
10441
- }
10442
10466
  }
10443
10467
 
10444
10468
  /**
@@ -11152,9 +11176,9 @@ class PanelRenderer extends BaseComponent {
11152
11176
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 transition-colors duration-150" data-action="redownload-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.redownload')}">
11153
11177
  ${icons.download({ size: 14, color: 'currentColor' })}
11154
11178
  </button>
11155
- <!-- <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
11179
+ <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-600 dark:text-purple-400 transition-colors duration-150" data-action="import-export" data-region-id="${escapeHtml$1(region.id)}" title="${t('actions.importExport')}">
11156
11180
  ${icons.deviceFloppy({ size: 14, color: 'currentColor' })}
11157
- </button> -->
11181
+ </button>
11158
11182
  <button class="region-action-btn p-1.5 rounded-md cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 transition-colors duration-150" data-action="delete-region" data-region-id="${escapeHtml$1(region.id)}" title="${t('app.delete')}">
11159
11183
  ${icons.trash({ size: 14, color: 'currentColor' })}
11160
11184
  </button>
@@ -11416,7 +11440,7 @@ class PanelRenderer extends BaseComponent {
11416
11440
  }
11417
11441
  }
11418
11442
  /**
11419
- * Handle import/export functionality
11443
+ * Show the MBTiles import/export modal for a region.
11420
11444
  */
11421
11445
  async handleImportExport(regionId, _regionData) {
11422
11446
  try {
@@ -11424,44 +11448,26 @@ class PanelRenderer extends BaseComponent {
11424
11448
  const region = regions.find((r) => r.id === regionId);
11425
11449
  if (!region)
11426
11450
  return;
11427
- const importExportModal = new ImportExportModal({
11451
+ const mbtilesModal = new MBTilesModal({
11428
11452
  region,
11429
- onClose: () => {
11430
- this.modalManager.close();
11431
- },
11453
+ onClose: () => this.modalManager.close(),
11432
11454
  onExport: result => {
11433
- panelLogger.debug('Export completed:', result);
11434
- // Handle export result - could show success message
11455
+ panelLogger.debug('MBTiles export completed:', result);
11435
11456
  this.offlineManager.downloadExportedRegion(result);
11436
11457
  },
11437
11458
  onImport: result => {
11438
- panelLogger.debug('Import completed:', result);
11439
- // Refresh the panel to show updated regions
11440
- this.refresh();
11441
- },
11442
- exportRegion: async (regionId, format, options) => {
11443
- // Delegate to offline manager's export functionality
11444
- switch (format) {
11445
- case 'json':
11446
- return await this.offlineManager.exportRegionAsJSON(regionId, options);
11447
- case 'pmtiles':
11448
- return await this.offlineManager.exportRegionAsPMTiles(regionId, options);
11449
- case 'mbtiles':
11450
- return await this.offlineManager.exportRegionAsMBTiles(regionId, options);
11451
- default:
11452
- throw new Error(`Unsupported export format: ${format}`);
11453
- }
11454
- },
11455
- importRegion: async (data) => {
11456
- // Delegate to offline manager's import functionality
11457
- return await this.offlineManager.importRegion(data);
11459
+ panelLogger.debug('MBTiles import completed:', result);
11460
+ if (result.success)
11461
+ this.refresh();
11458
11462
  },
11463
+ exportRegion: (id, options) => this.offlineManager.exportRegionAsMBTiles(id, options),
11464
+ importRegion: data => this.offlineManager.importRegion(data),
11459
11465
  });
11460
- const modal = importExportModal.show();
11466
+ const modal = mbtilesModal.show();
11461
11467
  this.modalManager.show(modal);
11462
11468
  }
11463
11469
  catch (error) {
11464
- panelLogger.error('Error showing import/export modal:', error);
11470
+ panelLogger.error('Error showing MBTiles modal:', error);
11465
11471
  }
11466
11472
  }
11467
11473
  /**
@@ -11860,6 +11866,9 @@ class PanelRenderer extends BaseComponent {
11860
11866
  delete patchedStyle.imports;
11861
11867
  panelLogger.debug('Stripped imports from offline style (already flattened)');
11862
11868
  }
11869
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
11870
+ // downloaded before resolveImports learned to rewrite them.
11871
+ sanitizeIndoorExpressions(patchedStyle);
11863
11872
  // Enforce maxzoom for all tile sources to prevent requesting non-existent tiles
11864
11873
  // Find the maximum zoom level from all regions using this style
11865
11874
  let maxZoom = 14; // Default fallback
@@ -12601,9 +12610,7 @@ class RegionFormModal {
12601
12610
  detectProviderFromUrl() {
12602
12611
  const styleUrl = this.styleUrlInput?.value || '';
12603
12612
  // Simple detection logic
12604
- if (styleUrl.startsWith('mapbox://') ||
12605
- styleUrl.includes('mapbox.com') ||
12606
- styleUrl.includes('api.mapbox.com')) {
12613
+ if (styleUrl.startsWith('mapbox://') || isMapboxHost(styleUrl)) {
12607
12614
  if (this.providerSelect)
12608
12615
  this.providerSelect.value = 'mapbox';
12609
12616
  this.toggleAccessTokenVisibility(true);
@@ -13367,39 +13374,39 @@ class OfflineManagerControl {
13367
13374
  }
13368
13375
  // Development proxy for CORS issues (when running on localhost)
13369
13376
  if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
13370
- // Proxy Carto tile requests (tiles and TileJSON)
13371
- const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(url);
13372
- const isTileJsonRequest = url.includes('.json') && url.includes('basemaps.cartocdn.com');
13373
- if (isTileRequest && url.includes('tiles-a.basemaps.cartocdn.com')) {
13374
- const proxyUrl = url.replace('https://tiles-a.basemaps.cartocdn.com', '/tiles/carto-a');
13375
- return originalFetch(proxyUrl, init);
13376
- }
13377
- if (isTileRequest && url.includes('tiles-b.basemaps.cartocdn.com')) {
13378
- const proxyUrl = url.replace('https://tiles-b.basemaps.cartocdn.com', '/tiles/carto-b');
13379
- return originalFetch(proxyUrl, init);
13377
+ let parsed = null;
13378
+ try {
13379
+ parsed = new URL(url, location.origin);
13380
13380
  }
13381
- if (isTileRequest && url.includes('tiles-c.basemaps.cartocdn.com')) {
13382
- const proxyUrl = url.replace('https://tiles-c.basemaps.cartocdn.com', '/tiles/carto-c');
13383
- return originalFetch(proxyUrl, init);
13381
+ catch {
13382
+ parsed = null;
13384
13383
  }
13385
- if (isTileRequest && url.includes('tiles-d.basemaps.cartocdn.com')) {
13386
- const proxyUrl = url.replace('https://tiles-d.basemaps.cartocdn.com', '/tiles/carto-d');
13387
- return originalFetch(proxyUrl, init);
13384
+ const hostname = parsed?.hostname ?? '';
13385
+ const pathAndQuery = parsed ? parsed.pathname + parsed.search : '';
13386
+ // Proxy Carto tile requests (tiles and TileJSON)
13387
+ const isTileRequest = /\/\d+\/\d+\/\d+\.(pbf|mvt|png|jpg|jpeg|webp)/.test(parsed?.pathname ?? '');
13388
+ const isTileJsonRequest = (parsed?.pathname.endsWith('.json') ?? false) &&
13389
+ (hostname === 'basemaps.cartocdn.com' || hostname.endsWith('.basemaps.cartocdn.com'));
13390
+ const cartoSubdomainProxy = {
13391
+ 'tiles-a.basemaps.cartocdn.com': '/tiles/carto-a',
13392
+ 'tiles-b.basemaps.cartocdn.com': '/tiles/carto-b',
13393
+ 'tiles-c.basemaps.cartocdn.com': '/tiles/carto-c',
13394
+ 'tiles-d.basemaps.cartocdn.com': '/tiles/carto-d',
13395
+ };
13396
+ if (isTileRequest && cartoSubdomainProxy[hostname]) {
13397
+ return originalFetch(cartoSubdomainProxy[hostname] + pathAndQuery, init);
13388
13398
  }
13389
13399
  // Proxy TileJSON requests from tiles.basemaps.cartocdn.com
13390
- if (isTileJsonRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13391
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/carto-api');
13392
- return originalFetch(proxyUrl, init);
13400
+ if (isTileJsonRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13401
+ return originalFetch('/carto-api' + pathAndQuery, init);
13393
13402
  }
13394
13403
  // Fallback for old format (tiles without subdomain)
13395
- if (isTileRequest && url.includes('tiles.basemaps.cartocdn.com')) {
13396
- const proxyUrl = url.replace('https://tiles.basemaps.cartocdn.com', '/tiles/carto-a');
13397
- return originalFetch(proxyUrl, init);
13404
+ if (isTileRequest && hostname === 'tiles.basemaps.cartocdn.com') {
13405
+ return originalFetch('/tiles/carto-a' + pathAndQuery, init);
13398
13406
  }
13399
13407
  // Proxy OpenStreetMap tile requests
13400
- if (url.includes('tile.openstreetmap.org')) {
13401
- const proxyUrl = url.replace('https://tile.openstreetmap.org', '/tiles/osm');
13402
- return originalFetch(proxyUrl, init);
13408
+ if (hostname === 'tile.openstreetmap.org') {
13409
+ return originalFetch('/tiles/osm' + pathAndQuery, init);
13403
13410
  }
13404
13411
  }
13405
13412
  return originalFetch(input, init);
@@ -13832,6 +13839,9 @@ class OfflineManagerControl {
13832
13839
  if (patchedStyle.imports) {
13833
13840
  delete patchedStyle.imports;
13834
13841
  }
13842
+ // Scrub indoor-only expressions for pre-0.8.1 stored styles that were
13843
+ // downloaded before resolveImports learned to rewrite them.
13844
+ sanitizeIndoorExpressions(patchedStyle);
13835
13845
  // If using Service Worker (Mapbox GL JS), convert idb:// to /__offline__/ URLs
13836
13846
  if (this.useServiceWorker) {
13837
13847
  if (this.swReadyPromise) {
@@ -13985,6 +13995,7 @@ exports.DB_VERSION = DB_VERSION;
13985
13995
  exports.DOWNLOAD_DEFAULTS = DOWNLOAD_DEFAULTS;
13986
13996
  exports.ERROR_MESSAGES = ERROR_MESSAGES;
13987
13997
  exports.FontService = FontService;
13998
+ exports.GLYPH_BLOCK_SIZE = GLYPH_BLOCK_SIZE;
13988
13999
  exports.GLYPH_CONFIG = GLYPH_CONFIG;
13989
14000
  exports.GZIP_MAGIC_BYTES = GZIP_MAGIC_BYTES;
13990
14001
  exports.GlyphService = GlyphService;
@@ -13993,6 +14004,7 @@ exports.MAPBOX_API = MAPBOX_API;
13993
14004
  exports.MAPBOX_CACHE_TTL = MAPBOX_CACHE_TTL;
13994
14005
  exports.MAPBOX_CLASSIC_STYLES = MAPBOX_CLASSIC_STYLES;
13995
14006
  exports.MAP_PROVIDERS = MAP_PROVIDERS;
14007
+ exports.MAX_GLYPH_CODEPOINT = MAX_GLYPH_CODEPOINT;
13996
14008
  exports.MaintenanceService = MaintenanceService;
13997
14009
  exports.ModelService = ModelService;
13998
14010
  exports.OfflineManagerControl = OfflineManagerControl;
@@ -14025,6 +14037,7 @@ exports.cleanupService = cleanupService;
14025
14037
  exports.clearAllCaches = clearAllCaches;
14026
14038
  exports.configureLogger = configureLogger;
14027
14039
  exports.configureProxy = configureProxy;
14040
+ exports.configureSqlJs = configureSqlJs;
14028
14041
  exports.convertStyleForServiceWorker = convertStyleForServiceWorker;
14029
14042
  exports.countCompressedTiles = countCompressedTiles;
14030
14043
  exports.createProgressTracker = createProgressTracker;
@@ -14047,6 +14060,7 @@ exports.escapeHtml = escapeHtml$1;
14047
14060
  exports.extractAccessToken = extractAccessToken;
14048
14061
  exports.extractAllFontNames = extractAllFontNames;
14049
14062
  exports.extractFontNamesFromTextField = extractFontNamesFromTextField;
14063
+ exports.extractTileExtensionFromUrl = extractTileExtensionFromUrl;
14050
14064
  exports.fetchResourceWithRetry = fetchResourceWithRetry;
14051
14065
  exports.fetchWithRetry = fetchWithRetry;
14052
14066
  exports.fontService = fontService;
@@ -14064,15 +14078,19 @@ exports.getModelStats = getModelStats;
14064
14078
  exports.getRegionAnalytics = getRegionAnalytics;
14065
14079
  exports.getSpriteAnalytics = getSpriteAnalytics;
14066
14080
  exports.getSpriteStats = getSpriteStats;
14081
+ exports.getSqlJs = getSqlJs;
14067
14082
  exports.getStyleStats = getStyleStats;
14068
14083
  exports.getTileAnalytics = getTileAnalytics;
14069
14084
  exports.getTileStats = getTileStats;
14085
+ exports.getUrlHostname = getUrlHostname;
14070
14086
  exports.getUserErrorMessage = getUserErrorMessage;
14071
14087
  exports.glyphService = glyphService;
14072
14088
  exports.hasImports = hasImports;
14089
+ exports.hostMatches = hostMatches;
14073
14090
  exports.i18n = i18n;
14074
14091
  exports.icons = icons;
14075
14092
  exports.idbFetchHandler = idbFetchHandler;
14093
+ exports.isMapboxHost = isMapboxHost;
14076
14094
  exports.isMapboxProtocol = isMapboxProtocol;
14077
14095
  exports.isStyleDownloaded = isStyleDownloaded;
14078
14096
  exports.loadAllStoredRegions = loadAllStoredRegions;
@@ -14098,6 +14116,7 @@ exports.resolveMapboxUrl = resolveMapboxUrl;
14098
14116
  exports.resourceKeyBelongsToStyle = resourceKeyBelongsToStyle;
14099
14117
  exports.rewriteMapboxCdnTileUrl = rewriteMapboxCdnTileUrl;
14100
14118
  exports.safeExecute = safeExecute;
14119
+ exports.sanitizeIndoorExpressions = sanitizeIndoorExpressions;
14101
14120
  exports.setupAutoCleanup = setupAutoCleanup;
14102
14121
  exports.spriteService = spriteService;
14103
14122
  exports.stopAutoCleanup = stopAutoCleanup;