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.
- package/README.md +62 -12
- package/bin/cli.js +40 -0
- package/bin/vite-plugin.js +48 -0
- package/dist/idb-offline-sw.js +396 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +4274 -11248
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +4278 -11252
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +4277 -11256
- package/dist/index.umd.js.map +1 -1
- package/dist/managers/offlineMapManager/analyticsManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/base.d.ts +5 -5
- package/dist/managers/offlineMapManager/cleanupManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/importExportManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/maintenanceManagement.d.ts +2 -2
- package/dist/managers/offlineMapManager/regionManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/resourceManagement.d.ts +1 -1
- package/dist/managers/offlineMapManager/styleManagement.d.ts +1 -1
- package/dist/services/analyticsService.d.ts +1 -1
- package/dist/services/cleanupService.d.ts +1 -1
- package/dist/services/fontService.d.ts +1 -1
- package/dist/services/glyphService.d.ts +1 -3
- package/dist/services/importExportService.d.ts +3 -3
- package/dist/services/maintenanceService.d.ts +2 -2
- package/dist/services/regionService.d.ts +1 -1
- package/dist/services/resourceService.d.ts +2 -2
- package/dist/services/spriteService.d.ts +1 -3
- package/dist/services/styleService.d.ts +2 -2
- package/dist/services/tileService.d.ts +1 -1
- package/dist/storage/indexedDbManager.d.ts +1 -1
- package/dist/types/maintenance.d.ts +1 -1
- package/dist/ui/components/PanelActions.d.ts +1 -1
- package/dist/ui/components/PanelHeader.d.ts +1 -1
- package/dist/ui/components/RegionList.d.ts +1 -1
- package/dist/ui/components/shared/LanguageSelector.d.ts +1 -1
- package/dist/ui/components/shared/MapControlButton.d.ts +1 -1
- package/dist/ui/components/shared/PanelContent.d.ts +3 -3
- package/dist/ui/components/shared/RegionDrawingTool.d.ts +2 -2
- package/dist/ui/controls/polygonControl.d.ts +1 -1
- package/dist/ui/controls/regionControl.d.ts +3 -3
- package/dist/ui/managers/ControlButtonManager.d.ts +1 -1
- package/dist/ui/managers/PanelManager.d.ts +3 -3
- package/dist/ui/managers/downloadManager.d.ts +2 -2
- package/dist/ui/modals/importExportModal.d.ts +1 -1
- package/dist/ui/modals/regionDetailsModal.d.ts +1 -1
- package/dist/ui/offlineManagerControl.d.ts +1 -1
- package/dist/utils/convertStyleForSW.d.ts +1 -1
- package/dist/utils/download.d.ts +1 -1
- package/dist/utils/importResolver.d.ts +1 -1
- package/dist/utils/styleProviderUtils.d.ts +1 -1
- package/dist/utils/styleUtils.d.ts +1 -1
- package/dist/utils/validation.d.ts +1 -1
- package/dist/vite-plugin.js +48 -0
- 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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
493
|
+
npm install
|
|
465
494
|
|
|
466
495
|
# Run development server
|
|
467
|
-
|
|
496
|
+
npm run dev
|
|
468
497
|
|
|
469
498
|
# Run tests
|
|
470
|
-
|
|
499
|
+
npm test
|
|
471
500
|
|
|
472
501
|
# Build library
|
|
473
|
-
|
|
502
|
+
npm run build
|
|
474
503
|
|
|
475
|
-
# Run example app
|
|
504
|
+
# Run MapLibre example app
|
|
476
505
|
cd examples/maplibre
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
│
|
|
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.
|
|
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/
|
|
15
|
+
* import 'map-gl-offline/style.css';
|
|
16
16
|
*
|
|
17
17
|
* const map = new maplibregl.Map({
|
|
18
18
|
* container: 'map',
|