Switching the layer on only at the moment a location was saved was too narrow. loadLocations() runs at startup and after every change, and it left the layer hidden — so markers were visible right after saving and gone again on the next reload, which is what made it look as though closing Digitise had removed them. The call moves into loadLocations(), guarded on there being at least one location. That covers startup, saving, and any later refresh from one place, and the two calls in the add paths that duplicated it are gone. An empty district still opens with the layer off, which is what it was created hidden for. One trade-off, since it was asked about: an officer who deliberately unchecks Markers in the layer switcher will find it back on after the next reload or saved location. Respecting that choice would mean persisting it, which is worth doing only if anyone actually wants the layer off while holding locations. Verified both cases: with locations the layer goes visible on the startup load and stays visible through entering and leaving Digitise and through a reload; with none it stays hidden throughout. Service Worker v22 -> v23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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:
- SQLocal already uses its own internal worker - It handles OPFS access internally
- OPFS handles file coordination - Multiple SQLocal instances can access the same database file
- 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
# 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
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
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
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
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
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)
<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)
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
- Check that you're using HTTPS (or localhost)
- Verify COOP/COEP headers are present (DevTools → Network → check response headers)
- 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:
- Use
vite-plugin-cross-origin-isolation(add to vite.config.js) - Or manually refresh the browser after changes
License
MIT
Description
Languages
JavaScript
75.4%
HTML
11%
CSS
9.1%
PHP
2.3%
Python
1.2%
Other
1%