map-gl-offline 0.5.1 → 0.5.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.
Files changed (55) hide show
  1. package/README.md +62 -12
  2. package/bin/cli.js +40 -0
  3. package/bin/vite-plugin.js +48 -0
  4. package/dist/idb-offline-sw.js +396 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.esm.js +4274 -11248
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/index.js +4278 -11252
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.umd.js +4277 -11256
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/managers/offlineMapManager/analyticsManagement.d.ts +1 -1
  13. package/dist/managers/offlineMapManager/base.d.ts +5 -5
  14. package/dist/managers/offlineMapManager/cleanupManagement.d.ts +1 -1
  15. package/dist/managers/offlineMapManager/importExportManagement.d.ts +1 -1
  16. package/dist/managers/offlineMapManager/maintenanceManagement.d.ts +2 -2
  17. package/dist/managers/offlineMapManager/regionManagement.d.ts +1 -1
  18. package/dist/managers/offlineMapManager/resourceManagement.d.ts +1 -1
  19. package/dist/managers/offlineMapManager/styleManagement.d.ts +1 -1
  20. package/dist/services/analyticsService.d.ts +1 -1
  21. package/dist/services/cleanupService.d.ts +1 -1
  22. package/dist/services/fontService.d.ts +1 -1
  23. package/dist/services/glyphService.d.ts +1 -3
  24. package/dist/services/importExportService.d.ts +3 -3
  25. package/dist/services/maintenanceService.d.ts +2 -2
  26. package/dist/services/regionService.d.ts +1 -1
  27. package/dist/services/resourceService.d.ts +2 -2
  28. package/dist/services/spriteService.d.ts +1 -3
  29. package/dist/services/styleService.d.ts +2 -2
  30. package/dist/services/tileService.d.ts +1 -1
  31. package/dist/storage/indexedDbManager.d.ts +1 -1
  32. package/dist/types/maintenance.d.ts +1 -1
  33. package/dist/ui/components/PanelActions.d.ts +1 -1
  34. package/dist/ui/components/PanelHeader.d.ts +1 -1
  35. package/dist/ui/components/RegionList.d.ts +1 -1
  36. package/dist/ui/components/shared/LanguageSelector.d.ts +1 -1
  37. package/dist/ui/components/shared/MapControlButton.d.ts +1 -1
  38. package/dist/ui/components/shared/PanelContent.d.ts +3 -3
  39. package/dist/ui/components/shared/RegionDrawingTool.d.ts +2 -2
  40. package/dist/ui/controls/polygonControl.d.ts +1 -1
  41. package/dist/ui/controls/regionControl.d.ts +3 -3
  42. package/dist/ui/managers/ControlButtonManager.d.ts +1 -1
  43. package/dist/ui/managers/PanelManager.d.ts +3 -3
  44. package/dist/ui/managers/downloadManager.d.ts +2 -2
  45. package/dist/ui/modals/importExportModal.d.ts +1 -1
  46. package/dist/ui/modals/regionDetailsModal.d.ts +1 -1
  47. package/dist/ui/offlineManagerControl.d.ts +1 -1
  48. package/dist/utils/convertStyleForSW.d.ts +1 -1
  49. package/dist/utils/download.d.ts +1 -1
  50. package/dist/utils/importResolver.d.ts +1 -1
  51. package/dist/utils/styleProviderUtils.d.ts +1 -1
  52. package/dist/utils/styleUtils.d.ts +1 -1
  53. package/dist/utils/validation.d.ts +1 -1
  54. package/dist/vite-plugin.js +48 -0
  55. package/package.json +19 -15
package/README.md CHANGED
@@ -67,7 +67,7 @@ For use via `<script>` tag, the library is available as the `mapgloffline` globa
67
67
 
68
68
  ```html
69
69
  <script src="https://unpkg.com/map-gl-offline/dist/index.umd.js"></script>
70
- <link rel="stylesheet" href="https://unpkg.com/map-gl-offline/dist/style.css" />
70
+ <link rel="stylesheet" href="https://unpkg.com/map-gl-offline/style.css" />
71
71
  <script>
72
72
  const manager = new mapgloffline.OfflineMapManager();
73
73
  const control = new mapgloffline.OfflineManagerControl(manager, {
@@ -97,7 +97,7 @@ For Mapbox styles, you will also need a Mapbox access token from [Mapbox](https:
97
97
  import maplibregl from 'maplibre-gl';
98
98
  import { OfflineMapManager, OfflineManagerControl } from 'map-gl-offline';
99
99
  import 'maplibre-gl/dist/maplibre-gl.css';
100
- import 'map-gl-offline/dist/style.css';
100
+ import 'map-gl-offline/style.css';
101
101
 
102
102
  const styleUrl = 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_API_KEY';
103
103
 
@@ -123,11 +123,36 @@ map.on('load', () => {
123
123
 
124
124
  ### Mapbox GL JS
125
125
 
126
+ Mapbox GL JS v3 does not support `addProtocol`, so offline tile serving uses a **Service Worker** fallback. You need to copy `idb-offline-sw.js` to your project's public directory so it is served at the root (`/idb-offline-sw.js`).
127
+
128
+ **Option 1: CLI (recommended)**
129
+
130
+ ```bash
131
+ npx map-gl-offline init
132
+ ```
133
+
134
+ **Option 2: Vite plugin**
135
+
136
+ ```js
137
+ // vite.config.js
138
+ import { offlineSwPlugin } from 'map-gl-offline/vite-plugin';
139
+
140
+ export default defineConfig({
141
+ plugins: [offlineSwPlugin()],
142
+ });
143
+ ```
144
+
145
+ **Option 3: Manual copy**
146
+
147
+ ```bash
148
+ cp node_modules/map-gl-offline/dist/idb-offline-sw.js public/idb-offline-sw.js
149
+ ```
150
+
126
151
  ```typescript
127
152
  import mapboxgl from 'mapbox-gl';
128
153
  import { OfflineMapManager, OfflineManagerControl } from 'map-gl-offline';
129
154
  import 'mapbox-gl/dist/mapbox-gl.css';
130
- import 'map-gl-offline/dist/style.css';
155
+ import 'map-gl-offline/style.css';
131
156
 
132
157
  mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
133
158
 
@@ -146,11 +171,15 @@ map.on('load', () => {
146
171
  theme: 'dark',
147
172
  showBbox: true,
148
173
  accessToken: mapboxgl.accessToken,
174
+ // No mapLib needed - Mapbox GL JS v3 lacks addProtocol,
175
+ // so the library auto-registers a Service Worker fallback
149
176
  });
150
177
  map.addControl(control, 'top-right');
151
178
  });
152
179
  ```
153
180
 
181
+ > **Note:** MapLibre GL JS has built-in `addProtocol` support, so it does not need the Service Worker. Only Mapbox GL JS requires this extra step.
182
+
154
183
  The UI control provides:
155
184
 
156
185
  - 📍 **Polygon drawing** for region selection
@@ -461,21 +490,21 @@ git clone https://github.com/muimsd/map-gl-offline.git
461
490
  cd map-gl-offline
462
491
 
463
492
  # Install dependencies
464
- pnpm install
493
+ npm install
465
494
 
466
495
  # Run development server
467
- pnpm dev
496
+ npm run dev
468
497
 
469
498
  # Run tests
470
- pnpm test
499
+ npm test
471
500
 
472
501
  # Build library
473
- pnpm build
502
+ npm run build
474
503
 
475
- # Run example app
504
+ # Run MapLibre example app
476
505
  cd examples/maplibre
477
- pnpm install
478
- pnpm dev
506
+ npm install
507
+ npm run dev
479
508
  ```
480
509
 
481
510
  ### Project Structure
@@ -490,8 +519,11 @@ map-gl-offline/
490
519
  │ │ └── translations/ # i18n (English, Arabic)
491
520
  │ ├── utils/ # Utilities & helpers
492
521
  │ └── types/ # TypeScript definitions
522
+ ├── bin/ # CLI (map-gl-offline init) & Vite plugin
493
523
  ├── examples/
494
- └── maplibre/ # Live example app
524
+ ├── maplibre/ # MapLibre GL JS example app
525
+ │ └── mapbox-gl/ # Mapbox GL JS example app
526
+ ├── docs/ # Docusaurus documentation site
495
527
  └── tests/ # Test suites
496
528
  ```
497
529
 
@@ -506,7 +538,25 @@ map-gl-offline/
506
538
 
507
539
  ## 🔄 Recent Updates
508
540
 
509
- ### v0.5.0 (Latest)
541
+ ### v0.5.3 (Latest)
542
+
543
+ - ✅ **Bundle Size**: Reduced ESM bundle from 783 KB to 565 KB (28% reduction)
544
+ - ✅ **Turf Tree-Shaking**: Replaced `@turf/turf` monorepo with individual packages (`@turf/area`, `@turf/bbox-polygon`, `@turf/difference`, `@turf/helpers`)
545
+ - ✅ **Externalized Dependencies**: `i18next` and `@turf/*` moved to externals
546
+ - ✅ **Removed Unused Dependency**: Removed `@tabler/icons` (unused, 47 MB install)
547
+ - ✅ **Bug Fixes**: 20+ bugs resolved from comprehensive codebase audit
548
+ - ✅ **Import Atomicity**: Region imports use single IndexedDB transactions
549
+ - ✅ **Expired Region Cleanup**: `forceCleanupExpiredRegions` now uses actual expiry timestamps
550
+ - ✅ **XSS Prevention**: User data escaped in all UI templates
551
+ - ✅ **Code Quality**: Dead code removal, `@/` path alias for all imports
552
+
553
+ ### v0.5.2
554
+
555
+ - ✅ **CLI Command**: `npx map-gl-offline init` to copy the Service Worker into your project
556
+ - ✅ **Vite Plugin**: `offlineSwPlugin()` to auto-copy the Service Worker on each build
557
+ - ✅ **Mapbox GL Example**: Full React + Vite example app for Mapbox GL JS
558
+
559
+ ### v0.5.0
510
560
 
511
561
  - ✅ **Mapbox GL JS Support**: Full support for Mapbox styles, including `mapbox://` protocol URL resolution
512
562
  - ✅ **Mapbox Standard Style**: 3D models, raster-dem terrain, and import-based style resolution
package/bin/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cpSync, mkdirSync, existsSync } from 'fs';
4
+ import { resolve, dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const SW_FILENAME = 'idb-offline-sw.js';
9
+
10
+ function init() {
11
+ const src = resolve(__dirname, '..', 'dist', SW_FILENAME);
12
+ const destDir = resolve(process.cwd(), 'public');
13
+ const dest = join(destDir, SW_FILENAME);
14
+
15
+ if (!existsSync(src)) {
16
+ console.error(
17
+ `Error: ${SW_FILENAME} not found in package dist/. Please reinstall map-gl-offline.`,
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ mkdirSync(destDir, { recursive: true });
23
+ cpSync(src, dest);
24
+ console.log(`Copied ${SW_FILENAME} to public/${SW_FILENAME}`);
25
+ }
26
+
27
+ const command = process.argv[2];
28
+
29
+ if (command === 'init') {
30
+ init();
31
+ } else {
32
+ console.log(`Usage: map-gl-offline <command>
33
+
34
+ Commands:
35
+ init Copy the Service Worker (${SW_FILENAME}) to public/
36
+
37
+ The Service Worker is required for Mapbox GL JS offline support.
38
+ MapLibre GL JS does not need it (uses addProtocol instead).`);
39
+ process.exit(command ? 1 : 0);
40
+ }
@@ -0,0 +1,48 @@
1
+ import { cpSync, existsSync, mkdirSync } from 'fs';
2
+ import { resolve, dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const SW_FILENAME = 'idb-offline-sw.js';
7
+
8
+ /**
9
+ * Vite plugin that copies the offline Service Worker to the public directory.
10
+ * Required for Mapbox GL JS offline support.
11
+ *
12
+ * @example
13
+ * ```js
14
+ * import { offlineSwPlugin } from 'map-gl-offline/vite-plugin';
15
+ *
16
+ * export default defineConfig({
17
+ * plugins: [offlineSwPlugin()],
18
+ * });
19
+ * ```
20
+ */
21
+ export function offlineSwPlugin() {
22
+ let projectRoot = '';
23
+
24
+ return {
25
+ name: 'map-gl-offline-sw',
26
+
27
+ configResolved(config) {
28
+ projectRoot = config.root;
29
+ },
30
+
31
+ buildStart() {
32
+ const src = resolve(__dirname, '..', 'dist', SW_FILENAME);
33
+ const destDir = join(projectRoot, 'public');
34
+ const dest = join(destDir, SW_FILENAME);
35
+
36
+ if (!existsSync(src)) {
37
+ this.warn(
38
+ `${SW_FILENAME} not found in map-gl-offline dist/. Skipping copy.`,
39
+ );
40
+ return;
41
+ }
42
+
43
+ mkdirSync(destDir, { recursive: true });
44
+ cpSync(src, dest);
45
+ console.log(`[map-gl-offline] Copied ${SW_FILENAME} to public/`);
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Service Worker for offline map tile serving.
3
+ *
4
+ * Intercepts /__offline__/{downloadId}/{type}/{...path} requests and serves
5
+ * resources from IndexedDB. This is needed for Mapbox GL JS v3 which does NOT
6
+ * have addProtocol, so tile requests from web workers cannot be intercepted
7
+ * via window.fetch override alone.
8
+ *
9
+ * Self-contained: uses raw IndexedDB API (no imports).
10
+ *
11
+ * DB: 'offline-map-db' v3
12
+ * Stores: tiles, styles, sprites, glyphs
13
+ * Tile key format: {styleId}:{sourceId}:{z}:{x}:{y}.{ext}
14
+ */
15
+
16
+ const DB_NAME = 'offline-map-db';
17
+ const OFFLINE_PREFIX = '/__offline__/';
18
+
19
+ // In-memory cache: regionId -> { styleKey, timestamp }
20
+ const regionToStyleCache = new Map();
21
+ const CACHE_TTL_MS = 60000;
22
+
23
+ // -----------------------------------------------------------
24
+ // Install / Activate
25
+ // -----------------------------------------------------------
26
+
27
+ self.addEventListener('install', () => {
28
+ self.skipWaiting();
29
+ });
30
+
31
+ self.addEventListener('activate', (event) => {
32
+ event.waitUntil(self.clients.claim());
33
+ });
34
+
35
+ // -----------------------------------------------------------
36
+ // IndexedDB helpers (raw API, no idb library)
37
+ // -----------------------------------------------------------
38
+
39
+ function openDatabase() {
40
+ return new Promise((resolve, reject) => {
41
+ // Open without specifying a version so we never trigger onupgradeneeded.
42
+ // The main application is responsible for creating/upgrading the DB schema.
43
+ // If the DB doesn't exist yet, this will create it at version 1 with no stores,
44
+ // but the SW should only run after the main app has initialised the DB.
45
+ const request = indexedDB.open(DB_NAME);
46
+ request.onsuccess = () => resolve(request.result);
47
+ request.onerror = () => reject(request.error);
48
+ });
49
+ }
50
+
51
+ function idbGet(db, storeName, key) {
52
+ return new Promise((resolve, reject) => {
53
+ const tx = db.transaction(storeName, 'readonly');
54
+ const store = tx.objectStore(storeName);
55
+ const req = store.get(key);
56
+ req.onsuccess = () => resolve(req.result || null);
57
+ req.onerror = () => reject(req.error);
58
+ });
59
+ }
60
+
61
+ function idbGetAll(db, storeName) {
62
+ return new Promise((resolve, reject) => {
63
+ const tx = db.transaction(storeName, 'readonly');
64
+ const store = tx.objectStore(storeName);
65
+ const req = store.getAll();
66
+ req.onsuccess = () => resolve(req.result || []);
67
+ req.onerror = () => reject(req.error);
68
+ });
69
+ }
70
+
71
+ // -----------------------------------------------------------
72
+ // findStyleByRegionId - search all styles for matching region
73
+ // -----------------------------------------------------------
74
+
75
+ async function findStyleByRegionId(db, regionId) {
76
+ // Check cache
77
+ const cached = regionToStyleCache.get(regionId);
78
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
79
+ return cached.styleEntry;
80
+ }
81
+
82
+ try {
83
+ const allStyles = await idbGetAll(db, 'styles');
84
+ for (const styleEntry of allStyles) {
85
+ if (styleEntry.regions && Array.isArray(styleEntry.regions)) {
86
+ const hasRegion = styleEntry.regions.some(
87
+ (r) => r.regionId === regionId || r.id === regionId
88
+ );
89
+ if (hasRegion) {
90
+ regionToStyleCache.set(regionId, { styleEntry, timestamp: Date.now() });
91
+ return styleEntry;
92
+ }
93
+ }
94
+ }
95
+ // Cache negative result
96
+ regionToStyleCache.set(regionId, { styleEntry: null, timestamp: Date.now() });
97
+ return null;
98
+ } catch (err) {
99
+ console.warn('[SW] Error searching for style by region ID:', regionId, err);
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // -----------------------------------------------------------
105
+ // Response builders
106
+ // -----------------------------------------------------------
107
+
108
+ function createTileKey(x, y, z, styleId, sourceId, ext) {
109
+ return `${styleId}:${sourceId}:${z}:${x}:${y}.${ext}`;
110
+ }
111
+
112
+ async function decompressGzip(data) {
113
+ try {
114
+ const ds = new DecompressionStream('gzip');
115
+ const stream = new Response(data).body.pipeThrough(ds);
116
+ return await new Response(stream).arrayBuffer();
117
+ } catch (err) {
118
+ console.warn('[SW] Gzip decompression failed:', err);
119
+ return data;
120
+ }
121
+ }
122
+
123
+ async function buildTileResponse(resource) {
124
+ const headers = {
125
+ 'Access-Control-Allow-Origin': '*',
126
+ 'Cache-Control': 'public, max-age=31536000',
127
+ };
128
+
129
+ if (resource.contentType) {
130
+ headers['Content-Type'] = resource.contentType;
131
+ } else if (resource.type === 'vector') {
132
+ headers['Content-Type'] = 'application/vnd.mapbox-vector-tile';
133
+ }
134
+
135
+ let finalData = resource.data;
136
+
137
+ // Check for gzip and decompress
138
+ const view = new Uint8Array(resource.data);
139
+ const isGzipped = view.length >= 2 && view[0] === 0x1f && view[1] === 0x8b;
140
+
141
+ if (isGzipped && resource.type === 'vector') {
142
+ finalData = await decompressGzip(resource.data);
143
+ }
144
+
145
+ if (resource.contentEncoding && resource.contentEncoding !== 'gzip') {
146
+ headers['Content-Encoding'] = resource.contentEncoding;
147
+ }
148
+
149
+ return new Response(finalData, { status: 200, statusText: 'OK', headers });
150
+ }
151
+
152
+ function deriveTileExtension(tiles) {
153
+ if (Array.isArray(tiles) && tiles.length > 0 && typeof tiles[0] === 'string') {
154
+ const match = tiles[0].match(/\.([\w]+)(?:\?|$)/i);
155
+ if (match) return match[1];
156
+ }
157
+ return 'pbf';
158
+ }
159
+
160
+ // -----------------------------------------------------------
161
+ // Resource handlers
162
+ // -----------------------------------------------------------
163
+
164
+ async function handleTile(db, downloadId, rest) {
165
+ const styleEntry = await findStyleByRegionId(db, downloadId);
166
+ const actualStyleId = styleEntry ? styleEntry.key : downloadId;
167
+
168
+ // rest = ['sourceKey', 'z', 'x', 'y.ext']
169
+ if (rest.length !== 4) {
170
+ return new Response('Invalid tile path', { status: 400 });
171
+ }
172
+
173
+ const sourceKey = rest[0];
174
+ const z = Math.floor(parseFloat(rest[1]));
175
+ const x = parseInt(rest[2], 10);
176
+ const yExt = rest[3];
177
+ const yMatch = yExt.match(/(\d+)\.(\w+)/);
178
+
179
+ if (!yMatch) {
180
+ return new Response('Invalid tile coordinates', { status: 400 });
181
+ }
182
+
183
+ const y = parseInt(yMatch[1], 10);
184
+ const requestedExt = yMatch[2];
185
+
186
+ // Try primary key
187
+ const tileKey = createTileKey(x, y, z, actualStyleId, sourceKey, requestedExt);
188
+ let resource = await idbGet(db, 'tiles', tileKey);
189
+ if (resource && resource.data) {
190
+ return buildTileResponse(resource);
191
+ }
192
+
193
+ // Fallback extensions
194
+ const fallbacks = ['pbf', 'mvt', 'png', 'jpg', 'webp'].filter((e) => e !== requestedExt);
195
+ for (const ext of fallbacks) {
196
+ const key = createTileKey(x, y, z, actualStyleId, sourceKey, ext);
197
+ resource = await idbGet(db, 'tiles', key);
198
+ if (resource && resource.data) {
199
+ return buildTileResponse(resource);
200
+ }
201
+ }
202
+
203
+ return new Response('Tile not found', { status: 404 });
204
+ }
205
+
206
+ async function handleGlyph(db, downloadId, rest) {
207
+ const styleEntry = await findStyleByRegionId(db, downloadId);
208
+ const actualStyleId = styleEntry ? styleEntry.key : downloadId;
209
+
210
+ // rest might be ['FontA,FontB', '0-255.pbf'] or ['FontA,FontB,FontC', '0-255.pbf']
211
+ const resourcePath = decodeURIComponent(rest.join('/'));
212
+ const pathParts = resourcePath.split('/');
213
+ const fontstackPart = pathParts[0] || '';
214
+ const rangePart = pathParts[1] || '0-255.pbf';
215
+
216
+ // Split comma-separated fonts for fallback
217
+ const fontstacks = fontstackPart.split(',').map((f) => f.trim());
218
+
219
+ for (const fontstack of fontstacks) {
220
+ const glyphPath = `${fontstack}/${rangePart}`;
221
+ const normalizedPath = glyphPath.endsWith('.pbf') ? glyphPath : `${glyphPath}.pbf`;
222
+
223
+ const candidateKeys = [
224
+ `${actualStyleId}::${normalizedPath}`,
225
+ `${actualStyleId}::${glyphPath}`,
226
+ `${downloadId}::${normalizedPath}`,
227
+ `${downloadId}::${glyphPath}`,
228
+ normalizedPath,
229
+ glyphPath,
230
+ ];
231
+
232
+ for (const key of candidateKeys) {
233
+ const resource = await idbGet(db, 'glyphs', key);
234
+ if (resource && resource.data) {
235
+ return new Response(resource.data, {
236
+ status: 200,
237
+ headers: { 'Content-Type': 'application/x-protobuf' },
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ return new Response('Glyph not found', { status: 404 });
244
+ }
245
+
246
+ async function handleSprite(db, downloadId, rest) {
247
+ const styleEntry = await findStyleByRegionId(db, downloadId);
248
+ const actualStyleId = styleEntry ? styleEntry.key : downloadId;
249
+
250
+ const decodedResourcePath = decodeURIComponent(rest.join('/'));
251
+
252
+ const candidateKeys = [
253
+ `${actualStyleId}::${decodedResourcePath}`,
254
+ `${actualStyleId}:${decodedResourcePath}`,
255
+ `${actualStyleId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
256
+ `${actualStyleId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
257
+ `${downloadId}::${decodedResourcePath}`,
258
+ `${downloadId}:${decodedResourcePath}`,
259
+ `${downloadId}::${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
260
+ `${downloadId}:${decodedResourcePath.replace(/\.(json|png)$/i, '')}`,
261
+ decodedResourcePath,
262
+ `${downloadId}::${decodedResourcePath}`,
263
+ ];
264
+
265
+ // Deduplicate
266
+ const uniqueKeys = [...new Set(candidateKeys)];
267
+
268
+ for (const key of uniqueKeys) {
269
+ const resource = await idbGet(db, 'sprites', key);
270
+ if (resource && resource.data) {
271
+ return new Response(resource.data, {
272
+ status: 200,
273
+ headers: resource.contentType ? { 'Content-Type': resource.contentType } : {},
274
+ });
275
+ }
276
+ }
277
+
278
+ return new Response('Sprite not found', { status: 404 });
279
+ }
280
+
281
+ async function handleTileJSON(db, downloadId, rest) {
282
+ const decodedResourcePath = decodeURIComponent(rest.join('/'));
283
+
284
+ // Try direct style lookup first
285
+ let styleEntry = await idbGet(db, 'styles', downloadId);
286
+
287
+ // Fallback: search by region ID
288
+ if (!styleEntry || !styleEntry.style || !styleEntry.style.sources) {
289
+ const foundStyle = await findStyleByRegionId(db, downloadId);
290
+ if (foundStyle) {
291
+ styleEntry = foundStyle;
292
+ }
293
+ }
294
+
295
+ if (!styleEntry || !styleEntry.style || !styleEntry.style.sources) {
296
+ return new Response('Style not found for TileJSON', { status: 404 });
297
+ }
298
+
299
+ const sources = styleEntry.style.sources;
300
+ let matchedSourceId;
301
+ let matchedSourceConfig;
302
+
303
+ if (decodedResourcePath in sources) {
304
+ matchedSourceId = decodedResourcePath;
305
+ matchedSourceConfig = sources[decodedResourcePath];
306
+ } else {
307
+ for (const [sourceId, sourceValue] of Object.entries(sources)) {
308
+ const sourceUrl = typeof sourceValue.url === 'string' ? sourceValue.url : undefined;
309
+ const originalUrl =
310
+ typeof sourceValue.__originalTilesetUrl === 'string'
311
+ ? sourceValue.__originalTilesetUrl
312
+ : undefined;
313
+ if (sourceUrl === decodedResourcePath || originalUrl === decodedResourcePath) {
314
+ matchedSourceId = sourceId;
315
+ matchedSourceConfig = sourceValue;
316
+ break;
317
+ }
318
+ }
319
+ }
320
+
321
+ if (!matchedSourceId || !matchedSourceConfig) {
322
+ return new Response('Source not found for TileJSON', { status: 404 });
323
+ }
324
+
325
+ // Build offline TileJSON with /__offline__/ tile URLs
326
+ const extension = deriveTileExtension(matchedSourceConfig.tiles);
327
+ const offlineTiles = [
328
+ `${self.location.origin}${OFFLINE_PREFIX}${downloadId}/tile/${matchedSourceId}/{z}/{x}/{y}.${extension}`,
329
+ ];
330
+
331
+ const tileJson = {
332
+ tilejson: typeof matchedSourceConfig.tilejson === 'string' ? matchedSourceConfig.tilejson : '2.2.0',
333
+ name: matchedSourceConfig.name || matchedSourceId,
334
+ tiles: offlineTiles,
335
+ minzoom: typeof matchedSourceConfig.minzoom === 'number' ? matchedSourceConfig.minzoom : 0,
336
+ maxzoom: typeof matchedSourceConfig.maxzoom === 'number' ? matchedSourceConfig.maxzoom : 22,
337
+ };
338
+
339
+ // Copy additional fields
340
+ const fieldsToCopy = [
341
+ 'bounds', 'center', 'vector_layers', 'scheme', 'attribution',
342
+ 'encoding', 'format', 'grids', 'data', 'template', 'version',
343
+ ];
344
+ for (const field of fieldsToCopy) {
345
+ if (field in matchedSourceConfig && matchedSourceConfig[field] !== undefined) {
346
+ tileJson[field] = matchedSourceConfig[field];
347
+ }
348
+ }
349
+
350
+ return new Response(JSON.stringify(tileJson), {
351
+ status: 200,
352
+ headers: { 'Content-Type': 'application/json' },
353
+ });
354
+ }
355
+
356
+ // -----------------------------------------------------------
357
+ // Fetch event listener
358
+ // -----------------------------------------------------------
359
+
360
+ self.addEventListener('fetch', (event) => {
361
+ const url = event.request.url;
362
+ const idx = url.indexOf(OFFLINE_PREFIX);
363
+ if (idx === -1) return; // Not an offline request, let it pass through
364
+
365
+ event.respondWith(handleOfflineRequest(url, idx));
366
+ });
367
+
368
+ async function handleOfflineRequest(url, prefixIndex) {
369
+ try {
370
+ const path = url.substring(prefixIndex + OFFLINE_PREFIX.length);
371
+ const parts = path.split('/');
372
+ const [downloadId, type, ...rest] = parts;
373
+
374
+ if (!downloadId || !type) {
375
+ return new Response('Invalid offline URL', { status: 400 });
376
+ }
377
+
378
+ const db = await openDatabase();
379
+
380
+ switch (type) {
381
+ case 'tile':
382
+ return await handleTile(db, downloadId, rest);
383
+ case 'glyph':
384
+ return await handleGlyph(db, downloadId, rest);
385
+ case 'sprite':
386
+ return await handleSprite(db, downloadId, rest);
387
+ case 'tilesjson':
388
+ return await handleTileJSON(db, downloadId, rest);
389
+ default:
390
+ return new Response(`Unknown resource type: ${type}`, { status: 400 });
391
+ }
392
+ } catch (err) {
393
+ console.error('[SW] Error handling offline request:', err);
394
+ return new Response('Service Worker error', { status: 500 });
395
+ }
396
+ }
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * ```typescript
13
13
  * import { OfflineMapManager, OfflineManagerControl } from 'map-gl-offline';
14
14
  * import maplibregl from 'maplibre-gl';
15
- * import 'map-gl-offline/dist/style.css';
15
+ * import 'map-gl-offline/style.css';
16
16
  *
17
17
  * const map = new maplibregl.Map({
18
18
  * container: 'map',