pwaLUPMIS2/dist/index.html
ekke ef12e4477b Offline tile cache, polygon Divide, topographic layer integrations
Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.

Drawing & editing toolkit
  * Polygon Divide tool — sub-button under Split, divides a polygon into
    N equal-area pieces via binary search; user picks the cutting edge
  * UPN pick phase after Split and Divide — non-picked pieces have their
    identifier fields cleared automatically
  * Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
    hybrid lockstep extension; bold A/B labels on selected polygons
  * Persistent vertex highlights — all vertices of the selected polygon
    rendered as dots while edit mode is on, without subclassing ol-ext
  * Toast notifications for merge/split/divide outcomes
  * Shapefile import — addGeoJSONLayer now includes an image style so
    Point features render (previously invisible)

Background & overlay layers
  * DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
  * DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
  * Contours hillshade — get_contours_hillshade.php → local SQLite cache
  * OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
    style (black 3.5 px outer, #F0F1F0 1.5 px inner)
  * External Source dialog — green + button in LayerSwitcher lets users
    add WMS / WFS / XYZ layers at runtime
  * Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
    legendUrl, onlineOnly options
  * TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
  * Legend panel — bottom-right, auto-shown for visible layers that
    register a legendUrl
  * Default base map setting in Settings, persisted in localStorage;
    setBaseMap() on MapView

Offline tile cache (Phase 1 + 2)
  * Service worker: per-host tile caches (osm / topo / satellite /
    carto-light / carto-dark), counter-based eviction to prevent
    iOS Safari memory-pressure reloads, GET_TILE_STATS /
    CLEAR_TILE_CACHES message API
  * pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
    getTileCacheStats, clearTileCaches, getStorageEstimate
  * Settings: Offline Map Tiles card with per-provider stats + clear
  * Phase 2 download dialog: form to pick base map, area (current view /
    district / Ghana), zoom range; live tile-count + size estimate;
    progress bar with cancel; OfflineTileDownloader class with
    concurrency + throttling

Local database management
  * osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
  * CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
  * Local Database Tables card: per-row Clear button (cached layers
    only) + 'Refresh cached layers' header button with reload prompt

Build & infrastructure
  * Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
  * chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
    can't be split further)
  * Toast notification module (src/toast.js)
  * Units module (src/units.js) for metric / imperial conversions
  * PDF export module (src/pdf-export.js)

Documentation & SQL
  * Topographic_Background_Layers_for_LUPMIS2.docx — research report
  * OpenTopography_Workflow.svg/.png — ETL pipeline diagram
  * LUPMIS2_Development_Status_Report.docx — April update section
  * sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
    land-use parcel specification (Feb 2026, revised), with PostGIS
    geometry column and standard indices

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:55:30 +02:00

1907 lines
62 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
<style>
/* Bebas Neue 400 — latin-ext */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Bebas Neue 400 — latin */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Exo 300-800 — vietnamese */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* Exo 300-800 — latin-ext */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Exo 300-800 — latin */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>
<title>LUPMIS2 Drawing Tools</title>
<style>
/* LUSPA Design System Tokens
* Source: lupmis.ammonvictor.com design guidelines
*
* Brand Palette:
* --brand-navy: #1e1a4b (foreground text)
* --brand-blue-gray: #333f48 (charcoal)
* --brand-blue-strong: #005eb8 (primary action)
* --brand-blue-light: #b9d9eb (accent/highlight)
* --brand-gray-cool: #f2f4f7 (muted background)
* --brand-green-deep: #006b3f (success)
* --brand-green-bright: #41b6a6 (teal accent)
* --brand-brown-muted: #8b6f47 (earth tone)
* --brand-orange-warm: #ff9e1b (warning)
* --brand-gray-medium: #7a7a7a (muted text)
*/
:root {
/* Brand palette */
--brand-navy: #1e1a4b;
--brand-blue-gray: #333f48;
--brand-blue-strong: #005eb8;
--brand-blue-light: #b9d9eb;
--brand-gray-cool: #f2f4f7;
--brand-green-deep: #006b3f;
--brand-green-bright: #41b6a6;
--brand-brown-muted: #8b6f47;
--brand-orange-warm: #ff9e1b;
--brand-gray-medium: #7a7a7a;
/* Semantic tokens */
--primary: var(--brand-blue-strong);
--primary-foreground: #fff;
--primary-hover: #004a92;
--foreground: var(--brand-navy);
--background: #fff;
--card: #fff;
--card-foreground: var(--brand-navy);
--muted: var(--brand-gray-cool);
--muted-foreground: var(--brand-gray-medium);
--accent: var(--brand-blue-light);
--accent-foreground: var(--brand-navy);
--success: var(--brand-green-deep);
--success-foreground: #fff;
--warning: var(--brand-orange-warm);
--warning-foreground: var(--brand-navy);
--destructive: #d4183d;
--destructive-foreground: #fff;
--border: #1e1a4b1f;
--ring: var(--brand-blue-strong);
/* Typography */
--font-display: "Bebas Neue", sans-serif;
--font-body: "Exo", sans-serif;
/* Bootstrap overrides using semantic tokens */
--bs-primary: var(--brand-blue-strong);
--bs-primary-rgb: 0, 94, 184;
--bs-secondary: var(--brand-green-deep);
--bs-secondary-rgb: 0, 107, 63;
--bs-body-color: var(--brand-navy);
/* Shorthand overrides for component styling */
--bsOverwrite-primary: var(--brand-blue-strong);
--bsOverwrite-primary-light: var(--brand-blue-light);
--bsOverwrite-primary-dark: var(--primary-hover);
--bsOverwrite-secondary: var(--brand-green-deep);
--bsOverwrite-text: var(--brand-navy);
--bsOverwrite-on-primary: #fff;
/* Legacy named tokens (mapped to new palette) */
--luspa-charcoal: var(--brand-blue-gray);
--luspa-navy: var(--brand-navy);
--luspa-blue: var(--brand-blue-strong);
--luspa-blue-light: var(--brand-blue-light);
--luspa-lavender: var(--brand-gray-cool);
--luspa-green: var(--brand-green-deep);
--luspa-lime: var(--brand-green-bright);
--luspa-tan: var(--brand-brown-muted);
--luspa-gold: var(--brand-orange-warm);
/* Border radius tokens */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
}
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
.fieldwork-mode {
--foreground: #000;
--background: #fff;
--card: #fff;
--card-foreground: #000;
--primary: #0044aa;
--primary-foreground: #fff;
--primary-hover: #003080;
--muted: #e0e0e0;
--muted-foreground: #333;
--accent: #cce0ff;
--accent-foreground: #000;
--border: rgba(0,0,0,0.25);
--success: #005a00;
--success-foreground: #fff;
--warning: #b36b00;
--warning-foreground: #000;
--destructive: #b80000;
--destructive-foreground: #fff;
--ring: #0044aa;
--bs-body-color: #000;
}
/* Fieldwork: larger dock buttons */
.fieldwork-mode .dock-btn {
min-width: 72px;
min-height: 58px;
font-size: 1.6rem;
border-width: 2px;
}
.fieldwork-mode .dock-btn-label {
font-size: 0.75rem;
font-weight: 600;
}
/* Fieldwork: bolder navbar */
.fieldwork-mode .navbar {
border-bottom-width: 4px;
}
.fieldwork-mode .navbar .navbar-brand {
font-size: 1.6rem;
}
/* Fieldwork: larger offcanvas toggle buttons */
.fieldwork-mode .offcanvas-toggle {
width: 44px;
height: 44px;
font-size: 1.2rem;
}
/* Fieldwork: thicker bottom dock border */
.fieldwork-mode .bottom-dock {
border-top-width: 4px;
}
/* Fieldwork: larger text in cards / lists */
.fieldwork-mode .card-header h6 {
font-size: 1rem;
}
.fieldwork-mode .list-group-item {
font-size: 0.95rem;
padding: 0.65rem 1rem;
}
/* Fieldwork: larger buttons globally */
.fieldwork-mode .btn {
font-size: 0.95rem;
padding: 0.5rem 1rem;
font-weight: 600;
}
.fieldwork-mode .btn-sm {
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
}
/* Fieldwork: stronger borders on inputs / form controls */
.fieldwork-mode .form-control,
.fieldwork-mode .form-select {
border-width: 2px;
border-color: #555;
font-size: 1rem;
}
/* Fieldwork: bolder map controls (ol-ext) */
.fieldwork-mode .ol-control button {
font-size: 1.3rem;
width: 2.2em;
height: 2.2em;
}
/* Fieldwork: scale bar text legibility */
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
.fieldwork-mode .ol-scale-bar .ol-scale-text {
font-size: 12px;
font-weight: 700;
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
}
/* ─── Dark Mode ─── reversed colour scheme ─── */
.dark-mode {
--foreground: #e0dff0;
--background: #131325;
--card: #1e1e38;
--card-foreground: #e0dff0;
--primary: #4d9de6;
--primary-foreground: #fff;
--primary-hover: #6fb3f0;
--muted: #272745;
--muted-foreground: #9594a8;
--accent: #1e3a5f;
--accent-foreground: #e0dff0;
--border: rgba(255,255,255,0.12);
--ring: #4d9de6;
--success: #2dd46a;
--success-foreground: #131325;
--warning: #ffb84d;
--warning-foreground: #131325;
--destructive: #f04040;
--destructive-foreground: #fff;
--bs-body-color: #e0dff0;
--bs-body-bg: #131325;
--bs-tertiary-bg: #1e1e38;
color-scheme: dark;
}
/* Dark: navbar */
.dark-mode .navbar {
background-color: #1a1a30 !important;
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
}
/* Dark: bottom dock */
.dark-mode .bottom-dock {
background-color: #1a1a30;
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
}
.dark-mode .dock-btn {
border-color: var(--primary);
color: var(--foreground);
}
.dark-mode .dock-btn:hover {
background-color: var(--muted);
}
.dark-mode .dock-btn.active {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: offcanvas panels */
.dark-mode .offcanvas {
background-color: var(--background) !important;
color: var(--foreground) !important;
}
.dark-mode .offcanvas-header {
border-bottom-color: var(--border) !important;
}
.dark-mode .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Dark: cards */
.dark-mode .card {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: offcanvas toggle buttons */
.dark-mode .offcanvas-toggle {
background-color: var(--card);
color: var(--foreground);
border-color: var(--border);
}
.dark-mode .offcanvas-toggle:hover {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: form controls */
.dark-mode .form-control,
.dark-mode .form-select {
background-color: var(--muted) !important;
color: var(--foreground) !important;
border-color: var(--border) !important;
}
.dark-mode .form-check-input {
background-color: var(--muted);
border-color: var(--muted-foreground);
}
.dark-mode .form-check-input:checked {
background-color: var(--primary);
border-color: var(--primary);
}
/* Dark: list groups */
.dark-mode .list-group-item {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: buttons */
.dark-mode .btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
}
.dark-mode .btn-outline-danger {
color: var(--destructive);
border-color: var(--destructive);
}
/* Dark: text utilities */
.dark-mode .text-muted {
color: var(--muted-foreground) !important;
}
/* Dark: measurement tooltips */
.dark-mode .measure-tooltip {
background: rgba(30, 30, 56, 0.95);
color: var(--foreground);
border-color: var(--primary);
}
.dark-mode .measure-tooltip::before {
border-right-color: var(--primary);
}
/* Dark: OL controls */
.dark-mode .ol-control button {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-control button:hover {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
.dark-mode .ol-attribution,
.dark-mode .ol-attribution a {
color: var(--muted-foreground) !important;
}
/* Dark: scale bar */
.dark-mode .ol-scale-bar .ol-scale-step-text,
.dark-mode .ol-scale-bar .ol-scale-text {
color: #fff !important;
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
background-color: #fff !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
background-color: #999 !important;
}
/* Dark: map drop overlay */
.dark-mode .map-drop-overlay {
background: rgba(19, 19, 37, 0.85);
border-color: var(--primary);
color: var(--foreground);
}
/* Dark: ol-ext LayerSwitcher */
.dark-mode .ol-layerswitcher {
background-color: var(--card) !important;
}
.dark-mode .ol-layerswitcher .panel {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-layerswitcher .panel li {
color: var(--foreground);
}
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
background: var(--card) !important;
}
/* Dark: alert boxes */
.dark-mode .alert-danger {
background-color: rgba(240, 64, 64, 0.15) !important;
color: var(--destructive) !important;
border-color: var(--destructive) !important;
}
.dark-mode .alert-success {
background-color: rgba(45, 212, 106, 0.15) !important;
color: var(--success) !important;
border-color: var(--success) !important;
}
/* Full height layout */
html, body {
height: 100%;
overflow: hidden;
color: var(--foreground);
font-family: var(--font-body);
}
/* Override Bootstrap primary color */
.btn-primary {
--bs-btn-bg: var(--bsOverwrite-primary);
--bs-btn-border-color: var(--bsOverwrite-primary);
--bs-btn-hover-bg: var(--bsOverwrite-primary-dark);
--bs-btn-hover-border-color: var(--bsOverwrite-primary-dark);
--bs-btn-active-bg: var(--bsOverwrite-primary-dark);
--bs-btn-active-border-color: var(--bsOverwrite-primary-dark);
--bs-btn-color: var(--bsOverwrite-on-primary);
--bs-btn-hover-color: var(--bsOverwrite-on-primary);
--bs-btn-active-color: var(--bsOverwrite-on-primary);
}
.btn-outline-primary {
--bs-btn-color: var(--bsOverwrite-primary);
--bs-btn-border-color: var(--bsOverwrite-primary);
--bs-btn-hover-bg: var(--bsOverwrite-primary);
--bs-btn-hover-border-color: var(--bsOverwrite-primary);
--bs-btn-hover-color: var(--bsOverwrite-on-primary);
--bs-btn-active-bg: var(--bsOverwrite-primary);
--bs-btn-active-border-color: var(--bsOverwrite-primary);
--bs-btn-active-color: var(--bsOverwrite-on-primary);
}
.btn-secondary {
--bs-btn-bg: var(--bsOverwrite-secondary);
--bs-btn-border-color: var(--bsOverwrite-secondary);
--bs-btn-color: #fff;
}
.bg-primary {
background-color: var(--bsOverwrite-primary) !important;
color: var(--bsOverwrite-on-primary) !important;
}
.text-primary {
color: var(--bsOverwrite-primary) !important;
}
.border-primary {
border-color: var(--bsOverwrite-primary) !important;
}
/* Navbar styling — white background with navy text and blue-strong accent */
.navbar {
background-color: var(--background) !important;
border-bottom: 3px solid var(--primary);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.navbar .navbar-brand {
color: var(--foreground) !important;
font-family: var(--font-display);
font-size: 1.4rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Card headers */
.card-header.bg-primary {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
.card-header.bg-primary h6 {
color: var(--primary-foreground) !important;
font-family: var(--font-body);
font-weight: 600;
}
/* Modal header */
.modal-header.bg-primary {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
.modal-header.bg-primary .modal-title {
color: var(--primary-foreground) !important;
font-family: var(--font-display);
font-weight: 400;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.modal-header.bg-primary .btn-close-white {
filter: none;
}
/* Focus states */
.form-control:focus, .form-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 0.25rem rgba(0, 94, 184, 0.2);
}
/* Form labels */
.form-label {
color: var(--foreground);
font-family: var(--font-body);
}
/* Main container - full height.
100dvh accounts for mobile browser chrome and OS nav bars.
Falls back to 100vh for older browsers. */
.app-container {
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
}
/* Map and sidebar row */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* Map container - now takes full space */
.map-container {
flex: 1;
position: relative;
min-height: 250px;
width: 100%;
height: 100%;
}
#map {
width: 100%;
height: 100%;
}
/* Drag-and-drop overlay shown when files are dragged over the map */
.map-drop-overlay {
position: absolute;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 94, 184, 0.15);
border: 3px dashed var(--primary, #005eb8);
border-radius: 8px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.map-container.drag-over .map-drop-overlay {
opacity: 1;
}
.map-drop-overlay span {
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 1.15rem;
font-weight: 600;
color: var(--primary, #005eb8);
background: var(--card, #fff);
padding: 0.6rem 1.4rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
/* Offline indicator */
#offline-indicator {
display: none;
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
body.is-offline #offline-indicator {
display: block;
}
/* Locations list */
.locations-list {
max-height: 200px;
overflow-y: auto;
}
@media (min-width: 768px) {
.locations-list {
max-height: 300px;
}
}
.location-item {
cursor: pointer;
transition: background-color 0.15s;
color: var(--foreground);
}
.location-item:hover {
background-color: var(--muted);
}
.location-item h6 {
color: var(--foreground);
}
/* Category badges */
.badge-water { background-color: #3b82f6 !important; }
.badge-school { background-color: #f59e0b !important; }
.badge-health { background-color: #ef4444 !important; }
.badge-market { background-color: #8b5cf6 !important; }
.badge-default { background-color: var(--bsOverwrite-secondary) !important; }
.badge-other { background-color: #6b7280 !important; }
/* Install button */
#install-btn {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1050;
}
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
.ol-overlaycontainer-stopevent, trapping all controls below the
offcanvas-toggle buttons (z-index:500). Raising the container
to 501 lets the LayerSwitcher dropdown render above the toggles.
pointer-events:none on the container still lets clicks through
to the toggle buttons underneath. */
.ol-overlaycontainer-stopevent {
z-index: 501 !important;
}
/* Alert hint box */
.alert-light.border-primary {
border-color: var(--primary) !important;
color: var(--foreground);
}
/* Badge in navbar and cards */
.badge.bg-primary {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
/* Headings and text — LUSPA Design System typography */
h1, h2 {
font-family: var(--font-display);
font-weight: 400;
line-height: 1.25;
letter-spacing: 0.025em;
text-transform: uppercase;
color: var(--foreground);
}
h3, h4, h5, h6 {
font-family: var(--font-body);
font-weight: 600;
line-height: 1.375;
color: var(--foreground);
}
/* Scrollbar styling */
.offcanvas-body::-webkit-scrollbar,
.locations-list::-webkit-scrollbar {
width: 6px;
}
.offcanvas-body::-webkit-scrollbar-track,
.locations-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.offcanvas-body::-webkit-scrollbar-thumb,
.locations-list::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
.offcanvas-body::-webkit-scrollbar-thumb:hover,
.locations-list::-webkit-scrollbar-thumb:hover {
background: var(--primary-hover);
}
/* Offcanvas toggle buttons on map */
.offcanvas-toggle {
position: absolute;
z-index: 500;
background-color: var(--card);
border: 2px solid var(--primary);
color: var(--primary);
min-width: 44px;
min-height: 44px;
padding: 10px 14px;
border-radius: var(--radius-lg);
cursor: pointer;
font-size: 1.2rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
transition: all 0.15s ease;
/* Touch-friendly */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.offcanvas-toggle:hover {
background-color: var(--primary);
color: var(--primary-foreground);
transform: scale(1.05);
}
.offcanvas-toggle:active {
background-color: var(--primary-hover);
color: var(--primary-foreground);
transform: scale(0.95);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.offcanvas-toggle-left {
left: 10px;
top: 50%;
transform: translateY(-50%);
}
.offcanvas-toggle-left:hover {
transform: translateY(-50%) scale(1.05);
}
.offcanvas-toggle-left:active {
transform: translateY(-50%) scale(0.95);
}
.offcanvas-toggle-right {
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.offcanvas-toggle-right:hover {
transform: translateY(-50%) scale(1.05);
}
.offcanvas-toggle-right:active {
transform: translateY(-50%) scale(0.95);
}
.offcanvas-toggle-bottom {
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
left: 50%;
transform: translateX(-50%);
}
.offcanvas-toggle-bottom:hover {
transform: translateX(-50%) scale(1.05);
}
.offcanvas-toggle-bottom:active {
transform: translateX(-50%) scale(0.95);
}
/* Bottom Dock — white card style with blue-strong accent.
env(safe-area-inset-bottom) adds padding on devices with a
home indicator / gesture bar (e.g. iPhone notch models).
The value is 0 on devices without an inset. */
.bottom-dock {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 600;
background-color: var(--card);
border-top: 3px solid var(--primary);
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
display: flex;
justify-content: space-around;
align-items: center;
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
}
.dock-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 60px;
min-height: 50px;
padding: 6px 12px;
background-color: transparent;
border: 1px solid var(--primary);
color: var(--foreground);
font-size: 1.4rem;
cursor: pointer;
border-radius: var(--radius-lg);
transition: all 0.15s ease;
/* Touch-friendly */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.dock-btn:hover {
background-color: var(--muted);
}
.dock-btn:active {
background-color: var(--accent);
transform: scale(0.92);
}
.dock-btn.active {
background-color: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.dock-btn.active:hover {
background-color: var(--primary-hover);
color: var(--primary-foreground);
}
.dock-btn-label {
font-size: 0.65rem;
margin-top: 2px;
font-weight: 500;
font-family: var(--font-body);
}
/* EditBar colour picker inline styling */
.ol-editbar .ol-colorpicker button {
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
/* Snap-guides toggle — highlighted when active */
.ol-snap-toggle.ol-active button {
background: var(--primary) !important;
color: var(--primary-foreground, #fff) !important;
border-radius: 3px;
}
/* Touch-friendly improvements for forms and buttons */
.form-control, .form-select {
min-height: 44px;
font-size: 16px; /* Prevents iOS zoom on focus */
}
.btn {
min-height: 44px;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn:active {
transform: scale(0.97);
}
/* Location list items - larger touch targets */
.location-item {
cursor: pointer;
transition: background-color 0.15s;
color: var(--foreground);
min-height: 60px;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.location-item:hover {
background-color: var(--muted);
}
.location-item:active {
background-color: var(--accent);
}
.location-item h6 {
color: var(--foreground);
}
/* Message log in the right panel */
.message-log {
max-height: 260px;
overflow-y: auto;
}
.message-log-entry {
font-size: 0.82rem;
border-color: var(--border, #eee) !important;
background: transparent;
}
/* ol-ext GeolocationButton styling */
.ol-geobt {
top: auto !important;
bottom: 90px !important;
right: 10px !important;
left: auto !important;
}
.ol-geobt button {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
min-width: 44px;
min-height: 44px;
}
.ol-geobt button:hover {
background-color: var(--primary-hover) !important;
}
/* ol-ext SearchNominatim styling */
.ol-search {
top: 10px !important;
left: 40px !important;
right: auto !important;
}
.ol-search button {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
min-width: 24px;
min-height: 24px;
border: none;
}
.ol-search button:hover {
background-color: var(--primary-hover) !important;
}
.ol-search input {
border: 2px solid var(--primary) !important;
border-radius: var(--radius-md);
padding: 8px 12px;
font-size: 14px;
font-family: var(--font-body);
min-height: 40px;
color: var(--foreground);
width: 200px;
}
@media (min-width: 500px) {
.ol-search input {
width: 280px;
}
}
.ol-search input:focus {
outline: none;
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px rgba(0, 94, 184, 0.2);
}
.ol-search input::placeholder {
color: var(--muted-foreground);
}
.ol-search ul.autocomplete {
background: var(--card);
border: 2px solid var(--primary);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.ol-search ul.autocomplete li {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
color: var(--foreground);
font-size: 13px;
font-family: var(--font-body);
min-height: 44px;
display: flex;
align-items: center;
}
.ol-search ul.autocomplete li:last-child {
border-bottom: none;
}
.ol-search ul.autocomplete li:hover,
.ol-search ul.autocomplete li.select {
background-color: var(--muted) !important;
}
.ol-search ul.autocomplete li b {
color: var(--foreground);
font-weight: 600;
}
/* Add Location Popup Form on Map */
.map-add-location-popup {
background: var(--card);
border-radius: var(--radius-xl);
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
min-width: 280px;
max-width: 320px;
font-family: var(--font-body);
font-size: 14px;
z-index: 1000;
border: 2px solid var(--primary);
overflow: hidden;
}
.add-location-popup-header {
background-color: var(--primary);
color: var(--primary-foreground);
padding: 10px 14px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.add-location-popup-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--primary-foreground);
cursor: pointer;
padding: 0 4px;
opacity: 0.7;
}
.add-location-popup-close:hover {
opacity: 1;
}
#map-add-location-form {
padding: 14px;
}
.add-location-popup-field {
margin-bottom: 12px;
}
.add-location-popup-field label {
display: block;
margin-bottom: 4px;
font-size: 13px;
font-weight: 500;
color: var(--foreground);
}
.add-location-popup-field input,
.add-location-popup-field select,
.add-location-popup-field textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 14px;
font-family: var(--font-body);
min-height: 40px;
color: var(--foreground);
}
.add-location-popup-field input:focus,
.add-location-popup-field select:focus,
.add-location-popup-field textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 94, 184, 0.2);
}
.add-location-popup-field textarea {
resize: vertical;
min-height: 60px;
}
.add-location-popup-coords {
margin-bottom: 12px;
padding: 8px 10px;
background: var(--muted);
border-radius: var(--radius-md);
color: var(--muted-foreground);
font-family: monospace;
}
.add-location-popup-submit {
width: 100%;
padding: 10px 16px;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
min-height: 44px;
transition: background-color 0.15s;
}
.add-location-popup-submit:hover {
background-color: var(--primary-hover);
}
.add-location-popup-submit:active {
transform: scale(0.98);
}
/* Navbar location count as clickable button */
.location-count-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.location-count-btn .badge {
transition: transform 0.15s;
}
.location-count-btn:hover .badge {
transform: scale(1.1);
}
.location-count-btn:active .badge {
transform: scale(0.95);
}
/* Offcanvas styling — white card with blue-strong header */
.offcanvas {
background-color: var(--background) !important;
color: var(--foreground);
}
.offcanvas-header {
background-color: var(--primary);
border-bottom: none;
}
.offcanvas-title {
color: var(--primary-foreground);
font-family: var(--font-display);
font-weight: 400;
letter-spacing: 0.025em;
text-transform: uppercase;
font-size: 1.25rem;
}
.offcanvas-header .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
.offcanvas-body {
color: var(--foreground);
}
/* Right offcanvas - Data Entry panel */
.offcanvas-end {
width: 320px !important;
background-color: var(--bs-body-bg) !important;
}
@media (min-width: 400px) {
.offcanvas-end {
width: 380px !important;
}
}
.offcanvas-end .offcanvas-header {
background-color: var(--primary);
color: var(--primary-foreground);
}
.offcanvas-end .offcanvas-body {
background-color: var(--background);
color: var(--foreground);
padding: 1rem;
}
/* Locations list in offcanvas - can be taller now without form */
.offcanvas-end .locations-list {
max-height: calc(100vh - 280px);
max-height: calc(100dvh - 280px);
overflow-y: auto;
}
/* Bottom offcanvas height */
.offcanvas-bottom {
height: 40vh;
max-height: 400px;
}
/* ================================
Map Tools - Measurement Tooltips
================================ */
.measure-tooltip {
position: relative;
background: rgba(255, 255, 255, 0.95);
border-radius: var(--radius-md);
padding: 6px 10px;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
color: var(--foreground);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border: 2px solid var(--primary);
}
.measure-tooltip-static {
background-color: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.measure-tooltip-static::before {
border-top-color: var(--primary);
}
/* Arrow pointing to measurement point */
.measure-tooltip::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
border: 5px solid transparent;
border-right-color: var(--primary);
}
/* ================================
Map Tools - Control Bar Styling
================================ */
.map-tools-bar {
position: absolute;
top: 70px;
left: 10px;
z-index: 100;
}
.map-tools-bar .ol-bar {
background: var(--card);
border-radius: var(--radius-lg);
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border: 2px solid var(--primary);
padding: 4px;
}
.map-tools-bar button {
background: var(--card);
border: none;
min-width: 40px;
min-height: 40px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.map-tools-bar button:hover {
background: var(--muted);
}
.map-tools-bar button.ol-active,
.map-tools-bar button:active {
background: var(--primary);
}
.map-tools-bar .tool-icon {
font-size: 18px;
}
/* ScaleBar - position above the bottom dock with 4px gap */
.ol-scale-bar {
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
left: 10px !important;
}
.ol-scale-bar .ol-scale-step-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-singlebar-even {
background-color: var(--foreground) !important;
}
.ol-scale-bar .ol-scale-singlebar-odd {
background-color: var(--muted-foreground) !important;
}
/* ol-ext Bar overrides */
.ol-control.ol-bar {
padding: 0;
background: transparent;
}
.ol-control.ol-bar .ol-bar {
display: flex;
gap: 2px;
}
.ol-bar.ol-group {
display: flex;
gap: 2px;
}
</style>
<script type="module" crossorigin src="/assets/index-B4XzHtZX.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-CUDtI0S3.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-CSk2UikI.js">
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
</head>
<body>
<div class="app-container">
<!-- Navbar - visible on all screens -->
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span>LUPMIS2 Drawing Tools
</span>
<button type="button"
class="location-count-btn"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasRight"
aria-controls="offcanvasRight"
title="View saved locations">
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
</button>
</div>
</nav>
<!-- Main content area -->
<div class="main-content">
<!-- Map -->
<div class="map-container">
<div id="offline-indicator">
<span class="badge bg-danger fs-6">
📴 OFFLINE
</span>
</div>
<div id="map"></div>
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
<!-- Offcanvas toggle buttons -->
<button class="offcanvas-toggle offcanvas-toggle-left"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasLeft"
aria-controls="offcanvasLeft"
title="Open left panel">
<i class="bi bi-chevron-left"></i>
</button>
<button class="offcanvas-toggle offcanvas-toggle-right"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasRight"
aria-controls="offcanvasRight"
title="Open right panel">
<i class="bi bi-chevron-right"></i>
</button>
<button class="offcanvas-toggle offcanvas-toggle-bottom"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasBottom"
aria-controls="offcanvasBottom"
title="Open bottom panel">
<i class="bi bi-chevron-down"></i>
</button>
<!-- Bottom Dock -->
<div class="bottom-dock">
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode">
<span>📍</span>
<span class="dock-btn-label">Add</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-measure-circle" title="Measure Circle">
<span></span>
<span class="dock-btn-label">Circle</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-measure-line" title="Measure Distance">
<span>📏</span>
<span class="dock-btn-label">Distance</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-measure-area" title="Measure Area">
<span></span>
<span class="dock-btn-label">Area</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
<span>✏️</span>
<span class="dock-btn-label">Draw</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span>
<span class="dock-btn-label">Clear</span>
</button>
</div>
</div>
</div>
</div>
<!-- Install PWA button -->
<button id="install-btn" class="btn btn-primary btn-lg shadow">
📲 Install App
</button>
<!-- Status Modal -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary">
<h5 class="modal-title" id="statusModalLabel"> Database Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="status-content">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Table Content Modal -->
<div class="modal fade" id="tableContentModal" tabindex="-1" aria-labelledby="tableContentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-primary">
<h5 class="modal-title" id="tableContentModalLabel"><i class="bi bi-table me-2"></i>Table</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="table-content-body">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<small class="text-muted me-auto" id="table-content-info"></small>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Left Offcanvas -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasLeft" aria-labelledby="offcanvasLeftLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLeftLabel"><i class="bi bi-chevron-left me-2"></i>Tools</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
<i class="bi bi-database me-2"></i>Local Data
</button>
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
</button>
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
</button>
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
</button>
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
<small class="message-text"></small>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="imported-layers-info" class="d-none mb-3"></div>
<div id="local-data-stats" class="d-none">
<div class="card">
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
<button type="button" class="btn btn-sm btn-outline-light"
id="clear-all-cached-btn"
title="Delete all cached map layers. They will be re-downloaded on next app start.">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Table</th>
<th class="text-end">Records</th>
<th class="text-end pe-3" style="width:3rem;"></th>
</tr>
</thead>
<tbody id="local-data-tbody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Right Offcanvas - Saved Locations Panel -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<!-- Alert messages -->
<div id="error-message" class="alert alert-danger alert-dismissible fade show d-none" role="alert">
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="success-message" class="alert alert-success alert-dismissible fade show d-none" role="alert">
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<!-- Tip -->
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
<small>💡 Tap on the map to add a new location.</small>
</div>
<!-- Saved Locations Card -->
<div class="card">
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0">📍 Locations</h6>
<span class="badge bg-light text-dark" id="location-count">0</span>
</div>
<div class="card-body p-0">
<div id="locations-list" class="locations-list list-group list-group-flush">
<div class="text-center text-muted py-4">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Loading...
</div>
</div>
</div>
<div class="card-footer bg-transparent">
<div class="btn-group btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary" id="fit-btn" title="Fit map to all markers">
🔍 Fit All
</button>
<button type="button" class="btn btn-outline-secondary" id="export-btn" title="Export database">
📥 Export
</button>
<button type="button" class="btn btn-outline-secondary" id="exportGeoJSON-btn" title="Export GeoJSON">
📥 Export
</button>
<button type="button" class="btn btn-outline-secondary" id="status-btn" title="Show database status">
Status
</button>
</div>
</div>
</div>
<!-- Message Log -->
<div class="card mt-3" id="message-log-card">
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
<i class="bi bi-trash3"></i>
</button>
</div>
<div class="card-body p-0">
<div id="message-log" class="message-log list-group list-group-flush">
<div class="text-center text-muted py-3">
<small>No messages yet.</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Offcanvas -->
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div class="row g-3">
<!-- Fieldwork Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Dark Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Measurement System -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Default Base Map</h6>
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
<option value="satellite">Satellite</option>
<option value="googlesat">Google Sat</option>
<option value="carto-light">Carto Light</option>
<option value="carto-dark">Carto Dark</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Offline Map Tiles -->
<div class="col-12 col-md-6 col-lg-8">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-2">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
<i class="bi bi-map me-1"></i>Offline Map Tiles
</h6>
<small class="text-muted">
Map tiles you've already viewed are cached on this device so they work offline.
Tiles are cached automatically as you browse, or you can pre-download a region.
</small>
</div>
<div class="ms-3 d-flex gap-2 flex-shrink-0">
<button type="button" class="btn btn-sm btn-primary"
id="download-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-cloud-download me-1"></i>Download offline map
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
id="clear-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
</button>
</div>
</div>
<div id="tile-cache-stats" class="small">
<div class="text-muted fst-italic">Loading…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Download Offline Map modal -->
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="offline-download-title">
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
</div>
<!-- Form view (shown until Start is clicked) -->
<div class="modal-body" id="offline-download-form-view">
<p class="text-muted small mb-3">
Pre-fetch map tiles so they're available when you're offline.
Only the OpenStreetMap and Topographic base maps can be downloaded;
other providers don't permit bulk caching.
</p>
<!-- Base map -->
<div class="mb-3">
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
<select class="form-select form-select-sm" id="offline-basemap-select">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
</select>
</div>
<!-- Area -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Area to download</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
<label class="form-check-label" for="offline-area-view">
Current map view
<span class="text-muted small" id="offline-area-view-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
<label class="form-check-label" for="offline-area-district">
District boundary
<span class="text-muted small" id="offline-area-district-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
<label class="form-check-label" for="offline-area-ghana">
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
</label>
</div>
</div>
<!-- Zoom range -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Zoom levels</label>
<div class="row g-2 align-items-center">
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
</div>
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
</div>
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
</div>
</div>
<!-- Estimate -->
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
<strong>Estimated download:</strong>
<span id="offline-estimate-detail">Calculating…</span>
</div>
<!-- Acknowledgement -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="offline-ack-check">
<label class="form-check-label small" for="offline-ack-check">
I understand this counts against the tile provider's usage quota
and will use mobile data if I'm not on Wi-Fi.
</label>
</div>
</div>
<!-- Progress view (shown during download) -->
<div class="modal-body d-none" id="offline-download-progress-view">
<div class="text-center mb-3">
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
</div>
<div class="progress mb-3" style="height:1.25em;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" id="offline-progress-bar"
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
</div>
<div class="row text-center small text-muted">
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta"></div>remaining</div>
</div>
</div>
<!-- Done view (shown after completion) -->
<div class="modal-body d-none" id="offline-download-done-view">
<div class="text-center py-3">
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
<div class="text-muted" id="offline-done-detail"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
Cancel
</button>
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
<i class="bi bi-cloud-download me-1"></i>Start download
</button>
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
<!-- Must run before module imports (SQLocal/coincident require it) -->
<script>
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
crypto.randomUUID = function () {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, function (c) {
return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
});
};
}
</script>
<!-- Main application script -->
</body>
</html>