pwaLUPMIS2/dist/index.html
ekke cfaceb3487 GPS trail recording, SSO auth, account menu, and mobile/UI refinements
Major:
- GPS trail recording: reusable, dependency-free engine in src/geotracker/
  (GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS
  wiring in src/geotracker-lupmis.js. Expandable My Location control
  (Locate Me + Record Trail), live navbar GPS readout, on-map trail/position
  rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward
  sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not
  yet built).
- SSO authentication: public/index.php entry point validates the LUSPA SSO
  cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a
  session-resolved getter. Adds public/.htaccess (DirectoryIndex).
- Account menu offcanvas (navbar burger) with sign-in/out states.

UI / fixes:
- LayerSwitcher modernisation; base-map "None" option in picker + settings.
- Mobile drawing toolbar wraps to two rows below 576px and shows only in
  Draw mode; second row right-aligned and clears the Select option bar.
- Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh).
- Rename public/icons -> app-icons to dodge Apache's default /icons/ alias.
- Service Worker bumped to v8 (network-first HTML, per-provider tile clear).

Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office
lock files (~$*). Rebuilt dist/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:08:37 +02:00

2207 lines
74 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="app-icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="app-icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="app-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.
100svh = the "small" viewport height (browser toolbar shown). Because
this app disables scrolling, the toolbar never auto-hides, so svh is
always accurate AND guarantees the bottom dock stays clear of Safari's
bottom chrome (Safari resolves 100vh/100dvh taller than the visible
area, which pushed the dock behind the toolbar and clipped its labels).
Falls back to 100vh on browsers without svh support. */
.app-container {
height: 100vh;
height: 100svh;
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;
}
/* ================================
Expandable "My Location" control
(replaces the ol-ext GeolocationButton — see MapView._createLocationControl)
================================ */
.ls-locate-toggle {
position: absolute;
right: 10px;
bottom: 90px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
cursor: pointer;
z-index: 100;
font-size: 20px;
transition: background 0.15s;
}
.ls-locate-toggle:hover,
.ls-locate-toggle.active { background: var(--primary-hover); }
.ls-locate-toggle.recording {
background: #d32f2f;
animation: ls-locate-pulse 1.4s ease-out infinite;
}
/* Sub-button cluster — expands to the LEFT of the main button */
.ls-locate-actions {
position: absolute;
right: 62px; /* 10 + 44 + 8 gap */
bottom: 90px;
display: flex;
gap: 8px;
opacity: 0;
transform: translateX(8px);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 100;
}
.ls-locate-actions.open {
opacity: 1;
transform: none;
pointer-events: auto;
}
.ls-locate-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card);
color: var(--primary);
border: 2px solid var(--primary);
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
cursor: pointer;
font-size: 18px;
transition: all 0.12s;
}
.ls-locate-btn:hover { background: var(--muted); }
.ls-locate-record.recording {
background: #d32f2f;
color: #fff;
border-color: #d32f2f;
}
@keyframes ls-locate-pulse {
0% { box-shadow: 0 0 0 0 rgba(211,47,47,0.55); }
70% { box-shadow: 0 0 0 10px rgba(211,47,47,0); }
100% { box-shadow: 0 0 0 0 rgba(211,47,47,0); }
}
/* ================================
Navbar live GPS readout
================================ */
.gps-readout {
display: flex;
align-items: center;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
padding: 3px 10px;
border-radius: 999px;
background: var(--muted, #f1f3f5);
color: var(--muted-foreground, #6b7280);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.72rem;
line-height: 1.15;
white-space: nowrap;
transition: color 0.2s, background 0.2s;
}
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
.gps-coords { font-weight: 600; }
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
.gps-readout.recording { background: rgba(211,47,47,0.12); }
.gps-readout.quality-good .bi-broadcast { color: #10b981; opacity: 1; }
.gps-readout.quality-fair .bi-broadcast { color: #f59e0b; opacity: 1; }
.gps-readout.quality-poor .bi-broadcast { color: #ef4444; opacity: 1; }
/* Tight navbars: drop the brand text and the secondary meta line first */
@media (max-width: 600px) {
.brand-text { display: none; }
.gps-readout { font-size: 0.68rem; padding: 3px 8px; }
}
@media (max-width: 380px) {
.gps-readout .gps-meta { display: none; }
}
/* 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);
}
/* Navbar Menu button — opens the right-side account menu */
.navbar-menu-btn {
background: var(--primary, #005eb8);
color: var(--primary-foreground, #fff);
border: none;
border-radius: 8px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
cursor: pointer;
transition: background .15s, transform .12s;
-webkit-tap-highlight-color: transparent;
}
.navbar-menu-btn:hover {
background: var(--primary-hover, #004a92);
}
.navbar-menu-btn:active {
transform: scale(0.95);
}
/* Subtle red dot when no session — visual cue without being intrusive */
.navbar-menu-btn[data-state="no-session"]::after,
.navbar-menu-btn[data-state="unauthenticated"]::after {
content: '';
position: absolute;
width: 9px;
height: 9px;
background: var(--brand-orange-warm, #ff9e1b);
border: 2px solid var(--primary, #005eb8);
border-radius: 50%;
transform: translate(14px, -14px);
}
.navbar-menu-btn { position: relative; }
/* 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;
}
/* ----------------------------------------------------------------
Small-screen drawing toolbar: the EditBar is a single nowrap row
by default and overflows narrow phones. Below the sm breakpoint we
let it wrap and push the action group (undo / redo / save / snap)
onto its own second row so every tool stays reachable.
---------------------------------------------------------------- */
/* Flex line-break used to start the toolbar's second row. Inert (removed
from flow) on wider screens; activated inside the sm media query. */
.ol-editbar-break {
display: none;
}
@media (max-width: 576px) {
.ol-editbar.ol-bar {
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
inline `display:none`/`''` (setVisible). A `!important` here would beat
that inline style and keep the toolbar permanently visible even when
Draw mode is off. Plain `display:flex` only applies when visible. */
display: flex;
flex-wrap: wrap !important;
justify-content: center;
align-items: center;
row-gap: 4px;
white-space: normal !important;
max-width: calc(100vw - 12px);
}
/* Full-width, zero-height break → forces everything after it (the
action group + Split + Merge) onto a shared second row. */
.ol-editbar.ol-bar > .ol-editbar-break {
display: block;
flex-basis: 100%;
width: 100%;
height: 0;
margin: 0;
padding: 0;
}
/* Right-align the second row (action group + Split + Merge). The auto
left-margin pushes them to the right end of the line, clearing the
far-left zone where an active tool's option bar (e.g. Select's
Delete/Info) drops down from row 1 — which otherwise overlaps them. */
.ol-editbar.ol-bar > .ol-editbar-actions {
/* !important: ol-ext's `.ol-control.ol-bar .ol-control { margin:0 }`
has equal specificity and loads later, so it would otherwise cancel
this auto-margin and the group would stay glued to the left. */
margin-left: auto !important;
}
/* Pull the second row (action group + Split + Merge — everything after
the line-break) up ~10px. The zero-height break sits on its own flex
line, stacking two row-gaps above the row; this negative top margin
closes that extra space so row 2 aligns nicely under row 1. */
.ol-editbar.ol-bar > .ol-editbar-break ~ .ol-control {
margin-top: -8px !important;
}
}
</style>
<script type="module" crossorigin src="/assets/index-DJ2WL3EC.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-CvK8xBSr.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-BR0zF6aa.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-BxlvFVPW.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><span class="brand-text">LUPMIS2 Drawing Tools</span>
</span>
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
Satellites show "—" on the web (the Geolocation API does not expose
them); a native build can populate the field. -->
<div class="gps-readout" id="gps-readout" title="Live GPS status">
<i class="bi bi-broadcast" aria-hidden="true"></i>
<span class="gps-readout-body">
<span class="gps-coords" id="gps-coords">GPS off</span>
<span class="gps-meta">
<span id="gps-accuracy"></span>
<span class="gps-sep">·</span>
<span id="gps-sats">— sat</span>
</span>
</span>
</div>
<div class="d-flex align-items-center gap-2">
<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>
<!-- Menu button — opens the right-side account menu -->
<button type="button"
class="navbar-menu-btn"
id="menu-btn"
data-bs-toggle="offcanvas"
data-bs-target="#menuOffcanvas"
aria-controls="menuOffcanvas"
title="Open menu">
<i class="bi bi-list"></i>
</button>
</div>
</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 -->
<!-- Right Offcanvas — Account Menu -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="menuOffcanvas" aria-labelledby="menuOffcanvasLabel"
style="max-width:90vw;width:340px;">
<div class="offcanvas-header" style="background:var(--primary);color:#fff;">
<h5 class="offcanvas-title" id="menuOffcanvasLabel">Menu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<!-- User section -->
<div class="mb-3">
<h6 class="text-muted text-uppercase mb-2" style="font-size:0.75rem;letter-spacing:0.06em;font-weight:700;">User</h6>
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
id="menu-user-avatar"
style="width:44px;height:44px;background:var(--brand-navy);color:#fff;font-weight:700;font-size:17px;font-family:var(--font-body);">
<i class="bi bi-person-fill"></i>
</div>
<div style="flex:1;min-width:0;">
<div id="menu-user-name"
style="font-family:var(--font-body);font-weight:700;color:var(--brand-navy);font-size:1rem;line-height:1.2;">
--
</div>
<small id="menu-user-email" class="text-muted d-block" style="line-height:1.3;"></small>
</div>
</div>
<div class="mt-2 small text-muted" id="menu-user-detail">District: --</div>
</div>
<hr>
<!-- Sign-out (only visible when authenticated) -->
<button type="button" id="menu-signout-btn"
class="btn btn-outline-danger w-100 d-none"
style="font-weight:600;">
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
</button>
<!-- Sign-in prompt (only visible when NOT authenticated) -->
<a id="menu-signin-link"
href="https://lupmis4luspa.org/"
target="_blank" rel="noopener"
class="btn btn-outline-primary w-100 d-none"
style="font-weight:600;">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign in at lupmis4luspa.org
</a>
<!-- Dev-mode info (only visible when window.LUPMIS_SESSION is undefined) -->
<div id="menu-no-session-note" class="alert alert-warning small mt-2 d-none mb-0" role="alert" style="font-size:0.8em;">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>No session injected.</strong> Page not served via <code>index.php</code>
— running in dev mode or Apache is bypassing PHP.
</div>
</div>
</div>
<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">
<!-- Account info lives in a click-popover on the map chip
(id="account-chip"), not inside the Settings panel. -->
<!-- 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>
<option value="none">None (no base map)</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>