pwaLUPMIS2/README.md
2026-03-04 12:59:40 +01:00

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