Section 18 described the typed circle radius and the movable analysis popup as
held on a branch awaiting a usability check. They have since been merged and
issued as Service Worker v24, so the section now says so.
18 intro Service Worker versions for the period run to v24.
18.5 Heading no longer "under test". The status paragraph records the
merge after desktop approval, and the two defects found by testing
through the controls before it: Escape applying the value it was
meant to discard, and a typed 200 m stepping by 5 m because the map
library recomputes the radius a couple of hundred-billionths of a
metre short. Mobile remains unverified.
18.6 Adds "Typing a number exposes an error that dragging hides" from the
Typed Measurements design review, with its paragraph and table
verbatim, replacing the section's own shorter version of the same
point. What follows says what was built, including where it departs
from the review: the review suggested measuring the result with
ol/sphere, but the same cos(latitude) factor is used both ways so
the value typed, the radius shown and pi x radius^2 agree exactly.
18.7 Records the four guide and manual passages corrected after the
merge, and that the fourth was only found by reading the rendered
page.
18.8 The open item is no longer "merge it"; it is the phone check that
is still outstanding.
August's open items in 17.13 are left as written — they are the record of that
period.
Co-Authored-By: Claude Opus 5 <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.2%
HTML
10.6%
CSS
8.5%
PHP
2.7%
Python
1.1%
Other
1.9%