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>
3
.gitignore
vendored
@ -102,3 +102,6 @@ coverage/
|
|||||||
.eslintcache
|
.eslintcache
|
||||||
.parcel-cache/
|
.parcel-cache/
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Microsoft Office lock / owner files (e.g. ~$Document.docx)
|
||||||
|
~$*
|
||||||
|
|||||||
BIN
Adding_DXF_Support.docx
Normal file
BIN
LUPMIS2_OSM_3D_Buildings_Concept.docx
Normal file
BIN
LUPMIS2_Reusable_Mapping_Concept.docx
Normal file
24
dist/.htaccess
vendored
Normal 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>
|
||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
636
dist/assets/index-B4XzHtZX.js
vendored
1
dist/assets/index-B4XzHtZX.js.map
vendored
707
dist/assets/index-DJ2WL3EC.js
vendored
Normal file
1
dist/assets/index-DJ2WL3EC.js.map
vendored
Normal file
2
dist/assets/ol-ext-BR0zF6aa.js
vendored
Normal file
1
dist/assets/ol-ext-BR0zF6aa.js.map
vendored
Normal file
2
dist/assets/ol-ext-CSk2UikI.js
vendored
1
dist/assets/ol-ext-CSk2UikI.js.map
vendored
573
dist/assets/openlayers-CUDtI0S3.js
vendored
1
dist/assets/openlayers-CUDtI0S3.js.map
vendored
573
dist/assets/openlayers-CvK8xBSr.js
vendored
Normal file
1
dist/assets/openlayers-CvK8xBSr.js.map
vendored
Normal file
2
dist/assets/pdf-export-Vpiz8VA4.js
vendored
@ -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
|
|
||||||
1
dist/assets/pdf-export-Vpiz8VA4.js.map
vendored
2
dist/assets/pdf-export-vzOHm8wb.js
vendored
Normal 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
|
||||||
1
dist/assets/pdf-export-vzOHm8wb.js.map
vendored
Normal file
338
dist/index.html
vendored
@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="app-icons/luspa-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="app-icons/luspa-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.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 -->
|
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||||
<style>
|
<style>
|
||||||
@ -549,11 +549,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main container - full height.
|
/* Main container - full height.
|
||||||
100dvh accounts for mobile browser chrome and OS nav bars.
|
100svh = the "small" viewport height (browser toolbar shown). Because
|
||||||
Falls back to 100vh for older browsers. */
|
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 {
|
.app-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -953,6 +957,120 @@
|
|||||||
background-color: var(--primary-hover) !important;
|
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-ext SearchNominatim styling */
|
||||||
.ol-search {
|
.ol-search {
|
||||||
top: 10px !important;
|
top: 10px !important;
|
||||||
@ -1169,6 +1287,42 @@
|
|||||||
transform: scale(0.95);
|
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 styling — white card with blue-strong header */
|
||||||
.offcanvas {
|
.offcanvas {
|
||||||
background-color: var(--background) !important;
|
background-color: var(--background) !important;
|
||||||
@ -1358,15 +1512,73 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
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>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-B4XzHtZX.js"></script>
|
<script type="module" crossorigin src="/assets/index-DJ2WL3EC.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-CUDtI0S3.js">
|
<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/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/openlayers-BtPuoxOl.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.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/ol-ext-BgKrOIxx.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BxlvFVPW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
@ -1374,16 +1586,45 @@
|
|||||||
<nav class="navbar py-2">
|
<nav class="navbar py-2">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1">
|
<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>
|
</span>
|
||||||
<button type="button"
|
|
||||||
class="location-count-btn"
|
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
||||||
data-bs-toggle="offcanvas"
|
Satellites show "—" on the web (the Geolocation API does not expose
|
||||||
data-bs-target="#offcanvasRight"
|
them); a native build can populate the field. -->
|
||||||
aria-controls="offcanvasRight"
|
<div class="gps-readout" id="gps-readout" title="Live GPS status">
|
||||||
title="View saved locations">
|
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
||||||
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
|
<span class="gps-readout-body">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -1568,6 +1809,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Offcanvas - Saved Locations Panel -->
|
<!-- 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 offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel">
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
|
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
|
||||||
@ -1655,6 +1951,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<div class="row g-3">
|
<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 -->
|
<!-- Fieldwork Mode -->
|
||||||
<div class="col-12 col-md-6 col-lg-4">
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -1721,6 +2020,7 @@
|
|||||||
<option value="googlesat">Google Sat</option>
|
<option value="googlesat">Google Sat</option>
|
||||||
<option value="carto-light">Carto Light</option>
|
<option value="carto-light">Carto Light</option>
|
||||||
<option value="carto-dark">Carto Dark</option>
|
<option value="carto-dark">Carto Dark</option>
|
||||||
|
<option value="none">None (no base map)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
116
dist/index.php
vendored
Normal 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
@ -10,49 +10,49 @@
|
|||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-72x72.png",
|
"src": "./app-icons/luspa-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-96x96.png",
|
"src": "./app-icons/luspa-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-128x128.png",
|
"src": "./app-icons/luspa-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-144x144.png",
|
"src": "./app-icons/luspa-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-152x152.png",
|
"src": "./app-icons/luspa-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-192x192.png",
|
"src": "./app-icons/luspa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-384x384.png",
|
"src": "./app-icons/luspa-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-512x512.png",
|
"src": "./app-icons/luspa-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
101
dist/sw.js
vendored
@ -16,7 +16,20 @@
|
|||||||
// prevent Safari memory-pressure reloads.
|
// prevent Safari memory-pressure reloads.
|
||||||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||||||
// downloads (Phase 2). Other providers stay at 1500.
|
// 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 SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
@ -164,12 +177,18 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- OTHER ROUTES (unchanged) -----
|
// ----- OTHER ROUTES -----
|
||||||
if (isApiRequest(url)) {
|
if (isApiRequest(url)) {
|
||||||
event.respondWith(networkFirst(request, API_CACHE));
|
event.respondWith(networkFirst(request, API_CACHE));
|
||||||
} else if (isModuleAsset(url)) {
|
} else if (isModuleAsset(url)) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
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)) {
|
} 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));
|
event.respondWith(cacheFirst(request, SHELL_CACHE));
|
||||||
}
|
}
|
||||||
// Let other requests pass through to network
|
// Let other requests pass through to network
|
||||||
@ -220,14 +239,35 @@ function isModuleAsset(url) {
|
|||||||
return url.pathname.startsWith('/modules/');
|
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) {
|
function isAppAsset(url) {
|
||||||
return url.origin === self.location.origin &&
|
if (url.origin !== self.location.origin) return false;
|
||||||
(url.pathname.endsWith('.html') ||
|
if (isHtmlAsset(url)) return false; // HTML handled separately
|
||||||
url.pathname.endsWith('.css') ||
|
return (
|
||||||
url.pathname.endsWith('.js') ||
|
url.pathname.endsWith('.css') ||
|
||||||
url.pathname.endsWith('.wasm') ||
|
url.pathname.endsWith('.js') ||
|
||||||
url.pathname.endsWith('.json') ||
|
url.pathname.endsWith('.wasm') ||
|
||||||
url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/));
|
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
|
// 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) => {
|
self.addEventListener('message', (event) => {
|
||||||
const { type, payload } = event.data || {};
|
const { type, payload, cacheName } = event.data || {};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'SKIP_WAITING':
|
case 'SKIP_WAITING':
|
||||||
@ -382,22 +436,31 @@ self.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'GET_CACHE_STATUS':
|
case 'GET_CACHE_STATUS':
|
||||||
getCacheStatus().then((status) => {
|
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
|
||||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||||||
case 'GET_TILE_STATS':
|
case 'GET_TILE_STATS':
|
||||||
getTileStats().then((stats) => {
|
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
|
||||||
event.source.postMessage({ type: 'TILE_STATS', stats });
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'CLEAR_TILE_CACHES':
|
case 'CLEAR_TILE_CACHES':
|
||||||
clearTileCaches().then(() => {
|
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
|
||||||
event.source.postMessage({ 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;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
330
index.html
@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="app-icons/luspa-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="app-icons/luspa-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.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 -->
|
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||||
<style>
|
<style>
|
||||||
@ -549,11 +549,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main container - full height.
|
/* Main container - full height.
|
||||||
100dvh accounts for mobile browser chrome and OS nav bars.
|
100svh = the "small" viewport height (browser toolbar shown). Because
|
||||||
Falls back to 100vh for older browsers. */
|
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 {
|
.app-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -953,6 +957,120 @@
|
|||||||
background-color: var(--primary-hover) !important;
|
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-ext SearchNominatim styling */
|
||||||
.ol-search {
|
.ol-search {
|
||||||
top: 10px !important;
|
top: 10px !important;
|
||||||
@ -1169,6 +1287,42 @@
|
|||||||
transform: scale(0.95);
|
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 styling — white card with blue-strong header */
|
||||||
.offcanvas {
|
.offcanvas {
|
||||||
background-color: var(--background) !important;
|
background-color: var(--background) !important;
|
||||||
@ -1358,6 +1512,64 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -1366,16 +1578,45 @@
|
|||||||
<nav class="navbar py-2">
|
<nav class="navbar py-2">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1">
|
<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>
|
</span>
|
||||||
<button type="button"
|
|
||||||
class="location-count-btn"
|
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
||||||
data-bs-toggle="offcanvas"
|
Satellites show "—" on the web (the Geolocation API does not expose
|
||||||
data-bs-target="#offcanvasRight"
|
them); a native build can populate the field. -->
|
||||||
aria-controls="offcanvasRight"
|
<div class="gps-readout" id="gps-readout" title="Live GPS status">
|
||||||
title="View saved locations">
|
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
||||||
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
|
<span class="gps-readout-body">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -1560,6 +1801,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Offcanvas - Saved Locations Panel -->
|
<!-- 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 offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel">
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
|
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
|
||||||
@ -1647,6 +1943,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<div class="row g-3">
|
<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 -->
|
<!-- Fieldwork Mode -->
|
||||||
<div class="col-12 col-md-6 col-lg-4">
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -1713,6 +2012,7 @@
|
|||||||
<option value="googlesat">Google Sat</option>
|
<option value="googlesat">Google Sat</option>
|
||||||
<option value="carto-light">Carto Light</option>
|
<option value="carto-light">Carto Light</option>
|
||||||
<option value="carto-dark">Carto Dark</option>
|
<option value="carto-dark">Carto Dark</option>
|
||||||
|
<option value="none">None (no base map)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
301
main.js
@ -72,7 +72,7 @@ async function getShp() {
|
|||||||
import { MapTools } from './src/components/MapTools.js';
|
import { MapTools } from './src/components/MapTools.js';
|
||||||
|
|
||||||
// PWA module (registers Service Worker, handles install/offline)
|
// 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 {
|
import {
|
||||||
BASEMAP_TEMPLATES,
|
BASEMAP_TEMPLATES,
|
||||||
GHANA_EXTENT_3857,
|
GHANA_EXTENT_3857,
|
||||||
@ -82,7 +82,11 @@ import {
|
|||||||
} from './src/offlineTiles.js';
|
} from './src/offlineTiles.js';
|
||||||
|
|
||||||
// Remote database API (PostgreSQL backend)
|
// 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)
|
// Map instance (global for access across functions)
|
||||||
let mapView = null;
|
let mapView = null;
|
||||||
@ -118,6 +122,9 @@ async function initApp() {
|
|||||||
// Initialize map measurement tools
|
// Initialize map measurement tools
|
||||||
mapTools = new MapTools(mapView.getMap());
|
mapTools = new MapTools(mapView.getMap());
|
||||||
|
|
||||||
|
// Wire up GPS live-position + trail recording
|
||||||
|
initGpsTracking();
|
||||||
|
|
||||||
// Handle measurement results
|
// Handle measurement results
|
||||||
mapTools.onMeasureComplete((result) => {
|
mapTools.onMeasureComplete((result) => {
|
||||||
console.log('[MapTools] Measurement complete:', result);
|
console.log('[MapTools] Measurement complete:', result);
|
||||||
@ -383,6 +390,9 @@ async function initApp() {
|
|||||||
// 13. Offline-download dialog
|
// 13. Offline-download dialog
|
||||||
initOfflineDownloadDialog();
|
initOfflineDownloadDialog();
|
||||||
|
|
||||||
|
// 14. Account card (signed-in user + sign-out)
|
||||||
|
initAccountCard();
|
||||||
|
|
||||||
console.log('[App] Initialized successfully');
|
console.log('[App] Initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1155,6 +1165,7 @@ async function loadDistrictBoundary() {
|
|||||||
strokeColor: '#e11d48',
|
strokeColor: '#e11d48',
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
fillColor: 'rgba(225,29,72,0.08)',
|
fillColor: 'rgba(225,29,72,0.08)',
|
||||||
|
typeDescription: 'Vector / Polygon',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Target group: Administration (id 1), fall back to root overlay group
|
// Target group: Administration (id 1), fall back to root overlay group
|
||||||
@ -1248,6 +1259,7 @@ async function loadCollectorZones() {
|
|||||||
strokeColor: '#7c3aed',
|
strokeColor: '#7c3aed',
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
fillColor: 'rgba(124,58,237,0.12)',
|
fillColor: 'rgba(124,58,237,0.12)',
|
||||||
|
typeDescription: 'Vector / Polygon',
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
|
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
|
||||||
@ -1386,6 +1398,7 @@ async function loadParcels() {
|
|||||||
strokeColor: '#0ea5e9',
|
strokeColor: '#0ea5e9',
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
fillColor: 'rgba(14,165,233,0.12)',
|
fillColor: 'rgba(14,165,233,0.12)',
|
||||||
|
typeDescription: 'Vector / Polygon',
|
||||||
};
|
};
|
||||||
|
|
||||||
const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null;
|
const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null;
|
||||||
@ -1524,6 +1537,7 @@ async function loadBuildingFootprints() {
|
|||||||
strokeColor: '#8b6f47',
|
strokeColor: '#8b6f47',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
fillColor: 'rgba(139,111,71,0.18)',
|
fillColor: 'rgba(139,111,71,0.18)',
|
||||||
|
typeDescription: 'Vector / Polygon',
|
||||||
};
|
};
|
||||||
|
|
||||||
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
|
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
|
||||||
@ -1684,6 +1698,7 @@ async function loadContoursHillshade() {
|
|||||||
const contoursStyle = {
|
const contoursStyle = {
|
||||||
strokeColor: '#78716c', // warm grey — traditional contour colour
|
strokeColor: '#78716c', // warm grey — traditional contour colour
|
||||||
strokeWidth: 0.8,
|
strokeWidth: 0.8,
|
||||||
|
typeDescription: 'Vector / Line',
|
||||||
fillColor: 'rgba(0,0,0,0)',
|
fillColor: 'rgba(0,0,0,0)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1763,6 +1778,7 @@ async function loadOSMRoads() {
|
|||||||
lineCasingColor: '#000000', // outer — black casing
|
lineCasingColor: '#000000', // outer — black casing
|
||||||
lineCasingWidth: 3.5,
|
lineCasingWidth: 3.5,
|
||||||
fillColor: 'rgba(0,0,0,0)',
|
fillColor: 'rgba(0,0,0,0)',
|
||||||
|
typeDescription: 'Vector / Line',
|
||||||
};
|
};
|
||||||
|
|
||||||
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
|
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);
|
const layer = mapView?.addGeoJSONLayer(fc, layerName, IMPORT_STYLE);
|
||||||
if (layer) {
|
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);
|
importedFileLayers.push(layer);
|
||||||
totalFeatures += fc.features.length;
|
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
|
// Toast-style alerts (auto-dismiss) + persistent log
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -2608,6 +2728,16 @@ function initDefaultBasemap() {
|
|||||||
mapView?.setBaseMap(key);
|
mapView?.setBaseMap(key);
|
||||||
console.log('[Settings] Default base map:', 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)
|
.filter((p) => p.count > 0)
|
||||||
.map((p) => `
|
.map((p) => `
|
||||||
<tr>
|
<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">${p.count.toLocaleString()} / ${p.limit.toLocaleString()}</td>
|
||||||
<td class="text-end">${fmtBytes(p.estBytes)}</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('');
|
</tr>`).join('');
|
||||||
|
|
||||||
let storageNote = '';
|
let storageNote = '';
|
||||||
@ -2696,10 +2833,31 @@ function initOfflineTileCache() {
|
|||||||
<th>Base map</th>
|
<th>Base map</th>
|
||||||
<th class="text-end">Cached / limit</th>
|
<th class="text-end">Cached / limit</th>
|
||||||
<th class="text-end">Approx. size</th>
|
<th class="text-end">Approx. size</th>
|
||||||
|
<th class="text-end pe-0" style="width:2.2rem;"></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>${storageNote}`;
|
</table>${storageNote}`;
|
||||||
clearBtn.disabled = false;
|
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 {
|
} finally {
|
||||||
refreshInFlight = null;
|
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
|
// Start Application
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
24
public/.htaccess
Normal 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>
|
||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
116
public/index.php
Normal 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;
|
||||||
@ -10,49 +10,49 @@
|
|||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-72x72.png",
|
"src": "./app-icons/luspa-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-96x96.png",
|
"src": "./app-icons/luspa-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-128x128.png",
|
"src": "./app-icons/luspa-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-144x144.png",
|
"src": "./app-icons/luspa-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-152x152.png",
|
"src": "./app-icons/luspa-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-192x192.png",
|
"src": "./app-icons/luspa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-384x384.png",
|
"src": "./app-icons/luspa-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icons/luspa-512x512.png",
|
"src": "./app-icons/luspa-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
101
public/sw.js
@ -16,7 +16,20 @@
|
|||||||
// prevent Safari memory-pressure reloads.
|
// prevent Safari memory-pressure reloads.
|
||||||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||||||
// downloads (Phase 2). Other providers stay at 1500.
|
// 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 SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
@ -164,12 +177,18 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- OTHER ROUTES (unchanged) -----
|
// ----- OTHER ROUTES -----
|
||||||
if (isApiRequest(url)) {
|
if (isApiRequest(url)) {
|
||||||
event.respondWith(networkFirst(request, API_CACHE));
|
event.respondWith(networkFirst(request, API_CACHE));
|
||||||
} else if (isModuleAsset(url)) {
|
} else if (isModuleAsset(url)) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
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)) {
|
} 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));
|
event.respondWith(cacheFirst(request, SHELL_CACHE));
|
||||||
}
|
}
|
||||||
// Let other requests pass through to network
|
// Let other requests pass through to network
|
||||||
@ -220,14 +239,35 @@ function isModuleAsset(url) {
|
|||||||
return url.pathname.startsWith('/modules/');
|
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) {
|
function isAppAsset(url) {
|
||||||
return url.origin === self.location.origin &&
|
if (url.origin !== self.location.origin) return false;
|
||||||
(url.pathname.endsWith('.html') ||
|
if (isHtmlAsset(url)) return false; // HTML handled separately
|
||||||
url.pathname.endsWith('.css') ||
|
return (
|
||||||
url.pathname.endsWith('.js') ||
|
url.pathname.endsWith('.css') ||
|
||||||
url.pathname.endsWith('.wasm') ||
|
url.pathname.endsWith('.js') ||
|
||||||
url.pathname.endsWith('.json') ||
|
url.pathname.endsWith('.wasm') ||
|
||||||
url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/));
|
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
|
// 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) => {
|
self.addEventListener('message', (event) => {
|
||||||
const { type, payload } = event.data || {};
|
const { type, payload, cacheName } = event.data || {};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'SKIP_WAITING':
|
case 'SKIP_WAITING':
|
||||||
@ -382,22 +436,31 @@ self.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'GET_CACHE_STATUS':
|
case 'GET_CACHE_STATUS':
|
||||||
getCacheStatus().then((status) => {
|
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
|
||||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||||||
case 'GET_TILE_STATS':
|
case 'GET_TILE_STATS':
|
||||||
getTileStats().then((stats) => {
|
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
|
||||||
event.source.postMessage({ type: 'TILE_STATS', stats });
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'CLEAR_TILE_CACHES':
|
case 'CLEAR_TILE_CACHES':
|
||||||
clearTileCaches().then(() => {
|
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
|
||||||
event.source.postMessage({ 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;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import TileWMS from 'ol/source/TileWMS';
|
|||||||
import OSM from 'ol/source/OSM';
|
import OSM from 'ol/source/OSM';
|
||||||
import XYZ from 'ol/source/XYZ';
|
import XYZ from 'ol/source/XYZ';
|
||||||
import { fromLonLat, toLonLat } from 'ol/proj';
|
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 Feature from 'ol/Feature';
|
||||||
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
|
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
|
||||||
import GeoJSON from 'ol/format/GeoJSON';
|
import GeoJSON from 'ol/format/GeoJSON';
|
||||||
@ -42,9 +42,6 @@ import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../u
|
|||||||
// ol-ext LayerSwitcher
|
// ol-ext LayerSwitcher
|
||||||
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
|
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
|
||||||
|
|
||||||
// ol-ext GeolocationButton
|
|
||||||
import GeolocationButton from 'ol-ext/control/GeolocationButton';
|
|
||||||
|
|
||||||
// ol-ext SearchNominatim
|
// ol-ext SearchNominatim
|
||||||
import SearchNominatim from 'ol-ext/control/SearchNominatim';
|
import SearchNominatim from 'ol-ext/control/SearchNominatim';
|
||||||
|
|
||||||
@ -89,6 +86,7 @@ import { showToast } from '../toast.js';
|
|||||||
// CSS imports
|
// CSS imports
|
||||||
import 'ol/ol.css';
|
import 'ol/ol.css';
|
||||||
import 'ol-ext/dist/ol-ext.css';
|
import 'ol-ext/dist/ol-ext.css';
|
||||||
|
import '../styles/layerswitcher.css';
|
||||||
|
|
||||||
export class MapView {
|
export class MapView {
|
||||||
constructor(targetId, options = {}) {
|
constructor(targetId, options = {}) {
|
||||||
@ -150,11 +148,13 @@ export class MapView {
|
|||||||
// Create base layers group
|
// Create base layers group
|
||||||
const baseLayers = this.createBaseLayers(options.basemap || 'topo');
|
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({
|
this.markersLayer = new VectorLayer({
|
||||||
title: 'Markers',
|
title: 'Markers',
|
||||||
source: this.markerSource,
|
source: this.markerSource,
|
||||||
style: (feature) => this.getFeatureStyle(feature),
|
style: (feature) => this.getFeatureStyle(feature),
|
||||||
|
visible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Overlay layers group (for remote data like boundaries)
|
// Overlay layers group (for remote data like boundaries)
|
||||||
@ -193,38 +193,48 @@ export class MapView {
|
|||||||
});
|
});
|
||||||
this.map.addControl(layerSwitcher);
|
this.map.addControl(layerSwitcher);
|
||||||
|
|
||||||
// Inject "Add Layer" button into the "External Source" group header
|
// Apply the LUSPA branded icon to the LayerSwitcher's collapse button.
|
||||||
layerSwitcher.on('drawlist', (evt) => {
|
// Done in JS so the URL respects Vite's BASE_URL — survives deployment
|
||||||
const groupTitle = (evt.layer.get('title') || '').toLowerCase();
|
// under any sub-path.
|
||||||
if (groupTitle.includes('external')) {
|
// NOTE: folder name is `app-icons`, NOT `icons` — Apache aliases `/icons/`
|
||||||
// Store reference to the actual External group for later use
|
// by default to its own directory-listing thumbnails, which would
|
||||||
this._externalSourceGroup = evt.layer;
|
// intercept this request server-side.
|
||||||
const btnBar = evt.li.querySelector('.ol-layerswitcher-buttons');
|
queueMicrotask(() => {
|
||||||
if (btnBar && !btnBar.querySelector('.ol-add-layer')) {
|
const btn = layerSwitcher.element?.querySelector(':scope > button');
|
||||||
const addBtn = document.createElement('span');
|
if (btn) {
|
||||||
addBtn.className = 'ol-add-layer';
|
const baseUrl = (import.meta.env?.BASE_URL || '/').replace(/\/?$/, '/');
|
||||||
addBtn.title = 'Add external layer';
|
btn.style.backgroundImage = `url('${baseUrl}app-icons/luspa-72x72.png')`;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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)
|
// Create the add-layer dialog (hidden by default)
|
||||||
this._createAddLayerDialog();
|
this._createAddLayerDialog();
|
||||||
|
|
||||||
@ -240,16 +250,13 @@ export class MapView {
|
|||||||
});
|
});
|
||||||
this.map.addControl(this.scaleBar);
|
this.map.addControl(this.scaleBar);
|
||||||
|
|
||||||
// Add GeolocationButton control
|
// GPS rendering layers (current position + recorded trail) and the
|
||||||
const geolocationButton = new GeolocationButton({
|
// expandable "My Location" control (Locate Me + Record Trail sub-buttons).
|
||||||
title: 'My Location',
|
this._initGpsRendering();
|
||||||
delay: 3000, // Auto-center duration
|
this._createLocationControl();
|
||||||
zoom: 16, // Zoom level when centering on location
|
|
||||||
});
|
|
||||||
this.map.addControl(geolocationButton);
|
|
||||||
|
|
||||||
// Store reference for external access
|
// Dedicated base-map picker — sits above the My Location button
|
||||||
this.geolocationButton = geolocationButton;
|
this._createBaseMapPicker();
|
||||||
|
|
||||||
// Add SearchNominatim control
|
// Add SearchNominatim control
|
||||||
const searchNominatim = new SearchNominatim({
|
const searchNominatim = new SearchNominatim({
|
||||||
@ -420,6 +427,9 @@ export class MapView {
|
|||||||
// inside the EditBar so they appear inline.
|
// inside the EditBar so they appear inline.
|
||||||
const extraBar = new Bar({
|
const extraBar = new Bar({
|
||||||
group: true,
|
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: [
|
controls: [
|
||||||
new Button({
|
new Button({
|
||||||
html: '<i class="bi bi-arrow-counterclockwise"></i>',
|
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);
|
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.
|
// 6b. SnapGuides — shows alignment guides while drawing.
|
||||||
// Uses VectorImageLayer for GPU-friendly canvas rendering instead of
|
// Uses VectorImageLayer for GPU-friendly canvas rendering instead of
|
||||||
// re-creating individual SVG elements on every guide update.
|
// re-creating individual SVG elements on every guide update.
|
||||||
@ -2378,10 +2401,10 @@ export class MapView {
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
// Return LayerGroup for LayerSwitcher
|
// Return LayerGroup. Hidden from the main LayerSwitcher — base maps are
|
||||||
// Note: ol-ext LayerSwitcher iterates layers in reverse — the LAST item
|
// managed by the dedicated base-map picker (see _createBaseMapPicker)
|
||||||
// in this array appears at the TOP of the base-map list in the UI.
|
// accessed via the layers-stack icon above the My Location button.
|
||||||
return new LayerGroup({
|
const baseGroup = new LayerGroup({
|
||||||
title: 'Base Maps',
|
title: 'Base Maps',
|
||||||
layers: [
|
layers: [
|
||||||
cartoLightLayer,
|
cartoLightLayer,
|
||||||
@ -2390,30 +2413,355 @@ export class MapView {
|
|||||||
osmCycleLayer,
|
osmCycleLayer,
|
||||||
googleLayer,
|
googleLayer,
|
||||||
osmLayer,
|
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.
|
* Switch the active base map by key.
|
||||||
* Sets exactly one base layer visible; hides all others.
|
* Sets exactly one base layer visible; hides all others.
|
||||||
*
|
*
|
||||||
* @param {string} key Basemap key: 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
|
* @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
|
* @returns {boolean} true if the key matched a known base layer (or 'none')
|
||||||
*/
|
*/
|
||||||
setBaseMap(key) {
|
setBaseMap(key) {
|
||||||
if (!this._baseMapLayers) return false;
|
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;
|
let matched = false;
|
||||||
for (const layer of this._baseMapLayers) {
|
for (const layer of this._baseMapLayers) {
|
||||||
const on = layer.get('basemapKey') === key;
|
const on = layer.get('basemapKey') === key;
|
||||||
layer.setVisible(on);
|
layer.setVisible(on);
|
||||||
if (on) matched = true;
|
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;
|
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)
|
* Get style for a feature (handles selection state)
|
||||||
*/
|
*/
|
||||||
@ -2845,6 +3193,40 @@ export class MapView {
|
|||||||
source: source,
|
source: source,
|
||||||
style: layerStyle,
|
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;
|
const group = targetGroup || this.overlayGroup;
|
||||||
group.getLayers().push(layer);
|
group.getLayers().push(layer);
|
||||||
@ -2925,6 +3307,8 @@ export class MapView {
|
|||||||
opacity: options.opacity !== undefined ? options.opacity : 1,
|
opacity: options.opacity !== undefined ? options.opacity : 1,
|
||||||
zIndex: options.zIndex,
|
zIndex: options.zIndex,
|
||||||
});
|
});
|
||||||
|
wmsLayer.set('typeTag', 'WMS');
|
||||||
|
wmsLayer.set('typeDescription', 'WMS / Raster');
|
||||||
|
|
||||||
// Show toast on tile load errors (e.g. server rejects request)
|
// Show toast on tile load errors (e.g. server rejects request)
|
||||||
wmsSource.on('tileloaderror', () => {
|
wmsSource.on('tileloaderror', () => {
|
||||||
@ -2990,6 +3374,8 @@ export class MapView {
|
|||||||
opacity: options.opacity !== undefined ? options.opacity : 1,
|
opacity: options.opacity !== undefined ? options.opacity : 1,
|
||||||
zIndex: options.zIndex,
|
zIndex: options.zIndex,
|
||||||
});
|
});
|
||||||
|
xyzLayer.set('typeTag', 'XYZ');
|
||||||
|
xyzLayer.set('typeDescription', 'XYZ / Tile');
|
||||||
|
|
||||||
// Show toast on tile load errors
|
// Show toast on tile load errors
|
||||||
xyzSource.on('tileloaderror', () => {
|
xyzSource.on('tileloaderror', () => {
|
||||||
@ -3273,11 +3659,336 @@ export class MapView {
|
|||||||
return;
|
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);
|
group.getLayers().push(layer);
|
||||||
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
|
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
|
||||||
console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`);
|
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
|
// Online-Only Layer Helper
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
156
src/database.js
@ -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
|
// Create indexes
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
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_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
|
// Final verification
|
||||||
const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
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 {
|
export default {
|
||||||
sql,
|
sql,
|
||||||
dbReady,
|
dbReady,
|
||||||
@ -1046,6 +1194,14 @@ export default {
|
|||||||
getLocalBuildingFootprints,
|
getLocalBuildingFootprints,
|
||||||
saveOSMRoads,
|
saveOSMRoads,
|
||||||
getLocalOSMRoads,
|
getLocalOSMRoads,
|
||||||
|
createGpsTrail,
|
||||||
|
addGpsTrailPoint,
|
||||||
|
finishGpsTrail,
|
||||||
|
getUnsyncedGpsTrails,
|
||||||
|
getGpsTrailPoints,
|
||||||
|
markGpsTrailSynced,
|
||||||
|
getGpsTrails,
|
||||||
|
deleteGpsTrail,
|
||||||
CACHED_LAYER_TABLES,
|
CACHED_LAYER_TABLES,
|
||||||
isCachedLayerTable,
|
isCachedLayerTable,
|
||||||
clearTable,
|
clearTable,
|
||||||
|
|||||||
68
src/geotracker-lupmis.js
Normal 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;
|
||||||
371
src/geotracker/GeoTracker.js
Normal 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;
|
||||||
87
src/geotracker/geo-utils.js
Normal 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';
|
||||||
|
}
|
||||||
@ -33,7 +33,7 @@ async function getLogoDataUrl() {
|
|||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
img.onerror = reject;
|
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
|
// Draw onto a canvas to get a reliable JPEG data URL
|
||||||
|
|||||||
23
src/pwa.js
@ -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).
|
* Get total disk used by this origin (Cache API + IndexedDB + OPFS).
|
||||||
* Returns null if the Storage API is not available.
|
* Returns null if the Storage API is not available.
|
||||||
@ -468,6 +490,7 @@ export default {
|
|||||||
clearUserCaches,
|
clearUserCaches,
|
||||||
getTileCacheStats,
|
getTileCacheStats,
|
||||||
clearTileCaches,
|
clearTileCaches,
|
||||||
|
clearTileCacheForProvider,
|
||||||
getStorageEstimate,
|
getStorageEstimate,
|
||||||
getActiveServiceWorker,
|
getActiveServiceWorker,
|
||||||
onServiceWorkerControllerChange,
|
onServiceWorkerControllerChange,
|
||||||
|
|||||||
154
src/remotedb.js
@ -16,11 +16,81 @@
|
|||||||
|
|
||||||
const API_BASE = 'https://api.lupmis4luspa.org/api/spatial_planning';
|
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 = {
|
const API_CREDENTIALS = {
|
||||||
district_id: '1',
|
get district_id() { return resolveDistrictId(); },
|
||||||
api_token: '1c46538c712e9b5b'
|
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
|
// Server Reachability
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -266,9 +336,11 @@ export async function getBuildingFootprints() {
|
|||||||
/**
|
/**
|
||||||
* Fetch the Contours hillshade elevation layer from the server.
|
* 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).
|
* (imported from OpenTopography's viz.hh_hillshade).
|
||||||
*
|
*
|
||||||
|
* The current district_id is passed automatically via API_CREDENTIALS.
|
||||||
|
*
|
||||||
* Expected response:
|
* Expected response:
|
||||||
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
|
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
|
||||||
*
|
*
|
||||||
@ -293,11 +365,86 @@ export async function getOSMRoads() {
|
|||||||
return remotePost('get_osm_roads.php');
|
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
|
// Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
getSession,
|
||||||
checkServerReachable,
|
checkServerReachable,
|
||||||
isServerReachable,
|
isServerReachable,
|
||||||
remoteGet,
|
remoteGet,
|
||||||
@ -309,4 +456,5 @@ export default {
|
|||||||
getBuildingFootprints,
|
getBuildingFootprints,
|
||||||
getContoursHillshade,
|
getContoursHillshade,
|
||||||
getOSMRoads,
|
getOSMRoads,
|
||||||
|
pushGpsTrail,
|
||||||
};
|
};
|
||||||
|
|||||||
610
src/styles/layerswitcher.css
Normal 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;
|
||||||
|
}
|
||||||