ekke 90a5a01b05 Drag the analysis popup by its title bar
The popup covers the thing it is reporting on — a circle's own parcels sit
directly underneath it — so it has to be movable to be read against the map.
Pressing the title bar and dragging now moves it.

The drag changes the overlay's offset, not its position. The popup stays tied to
the shape it describes, so panning and zooming carry it along and it goes on
pointing at the right circle; only where it rests relative to that anchor
changes. Setting a new position instead would cut it loose and re-anchor it to
whatever ground it happened to be dropped on.

The resting offset is now a named constant shared with the overlay, so the two
cannot drift apart, and each showing starts from it — a popup opened for a new
shape appears where it belongs rather than where the last one was left.

A press on the close button is not a drag, and the overlay already has
stopEvent:true, so the map does not pan underneath.

This covers the Area Analysis popup too: both are built by the same
_buildAnalysisPopupHtml, so both get the handle.

Separate commit from the radius work so it can be cherry-picked onto main if the
radius entry does not survive the usability check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 17:26:30 +02:00
2026-03-04 12:59:40 +01:00
2026-03-04 12:59:40 +01:00
2026-03-04 12:59:40 +01:00

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

# 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

  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

Description
progressive web app with sqlocal as a mobile app
Readme 96 MiB
Languages
JavaScript 75.2%
HTML 10.6%
CSS 8.5%
PHP 2.7%
Python 1.1%
Other 1.9%