ekke d165b57853 Drawing toolbar: fix iPhone portrait and landscape
Two separate faults, both reported from an iPhone running v16.

Portrait showed no toolbar at all. On iOS Safari an element that both blurs its
backdrop and scrolls frequently fails to paint, and v16's strip did both:
backdrop-filter inherited from the rail, plus overflow-x for the horizontal
scroll. The rail has no overflow, which is why the same build rendered fine in
landscape. The strip now paints an opaque background and switches the blur off.
`-webkit-overflow-scrolling: touch` goes with it — obsolete since iOS 13 and
implicated in the same class of bug.

Landscape showed the desktop rail. The breakpoint was width-only, and a phone
in landscape is 844px wide, so it did not qualify as a phone — but it leaves
only ~270px of map height, and the rail is 396px tall. The layout now switches
on either axis: `(max-width: 576px), (max-height: 560px)`. 560px is the point
below which the rail plus the navbar and dock no longer fit. The matchMedia
query that disables dragging on the docked strip matches the new condition.

Verified across all three states: narrow-and-tall and wide-and-short both give
the strip with no blur and no grip; wide-and-tall keeps the rail.

Service Worker v16 -> v17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 16:18:16 +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 49 MiB
Languages
JavaScript 75.4%
HTML 11%
CSS 9.1%
PHP 2.3%
Python 1.2%
Other 1%