pwaLUPMIS2/index.html
ekke 1c0d4319b7 Drawing toolbar: lift the phone strip clear of the map controls
Now that the strip paints on iPhone, it turned out to sit at the very bottom
edge — under the right-edge control stack and clipped by the dock.

The strip spans the full width, so it has to clear that stack rather than sit
beside it:

  My Location        bottom: 90px  + 44px tall -> top at 134px
  Base map switcher  bottom: 144px + 44px tall -> top at 188px

so bottom: 196px, 8px above the topmost of them. The arithmetic is written into
the rule, with a pointer to .ls-locate-toggle and the LayerSwitcher button in
layerswitcher.css, since a change to either silently reintroduces the overlap.

Landscape still fits: the map is ~270px tall there and the strip is one row at
that width, so 196 + 52 = 248px.

Verified against stand-ins for both buttons at phone and tablet widths: no
overlap in either the one-row or the wrapped two-row layout.

Service Worker v18 -> v19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:04:08 +02:00

2869 lines
102 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);
/* Floating map panels (drawing rail, its option bars) */
--bar-bg: rgba(255, 255, 255, 0.94);
/* 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; }
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
.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; }
.gps-readout .gps-utm { 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;
}
/* ================================================================
Drawing toolbar — docked left rail
----------------------------------------------------------------
Field officers reported three problems with the palette: it had no
background and vanished over light imagery, it sat centred at the top
across the area being drawn, and its icons did not match each other.
The bar is now a two-column rail docked to the left edge, on a solid
panel, with its controls banded into labelled groups by
MapView._buildToolbarRail(). Two columns rather than one because a
single file of 13 buttons plus labels runs past the bottom of a laptop
map view.
`ol-left` is ol-ext's own class: it docks the bar left AND makes option
bars (Select's Delete/Info, Split's sub-tools) fly out to the right
instead of dropping downwards. We keep that behaviour and override only
the layout.
================================================================ */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail {
/* Clear of the left off-canvas toggle, which sits at left:10px and is
44px wide, vertically centred — exactly where ol-ext docks a left bar.
Officers can drag the rail anywhere from here; see the grip below. */
left: 66px;
display: grid;
grid-template-columns: repeat(2, 30px);
gap: 3px;
align-content: start;
padding: 5px;
background: var(--bar-bg);
border: 1px solid rgba(30, 26, 75, 0.14);
border-radius: 11px;
box-shadow:
0 6px 24px -6px rgba(30, 26, 75, 0.3),
0 1px 3px rgba(30, 26, 75, 0.14);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
/* Deliberately NOT scrollable. The rail measures 75 x 363 px, which fits
any map viewport we support, and setting overflow on either axis makes
the other compute to `auto` as well — which clips the option bars that
fly out past the right edge. */
overflow: visible;
}
/* Direct children only. A descendant selector here would also match the
nested option bars — which are themselves .ol-control — and override the
`display: none` that keeps them closed until their tool is active. */
.ol-editbar.ol-editbar-rail.ol-left > .ol-control {
margin: 0;
}
/* --- drag grip ---------------------------------------------------
The rail can be moved: officers work at different zoom levels and on
different hands, and a fixed panel always covers something. Dragging is
deliberately restricted to this grip so a mis-aimed tap on a tool never
moves the palette instead of selecting.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-rail-grip {
grid-column: 1 / -1;
display: grid;
place-items: center;
height: 18px;
margin: -2px 0 2px;
border-radius: 6px;
color: var(--muted-foreground);
cursor: grab;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.ol-editbar-rail .ol-rail-grip:hover {
background: var(--muted);
color: var(--foreground);
}
.ol-editbar-rail .ol-rail-grip:active,
.ol-editbar-rail.is-dragging .ol-rail-grip {
cursor: grabbing;
}
.ol-editbar-rail .ol-rail-grip::before {
content: "";
width: 22px;
height: 4px;
border-radius: 2px;
background: currentColor;
opacity: 0.45;
}
.ol-editbar-rail.is-dragging {
opacity: 0.9;
user-select: none;
}
/* No dragging on phones — the strip is docked above the dock bar. */
@media (max-width: 576px), (max-height: 560px) {
.ol-editbar-rail .ol-rail-grip {
display: none;
}
}
/* Group heading — spans both columns. */
.ol-editbar-rail .ol-rail-label {
grid-column: 1 / -1;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
text-align: center;
padding: 4px 0 1px;
user-select: none;
-webkit-user-select: none;
}
.ol-editbar-rail .ol-rail-label:first-child {
padding-top: 1px;
}
/* The action group is one control holding four buttons; let it span the
rail and lay its own buttons out on the same 2-column rhythm. */
.ol-editbar-rail > .ol-editbar-actions {
grid-column: 1 / -1;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: grid !important;
grid-template-columns: repeat(2, 30px);
gap: 3px;
}
/* --- buttons ---------------------------------------------------- */
.ol-editbar-rail .ol-control button {
width: 30px;
height: 30px;
margin: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--foreground);
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: background-color 0.13s ease, color 0.13s ease;
}
.ol-editbar-rail .ol-control button:hover {
background: var(--muted);
}
/* The `.ol-left` is here for specificity, not layout: ol-ext paints the
active tool with `.ol-control.ol-bar .ol-toggle.ol-active > button`
(0,4,1) in its own brand cyan. Matching that and adding a class puts the
LUSPA blue back. */
.ol-editbar.ol-editbar-rail.ol-left .ol-control.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button:hover,
.ol-editbar.ol-editbar-rail.ol-left .ol-control button.ol-active,
.ol-editbar.ol-editbar-rail.ol-left .ol-snap-toggle.ol-active button {
background: var(--primary);
color: var(--primary-foreground);
}
.ol-editbar-rail .ol-control button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.ol-editbar-rail .ol-control button i {
pointer-events: none;
}
/* --- suppress ol-ext's CSS-drawn icons --------------------------
ol-ext has no icon font: each built-in tool is drawn with ::before and
::after shapes built from borders and box-shadows. Those are switched
off here so the Bootstrap Icon injected by _buildToolbarRail() is the
only thing in the button. The `> button` shapes and the `> button i`
shapes (Transform, Offset) both have to go.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-control > button::before,
.ol-editbar-rail .ol-control > button::after {
content: none !important;
}
/* The injected glyph is a <span>, so ol-ext's `> button i` rules for
Transform and Offset cannot reach it — nothing to undo here, only the
sizing ol-ext applies to any icon holder. */
.ol-editbar-rail .ol-control > button > span.bi {
position: static;
width: auto;
height: auto;
overflow: visible;
transform: none;
display: block;
}
/* --- option bars (Select ▸ Delete/Info, Split ▸ sub-tools) -------
ol-ext anchors these to the control with `left: 100%`, which assumes a
one-column rail — in two columns that lands the flyout on top of the
neighbouring button. Making the controls static hands the containing
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
top: 50% !important;
bottom: auto !important;
left: 100% !important;
transform: translateY(-50%);
background: var(--bar-bg);
border-radius: 9px;
box-shadow:
0 4px 16px -4px rgba(30, 26, 75, 0.3),
0 0 0 1px rgba(30, 26, 75, 0.14);
padding: 4px;
margin: 0 0 0 8px !important;
white-space: nowrap;
}
/* ol-ext draws a little pointer triangle on the option bar. It reads as a
stray dark button next to the flyout, and it is declared at
`.ol-control.ol-bar.ol-left .ol-option-bar:before` (0,4,1) — so the
selector that hides it has to be at least as specific. */
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::before,
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::after {
display: none;
content: none;
}
.ol-editbar-rail .ol-option-bar .ol-control {
display: inline-block !important;
}
/* --- dark mode -------------------------------------------------- */
.dark-mode {
--bar-bg: rgba(28, 25, 64, 0.94);
}
.dark-mode .ol-editbar.ol-editbar-rail,
.dark-mode .ol-editbar-rail .ol-option-bar {
border-color: rgba(255, 255, 255, 0.14);
}
.dark-mode .ol-editbar-rail .ol-control button {
color: #eceaf7;
}
.dark-mode .ol-editbar-rail .ol-control button:hover {
background: rgba(255, 255, 255, 0.1);
}
/* --- fieldwork mode: larger touch targets ----------------------- */
.fieldwork-mode .ol-editbar.ol-editbar-rail {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions,
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions .ol-bar {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail .ol-control button {
width: 38px;
height: 38px;
font-size: 18px;
}
/* ----------------------------------------------------------------
Phones: the rail becomes a horizontally scrolling strip sitting
above the bottom dock. Scrolling replaces the previous two-row
wrap, so every tool stays reachable at full touch size.
---------------------------------------------------------------- */
/* Narrow OR short. A phone in landscape is 844px wide but leaves only
~270px of map height, where the 395px rail cannot fit — so the strip is
chosen on either axis, not on width alone. */
@media (max-width: 576px), (max-height: 560px) {
/* `.ol-left` is repeated here for specificity: ol-ext's own
`.ol-control.ol-bar.ol-left` sets top/transform, and without matching
it the rail stays vertically centred and stretches to `bottom`.
This layout is deliberately plain. An earlier version scrolled
horizontally and blurred its backdrop, and on iOS Safari it did not
paint at all — a scroll container that also has backdrop-filter is a
known way to lose an element entirely. Wrapping onto two rows needs
neither, and it is what shipped before the rail, on the same phones.
Nothing here should be made cleverer without testing on a device. */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail.ol-left {
/* NOTE: display must NOT be !important — ol-ext toggles the bar with an
inline display:none/'' (setVisible). An !important here would beat
that and keep the toolbar visible even when Draw mode is off. */
display: flex;
grid-template-columns: none;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
row-gap: 3px;
top: auto;
/* Clear of the right-edge control stack, which the strip spans the full
width of and would otherwise sit under:
My Location bottom: 90px + 44px tall -> top at 134px
Base map switcher bottom: 144px + 44px tall -> top at 188px
so 188 + 8px gap. Keep these in step with .ls-locate-toggle and the
LayerSwitcher button in src/styles/layerswitcher.css. */
bottom: 196px;
left: 8px;
/* width rather than `right`, which is the more conventional pairing
with `left` and avoids another layout edge case. */
right: auto;
width: calc(100% - 16px);
transform: none;
overflow: visible;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--card);
}
/* Group headings become hairline dividers: the words cost more width than
two rows can spare, but the grouping is still worth showing. */
.ol-editbar-rail .ol-rail-label {
grid-column: auto;
flex: none;
width: 1px;
height: 24px;
padding: 0;
margin: 0 5px;
font-size: 0;
background: var(--border);
}
.ol-editbar-rail .ol-rail-grip + .ol-rail-label {
display: none;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: flex !important;
grid-template-columns: none;
flex: none;
}
.ol-editbar.ol-editbar-rail.ol-left .ol-control {
flex: none;
}
/* Touch targets stay at the recommended minimum on a phone. */
.ol-editbar-rail .ol-control button {
width: 36px;
height: 36px;
font-size: 17px;
}
/* Option bars open upward here — there is no room to the right, and
downward would land under the dock. */
.ol-editbar.ol-editbar-rail .ol-option-bar {
top: auto !important;
bottom: 100% !important;
left: 8px !important;
transform: none;
margin: 0 0 8px !important;
}
}
/* ----------------------------------------------------------------
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
The body class is set by the inline script injected by embed.php
(`embed embed-mode-permit`). In permit mode the iframe is hosted
inside the permitting app, so all LUPMIS2 chrome is hidden —
only the map and its floating controls remain visible. The map
container expands to fill the viewport.
---------------------------------------------------------------- */
body.embed-mode-permit .navbar,
body.embed-mode-permit .bottom-dock,
body.embed-mode-permit .offcanvas,
body.embed-mode-permit .offcanvas-toggle,
body.embed-mode-permit #install-btn,
body.embed-mode-permit #offline-indicator,
body.embed-mode-permit .map-tools-bar {
display: none !important;
}
body.embed-mode-permit .main-content,
body.embed-mode-permit .map-container {
flex: 1 1 auto;
height: 100%;
}
body.embed-mode-permit #map {
height: 100svh;
height: 100dvh;
}
</style>
</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-utm d-none" id="gps-utm"></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" 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">Digitise</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-analysis"
title="Spatial Analysis — overlays and zonal statistics">
<span>🧮</span>
<span class="dock-btn-label">Analyse</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>
<!-- GIS export modal — opened from the Export GIS button on the Area /
Circle Analysis popup. Lets the user pick a format and rename the
output fields before downloading. Wired up in src/export-gis-modal.js. -->
<div class="modal fade" id="exportGisModal" tabindex="-1"
aria-labelledby="exportGisModalLabel" 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="exportGisModalLabel">
<i class="bi bi-globe me-2"></i>Export intersecting features
</h5>
<button type="button" class="btn-close btn-close-white"
data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Summary -->
<div class="mb-3">
<strong id="export-gis-summary"></strong>
</div>
<!-- Format -->
<div class="mb-3">
<label class="form-label fw-bold d-block">Format</label>
<div class="btn-group" role="group" aria-label="Export format">
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-geojson" value="geojson" checked>
<label class="btn btn-outline-primary" for="export-gis-fmt-geojson">
GeoJSON
</label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-shp" value="shp">
<label class="btn btn-outline-primary" for="export-gis-fmt-shp">
Shapefile
</label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-kml" value="kml">
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
KML
</label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-pdf" value="pdf">
<label class="btn btn-outline-primary" for="export-gis-fmt-pdf">
PDF report
</label>
</div>
<div class="form-text" id="export-gis-format-hint">
GeoJSON keeps all attributes as-is and is the safest default.
</div>
</div>
<!-- Filename -->
<div class="mb-3">
<label for="export-gis-filename" class="form-label fw-bold">
Filename (without extension)
</label>
<input type="text" class="form-control" id="export-gis-filename"
value="area_analysis">
</div>
<!-- Field rename table (hidden for the PDF report, which exports the
analysis summary figures rather than feature attributes) -->
<div id="export-gis-fields-wrap">
<label class="form-label fw-bold mb-1">Field names</label>
<div class="form-text mb-2">
Each source attribute on the left; rename it on the right
(or clear the input to drop the field from the export).
Shapefiles cap field names at 10 characters — the table flags
any over-length names when SHP is selected.
</div>
<div class="table-responsive" style="max-height:340px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
<tr>
<th style="width:42%">Source field</th>
<th>Export as</th>
</tr>
</thead>
<tbody id="export-gis-fields-tbody">
<!-- Populated at runtime -->
</tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="export-gis-go">
<i class="bi bi-download me-1"></i>Export
</button>
</div>
</div>
</div>
</div>
<!-- Analysis panel — Stage 1 client-side spatial analysis (overlays + zonal
statistics). Runs in-browser via Turf.js, so it works offline. Results
are added as a new layer in the "Analysis" group. -->
<div class="modal fade" id="analysisModal" tabindex="-1"
aria-labelledby="analysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="analysisModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Spatial Analysis
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Mode switch -->
<div class="btn-group w-100 mb-3" role="group" aria-label="Analysis type">
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-overlay"
value="overlay" checked>
<label class="btn btn-outline-primary" for="analysis-mode-overlay">
<i class="bi bi-intersect me-1"></i>Overlay
</label>
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-zonal"
value="zonal">
<label class="btn btn-outline-primary" for="analysis-mode-zonal">
<i class="bi bi-table me-1"></i>Zonal statistics
</label>
</div>
<!-- Scope: keeps big layers workable by restricting the inputs -->
<div class="mb-3">
<label for="analysis-scope" class="form-label fw-bold">Apply to</label>
<select class="form-select form-select-sm" id="analysis-scope">
<option value="all" selected>Whole layer</option>
<option value="view">Features in current map view</option>
<option value="selection">Selected features only</option>
</select>
<div class="form-text" id="analysis-scope-hint"></div>
</div>
<!-- ---------- Overlay ---------- -->
<div id="analysis-overlay-box">
<div class="mb-3">
<label for="analysis-op" class="form-label fw-bold">Operation</label>
<select class="form-select form-select-sm" id="analysis-op"></select>
<div class="form-text" id="analysis-op-hint"></div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="analysis-layer-a" class="form-label fw-bold">Layer A</label>
<select class="form-select form-select-sm" id="analysis-layer-a"></select>
</div>
<div class="col-md-6" id="analysis-layer-b-wrap">
<label for="analysis-layer-b" class="form-label fw-bold">Layer B</label>
<select class="form-select form-select-sm" id="analysis-layer-b"></select>
</div>
</div>
</div>
<!-- ---------- Zonal statistics ---------- -->
<div id="analysis-zonal-box" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-zone-layer" class="form-label fw-bold">Zone layer</label>
<select class="form-select form-select-sm" id="analysis-zone-layer"></select>
<div class="form-text">Polygons the results are summarised into.</div>
</div>
<div class="col-md-6">
<label for="analysis-input-layer" class="form-label fw-bold">Features to summarise</label>
<select class="form-select form-select-sm" id="analysis-input-layer"></select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-membership" class="form-label fw-bold">Counted when</label>
<select class="form-select form-select-sm" id="analysis-membership"></select>
<div class="form-text" id="analysis-membership-hint"></div>
</div>
<div class="col-md-6">
<label for="analysis-field" class="form-label fw-bold">Numeric field</label>
<select class="form-select form-select-sm" id="analysis-field"></select>
<div class="form-text">Needed for sum, mean, minimum and maximum.</div>
</div>
</div>
<label class="form-label fw-bold d-block mb-1">Statistics</label>
<div id="analysis-stats-box" class="mb-1"><!-- populated at runtime --></div>
</div>
<hr>
<div class="mb-3">
<label for="analysis-output-name" class="form-label fw-bold">Result layer name</label>
<input type="text" class="form-control form-control-sm" id="analysis-output-name">
</div>
<div id="analysis-result"><!-- summary / table / errors --></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<!-- Shown only after a zonal run — exports the results table plus the
parameters that produced it, as a two-sheet Excel workbook. -->
<button type="button" class="btn btn-outline-primary d-none" id="analysis-export-table">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export table (Excel)
</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>
</div>
</div>
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. -->
<div id="import-spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100"
style="z-index: 1080; background: rgba(255,255,255,0.85);
align-items: center; justify-content: center;"
role="status" aria-live="polite">
<div class="text-center"
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
padding:28px 36px;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<div class="spinner-border text-primary mb-3"
style="width:3rem;height:3rem;" aria-hidden="true"></div>
<div class="fw-bold" style="color:var(--primary,#1e1a4b);font-size:1rem;">
Parsing imported file…
</div>
<div class="text-muted mt-1" style="font-size:0.85rem;">
<span id="import-spinner-filename"></span>
</div>
</div>
</div>
<!-- Import-mapping modal — shown after a file is dropped on the map.
Wired up in src/import-modal.js. Lets the user pick a target type
and map source fields to LUPMIS2 columns; on save the staging row
is updated (status: 'other' | 'mapped'). See
LUPMIS2_Import_Upload_Design.docx §3. -->
<div class="modal fade" id="importMappingModal" tabindex="-1"
aria-labelledby="importMappingModalLabel" 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="importMappingModalLabel">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Imported dataset
</h5>
<button type="button" class="btn-close btn-close-white"
data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Summary -->
<div class="mb-3">
<div class="d-flex flex-wrap gap-2 align-items-baseline">
<strong id="import-modal-filename" class="text-truncate"></strong>
<span class="text-muted" id="import-modal-summary"></span>
</div>
</div>
<!-- Target-type dropdown -->
<div class="mb-3">
<label for="import-modal-target" class="form-label fw-bold">
Target type
</label>
<select class="form-select" id="import-modal-target">
<!-- Populated from TARGET_TYPES at runtime -->
</select>
<div class="form-text" id="import-modal-target-hint">
Choose <em>Other (view only)</em> if this dataset is for display
only and should not be uploaded to the database.
</div>
</div>
<!-- Field-mapping table — only shown when a real target type is chosen -->
<div id="import-modal-fields-wrap">
<label class="form-label fw-bold mb-1">Field mapping</label>
<div class="form-text mb-2">
Each LUPMIS2 column is matched to a source field where possible.
Choose <em>(none)</em> to leave a column unfilled.
</div>
<div class="table-responsive" style="max-height:340px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
<tr>
<th style="width:42%">LUPMIS2 column</th>
<th>Source field</th>
</tr>
</thead>
<tbody id="import-modal-fields-tbody">
<!-- Populated at runtime -->
</tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
id="import-modal-cancel" data-bs-dismiss="modal">
Cancel (keep as view-only)
</button>
<button type="button" class="btn btn-outline-primary"
id="import-modal-save">
<i class="bi bi-check2 me-1"></i>Save mapping
</button>
<button type="button" class="btn btn-primary"
id="import-modal-save-upload">
<i class="bi bi-cloud-arrow-up me-1"></i>Save + Upload now
</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>
<!-- Back to Landing Page (stay signed in) — visible when authenticated.
The landing page is the dispatch hub for launching the other LUSPA
modules. Plain navigation: keeps the SSO session, no logout. -->
<a id="menu-landing-link"
href="https://lupmis4luspa.org/"
class="btn btn-primary w-100 d-none mb-2"
style="font-weight:600;">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Back to Landing Page
</a>
<!-- Logout (only visible when authenticated) — routes through the
server logout endpoint (/?logout=1) which destroys the PWA's PHP
session and hands off to the portal's /user-logout for full SSO
logout. Distinct from "Back to Landing Page" above, which stays
signed in. -->
<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>Logout
</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>
<!-- GPS Coordinate Format -->
<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;">GPS Coordinate Format</h6>
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
<option value="latlon">Lat / Lon</option>
<option value="utm">UTM</option>
<option value="both">Both</option>
</select>
</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 -->
<script type="module" src="/main.js"></script>
</body>
</html>