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