307 lines
9.7 KiB
Markdown
307 lines
9.7 KiB
Markdown
# LUPMIS PWA with Offline SQLite and Maps
|
|
|
|
A Progressive Web App with:
|
|
- **OpenLayers** map with **ol-ext LayerSwitcher** for base map selection
|
|
- **SQLocal** for SQLite database in the browser (via OPFS)
|
|
- **BroadcastChannel** for cross-tab synchronization
|
|
- **Service Worker** for asset caching and offline support
|
|
- **Vite** for development and building
|
|
|
|
## Features
|
|
|
|
- 🗺️ Interactive map with 5 base layers (OSM, Satellite, Topo, Carto Light/Dark)
|
|
- 📍 Click map to set coordinates, markers colored by category
|
|
- 💾 Offline SQLite database (data persists in browser)
|
|
- 🔄 Cross-tab sync via BroadcastChannel
|
|
- 📴 Works offline (cached assets + up to 500 map tiles)
|
|
- 📱 Installable as PWA on mobile and desktop
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Browser │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
│ │ Tab 1 │ │ Tab 2 │ │ Tab 3 │ │
|
|
│ │ main.js │ │ main.js │ │ main.js │ │
|
|
│ │ SQLocal │ │ SQLocal │ │ SQLocal │ │
|
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
│ │ │ │ │
|
|
│ └───────────────┼───────────────┘ │
|
|
│ │ │
|
|
│ BroadcastChannel │
|
|
│ (notifies other tabs of changes) │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌────────────────┐ │
|
|
│ │ OPFS │ ← Single database file │
|
|
│ │ (lupmis.db) │ shared by all tabs │
|
|
│ └────────────────┘ │
|
|
│ │
|
|
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
|
│ │
|
|
│ ┌────────────────┐ │
|
|
│ │ Service Worker │ ← Caches assets for offline │
|
|
│ │ (sw.js) │ │
|
|
│ └────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Why This Architecture?
|
|
|
|
Initially we considered using a **SharedWorker** to manage a single database connection. However:
|
|
|
|
1. **SQLocal already uses its own internal worker** - It handles OPFS access internally
|
|
2. **OPFS handles file coordination** - Multiple SQLocal instances can access the same database file
|
|
3. **Simpler is better** - BroadcastChannel provides easy cross-tab notification without the complexity of SharedWorker bundling issues in Vite
|
|
|
|
The result is simpler code that works reliably with Vite's build system.
|
|
|
|
## File Structure
|
|
|
|
```
|
|
project/
|
|
├── index.html # Entry HTML with map container
|
|
├── main.js # App entry point
|
|
├── vite.config.js # Vite configuration
|
|
├── package.json
|
|
│
|
|
├── src/
|
|
│ ├── components/
|
|
│ │ └── MapView.js # OpenLayers map with ol-ext LayerSwitcher
|
|
│ ├── database.js # SQLocal + BroadcastChannel
|
|
│ └── pwa.js # PWA utilities (install, offline)
|
|
│
|
|
└── public/
|
|
├── sw.js # Service Worker (caching)
|
|
├── manifest.json # PWA manifest
|
|
├── offline.html # Offline fallback page
|
|
└── icons/ # PWA icons
|
|
```
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
# Install dependencies
|
|
npm install
|
|
|
|
# Start development server
|
|
npm run dev
|
|
|
|
# Build for production
|
|
npm run build
|
|
|
|
# Preview production build
|
|
npm run preview
|
|
```
|
|
|
|
## Usage
|
|
|
|
### Basic Database Operations
|
|
|
|
```javascript
|
|
import { sql, dbReady, addLocation, getLocations } from './src/database.js';
|
|
|
|
// Wait for database to be ready
|
|
await dbReady;
|
|
|
|
// Add a location
|
|
await addLocation('Water Point', -1.5234, 7.4567, {
|
|
description: 'Main village well',
|
|
category: 'water'
|
|
});
|
|
|
|
// Get all locations
|
|
const locations = await getLocations();
|
|
|
|
// Direct SQL queries using tagged templates
|
|
const results = await sql`SELECT * FROM locations WHERE category = ${'water'}`;
|
|
```
|
|
|
|
### MapView Component
|
|
|
|
```javascript
|
|
import { MapView } from './src/components/MapView.js';
|
|
|
|
// Create map centered on Ghana
|
|
const map = new MapView('map-container', {
|
|
center: [-1.5, 7.5], // [longitude, latitude]
|
|
zoom: 7,
|
|
basemap: 'osm' // 'osm' | 'satellite' | 'topo' | 'carto-light' | 'carto-dark'
|
|
});
|
|
|
|
// Set category-based marker colors
|
|
map.setCategoryStyles({
|
|
'water': { color: '#3b82f6' },
|
|
'school': { color: '#f59e0b' },
|
|
'health': { color: '#ef4444' },
|
|
});
|
|
|
|
// Add markers from database
|
|
const locations = await getLocations();
|
|
map.addMarkers(locations);
|
|
|
|
// Handle map clicks
|
|
map.onClick((lon, lat, feature) => {
|
|
if (feature) {
|
|
// Clicked on existing marker
|
|
console.log('Selected:', feature.get('name'));
|
|
} else {
|
|
// Clicked on empty space - use coordinates
|
|
document.getElementById('longitude').value = lon.toFixed(6);
|
|
document.getElementById('latitude').value = lat.toFixed(6);
|
|
}
|
|
});
|
|
|
|
// Zoom to a location
|
|
map.zoomTo(-1.5, 7.5, 14);
|
|
|
|
// Fit view to show all markers
|
|
map.fitToMarkers();
|
|
|
|
// Select a marker by ID
|
|
map.selectMarker(locationId);
|
|
```
|
|
|
|
### Available Base Maps
|
|
|
|
| Name | Key | Source |
|
|
|------|-----|--------|
|
|
| OpenStreetMap | `osm` | OpenStreetMap |
|
|
| Satellite | `satellite` | Esri World Imagery |
|
|
| Topographic | `topo` | OpenTopoMap |
|
|
| Carto Light | `carto-light` | CARTO |
|
|
| Carto Dark | `carto-dark` | CARTO |
|
|
|
|
### Cross-Tab Synchronization
|
|
|
|
```javascript
|
|
import { onDatabaseChange } from './src/database.js';
|
|
|
|
// Listen for changes from other tabs
|
|
onDatabaseChange((change) => {
|
|
console.log('Database changed:', change);
|
|
// { table: 'locations', action: 'INSERT', id: 5, timestamp: 1234567890 }
|
|
|
|
if (change.table === 'locations') {
|
|
refreshLocationsList();
|
|
}
|
|
});
|
|
```
|
|
|
|
### PWA Features
|
|
|
|
```javascript
|
|
import { initPWA, isOnline, onOfflineChange } from './src/pwa.js';
|
|
|
|
// Initialize PWA
|
|
await initPWA();
|
|
|
|
// Check online status
|
|
if (isOnline()) {
|
|
syncWithServer();
|
|
}
|
|
|
|
// React to offline/online changes
|
|
onOfflineChange((offline) => {
|
|
if (offline) {
|
|
showOfflineBanner();
|
|
} else {
|
|
hideOfflineBanner();
|
|
syncWithServer();
|
|
}
|
|
});
|
|
```
|
|
|
|
## Deployment
|
|
|
|
### Required Headers
|
|
|
|
Your web server must send these headers for OPFS to work:
|
|
|
|
```
|
|
Cross-Origin-Opener-Policy: same-origin
|
|
Cross-Origin-Embedder-Policy: require-corp
|
|
```
|
|
|
|
### Nginx Configuration
|
|
|
|
```nginx
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name your-domain.com;
|
|
|
|
root /var/www/dist;
|
|
index index.html;
|
|
|
|
# Required for OPFS/SQLite
|
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
|
|
# Cache static assets
|
|
location ~* \.(js|css|wasm|png|jpg|ico|svg)$ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
}
|
|
|
|
# SPA fallback
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Apache (.htaccess)
|
|
|
|
```apache
|
|
<IfModule mod_headers.c>
|
|
Header always set Cross-Origin-Opener-Policy "same-origin"
|
|
Header always set Cross-Origin-Embedder-Policy "require-corp"
|
|
</IfModule>
|
|
```
|
|
|
|
### OpenResty (Docker)
|
|
|
|
```nginx
|
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
```
|
|
|
|
## Browser Support
|
|
|
|
- **Chrome/Edge 89+** - Full support
|
|
- **Firefox 111+** - Full support
|
|
- **Safari 15.2+** - OPFS supported
|
|
- **Mobile** - Chrome Android, Safari iOS 15.2+
|
|
|
|
## Troubleshooting
|
|
|
|
### "SecurityError" or "NotAllowedError"
|
|
|
|
The COOP/COEP headers are missing. Check your server configuration.
|
|
|
|
### Database not persisting
|
|
|
|
1. Check that you're using HTTPS (or localhost)
|
|
2. Verify COOP/COEP headers are present (DevTools → Network → check response headers)
|
|
3. Check browser DevTools → Application → Storage → OPFS
|
|
|
|
### Changes not syncing between tabs
|
|
|
|
The BroadcastChannel should handle this automatically. Check the browser console for any errors.
|
|
|
|
### Vite HMR WebSocket errors
|
|
|
|
The cross-origin isolation can break Vite's hot reload. Options:
|
|
1. Use `vite-plugin-cross-origin-isolation` (add to vite.config.js)
|
|
2. Or manually refresh the browser after changes
|
|
|
|
## License
|
|
|
|
MIT
|