Fix stale district + offline-safe logout; GPS UTM format; touch-cursor gating; SW v12
Session / district correctness - public/index.php: add a /?logout=1 endpoint that destroys the PWA's own PHP session (session_destroy + expire PHPSESSID + clear sso_auth_token, then redirect to the SSO portal). Logout previously cleared only the SSO cookie, leaving the PHPSESSID session — and its frozen district_id — intact, which is why a reassigned user kept loading the old district across logout/login. - SSO token is validated once per session, at login (unchanged first-login logic). A district transfer is now picked up on the next logout→login, which is correct precisely because logout finally tears the session down. No periodic SSO polling. - main.js: the menu Logout button routes through /?logout=1 and wipes district-scoped local caches first. Logout is blocked while offline — a session can only be created online, so an offline logout would strand the user with no way back in (and would not actually reach the server). - main.js: enforceDistrictConsistency() clears district-scoped caches when the session district changes between loads; the district boundary is cached under a per-district key (district_boundary_<id>) so one district's geometry can never be served for another. GPS coordinate format - New "GPS Coordinate Format" setting (Lat/Lon · UTM · Both) in the Settings panel; the navbar read-out renders the chosen format and repaints the current fix immediately on change. Self-contained WGS84→UTM converter in geo-utils.js, verified against an independent Redfearn-series implementation. ol-ext touch cursor - MapView gates the TouchCursor to genuine touch-only devices via matchMedia (any-pointer: fine / any-hover: hover); hybrid touchscreen laptops keep the normal cursor. Reactive to pointer-capability changes. - Service worker v11 → v12 (new shell). docs/SSO_Session_Refresh_Proposal.md documents the implemented approach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3479b00d83
commit
d8ddbbc910
File diff suppressed because one or more lines are too long
1
dist/assets/index-BTRQkY7z.js.map
vendored
Normal file
1
dist/assets/index-BTRQkY7z.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DRlPLJxg.js.map
vendored
1
dist/assets/index-DRlPLJxg.js.map
vendored
File diff suppressed because one or more lines are too long
34
dist/index.html
vendored
34
dist/index.html
vendored
@ -1054,6 +1054,8 @@
|
|||||||
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
|
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
|
||||||
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
|
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
|
||||||
.gps-coords { font-weight: 600; }
|
.gps-coords { font-weight: 600; }
|
||||||
|
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
|
||||||
|
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
|
||||||
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
|
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
|
||||||
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
|
/* 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.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
|
||||||
@ -1069,6 +1071,7 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.gps-readout .gps-meta { display: none; }
|
.gps-readout .gps-meta { display: none; }
|
||||||
|
.gps-readout .gps-utm { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ol-ext SearchNominatim styling */
|
/* ol-ext SearchNominatim styling */
|
||||||
@ -1598,7 +1601,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-DRlPLJxg.js"></script>
|
<script type="module" crossorigin src="/assets/index-BTRQkY7z.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js">
|
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.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-P1ircg-B.js">
|
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
|
||||||
@ -1624,6 +1627,7 @@
|
|||||||
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
||||||
<span class="gps-readout-body">
|
<span class="gps-readout-body">
|
||||||
<span class="gps-coords" id="gps-coords">GPS off</span>
|
<span class="gps-coords" id="gps-coords">GPS off</span>
|
||||||
|
<span class="gps-utm d-none" id="gps-utm"></span>
|
||||||
<span class="gps-meta">
|
<span class="gps-meta">
|
||||||
<span id="gps-accuracy">—</span>
|
<span id="gps-accuracy">—</span>
|
||||||
<span class="gps-sep">·</span>
|
<span class="gps-sep">·</span>
|
||||||
@ -1698,7 +1702,7 @@
|
|||||||
|
|
||||||
<!-- Bottom Dock -->
|
<!-- Bottom Dock -->
|
||||||
<div class="bottom-dock">
|
<div class="bottom-dock">
|
||||||
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode">
|
<button class="dock-btn" type="button" id="dock-btn-add-location" title="Add Location Mode">
|
||||||
<span>📍</span>
|
<span>📍</span>
|
||||||
<span class="dock-btn-label">Add</span>
|
<span class="dock-btn-label">Add</span>
|
||||||
</button>
|
</button>
|
||||||
@ -2056,11 +2060,13 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- Sign-out (only visible when authenticated) -->
|
<!-- Logout (only visible when authenticated) — routes through the
|
||||||
|
server logout endpoint (/?logout=1) so the PWA's own PHP session
|
||||||
|
is destroyed, not just the SSO cookie. -->
|
||||||
<button type="button" id="menu-signout-btn"
|
<button type="button" id="menu-signout-btn"
|
||||||
class="btn btn-outline-danger w-100 d-none"
|
class="btn btn-outline-danger w-100 d-none"
|
||||||
style="font-weight:600;">
|
style="font-weight:600;">
|
||||||
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
|
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sign-in prompt (only visible when NOT authenticated) -->
|
<!-- Sign-in prompt (only visible when NOT authenticated) -->
|
||||||
@ -2220,6 +2226,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- GPS Coordinate Format -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
|
||||||
|
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3" style="min-width:140px;">
|
||||||
|
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
|
||||||
|
<option value="latlon">Lat / Lon</option>
|
||||||
|
<option value="utm">UTM</option>
|
||||||
|
<option value="both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Default Base Map -->
|
<!-- Default Base Map -->
|
||||||
<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">
|
||||||
|
|||||||
36
dist/index.php
vendored
36
dist/index.php
vendored
@ -24,8 +24,42 @@
|
|||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// SSO authentication — validate the cookie if we don't already have a session
|
// Logout — end the PWA's OWN session
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
|
||||||
|
// the central SSO portal. Logging out of the SSO portal clears `sso_auth_token`
|
||||||
|
// but leaves this PHPSESSID session intact, so the previous user's fields
|
||||||
|
// (incl. district_id) persist. This endpoint is what actually destroys the PWA
|
||||||
|
// session. Triggered by `/?logout=1` from the in-app menu.
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
$_SESSION = [];
|
||||||
|
// Expire the session cookie itself.
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$cp = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
// Clear the shared SSO cookie across all *.lupmis4luspa.org subdomains.
|
||||||
|
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
|
||||||
|
// Bounce to the central LUSPA portal to complete SSO logout.
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SSO authentication — validate once per session, at login
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// A user's district changes only when they are transferred — a rare event — so
|
||||||
|
// the validated fields (district_id, region_id, name, …) are valid for the
|
||||||
|
// whole session. We therefore validate the SSO token only when there is no
|
||||||
|
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
|
||||||
|
//
|
||||||
|
// A transfer is picked up the next time the user logs out and back in: the
|
||||||
|
// Logout endpoint above destroys the PHP session, so the subsequent login has
|
||||||
|
// no session and re-validates, fetching the new district_id. This is correct
|
||||||
|
// precisely because logout now tears the session down (previously it only
|
||||||
|
// cleared the SSO cookie, which left the stale district pinned).
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_COOKIE['sso_auth_token'];
|
$plainToken = $_COOKIE['sso_auth_token'];
|
||||||
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
||||||
|
|||||||
10
dist/sw.js
vendored
10
dist/sw.js
vendored
@ -45,7 +45,15 @@
|
|||||||
// tagging, geometry-edit + delete persistence to the staging tables,
|
// tagging, geometry-edit + delete persistence to the staging tables,
|
||||||
// sample values + Unicode-bold field names in the mapping dropdown).
|
// sample values + Unicode-bold field names in the mapping dropdown).
|
||||||
// New hashed bundle + updated embed.php shell.
|
// New hashed bundle + updated embed.php shell.
|
||||||
const CACHE_VERSION = 'v11';
|
// v12: Session/district correctness — a real /?logout=1 endpoint that destroys
|
||||||
|
// the PWA's own PHP session (logout previously only cleared the SSO
|
||||||
|
// cookie, leaving the district_id pinned). SSO is validated once per
|
||||||
|
// session at login; a transfer is picked up on the next logout→login.
|
||||||
|
// Client-side stale-district guard (wipes district-scoped caches when the
|
||||||
|
// session district changes) + district-keyed boundary cache; GPS read-out
|
||||||
|
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
|
||||||
|
// devices. New hashed bundle + updated index.php shell.
|
||||||
|
const CACHE_VERSION = 'v12';
|
||||||
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}`;
|
||||||
|
|||||||
97
docs/SSO_Session_Refresh_Proposal.md
Normal file
97
docs/SSO_Session_Refresh_Proposal.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# District-reassignment fix — implemented
|
||||||
|
|
||||||
|
**Status:** Implemented in `public/index.php`, `main.js`, `index.html`, `public/sw.js` (v12).
|
||||||
|
**Approach chosen:** validate-once-per-session + destroy-session-on-logout (no periodic TTL polling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The bug
|
||||||
|
|
||||||
|
When an administrator reassigned a user to a different district (e.g. **47 → 238**),
|
||||||
|
the PWA kept navigating to the old district even after logout, cache clear, and
|
||||||
|
re-login.
|
||||||
|
|
||||||
|
Two compounding causes:
|
||||||
|
|
||||||
|
1. **`public/index.php` validated the SSO token only when no PHP session
|
||||||
|
existed.** Once `$_SESSION['user_id']` was set, the user's fields
|
||||||
|
(`district_id`, …) were frozen for the life of that PHP session.
|
||||||
|
2. **Nothing ever destroyed the PWA's own PHP session.** The PWA
|
||||||
|
(`pwa.lupmis4luspa.org`) keeps its own `PHPSESSID` session, independent of
|
||||||
|
the central SSO portal. "Logout" cleared only the `sso_auth_token` cookie;
|
||||||
|
the `PHPSESSID` session (holding district 47) survived. Browser "clear cache"
|
||||||
|
does not delete cookies, so the stale `PHPSESSID` kept being reused — and
|
||||||
|
because a session still existed, SSO was never re-validated.
|
||||||
|
|
||||||
|
Net effect: district 47 stayed pinned until the `PHPSESSID` session was
|
||||||
|
physically destroyed (which nothing did).
|
||||||
|
|
||||||
|
## 2. The fix (as implemented)
|
||||||
|
|
||||||
|
A district changes only on transfer — a rare event — so the validated session
|
||||||
|
is correct for the entire session. We therefore do **not** poll SSO on a timer.
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
- **Validate once per session, at login** — unchanged first-login validation in
|
||||||
|
`index.php` (`if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token']))`).
|
||||||
|
- **Destroy the session on logout** — a new `/?logout=1` endpoint in
|
||||||
|
`index.php` runs `session_destroy()`, expires the `PHPSESSID` cookie, clears
|
||||||
|
`sso_auth_token`, and redirects to the LUSPA portal.
|
||||||
|
|
||||||
|
A transferred user logs out → the PHP session is destroyed → on the next login
|
||||||
|
there is no session, so SSO is re-validated and the **new** `district_id` is
|
||||||
|
fetched. No periodic SSO calls; the session stays valid the whole time the user
|
||||||
|
is working.
|
||||||
|
|
||||||
|
### Logout endpoint (`public/index.php`, immediately after `session_start()`)
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$cp = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSO validation block below it is the original first-login-only logic.
|
||||||
|
|
||||||
|
## 3. Client-side changes
|
||||||
|
|
||||||
|
- **Logout button** (`index.html` / `main.js`) — the menu's "Logout" button now
|
||||||
|
routes through `/?logout=1` instead of only clearing the cookie, and wipes the
|
||||||
|
device's district-scoped SQLite caches first.
|
||||||
|
- **Stale-district guard** (`main.js` `enforceDistrictConsistency()`) — on load,
|
||||||
|
if the session's `district_id` differs from the last-seen value in
|
||||||
|
`localStorage`, all district-scoped caches (boundary, UPN grid, parcels,
|
||||||
|
zones, roads) are cleared so the map repopulates for the new district.
|
||||||
|
- **District-keyed boundary cache** — the boundary is cached as
|
||||||
|
`district_boundary_<id>`, so one district's geometry can never be served for
|
||||||
|
another.
|
||||||
|
|
||||||
|
## 4. Why not a periodic TTL
|
||||||
|
|
||||||
|
An earlier draft re-validated SSO on a 5-minute TTL. Rejected because:
|
||||||
|
|
||||||
|
- District changes are rare (transfers only), so polling SSO on every load
|
||||||
|
wastes a blocking `curl` on the page's critical path for no benefit almost
|
||||||
|
all of the time.
|
||||||
|
- The logout→login cycle is the natural, explicit moment a transfer takes
|
||||||
|
effect, and it is now correct because logout destroys the session.
|
||||||
|
|
||||||
|
## 5. Operational notes
|
||||||
|
|
||||||
|
- **Existing stale sessions:** a transferred user who is still on an old session
|
||||||
|
must **log out and log back in** (or have their `PHPSESSID` cleared) once after
|
||||||
|
deploy to pick up the new district. After that the model is self-correcting.
|
||||||
|
- **Assumes the SSO `validate` endpoint returns the new district** — confirmed
|
||||||
|
via an incognito test (fresh session injected the correct `district_id: 238`).
|
||||||
|
- **Edge case:** switching to a different SSO user *without* logging out of the
|
||||||
|
PWA first would reuse the old PHP session. The Logout button is the supported
|
||||||
|
path; this matches the original single-user-per-device assumption.
|
||||||
30
index.html
30
index.html
@ -1054,6 +1054,8 @@
|
|||||||
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
|
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
|
||||||
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
|
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
|
||||||
.gps-coords { font-weight: 600; }
|
.gps-coords { font-weight: 600; }
|
||||||
|
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
|
||||||
|
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
|
||||||
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
|
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
|
||||||
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
|
/* 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.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
|
||||||
@ -1069,6 +1071,7 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.gps-readout .gps-meta { display: none; }
|
.gps-readout .gps-meta { display: none; }
|
||||||
|
.gps-readout .gps-utm { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ol-ext SearchNominatim styling */
|
/* ol-ext SearchNominatim styling */
|
||||||
@ -1615,6 +1618,7 @@
|
|||||||
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
||||||
<span class="gps-readout-body">
|
<span class="gps-readout-body">
|
||||||
<span class="gps-coords" id="gps-coords">GPS off</span>
|
<span class="gps-coords" id="gps-coords">GPS off</span>
|
||||||
|
<span class="gps-utm d-none" id="gps-utm"></span>
|
||||||
<span class="gps-meta">
|
<span class="gps-meta">
|
||||||
<span id="gps-accuracy">—</span>
|
<span id="gps-accuracy">—</span>
|
||||||
<span class="gps-sep">·</span>
|
<span class="gps-sep">·</span>
|
||||||
@ -2047,11 +2051,13 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- Sign-out (only visible when authenticated) -->
|
<!-- Logout (only visible when authenticated) — routes through the
|
||||||
|
server logout endpoint (/?logout=1) so the PWA's own PHP session
|
||||||
|
is destroyed, not just the SSO cookie. -->
|
||||||
<button type="button" id="menu-signout-btn"
|
<button type="button" id="menu-signout-btn"
|
||||||
class="btn btn-outline-danger w-100 d-none"
|
class="btn btn-outline-danger w-100 d-none"
|
||||||
style="font-weight:600;">
|
style="font-weight:600;">
|
||||||
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
|
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sign-in prompt (only visible when NOT authenticated) -->
|
<!-- Sign-in prompt (only visible when NOT authenticated) -->
|
||||||
@ -2211,6 +2217,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- GPS Coordinate Format -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
|
||||||
|
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3" style="min-width:140px;">
|
||||||
|
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
|
||||||
|
<option value="latlon">Lat / Lon</option>
|
||||||
|
<option value="utm">UTM</option>
|
||||||
|
<option value="both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Default Base Map -->
|
<!-- Default Base Map -->
|
||||||
<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">
|
||||||
|
|||||||
191
main.js
191
main.js
@ -97,7 +97,7 @@ import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers
|
|||||||
|
|
||||||
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
|
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
|
||||||
import { geoTracker } from './src/geotracker-lupmis.js';
|
import { geoTracker } from './src/geotracker-lupmis.js';
|
||||||
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
|
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality, formatUTM } from './src/geotracker/geo-utils.js';
|
||||||
|
|
||||||
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
|
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
|
||||||
import { createEmbedBridge } from './src/embed-bridge.js';
|
import { createEmbedBridge } from './src/embed-bridge.js';
|
||||||
@ -473,6 +473,14 @@ async function initApp() {
|
|||||||
// Now dbReady should be resolved
|
// Now dbReady should be resolved
|
||||||
console.log('[App] Database ready');
|
console.log('[App] Database ready');
|
||||||
|
|
||||||
|
// Guard against a stale district: if the authenticated user's district
|
||||||
|
// has changed since the last load (e.g. an admin reassigned them in the
|
||||||
|
// remote DB), every locally-cached, district-scoped layer is now wrong.
|
||||||
|
// Wipe them so the fresh-fetch path repopulates from the correct
|
||||||
|
// district instead of showing the previous one. Must run BEFORE any
|
||||||
|
// district-scoped load below.
|
||||||
|
await enforceDistrictConsistency();
|
||||||
|
|
||||||
// Show database status
|
// Show database status
|
||||||
const status = await getDatabaseStatus();
|
const status = await getDatabaseStatus();
|
||||||
console.log('[App] Database status:', status);
|
console.log('[App] Database status:', status);
|
||||||
@ -559,6 +567,9 @@ async function initApp() {
|
|||||||
// 9. Measurement system toggle (metric / imperial)
|
// 9. Measurement system toggle (metric / imperial)
|
||||||
initMeasurementSystem();
|
initMeasurementSystem();
|
||||||
|
|
||||||
|
// 9b. GPS coordinate format (lat/lon · UTM · both)
|
||||||
|
initCoordinateFormat();
|
||||||
|
|
||||||
// 10. Dark mode
|
// 10. Dark mode
|
||||||
initDarkMode();
|
initDarkMode();
|
||||||
|
|
||||||
@ -1342,13 +1353,58 @@ function zonesToGeoJSON(zones) {
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** localStorage key tracking the district the local caches were filled for. */
|
||||||
|
const LAST_DISTRICT_KEY = 'lupmis-last-district';
|
||||||
|
|
||||||
|
/** Cache key for the district boundary, scoped per district id. */
|
||||||
|
function districtBoundaryCacheKey(districtId) {
|
||||||
|
return districtId == null || String(districtId).length === 0
|
||||||
|
? 'district_boundary' // dev fallback (no session)
|
||||||
|
: `district_boundary_${districtId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a district change between page loads and wipe district-scoped local
|
||||||
|
* caches when it happens, so we never display a previous district's boundary,
|
||||||
|
* parcels, zones or UPN grid after an admin reassigns the user.
|
||||||
|
*
|
||||||
|
* The comparison is against the authenticated session district only — in local
|
||||||
|
* dev (no session) there is no district to enforce, so this is a no-op.
|
||||||
|
*/
|
||||||
|
async function enforceDistrictConsistency() {
|
||||||
|
const session = getSession();
|
||||||
|
// Only meaningful for an authenticated user with a district.
|
||||||
|
const current = session?.district_id;
|
||||||
|
if (current == null || String(current).length === 0) return;
|
||||||
|
const currentStr = String(current);
|
||||||
|
|
||||||
|
let previous = null;
|
||||||
|
try { previous = localStorage.getItem(LAST_DISTRICT_KEY); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (previous !== null && previous !== currentStr) {
|
||||||
|
console.warn(`[App] District changed ${previous} → ${currentStr}; clearing cached layers.`);
|
||||||
|
try {
|
||||||
|
await clearAllCachedLayers();
|
||||||
|
showWarning('Your district was updated — refreshing map data.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[App] Failed to clear caches on district change:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try { localStorage.setItem(LAST_DISTRICT_KEY, currentStr); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load district boundary with local-first strategy:
|
* Load district boundary with local-first strategy:
|
||||||
* 1. Always read from local SQLite cache (GeoJSON) first — instant, works offline
|
* 1. Always read from local SQLite cache (GeoJSON) first — instant, works offline
|
||||||
* 2. If online, fetch from API, convert WKT → GeoJSON, cache and display
|
* 2. If online, fetch from API, convert WKT → GeoJSON, cache and display
|
||||||
|
*
|
||||||
|
* The cache is keyed by district id, so a boundary cached for one district can
|
||||||
|
* never be served for another even if the stale-district guard is bypassed.
|
||||||
*/
|
*/
|
||||||
async function loadDistrictBoundary() {
|
async function loadDistrictBoundary() {
|
||||||
const CACHE_KEY = 'district_boundary';
|
const districtId = getSession()?.district_id ?? null;
|
||||||
|
const CACHE_KEY = districtBoundaryCacheKey(districtId);
|
||||||
const ADMIN_GROUP_ID = 1; // Administration layer group
|
const ADMIN_GROUP_ID = 1; // Administration layer group
|
||||||
const boundaryStyle = {
|
const boundaryStyle = {
|
||||||
strokeColor: '#e11d48',
|
strokeColor: '#e11d48',
|
||||||
@ -1390,16 +1446,22 @@ async function loadDistrictBoundary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Will we be fetching fresh data below? If so, the cached layer is shown
|
||||||
|
// for an instant paint but we DON'T zoom to it — the fresh fetch performs
|
||||||
|
// the single authoritative zoom, avoiding a visible "fly to old extent
|
||||||
|
// then re-fly" when the cached geometry differs.
|
||||||
|
const willFetchFresh = isOnline() && isServerReachable();
|
||||||
|
|
||||||
// Step 1: Load from local cache (already stored as GeoJSON)
|
// Step 1: Load from local cache (already stored as GeoJSON)
|
||||||
const cached = await getRemoteData(CACHE_KEY);
|
const cached = await getRemoteData(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log('[App] District boundary loaded from local cache');
|
console.log('[App] District boundary loaded from local cache');
|
||||||
const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
|
const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
|
||||||
zoomToBoundary(layer);
|
if (!willFetchFresh) zoomToBoundary(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: If online and server reachable, fetch fresh data from the API
|
// Step 2: If online and server reachable, fetch fresh data from the API
|
||||||
if (isOnline() && isServerReachable()) {
|
if (willFetchFresh) {
|
||||||
console.log('[App] Fetching district boundary from API...');
|
console.log('[App] Fetching district boundary from API...');
|
||||||
const apiResponse = await getDistrictBoundary();
|
const apiResponse = await getDistrictBoundary();
|
||||||
|
|
||||||
@ -3115,6 +3177,48 @@ function initMessageLog() {
|
|||||||
// events to the navbar readout and the map's render/control hooks.
|
// events to the navbar readout and the map's render/control hooks.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Last GPS fix rendered into the navbar read-out. Kept at module scope so the
|
||||||
|
// Coordinate-Format setting can re-render the current position immediately when
|
||||||
|
// the user switches format, without waiting for the next fix.
|
||||||
|
let _lastGpsFix = null;
|
||||||
|
|
||||||
|
/** Read the user's coordinate-format preference ('latlon' | 'utm' | 'both'). */
|
||||||
|
function getCoordFormat() {
|
||||||
|
const v = localStorage.getItem('coord-format');
|
||||||
|
return (v === 'utm' || v === 'both') ? v : 'latlon';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint the navbar GPS coordinate spans from `_lastGpsFix` according to the
|
||||||
|
* current coordinate-format preference. Safe to call any time (no-op when
|
||||||
|
* there is no fix yet, leaving the "GPS off" placeholder in place).
|
||||||
|
*/
|
||||||
|
function renderGpsCoords() {
|
||||||
|
const coordsEl = document.getElementById('gps-coords');
|
||||||
|
const utmEl = document.getElementById('gps-utm');
|
||||||
|
if (!coordsEl) return;
|
||||||
|
if (!_lastGpsFix) return;
|
||||||
|
|
||||||
|
const { lat, lon } = _lastGpsFix;
|
||||||
|
const latlon = `${formatCoord(lat)}, ${formatCoord(lon)}`;
|
||||||
|
const utm = formatUTM(lat, lon);
|
||||||
|
const fmt = getCoordFormat();
|
||||||
|
|
||||||
|
if (fmt === 'utm') {
|
||||||
|
coordsEl.textContent = utm;
|
||||||
|
if (utmEl) utmEl.classList.add('d-none');
|
||||||
|
} else if (fmt === 'both') {
|
||||||
|
coordsEl.textContent = latlon;
|
||||||
|
if (utmEl) { utmEl.textContent = utm; utmEl.classList.remove('d-none'); }
|
||||||
|
} else {
|
||||||
|
coordsEl.textContent = latlon;
|
||||||
|
if (utmEl) utmEl.classList.add('d-none');
|
||||||
|
}
|
||||||
|
// Keep the alternate format reachable on hover regardless of mode.
|
||||||
|
const readout = document.getElementById('gps-readout');
|
||||||
|
if (readout) readout.title = `Lat/Lon: ${latlon}\nUTM: ${utm}`;
|
||||||
|
}
|
||||||
|
|
||||||
function initGpsTracking() {
|
function initGpsTracking() {
|
||||||
const readout = document.getElementById('gps-readout');
|
const readout = document.getElementById('gps-readout');
|
||||||
const coordsEl = document.getElementById('gps-coords');
|
const coordsEl = document.getElementById('gps-coords');
|
||||||
@ -3128,7 +3232,8 @@ function initGpsTracking() {
|
|||||||
|
|
||||||
// Live navbar readout — fires for every fix (one-shot Locate or watch).
|
// Live navbar readout — fires for every fix (one-shot Locate or watch).
|
||||||
geoTracker.on('position', (fix) => {
|
geoTracker.on('position', (fix) => {
|
||||||
if (coordsEl) coordsEl.textContent = `${formatCoord(fix.lat)}, ${formatCoord(fix.lon)}`;
|
_lastGpsFix = fix;
|
||||||
|
renderGpsCoords();
|
||||||
if (accEl) accEl.textContent = formatAccuracy(fix.accuracy);
|
if (accEl) accEl.textContent = formatAccuracy(fix.accuracy);
|
||||||
if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
|
if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
|
||||||
if (readout) {
|
if (readout) {
|
||||||
@ -3323,6 +3428,25 @@ function initMeasurementSystem() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GPS Coordinate Format (Lat/Lon · UTM · Both)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function initCoordinateFormat() {
|
||||||
|
const select = document.getElementById('coord-format-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Restore saved preference (default: latlon)
|
||||||
|
select.value = getCoordFormat();
|
||||||
|
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
const fmt = (select.value === 'utm' || select.value === 'both') ? select.value : 'latlon';
|
||||||
|
localStorage.setItem('coord-format', fmt);
|
||||||
|
renderGpsCoords(); // repaint the current fix immediately
|
||||||
|
console.log('[Settings] GPS coordinate format:', fmt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default base map selector — persisted in localStorage.
|
* Default base map selector — persisted in localStorage.
|
||||||
* Keys must match those handled by MapView.setBaseMap().
|
* Keys must match those handled by MapView.setBaseMap().
|
||||||
@ -3868,18 +3992,40 @@ function initAccountCard() {
|
|||||||
// right-side menuOffcanvas. See initAccountCard above.
|
// right-side menuOffcanvas. See initAccountCard above.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-out flow:
|
* Logout flow:
|
||||||
* 1. Confirm with the user.
|
* 1. Confirm with the user.
|
||||||
* 2. Best-effort fire-and-forget call to the SSO logout endpoint so the
|
* 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).
|
* 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
|
* 3. Wipe district-scoped local caches so the next (possibly different) user
|
||||||
* browser stops sending it.
|
* on this device can't briefly see the previous user's cached map data.
|
||||||
* 4. Redirect to the SSO login page — leaves the user on familiar ground
|
* 4. Navigate to the PWA's OWN logout endpoint (/?logout=1). That endpoint
|
||||||
* (and on next visit, index.php sees no session and serves a fresh
|
* runs session_destroy() — clearing the PHPSESSID session that holds the
|
||||||
* page with no LUPMIS_SESSION).
|
* user's district_id — clears the sso_auth_token cookie server-side, and
|
||||||
|
* redirects to the central LUSPA portal. Clearing only the cookie here
|
||||||
|
* (as before) left the PHP session intact, which is what pinned users to
|
||||||
|
* a stale district across logout/login.
|
||||||
*/
|
*/
|
||||||
async function handleSignOut(session) {
|
async function handleSignOut(session) {
|
||||||
if (!confirm(`Return to Landing Page, ${session?.full_name || session?.username || 'user'}?`)) {
|
// Guard: logging back in requires the SSO server (a session can only be
|
||||||
|
// created by validating the token online). If we let the user log out while
|
||||||
|
// offline, they would be stranded on this device — unable to sign back in
|
||||||
|
// until connectivity returns — and the server-side session_destroy() at
|
||||||
|
// /?logout=1 could not run anyway. So we refuse offline logout outright,
|
||||||
|
// which also protects against an accidental tap wiping the working session
|
||||||
|
// during fieldwork. The app keeps functioning offline on the cached session,
|
||||||
|
// so there is no need to log out until back online.
|
||||||
|
if (!isOnline()) {
|
||||||
|
alert(
|
||||||
|
'You appear to be offline.\n\n' +
|
||||||
|
'Logging out needs a connection to the sign-in server, and you would not ' +
|
||||||
|
'be able to sign back in until you are online again.\n\n' +
|
||||||
|
'Please try again once you have a connection. You can keep working — the ' +
|
||||||
|
'app stays signed in while offline.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Log out, ${session?.full_name || session?.username || 'user'}?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3899,19 +4045,22 @@ async function handleSignOut(session) {
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Signout] Best-effort SSO logout call failed:', err);
|
console.warn('[Logout] Best-effort SSO logout call failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Clear the cookie on the shared parent domain
|
// 2. Drop locally-cached, district-scoped layers and the last-district
|
||||||
// Set with both leading-dot and no-dot variants; browsers vary on which sticks.
|
// marker so a different next user starts clean.
|
||||||
const past = 'Thu, 01 Jan 1970 00:00:00 GMT';
|
try {
|
||||||
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=.lupmis4luspa.org`;
|
await clearAllCachedLayers();
|
||||||
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=lupmis4luspa.org`;
|
localStorage.removeItem(LAST_DISTRICT_KEY);
|
||||||
document.cookie = `sso_auth_token=; expires=${past}; path=/`;
|
} catch (err) {
|
||||||
|
console.warn('[Logout] Cache clear failed (continuing):', err);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Redirect to the central LUSPA login
|
// 3. Hand off to the server logout endpoint — it destroys the PHP session,
|
||||||
window.location.href = 'https://lupmis4luspa.org/';
|
// clears the SSO cookie, and 302-redirects to the LUSPA portal.
|
||||||
|
window.location.href = '/?logout=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -24,8 +24,42 @@
|
|||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// SSO authentication — validate the cookie if we don't already have a session
|
// Logout — end the PWA's OWN session
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
|
||||||
|
// the central SSO portal. Logging out of the SSO portal clears `sso_auth_token`
|
||||||
|
// but leaves this PHPSESSID session intact, so the previous user's fields
|
||||||
|
// (incl. district_id) persist. This endpoint is what actually destroys the PWA
|
||||||
|
// session. Triggered by `/?logout=1` from the in-app menu.
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
$_SESSION = [];
|
||||||
|
// Expire the session cookie itself.
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$cp = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
// Clear the shared SSO cookie across all *.lupmis4luspa.org subdomains.
|
||||||
|
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
|
||||||
|
// Bounce to the central LUSPA portal to complete SSO logout.
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SSO authentication — validate once per session, at login
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// A user's district changes only when they are transferred — a rare event — so
|
||||||
|
// the validated fields (district_id, region_id, name, …) are valid for the
|
||||||
|
// whole session. We therefore validate the SSO token only when there is no
|
||||||
|
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
|
||||||
|
//
|
||||||
|
// A transfer is picked up the next time the user logs out and back in: the
|
||||||
|
// Logout endpoint above destroys the PHP session, so the subsequent login has
|
||||||
|
// no session and re-validates, fetching the new district_id. This is correct
|
||||||
|
// precisely because logout now tears the session down (previously it only
|
||||||
|
// cleared the SSO cookie, which left the stale district pinned).
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_COOKIE['sso_auth_token'];
|
$plainToken = $_COOKIE['sso_auth_token'];
|
||||||
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
||||||
|
|||||||
10
public/sw.js
10
public/sw.js
@ -45,7 +45,15 @@
|
|||||||
// tagging, geometry-edit + delete persistence to the staging tables,
|
// tagging, geometry-edit + delete persistence to the staging tables,
|
||||||
// sample values + Unicode-bold field names in the mapping dropdown).
|
// sample values + Unicode-bold field names in the mapping dropdown).
|
||||||
// New hashed bundle + updated embed.php shell.
|
// New hashed bundle + updated embed.php shell.
|
||||||
const CACHE_VERSION = 'v11';
|
// v12: Session/district correctness — a real /?logout=1 endpoint that destroys
|
||||||
|
// the PWA's own PHP session (logout previously only cleared the SSO
|
||||||
|
// cookie, leaving the district_id pinned). SSO is validated once per
|
||||||
|
// session at login; a transfer is picked up on the next logout→login.
|
||||||
|
// Client-side stale-district guard (wipes district-scoped caches when the
|
||||||
|
// session district changes) + district-keyed boundary cache; GPS read-out
|
||||||
|
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
|
||||||
|
// devices. New hashed bundle + updated index.php shell.
|
||||||
|
const CACHE_VERSION = 'v12';
|
||||||
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}`;
|
||||||
|
|||||||
@ -669,19 +669,44 @@ export class MapView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 8. Touch-device detection & TouchCursor setup
|
// 8. Touch-device detection & TouchCursor setup
|
||||||
const isTouchDevice = ('ontouchstart' in window) ||
|
//
|
||||||
(navigator.maxTouchPoints > 0) ||
|
// The TouchCursor is only practical on *touch-only* devices (phones,
|
||||||
(navigator.msMaxTouchPoints > 0);
|
// tablets) where the finger is the sole pointing device. On hybrid
|
||||||
|
// laptops — a touchscreen plus a touchpad/mouse — the changed cursor
|
||||||
if (isTouchDevice) {
|
// gets in the way, because the user is most likely driving the map
|
||||||
|
// with the precise pointer, not the screen.
|
||||||
|
//
|
||||||
|
// The classic `'ontouchstart' in window` / `maxTouchPoints` test can't
|
||||||
|
// tell the two apart: it is true for both a tablet and a touchscreen
|
||||||
|
// laptop. We additionally consult CSS Media Queries Level 4 pointer
|
||||||
|
// features, which DO distinguish them:
|
||||||
|
//
|
||||||
|
// any-pointer: fine → at least one fine pointer exists
|
||||||
|
// (mouse / touchpad / stylus). True on a
|
||||||
|
// touch laptop, false on a phone/tablet.
|
||||||
|
// any-hover: hover → at least one device can hover. Same split.
|
||||||
|
//
|
||||||
|
// So we enable the TouchCursor only when the device is touch-capable
|
||||||
|
// AND exposes no fine/hovering pointer — i.e. a genuine touch-only
|
||||||
|
// device. `_isTouchOnlyDevice()` is also reactive: if the user later
|
||||||
|
// plugs in a mouse, `_refreshTouchCursor()` tears the cursor down.
|
||||||
|
if (this._isTouchOnlyDevice()) {
|
||||||
this.touchCursor = new TouchCursor({
|
this.touchCursor = new TouchCursor({
|
||||||
className: 'ol-editbar-cursor',
|
className: 'ol-editbar-cursor',
|
||||||
});
|
});
|
||||||
this.map.addInteraction(this.touchCursor);
|
this.map.addInteraction(this.touchCursor);
|
||||||
this.touchCursor.setActive(false);
|
this.touchCursor.setActive(false);
|
||||||
console.log('[MapView] Touch device detected — TouchCursor added');
|
console.log('[MapView] Touch-only device detected — TouchCursor added');
|
||||||
|
} else {
|
||||||
|
console.log('[MapView] Fine pointer available — TouchCursor skipped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// React to pointer-capability changes (e.g. a Bluetooth mouse paired
|
||||||
|
// with a tablet, or a tablet docked to a trackpad keyboard). When a
|
||||||
|
// fine pointer appears we drop the TouchCursor; when the last one is
|
||||||
|
// removed on a touch device we add it back.
|
||||||
|
this._installPointerCapabilityWatcher();
|
||||||
|
|
||||||
// 9. Listen for polygon features drawn via EditBar's DrawPolygon tool.
|
// 9. Listen for polygon features drawn via EditBar's DrawPolygon tool.
|
||||||
// When a Polygon is added to the drawings source, show the attribute popup.
|
// When a Polygon is added to the drawings source, show the attribute popup.
|
||||||
this.drawingsSource.on('addfeature', (evt) => {
|
this.drawingsSource.on('addfeature', (evt) => {
|
||||||
@ -781,6 +806,85 @@ export class MapView {
|
|||||||
return this._editBarActive;
|
return this._editBarActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pointer-capability detection (touch-only vs. hybrid laptop)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the ol-ext TouchCursor should be used.
|
||||||
|
*
|
||||||
|
* Returns true ONLY for genuine touch-only devices (phones, tablets) —
|
||||||
|
* i.e. devices that are touch-capable but expose no fine pointer and no
|
||||||
|
* hover capability. Hybrid laptops (touchscreen + touchpad/mouse) return
|
||||||
|
* false, so they keep the normal cursor.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isTouchOnlyDevice() {
|
||||||
|
const hasTouch = ('ontouchstart' in window) ||
|
||||||
|
(navigator.maxTouchPoints > 0) ||
|
||||||
|
(navigator.msMaxTouchPoints > 0);
|
||||||
|
if (!hasTouch) return false;
|
||||||
|
|
||||||
|
// Without matchMedia we can't refine the signal — fall back to the
|
||||||
|
// historical behaviour (treat any touch device as touch).
|
||||||
|
if (typeof window.matchMedia !== 'function') return true;
|
||||||
|
|
||||||
|
// `any-pointer: fine` is true when ANY attached pointing device is
|
||||||
|
// fine (mouse / touchpad / stylus); `any-hover: hover` when ANY device
|
||||||
|
// can hover. A phone/tablet satisfies neither; a touch laptop both.
|
||||||
|
const hasFinePointer = window.matchMedia('(any-pointer: fine)').matches;
|
||||||
|
const canHover = window.matchMedia('(any-hover: hover)').matches;
|
||||||
|
|
||||||
|
return !hasFinePointer && !canHover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or remove the TouchCursor to match the current pointer capability,
|
||||||
|
* preserving its active state relative to edit mode. Called both at init
|
||||||
|
* and whenever the pointer-capability media queries change.
|
||||||
|
*/
|
||||||
|
_refreshTouchCursor() {
|
||||||
|
const wantCursor = this._isTouchOnlyDevice();
|
||||||
|
|
||||||
|
if (wantCursor && !this.touchCursor) {
|
||||||
|
this.touchCursor = new TouchCursor({ className: 'ol-editbar-cursor' });
|
||||||
|
this.map.addInteraction(this.touchCursor);
|
||||||
|
this.touchCursor.setActive(this._editBarActive);
|
||||||
|
console.log('[MapView] Pointer change → TouchCursor added');
|
||||||
|
} else if (!wantCursor && this.touchCursor) {
|
||||||
|
this.map.removeInteraction(this.touchCursor);
|
||||||
|
this.touchCursor = null;
|
||||||
|
console.log('[MapView] Pointer change → TouchCursor removed (fine pointer present)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the pointer-capability media queries and re-evaluate the
|
||||||
|
* TouchCursor when they change — e.g. a Bluetooth mouse paired with a
|
||||||
|
* tablet, or a 2-in-1 docked to / undocked from a keyboard-trackpad.
|
||||||
|
*/
|
||||||
|
_installPointerCapabilityWatcher() {
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
if (this._pointerMediaQueries) return; // already installed
|
||||||
|
|
||||||
|
const queries = ['(any-pointer: fine)', '(any-hover: hover)']
|
||||||
|
.map((q) => window.matchMedia(q));
|
||||||
|
const onChange = () => this._refreshTouchCursor();
|
||||||
|
|
||||||
|
for (const mq of queries) {
|
||||||
|
// addEventListener is the modern API; addListener is the legacy
|
||||||
|
// fallback for older Safari.
|
||||||
|
if (typeof mq.addEventListener === 'function') {
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
} else if (typeof mq.addListener === 'function') {
|
||||||
|
mq.addListener(onChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._pointerMediaQueries = queries;
|
||||||
|
this._pointerMediaListener = onChange;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Persistent Vertex Highlight Overlay
|
// Persistent Vertex Highlight Overlay
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -52,6 +52,101 @@ export function formatCoord(value, decimals = 5) {
|
|||||||
return value.toFixed(decimals);
|
return value.toFixed(decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UTM (Universal Transverse Mercator) — WGS84 forward projection
|
||||||
|
//
|
||||||
|
// Self-contained implementation of the standard USGS series-expansion forward
|
||||||
|
// formulas (Snyder, "Map Projections — A Working Manual"). Accurate to a few
|
||||||
|
// millimetres within a zone, which is far beyond what a consumer GPS fix
|
||||||
|
// needs. Ghana spans UTM zones 30N and 31N, but this handles the whole globe.
|
||||||
|
// No proj4 / OpenLayers dependency so it stays usable from the pure GeoTracker
|
||||||
|
// layer and from plain display code.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const UTM_A = 6378137.0; // WGS84 semi-major axis (metres)
|
||||||
|
const UTM_F = 1 / 298.257223563; // WGS84 flattening
|
||||||
|
const UTM_K0 = 0.9996; // UTM scale factor on the central meridian
|
||||||
|
const UTM_E2 = UTM_F * (2 - UTM_F); // first eccentricity squared
|
||||||
|
const UTM_EP2 = UTM_E2 / (1 - UTM_E2); // second eccentricity squared (e'^2)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert WGS84 latitude/longitude to UTM.
|
||||||
|
*
|
||||||
|
* @param {number} lat latitude in decimal degrees (−80 … 84 is the UTM band)
|
||||||
|
* @param {number} lon longitude in decimal degrees (−180 … 180)
|
||||||
|
* @returns {{zone:number, hemisphere:'N'|'S', easting:number, northing:number}|null}
|
||||||
|
* null when the input is missing / not finite.
|
||||||
|
*/
|
||||||
|
export function latLonToUTM(lat, lon) {
|
||||||
|
if (lat == null || lon == null || Number.isNaN(lat) || Number.isNaN(lon)) return null;
|
||||||
|
|
||||||
|
// Normalise longitude to [−180, 180) so the zone maths is well-defined.
|
||||||
|
let lonNorm = ((lon + 180) % 360 + 360) % 360 - 180;
|
||||||
|
|
||||||
|
const zone = Math.floor((lonNorm + 180) / 6) + 1;
|
||||||
|
const lon0 = (zone - 1) * 6 - 180 + 3; // central meridian of the zone (deg)
|
||||||
|
|
||||||
|
const phi = lat * DEG2RAD;
|
||||||
|
const lambda = lonNorm * DEG2RAD;
|
||||||
|
const lambda0 = lon0 * DEG2RAD;
|
||||||
|
|
||||||
|
const sinPhi = Math.sin(phi);
|
||||||
|
const cosPhi = Math.cos(phi);
|
||||||
|
const tanPhi = Math.tan(phi);
|
||||||
|
|
||||||
|
const N = UTM_A / Math.sqrt(1 - UTM_E2 * sinPhi * sinPhi);
|
||||||
|
const T = tanPhi * tanPhi;
|
||||||
|
const C = UTM_EP2 * cosPhi * cosPhi;
|
||||||
|
const A = cosPhi * (lambda - lambda0);
|
||||||
|
|
||||||
|
// Meridional arc length from the equator to the given latitude.
|
||||||
|
const M = UTM_A * (
|
||||||
|
(1 - UTM_E2 / 4 - 3 * UTM_E2 ** 2 / 64 - 5 * UTM_E2 ** 3 / 256) * phi
|
||||||
|
- (3 * UTM_E2 / 8 + 3 * UTM_E2 ** 2 / 32 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(2 * phi)
|
||||||
|
+ (15 * UTM_E2 ** 2 / 256 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(4 * phi)
|
||||||
|
- (35 * UTM_E2 ** 3 / 3072) * Math.sin(6 * phi)
|
||||||
|
);
|
||||||
|
|
||||||
|
let easting = UTM_K0 * N * (
|
||||||
|
A
|
||||||
|
+ (1 - T + C) * A ** 3 / 6
|
||||||
|
+ (5 - 18 * T + T * T + 72 * C - 58 * UTM_EP2) * A ** 5 / 120
|
||||||
|
) + 500000; // false easting
|
||||||
|
|
||||||
|
let northing = UTM_K0 * (
|
||||||
|
M + N * tanPhi * (
|
||||||
|
A * A / 2
|
||||||
|
+ (5 - T + 9 * C + 4 * C * C) * A ** 4 / 24
|
||||||
|
+ (61 - 58 * T + T * T + 600 * C - 330 * UTM_EP2) * A ** 6 / 720
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hemisphere = lat >= 0 ? 'N' : 'S';
|
||||||
|
if (hemisphere === 'S') northing += 10000000; // false northing (southern hemi)
|
||||||
|
|
||||||
|
return {
|
||||||
|
zone,
|
||||||
|
hemisphere,
|
||||||
|
easting: Math.round(easting),
|
||||||
|
northing: Math.round(northing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a latitude/longitude as a compact UTM string for display, e.g.
|
||||||
|
* `30N 706123E 619876N`. Returns the em-dash placeholder when the position
|
||||||
|
* is unavailable.
|
||||||
|
*
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatUTM(lat, lon) {
|
||||||
|
const u = latLonToUTM(lat, lon);
|
||||||
|
if (!u) return '—';
|
||||||
|
return `${u.zone}${u.hemisphere} ${u.easting}E ${u.northing}N`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a distance in metres into a friendly string (m below 1 km, km above).
|
* Format a distance in metres into a friendly string (m below 1 km, km above).
|
||||||
* @param {number} meters
|
* @param {number} meters
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user