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>
This commit is contained in:
ekke 2026-05-28 16:08:37 +02:00
parent 9b57ff9e22
commit cfaceb3487
67 changed files with 4921 additions and 1372 deletions

3
.gitignore vendored
View File

@ -102,3 +102,6 @@ coverage/
.eslintcache
.parcel-cache/
.cache/
# Microsoft Office lock / owner files (e.g. ~$Document.docx)
~$*

BIN
Adding_DXF_Support.docx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
dist/.htaccess vendored Normal file
View File

@ -0,0 +1,24 @@
# ============================================================================
# LUPMIS2 PWA — Apache config
# ============================================================================
# Apache's default DirectoryIndex order serves index.html before index.php.
# We need the opposite so the SSO-aware index.php gets a chance to run first,
# inject session data into the page, and then return the index.html content.
DirectoryIndex index.php index.html
# Make sure .php files are executed (defensive — usually enabled site-wide,
# but explicit here in case the deployment dropped this association).
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
# Common single-page-app behaviour: if a route doesn't map to a real file or
# directory, send the request to index.php so the PWA can handle it client-side.
# Comment out the next block if hash-based routing is preferred (no rewrites).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

View File

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

707
dist/assets/index-DJ2WL3EC.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DJ2WL3EC.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/ol-ext-BR0zF6aa.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/ol-ext-BR0zF6aa.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

573
dist/assets/openlayers-CvK8xBSr.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{E as f,a as b}from"./jspdf-Cu-2SCgw.js";import"./openlayers-CUDtI0S3.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function v({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),y=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${y}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const S=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:S,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const p=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,p);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{v as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-Vpiz8VA4.js.map

File diff suppressed because one or more lines are too long

2
dist/assets/pdf-export-vzOHm8wb.js vendored Normal file
View File

@ -0,0 +1,2 @@
import{E as f,a as b}from"./jspdf-Dzj2Osmy.js";import"./openlayers-CvK8xBSr.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function v({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{v as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-vzOHm8wb.js.map

File diff suppressed because one or more lines are too long

338
dist/index.html vendored
View File

@ -8,9 +8,9 @@
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
<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>
@ -549,11 +549,15 @@
}
/* Main container - full height.
100dvh accounts for mobile browser chrome and OS nav bars.
Falls back to 100vh for older browsers. */
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: 100dvh;
height: 100svh;
display: flex;
flex-direction: column;
}
@ -953,6 +957,120 @@
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;
@ -1169,6 +1287,42 @@
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;
@ -1358,15 +1512,73 @@
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-B4XzHtZX.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-CUDtI0S3.js">
<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-CSk2UikI.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-BnwqsTiD.css">
<link rel="stylesheet" crossorigin href="/assets/index-BxlvFVPW.css">
</head>
<body>
<div class="app-container">
@ -1374,16 +1586,45 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span>LUPMIS2 Drawing Tools
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
</span>
<button type="button"
class="location-count-btn"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasRight"
aria-controls="offcanvasRight"
title="View saved locations">
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
</button>
<!-- 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>
@ -1568,6 +1809,61 @@
</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>
@ -1655,6 +1951,9 @@
</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">
@ -1721,6 +2020,7 @@
<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>

116
dist/index.php vendored Normal file
View File

@ -0,0 +1,116 @@
<?php
/**
* LUPMIS2 PWA Authenticated entry point
*
* This file replaces a plain index.html as the directory index in production.
* It:
* 1. Picks up the LUSPA SSO cookie (sso_auth_token) set by the central
* login at https://lupmis4luspa.org/sso/.
* 2. Validates the token server-side against the SSO endpoint.
* 3. Populates a PHP session with the authenticated user's profile
* (user_id, district_id, region_id, full_name, ua_id, ).
* 4. Reads the built index.html that Vite produces and injects the
* session payload as a JavaScript global `window.LUPMIS_SESSION`
* the PWA reads this on startup (see src/remotedb.js) to scope every
* API call to the logged-in user's district.
*
* In local development (Vite serves index.html directly without PHP) the
* global is absent and the PWA falls back to a hard-coded district for
* testing. See remotedb.js getApiCredentials().
*
* Adapted from auth code provided by the LUSPA authentication team
* (FromKwesi / 20260527 / index.php).
*/
session_start();
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $validate_url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode === 200) {
$data = json_decode($response, true);
if (
is_array($data)
&& isset($data['valid']) && $data['valid'] === true
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
) {
// Copy all returned user fields into the session
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
// Token rejected by the SSO server — clear the stale cookie so the
// browser stops sending it. Domain `.lupmis4luspa.org` covers all
// subdomains (so SSO logout works from the PWA too).
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
// ────────────────────────────────────────────────────────────────────────────
$payload = [];
if (isset($_SESSION['user_id'])) {
$payload = [
'user_id' => $_SESSION['user_id'] ?? null,
'ua_id' => $_SESSION['ua_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'title' => $_SESSION['title'] ?? null,
'full_name' => $_SESSION['full_name'] ?? null,
'email' => $_SESSION['email'] ?? null,
'user_type' => $_SESSION['user_type'] ?? null,
'phone' => $_SESSION['phone'] ?? null,
'ua_position' => $_SESSION['ua_position'] ?? null,
'region_id' => $_SESSION['region_id'] ?? null,
'district_id' => $_SESSION['district_id'] ?? null,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session as a JS global
// ────────────────────────────────────────────────────────────────────────────
$indexPath = __DIR__ . '/index.html';
$html = is_readable($indexPath)
? file_get_contents($indexPath)
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing from this deployment.</p></body></html>';
// Encode safely for inline <script> — the JSON flags below escape
// characters that could break the HTML parser (<, >, &, ', ").
$sessionJson = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$inject = "<script>window.LUPMIS_SESSION = {$sessionJson};</script>";
// Insert right after the opening <head> tag
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Serve
// ────────────────────────────────────────────────────────────────────────────
header('Content-Type: text/html; charset=utf-8');
// Don't let intermediaries cache an authenticated response — the next visit
// might be a different user. Asset hashes still let static files be cached.
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
echo $html;

16
dist/manifest.json vendored
View File

@ -10,49 +10,49 @@
"orientation": "any",
"icons": [
{
"src": "./icons/luspa-72x72.png",
"src": "./app-icons/luspa-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-96x96.png",
"src": "./app-icons/luspa-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-128x128.png",
"src": "./app-icons/luspa-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-144x144.png",
"src": "./app-icons/luspa-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-152x152.png",
"src": "./app-icons/luspa-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-192x192.png",
"src": "./app-icons/luspa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./icons/luspa-384x384.png",
"src": "./app-icons/luspa-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-512x512.png",
"src": "./app-icons/luspa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

101
dist/sw.js vendored
View File

@ -16,7 +16,20 @@
// prevent Safari memory-pressure reloads.
// v4: raise OSM and Topographic limits to 8000 to support active offline
// downloads (Phase 2). Other providers stay at 1500.
const CACHE_VERSION = 'v4';
// v5: switch LayerSwitcher icon path to base-URL-aware; force shell refresh.
// v6: rename /icons/ → /app-icons/ to dodge Apache's default mod_alias
// mapping (Alias /icons/ /usr/share/apache2/icons/) which intercepts
// the path server-side. Force shell refresh so deployed clients
// re-fetch the new HTML/manifest with the new path.
// v7: HTML pages now use network-first (was cache-first) so new deploys
// are picked up immediately without needing another SW version bump.
// Hashed JS / CSS / WASM stay cache-first (they're immutable per build).
// v8: GPS trail recording feature (reusable src/geotracker/ engine, expandable
// My Location control, navbar GPS readout, gps_trails SQLocal tables) plus
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
// old module/shell caches.
const CACHE_VERSION = 'v8';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -164,12 +177,18 @@ self.addEventListener('fetch', (event) => {
return;
}
// ----- OTHER ROUTES (unchanged) -----
// ----- OTHER ROUTES -----
if (isApiRequest(url)) {
event.respondWith(networkFirst(request, API_CACHE));
} else if (isModuleAsset(url)) {
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
} else if (isHtmlAsset(url)) {
// HTML uses network-first so a fresh deploy is picked up immediately.
// Falls back to the cached copy when offline (so the app still loads).
event.respondWith(networkFirst(request, SHELL_CACHE));
} else if (isAppAsset(url)) {
// Hashed JS / CSS / WASM / icons are immutable per build — cache-first
// is the right strategy here.
event.respondWith(cacheFirst(request, SHELL_CACHE));
}
// Let other requests pass through to network
@ -220,14 +239,35 @@ function isModuleAsset(url) {
return url.pathname.startsWith('/modules/');
}
/**
* HTML pages (and the bare PWA root) fetched network-first so new deploys
* roll out immediately. We never want a stale shell pointing at hashed
* asset URLs that no longer exist on the server.
*/
function isHtmlAsset(url) {
if (url.origin !== self.location.origin) return false;
if (url.pathname.endsWith('.html')) return true;
// Treat the bare site root and any trailing-slash path as HTML too,
// since they map to index.html / index.php server-side.
if (url.pathname === '/' || url.pathname.endsWith('/')) return true;
return false;
}
/**
* Immutable per-build static assets (hashed JS / CSS / WASM / images)
* safe to cache-first; each new build produces new URLs so there's no
* stale-content risk.
*/
function isAppAsset(url) {
return url.origin === self.location.origin &&
(url.pathname.endsWith('.html') ||
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.wasm') ||
url.pathname.endsWith('.json') ||
url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/));
if (url.origin !== self.location.origin) return false;
if (isHtmlAsset(url)) return false; // HTML handled separately
return (
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.wasm') ||
url.pathname.endsWith('.json') ||
!!url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf)$/)
);
}
// ============================================================================
@ -365,8 +405,22 @@ async function maybeEvict(cache, cacheName, force = false) {
// MESSAGE HANDLING
// ============================================================================
/**
* Reply to a message, preferring the transferred MessageChannel port (the
* window's pwa.js sends a port for request/response correlation), and
* falling back to the originating WindowClient if no port was supplied.
*/
function replyTo(event, message) {
if (event.ports && event.ports[0]) {
try { event.ports[0].postMessage(message); return; } catch {}
}
if (event.source && typeof event.source.postMessage === 'function') {
event.source.postMessage(message);
}
}
self.addEventListener('message', (event) => {
const { type, payload } = event.data || {};
const { type, payload, cacheName } = event.data || {};
switch (type) {
case 'SKIP_WAITING':
@ -382,22 +436,31 @@ self.addEventListener('message', (event) => {
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then((status) => {
event.source.postMessage({ type: 'CACHE_STATUS', status });
});
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
break;
// ----- Tile-cache management (Phase 1 offline maps) -----
case 'GET_TILE_STATS':
getTileStats().then((stats) => {
event.source.postMessage({ type: 'TILE_STATS', stats });
});
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
break;
case 'CLEAR_TILE_CACHES':
clearTileCaches().then(() => {
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
});
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
break;
// Clear a single provider's tile cache (Phase 3 — per-provider Clear).
// Validates the requested name against the known ALL_TILE_CACHES list so
// a misbehaving caller can't drop unrelated caches.
case 'CLEAR_TILE_CACHE':
if (typeof cacheName === 'string' && ALL_TILE_CACHES.includes(cacheName)) {
caches.delete(cacheName).then((deleted) => {
_tileInsertCounters.delete(cacheName);
_cachedStats = null; _cachedStatsAt = 0; // invalidate stats cache
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted });
});
} else {
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted: false, error: 'Unknown or invalid cache name' });
}
break;
}
});

View File

@ -8,9 +8,9 @@
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
<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>
@ -549,11 +549,15 @@
}
/* Main container - full height.
100dvh accounts for mobile browser chrome and OS nav bars.
Falls back to 100vh for older browsers. */
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: 100dvh;
height: 100svh;
display: flex;
flex-direction: column;
}
@ -953,6 +957,120 @@
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;
@ -1169,6 +1287,42 @@
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;
@ -1358,6 +1512,64 @@
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>
</head>
<body>
@ -1366,16 +1578,45 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span>LUPMIS2 Drawing Tools
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
</span>
<button type="button"
class="location-count-btn"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasRight"
aria-controls="offcanvasRight"
title="View saved locations">
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
</button>
<!-- 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>
@ -1560,6 +1801,61 @@
</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>
@ -1647,6 +1943,9 @@
</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">
@ -1713,6 +2012,7 @@
<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>

301
main.js
View File

@ -72,7 +72,7 @@ async function getShp() {
import { MapTools } from './src/components/MapTools.js';
// PWA module (registers Service Worker, handles install/offline)
import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js';
import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js';
import {
BASEMAP_TEMPLATES,
GHANA_EXTENT_3857,
@ -82,7 +82,11 @@ import {
} from './src/offlineTiles.js';
// Remote database API (PostgreSQL backend)
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads } from './src/remotedb.js';
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getSession } from './src/remotedb.js';
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
// Map instance (global for access across functions)
let mapView = null;
@ -118,6 +122,9 @@ async function initApp() {
// Initialize map measurement tools
mapTools = new MapTools(mapView.getMap());
// Wire up GPS live-position + trail recording
initGpsTracking();
// Handle measurement results
mapTools.onMeasureComplete((result) => {
console.log('[MapTools] Measurement complete:', result);
@ -383,6 +390,9 @@ async function initApp() {
// 13. Offline-download dialog
initOfflineDownloadDialog();
// 14. Account card (signed-in user + sign-out)
initAccountCard();
console.log('[App] Initialized successfully');
}
@ -1155,6 +1165,7 @@ async function loadDistrictBoundary() {
strokeColor: '#e11d48',
strokeWidth: 2.5,
fillColor: 'rgba(225,29,72,0.08)',
typeDescription: 'Vector / Polygon',
};
// Target group: Administration (id 1), fall back to root overlay group
@ -1248,6 +1259,7 @@ async function loadCollectorZones() {
strokeColor: '#7c3aed',
strokeWidth: 1.5,
fillColor: 'rgba(124,58,237,0.12)',
typeDescription: 'Vector / Polygon',
};
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
@ -1386,6 +1398,7 @@ async function loadParcels() {
strokeColor: '#0ea5e9',
strokeWidth: 1.5,
fillColor: 'rgba(14,165,233,0.12)',
typeDescription: 'Vector / Polygon',
};
const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null;
@ -1524,6 +1537,7 @@ async function loadBuildingFootprints() {
strokeColor: '#8b6f47',
strokeWidth: 1,
fillColor: 'rgba(139,111,71,0.18)',
typeDescription: 'Vector / Polygon',
};
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
@ -1684,6 +1698,7 @@ async function loadContoursHillshade() {
const contoursStyle = {
strokeColor: '#78716c', // warm grey — traditional contour colour
strokeWidth: 0.8,
typeDescription: 'Vector / Line',
fillColor: 'rgba(0,0,0,0)',
};
@ -1763,6 +1778,7 @@ async function loadOSMRoads() {
lineCasingColor: '#000000', // outer — black casing
lineCasingWidth: 3.5,
fillColor: 'rgba(0,0,0,0)',
typeDescription: 'Vector / Line',
};
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
@ -2028,6 +2044,10 @@ function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
const layer = mapView?.addGeoJSONLayer(fc, layerName, IMPORT_STYLE);
if (layer) {
// Imported file layers are not part of the built-in data model;
// the user can remove them via the LayerSwitcher × button.
layer.set('removable', true);
layer.set('typeTag', 'GEO');
importedFileLayers.push(layer);
totalFeatures += fc.features.length;
}
@ -2474,6 +2494,106 @@ function initMessageLog() {
}
}
// ============================================================================
// GPS live-position + trail recording
//
// Wiring only — all GPS logic lives in the reusable src/geotracker/ engine and
// the LUPMIS adapter in src/geotracker-lupmis.js. Here we connect the engine's
// events to the navbar readout and the map's render/control hooks.
// ============================================================================
function initGpsTracking() {
const readout = document.getElementById('gps-readout');
const coordsEl = document.getElementById('gps-coords');
const accEl = document.getElementById('gps-accuracy');
const satsEl = document.getElementById('gps-sats');
if (!geoTracker.isSupported) {
if (coordsEl) coordsEl.textContent = 'No GPS';
return;
}
// Live navbar readout — fires for every fix (one-shot Locate or watch).
geoTracker.on('position', (fix) => {
if (coordsEl) coordsEl.textContent = `${formatCoord(fix.lat)}, ${formatCoord(fix.lon)}`;
if (accEl) accEl.textContent = formatAccuracy(fix.accuracy);
if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
if (readout) {
readout.classList.add('active');
readout.classList.remove('quality-good', 'quality-fair', 'quality-poor');
readout.classList.add('quality-' + accuracyQuality(fix.accuracy));
}
mapView?.showCurrentPosition(fix.lon, fix.lat, fix.accuracy);
});
// Each recorded waypoint extends the on-map trail line.
geoTracker.on('point', (evt) => {
mapView?.appendTrailPoint(evt.point.lon, evt.point.lat);
});
geoTracker.on('error', (err) => {
console.warn('[GPS]', err?.message || err);
if (err && err.code === 1) { // PERMISSION_DENIED
showError('Location permission denied. Enable location access to use GPS.');
}
});
// "Locate me" → one-shot position + recenter.
mapView.onLocateMe(async () => {
try {
const fix = await geoTracker.getCurrentPosition();
mapView.centerOn(fix.lon, fix.lat, 16);
} catch (err) {
showError('Could not get your location: ' + (err?.message || err));
}
});
// "Record trail" → start/stop. Recording persists locally and syncs on stop.
mapView.onToggleRecording(async (start) => {
if (start) {
try {
await dbReady;
mapView.startTrailRender();
mapView.setRecordingState(true);
readout?.classList.add('recording');
await geoTracker.startRecording({ name: `Trail ${new Date().toLocaleString()}` });
showSuccess('GPS trail recording started');
} catch (err) {
mapView.setRecordingState(false);
readout?.classList.remove('recording');
showError('Could not start recording: ' + (err?.message || err));
}
} else {
try {
const res = await geoTracker.stopRecording();
mapView.setRecordingState(false);
readout?.classList.remove('recording');
if (res) {
const msg = `Trail saved: ${res.pointCount} points, ${formatDistance(res.distanceM)}` +
(res.synced ? ' — synced' : ' — will sync when online');
showSuccess(msg);
}
} catch (err) {
showError('Error stopping recording: ' + (err?.message || err));
}
}
});
// Retry uploading trails recorded while offline — on load and when back online.
const trySync = async () => {
if (!isOnline()) return;
try {
await dbReady;
const r = await geoTracker.syncPending();
if (r.pushed) console.log(`[GPS] Synced ${r.pushed} pending trail(s)`);
} catch (e) {
console.warn('[GPS] pending-sync error', e);
}
};
trySync();
onOfflineChange((offline) => { if (!offline) trySync(); });
}
// ============================================================================
// Toast-style alerts (auto-dismiss) + persistent log
// ============================================================================
@ -2608,6 +2728,16 @@ function initDefaultBasemap() {
mapView?.setBaseMap(key);
console.log('[Settings] Default base map:', key);
});
// Keep the dropdown in sync when the user switches via the floating
// base-map picker (or any other UI) — MapView fires `basemapchange`
// from setBaseMap().
mapView?.getMap()?.on('basemapchange', (evt) => {
if (evt?.key && select.value !== evt.key) {
select.value = evt.key;
try { localStorage.setItem('default-basemap', evt.key); } catch {}
}
});
}
/**
@ -2663,9 +2793,16 @@ function initOfflineTileCache() {
.filter((p) => p.count > 0)
.map((p) => `
<tr>
<td>${p.label}</td>
<td>${escapeHtml(p.label)}</td>
<td class="text-end">${p.count.toLocaleString()} / ${p.limit.toLocaleString()}</td>
<td class="text-end">${fmtBytes(p.estBytes)}</td>
<td class="text-end pe-0" style="width:2.2rem;">
<button type="button" class="btn btn-sm btn-link text-danger p-0 provider-clear-btn"
data-cache="${escapeHtml(p.key)}" data-label="${escapeHtml(p.label)}"
title="Clear ${escapeHtml(p.label)} tiles only">
<i class="bi bi-trash3"></i>
</button>
</td>
</tr>`).join('');
let storageNote = '';
@ -2696,10 +2833,31 @@ function initOfflineTileCache() {
<th>Base map</th>
<th class="text-end">Cached / limit</th>
<th class="text-end">Approx. size</th>
<th class="text-end pe-0" style="width:2.2rem;"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>${storageNote}`;
clearBtn.disabled = false;
// Per-provider Clear — confirm, clear that bucket only, refresh
statsEl.querySelectorAll('.provider-clear-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const cacheName = btn.dataset.cache;
const label = btn.dataset.label || cacheName;
if (!confirm(`Clear cached "${label}" tiles?\n\nOther providers are not affected. The tiles will re-download as you browse online.`)) {
return;
}
btn.disabled = true;
const ok = await clearTileCacheForProvider(cacheName);
if (ok) {
console.log(`[Settings] Cleared tile cache for ${label}`);
} else {
console.warn(`[Settings] Could not clear tile cache for ${label}`);
}
await refresh();
});
});
} finally {
refreshInFlight = null;
}
@ -3006,6 +3164,143 @@ function initOfflineDownloadDialog() {
});
}
/**
* Account card displays the signed-in user from window.LUPMIS_SESSION
* (injected by public/index.php) and wires the "Sign out" button.
*
* In local dev (no PHP), window.LUPMIS_SESSION is absent / empty and the
* card shows "Guest (no session)" without a Sign-out button.
*/
/**
* Account UI populates the right-side Menu offcanvas (id="menuOffcanvas")
* with the signed-in user's details, and wires the Sign-out button.
* The Menu is opened from the navbar Menu button (id="menu-btn").
*
* Three states:
* authenticated show name, email, district info, and "Sign out"
* unauthenticated (PHP ran, no SSO cookie) show "Sign in" link
* no-session (window.LUPMIS_SESSION undefined dev mode) show
* a warning note that the page wasn't served via index.php
*/
function initAccountCard() {
const session = getSession();
const menuBtn = document.getElementById('menu-btn');
const avatarEl = document.getElementById('menu-user-avatar');
const nameEl = document.getElementById('menu-user-name');
const emailEl = document.getElementById('menu-user-email');
const detailEl = document.getElementById('menu-user-detail');
const signoutBtn = document.getElementById('menu-signout-btn');
const signinLink = document.getElementById('menu-signin-link');
const noSessNote = document.getElementById('menu-no-session-note');
if (!menuBtn || !avatarEl || !nameEl || !emailEl || !detailEl || !signoutBtn) {
console.warn('[AccountMenu] One or more elements missing — shell may be stale. Hard-refresh.');
return;
}
const isAuthenticated = !!session && !!session.user_id;
if (isAuthenticated) {
// ---------- Authenticated state ----------
const displayName = [session.title, session.full_name].filter(Boolean).join(' ').trim()
|| session.username || 'Authenticated user';
const initial = (session.full_name || session.username || '?').trim().charAt(0).toUpperCase();
avatarEl.textContent = initial;
avatarEl.style.background = 'var(--brand-navy, #1e1a4b)';
nameEl.textContent = displayName;
emailEl.textContent = session.email || '';
const bits = [];
if (session.district_id != null) bits.push(`District ${escapeHtml(String(session.district_id))}`);
if (session.region_id != null) bits.push(`Region ${escapeHtml(String(session.region_id))}`);
if (session.ua_position) bits.push(escapeHtml(session.ua_position));
detailEl.innerHTML = bits.join(' · ') || 'No district info';
signoutBtn.classList.remove('d-none');
signoutBtn.addEventListener('click', () => handleSignOut(session), { once: false });
signinLink?.classList.add('d-none');
noSessNote?.classList.add('d-none');
menuBtn.removeAttribute('data-state');
menuBtn.setAttribute('title', `Menu — ${displayName}`);
} else if (typeof window.LUPMIS_SESSION === 'undefined') {
// ---------- Dev mode (no PHP processing) ----------
avatarEl.innerHTML = '<i class="bi bi-exclamation"></i>';
avatarEl.style.background = 'var(--brand-orange-warm, #ff9e1b)';
nameEl.textContent = 'No session injected';
emailEl.textContent = '';
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
signinLink?.classList.add('d-none');
noSessNote?.classList.remove('d-none');
menuBtn.dataset.state = 'no-session';
menuBtn.setAttribute('title', 'Menu (no session — dev mode)');
} else {
// ---------- PHP ran but the user has no valid SSO session ----------
avatarEl.innerHTML = '<i class="bi bi-person-fill"></i>';
avatarEl.style.background = 'var(--brand-gray-medium, #7a7a7a)';
nameEl.textContent = 'Not signed in';
emailEl.textContent = '';
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
signinLink?.classList.remove('d-none');
noSessNote?.classList.add('d-none');
menuBtn.dataset.state = 'unauthenticated';
menuBtn.setAttribute('title', 'Menu (not signed in)');
}
}
// Legacy chip+popover removed — replaced by the navbar Menu button +
// right-side menuOffcanvas. See initAccountCard above.
/**
* Sign-out flow:
* 1. Confirm with the user.
* 2. Best-effort fire-and-forget call to the SSO logout endpoint so the
* server-side token is invalidated (no-cors mode tolerates CORS issues).
* 3. Expire the local sso_auth_token cookie on the parent domain so the
* browser stops sending it.
* 4. Redirect to the SSO login page leaves the user on familiar ground
* (and on next visit, index.php sees no session and serves a fresh
* page with no LUPMIS_SESSION).
*/
async function handleSignOut(session) {
if (!confirm(`Return to Landing Page, ${session?.full_name || session?.username || 'user'}?`)) {
return;
}
// 1. Best-effort: invalidate the SSO token server-side
const cookieToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('sso_auth_token='))
?.split('=')[1];
if (cookieToken) {
try {
// no-cors swallows CORS errors; we don't read the response
await fetch('https://lupmis4luspa.org/sso/logout?token=' + encodeURIComponent(cookieToken), {
method: 'GET',
mode: 'no-cors',
credentials: 'include',
cache: 'no-store',
});
} catch (err) {
console.warn('[Signout] Best-effort SSO logout call failed:', err);
}
}
// 2. Clear the cookie on the shared parent domain
// Set with both leading-dot and no-dot variants; browsers vary on which sticks.
const past = 'Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=.lupmis4luspa.org`;
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=lupmis4luspa.org`;
document.cookie = `sso_auth_token=; expires=${past}; path=/`;
// 3. Redirect to the central LUSPA login
window.location.href = 'https://lupmis4luspa.org/';
}
// ============================================================================
// Start Application
// ============================================================================

24
public/.htaccess Normal file
View File

@ -0,0 +1,24 @@
# ============================================================================
# LUPMIS2 PWA — Apache config
# ============================================================================
# Apache's default DirectoryIndex order serves index.html before index.php.
# We need the opposite so the SSO-aware index.php gets a chance to run first,
# inject session data into the page, and then return the index.html content.
DirectoryIndex index.php index.html
# Make sure .php files are executed (defensive — usually enabled site-wide,
# but explicit here in case the deployment dropped this association).
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
# Common single-page-app behaviour: if a route doesn't map to a real file or
# directory, send the request to index.php so the PWA can handle it client-side.
# Comment out the next block if hash-based routing is preferred (no rewrites).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

View File

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

116
public/index.php Normal file
View File

@ -0,0 +1,116 @@
<?php
/**
* LUPMIS2 PWA Authenticated entry point
*
* This file replaces a plain index.html as the directory index in production.
* It:
* 1. Picks up the LUSPA SSO cookie (sso_auth_token) set by the central
* login at https://lupmis4luspa.org/sso/.
* 2. Validates the token server-side against the SSO endpoint.
* 3. Populates a PHP session with the authenticated user's profile
* (user_id, district_id, region_id, full_name, ua_id, ).
* 4. Reads the built index.html that Vite produces and injects the
* session payload as a JavaScript global `window.LUPMIS_SESSION`
* the PWA reads this on startup (see src/remotedb.js) to scope every
* API call to the logged-in user's district.
*
* In local development (Vite serves index.html directly without PHP) the
* global is absent and the PWA falls back to a hard-coded district for
* testing. See remotedb.js getApiCredentials().
*
* Adapted from auth code provided by the LUSPA authentication team
* (FromKwesi / 20260527 / index.php).
*/
session_start();
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $validate_url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode === 200) {
$data = json_decode($response, true);
if (
is_array($data)
&& isset($data['valid']) && $data['valid'] === true
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
) {
// Copy all returned user fields into the session
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
// Token rejected by the SSO server — clear the stale cookie so the
// browser stops sending it. Domain `.lupmis4luspa.org` covers all
// subdomains (so SSO logout works from the PWA too).
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
// ────────────────────────────────────────────────────────────────────────────
$payload = [];
if (isset($_SESSION['user_id'])) {
$payload = [
'user_id' => $_SESSION['user_id'] ?? null,
'ua_id' => $_SESSION['ua_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'title' => $_SESSION['title'] ?? null,
'full_name' => $_SESSION['full_name'] ?? null,
'email' => $_SESSION['email'] ?? null,
'user_type' => $_SESSION['user_type'] ?? null,
'phone' => $_SESSION['phone'] ?? null,
'ua_position' => $_SESSION['ua_position'] ?? null,
'region_id' => $_SESSION['region_id'] ?? null,
'district_id' => $_SESSION['district_id'] ?? null,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session as a JS global
// ────────────────────────────────────────────────────────────────────────────
$indexPath = __DIR__ . '/index.html';
$html = is_readable($indexPath)
? file_get_contents($indexPath)
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing from this deployment.</p></body></html>';
// Encode safely for inline <script> — the JSON flags below escape
// characters that could break the HTML parser (<, >, &, ', ").
$sessionJson = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$inject = "<script>window.LUPMIS_SESSION = {$sessionJson};</script>";
// Insert right after the opening <head> tag
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Serve
// ────────────────────────────────────────────────────────────────────────────
header('Content-Type: text/html; charset=utf-8');
// Don't let intermediaries cache an authenticated response — the next visit
// might be a different user. Asset hashes still let static files be cached.
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
echo $html;

View File

@ -10,49 +10,49 @@
"orientation": "any",
"icons": [
{
"src": "./icons/luspa-72x72.png",
"src": "./app-icons/luspa-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-96x96.png",
"src": "./app-icons/luspa-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-128x128.png",
"src": "./app-icons/luspa-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-144x144.png",
"src": "./app-icons/luspa-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-152x152.png",
"src": "./app-icons/luspa-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-192x192.png",
"src": "./app-icons/luspa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./icons/luspa-384x384.png",
"src": "./app-icons/luspa-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/luspa-512x512.png",
"src": "./app-icons/luspa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

View File

@ -16,7 +16,20 @@
// prevent Safari memory-pressure reloads.
// v4: raise OSM and Topographic limits to 8000 to support active offline
// downloads (Phase 2). Other providers stay at 1500.
const CACHE_VERSION = 'v4';
// v5: switch LayerSwitcher icon path to base-URL-aware; force shell refresh.
// v6: rename /icons/ → /app-icons/ to dodge Apache's default mod_alias
// mapping (Alias /icons/ /usr/share/apache2/icons/) which intercepts
// the path server-side. Force shell refresh so deployed clients
// re-fetch the new HTML/manifest with the new path.
// v7: HTML pages now use network-first (was cache-first) so new deploys
// are picked up immediately without needing another SW version bump.
// Hashed JS / CSS / WASM stay cache-first (they're immutable per build).
// v8: GPS trail recording feature (reusable src/geotracker/ engine, expandable
// My Location control, navbar GPS readout, gps_trails SQLocal tables) plus
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
// old module/shell caches.
const CACHE_VERSION = 'v8';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -164,12 +177,18 @@ self.addEventListener('fetch', (event) => {
return;
}
// ----- OTHER ROUTES (unchanged) -----
// ----- OTHER ROUTES -----
if (isApiRequest(url)) {
event.respondWith(networkFirst(request, API_CACHE));
} else if (isModuleAsset(url)) {
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
} else if (isHtmlAsset(url)) {
// HTML uses network-first so a fresh deploy is picked up immediately.
// Falls back to the cached copy when offline (so the app still loads).
event.respondWith(networkFirst(request, SHELL_CACHE));
} else if (isAppAsset(url)) {
// Hashed JS / CSS / WASM / icons are immutable per build — cache-first
// is the right strategy here.
event.respondWith(cacheFirst(request, SHELL_CACHE));
}
// Let other requests pass through to network
@ -220,14 +239,35 @@ function isModuleAsset(url) {
return url.pathname.startsWith('/modules/');
}
/**
* HTML pages (and the bare PWA root) fetched network-first so new deploys
* roll out immediately. We never want a stale shell pointing at hashed
* asset URLs that no longer exist on the server.
*/
function isHtmlAsset(url) {
if (url.origin !== self.location.origin) return false;
if (url.pathname.endsWith('.html')) return true;
// Treat the bare site root and any trailing-slash path as HTML too,
// since they map to index.html / index.php server-side.
if (url.pathname === '/' || url.pathname.endsWith('/')) return true;
return false;
}
/**
* Immutable per-build static assets (hashed JS / CSS / WASM / images)
* safe to cache-first; each new build produces new URLs so there's no
* stale-content risk.
*/
function isAppAsset(url) {
return url.origin === self.location.origin &&
(url.pathname.endsWith('.html') ||
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.wasm') ||
url.pathname.endsWith('.json') ||
url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/));
if (url.origin !== self.location.origin) return false;
if (isHtmlAsset(url)) return false; // HTML handled separately
return (
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.wasm') ||
url.pathname.endsWith('.json') ||
!!url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf)$/)
);
}
// ============================================================================
@ -365,8 +405,22 @@ async function maybeEvict(cache, cacheName, force = false) {
// MESSAGE HANDLING
// ============================================================================
/**
* Reply to a message, preferring the transferred MessageChannel port (the
* window's pwa.js sends a port for request/response correlation), and
* falling back to the originating WindowClient if no port was supplied.
*/
function replyTo(event, message) {
if (event.ports && event.ports[0]) {
try { event.ports[0].postMessage(message); return; } catch {}
}
if (event.source && typeof event.source.postMessage === 'function') {
event.source.postMessage(message);
}
}
self.addEventListener('message', (event) => {
const { type, payload } = event.data || {};
const { type, payload, cacheName } = event.data || {};
switch (type) {
case 'SKIP_WAITING':
@ -382,22 +436,31 @@ self.addEventListener('message', (event) => {
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then((status) => {
event.source.postMessage({ type: 'CACHE_STATUS', status });
});
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
break;
// ----- Tile-cache management (Phase 1 offline maps) -----
case 'GET_TILE_STATS':
getTileStats().then((stats) => {
event.source.postMessage({ type: 'TILE_STATS', stats });
});
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
break;
case 'CLEAR_TILE_CACHES':
clearTileCaches().then(() => {
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
});
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
break;
// Clear a single provider's tile cache (Phase 3 — per-provider Clear).
// Validates the requested name against the known ALL_TILE_CACHES list so
// a misbehaving caller can't drop unrelated caches.
case 'CLEAR_TILE_CACHE':
if (typeof cacheName === 'string' && ALL_TILE_CACHES.includes(cacheName)) {
caches.delete(cacheName).then((deleted) => {
_tileInsertCounters.delete(cacheName);
_cachedStats = null; _cachedStatsAt = 0; // invalidate stats cache
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted });
});
} else {
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted: false, error: 'Unknown or invalid cache name' });
}
break;
}
});

View File

@ -30,7 +30,7 @@ import TileWMS from 'ol/source/TileWMS';
import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Point, Polygon as PolygonGeom } from 'ol/geom';
import { Point, LineString, Polygon as PolygonGeom } from 'ol/geom';
import Feature from 'ol/Feature';
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
import GeoJSON from 'ol/format/GeoJSON';
@ -42,9 +42,6 @@ import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../u
// ol-ext LayerSwitcher
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
// ol-ext GeolocationButton
import GeolocationButton from 'ol-ext/control/GeolocationButton';
// ol-ext SearchNominatim
import SearchNominatim from 'ol-ext/control/SearchNominatim';
@ -89,6 +86,7 @@ import { showToast } from '../toast.js';
// CSS imports
import 'ol/ol.css';
import 'ol-ext/dist/ol-ext.css';
import '../styles/layerswitcher.css';
export class MapView {
constructor(targetId, options = {}) {
@ -150,11 +148,13 @@ export class MapView {
// Create base layers group
const baseLayers = this.createBaseLayers(options.basemap || 'topo');
// Markers layer
// Markers layer — hidden at startup; the user enables it from the
// LayerSwitcher when they want to see location markers / category pins.
this.markersLayer = new VectorLayer({
title: 'Markers',
source: this.markerSource,
style: (feature) => this.getFeatureStyle(feature),
visible: false,
});
// Overlay layers group (for remote data like boundaries)
@ -193,38 +193,48 @@ export class MapView {
});
this.map.addControl(layerSwitcher);
// Inject "Add Layer" button into the "External Source" group header
layerSwitcher.on('drawlist', (evt) => {
const groupTitle = (evt.layer.get('title') || '').toLowerCase();
if (groupTitle.includes('external')) {
// Store reference to the actual External group for later use
this._externalSourceGroup = evt.layer;
const btnBar = evt.li.querySelector('.ol-layerswitcher-buttons');
if (btnBar && !btnBar.querySelector('.ol-add-layer')) {
const addBtn = document.createElement('span');
addBtn.className = 'ol-add-layer';
addBtn.title = 'Add external layer';
addBtn.textContent = '+';
addBtn.style.cssText = `
display:inline-flex !important;align-items:center;justify-content:center;
width:20px !important;height:20px !important;border-radius:50%;
background:#10b981 !important;color:#fff !important;
font-size:16px !important;font-weight:700;
cursor:pointer;line-height:1 !important;
margin:2px 4px 2px 2px;vertical-align:middle;
transition:background 0.2s;box-sizing:border-box;
`;
addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#059669'; });
addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#10b981'; });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showAddLayerDialog();
});
btnBar.prepend(addBtn);
}
// Apply the LUSPA branded icon to the LayerSwitcher's collapse button.
// Done in JS so the URL respects Vite's BASE_URL — survives deployment
// under any sub-path.
// NOTE: folder name is `app-icons`, NOT `icons` — Apache aliases `/icons/`
// by default to its own directory-listing thumbnails, which would
// intercept this request server-side.
queueMicrotask(() => {
const btn = layerSwitcher.element?.querySelector(':scope > button');
if (btn) {
const baseUrl = (import.meta.env?.BASE_URL || '/').replace(/\/?$/, '/');
btn.style.backgroundImage = `url('${baseUrl}app-icons/luspa-72x72.png')`;
}
});
// ------------------------------------------------------------------
// Decorate each layer's <li> as it's rendered:
// • inject a type-tag chip (WMS / XYZ / VEC / …) next to the label
// • add a green "+" button to the "External Source" group header
// After each draw cycle, refresh the panel chrome (active count badge
// + footer reset button). Schedule once per cycle via a microtask.
// ------------------------------------------------------------------
let _lsChromeScheduled = false;
layerSwitcher.on('drawlist', (evt) => {
this._decorateLayerListItem(evt.layer, evt.li);
if (!_lsChromeScheduled) {
_lsChromeScheduled = true;
queueMicrotask(() => {
_lsChromeScheduled = false;
this._refreshLayerSwitcherChrome(layerSwitcher);
});
}
});
// Re-render the chrome whenever any layer's visibility changes (so the
// active-count badge updates even when the user toggles via the panel).
this.map.getLayers().on('change', () => {
this._refreshLayerSwitcherChrome(layerSwitcher);
});
// Hook visibility events on every layer (recursive into groups).
this._wireLayerSwitcherVisibilityHooks(layerSwitcher);
// Create the add-layer dialog (hidden by default)
this._createAddLayerDialog();
@ -240,16 +250,13 @@ export class MapView {
});
this.map.addControl(this.scaleBar);
// Add GeolocationButton control
const geolocationButton = new GeolocationButton({
title: 'My Location',
delay: 3000, // Auto-center duration
zoom: 16, // Zoom level when centering on location
});
this.map.addControl(geolocationButton);
// GPS rendering layers (current position + recorded trail) and the
// expandable "My Location" control (Locate Me + Record Trail sub-buttons).
this._initGpsRendering();
this._createLocationControl();
// Store reference for external access
this.geolocationButton = geolocationButton;
// Dedicated base-map picker — sits above the My Location button
this._createBaseMapPicker();
// Add SearchNominatim control
const searchNominatim = new SearchNominatim({
@ -420,6 +427,9 @@ export class MapView {
// inside the EditBar so they appear inline.
const extraBar = new Bar({
group: true,
// Stable class so CSS can move this group (undo/redo/save/snap) to a
// second row on small screens — see `.ol-editbar-actions` media query.
className: 'ol-editbar-actions',
controls: [
new Button({
html: '<i class="bi bi-arrow-counterclockwise"></i>',
@ -567,6 +577,19 @@ export class MapView {
this.showMergeIdentifierPopup(evt.merged, evt.propsA, evt.propsB, evt.coordinate);
});
// Small-screen layout: insert a zero-height, full-width flex line-break
// immediately BEFORE the action group. On phones (see the .ol-editbar
// media query) this forces the wrap to happen here, so the action group
// (undo/redo/save/snap) together with the Split and Merge toggles all land
// on a single second row instead of Split/Merge spilling onto a third row.
// The break is display:none on wider screens, so desktop layout is unchanged.
const editbarEl = this.editBar.element;
if (editbarEl && extraBar.element && extraBar.element.parentNode === editbarEl) {
const breakEl = document.createElement('div');
breakEl.className = 'ol-editbar-break';
editbarEl.insertBefore(breakEl, extraBar.element);
}
// 6b. SnapGuides — shows alignment guides while drawing.
// Uses VectorImageLayer for GPU-friendly canvas rendering instead of
// re-creating individual SVG elements on every guide update.
@ -2378,10 +2401,10 @@ export class MapView {
];
// Return LayerGroup for LayerSwitcher
// Note: ol-ext LayerSwitcher iterates layers in reverse — the LAST item
// in this array appears at the TOP of the base-map list in the UI.
return new LayerGroup({
// Return LayerGroup. Hidden from the main LayerSwitcher — base maps are
// managed by the dedicated base-map picker (see _createBaseMapPicker)
// accessed via the layers-stack icon above the My Location button.
const baseGroup = new LayerGroup({
title: 'Base Maps',
layers: [
cartoLightLayer,
@ -2390,30 +2413,355 @@ export class MapView {
osmCycleLayer,
googleLayer,
osmLayer,
topoLayer, // ← displayed at the top of the base map stack
topoLayer,
],
});
baseGroup.set('displayInLayerSwitcher', false);
return baseGroup;
}
/**
* Switch the active base map by key.
* Sets exactly one base layer visible; hides all others.
*
* @param {string} key Basemap key: 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
* @returns {boolean} true if the key matched a known base layer
* @param {string} key Basemap key: 'none' | 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
* @returns {boolean} true if the key matched a known base layer (or 'none')
*/
setBaseMap(key) {
if (!this._baseMapLayers) return false;
// 'none' switches the base map off entirely — hide every base layer so the
// map renders on a blank background (useful over imagery overlays / when a
// full-coverage overlay should stand alone).
if (key === 'none') {
for (const layer of this._baseMapLayers) layer.setVisible(false);
console.log('[MapView] Base map switched off (none)');
this.map.dispatchEvent({ type: 'basemapchange', key: 'none' });
return true;
}
let matched = false;
for (const layer of this._baseMapLayers) {
const on = layer.get('basemapKey') === key;
layer.setVisible(on);
if (on) matched = true;
}
if (matched) console.log('[MapView] Base map switched to:', key);
if (matched) {
console.log('[MapView] Base map switched to:', key);
// Notify external UIs (Settings dropdown, base-map picker, …) so they
// can keep their visible state in sync.
this.map.dispatchEvent({ type: 'basemapchange', key });
}
return matched;
}
/**
* Build the floating "Base Map" picker a small icon button stacked
* directly above the My Location control, plus a slide-out card with
* thumbnail chips for every selectable base map.
*
* Hidden in tandem with the main LayerSwitcher: clicking outside the
* picker (or making a selection) closes it.
*
* Two-way sync with the existing Settings dropdown is via the
* `basemapchange` event fired from setBaseMap().
*/
_createBaseMapPicker() {
// Configuration — must match the basemapKey set in createBaseLayers.
// The colour gradients hint at each base map's character so the chip is
// recognisable without rendering an actual tile preview.
const OPTIONS = [
{ key: 'topo', label: 'Topographic', grad: 'linear-gradient(135deg,#e8d5b7,#a67c52)' },
{ key: 'osm', label: 'OpenStreetMap',grad: 'linear-gradient(135deg,#d4e6f1,#85c1e9)' },
{ key: 'satellite', label: 'Satellite', grad: 'linear-gradient(135deg,#1b4332,#40916c)' },
{ key: 'googlesat', label: 'Google Sat', grad: 'linear-gradient(135deg,#2a5d3d,#4a8c5a)' },
{ key: 'carto-light', label: 'Carto Light', grad: 'linear-gradient(135deg,#f5f5f5,#d4d4d4)' },
{ key: 'carto-dark', label: 'Carto Dark', grad: 'linear-gradient(135deg,#1a1a2e,#0f3460)' },
// "None" turns the base map off — checkerboard hints at a blank/transparent background.
{ key: 'none', label: 'None', grad: 'repeating-conic-gradient(#e5e7eb 0 25%, #fff 0 50%) 50% / 12px 12px' },
];
const target = this.map.getTargetElement();
if (!target) return;
// ---------- Toggle button ----------
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ls-basemap-toggle';
btn.title = 'Switch base map';
btn.setAttribute('aria-label', 'Switch base map');
btn.innerHTML =
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">' +
'<path d="M9 2L16 5.8L9 9.6L2 5.8L9 2Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
'<path d="M2 9.2L9 13L16 9.2" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
'<path d="M2 12.4L9 16.2L16 12.4" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-opacity=".4"/>' +
'</svg>';
target.appendChild(btn);
// ---------- Picker panel ----------
const panel = document.createElement('div');
panel.className = 'ls-basemap-panel';
panel.innerHTML =
'<div class="ls-basemap-header">Base Map</div>' +
'<div class="ls-basemap-grid">' +
OPTIONS.map((opt) => `
<label class="ls-bm-chip">
<input type="radio" name="lupmis-basemap" value="${opt.key}">
<div class="ls-bm-label">
<div class="ls-bm-thumb" style="background:${opt.grad};"></div>
<div class="ls-bm-name">${opt.label}</div>
</div>
</label>
`).join('') +
'</div>';
target.appendChild(panel);
this._basemapPanel = panel;
this._basemapToggle = btn;
/** Mark the radio matching the currently-visible base layer. */
const syncSelection = (key) => {
const k = key || this._baseMapLayers?.find((l) => l.getVisible())?.get('basemapKey');
panel.querySelectorAll('input[name="lupmis-basemap"]').forEach((r) => {
r.checked = (r.value === k);
});
};
syncSelection();
// ---------- Events ----------
// Toggle button → open / close the panel
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) syncSelection();
});
// Click outside → close
document.addEventListener('click', (e) => {
if (!panel.classList.contains('open')) return;
if (panel.contains(e.target) || btn.contains(e.target)) return;
panel.classList.remove('open');
btn.classList.remove('active');
});
// Selection → apply, persist, close
panel.addEventListener('change', (e) => {
const radio = e.target.closest('input[type=radio][name="lupmis-basemap"]');
if (!radio) return;
const key = radio.value;
this.setBaseMap(key);
try { localStorage.setItem('default-basemap', key); } catch {}
panel.classList.remove('open');
btn.classList.remove('active');
});
// Keep the radio state synced when other UIs (Settings dropdown) change it
this.map.on('basemapchange', (evt) => syncSelection(evt.key));
}
// ============================================================================
// GPS: current-position + trail rendering, and the expandable Location control
//
// NOTE: MapView deliberately knows nothing about the GeoTracker engine,
// SQLocal, or sync. It only (a) renders what it's told and (b) emits UI
// intents via callbacks. main.js wires those intents to the GeoTracker so the
// map stays reusable/decoupled.
// ============================================================================
/** Create the vector layers used to draw the live position and the trail. */
_initGpsRendering() {
this._gpsPositionSource = new VectorSource();
this._gpsTrailSource = new VectorSource();
this._gpsTrailCoords = []; // [ [x,y], ... ] in map projection
// Trail line (drawn under the position marker)
this._gpsTrailLayer = new VectorLayer({
source: this._gpsTrailSource,
zIndex: 940,
style: new Style({
stroke: new Stroke({ color: '#ff6d00', width: 4, lineCap: 'round', lineJoin: 'round' }),
}),
properties: { title: 'GPS Trail', displayInLayerSwitcher: false },
});
// Current position: accuracy halo + solid dot
this._gpsPositionLayer = new VectorLayer({
source: this._gpsPositionSource,
zIndex: 950,
style: (feature) => {
if (feature.get('_kind') === 'accuracy') {
return new Style({
fill: new Fill({ color: 'rgba(0,94,184,0.12)' }),
stroke: new Stroke({ color: 'rgba(0,94,184,0.35)', width: 1 }),
});
}
return new Style({
image: new Circle({
radius: 7,
fill: new Fill({ color: '#005eb8' }),
stroke: new Stroke({ color: '#ffffff', width: 2.5 }),
}),
});
},
properties: { title: 'GPS Position', displayInLayerSwitcher: false },
});
this.map.addLayer(this._gpsTrailLayer);
this.map.addLayer(this._gpsPositionLayer);
this._gpsCallbacks = { locate: [], record: [] };
this._gpsRecording = false;
}
/** Register a callback fired when the user taps "Locate Me". */
onLocateMe(cb) { this._gpsCallbacks.locate.push(cb); }
/** Register a callback fired when the user toggles trail recording. Receives the desired state (true=start). */
onToggleRecording(cb) { this._gpsCallbacks.record.push(cb); }
/**
* Draw / move the current-position marker and accuracy halo.
* @param {number} lon
* @param {number} lat
* @param {number|null} [accuracy] horizontal accuracy in metres
*/
showCurrentPosition(lon, lat, accuracy = null) {
if (lon == null || lat == null) return;
const center = fromLonLat([lon, lat]);
this._gpsPositionSource.clear();
if (accuracy && accuracy > 0) {
// Approximate the accuracy circle in projected units. Good enough for a
// visual halo at typical zoom levels.
const resAtLat = accuracy / Math.cos((lat * Math.PI) / 180);
const halo = new Feature({ geometry: new PolygonGeom([this._circleRing(center, resAtLat)]) });
halo.set('_kind', 'accuracy');
this._gpsPositionSource.addFeature(halo);
}
const dot = new Feature({ geometry: new Point(center) });
dot.set('_kind', 'dot');
this._gpsPositionSource.addFeature(dot);
}
/** @private build a ring of coordinates approximating a circle (metres → projected). */
_circleRing(center, radiusMeters, segments = 48) {
// Convert metres to projected units (Web Mercator) roughly via the
// resolution at the centre latitude.
const ring = [];
const metersPerUnit = 1; // EPSG:3857 units are metres (approx near equator/locally)
const r = radiusMeters / metersPerUnit;
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * 2 * Math.PI;
ring.push([center[0] + r * Math.cos(a), center[1] + r * Math.sin(a)]);
}
return ring;
}
/** Smoothly center the view on a coordinate. */
centerOn(lon, lat, zoom = 16) {
const view = this.map.getView();
view.animate({ center: fromLonLat([lon, lat]), zoom, duration: 500 });
}
/** Reset the trail line (call when a new recording starts). */
startTrailRender() {
this._gpsTrailCoords = [];
this._gpsTrailSource.clear();
}
/** Append a coordinate to the growing trail line. */
appendTrailPoint(lon, lat) {
if (lon == null || lat == null) return;
this._gpsTrailCoords.push(fromLonLat([lon, lat]));
this._gpsTrailSource.clear();
if (this._gpsTrailCoords.length >= 2) {
this._gpsTrailSource.addFeature(new Feature({ geometry: new LineString(this._gpsTrailCoords) }));
}
}
/** Remove the rendered trail (does not affect stored data). */
clearTrailRender() {
this._gpsTrailCoords = [];
this._gpsTrailSource.clear();
}
/** Reflect recording state on the control button. */
setRecordingState(active) {
this._gpsRecording = !!active;
if (this._recordBtn) {
this._recordBtn.classList.toggle('recording', this._gpsRecording);
this._recordBtn.title = this._gpsRecording ? 'Stop trail recording' : 'Record GPS trail';
this._recordBtn.innerHTML = this._gpsRecording
? '<i class="bi bi-stop-fill"></i>'
: '<i class="bi bi-record-circle"></i>';
}
if (this._locateToggle) this._locateToggle.classList.toggle('recording', this._gpsRecording);
}
/**
* Build the expandable "My Location" control: a main button that reveals two
* sub-buttons (Locate Me, Record Trail). Anchored at the same spot the old
* ol-ext GeolocationButton occupied (bottom-right), so the base-map picker
* still lines up above it.
*/
_createLocationControl() {
const target = this.map.getTargetElement();
if (!target) return;
// Main toggle
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'ls-locate-toggle';
toggle.title = 'My Location';
toggle.setAttribute('aria-label', 'My Location');
toggle.innerHTML = '<i class="bi bi-geo-alt-fill"></i>';
target.appendChild(toggle);
// Sub-button cluster (hidden until the main button is tapped)
const actions = document.createElement('div');
actions.className = 'ls-locate-actions';
actions.innerHTML =
'<button type="button" class="ls-locate-btn ls-locate-me" title="Locate me">' +
'<i class="bi bi-crosshair"></i></button>' +
'<button type="button" class="ls-locate-btn ls-locate-record" title="Record GPS trail">' +
'<i class="bi bi-record-circle"></i></button>';
target.appendChild(actions);
this._locateToggle = toggle;
this._locateActions = actions;
this._locateMeBtn = actions.querySelector('.ls-locate-me');
this._recordBtn = actions.querySelector('.ls-locate-record');
const close = () => { actions.classList.remove('open'); toggle.classList.remove('active'); };
const open = () => { actions.classList.add('open'); toggle.classList.add('active'); };
toggle.addEventListener('click', (e) => {
e.stopPropagation();
actions.classList.contains('open') ? close() : open();
});
// Tap outside closes the cluster (but never while recording, so the stop
// button stays reachable).
document.addEventListener('click', (e) => {
if (!actions.classList.contains('open')) return;
if (actions.contains(e.target) || toggle.contains(e.target)) return;
if (this._gpsRecording) return;
close();
});
this._locateMeBtn.addEventListener('click', (e) => {
e.stopPropagation();
for (const cb of this._gpsCallbacks.locate) { try { cb(); } catch (err) { console.error(err); } }
if (!this._gpsRecording) close();
});
this._recordBtn.addEventListener('click', (e) => {
e.stopPropagation();
const next = !this._gpsRecording;
for (const cb of this._gpsCallbacks.record) { try { cb(next); } catch (err) { console.error(err); } }
});
}
/**
* Get style for a feature (handles selection state)
*/
@ -2845,6 +3193,40 @@ export class MapView {
source: source,
style: layerStyle,
});
layer.set('typeTag', styleOptions.typeTag || 'VEC');
// Derive a friendly "Vector / Polygon" / "Vector / Line" / "Vector / Point"
// subtitle from the first feature's geometry type, unless the caller
// already supplied one in styleOptions.
//
// Layers created EMPTY (parcels, OSM_roads, …, populated later from the
// API) leave the subtitle absent until the first feature arrives —
// see the `addfeature` listener below.
const describeFromGeom = (geomType) => {
if (!geomType) return null;
if (geomType.includes('Polygon')) return 'Vector / Polygon';
if (geomType.includes('LineString')) return 'Vector / Line';
if (geomType.includes('Point')) return 'Vector / Point';
return 'Vector';
};
if (styleOptions.typeDescription) {
layer.set('typeDescription', styleOptions.typeDescription);
} else {
const feats = source.getFeatures();
const initial = describeFromGeom(feats[0]?.getGeometry?.()?.getType?.());
if (initial) {
layer.set('typeDescription', initial);
} else {
// Source is empty — wait for the first feature and set then.
const once = (ev) => {
const desc = describeFromGeom(ev.feature.getGeometry?.()?.getType?.());
if (desc) layer.set('typeDescription', desc);
source.un('addfeature', once);
};
source.on('addfeature', once);
}
}
const group = targetGroup || this.overlayGroup;
group.getLayers().push(layer);
@ -2925,6 +3307,8 @@ export class MapView {
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
wmsLayer.set('typeTag', 'WMS');
wmsLayer.set('typeDescription', 'WMS / Raster');
// Show toast on tile load errors (e.g. server rejects request)
wmsSource.on('tileloaderror', () => {
@ -2990,6 +3374,8 @@ export class MapView {
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
xyzLayer.set('typeTag', 'XYZ');
xyzLayer.set('typeDescription', 'XYZ / Tile');
// Show toast on tile load errors
xyzSource.on('tileloaderror', () => {
@ -3273,11 +3659,336 @@ export class MapView {
return;
}
// Tag for the LayerSwitcher chip
layer.set('typeTag', type.toUpperCase()); // 'WMS' | 'WFS' | 'XYZ'
layer.set('typeDescription', {
wms: 'WMS / Raster',
wfs: 'WFS / Vector',
xyz: 'XYZ / Tile',
}[type] || type.toUpperCase());
// User-added external layers ARE removable — they're not part of the
// app's built-in data model.
layer.set('removable', true);
group.getLayers().push(layer);
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`);
}
// ============================================================================
// LayerSwitcher decoration (Option A — visual refresh)
// ============================================================================
/**
* Decorate a layer's <li> after ol-ext renders it:
* inject a type-tag chip next to the layer label
* inject the green "+" button on the External Source group header
*
* Idempotent safe to call repeatedly; each injected element checks
* whether it already exists in the row.
*/
_decorateLayerListItem(layer, li) {
// 1. Type-tag chip (e.g. WMS / XYZ / VEC) next to the layer name.
// Inserted INSIDE the label's <span> so it doesn't collide with the
// label's left padding (where ol-ext draws the checkbox via ::before).
const tag = layer.get('typeTag'); // 'WMS' | 'WFS' | 'XYZ' | 'VEC' | 'GEO' | 'BASE'
if (tag) {
const labelSpan = li.querySelector(':scope > .li-content > label > span');
if (labelSpan && !labelSpan.querySelector(':scope > .ls-type-tag')) {
const chip = document.createElement('span');
chip.className = `ls-type-tag ls-type-tag-${String(tag).toLowerCase()}`;
chip.textContent = String(tag);
chip.title = `${tag} layer`;
labelSpan.appendChild(chip);
}
}
// 2. Replace ol-ext's bar-drawn +/- chevron with the GeoView chevron SVG.
// The SVG uses stroke="currentColor", so CSS `color` (set in
// layerswitcher.css) tints it. Rotation handles the open/closed state.
const btnBar = li.querySelector(':scope > .ol-layerswitcher-buttons');
if (btnBar) {
const chevronEl = btnBar.querySelector(':scope > .expend-layers, :scope > .collapse-layers');
if (chevronEl && !chevronEl.querySelector(':scope > svg.ls-chevron-svg')) {
chevronEl.innerHTML =
'<svg class="ls-chevron-svg" width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
'<path d="M3 2L7 5.5L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>' +
'</svg>';
}
}
// 3. Layer-type subtitle ("Vector / Polygon", "WMS / Raster", …) below
// the layer name. Only rendered when layer.get('typeDescription') is
// set. For layers that start empty and gain features later (the API
// loaders), we listen for `change:typeDescription` and update or
// insert the subtitle then.
const content = li.querySelector(':scope > .li-content');
const ensureSubtitle = () => {
if (!content) return;
const text = layer.get('typeDescription');
let sub = content.querySelector(':scope > .ls-layer-subtitle');
if (!text) {
if (sub) sub.remove();
return;
}
if (!sub) {
sub = document.createElement('div');
sub.className = 'ls-layer-subtitle';
const label = content.querySelector(':scope > label');
if (label && label.nextSibling) {
content.insertBefore(sub, label.nextSibling);
} else {
content.appendChild(sub);
}
}
sub.textContent = text;
};
ensureSubtitle();
if (!layer._lsSubtitleHooked) {
layer._lsSubtitleHooked = true;
layer.on('change:typeDescription', () => {
// The <li> may not exist any more (panel re-rendered between events)
// — guard with a fresh lookup via the LayerSwitcher next time it draws.
// For now we just call ensureSubtitle bound to the original li.
ensureSubtitle();
});
}
// 4. Per-layer Remove button — only for layers explicitly marked
// `removable: true` (external sources, imported files, …). Built-in
// layers (Parcels, OSM_roads, district boundary, …) are NOT removable
// so the user can't accidentally delete them.
if (layer.get('removable') === true && btnBar && !btnBar.querySelector(':scope > .ls-remove-btn')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'ls-remove-btn';
removeBtn.title = 'Remove this layer';
removeBtn.setAttribute('aria-label', 'Remove layer');
removeBtn.innerHTML =
'<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
'<path d="M2 2l7 7M9 2L2 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"></path>' +
'</svg>';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._removeLayer(layer);
});
btnBar.appendChild(removeBtn);
}
// 5. "+" button on the External Source group
const groupTitle = (layer.get('title') || '').toLowerCase();
if (groupTitle.includes('external')) {
this._externalSourceGroup = layer;
// btnBar already resolved above (same .ol-layerswitcher-buttons element)
if (btnBar && !btnBar.querySelector('.ol-add-layer')) {
const addBtn = document.createElement('span');
addBtn.className = 'ol-add-layer';
addBtn.title = 'Add external layer';
addBtn.textContent = '+';
addBtn.style.cssText = `
display:inline-flex !important;align-items:center;justify-content:center;
width:22px !important;height:22px !important;border-radius:50%;
background:#41b6a6 !important;color:#fff !important;
font-size:15px !important;font-weight:700;
cursor:pointer;line-height:1 !important;
margin:0 4px 0 0;vertical-align:middle;
transition:background 0.2s;box-sizing:border-box;border:none;
`;
addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#329686'; });
addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#41b6a6'; });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showAddLayerDialog();
});
btnBar.prepend(addBtn);
}
}
}
/**
* Remove a layer from its parent group, after confirmation. Only called
* from the per-layer × button injected by `_decorateLayerListItem` and
* that button is only injected for layers marked `removable: true`, so
* built-in layers (Parcels, OSM_roads, ) can never reach this path.
*
* @param {Layer} layer
*/
_removeLayer(layer) {
const title = layer.get('title') || 'this layer';
if (!confirm(`Remove "${title}" from the map?\n\nThis only affects the current session — built-in layers cannot be removed.`)) {
return;
}
// Find the parent group that owns this layer and call .remove() on its
// collection. Walk recursively from the overlay group.
const visit = (group) => {
const layers = group.getLayers();
if (layers.getArray().includes(layer)) {
layers.remove(layer);
return true;
}
let removed = false;
layers.forEach((child) => {
if (!removed && child.getLayers) {
removed = visit(child);
}
});
return removed;
};
const ok = visit(this.overlayGroup);
if (ok) {
console.log(`[MapView] Removed layer "${title}"`);
showToast(`Removed "${title}" from the map.`, 'info', 3000);
} else {
console.warn(`[MapView] Could not find layer "${title}" in any group`);
}
}
/**
* Inject (or refresh) the panel chrome an "active count" badge at the top
* and a footer row with "Reset overlays" button at the bottom.
*
* The chrome lives in `.panel-container` (the wrapping <div>), not inside
* the `<ul class="panel">` that way the badge and footer are siblings of
* the layer list rather than malformed children of a `<ul>`.
*
* Called from drawlist via queueMicrotask, so it runs once per redraw cycle
* regardless of how many layers are in the panel.
*/
_refreshLayerSwitcherChrome(layerSwitcher) {
const panelContainer = layerSwitcher.element?.querySelector('.panel-container');
const ul = layerSwitcher.element?.querySelector('ul.panel');
if (!panelContainer || !ul) return;
// --- Active-count badge (top of panel-container, before the <ul>) ---
let badge = panelContainer.querySelector(':scope > .ls-active-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'ls-active-badge';
badge.innerHTML = `
<span class="ls-active-badge-title">Layers</span>
<span class="ls-active-badge-count">0 active</span>
`;
panelContainer.insertBefore(badge, ul);
}
// --- Footer row (bottom of panel-container, after the <ul>) ---
let footer = panelContainer.querySelector(':scope > .ls-footer-row');
if (!footer) {
footer = document.createElement('div');
footer.className = 'ls-footer-row';
footer.innerHTML = `
<span class="ls-footer-note"> layers total</span>
<button type="button" class="ls-footer-btn"
title="Hide every overlay (base map stays on)">
Reset
</button>
`;
panelContainer.appendChild(footer);
footer.querySelector('.ls-footer-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._resetAllOverlays();
});
}
// --- Update counters ---
const counts = this._countLayers();
badge.querySelector('.ls-active-badge-count').textContent =
`${counts.activeOverlays} active`;
footer.querySelector('.ls-footer-note').textContent =
`${counts.totalOverlays} overlay${counts.totalOverlays === 1 ? '' : 's'}`;
}
/**
* Walk the overlay group recursively, returning the number of *leaf* layers
* (i.e. not groups) and how many of them are currently visible.
* Excludes base maps and internal layers (Markers, Drawings, vertex overlay).
*/
_countLayers() {
let totalOverlays = 0;
let activeOverlays = 0;
const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.get('displayInLayerSwitcher') === false) return;
if (HIDDEN_INTERNAL.has(layer.get('title'))) return;
if (layer.getLayers) {
visit(layer);
} else {
totalOverlays++;
if (layer.getVisible()) activeOverlays++;
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
return { totalOverlays, activeOverlays };
}
/**
* Hide every overlay layer (base maps stay on). Wired to the footer
* "Reset overlays" button.
*/
_resetAllOverlays() {
const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.get('displayInLayerSwitcher') === false) return;
if (HIDDEN_INTERNAL.has(layer.get('title'))) return;
if (layer.getLayers) {
visit(layer);
} else {
layer.setVisible(false);
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
console.log('[MapView] Reset overlays — all hidden');
}
/**
* Hook `change:visible` on every overlay leaf so the active-count badge
* stays in sync even when ol-ext doesn't re-fire drawlist (e.g. ticking
* a checkbox just toggles visibility without rebuilding the list).
* Also re-hooks when a new layer is added.
*/
_wireLayerSwitcherVisibilityHooks(layerSwitcher) {
const refresh = () => this._refreshLayerSwitcherChrome(layerSwitcher);
const hookLayer = (layer) => {
if (layer._lsVisHooked) return;
layer._lsVisHooked = true;
layer.on('change:visible', refresh);
};
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.getLayers) {
visit(layer);
// Listen for layers added later to this group
if (!group._lsAddHooked) {
group._lsAddHooked = true;
group.getLayers().on('add', (ev) => {
const added = ev.element;
if (added.getLayers) visit(added); else hookLayer(added);
refresh();
});
}
} else {
hookLayer(layer);
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
}
// ============================================================================
// Online-Only Layer Helper
// ============================================================================

View File

@ -207,9 +207,52 @@ export async function initSchema() {
)
`;
// ── GPS trails ──────────────────────────────────────────────────────
// Recorded field-movement tracks. These are the local store for the
// reusable GeoTracker module (src/geotracker/). `client_uuid` lets the
// server de-duplicate re-synced trails; `satellites` is nullable because
// the web Geolocation API does not expose it (only native builds do).
console.log('[Database] Creating gps_trails table...');
await sql`
CREATE TABLE IF NOT EXISTS gps_trails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_uuid TEXT UNIQUE,
name TEXT,
district_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
status TEXT NOT NULL DEFAULT 'recording',
point_count INTEGER NOT NULL DEFAULT 0,
distance_m REAL NOT NULL DEFAULT 0,
synced INTEGER NOT NULL DEFAULT 0,
remote_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
console.log('[Database] Creating gps_trail_points table...');
await sql`
CREATE TABLE IF NOT EXISTS gps_trail_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trail_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
longitude REAL NOT NULL,
latitude REAL NOT NULL,
altitude REAL,
accuracy REAL,
altitude_accuracy REAL,
heading REAL,
speed REAL,
satellites INTEGER,
recorded_at TEXT NOT NULL
)
`;
// Create indexes
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
await sql`CREATE INDEX IF NOT EXISTS idx_gps_trails_synced ON gps_trails(synced, status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_gps_trail_points_trail ON gps_trail_points(trail_id, seq)`;
// Final verification
const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
@ -1020,6 +1063,111 @@ export async function closeDatabase() {
}
}
// ============================================================================
// GPS Trails (storage adapter for the reusable GeoTracker module)
// ============================================================================
//
// These functions implement the GeoTracker "storage adapter" contract using
// SQLocal. They are intentionally generic (no map / UI coupling) so the same
// schema + helpers can be lifted into another app alongside src/geotracker/.
/**
* Create a new trail row (status='recording') and return its local id.
* @param {object} meta { uuid, name, startedAt, districtId }
* @returns {Promise<number>} local trail id
*/
export async function createGpsTrail(meta) {
const { uuid, name = null, startedAt, districtId = null } = meta;
await sql`
INSERT INTO gps_trails (client_uuid, name, district_id, started_at, status)
VALUES (${uuid}, ${name}, ${districtId}, ${startedAt}, 'recording')
`;
const idResult = await sql`SELECT last_insert_rowid() as id`;
const id = idResult[0]?.id;
broadcastChange('gps_trails', 'insert', id);
return id;
}
/**
* Append one recorded point to a trail.
* @param {number} trailId
* @param {object} point normalized fix + { seq }
*/
export async function addGpsTrailPoint(trailId, point) {
const {
seq, lon, lat,
altitude = null, accuracy = null, altitudeAccuracy = null,
heading = null, speed = null, satellites = null, timestamp,
} = point;
const recordedAt = typeof timestamp === 'number' ? new Date(timestamp).toISOString() : (timestamp || new Date().toISOString());
await sql`
INSERT INTO gps_trail_points
(trail_id, seq, longitude, latitude, altitude, accuracy, altitude_accuracy, heading, speed, satellites, recorded_at)
VALUES
(${trailId}, ${seq}, ${lon}, ${lat}, ${altitude}, ${accuracy}, ${altitudeAccuracy}, ${heading}, ${speed}, ${satellites}, ${recordedAt})
`;
}
/**
* Finalise a trail: mark completed and store the summary.
* @param {number} trailId
* @param {object} summary { endedAt, pointCount, distanceM }
*/
export async function finishGpsTrail(trailId, summary) {
const { endedAt, pointCount = 0, distanceM = 0 } = summary;
await sql`
UPDATE gps_trails
SET ended_at = ${endedAt}, point_count = ${pointCount}, distance_m = ${distanceM}, status = 'completed'
WHERE id = ${trailId}
`;
broadcastChange('gps_trails', 'update', trailId);
}
/**
* Trails that are completed but not yet pushed to the server.
* @returns {Promise<Array>}
*/
export async function getUnsyncedGpsTrails() {
return sql`SELECT * FROM gps_trails WHERE synced = 0 AND status = 'completed' ORDER BY started_at ASC`;
}
/**
* All points of a trail, in recorded order.
* @param {number} trailId
* @returns {Promise<Array>}
*/
export async function getGpsTrailPoints(trailId) {
return sql`SELECT * FROM gps_trail_points WHERE trail_id = ${trailId} ORDER BY seq ASC`;
}
/**
* Mark a trail as synced and record the server-assigned id.
* @param {number} trailId
* @param {string|number|null} remoteId
*/
export async function markGpsTrailSynced(trailId, remoteId = null) {
await sql`UPDATE gps_trails SET synced = 1, remote_id = ${remoteId} WHERE id = ${trailId}`;
broadcastChange('gps_trails', 'update', trailId);
}
/**
* List trails (most recent first) for a history/list UI.
* @returns {Promise<Array>}
*/
export async function getGpsTrails() {
return sql`SELECT * FROM gps_trails ORDER BY started_at DESC`;
}
/**
* Delete a trail and all its points.
* @param {number} trailId
*/
export async function deleteGpsTrail(trailId) {
await sql`DELETE FROM gps_trail_points WHERE trail_id = ${trailId}`;
await sql`DELETE FROM gps_trails WHERE id = ${trailId}`;
broadcastChange('gps_trails', 'delete', trailId);
}
export default {
sql,
dbReady,
@ -1046,6 +1194,14 @@ export default {
getLocalBuildingFootprints,
saveOSMRoads,
getLocalOSMRoads,
createGpsTrail,
addGpsTrailPoint,
finishGpsTrail,
getUnsyncedGpsTrails,
getGpsTrailPoints,
markGpsTrailSynced,
getGpsTrails,
deleteGpsTrail,
CACHED_LAYER_TABLES,
isCachedLayerTable,
clearTable,

68
src/geotracker-lupmis.js Normal file
View File

@ -0,0 +1,68 @@
/**
* geotracker-lupmis.js LUPMIS2 integration layer for the reusable GeoTracker.
*
* This is the ONLY place that couples the generic src/geotracker/ engine to
* LUPMIS specifics (SQLocal storage, the PHP sync endpoint, district id,
* online checks). To reuse GeoTracker in another app, copy src/geotracker/ and
* write a file like this one with that app's storage + sync adapters.
*
* @module geotracker-lupmis
*/
import { GeoTracker } from './geotracker/GeoTracker.js';
import {
createGpsTrail,
addGpsTrailPoint,
finishGpsTrail,
getUnsyncedGpsTrails,
getGpsTrailPoints,
markGpsTrailSynced,
} from './database.js';
import { pushGpsTrail } from './remotedb.js';
import { isOnline } from './pwa.js';
import { getSession } from './remotedb.js';
/**
* Storage adapter maps the GeoTracker contract onto the SQLocal helpers in
* database.js. The district id is stamped onto each trail at creation time.
*/
const sqlocalStorage = {
async createTrail(meta) {
const districtId = meta.districtId
?? getSession()?.district_id
?? null;
return createGpsTrail({ ...meta, districtId: districtId != null ? String(districtId) : null });
},
addPoint: (trailId, point) => addGpsTrailPoint(trailId, point),
finishTrail: (trailId, summary) => finishGpsTrail(trailId, summary),
getUnsyncedTrails: () => getUnsyncedGpsTrails(),
getTrailPoints: (trailId) => getGpsTrailPoints(trailId),
markTrailSynced: (trailId, remote) => markGpsTrailSynced(trailId, remote),
};
/**
* Sync adapter store-and-forward upload via the PHP endpoint. `isOnline()`
* lets the tracker skip pushes while offline (it retries later).
*/
const remoteSync = {
pushTrail: (trail, points) => pushGpsTrail(trail, points),
isOnline: () => isOnline(),
};
/**
* The configured, app-wide tracker instance. Tunables chosen for field
* walking/driving: a point every ~5 m, throttled to 1 s, with a 20 s
* heartbeat so stationary pauses still leave a breadcrumb, dropping fixes
* worse than 50 m accuracy.
*/
export const geoTracker = new GeoTracker({
storage: sqlocalStorage,
sync: remoteSync,
minDistanceM: 5,
minIntervalMs: 1000,
heartbeatMs: 20000,
maxAccuracyM: 50,
enableHighAccuracy: true,
});
export default geoTracker;

View File

@ -0,0 +1,371 @@
/**
* GeoTracker.js a framework-agnostic GPS live-position + trail-recording
* engine. It has **no** dependency on OpenLayers, Bootstrap, SQLocal, or any
* LUPMIS code, so it can be dropped into any web project. Persistence and
* server sync are provided by the host through small adapter objects.
*
*
* STORAGE ADAPTER (required for recording) all methods may be async:
* createTrail(meta) -> trailId // meta: {uuid,name,startedAt,...}
* addPoint(trailId, point) -> void // point: normalized fix (see below)
* finishTrail(trailId, summary)-> void // summary: {endedAt,pointCount,distanceM}
* getUnsyncedTrails() -> Array<trail> // trails with synced=0 and completed
* getTrailPoints(trailId) -> Array<point>
* markTrailSynced(trailId, remoteId) -> void
*
* SYNC ADAPTER (optional) store-and-forward:
* pushTrail(trail, points) -> { remoteId } | throws
* isOnline?() -> boolean // optional connectivity probe
*
* NORMALIZED FIX shape emitted on 'position' and stored via addPoint:
* { lon, lat, accuracy, altitude, altitudeAccuracy, heading, speed,
* satellites:null, timestamp }
* (satellites is always null on the web Geolocation API kept for parity
* with native builds that can populate it.)
*
*
* @module geotracker/GeoTracker
*/
import { haversineMeters } from './geo-utils.js';
/** @typedef {'idle'|'watching'|'recording'} GeoTrackerState */
const DEFAULTS = {
/** Minimum metres between two recorded trail points. */
minDistanceM: 5,
/** Ignore fixes arriving faster than this (throttle, ms). */
minIntervalMs: 1000,
/** Record a point at least this often even when stationary (heartbeat, ms). */
heartbeatMs: 20000,
/** Drop fixes worse than this horizontal accuracy (metres). 0 = accept all. */
maxAccuracyM: 50,
/** navigator.geolocation options. */
enableHighAccuracy: true,
timeoutMs: 15000,
maximumAgeMs: 0,
};
export class GeoTracker {
/**
* @param {object} [options]
* @param {object} [options.storage] storage adapter (see module docs)
* @param {object} [options.sync] sync adapter (see module docs)
* @param {Geolocation} [options.geolocation] inject navigator.geolocation (for tests)
* @param {number} [options.minDistanceM]
* @param {number} [options.minIntervalMs]
* @param {number} [options.heartbeatMs]
* @param {number} [options.maxAccuracyM]
* @param {boolean} [options.enableHighAccuracy]
*/
constructor(options = {}) {
this.opts = { ...DEFAULTS, ...options };
this.storage = options.storage || null;
this.sync = options.sync || null;
this._geo = options.geolocation ||
(typeof navigator !== 'undefined' ? navigator.geolocation : null);
/** @type {GeoTrackerState} */
this._state = 'idle';
this._watchId = null;
this._live = false; // live readout requested
this._recording = false; // recording in progress
this._activeTrailId = null;
this._activeTrailUuid = null;
this._lastRecorded = null; // last point actually written {lon,lat,timestamp}
this._lastRecordedAt = 0;
this._distanceM = 0;
this._pointCount = 0;
this._lastFix = null; // most recent normalized fix (any quality)
/** @type {Record<string, Set<Function>>} */
this._listeners = Object.create(null);
}
// ── Events ────────────────────────────────────────────────────────────
/**
* Subscribe to an event. Returns an unsubscribe function.
* Events: 'position' | 'point' | 'statechange' | 'trailstart' |
* 'trailstop' | 'error' | 'syncstatus'
* @param {string} event
* @param {Function} cb
* @returns {() => void}
*/
on(event, cb) {
(this._listeners[event] || (this._listeners[event] = new Set())).add(cb);
return () => this._listeners[event]?.delete(cb);
}
_emit(event, payload) {
const set = this._listeners[event];
if (!set) return;
for (const cb of set) {
try { cb(payload); } catch (err) { console.error(`[GeoTracker] listener for "${event}" threw`, err); }
}
}
// ── Public state ──────────────────────────────────────────────────────
/** @returns {GeoTrackerState} */
get state() { return this._state; }
get isRecording() { return this._recording; }
get lastFix() { return this._lastFix; }
get isSupported() { return !!this._geo; }
_setState(s) {
if (this._state === s) return;
this._state = s;
this._emit('statechange', s);
}
// ── Live readout (watch without recording) ──────────────────────────────
/**
* Begin a position watch purely for the live readout (no trail is recorded).
* Safe to call repeatedly.
*/
startLive() {
if (!this._geo) { this._emit('error', new Error('Geolocation not supported')); return; }
this._live = true;
this._ensureWatch();
}
/** Stop the live readout. Has no effect while a recording is in progress. */
stopLive() {
this._live = false;
if (!this._recording) this._teardownWatch();
}
/**
* One-shot position request (e.g. for a "Locate me" button). Resolves with a
* normalized fix. Does not start/stop the watch.
* @returns {Promise<object>}
*/
getCurrentPosition() {
return new Promise((resolve, reject) => {
if (!this._geo) { reject(new Error('Geolocation not supported')); return; }
this._geo.getCurrentPosition(
(pos) => {
const fix = GeoTracker.normalize(pos);
this._lastFix = fix;
this._emit('position', fix);
resolve(fix);
},
(err) => { this._emit('error', err); reject(err); },
{
enableHighAccuracy: this.opts.enableHighAccuracy,
timeout: this.opts.timeoutMs,
maximumAge: this.opts.maximumAgeMs,
}
);
});
}
// ── Recording ───────────────────────────────────────────────────────────
/**
* Start recording a new trail. Creates the trail in storage, then records
* filtered points as the device moves.
* @param {object} [meta] e.g. { name, districtId }
* @returns {Promise<{trailId:*, uuid:string}>}
*/
async startRecording(meta = {}) {
if (!this._geo) throw new Error('Geolocation not supported');
if (!this.storage) throw new Error('GeoTracker: no storage adapter configured');
if (this._recording) return { trailId: this._activeTrailId, uuid: this._activeTrailUuid };
const uuid = GeoTracker.uuid();
const startedAt = new Date().toISOString();
const trailMeta = { uuid, name: meta.name || null, startedAt, ...meta };
const trailId = await this.storage.createTrail(trailMeta);
this._activeTrailId = trailId;
this._activeTrailUuid = uuid;
this._lastRecorded = null;
this._lastRecordedAt = 0;
this._distanceM = 0;
this._pointCount = 0;
this._recording = true;
this._ensureWatch();
this._setState('recording');
this._emit('trailstart', { trailId, uuid, startedAt });
return { trailId, uuid };
}
/**
* Stop the active recording, finalise the trail summary, and (if a sync
* adapter is present) attempt to push it immediately.
* @returns {Promise<{trailId:*, pointCount:number, distanceM:number, synced:boolean}>}
*/
async stopRecording() {
if (!this._recording) return null;
const trailId = this._activeTrailId;
const endedAt = new Date().toISOString();
const summary = { endedAt, pointCount: this._pointCount, distanceM: this._distanceM };
this._recording = false;
if (!this._live) this._teardownWatch();
this._setState(this._live ? 'watching' : 'idle');
try {
await this.storage.finishTrail(trailId, summary);
} catch (err) {
this._emit('error', err);
}
this._emit('trailstop', { trailId, ...summary });
let synced = false;
if (this.sync) {
try { synced = await this._syncTrail(trailId); }
catch (err) { this._emit('error', err); }
}
this._activeTrailId = null;
this._activeTrailUuid = null;
return { trailId, pointCount: summary.pointCount, distanceM: summary.distanceM, synced };
}
// ── Sync (store-and-forward) ────────────────────────────────────────────
/**
* Push all completed-but-unsynced trails to the server via the sync adapter.
* Call on app start and whenever connectivity returns.
* @returns {Promise<{pushed:number, failed:number}>}
*/
async syncPending() {
if (!this.sync || !this.storage) return { pushed: 0, failed: 0 };
if (this.sync.isOnline && !this.sync.isOnline()) return { pushed: 0, failed: 0 };
let pushed = 0, failed = 0;
const trails = await this.storage.getUnsyncedTrails();
for (const trail of trails) {
try {
const ok = await this._syncTrail(trail.id ?? trail.trailId, trail);
ok ? pushed++ : failed++;
} catch (err) {
failed++;
this._emit('error', err);
}
}
this._emit('syncstatus', { pushed, failed });
return { pushed, failed };
}
/** @private push a single trail by id. */
async _syncTrail(trailId, trailRow) {
const points = await this.storage.getTrailPoints(trailId);
const trail = trailRow || { id: trailId };
const result = await this.sync.pushTrail(trail, points);
const remoteId = result && (result.remoteId ?? result.id ?? null);
await this.storage.markTrailSynced(trailId, remoteId);
return true;
}
// ── Internal watch handling ──────────────────────────────────────────────
/** @private start the geolocation watch if not already running. */
_ensureWatch() {
if (this._watchId != null || !this._geo) {
if (this._state === 'idle' && this._live) this._setState('watching');
return;
}
this._watchId = this._geo.watchPosition(
(pos) => this._onFix(pos),
(err) => this._emit('error', err),
{
enableHighAccuracy: this.opts.enableHighAccuracy,
timeout: this.opts.timeoutMs,
maximumAge: this.opts.maximumAgeMs,
}
);
if (!this._recording) this._setState('watching');
}
/** @private stop the geolocation watch. */
_teardownWatch() {
if (this._watchId != null && this._geo) {
this._geo.clearWatch(this._watchId);
}
this._watchId = null;
}
/** @private handle a raw Geolocation fix. */
async _onFix(pos) {
const fix = GeoTracker.normalize(pos);
this._lastFix = fix;
this._emit('position', fix); // always emit for the live readout
if (!this._recording) return;
const { minIntervalMs, minDistanceM, heartbeatMs, maxAccuracyM } = this.opts;
const now = fix.timestamp;
// Throttle very frequent fixes.
if (this._lastRecordedAt && (now - this._lastRecordedAt) < minIntervalMs) return;
// Drop low-quality fixes (unless this is the very first point).
if (maxAccuracyM > 0 && fix.accuracy != null && fix.accuracy > maxAccuracyM && this._lastRecorded) return;
let keep = false;
let stepM = 0;
if (!this._lastRecorded) {
keep = true; // always record the first point
} else {
stepM = haversineMeters(this._lastRecorded.lon, this._lastRecorded.lat, fix.lon, fix.lat);
const elapsed = now - this._lastRecordedAt;
if (stepM >= minDistanceM || elapsed >= heartbeatMs) keep = true;
}
if (!keep) return;
if (this._lastRecorded) this._distanceM += stepM;
this._pointCount += 1;
this._lastRecorded = { lon: fix.lon, lat: fix.lat, timestamp: now };
this._lastRecordedAt = now;
try {
await this.storage.addPoint(this._activeTrailId, { ...fix, seq: this._pointCount });
this._emit('point', {
trailId: this._activeTrailId,
seq: this._pointCount,
point: fix,
distanceM: this._distanceM,
pointCount: this._pointCount,
});
} catch (err) {
this._emit('error', err);
}
}
// ── Static helpers ────────────────────────────────────────────────────────
/** Normalize a browser GeolocationPosition into the module's fix shape. */
static normalize(pos) {
const c = pos.coords || {};
const num = (v) => (v != null && !Number.isNaN(v) ? v : null);
return {
lon: c.longitude,
lat: c.latitude,
accuracy: num(c.accuracy),
altitude: num(c.altitude),
altitudeAccuracy: num(c.altitudeAccuracy),
heading: num(c.heading),
speed: num(c.speed),
satellites: null, // not exposed by the web Geolocation API
timestamp: pos.timestamp || Date.now(),
};
}
/** RFC4122-ish UUID, using crypto when available. */
static uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
export default GeoTracker;

View File

@ -0,0 +1,87 @@
/**
* geo-utils.js pure, dependency-free geospatial helpers for the GeoTracker
* module. No browser APIs, no framework imports safe to reuse anywhere
* (including Node, web workers, or other projects).
*
* @module geotracker/geo-utils
*/
const EARTH_RADIUS_M = 6371008.8; // mean Earth radius (metres)
const DEG2RAD = Math.PI / 180;
/**
* Great-circle distance between two lon/lat points using the haversine
* formula.
*
* @param {number} lon1
* @param {number} lat1
* @param {number} lon2
* @param {number} lat2
* @returns {number} distance in metres
*/
export function haversineMeters(lon1, lat1, lon2, lat2) {
const dLat = (lat2 - lat1) * DEG2RAD;
const dLon = (lon2 - lon1) * DEG2RAD;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * DEG2RAD) * Math.cos(lat2 * DEG2RAD) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Total length of a polyline expressed as an array of points.
* @param {Array<{lon:number, lat:number}>} points
* @returns {number} metres
*/
export function pathLengthMeters(points) {
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineMeters(points[i - 1].lon, points[i - 1].lat, points[i].lon, points[i].lat);
}
return total;
}
/**
* Format a latitude or longitude for compact display.
* @param {number} value
* @param {number} [decimals=5] ~1.1 m precision at 5 dp
* @returns {string}
*/
export function formatCoord(value, decimals = 5) {
if (value == null || Number.isNaN(value)) return '—';
return value.toFixed(decimals);
}
/**
* Format a distance in metres into a friendly string (m below 1 km, km above).
* @param {number} meters
* @returns {string}
*/
export function formatDistance(meters) {
if (meters == null || Number.isNaN(meters)) return '—';
if (meters < 1000) return `${Math.round(meters)} m`;
return `${(meters / 1000).toFixed(2)} km`;
}
/**
* Format a horizontal accuracy (metres) into a friendly ± string.
* @param {number|null} meters
* @returns {string}
*/
export function formatAccuracy(meters) {
if (meters == null || Number.isNaN(meters)) return '—';
return `±${Math.round(meters)} m`;
}
/**
* Classify a horizontal accuracy into a qualitative fix-quality bucket. Useful
* for colour-coding the UI without needing satellite count.
* @param {number|null} meters
* @returns {'good'|'fair'|'poor'|'none'}
*/
export function accuracyQuality(meters) {
if (meters == null || Number.isNaN(meters)) return 'none';
if (meters <= 10) return 'good';
if (meters <= 30) return 'fair';
return 'poor';
}

View File

@ -33,7 +33,7 @@ async function getLogoDataUrl() {
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = './icons/luspa-pdf.jpg';
img.src = './app-icons/luspa-pdf.jpg';
});
// Draw onto a canvas to get a reliable JPEG data URL

View File

@ -411,6 +411,28 @@ export async function clearTileCaches() {
}
}
/**
* Delete cached tiles for a single provider. `cacheName` must match one of
* the per-provider caches reported by `getTileCacheStats()` (e.g.
* `tiles-osm-v4`, `tiles-topo-v4`). Unknown names are rejected by the SW.
*
* @param {string} cacheName
* @returns {Promise<boolean>} true if the cache was actually deleted
*/
export async function clearTileCacheForProvider(cacheName) {
if (!cacheName) return false;
try {
const reply = await requestFromServiceWorker(
'CLEAR_TILE_CACHE', 'TILE_CACHE_CLEARED',
{ cacheName },
);
return !!reply.deleted;
} catch (err) {
console.warn(`[PWA] clearTileCacheForProvider(${cacheName}) failed:`, err);
return false;
}
}
/**
* Get total disk used by this origin (Cache API + IndexedDB + OPFS).
* Returns null if the Storage API is not available.
@ -468,6 +490,7 @@ export default {
clearUserCaches,
getTileCacheStats,
clearTileCaches,
clearTileCacheForProvider,
getStorageEstimate,
getActiveServiceWorker,
onServiceWorkerControllerChange,

View File

@ -16,11 +16,81 @@
const API_BASE = 'https://api.lupmis4luspa.org/api/spatial_planning';
/**
* Per-request credentials sent with every API call.
*
* `district_id` is resolved dynamically when the PWA is loaded via the PHP
* entry point (public/index.php), the SSO session is injected into the page
* as `window.LUPMIS_SESSION` and we read the authenticated user's district
* from there. In local development (Vite serves index.html directly without
* PHP), the global is undefined and we fall back to the hard-coded test
* district below.
*
* `api_token` is currently a single global app token not per-user.
*/
const FALLBACK_DISTRICT_ID = '1';
const API_TOKEN = '1c46538c712e9b5b';
/**
* Returns the authenticated user's district_id, or the dev fallback.
* The getter runs on each spread of API_CREDENTIALS, so changing the
* session at runtime (rare but possible) takes effect immediately.
*/
function resolveDistrictId() {
try {
const id = (typeof window !== 'undefined') && window.LUPMIS_SESSION?.district_id;
if (id !== null && id !== undefined && String(id).length > 0) {
return String(id);
}
} catch { /* no-op */ }
return FALLBACK_DISTRICT_ID;
}
const API_CREDENTIALS = {
district_id: '1',
api_token: '1c46538c712e9b5b'
get district_id() { return resolveDistrictId(); },
api_token: API_TOKEN,
};
/**
* Get the full session payload (or null if not authenticated).
* Exposed for UI code that wants to display the user's name, email, etc.
*
* Dev-mode helper: setting `localStorage['dev-session']` to a JSON object
* (e.g. via `lupmisDevSession({...})` in the console) overrides the real
* session useful when running against `localhost:5173` to test the
* authenticated UI without standing up a PHP server.
*/
export function getSession() {
// 1. Real session injected by index.php (production)
if (typeof window !== 'undefined' && window.LUPMIS_SESSION && window.LUPMIS_SESSION.user_id) {
return window.LUPMIS_SESSION;
}
// 2. Dev-mode override (developer's own localStorage tweak)
try {
const raw = localStorage.getItem('dev-session');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.user_id) return parsed;
}
} catch { /* ignore */ }
return null;
}
// Console helper — set a fake session for dev work. Reload to apply.
// lupmisDevSession({ user_id: 42, district_id: '1', full_name: 'Test User', ... })
// Clear it via lupmisDevSession(null) or localStorage.removeItem('dev-session').
if (typeof window !== 'undefined') {
window.lupmisDevSession = (payload) => {
if (payload == null) {
localStorage.removeItem('dev-session');
console.log('[Dev] Session override cleared. Reload to apply.');
} else {
localStorage.setItem('dev-session', JSON.stringify(payload));
console.log('[Dev] Session override saved. Reload to apply:', payload);
}
};
}
// ============================================================================
// Server Reachability
// ============================================================================
@ -266,9 +336,11 @@ export async function getBuildingFootprints() {
/**
* Fetch the Contours hillshade elevation layer from the server.
*
* Source: table `contours_hillshade` in the local PostgreSQL `public` schema
* Source: table `be_contour_hillside` in the local PostgreSQL `public` schema
* (imported from OpenTopography's viz.hh_hillshade).
*
* The current district_id is passed automatically via API_CREDENTIALS.
*
* Expected response:
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
*
@ -293,11 +365,86 @@ export async function getOSMRoads() {
return remotePost('get_osm_roads.php');
}
/**
* Push a recorded GPS trail (with all its points) to the server.
*
* Implements the GeoTracker "sync adapter" contract: store-and-forward the
* whole trail is uploaded once recording stops (and retried when back online).
* `district_id` and `api_token` are attached automatically by remotePost().
*
* SERVER SIDE (NOT YET CREATED)
* Proposed endpoint: `save_gps_trail.php`
* Proposed PostgreSQL/PostGIS tables (SRID 4326):
*
* CREATE TABLE be_gps_trail (
* id SERIAL PRIMARY KEY,
* client_uuid TEXT UNIQUE, -- de-dupe re-syncs
* district_id INTEGER,
* name TEXT,
* started_at TIMESTAMPTZ,
* ended_at TIMESTAMPTZ,
* point_count INTEGER,
* distance_m DOUBLE PRECISION,
* track GEOMETRY(LineStringZ, 4326), -- optional aggregate line
* createdt TIMESTAMPTZ DEFAULT now()
* );
* CREATE TABLE be_gps_trail_point (
* id SERIAL PRIMARY KEY,
* trail_id INTEGER REFERENCES be_gps_trail(id) ON DELETE CASCADE,
* seq INTEGER,
* geom GEOMETRY(PointZ, 4326),
* accuracy DOUBLE PRECISION,
* altitude DOUBLE PRECISION,
* heading DOUBLE PRECISION,
* speed DOUBLE PRECISION,
* satellites INTEGER, -- nullable (web has no sat count)
* recorded_at TIMESTAMPTZ
* );
*
* Request body (JSON, plus injected credentials):
* { client_uuid, name, started_at, ended_at, point_count, distance_m,
* points: [ { seq, longitude, latitude, altitude, accuracy,
* altitude_accuracy, heading, speed, satellites, recorded_at } ] }
*
* Expected response: { success: true, id: <server trail id> }
* Should be idempotent on client_uuid (INSERT ... ON CONFLICT DO UPDATE).
*
*
* @param {Object} trail local trail row (client_uuid, name, started_at, )
* @param {Array} points local point rows (seq, longitude, latitude, )
* @returns {Promise<{ remoteId: (string|number|null) }>}
*/
export async function pushGpsTrail(trail, points) {
const payload = {
client_uuid: trail.client_uuid,
name: trail.name ?? null,
started_at: trail.started_at,
ended_at: trail.ended_at,
point_count: trail.point_count ?? points.length,
distance_m: trail.distance_m ?? 0,
points: (points || []).map((p) => ({
seq: p.seq,
longitude: p.longitude,
latitude: p.latitude,
altitude: p.altitude ?? null,
accuracy: p.accuracy ?? null,
altitude_accuracy: p.altitude_accuracy ?? null,
heading: p.heading ?? null,
speed: p.speed ?? null,
satellites: p.satellites ?? null,
recorded_at: p.recorded_at,
})),
};
const res = await remotePost('save_gps_trail.php', payload);
return { remoteId: res?.id ?? res?.remote_id ?? null };
}
// ============================================================================
// Exports
// ============================================================================
export default {
getSession,
checkServerReachable,
isServerReachable,
remoteGet,
@ -309,4 +456,5 @@ export default {
getBuildingFootprints,
getContoursHillshade,
getOSMRoads,
pushGpsTrail,
};

View File

@ -0,0 +1,610 @@
/* ============================================================================
* LayerSwitcher visual refresh (Option A cherry-picked UX wins)
*
* MINIMALLY INVASIVE rewrite only changes colours / shadows / borders /
* fonts. We do NOT touch layout (no display/float/position/padding/margin
* overrides on the existing rows), because ol-ext relies on a specific
* internal flow: buttons + opacity slider float right, the chevron icons
* use absolute-positioned pseudo-elements within their button container,
* and the checkbox / radio is drawn via label::before / label::after.
*
* Earlier aggressive overrides broke that flow restoring the chevron
* means treating ol-ext's stock layout as a working contract and only
* skinning the surface.
* ============================================================================ */
:root {
--ls-accent: var(--brand-green-bright, #41b6a6);
--ls-accent-strong: var(--brand-blue-strong, #005eb8);
--ls-text: var(--brand-navy, #1e1a4b);
--ls-text-muted: var(--brand-gray-medium, #7a7a7a);
--ls-text-dim: #98a2b3;
--ls-surface: rgba(255, 255, 255, 0.97);
--ls-surface-2: #f9fafc;
--ls-border: rgba(30, 26, 75, 0.10);
--ls-border-strong: rgba(30, 26, 75, 0.22);
--ls-shadow: 0 12px 38px rgba(30, 26, 75, 0.18), 0 2px 8px rgba(30, 26, 75, 0.08);
--ls-shadow-sm: 0 2px 8px rgba(30, 26, 75, 0.10);
--ls-radius: 14px;
--ls-radius-sm: 9px;
--ls-transition: .18s cubic-bezier(.4, 0, .2, 1);
--ls-font: 'Exo', var(--font-sans, system-ui, sans-serif);
--ls-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* ============================================================================
PANEL CONTAINER + COLLAPSE BUTTON
============================================================================ */
.ol-layerswitcher {
font-family: var(--ls-font);
}
/* Collapse-toggle button branded with the LUSPA logo instead of ol-ext's
stack-of-papers pseudo glyph.
The background-image URL is set from JavaScript in MapView.js so Vite can
resolve it against `import.meta.env.BASE_URL` that way the icon works
whether the app is deployed at the domain root or under a sub-path. */
.ol-layerswitcher > button {
background-color: var(--ls-surface) !important;
background-repeat: no-repeat !important;
background-position: center !important;
background-size: 24px 24px !important;
border: 1px solid var(--ls-border);
color: var(--ls-text);
border-radius: var(--ls-radius-sm);
width: 36px;
height: 36px;
backdrop-filter: blur(14px);
box-shadow: var(--ls-shadow-sm);
transition: background-color var(--ls-transition), border-color var(--ls-transition),
transform .12s;
}
.ol-layerswitcher > button:hover {
background-color: rgba(65, 182, 166, 0.10) !important;
border-color: var(--ls-accent);
transform: scale(1.04);
}
/* Suppress ol-ext's stack-of-papers pseudo glyph
(ol-ext.css line ~2153: .ol-layerswitcher > button:before/:after) */
.ol-layerswitcher > button::before,
.ol-layerswitcher > button::after {
display: none !important;
content: none !important;
}
.ol-layerswitcher .panel-container {
background: var(--ls-surface);
border: 1px solid var(--ls-border);
border-radius: var(--ls-radius);
backdrop-filter: blur(16px);
box-shadow: var(--ls-shadow);
color: var(--ls-text);
font-size: 13px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* The <ul.panel> scrollable; keep ol-ext's padding intact for the chevron
pseudos to space correctly */
.ol-layerswitcher .panel-container > ul.panel {
background: transparent;
border: none;
overflow-y: auto;
flex: 1 1 auto;
margin: 0;
}
.ol-layerswitcher .panel-container > ul.panel::-webkit-scrollbar { width: 4px; }
.ol-layerswitcher .panel-container > ul.panel::-webkit-scrollbar-thumb {
background: var(--ls-border-strong);
border-radius: 4px;
}
/* Hide ol-ext's "Map" header LI — we render our own active-count badge */
.ol-layerswitcher .panel li.ol-header {
display: none;
}
/* ============================================================================
LIST ITEMS colours only, no layout changes
============================================================================ */
.ol-layerswitcher .panel li {
border-left: 2px solid transparent;
transition: background var(--ls-transition), border-color var(--ls-transition);
}
.ol-layerswitcher .panel li:hover {
background: rgba(65, 182, 166, 0.06);
}
.ol-layerswitcher .panel li.ol-visible:not(.baselayer) {
border-left-color: var(--ls-accent);
}
.ol-layerswitcher .panel li.ol-layer-group > .li-content {
background: rgba(30, 26, 75, 0.03);
}
.ol-layerswitcher .panel li.ol-layer-group > .li-content label > span {
font-weight: 600;
}
/* Label and label text colour keep ol-ext's `padding-left: 1.7em` so the
checkbox pseudo sits in its expected slot. */
.ol-layerswitcher .panel li label {
color: var(--ls-text);
}
.ol-layerswitcher .panel li label > span {
color: var(--ls-text);
}
/* ============================================================================
CHECKBOX / RADIO pseudo-elements on the LABEL (input is 0×0, hidden)
Just recolour ol-ext's existing pseudos.
============================================================================ */
/* Box outline (unchecked) clearly visible against the panel background.
ol-ext sets it to a 2 px navy-blue border by default; we recolour to a
medium grey that's readable both at rest and on hover. */
.ol-layerswitcher [type="checkbox"] + label:before,
.ol-layerswitcher [type="radio"] + label:before {
border-color: #9ca3af !important; /* visible grey (Tailwind gray-400) */
border-width: 2px !important;
background: #fff !important;
transition: border-color var(--ls-transition), background var(--ls-transition);
}
.ol-layerswitcher .panel li:hover [type="checkbox"] + label:before,
.ol-layerswitcher .panel li:hover [type="radio"] + label:before {
border-color: var(--ls-accent) !important;
}
/* Checkbox checked accent-filled box, white tick.
IMPORTANT: these rules must use !important too, otherwise they lose to
the base unchecked rule (which also uses !important to override ol-ext). */
.ol-layerswitcher [type="checkbox"]:checked + label:before {
background: var(--ls-accent) !important;
border-color: var(--ls-accent) !important;
}
.ol-layerswitcher [type="checkbox"]:checked + label:after {
border-color: #fff !important;
box-shadow: none !important;
}
/* Radio checked — strong-blue dot */
.ol-layerswitcher [type="radio"]:checked + label:before {
border-color: var(--ls-accent-strong) !important;
}
.ol-layerswitcher [type="radio"]:checked + label:after {
background: var(--ls-accent-strong) !important;
}
/* Focus ring */
.ol-layerswitcher li:has(> div.li-content > .ol-visibility:focus) {
border-color: var(--ls-accent);
box-shadow: inset 0 0 0 1px var(--ls-accent);
}
/* ============================================================================
ACTION BUTTONS (Extent, Info, Trash, Expand/Collapse)
Each button has a solid `background: #369` from ol-ext stock + white pseudo
glyphs. We recolour the background to a softer accent the white pseudos
stay visible.
.expend-layers / .collapse-layers are special: their parent background is
already transparent and the pseudo BARS are #369. We recolour those bars
to our accent so the "+" / "" chevron uses the brand palette.
============================================================================ */
.ol-layerswitcher .layerInfo,
.ol-layerswitcher .layerTrash,
.ol-layerswitcher .layerExtent,
.ol-layerswitcher .layerup {
background: var(--ls-accent-strong);
border-radius: 4px;
transition: background var(--ls-transition), transform .12s;
}
.ol-layerswitcher .layerInfo:hover,
.ol-layerswitcher .layerTrash:hover,
.ol-layerswitcher .layerExtent:hover,
.ol-layerswitcher .layerup:hover {
background: var(--ls-accent);
transform: scale(1.08);
}
/* layerInfo round badge — keep ol-ext's `border-radius: 100%` */
.ol-layerswitcher .layerInfo {
border-radius: 100%;
}
/* Replace ol-ext's bar-drawn "+" / "" with the GeoView-style chevron SVG.
Approach:
Hide ol-ext's ::before/::after bars (we don't want them).
The chevron <div> hosts an injected <svg> (see drawlist handler).
The SVG uses stroke="currentColor", so a CSS `color` rule tints it.
.expend-layers is the collapsed state (chevron right ).
.collapse-layers is the expanded state rotate 90° to point down .
*/
.ol-layerswitcher .expend-layers:before,
.ol-layerswitcher .expend-layers:after,
.ol-layerswitcher .collapse-layers:before,
.ol-layerswitcher .collapse-layers:after {
display: none !important;
}
.ol-layerswitcher .expend-layers,
.ol-layerswitcher .collapse-layers {
background-color: transparent !important;
color: var(--ls-accent-strong);
display: inline-flex !important;
align-items: center;
justify-content: center;
transition: color var(--ls-transition), transform var(--ls-transition);
}
.ol-layerswitcher .expend-layers > svg.ls-chevron-svg,
.ol-layerswitcher .collapse-layers > svg.ls-chevron-svg {
pointer-events: none; /* keep the parent div the click target */
display: block;
}
.ol-layerswitcher .collapse-layers {
transform: rotate(90deg);
}
.ol-layerswitcher .expend-layers:hover,
.ol-layerswitcher .collapse-layers:hover {
color: var(--ls-accent);
}
/* ============================================================================
OPACITY SLIDER recolour, don't relayout
The slider lives inside `.li-content`, after the label.
============================================================================ */
.ol-layerswitcher .layerswitcher-opacity {
background: rgba(30, 26, 75, 0.10);
border-radius: 3px;
height: 4px;
margin-top: 4px;
}
.ol-layerswitcher .layerswitcher-opacity-cursor {
background: var(--ls-accent);
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(30, 26, 75, 0.25);
}
.ol-layerswitcher .layerswitcher-opacity-label {
color: var(--ls-text-dim);
font-family: var(--ls-font-mono);
font-size: 10px;
}
/* Hide the opacity slider for group and base layer rows they don't need it.
Correct selector: it lives inside `.li-content`, not as a direct child of li. */
.ol-layerswitcher .panel li.ol-layer-group > .li-content > .layerswitcher-opacity,
.ol-layerswitcher .panel li.ol-layer-group > .li-content > .layerswitcher-opacity-label,
.ol-layerswitcher .panel li.baselayer > .li-content > .layerswitcher-opacity,
.ol-layerswitcher .panel li.baselayer > .li-content > .layerswitcher-opacity-label {
display: none;
}
/* ============================================================================
INJECTED ELEMENTS populated by MapView's drawlist handler
============================================================================ */
/* Layer info subtitle text below the layer name (e.g. "Vector / Polygon").
Injected per-layer when `layer.get('typeDescription')` is set. */
.ls-layer-subtitle {
display: block;
font-family: var(--ls-font-mono);
font-size: 9.5px;
letter-spacing: 0.04em;
color: var(--ls-text-dim);
line-height: 1.3;
margin: 2px 0 4px 1.8em; /* indent under label, past the checkbox pseudo */
pointer-events: none;
}
/* Per-layer Remove button (×) only injected for layers with `removable: true`.
Sits next to ol-ext's own buttons inside `.ol-layerswitcher-buttons`. */
.ls-remove-btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin: 2px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--ls-text-dim);
cursor: pointer;
vertical-align: middle;
transition: background var(--ls-transition), color var(--ls-transition),
border-color var(--ls-transition);
}
.ls-remove-btn:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
color: #dc2626;
}
.ls-remove-btn svg {
display: block;
pointer-events: none;
}
/* Type-tag chip — appended INSIDE the label's <span>; floated tightly */
.ls-type-tag {
display: inline-block;
font-family: var(--ls-font-mono);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 4px;
line-height: 1.4;
margin-left: 6px;
vertical-align: middle;
}
.ls-type-tag-wms { background: rgba(0, 94, 184, 0.13); color: #005eb8; }
.ls-type-tag-wfs { background: rgba(168, 85, 247, 0.14); color: #8b3cd9; }
.ls-type-tag-xyz { background: rgba(255, 158, 27, 0.18); color: #b25c00; }
.ls-type-tag-vec { background: rgba(65, 182, 166, 0.15); color: #218778; }
.ls-type-tag-geo { background: rgba(0, 107, 63, 0.13); color: #006b3f; }
.ls-type-tag-base { background: rgba(30, 26, 75, 0.08); color: #1e1a4b; }
/* Header "active count" badge — sits at top of panel-container, before the <ul> */
.ls-active-badge {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px 8px;
border-bottom: 1px solid var(--ls-border);
background: var(--ls-surface-2);
}
.ls-active-badge-title {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ls-text-muted);
}
.ls-active-badge-count {
font-family: var(--ls-font-mono);
font-size: 10px;
color: var(--ls-accent);
background: rgba(65, 182, 166, 0.12);
border: 1px solid rgba(65, 182, 166, 0.30);
border-radius: 20px;
padding: 1px 8px;
font-weight: 600;
white-space: nowrap;
}
/* Footer row — sits at bottom of panel-container, after the <ul> */
.ls-footer-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--ls-border);
background: var(--ls-surface-2);
/* Defensive: never let the row contents overflow the panel width */
min-width: 0;
}
.ls-footer-note {
font-family: var(--ls-font-mono);
font-size: 9.5px;
color: var(--ls-text-dim);
/* Truncate gracefully if the count grows long */
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* IMPORTANT: OpenLayers' own stylesheet forces every <button> inside an
.ol-control to a fixed 1.375em × 1.375em square (ol/ol.css `.ol-control
button`). Without these overrides our text-bearing footer button shrinks
to ~18 px and the label spills out (looking like an overlapping checkbox). */
.ls-footer-btn {
flex: 0 0 auto;
display: inline-flex !important;
align-items: center;
justify-content: center;
width: auto !important;
height: auto !important;
min-width: 0;
line-height: 1 !important;
margin: 0 !important;
font-size: 11px;
font-weight: 600;
font-family: var(--ls-font);
color: var(--ls-text-muted) !important;
background: transparent !important;
border: 1px solid var(--ls-border-strong) !important;
border-radius: 6px !important;
padding: 5px 12px !important;
cursor: pointer;
white-space: nowrap;
transition: background var(--ls-transition), color var(--ls-transition),
border-color var(--ls-transition);
}
.ls-footer-btn:hover {
background: rgba(65, 182, 166, 0.10) !important;
border-color: var(--ls-accent) !important;
color: var(--ls-accent) !important;
}
/* ============================================================================
BASE-MAP PICKER (separate floating button + thumbnail panel)
============================================================================ */
/* Toggle button stacked directly above the My Location control.
LUSPA overrides ol-ext's default in index.html:
.ol-geobt { bottom: 90 px; right: 10 px; }
.ol-geobt button { min-width: 44 px; min-height: 44 px; }
So the My Location button occupies 90 px ~140 px from the bottom.
We anchor this button at bottom: 144 px (8 px gap above) and use the
same right: 10 px so they line up vertically on the right edge. */
.ls-basemap-toggle {
position: absolute;
right: 10px;
bottom: 144px; /* 90 px (anchor) + 44 px (button) + 10 px gap */
z-index: 1000; /* above ol-ext controls (which sit at ~1) */
width: 44px; /* match LUSPA's .ol-geobt button (44 × 44) */
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: var(--ls-surface);
border: 1px solid var(--ls-border);
border-radius: var(--ls-radius-sm);
color: var(--ls-text);
cursor: pointer;
backdrop-filter: blur(14px);
box-shadow: var(--ls-shadow-sm);
transition: background var(--ls-transition), color var(--ls-transition),
border-color var(--ls-transition);
}
/* Bump the SVG icon slightly to balance the larger 44 × 44 button */
.ls-basemap-toggle svg {
width: 22px;
height: 22px;
}
.ls-basemap-toggle:hover,
.ls-basemap-toggle.active {
background: rgba(65, 182, 166, 0.10);
border-color: var(--ls-accent);
color: var(--ls-accent);
}
.ls-basemap-toggle svg { display: block; pointer-events: none; }
/* The slide-out picker panel sits to the LEFT of the button (since the
button is anchored to the right edge) */
.ls-basemap-panel {
position: absolute;
right: 62px; /* 10 px (button right) + 44 px (button width) + 8 px gap */
bottom: 144px; /* match the button */
z-index: 1000;
width: 250px;
background: var(--ls-surface);
border: 1px solid var(--ls-border);
border-radius: var(--ls-radius);
box-shadow: var(--ls-shadow);
backdrop-filter: blur(16px);
padding: 10px 12px;
opacity: 0;
pointer-events: none;
transform: translateX(6px);
transition: opacity .14s ease, transform .14s ease;
}
.ls-basemap-panel.open {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
/* Touch mode keeps the same offsets because LUSPA's custom .ol-geobt button
is already a 44 × 44 px touch target. */
.ls-basemap-header {
font-family: var(--ls-font);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ls-text-muted);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--ls-border);
}
.ls-basemap-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.ls-bm-chip {
position: relative;
cursor: pointer;
display: block;
margin: 0;
}
.ls-bm-chip input[type=radio] {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.ls-bm-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 5px 5px 6px;
border: 1px solid var(--ls-border);
border-radius: var(--ls-radius-sm);
background: rgba(30, 26, 75, 0.02);
cursor: pointer;
transition: background var(--ls-transition), border-color var(--ls-transition);
}
.ls-bm-chip:hover .ls-bm-label {
border-color: var(--ls-accent);
}
.ls-bm-chip input:checked + .ls-bm-label {
background: rgba(65, 182, 166, 0.10);
border-color: var(--ls-accent);
}
.ls-bm-thumb {
width: 100%;
height: 34px;
border-radius: 4px;
}
.ls-bm-name {
font-family: var(--ls-font);
font-size: 10.5px;
font-weight: 500;
color: var(--ls-text-muted);
text-align: center;
line-height: 1.2;
}
.ls-bm-chip input:checked + .ls-bm-label .ls-bm-name {
color: var(--ls-accent);
font-weight: 700;
}
/* ============================================================================
DARK MODE
============================================================================ */
body.dark-mode {
--ls-text: #e4e9f4;
--ls-text-muted: #98a2b3;
--ls-text-dim: #6e7e99;
--ls-surface: rgba(14, 18, 28, 0.94);
--ls-surface-2: #14182a;
--ls-border: rgba(255, 255, 255, 0.10);
--ls-border-strong: rgba(255, 255, 255, 0.22);
}
body.dark-mode .ol-layerswitcher .panel li.ol-layer-group > .li-content {
background: rgba(255, 255, 255, 0.04);
}
body.dark-mode .ol-layerswitcher [type="radio"] + label:before,
body.dark-mode .ol-layerswitcher [type="checkbox"] + label:before {
background: rgba(255, 255, 255, 0.06) !important;
border-color: #6b7280 !important; /* lighter grey in dark mode */
}
body.dark-mode .ls-type-tag-base {
background: rgba(255, 255, 255, 0.08);
color: #e4e9f4;
}