Field officers reported three problems with the drawing palette: it had no background and disappeared over light imagery, it sat centred at the top across the area being drawn, and its icons did not match one another. All three had distinct causes. Layout. The bar is now a two-column rail docked to the left edge on a solid panel, with its controls re-ordered into labelled Draw / Edit / Shape / File bands by MapView._buildToolbarRail(). Two columns because a single file of thirteen buttons plus labels runs past the bottom of a laptop map view; the rail measures 75 x 363 px. Adding ol-ext's own `ol-left` class also makes option bars (Select's Delete/Info, Split's sub-tools) fly out to the right rather than dropping downwards. Icons. ol-ext ships no icon font or sprite sheet — it draws each built-in tool as CSS ::before/::after shapes from borders and box-shadows, which is why our own Bootstrap-Icon tools looked foreign next to them. The generated shapes are suppressed and every button gets an icon from a single TOOLBAR_ICONS map. The glyph is injected as a <span>, not an <i>, because ol-ext styles `.ol-transform > button i::before` and would otherwise out-specify Bootstrap's `.bi::before` and blank the icon. Phones. The rail becomes a horizontally scrolling strip above the dock, which retires the two-row flex-break workaround and its `.ol-editbar-break` element. Touch targets stay at 36 px, and option bars open upward. Two things worth recording for anyone changing this later. The rail must not set `overflow` on either axis: the other axis then computes to `auto` too and clips the option bars that fly out past its right edge. And `display` in the phone media query must stay free of `!important`, since ol-ext shows and hides the whole bar with an inline style. Also picks up ol-offset, an EditBar default that was never explicitly enabled and had been sitting in the bar unlabelled; it is grouped under Shape. Verified in a headless browser at desktop and phone widths, in light and dark mode: icons, grouping, active-tool tint, option-bar placement, and that the bar still hides when Draw mode is off. Service Worker v14 -> v15. 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%