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>
1907 lines
62 KiB
HTML
1907 lines
62 KiB
HTML
<!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>
|