# 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 Header always set Cross-Origin-Opener-Policy "same-origin" Header always set Cross-Origin-Embedder-Policy "require-corp" ``` ### 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