pwaLUPMIS2/main.js
ekke 26d4f6235f UPN-grid layer, external imports/staged upload, GIS export, SW v10
UPN-grid layer:
- src/database.js — new upn_grid SQLocal table (id, districtid, upn_prefix,
  geometry_wkt) + saveUpnGrid / getLocalUpnGrid; cache-once-per-district.
- src/remotedb.js — getUpnGrid → get_upn_grid_per_district.php.
- main.js loadUpnGrid + upnGridToGeoJSON in the Administration group, with
  a zoom-aware style: white casing under a bolder violet dashed stroke
  (visible against parcels) and upn_prefix labels rendered only when
  resolution ≤ 7 m/px (≈ scale ≤ 1:25,000).
- main.js click handler: single click on a UPN-grid cell opens an info
  popup showing the upn_prefix.

External-dataset import → staging → upload (client-side complete):
- src/database.js — external_imports + external_import_features tables,
  plus createExternalImport / addExternalImportFeatures /
  updateExternalImport / getExternalImport / getExternalImportFeatures /
  listExternalImports / remapImportedFeatureProperties /
  deleteExternalImport. Status enum: imported/mapped/other/uploading/
  submitted/migrated/failed (aligned with the database team's staged-
  upload model — lu_parcels_upload_tmp + supervisor review).
- src/import-detect.js — pure helpers: detectTargetType(),
  autoMapFields(), applyFieldMapping(), listSourceFields() + TARGET_TYPES
  / TARGET_FIELDS registries.
- src/import-modal.js — Bootstrap mapping modal: target dropdown,
  field-rename table, three actions (Cancel / Save / Save + Upload now).
- main.js — stageImport hooked into addImportedGeoJSON (the single
  convergence point for shp/GeoJSON/KML drops); handleImportModalResult
  applies the mapping in one transaction; runUpload builds the real
  payload (district_id + api_token from remotePost, user_id_upload from
  SSO session, per-feature client_uuid/geom/props) and currently logs +
  toasts — the upload_<target>.php endpoints are not yet live.
- index.html — #importMappingModal markup.
- MapView._decorateLayerListItem — import-state chip (Upload N /
  spinner / ✓ submitted / ✓ live / N errors) dispatching
  lupmis:import-chip-click; src/styles/layerswitcher.css — chip variants.

GIS export from Area / Circle Analysis popups:
- MapView._showAnalysisPopup now accepts an exportContext (clipGeometry +
  parcelFeatures + zoneFeatures + otherByLayer) and renders an "Export
  GIS" button next to "Export PDF". Click dispatches lupmis:export-gis.
- index.html — #exportGisModal markup.
- src/export-gis-modal.js — Bootstrap modal: format toggle (GeoJSON
  default / Shapefile / KML), filename, field-rename table with SHP
  10-char DBF warning.
- src/gis-export.js — writers: GeoJSON via Blob, KML via OL KMLFormat,
  Shapefile via shp-write (with DBF-safe name sanitiser).
- Adds shp-write@0.3.2 dependency.

MapView style options:
- addGeoJSONLayer now accepts strokeDash for line-dash patterns (used by
  the UPN-grid layer and available for any future contextual overlay).

Service Worker v9 → v10 to evict the stale shell/module caches on the
next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:02:41 +02:00

3823 lines
134 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Main Application Entry Point
*
* Demonstrates integration of:
* - Bootstrap 5.3 for UI components
* - SQLocal (SQLite in browser via OPFS)
* - BroadcastChannel for cross-tab sync
* - OpenLayers map with ol-ext LayerSwitcher
* - PWA features (Service Worker, install prompt, offline detection)
*/
// Bootstrap CSS and JS
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import { Modal, Offcanvas } from 'bootstrap';
// Database module (uses SQLocal directly, BroadcastChannel for tab sync)
import {
sql,
dbReady,
initSchema,
addLocation,
getLocations,
getLocationCount,
getDatabaseStatus,
downloadDatabase,
onDatabaseChange,
exportToGeoJSON,
saveRemoteData,
getRemoteData,
saveCollectorZones,
getLocalCollectorZones,
saveUpnGrid,
getLocalUpnGrid,
createExternalImport,
addExternalImportFeatures,
updateExternalImport,
getExternalImport,
getExternalImportFeatures,
remapImportedFeatureProperties,
saveParcels,
getLocalParcels,
updateParcel,
insertNewParcel,
saveBuildingFootprints,
getLocalBuildingFootprints,
saveOSMRoads,
getLocalOSMRoads,
isCachedLayerTable,
clearTable,
clearAllCachedLayers,
getTableStats,
getTableContent
} from './src/database.js';
// Map component with OpenLayers and ol-ext LayerSwitcher
import { MapView } from './src/components/MapView.js';
// OpenLayers GeoJSON format (for updating layer sources directly)
import GeoJSON from 'ol/format/GeoJSON';
// OpenLayers WKT format (for writing drawn polygon geometries to database)
import WKT from 'ol/format/WKT';
// OpenLayers KML format (for KML file import)
import KML from 'ol/format/KML';
import { Style, Stroke, Fill, Text as OlText } from 'ol/style';
// Shapefile parser (reads .zip containing .shp/.dbf/.shx/.prj)
// Lazy-loaded — only fetched the first time the user imports a shapefile.
let _shpModule = null;
async function getShp() {
if (!_shpModule) {
const mod = await import('shpjs');
_shpModule = mod.default || mod;
}
return _shpModule;
}
// Map measurement and drawing tools
import { MapTools } from './src/components/MapTools.js';
// PWA module (registers Service Worker, handles install/offline)
import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js';
import {
BASEMAP_TEMPLATES,
GHANA_EXTENT_3857,
countTiles,
estimatedSizeBytes,
OfflineTileDownloader,
} from './src/offlineTiles.js';
// Remote database API (PostgreSQL backend)
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getUpnGrid, 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';
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js';
// External-dataset import → staging → upload (see LUPMIS2_Import_Upload_Design.docx)
import { openImportMappingModal } from './src/import-modal.js';
import { applyFieldMapping } from './src/import-detect.js';
// GIS export from the analysis popups (Area / Circle)
import { openExportGisModal } from './src/export-gis-modal.js';
// Map instance (global for access across functions)
let mapView = null;
let mapTools = null;
// Module-level reference so the embed bridge can access the parcels layer
// once loadParcels() has created it.
let parcelsLayer = null;
let embedBridge = null;
// Iframe embed mode. Set by public/embed.php when serving the /embed route;
// undefined for the normal /index.php entry point.
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
// In embed permit mode we don't want the default Add-Location click to fire,
// so start the mode in a neutral state.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation';
// ============================================================================
// Application Initialization
// ============================================================================
/**
* Pre-flight: when an SSO session is present but the user has no district
* assigned, the app cannot function (every API call is scoped to a district).
* Show a blocking message and halt initialisation so we never silently fall
* back to a default district.
*
* Local dev (no window.LUPMIS_SESSION at all) is *not* affected — that path
* still uses the remotedb FALLBACK_DISTRICT_ID for testing.
*
* @returns {boolean} true if the user is blocked (init should abort)
*/
function showNoDistrictBlockerIfNeeded() {
const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null;
if (!session || typeof session !== 'object') return false; // dev mode
const id = session.district_id;
if (id !== null && id !== undefined && String(id).length > 0) return false;
// Authenticated but no district — render an overlay and abort init.
console.warn('[App] Authenticated user has no district assigned; halting init.');
const overlay = document.createElement('div');
overlay.id = 'no-district-overlay';
overlay.setAttribute('role', 'alertdialog');
overlay.setAttribute('aria-modal', 'true');
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' +
'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;';
const name = session.full_name || session.username || 'You';
overlay.innerHTML = `
<div style="max-width:480px;text-align:center;border:1px solid #e5e7eb;
border-radius:12px;padding:28px 24px;box-shadow:0 8px 24px rgba(0,0,0,0.08);
background:#fff;font-family:var(--font-body,sans-serif);">
<div style="font-size:42px;line-height:1;margin-bottom:12px;">🛑</div>
<h2 style="margin:0 0 12px;color:var(--primary,#005eb8);font-size:1.35rem;">
No district assigned
</h2>
<p style="margin:0 0 10px;color:#333;">
${escapeHtml(name)}, your user profile is not associated with any
district. LUPMIS2 cannot load the relevant map data without one.
</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:0.95rem;">
Please contact the system administrator to have a district assigned
to your account.
</p>
<button type="button" id="no-district-portal-btn"
style="background:var(--primary,#005eb8);color:#fff;border:0;
border-radius:8px;padding:10px 18px;font-weight:600;cursor:pointer;">
Return to LUSPA portal
</button>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => {
window.location.href = 'https://lupmis4luspa.org/';
});
return true;
}
async function initApp() {
console.log('[App] Initializing...');
// Pre-flight: authenticated user must have a district assigned.
if (showNoDistrictBlockerIfNeeded()) return;
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
await initPWA({
installButton: '#install-btn',
offlineIndicator: '#offline-indicator',
autoRegisterSW: true
});
// 2. Initialize the map
// Restore the user's preferred default base map from localStorage
const savedBasemap = localStorage.getItem('default-basemap') || 'topo';
mapView = new MapView('map', {
center: [-1.5, 7.5], // Ghana
zoom: 7,
basemap: savedBasemap,
});
// Initialize map measurement tools
mapTools = new MapTools(mapView.getMap());
// Wire up GPS live-position + trail recording
initGpsTracking();
// Handle measurement results
mapTools.onMeasureComplete((result) => {
console.log('[MapTools] Measurement complete:', result);
// Only show the Polygon Attributes popup for polygons drawn with the
// Draw tool — NOT for area measurements (which have _layerType = 'measure_area').
if (result.type === 'polygon' && result.coordinate) {
const lt = result.feature?.get('_layerType');
if (lt !== 'measure_area') {
mapView?.showDrawnPolygonPopup(result.feature, result.coordinate);
}
}
});
// Category emojis are set up in MapView:
// 'water': '💧', 'school': '🏫', 'health': '🏥',
// 'market': '🏪', 'default': '📍', 'other': '📌'
// In iframe embed permit mode, install the postMessage bridge BEFORE the
// regular handlers so its outbound parcel:select / parcel:cleared events
// are wired up; the regular click/dblclick handlers below short-circuit in
// that mode (the bridge owns map interaction in the embed).
if (IS_EMBED_PERMIT) {
embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG });
}
// Set up map click handler immediately after map creation
mapView.onClick((lon, lat, feature, evt) => {
// Embed permit mode: the bridge handles parcel selection itself; the
// normal popup/add-location behaviour does not apply.
if (IS_EMBED_PERMIT) return;
console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
console.log('[MapClick] currentMode =', currentMode);
// In draw or measurement modes, clicks drive the tool — don't
// open popups or select features.
if (currentMode === 'draw' || currentMode.startsWith('measure')) {
return;
}
// Check if a parcel feature was clicked
let parcelFeature = null;
mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
if (f.get('_layerType') === 'parcel') {
parcelFeature = f;
return true; // stop at first parcel hit
}
});
// Parcel click: open Edit Attributes form in ANY non-draw mode.
// The feature is NOT selected — only the popup is shown.
if (parcelFeature) {
console.log('[MapClick] Clicked on parcel → Edit Attributes');
mapView.showParcelEditPopup(parcelFeature, evt.coordinate);
return;
}
// UPN-grid cell click: show a popup with the upn_prefix. This runs in
// ANY non-draw mode and is checked AFTER the parcel branch so that a
// parcel sitting inside a grid cell wins (parcels are the specific
// object, the grid is contextual).
let upnGridFeature = null;
mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
if (f.get('_layerType') === 'upn_grid') {
upnGridFeature = f;
return true;
}
});
if (upnGridFeature) {
console.log('[MapClick] Clicked on UPN-grid cell → Info popup');
mapView.showInfoPopup(upnGridFeature, evt.coordinate, {
title: 'UPN Grid Cell',
color: '#7c3aed',
});
return;
}
// Non-parcel clicks (markers, empty space) only in addLocation mode
if (currentMode !== 'addLocation') {
return;
}
if (feature) {
// Clicked on existing marker - select it and show details
console.log('[MapClick] Clicked on marker:', feature.getId());
mapView.selectMarker(feature);
showLocationDetails(feature);
} else {
// Clicked on empty space - show add location popup at click position
console.log('[MapClick] Empty space → Add Location popup');
mapView.clearSelection();
mapView.showAddLocationPopup(evt.coordinate);
}
});
// Set up double-click handler for overlay feature info
// Uses '_layerType' property to distinguish zone features from other layers
mapView.onDblClick((lon, lat, feature, evt) => {
// Embed permit mode shows no info popups (the host owns the UI).
if (IS_EMBED_PERMIT) return;
if (!feature) return;
const layerType = feature.get('_layerType');
console.log('[App] Double-click on feature, _layerType:', layerType || 'none');
if (layerType === 'measure_circle') {
// Circle measurement: show intersection analysis with other layers
mapView.showCircleIntersectionPopup(feature, evt.coordinate);
} else if (layerType === 'measure_circle_radius') {
// Clicked on the radius line — ignore
return;
} else if (layerType === 'measure_area') {
// Area measurement polygon: show intersection analysis
mapView.showAreaIntersectionPopup(feature, evt.coordinate);
} else if (layerType === 'collector_zone') {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Zone Info',
color: '#7c3aed',
});
} else if (layerType === 'parcel') {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Parcel Info',
color: '#0ea5e9',
});
} else {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Feature Info',
color: '#e11d48',
});
}
});
// Set up handler for the map add location popup form
mapView.onAddLocation(async (data) => {
console.log('[App] Add location from map popup:', data);
try {
const result = await addLocation(data.name, data.lon, data.lat, {
description: data.description || null,
category: data.category || 'default'
});
console.log('[App] Location added:', data.name, 'id:', result.id);
await loadLocations();
// Zoom to the new location on the map
mapView?.zoomTo(data.lon, data.lat, 14);
// Select the new marker
if (result.id) {
mapView?.selectMarker(result.id);
}
showSuccess('Location added successfully');
} catch (error) {
console.error('[App] Failed to add location:', error);
showError('Failed to add location: ' + error.message);
}
});
// Set up parcel edit save handler
mapView.onParcelEdit(async (feature, updatedProps) => {
const parcelId = updatedProps.id || updatedProps.parcelid || updatedProps.parcel_id;
console.log('[App] Parcel edit saved:', parcelId, updatedProps);
if (!parcelId) {
console.warn('[App] No parcel ID found in updated properties — skipping local save');
return;
}
try {
await updateParcel(parcelId, updatedProps);
showSuccess('Parcel updated locally');
} catch (error) {
console.error('[App] Failed to save parcel update:', error);
showError('Failed to save parcel: ' + error.message);
}
});
// Set up drawn polygon attribute save handler
const wktFormat = new WKT();
mapView.onDrawnPolygonSave(async (feature, props) => {
console.log('[App] Drawn polygon attributes saved:', props);
try {
// Convert the OL geometry (EPSG:3857) to WKT in EPSG:4326 for storage
const wktString = wktFormat.writeGeometry(feature.getGeometry(), {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
const result = await insertNewParcel(wktString, props);
console.log('[App] New parcel inserted with id:', result.id);
showSuccess('New parcel saved (pending verification)');
} catch (error) {
console.error('[App] Failed to save new parcel:', error);
showError('Failed to save parcel: ' + error.message);
}
});
// 3. Initialize database
try {
console.log('[App] Initializing database...');
// Initialize schema (creates tables if they don't exist)
// This also resolves dbReady when complete
await initSchema();
// Now dbReady should be resolved
console.log('[App] Database ready');
// Show database status
const status = await getDatabaseStatus();
console.log('[App] Database status:', status);
// Quick server reachability check (5 s timeout) — if the API server
// is down, all load functions will skip remote fetches and fall back
// to local cached data immediately, keeping the app responsive.
if (isOnline()) {
const reachable = await checkServerReachable();
if (!reachable) {
console.warn('[App] API server unreachable — using local data only');
showWarning('Server not responding — loading cached data.');
}
}
// Load remote overlays (needs remote_data table from initSchema)
// loadLayers must complete first so the layer groups exist
// before loadDistrictBoundary adds into the Administration group.
await loadLayers();
// Initialise EditBar with its own "Drawings" layer group
mapView?.initEditBar();
loadDistrictBoundary();
loadUpnGrid();
loadCollectorZones();
loadParcels();
// In embed permit mode the parcels layer is the user's working surface,
// so make it visible immediately and hand the layer to the bridge so it
// can emit `ready` (and resolve any pending `set:selected` UPN) once the
// features arrive. loadParcels() runs its synchronous prologue (creating
// the layer and assigning the module-level reference) before returning
// its promise, so `parcelsLayer` is already set here.
if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) {
parcelsLayer.setVisible(true);
embedBridge.attachParcelsLayer(parcelsLayer);
}
loadBuildingFootprints();
loadContoursHillshade();
loadOSMRoads();
loadExternalWMSLayers();
} catch (error) {
console.error('[App] Database initialization failed:', error);
showError('Failed to initialize database. Please refresh the page.');
return;
}
// 4. Initialize UI
initUI();
// 5. Load initial data and display on map
await loadLocations();
// 6. Listen for database changes (local + other tabs)
onDatabaseChange((change) => {
console.log('[App] Database change:', change);
if (change.table === 'locations' && !change.local) {
// Reload locations when another tab makes changes
loadLocations();
}
if (change.table === 'parcels') {
// Refresh the Local Data stats panel if it is visible
const statsContainer = document.getElementById('local-data-stats');
if (statsContainer && !statsContainer.classList.contains('d-none')) {
refreshLocalDataStats();
}
}
});
// 7. Set up offline handling
onOfflineChange((offline) => {
if (offline) {
console.log('[App] Working offline - data will sync when back online');
} else {
console.log('[App] Back online - syncing data...');
syncData();
}
});
// 8. Fieldwork mode (high-contrast + large touch targets)
initFieldworkMode();
// 9. Measurement system toggle (metric / imperial)
initMeasurementSystem();
// 10. Dark mode
initDarkMode();
// 11. Default base map selector
initDefaultBasemap();
// 12. Offline tile-cache stats card
initOfflineTileCache();
// 13. Offline-download dialog
initOfflineDownloadDialog();
// 14. Account card (signed-in user + sign-out)
initAccountCard();
console.log('[App] Initialized successfully');
}
// ============================================================================
// UI Initialization
// ============================================================================
function initUI() {
console.log('[initUI] Starting UI initialization...');
// Message log (persistent stack in right panel)
initMessageLog();
// Export button
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', handleExport);
}
// Local Data button — shows tables and record counts
const localDataBtn = document.getElementById('local-data-btn');
if (localDataBtn) {
localDataBtn.addEventListener('click', () => refreshLocalDataStats());
}
// File import buttons (Shapefile, GeoJSON, KML)
const importShpBtn = document.getElementById('import-shp-btn');
const shpFileInput = document.getElementById('shp-file-input');
if (importShpBtn && shpFileInput) {
importShpBtn.addEventListener('click', () => shpFileInput.click());
shpFileInput.addEventListener('change', handleShapefileImport);
}
const importGeoJSONBtn = document.getElementById('import-geojson-btn');
const geojsonFileInput = document.getElementById('geojson-file-input');
if (importGeoJSONBtn && geojsonFileInput) {
importGeoJSONBtn.addEventListener('click', () => geojsonFileInput.click());
geojsonFileInput.addEventListener('change', handleGeoJSONImport);
}
const importKMLBtn = document.getElementById('import-kml-btn');
const kmlFileInput = document.getElementById('kml-file-input');
if (importKMLBtn && kmlFileInput) {
importKMLBtn.addEventListener('click', () => kmlFileInput.click());
kmlFileInput.addEventListener('change', handleKMLImport);
}
// Drag-and-drop file import on the map
initMapDropZone();
// GeoJSON Export button
const exportGeoJSONBtn = document.getElementById('exportGeoJSON-btn');
if (exportGeoJSONBtn) {
exportGeoJSONBtn.addEventListener('click', handleExportGeoJSON);
}
// Status button
const statusBtn = document.getElementById('status-btn');
if (statusBtn) {
statusBtn.addEventListener('click', handleShowStatus);
}
// Fit to markers button
const fitBtn = document.getElementById('fit-btn');
if (fitBtn) {
fitBtn.addEventListener('click', () => mapView?.fitToMarkers());
}
// ============================================
// Mode Selector & Measurement Tools (Bottom Dock)
// ============================================
const addLocationBtn = document.getElementById('dock-btn-add-location');
const measureCircleBtn = document.getElementById('dock-btn-measure-circle');
const measureLineBtn = document.getElementById('dock-btn-measure-line');
const measureAreaBtn = document.getElementById('dock-btn-measure-area');
const drawBtn = document.getElementById('dock-btn-draw');
const clearBtn = document.getElementById('dock-btn-clear');
// Debug: Check if buttons are found
console.log('[initUI] Buttons found:', {
addLocation: !!addLocationBtn,
measureCircle: !!measureCircleBtn,
measureLine: !!measureLineBtn,
measureArea: !!measureAreaBtn,
draw: !!drawBtn,
clear: !!clearBtn
});
// All mode buttons (mutually exclusive)
const modeButtons = [addLocationBtn, measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn];
// Helper to set active mode and update button states
// Note: This updates the module-level currentMode variable
const setMode = (mode, activeBtn) => {
console.log('[setMode] Changing mode from', currentMode, 'to', mode);
currentMode = mode;
console.log('[setMode] currentMode is now:', currentMode);
// Update button active states
modeButtons.forEach(btn => {
if (btn) btn.classList.toggle('active', btn === activeBtn);
});
// Deactivate any measurement tool when switching modes
mapTools?.deactivate();
// Leave edit mode when switching away from draw
if (mode !== 'draw') {
mapView?.setEditMode(false);
}
// Hide add location popup when leaving addLocation mode
if (mode !== 'addLocation') {
mapView?.hideAddLocationPopup();
}
// Activate the appropriate tool for the new mode
switch (mode) {
case 'measureCircle':
mapTools?.startCircleMeasure();
break;
case 'measureLine':
mapTools?.startLineMeasure();
break;
case 'measureArea':
mapTools?.startAreaMeasure();
break;
case 'draw':
mapView?.setEditMode(true);
break;
// addLocation mode doesn't need tool activation
}
};
// Add Location mode button
if (addLocationBtn) {
addLocationBtn.addEventListener('click', () => {
console.log('[Button] Add Location clicked');
setMode('addLocation', addLocationBtn);
});
}
// Circle measurement button
if (measureCircleBtn) {
measureCircleBtn.addEventListener('click', () => {
console.log('[Button] Circle clicked, currentMode is:', currentMode);
if (currentMode === 'measureCircle') {
// Toggle off - return to addLocation mode
setMode('addLocation', addLocationBtn);
} else {
setMode('measureCircle', measureCircleBtn);
}
});
}
// Line measurement button
if (measureLineBtn) {
measureLineBtn.addEventListener('click', () => {
console.log('[Button] Line clicked, currentMode is:', currentMode);
if (currentMode === 'measureLine') {
setMode('addLocation', addLocationBtn);
} else {
setMode('measureLine', measureLineBtn);
}
});
}
// Area measurement button
if (measureAreaBtn) {
measureAreaBtn.addEventListener('click', () => {
console.log('[Button] Area clicked, currentMode is:', currentMode);
if (currentMode === 'measureArea') {
setMode('addLocation', addLocationBtn);
} else {
setMode('measureArea', measureAreaBtn);
}
});
}
// Draw / Edit button
if (drawBtn) {
drawBtn.addEventListener('click', () => {
console.log('[Button] Draw clicked, currentMode is:', currentMode);
if (currentMode === 'draw') {
setMode('addLocation', addLocationBtn);
} else {
setMode('draw', drawBtn);
}
});
}
// Clear button - clears measurements but stays in current mode
if (clearBtn) {
clearBtn.addEventListener('click', () => {
mapTools?.clearMeasurements();
// If in a measurement mode, restart the tool
if (currentMode.startsWith('measure')) {
mapTools?.deactivate();
switch (currentMode) {
case 'measureCircle':
mapTools?.startCircleMeasure();
break;
case 'measureLine':
mapTools?.startLineMeasure();
break;
case 'measureArea':
mapTools?.startAreaMeasure();
break;
}
}
});
}
}
// ============================================================================
// Location Handlers
// ============================================================================
async function handleAddLocation(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const name = formData.get('name');
const longitude = parseFloat(formData.get('longitude'));
const latitude = parseFloat(formData.get('latitude'));
const description = formData.get('description') || null;
const category = formData.get('category') || 'default';
if (!name || isNaN(longitude) || isNaN(latitude)) {
showError('Please fill in all required fields');
return;
}
try {
const result = await addLocation(name, longitude, latitude, { description, category });
console.log('[App] Location added:', name, 'id:', result.id);
form.reset();
await loadLocations();
// Zoom to the new location on the map
mapView?.zoomTo(longitude, latitude, 14);
// Select the new marker
if (result.id) {
mapView?.selectMarker(result.id);
}
showSuccess('Location added successfully');
} catch (error) {
console.error('[App] Failed to add location:', error);
showError('Failed to add location: ' + error.message);
}
}
async function loadLocations() {
try {
console.log('[App] Loading locations...');
const locations = await getLocations();
console.log('[App] Locations loaded:', locations);
// Update the list
renderLocations(locations);
// Update the map markers
if (mapView) {
mapView.clearMarkers();
if (locations.length > 0) {
mapView.addMarkers(locations);
console.log('[App] Added', locations.length, 'markers to map');
}
}
// Update count display
const countEl = document.getElementById('location-count');
if (countEl) {
countEl.textContent = locations.length;
}
} catch (error) {
console.error('[App] Failed to load locations:', error);
}
}
/**
* Show details for a selected location
*/
function showLocationDetails(feature) {
const name = feature.get('name');
const description = feature.get('description');
const category = feature.get('category');
const lon = feature.get('lon') || feature.get('longitude');
const lat = feature.get('lat') || feature.get('latitude');
// You could show a popup or info panel here
// For now, just log to console
console.log('[App] Selected location:', { name, description, category, lon, lat });
// Optionally zoom to the location
// mapView.zoomTo(lon, lat, 14);
}
function renderLocations(locations) {
const container = document.getElementById('locations-list');
if (!container) return;
// Also update mobile count
const mobileCount = document.getElementById('location-count-mobile');
if (mobileCount) {
mobileCount.textContent = locations.length;
}
if (locations.length === 0) {
container.innerHTML = `
<div class="text-center text-muted py-4">
<p class="mb-0">No locations yet.</p>
<small>Click the map or fill the form above!</small>
</div>
`;
return;
}
// Category emoji mapping
const categoryEmojis = {
'water': '💧',
'school': '🏫',
'health': '🏥',
'market': '🏪',
'default': '📍',
'other': '📌'
};
container.innerHTML = locations.map(loc => {
const emoji = categoryEmojis[loc.category] || '📍';
return `
<a href="#" class="list-group-item list-group-item-action location-item py-2"
data-id="${loc.id}" data-lon="${loc.longitude}" data-lat="${loc.latitude}">
<div class="d-flex w-100 justify-content-between align-items-start">
<div>
<h6 class="mb-1">${emoji} ${escapeHtml(loc.name)}</h6>
<small class="text-muted font-monospace">${loc.latitude.toFixed(5)}, ${loc.longitude.toFixed(5)}</small>
</div>
<span class="badge badge-${loc.category}">${loc.category}</span>
</div>
${loc.description ? `<small class="text-secondary d-block mt-1">${escapeHtml(loc.description)}</small>` : ''}
</a>
`;
}).join('');
// Add click handlers to zoom to location
container.querySelectorAll('.location-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const lon = parseFloat(item.dataset.lon);
const lat = parseFloat(item.dataset.lat);
const id = parseInt(item.dataset.id);
// Zoom to location on map
mapView?.zoomTo(lon, lat, 14);
// Select the marker
mapView?.selectMarker(id);
});
});
}
// ============================================================================
// Local Data Stats
// ============================================================================
/**
* Refresh the Local Data stats panel in the left offcanvas.
* If the panel is already visible it updates in-place; otherwise it opens it.
*/
async function refreshLocalDataStats() {
const statsContainer = document.getElementById('local-data-stats');
const tbody = document.getElementById('local-data-tbody');
const clearAllBtn = document.getElementById('clear-all-cached-btn');
if (!statsContainer || !tbody) return;
try {
const stats = await getTableStats();
tbody.innerHTML = stats.map((t) => {
const isCached = isCachedLayerTable(t.name);
const clearBtn = isCached
? `<button type="button" class="btn btn-sm btn-link text-danger p-0 table-clear-btn"
data-table="${escapeHtml(t.name)}"
title="Clear local cache (will re-download from server)">
<i class="bi bi-trash3"></i>
</button>`
: '';
return `
<tr>
<td class="ps-3">
<a href="#" class="table-name-link" data-table="${escapeHtml(t.name)}">${escapeHtml(t.name)}</a>
</td>
<td class="text-end"><span class="badge bg-secondary">${t.count}</span></td>
<td class="text-end pe-3">${clearBtn}</td>
</tr>
`;
}).join('');
statsContainer.classList.remove('d-none');
// Table-name link → open content modal
tbody.querySelectorAll('.table-name-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTableContent(link.dataset.table);
});
});
// Per-row clear → confirm, clear that table, refresh stats
tbody.querySelectorAll('.table-clear-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const tableName = btn.dataset.table;
if (!confirm(`Clear local cache for "${tableName}"?\n\nThe data will be re-downloaded from the server on the next app start.`)) return;
try {
const removed = await clearTable(tableName);
showSuccess(`Cleared ${removed} row${removed === 1 ? '' : 's'} from "${tableName}". It will re-download on next start.`);
await refreshLocalDataStats();
} catch (err) {
console.error('[App] Per-table clear failed:', err);
showError(`Could not clear "${tableName}": ${err.message}`);
}
});
});
} catch (error) {
console.error('[App] Failed to load table stats:', error);
tbody.innerHTML = `<tr><td colspan="3" class="text-danger ps-3">Failed to load</td></tr>`;
statsContainer.classList.remove('d-none');
}
// Bulk-clear button — wire up once
if (clearAllBtn && !clearAllBtn._wired) {
clearAllBtn._wired = true;
clearAllBtn.addEventListener('click', handleClearAllCachedLayers);
}
}
/**
* Clear every cached layer table and offer to reload the app so the layers
* re-download immediately. If the user dismisses the reload prompt, the
* fresh fetch will happen on the next manual app start.
*/
async function handleClearAllCachedLayers() {
if (!confirm(
'Delete all cached map layers from this device?\n\n' +
'The next time the app starts (or after a reload), every layer will be ' +
're-downloaded from the server. Your locally drawn data is not affected.'
)) return;
try {
const results = await clearAllCachedLayers();
const total = results.reduce((s, r) => s + r.count, 0);
showSuccess(`Cleared ${total} row${total === 1 ? '' : 's'} across ${results.length} table${results.length === 1 ? '' : 's'}.`);
await refreshLocalDataStats();
if (confirm('Reload the app now to re-download the layers fresh from the server?')) {
window.location.reload();
}
} catch (err) {
console.error('[App] Clear-all failed:', err);
showError('Failed to clear cached layers: ' + err.message);
}
}
// ============================================================================
// Table Content Viewer
// ============================================================================
/**
* Load and display all rows of a table in a modal.
* @param {string} tableName - The table to show
*/
async function showTableContent(tableName) {
const modalTitle = document.getElementById('tableContentModalLabel');
const modalBody = document.getElementById('table-content-body');
const modalInfo = document.getElementById('table-content-info');
// Set title and show spinner
modalTitle.textContent = `Table: ${tableName}`;
modalBody.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
`;
modalInfo.textContent = '';
// Open the modal
const modal = new Modal(document.getElementById('tableContentModal'));
modal.show();
try {
const { columns, rows } = await getTableContent(tableName);
if (rows.length === 0) {
modalBody.innerHTML = `<div class="text-center text-muted py-4">Table is empty</div>`;
modalInfo.textContent = '0 rows';
return;
}
// Build a responsive table
const headerCells = columns.map(c => `<th class="text-nowrap">${escapeHtml(c)}</th>`).join('');
const bodyRows = rows.map(row => {
const cells = columns.map(c => {
let val = row[c];
if (val === null || val === undefined) return '<td class="text-muted fst-italic">NULL</td>';
val = String(val);
// Truncate long values for display
const display = val.length > 120 ? val.substring(0, 120) + '...' : val;
return `<td>${escapeHtml(display)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
modalBody.innerHTML = `
<div class="table-responsive">
<table class="table table-sm table-striped table-hover mb-0" style="font-size:12px;">
<thead class="table-light">
<tr>${headerCells}</tr>
</thead>
<tbody>${bodyRows}</tbody>
</table>
</div>
`;
modalInfo.textContent = `${rows.length}${rows.length >= 200 ? '+' : ''} row(s), ${columns.length} column(s)`;
} catch (error) {
console.error('[App] Failed to load table content:', error);
modalBody.innerHTML = `<div class="text-danger text-center py-4">Failed to load: ${escapeHtml(error.message)}</div>`;
}
}
// ============================================================================
// Export Handler
// ============================================================================
async function handleExport() {
try {
await downloadDatabase('lupmis-backup.sqlite3');
showSuccess('Database exported successfully');
} catch (error) {
console.error('[App] Export failed:', error);
showError('Export failed: ' + error.message);
}
}
// Export as GeoJSON file
async function handleExportGeoJSON() {
try {
const geojson = await exportToGeoJSON();
// Download as file
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'locations.geojson';
a.click();
URL.revokeObjectURL(url);
showSuccess(`Exported ${geojson.features.length} location(s)`);
}catch (error) {
console.error('[App] GeoJSON Export failed:', error);
showError('GeoJSON Export failed: ' + error.message);
}
}
// ============================================================================
// Status Handler
// ============================================================================
async function handleShowStatus() {
try {
const status = await getDatabaseStatus();
// Update modal content
const statusContent = document.getElementById('status-content');
if (statusContent) {
statusContent.innerHTML = `
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="fw-semibold">Ready:</td>
<td><span class="badge ${status.ready ? 'bg-success' : 'bg-danger'}">${status.ready ? 'Yes' : 'No'}</span></td>
</tr>
<tr>
<td class="fw-semibold">Online:</td>
<td><span class="badge ${isOnline() ? 'bg-success' : 'bg-warning'}">${isOnline() ? 'Yes' : 'Offline'}</span></td>
</tr>
<tr>
<td class="fw-semibold">Database:</td>
<td><code>${status.databasePath || 'N/A'}</code></td>
</tr>
<tr>
<td class="fw-semibold">Tables:</td>
<td>${status.tables.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('')}</td>
</tr>
<tr>
<td class="fw-semibold">Locations:</td>
<td><span class="badge bg-primary">${status.locationCount}</span></td>
</tr>
</tbody>
</table>
`;
}
// Show the modal using Bootstrap
const statusModal = new Modal(document.getElementById('statusModal'));
statusModal.show();
} catch (error) {
console.error('[App] Failed to get status:', error);
showError('Failed to get status');
}
}
// ============================================================================
// Remote Data Loading
// ============================================================================
/**
* Parse a coordinate ring string into an array of [lon, lat] pairs.
* @param {string} ringStr - e.g. "lon lat,lon lat,..."
* @returns {Array} Array of [lon, lat]
*/
function parseCoordRing(ringStr) {
return ringStr.replace(/^\(+/, '').replace(/\)+$/, '')
.split(',')
.map(pair => {
const [lon, lat] = pair.trim().split(/\s+/).map(Number);
return [lon, lat];
});
}
/**
* Parse a WKT POLYGON string into GeoJSON geometry.
* Handles: POLYGON((lon lat,...),(hole,...))
*
* @param {string} wkt - WKT POLYGON string
* @returns {Object} GeoJSON geometry { type: 'Polygon', coordinates: [...] }
*/
function parseWKTPolygon(wkt) {
const inner = wkt.trim()
.replace(/^POLYGON\s*\(\s*/i, '')
.replace(/\s*\)$/, '');
const ringStrings = inner.split('),(');
const rings = ringStrings.map(parseCoordRing);
return { type: 'Polygon', coordinates: rings };
}
/**
* Parse a WKT MULTIPOLYGON string into GeoJSON geometry.
* Handles: MULTIPOLYGON(((lon lat,...),(hole),...),((polygon2)))
*
* @param {string} wkt - WKT MULTIPOLYGON string
* @returns {Object} GeoJSON geometry { type: 'MultiPolygon', coordinates: [...] }
*/
function parseWKTMultiPolygon(wkt) {
const inner = wkt.trim()
.replace(/^MULTIPOLYGON\s*\(\s*/i, '')
.replace(/\s*\)$/, '');
const polygonStrings = inner.split(')),((');
const polygons = polygonStrings.map(polyStr => {
const cleaned = polyStr.replace(/^\(+/, '').replace(/\)+$/, '');
const ringStrings = cleaned.split('),(');
return ringStrings.map(parseCoordRing);
});
return { type: 'MultiPolygon', coordinates: polygons };
}
/**
* Parse any supported WKT geometry string (POLYGON or MULTIPOLYGON).
* @param {string} wkt - WKT geometry string
* @returns {Object|null} GeoJSON geometry or null if unsupported
*/
function parseWKT(wkt) {
if (!wkt) return null;
const trimmed = wkt.trim().toUpperCase();
if (trimmed.startsWith('MULTIPOLYGON')) return parseWKTMultiPolygon(wkt);
if (trimmed.startsWith('POLYGON')) return parseWKTPolygon(wkt);
console.warn('[App] Unsupported WKT type:', trimmed.substring(0, 30));
return null;
}
/**
* Convert the API response to a GeoJSON FeatureCollection.
* The API returns: { success, data: { boundary: "MULTIPOLYGON(...)", districtid, district_name } }
*
* @param {Object} apiResponse - Raw API response
* @returns {Object|null} GeoJSON FeatureCollection or null
*/
function apiResponseToGeoJSON(apiResponse) {
if (!apiResponse?.success || !apiResponse?.data?.boundary) {
console.warn('[App] API response missing success or boundary data');
return null;
}
const { boundary, districtid, district_name } = apiResponse.data;
const geometry = parseWKT(boundary);
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {
districtid: districtid,
district_name: district_name
},
geometry: geometry
}]
};
}
/**
* Convert the collector zones API response to a GeoJSON FeatureCollection.
* The API returns: { success, data: [{ id, zone_name, boundary, ... }, ...] }
* Each zone feature gets a '_layerType' = 'collector_zone' property for identification.
*
* @param {Array} zones - Array of zone objects from the API
* @returns {Object|null} GeoJSON FeatureCollection or null
*/
function zonesToGeoJSON(zones) {
if (!Array.isArray(zones) || zones.length === 0) return null;
const features = [];
for (const zone of zones) {
// API returns WKT in 'polygon' field (not 'boundary')
const wkt = zone.polygon || zone.boundary;
const geometry = parseWKT(wkt);
if (!geometry) continue;
// Collect all properties except the raw WKT geometry
const properties = { _layerType: 'collector_zone' };
for (const [key, value] of Object.entries(zone)) {
if (key === 'polygon' || key === 'boundary') continue;
properties[key] = value;
}
features.push({ type: 'Feature', properties, geometry });
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
/**
* Load district boundary with local-first strategy:
* 1. Always read from local SQLite cache (GeoJSON) first — instant, works offline
* 2. If online, fetch from API, convert WKT → GeoJSON, cache and display
*/
async function loadDistrictBoundary() {
const CACHE_KEY = 'district_boundary';
const ADMIN_GROUP_ID = 1; // Administration layer group
const boundaryStyle = {
strokeColor: '#e11d48',
strokeWidth: 2.5,
fillColor: 'rgba(225,29,72,0.08)',
typeDescription: 'Vector / Polygon',
};
// Target group: Administration (id 1), fall back to root overlay group
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
/**
* Remove existing District Boundary layer from a group's layers.
*/
function removeBoundaryLayer(group) {
if (!group) return;
const layers = group.getLayers();
const toRemove = [];
layers.forEach((layer) => {
if (layer.get('title') === 'District Boundary') {
toRemove.push(layer);
}
});
toRemove.forEach((layer) => layers.remove(layer));
}
/**
* Zoom the map to fit the boundary layer's extent.
*/
function zoomToBoundary(layer) {
if (!layer || !mapView) return;
const extent = layer.getSource().getExtent();
if (extent && extent[0] !== Infinity) {
mapView.getMap().getView().fit(extent, {
padding: [40, 40, 40, 40],
duration: 600,
});
}
}
try {
// Step 1: Load from local cache (already stored as GeoJSON)
const cached = await getRemoteData(CACHE_KEY);
if (cached) {
console.log('[App] District boundary loaded from local cache');
const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
zoomToBoundary(layer);
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching district boundary from API...');
const apiResponse = await getDistrictBoundary();
// Convert WKT response to GeoJSON
const geojson = apiResponseToGeoJSON(apiResponse);
if (!geojson) {
console.warn('[App] Could not convert API response to GeoJSON');
return;
}
console.log('[App] District boundary:', geojson.features[0]?.properties?.district_name,
'→', geojson.features[0]?.geometry?.coordinates?.length, 'polygon(s)');
// Save converted GeoJSON to local cache for offline use
await saveRemoteData(CACHE_KEY, geojson);
// Replace old cached layer if present
if (cached) {
removeBoundaryLayer(adminGroup || mapView?.getOverlayGroup());
}
const layer = mapView?.addGeoJSONLayer(geojson, 'District Boundary', boundaryStyle, adminGroup);
zoomToBoundary(layer);
console.log('[App] District boundary loaded from API');
} else if (!cached) {
console.log('[App] District boundary not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load district boundary:', error);
}
}
/**
* Convert UPN-grid rows (either from the API or the local cache) into a
* GeoJSON FeatureCollection. Each feature carries only `_layerType` and
* `upn_prefix` as properties so the click popup shows nothing but the
* prefix (no fetched_at, no internal IDs).
*/
function upnGridToGeoJSON(rows) {
if (!Array.isArray(rows) || rows.length === 0) return null;
const features = [];
for (const r of rows) {
const wkt = r.polygon || r.geometry_wkt || r.geom;
const geometry = parseWKT(wkt);
if (!geometry) continue;
features.push({
type: 'Feature',
properties: { _layerType: 'upn_grid', upn_prefix: r.upn_prefix ?? null },
geometry,
});
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
/**
* Load the UPN-grid (district sub-division) layer into the Administration
* group. The grid never changes, so it is fetched once per district and
* served from the local cache thereafter; a fetch only happens when no
* cells are cached for the current district (e.g. first load, or the user
* now belongs to a different district).
*
* The "UPN Grid" layer is added to the Administration LayerGroup (id 1),
* initially not visible — the user toggles it in the LayerSwitcher.
*/
async function loadUpnGrid() {
const ADMIN_GROUP_ID = 1;
// Base style passed to addGeoJSONLayer keeps the LayerSwitcher subtitle
// ("Vector / Polygon") and a sensible initial render. We override the
// visual style below via setStyle() with a zoom-aware function.
const upnGridStyle = {
strokeColor: '#5b21b6',
strokeWidth: 1.5,
fillColor: 'rgba(124,58,237,0.04)',
typeDescription: 'Vector / Polygon',
};
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const upnGridLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'UPN Grid', upnGridStyle, adminGroup);
if (!upnGridLayer) {
console.warn('[App] Could not create UPN Grid layer');
return;
}
upnGridLayer.setVisible(false);
// Custom style function:
// - White casing under a bolder violet dashed line so the grid stays
// visible on top of (or under) the sky-blue Parcels layer.
// - Label: render the feature's upn_prefix at scales ≥ 1:25,000.
// Using the OGC convention scale = resolution / 0.00028, that is
// resolution ≤ 7 m/px in Web Mercator.
const UPN_LABEL_MAX_RESOLUTION = 7; // ≈ 1:25,000
upnGridLayer.setStyle((feature, resolution) => {
const styles = [
// 1) White halo / casing — drawn first, so it sits underneath.
new Style({
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3.5 }),
}),
// 2) Violet dashed stroke + faint fill.
new Style({
stroke: new Stroke({ color: '#5b21b6', width: 1.5, lineDash: [7, 4] }),
fill: new Fill({ color: 'rgba(124,58,237,0.04)' }),
}),
];
// 3) Label, only when sufficiently zoomed in.
if (resolution <= UPN_LABEL_MAX_RESOLUTION) {
const prefix = feature.get('upn_prefix');
if (prefix != null && String(prefix).length > 0) {
styles.push(new Style({
text: new OlText({
text: String(prefix),
font: '600 12px Arial, sans-serif',
fill: new Fill({ color: '#3b0764' }),
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }),
overflow: true, // allow drawing in tight cells
}),
}));
}
}
return styles;
});
upnGridLayer.on('change:visible', () => {
if (upnGridLayer.getVisible() && upnGridLayer.getSource().getFeatures().length === 0) {
showError('No UPN grid available locally. Connect to the internet to download it.');
}
});
function setFeatures(geojson) {
const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
upnGridLayer.getSource().clear();
upnGridLayer.getSource().addFeatures(newFeatures);
}
try {
const session = getSession();
const districtId = session?.district_id ?? null;
// 1) Local cache for the CURRENT district → use it; skip the API call.
// The cache is keyed by districtid so a cache from another district
// (e.g. dev fallback) won't satisfy this lookup and we'll re-fetch.
const cached = await getLocalUpnGrid(districtId);
if (cached) {
const geojson = upnGridToGeoJSON(cached);
if (geojson) setFeatures(geojson);
console.log('[App] UPN grid from cache:', cached.length, 'cells (district', districtId, ')');
return;
}
// 2) Not cached for this district → fetch, save, render. If offline,
// leave the layer empty; the toggle handler above will explain.
if (!isOnline() || !isServerReachable()) {
console.log('[App] UPN grid not available — offline and no cache for district', districtId);
return;
}
console.log('[App] Fetching UPN grid from API (district', districtId, ')...');
const apiResponse = await getUpnGrid();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getUpnGrid invalid response:', apiResponse);
return;
}
const rows = apiResponse.data;
console.log('[App] UPN grid from API:', rows.length, 'cells');
await saveUpnGrid(rows, districtId);
const geojson = upnGridToGeoJSON(rows);
if (geojson) setFeatures(geojson);
console.log('[App] UPN grid loaded:', geojson?.features.length ?? 0, 'cells rendered');
} catch (error) {
console.error('[App] Failed to load UPN grid:', error);
}
}
/**
* Load collector zones with local-first strategy:
* 1. Read from local collector_zones table → convert to GeoJSON → display
* 2. If online, fetch from API → save to local table → convert → display
*
* The "Zones" layer is added to the Administration LayerGroup (id 1),
* initially not visible. It becomes visible when toggled in the LayerSwitcher.
*/
async function loadCollectorZones() {
const ADMIN_GROUP_ID = 1;
const zoneStyle = {
strokeColor: '#7c3aed',
strokeWidth: 1.5,
fillColor: 'rgba(124,58,237,0.12)',
typeDescription: 'Vector / Polygon',
};
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
console.log('[App] loadCollectorZones — adminGroup:', adminGroup ? adminGroup.get('title') : 'null');
// Create the Zones layer immediately (empty) so it always appears
// in the LayerSwitcher. Features will be added once data is available.
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const zonesLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Zones', zoneStyle, adminGroup);
if (!zonesLayer) {
console.warn('[App] Could not create Zones layer');
return;
}
zonesLayer.setVisible(false);
// Warn when the user enables the layer but it has no data
zonesLayer.on('change:visible', () => {
if (zonesLayer.getVisible() && zonesLayer.getSource().getFeatures().length === 0) {
showError('No collector zones available locally. Connect to the internet to download zone data.');
}
});
/**
* Replace the layer's source features with features parsed from GeoJSON.
*/
function setZoneFeatures(geojson) {
const newFeatures = new GeoJSON().readFeatures(geojson, {
featureProjection: 'EPSG:3857',
});
zonesLayer.getSource().clear();
zonesLayer.getSource().addFeatures(newFeatures);
}
try {
// Step 1: Load from local table
const cached = await getLocalCollectorZones();
if (cached) {
const geojson = zonesToGeoJSON(cached);
if (geojson) {
console.log('[App] Collector zones loaded from local cache:', geojson.features.length, 'zones');
setZoneFeatures(geojson);
}
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching collector zones from API...');
const apiResponse = await getCollectorZones();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getCollectorZones API response invalid:', apiResponse);
return;
}
const zones = apiResponse.data;
console.log('[App] Collector zones from API:', zones.length, 'entries');
// Save to local table
await saveCollectorZones(zones);
// Convert to GeoJSON and update the existing layer
const geojson = zonesToGeoJSON(zones);
if (!geojson) {
console.warn('[App] Could not convert zones to GeoJSON');
return;
}
setZoneFeatures(geojson);
console.log('[App] Collector zones updated from API:', geojson.features.length, 'zones');
} else if (!cached) {
console.log('[App] Collector zones not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load collector zones:', error);
}
}
/**
* Convert parcels data to a GeoJSON FeatureCollection.
* Each parcel feature gets a '_layerType' = 'parcel' property for identification.
*
* @param {Array} parcels - Array of parcel objects from the API
* @returns {Object|null} GeoJSON FeatureCollection or null
*/
function parcelsToGeoJSON(parcels) {
if (!Array.isArray(parcels) || parcels.length === 0) return null;
// Deduplicate by id — the API may return the same parcel more than once
const seen = new Set();
const features = [];
for (const parcel of parcels) {
const id = parcel.id || parcel.parcelid || parcel.parcel_id;
if (id != null) {
if (seen.has(id)) continue;
seen.add(id);
}
// Geometry sources, in order:
// - API path: `geom` is a GeoJSON object, `boundary` is the WKT string
// - local cache: `geometry_wkt` is the WKT string
let geometry = null;
if (parcel.geom && parcel.geom.type && parcel.geom.coordinates) {
geometry = { type: parcel.geom.type, coordinates: parcel.geom.coordinates };
} else if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates };
} else {
const wkt = parcel.boundary || parcel.geometry_wkt || parcel.polygon || parcel.wkt;
geometry = parseWKT(wkt);
}
if (!geometry) continue;
// Collect all properties except bulky geometry fields and local housekeeping.
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'geometry_wkt', 'wkt', 'textboundary', 'sp_boundary', 'fetched_at']);
const properties = { _layerType: 'parcel' };
for (const [key, value] of Object.entries(parcel)) {
if (skipKeys.has(key)) continue;
properties[key] = value;
}
features.push({ type: 'Feature', properties, geometry });
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
/**
* Load parcels with local-first strategy:
* 1. Read from local parcels table → convert to GeoJSON → display
* 2. If online, fetch from API → save to local table → convert → display
*
* The "Parcels" layer is added to the "Land Use and Land Tenure" LayerGroup (id 4),
* initially not visible. It becomes visible when toggled in the LayerSwitcher.
*/
async function loadParcels() {
const LAND_USE_GROUP_ID = 4;
const parcelStyle = {
strokeColor: '#0ea5e9',
strokeWidth: 1.5,
fillColor: 'rgba(14,165,233,0.12)',
typeDescription: 'Vector / Polygon',
};
const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null;
console.log('[App] loadParcels — landUseGroup:', landUseGroup ? landUseGroup.get('title') : 'null');
// Create the Parcels layer immediately (empty) so it always appears
// in the LayerSwitcher. Features will be added once data is available.
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
// Assigned to the module-level `parcelsLayer` so the iframe embed bridge
// can pick it up after loadParcels() returns from its sync prologue.
parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
if (!parcelsLayer) {
console.warn('[App] Could not create Parcels layer');
return;
}
parcelsLayer.setVisible(false);
// Warn when the user enables the layer but it has no data
parcelsLayer.on('change:visible', () => {
if (parcelsLayer.getVisible() && parcelsLayer.getSource().getFeatures().length === 0) {
showError('No parcels available locally. Connect to the internet to download parcel data.');
}
});
/**
* Replace the layer's source features with features parsed from GeoJSON.
*/
function setParcelFeatures(geojson) {
const newFeatures = new GeoJSON().readFeatures(geojson, {
featureProjection: 'EPSG:3857',
});
parcelsLayer.getSource().clear();
parcelsLayer.getSource().addFeatures(newFeatures);
}
try {
// Step 1: Load from local table
const cached = await getLocalParcels();
if (cached) {
const geojson = parcelsToGeoJSON(cached);
if (geojson) {
console.log('[App] Parcels loaded from local cache:', geojson.features.length, 'parcels');
setParcelFeatures(geojson);
}
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching parcels from API...');
const apiResponse = await getDistrictParcels();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getDistrictParcels API response invalid:', apiResponse);
return;
}
const parcels = apiResponse.data;
console.log('[App] Parcels from API:', parcels.length, 'entries');
// Log first parcel's keys for debugging field names
if (parcels.length > 0) {
console.log('[App] First parcel keys:', Object.keys(parcels[0]));
}
// Save to local table
await saveParcels(parcels);
// Convert to GeoJSON and update the existing layer
const geojson = parcelsToGeoJSON(parcels);
if (!geojson) {
console.warn('[App] Could not convert parcels to GeoJSON');
return;
}
setParcelFeatures(geojson);
console.log('[App] Parcels updated from API:', geojson.features.length, 'parcels');
} else if (!cached) {
console.log('[App] Parcels not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load parcels:', error);
}
}
/**
* Convert an array of building footprint objects to a GeoJSON FeatureCollection.
* Each footprint's WKT geometry field is parsed; all other fields become properties.
*
* @param {Array} footprints - Array of footprint objects
* @returns {Object|null} GeoJSON FeatureCollection, or null if no valid features
*/
function footprintsToGeoJSON(footprints) {
if (!Array.isArray(footprints) || footprints.length === 0) return null;
const geomKeys = ['polygon', 'boundary', 'geom', 'wkt', 'footprint'];
const features = [];
for (const fp of footprints) {
const raw = fp.polygon || fp.boundary || fp.geom || fp.wkt || fp.footprint;
// Geometry may be WKT string or GeoJSON object
let geometry;
if (typeof raw === 'object' && raw !== null && raw.type) {
// Already a GeoJSON geometry object
geometry = raw;
} else {
geometry = parseWKT(raw);
}
if (!geometry) continue;
const properties = { _layerType: 'building_footprint' };
for (const [key, value] of Object.entries(fp)) {
if (geomKeys.includes(key)) continue;
// Skip nested objects that aren't useful as flat properties
if (typeof value === 'object' && value !== null) continue;
properties[key] = value;
}
features.push({ type: 'Feature', properties, geometry });
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
/**
* Load building footprints with local-first strategy:
* 1. Read from local building_footprints table → convert to GeoJSON → display
* 2. If online, fetch from API → save to local table → convert → display
*
* The "Building footprints" layer is added to the "Physical Infrastructures" LayerGroup (id 5),
* initially not visible. It becomes visible when toggled in the LayerSwitcher.
*/
async function loadBuildingFootprints() {
const PHYS_INFRA_GROUP_ID = 5;
const footprintStyle = {
strokeColor: '#8b6f47',
strokeWidth: 1,
fillColor: 'rgba(139,111,71,0.18)',
typeDescription: 'Vector / Polygon',
};
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
console.log('[App] loadBuildingFootprints — physInfraGroup:', physInfraGroup ? physInfraGroup.get('title') : 'null');
// Create the layer immediately (empty) so it always appears in the LayerSwitcher.
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const footprintsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Building footprints', footprintStyle, physInfraGroup);
if (!footprintsLayer) {
console.warn('[App] Could not create Building footprints layer');
return;
}
footprintsLayer.setVisible(false);
// Warn when the user enables the layer but it has no data
footprintsLayer.on('change:visible', () => {
if (footprintsLayer.getVisible() && footprintsLayer.getSource().getFeatures().length === 0) {
showError('No building footprints available locally. Connect to the internet to download footprint data.');
}
});
/**
* Replace the layer's source features with features parsed from GeoJSON.
*/
function setFootprintFeatures(geojson) {
const newFeatures = new GeoJSON().readFeatures(geojson, {
featureProjection: 'EPSG:3857',
});
footprintsLayer.getSource().clear();
footprintsLayer.getSource().addFeatures(newFeatures);
}
try {
// Step 1: Load from local table
const cached = await getLocalBuildingFootprints();
if (cached) {
const geojson = footprintsToGeoJSON(cached);
if (geojson) {
console.log('[App] Building footprints loaded from local cache:', geojson.features.length, 'footprints');
setFootprintFeatures(geojson);
}
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching building footprints from API...');
const apiResponse = await getBuildingFootprints();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getBuildingFootprints API response invalid:', apiResponse);
return;
}
const footprints = apiResponse.data;
console.log('[App] Building footprints from API:', footprints.length, 'entries');
// Log first footprint's keys for debugging field names
if (footprints.length > 0) {
console.log('[App] First footprint keys:', Object.keys(footprints[0]));
}
// Save to local table
await saveBuildingFootprints(footprints);
// Convert to GeoJSON and update the existing layer
const geojson = footprintsToGeoJSON(footprints);
if (!geojson) {
console.warn('[App] Could not convert building footprints to GeoJSON');
return;
}
setFootprintFeatures(geojson);
console.log('[App] Building footprints updated from API:', geojson.features.length, 'footprints');
} else if (!cached) {
console.log('[App] Building footprints not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load building footprints:', error);
}
}
/**
* Convert an array of DB rows (each with a WKT geom field) to GeoJSON.
* Uses OpenLayers' WKT parser so LINESTRING, MULTILINESTRING, POLYGON, etc.
* are all supported out of the box.
*
* @param {Array} rows — API rows, each having a WKT-valued geom/geometry/wkt field
* @param {string} layerType — value to store in each feature's _layerType property
* @returns {Object|null} GeoJSON FeatureCollection, or null if no valid rows
*/
function wktRowsToGeoJSON(rows, layerType) {
if (!Array.isArray(rows) || rows.length === 0) return null;
const wktFormat = new WKT();
const geojsonFormat = new GeoJSON();
// Field-name fallbacks — different endpoints alias the geometry column
// differently (e.g. get_osm_roads uses `road`, get_contours_hillshade uses
// `geom`). The first non-null match wins.
const geomKeys = ['geom', 'geometry', 'wkt', 'polygon', 'boundary', 'road', 'line'];
const features = [];
for (const row of rows) {
const raw = row.geom || row.geometry || row.wkt || row.polygon || row.boundary || row.road || row.line;
if (!raw) continue;
let olGeom;
try {
if (typeof raw === 'object' && raw !== null && raw.type) {
// Already a GeoJSON geometry — just pass through
features.push({
type: 'Feature',
properties: flattenProps(row, geomKeys, layerType),
geometry: raw,
});
continue;
}
olGeom = wktFormat.readGeometry(raw);
} catch (err) {
console.warn(`[App] Could not parse WKT for ${layerType}:`, err, raw?.toString().slice(0, 60));
continue;
}
const geometry = JSON.parse(geojsonFormat.writeGeometry(olGeom));
features.push({
type: 'Feature',
properties: flattenProps(row, geomKeys, layerType),
geometry,
});
}
if (features.length === 0) return null;
return { type: 'FeatureCollection', features };
}
/**
* Flatten a DB row into properties, skipping geometry fields and nested objects.
*/
function flattenProps(row, skipKeys, layerType) {
const props = { _layerType: layerType };
for (const [key, value] of Object.entries(row)) {
if (skipKeys.includes(key)) continue;
if (typeof value === 'object' && value !== null) continue;
props[key] = value;
}
return props;
}
/**
* Load the "Contours hillshade" layer — elevation contours derived from
* OpenTopography, stored in PostgreSQL as `contours_hillshade`.
*
* Added to the "Biophysical Environment" LayerGroup, initially not visible.
* No local caching (the server is the source of truth).
*/
async function loadContoursHillshade() {
const contoursStyle = {
strokeColor: '#78716c', // warm grey — traditional contour colour
strokeWidth: 0.8,
typeDescription: 'Vector / Line',
fillColor: 'rgba(0,0,0,0)',
};
const biophysGroup = mapView?.getLayerGroupByTitle('Biophysical Environment');
console.log('[App] loadContoursHillshade — group:', biophysGroup ? biophysGroup.get('title') : 'null');
// Create empty layer first so it always appears in the LayerSwitcher
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const contoursLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Contours hillshade', contoursStyle, biophysGroup);
if (!contoursLayer) {
console.warn('[App] Could not create Contours hillshade layer');
return;
}
contoursLayer.setVisible(false);
// Warn when the user enables the layer but it has no data
contoursLayer.on('change:visible', () => {
if (contoursLayer.getVisible() && contoursLayer.getSource().getFeatures().length === 0) {
showError('No Contours hillshade data available. Connect to the internet to download it.');
}
});
// Fetch from API (only when online and server reachable — no local cache)
if (!isOnline() || !isServerReachable()) {
console.log('[App] Contours hillshade not available — offline or server unreachable');
return;
}
try {
console.log('[App] Fetching contours_hillshade from API...');
const apiResponse = await getContoursHillshade();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getContoursHillshade API response invalid:', apiResponse);
return;
}
const rows = apiResponse.data;
console.log('[App] Contours hillshade from API:', rows.length, 'rows');
if (rows.length > 0) {
console.log('[App] First row keys:', Object.keys(rows[0]));
}
const geojson = wktRowsToGeoJSON(rows, 'contours_hillshade');
if (!geojson) {
console.warn('[App] Could not convert contours to GeoJSON');
return;
}
const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
contoursLayer.getSource().clear();
contoursLayer.getSource().addFeatures(features);
console.log('[App] Contours hillshade loaded:', features.length, 'features');
} catch (error) {
console.error('[App] Failed to load contours_hillshade:', error);
}
}
/**
* Load the "OSM_roads" layer — OpenStreetMap road network for the district.
*
* Added to the "Physical Infrastructures" LayerGroup (id 5), initially not
* visible — becomes visible when the user toggles it in the LayerSwitcher.
*
* Local-first caching:
* 1. Read from the local `osm_roads` table → render immediately if available
* 2. If online, fetch from the API → overwrite the local table → re-render
*/
async function loadOSMRoads() {
const PHYS_INFRA_GROUP_ID = 5;
// Cartographic road casing: a black outer stroke makes the light-coloured
// inner stroke (the "road body") readable on every base map.
const roadsStyle = {
strokeColor: '#F0F1F0', // inner — road body
strokeWidth: 1.5,
lineCasingColor: '#000000', // outer — black casing
lineCasingWidth: 3.5,
fillColor: 'rgba(0,0,0,0)',
typeDescription: 'Vector / Line',
};
const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
console.log('[App] loadOSMRoads — group:', physInfraGroup ? physInfraGroup.get('title') : 'null');
// Create the layer immediately (empty) so it appears in the LayerSwitcher
// even when offline.
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const roadsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'OSM_roads', roadsStyle, physInfraGroup);
if (!roadsLayer) {
console.warn('[App] Could not create OSM_roads layer');
return;
}
roadsLayer.setVisible(false);
// Warn only when the layer is enabled AND truly empty AND no source is reachable
roadsLayer.on('change:visible', () => {
if (roadsLayer.getVisible() && roadsLayer.getSource().getFeatures().length === 0) {
showError('No OSM roads available locally. Connect to the internet to download them.');
}
});
/** Replace the layer's features with those parsed from the API/cache rows. */
function setRoadFeatures(rows) {
const geojson = wktRowsToGeoJSON(rows, 'osm_road');
if (!geojson) {
console.warn('[App] Could not convert OSM roads to GeoJSON');
return 0;
}
const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
roadsLayer.getSource().clear();
roadsLayer.getSource().addFeatures(features);
return features.length;
}
try {
// Step 1 — local cache (works offline, instant)
const cached = await getLocalOSMRoads();
if (cached) {
const n = setRoadFeatures(cached);
console.log('[App] OSM_roads loaded from local cache:', n, 'features');
}
// Step 2 — fetch fresh from API when online
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching OSM_roads from API...');
const apiResponse = await getOSMRoads();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getOSMRoads API response invalid:', apiResponse);
return;
}
const rows = apiResponse.data;
console.log('[App] OSM_roads from API:', rows.length, 'rows');
if (rows.length > 0) {
console.log('[App] First row keys:', Object.keys(rows[0]));
}
// Persist to local table so it's available next time offline
await saveOSMRoads(rows);
const n = setRoadFeatures(rows);
console.log('[App] OSM_roads updated from API:', n, 'features');
} else if (!cached) {
console.log('[App] OSM_roads not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load OSM_roads:', error);
}
}
/**
* Add external WMS/XYZ layers to the map.
* Called after loadLayers() so the target layer groups already exist.
*/
function loadExternalWMSLayers() {
// DEAfrica Coastlines v0.4.0 — annual shorelines & rates of change
// Source: Digital Earth Africa GeoServer
// Latest available version as of 2026: v0.4.0
mapView?.addWMSLayer(
'Biophysical Environment',
'DEAfrica Coastlines v0.4',
'https://geoserver.digitalearth.africa/geoserver/wms',
'coastlines:DEAfrica_Coastlines',
{ serverType: 'geoserver', visible: false, onlineOnly: true }
);
// Note: OpenTopoMap is available as the "Topographic" base map —
// no separate overlay in "Biophysical Environment" needed.
// Digital Earth Africa — SRTM-derived Slope (30m)
// Shows terrain steepness as a background overlay — hills and valleys stand
// out naturally, reading like a traditional shaded-relief topographic map.
// Service: datacube-ows (not GeoServer).
// Layer 'srtm_deriv' styles: 'style_slope', 'style_mrvbf' (valley bottoms),
// 'style_mrrtf' (ridge tops).
mapView?.addWMSLayer(
'Biophysical Environment',
'DEAfrica Slope (SRTM 30m)',
'https://ows.digitalearth.africa/wms',
'srtm_deriv',
{
serverType: null,
style: 'style_slope',
visible: false,
opacity: 0.5,
zIndex: -50,
onlineOnly: true,
attributions:
'&copy; <a href="https://www.digitalearthafrica.org/">Digital Earth Africa</a> — ' +
'SRTM-derived Slope',
legendUrl: 'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png',
}
);
}
/**
* Load layer categories from the API and create empty VectorLayers on the map.
* Uses local-first caching — reads from SQLite first, then refreshes from API when online.
*
* API response: { success: true, data: [{ id, name, description, createdt, editdt }, ...] }
*/
async function loadLayers() {
const CACHE_KEY = 'layer_categories';
/**
* Create layer groups on the map from the layer category list.
* @param {Array} layers - Array of { id, name, description, ... }
*/
function createLayerGroupsOnMap(layers) {
// Sort by id descending so that id 1 is pushed last → ends up
// at the top of the layer stack and at the top of the LayerSwitcher.
const sorted = [...layers].sort((a, b) => b.id - a.id);
for (const layer of sorted) {
mapView?.addLayerGroup(layer.id, layer.name, layer.description || '');
}
console.log('[App] Created', layers.length, 'layer groups on map');
}
try {
// Step 1: Load from local cache
const cached = await getRemoteData(CACHE_KEY);
if (cached) {
console.log('[App] Layer categories loaded from local cache:', cached.length, 'entries');
createLayerGroupsOnMap(cached);
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching layer categories from API...');
const apiResponse = await getLayers();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getLayers API response invalid:', apiResponse);
return;
}
const layers = apiResponse.data;
console.log('[App] Layer categories from API:', layers.length, 'entries');
// Save to local cache
await saveRemoteData(CACHE_KEY, layers);
// Replace layers on map if we already created from cache
if (cached) {
// Remove previously created empty layers (keep District Boundary)
const overlayLayers = mapView?.getOverlayGroup()?.getLayers();
if (overlayLayers) {
const toRemove = [];
overlayLayers.forEach((layer) => {
if (layer.get('layerId') !== undefined) {
toRemove.push(layer);
}
});
toRemove.forEach((layer) => overlayLayers.remove(layer));
}
}
createLayerGroupsOnMap(layers);
console.log('[App] Layer categories refreshed from API');
} else if (!cached) {
console.log('[App] Layer categories not available — offline and no local cache');
}
} catch (error) {
console.error('[App] Failed to load layer categories:', error);
}
}
// ============================================================================
// Sync (placeholder - implement based on your backend)
// ============================================================================
async function syncData() {
if (!isOnline()) {
console.log('[App] Cannot sync - offline');
return;
}
// TODO: Implement sync with your backend
// Example:
// const unsynced = await getUnsyncedLocations();
// for (const location of unsynced) {
// await fetch('/api/locations', {
// method: 'POST',
// body: JSON.stringify(location)
// });
// await markLocationsSynced([location.id]);
// }
console.log('[App] Sync placeholder - implement based on your backend');
}
// ============================================================================
// File Import (Shapefile, GeoJSON, KML)
// ============================================================================
/** All layers added by file imports — shared across formats. */
const importedFileLayers = [];
/** Default style for imported layers. */
const IMPORT_STYLE = {
strokeColor: '#e11d48',
strokeWidth: 2,
fillColor: 'rgba(225,29,72,0.12)',
};
/**
* Show an error message inside the left panel's file-import alert area.
*/
function showFileImportError(message) {
logMessage('error', message);
const el = document.getElementById('file-import-alert');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 8000);
}
}
/**
* Add a GeoJSON FeatureCollection (or array of them) to the map, zoom to
* the data, and refresh the imported-layers info card.
*
* @param {Object|Object[]} geojsonInput - Single FeatureCollection or array
* @param {string} fallbackName - Layer name when the FC has no fileName
* @param {string} tag - Log prefix, e.g. 'ShpImport'
*/
function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
const collections = Array.isArray(geojsonInput) ? geojsonInput : [geojsonInput];
let totalFeatures = 0;
for (const fc of collections) {
if (!fc || fc.type !== 'FeatureCollection' || !fc.features?.length) continue;
const layerName = fc.fileName
? fc.fileName.replace(/\.[^/.]+$/, '')
: fallbackName;
const layer = mapView?.addGeoJSONLayer(fc, layerName, IMPORT_STYLE);
if (layer) {
// Imported file layers are not part of the built-in data model;
// the user can remove them via the LayerSwitcher × button.
layer.set('removable', true);
layer.set('typeTag', 'GEO');
importedFileLayers.push(layer);
totalFeatures += fc.features.length;
// Stage the import into external_imports + external_import_features
// (LUPMIS2_Import_Upload_Design.docx §3 + §4). The mapping modal opens
// automatically; the layer gets tagged with its import_id so the
// LayerSwitcher chip can find the row.
stageImport(fc, layerName, layer).catch((err) =>
console.warn('[FileImport] Staging failed (layer remains view-only):', err)
);
}
}
if (totalFeatures === 0) {
showFileImportError('No features found in the file.');
return;
}
console.log(`[${tag}] Added ${totalFeatures} feature(s) from ${collections.length} layer(s)`);
// Zoom to the last imported layer
const lastLayer = importedFileLayers[importedFileLayers.length - 1];
if (lastLayer) {
const extent = lastLayer.getSource().getExtent();
mapView?.getMap().getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 18 });
}
refreshImportedLayersCard();
}
// ===========================================================================
// External-dataset import staging (see LUPMIS2_Import_Upload_Design.docx)
// ===========================================================================
const wktFormat4326 = new WKT();
/**
* Convert an OL geometry (Map projection EPSG:3857) to a WKT string in
* EPSG:4326 — the format the server expects.
*/
function geometryToWkt4326(geometry) {
return wktFormat4326.writeGeometry(geometry, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
}
/**
* Stage an imported FeatureCollection into external_imports +
* external_import_features, then open the mapping modal.
*
* 1. Create the external_imports row (target_type='other', status='other').
* 2. Insert one external_import_features row per feature with the raw
* properties (mapping is applied later, when the user confirms).
* 3. Tag the OL layer with _externalImportId so the LayerSwitcher chip
* can render its status.
* 4. Open the mapping modal; on Save the row is updated with the chosen
* target type and mapping, the staged-feature properties are remapped,
* and (if requested) an upload is kicked off.
*/
async function stageImport(fc, displayName, layer) {
const featureCount = fc?.features?.length ?? 0;
if (featureCount === 0) return;
// ── 1. Create the staging row + features ────────────────────────────────
const { id: importId } = await createExternalImport({
filename: displayName || 'imported dataset',
targetType: 'other',
featureCount,
});
layer.set('_externalImportId', importId);
// Convert each OL feature to (WKT 4326 + raw source properties).
const olFeatures = layer.getSource().getFeatures();
const stagedRows = olFeatures.map((f) => {
const geom = f.getGeometry();
return {
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
properties: stripGeometryFromProps(f.getProperties()),
};
});
await addExternalImportFeatures(importId, stagedRows);
// ── 2. Open the mapping modal ───────────────────────────────────────────
openImportMappingModal({
importId,
filename: displayName,
fc,
onResult: async (result) => {
try {
await handleImportModalResult(importId, layer, result);
} catch (err) {
console.error('[FileImport] Failed to apply mapping result:', err);
showError('Could not save the import mapping: ' + err.message);
}
},
});
}
/** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */
function stripGeometryFromProps(props) {
const out = {};
for (const [k, v] of Object.entries(props || {})) {
if (k === 'geometry') continue;
out[k] = v;
}
return out;
}
/**
* Apply the user's choice from the mapping modal:
* cancel → keep as 'other' (view-only); no further action.
* save → set target_type + mapping, status='mapped', remap staged props.
* upload → same as save, then run the upload stub.
*/
async function handleImportModalResult(importId, layer, result) {
if (!result || result.action === 'cancel') {
layer?.set('_externalImportStatus', 'other');
refreshLayerSwitcherChip(layer);
return;
}
const { action, targetType, mapping } = result;
if (!targetType || targetType === 'other') {
await updateExternalImport(importId, { targetType: 'other', mapping: null, status: 'other' });
layer?.set('_externalImportStatus', 'other');
refreshLayerSwitcherChip(layer);
return;
}
// Remap each staged feature's properties to LUPMIS2 column names. The
// helper wraps every UPDATE in a single transaction.
await remapImportedFeatureProperties(importId, (props) =>
applyFieldMapping(props, mapping)
);
await updateExternalImport(importId, { targetType, mapping, status: 'mapped' });
layer?.set('_externalImportStatus', 'mapped');
layer?.set('_externalImportTargetType', targetType);
refreshLayerSwitcherChip(layer);
if (action === 'upload') {
await runUpload(importId, layer);
}
}
/**
* Upload stub. The server endpoints (upload_parcels.php, …) don't exist yet
* (LUPMIS2_Import_Upload_Design.docx §5 is the proposal sent to the database
* team). Until they're live we mark the row 'uploading' briefly so the chip
* can flash a spinner, log the would-be payload for verification, then
* revert to 'mapped' with a clear info toast — nothing is lost; the user
* can retry once the endpoint exists.
*/
async function runUpload(importId, layer) {
layer?.set('_externalImportStatus', 'uploading');
refreshLayerSwitcherChip(layer);
try {
await updateExternalImport(importId, { status: 'uploading' });
const imp = await getExternalImport(importId);
const features = await getExternalImportFeatures(importId);
const session = getSession();
// Build the request body exactly as the server will receive it once
// upload_<target_type>.php is live. district_id + api_token are merged
// in by remotePost from API_CREDENTIALS; user_id_upload comes from the
// SSO session (server may also derive it server-side — we send it for
// logging/audit completeness as agreed with the database team).
const body = {
user_id_upload: session?.user_id ?? null,
import: {
client_import_id: imp.client_import_id,
filename: imp.filename,
feature_count: features.length,
},
features: features.map((f) => ({
client_uuid: f.client_uuid,
geom: f.geometry_wkt,
props: f.properties,
})),
};
// ── TODO when the database team ships upload_<target_type>.php ────────
// const apiResponse = await remotePost(`upload_${imp.target_type}.php`, body);
// then walk apiResponse.results, update each feature's upload_status,
// and flip the import row to 'submitted' (or 'failed' if any rows
// failed). For now we just log the payload and roll back.
console.log('[Upload]', {
endpoint: `upload_${imp.target_type}.php (not yet available on the server)`,
target_type: imp.target_type,
body,
});
await updateExternalImport(importId, {
status: 'mapped',
lastUploadedAt: new Date().toISOString(),
});
layer?.set('_externalImportStatus', 'mapped');
refreshLayerSwitcherChip(layer);
showWarning(
'The server upload endpoint is not yet available. ' +
'The data stays staged locally — you can upload again later.'
);
} catch (err) {
console.error('[Upload] Stub failed:', err);
layer?.set('_externalImportStatus', 'mapped');
refreshLayerSwitcherChip(layer);
showError('Upload preparation failed: ' + err.message);
}
}
// Export GIS button on the Area / Circle Analysis popup — MapView dispatches
// this CustomEvent so main.js can own the format/rename modal + writers.
window.addEventListener('lupmis:export-gis', (e) => {
openExportGisModal(e.detail || {});
});
// LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's
// _decorateLayerListItem. We only act on the 'mapped' state today (upload);
// 'failed' will open an error-review modal once the server endpoints exist.
window.addEventListener('lupmis:import-chip-click', (e) => {
const { importId, status, layer } = e.detail || {};
if (status === 'mapped') {
runUpload(importId, layer).catch((err) =>
console.error('[FileImport] runUpload failed:', err)
);
}
});
/**
* Force the LayerSwitcher row for this layer to re-render. ol-ext rebuilds
* the panel on every render() call; the drawlist hook will read the new
* _externalImportStatus property and render the appropriate chip.
*/
function refreshLayerSwitcherChip(layer) {
if (!layer || !mapView) return;
const switcher = mapView.getMap()
?.getControls()
?.getArray()
?.find((c) => c?.constructor?.name === 'LayerSwitcher'
|| c?.element?.classList?.contains('ol-layerswitcher'));
if (switcher && typeof switcher.drawPanel === 'function') {
switcher.drawPanel();
}
}
/**
* Rebuild the imported-layers info card in the left panel.
*/
function refreshImportedLayersCard() {
const infoEl = document.getElementById('imported-layers-info');
if (!infoEl) return;
if (importedFileLayers.length === 0) {
infoEl.innerHTML = '';
infoEl.classList.add('d-none');
return;
}
infoEl.innerHTML = `
<div class="card">
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-layers me-2"></i>Imported Layers</h6>
<button type="button" class="btn btn-sm btn-outline-light" id="remove-imported-layers" title="Remove all imported layers">
<i class="bi bi-trash"></i>
</button>
</div>
<ul class="list-group list-group-flush" id="imported-layers-list"></ul>
</div>`;
const listEl = infoEl.querySelector('#imported-layers-list');
importedFileLayers.forEach((l, idx) => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center py-2';
li.innerHTML = `<small class="text-truncate me-2">${escapeHtml(l.get('title'))}</small>
<span class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="badge" style="background-color:var(--primary);color:var(--primary-foreground);">${l.getSource().getFeatures().length}</span>
<button type="button" class="btn btn-sm btn-outline-danger border-0 p-0 lh-1" data-remove-idx="${idx}" title="Remove layer">
<i class="bi bi-x-lg" style="font-size:.75rem;"></i>
</button>
</span>`;
listEl.appendChild(li);
});
infoEl.classList.remove('d-none');
// Per-layer remove buttons
infoEl.querySelectorAll('[data-remove-idx]').forEach(btn => {
btn.addEventListener('click', () => {
removeImportedLayer(Number(btn.dataset.removeIdx));
});
});
// Remove-all button
infoEl.querySelector('#remove-imported-layers')?.addEventListener('click', () => {
removeImportedLayers();
});
}
/**
* Remove a single imported layer by its index in importedFileLayers.
*/
function removeImportedLayer(idx) {
if (idx < 0 || idx >= importedFileLayers.length) return;
const layer = importedFileLayers[idx];
const overlayGroup = mapView?.getOverlayGroup();
if (overlayGroup) {
overlayGroup.getLayers().remove(layer);
}
importedFileLayers.splice(idx, 1);
refreshImportedLayersCard();
console.log('[FileImport] Removed layer:', layer.get('title'));
}
/**
* Remove all imported layers from the map and clear the info card.
*/
function removeImportedLayers() {
const overlayGroup = mapView?.getOverlayGroup();
if (overlayGroup) {
for (const layer of importedFileLayers) {
overlayGroup.getLayers().remove(layer);
}
}
importedFileLayers.length = 0;
refreshImportedLayersCard();
console.log('[FileImport] All imported layers removed');
}
// ---------------------------------------------------------------------------
// Shapefile (.shp / .zip)
// ---------------------------------------------------------------------------
/**
* Build a lookup of selected files keyed by lowercase extension.
*/
function indexFilesByExtension(files) {
const map = {};
for (const f of files) {
const ext = f.name.split('.').pop().toLowerCase();
map[ext] = f;
}
return map;
}
async function handleShapefileImport(evt) {
const files = evt.target.files;
if (!files || files.length === 0) return;
const MAX_FILE_SIZE = 200 * 1024 * 1024;
const totalSize = Array.from(files).reduce((s, f) => s + f.size, 0);
if (totalSize > MAX_FILE_SIZE) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(0);
showFileImportError(
`Files too large (${sizeMB} MB total). Maximum supported size is 200 MB.`
);
evt.target.value = '';
return;
}
try {
let geojson;
let displayName;
const byExt = indexFilesByExtension(files);
if (byExt.zip) {
const file = byExt.zip;
displayName = file.name.replace(/\.zip$/i, '');
console.log('[ShpImport] Parsing zip', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
const shp = await getShp();
geojson = await shp(await file.arrayBuffer());
} else if (byExt.shp) {
displayName = byExt.shp.name.replace(/\.shp$/i, '');
const required = ['dbf', 'shx', 'prj'];
const missing = required.filter(ext => !byExt[ext]);
if (missing.length > 0) {
showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ')
+ '. Please select .shp, .dbf, .shx and .prj together.');
evt.target.value = '';
return;
}
const shpObj = {};
shpObj.shp = await byExt.shp.arrayBuffer();
shpObj.dbf = await byExt.dbf.arrayBuffer();
shpObj.prj = await new Response(byExt.prj).text();
if (byExt.cpg) shpObj.cpg = await new Response(byExt.cpg).text();
console.log('[ShpImport] Parsing loose files:',
Object.keys(byExt).map(e => '.' + e).join(', '),
'(' + (byExt.shp.size / 1024).toFixed(1) + ' KB .shp)');
const shp = await getShp();
geojson = await shp(shpObj);
} else {
showFileImportError('Please select a .zip or at least a .shp file.');
evt.target.value = '';
return;
}
addImportedGeoJSON(geojson, displayName, 'ShpImport');
} catch (error) {
console.error('[ShpImport] Failed:', error);
showFileImportError('Failed to parse shapefile: ' + error.message);
}
evt.target.value = '';
}
// ---------------------------------------------------------------------------
// GeoJSON (.geojson / .json)
// ---------------------------------------------------------------------------
async function handleGeoJSONImport(evt) {
const file = evt.target.files?.[0];
if (!file) return;
// Guard: reject files larger than 200 MB — JSON.parse cannot reliably
// handle them in a single pass and the browser will freeze or crash.
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200 MB
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(0);
showFileImportError(
`File too large (${sizeMB} MB). Maximum supported size is 200 MB. `
+ 'Consider splitting the file into smaller tiles with ogr2ogr or QGIS.'
);
evt.target.value = '';
return;
}
try {
const text = await file.text();
console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
const parsed = JSON.parse(text);
// Normalise to a FeatureCollection
let fc;
if (parsed.type === 'FeatureCollection') {
fc = parsed;
} else if (parsed.type === 'Feature') {
fc = { type: 'FeatureCollection', features: [parsed] };
} else if (parsed.type && parsed.coordinates) {
// Bare geometry object
fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] };
} else {
showFileImportError('The file does not contain valid GeoJSON.');
evt.target.value = '';
return;
}
const displayName = file.name.replace(/\.(geo)?json$/i, '');
addImportedGeoJSON(fc, displayName, 'GeoJSONImport');
} catch (error) {
console.error('[GeoJSONImport] Failed:', error);
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
showFileImportError(
`Failed to import "${file.name}" (${sizeMB} MB): ${error.message}`
);
}
evt.target.value = '';
}
// ---------------------------------------------------------------------------
// KML (.kml)
// ---------------------------------------------------------------------------
async function handleKMLImport(evt) {
const file = evt.target.files?.[0];
if (!file) return;
const MAX_FILE_SIZE = 200 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(0);
showFileImportError(
`File too large (${sizeMB} MB). Maximum supported size is 200 MB.`
);
evt.target.value = '';
return;
}
try {
const text = await file.text();
console.log('[KMLImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
const kmlFormat = new KML({ extractStyles: false });
const features = kmlFormat.readFeatures(text, {
featureProjection: 'EPSG:3857',
});
if (!features || features.length === 0) {
showFileImportError('No features found in the KML file.');
evt.target.value = '';
return;
}
// Convert OL features back to GeoJSON so we can use the shared pipeline
const geojsonFormat = new GeoJSON();
const fc = JSON.parse(geojsonFormat.writeFeatures(features, {
featureProjection: 'EPSG:3857',
dataProjection: 'EPSG:4326',
}));
const displayName = file.name.replace(/\.kml$/i, '');
addImportedGeoJSON(fc, displayName, 'KMLImport');
} catch (error) {
console.error('[KMLImport] Failed:', error);
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
showFileImportError(
`Failed to import "${file.name}" (${sizeMB} MB): ${error.message}`
);
}
evt.target.value = '';
}
// ---------------------------------------------------------------------------
// Drag-and-drop on the map
// ---------------------------------------------------------------------------
/**
* Set up the map container as a drop zone for .shp/.zip, .geojson/.json, .kml
* files. Dragging files over the map shows a visual overlay; dropping them
* routes to the correct import handler.
*/
function initMapDropZone() {
const container = document.querySelector('.map-container');
if (!container) return;
let dragCounter = 0; // track nested enter/leave events
container.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
container.classList.add('drag-over');
});
container.addEventListener('dragover', (e) => {
e.preventDefault(); // required to allow drop
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
container.classList.remove('drag-over');
}
});
container.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
container.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
// Build extension lookup to decide which handler to use
const byExt = indexFilesByExtension(files);
const exts = Object.keys(byExt);
if (byExt.zip || byExt.shp) {
// Shapefile import (zip or loose .shp + companions)
const fakeEvt = { target: { files, value: '' } };
Object.defineProperty(fakeEvt.target, 'value', { writable: true });
handleShapefileImport(fakeEvt);
} else if (byExt.geojson || byExt.json) {
const file = byExt.geojson || byExt.json;
const fakeEvt = { target: { files: [file], value: '' } };
Object.defineProperty(fakeEvt.target, 'value', { writable: true });
handleGeoJSONImport(fakeEvt);
} else if (byExt.kml) {
const fakeEvt = { target: { files: [byExt.kml], value: '' } };
Object.defineProperty(fakeEvt.target, 'value', { writable: true });
handleKMLImport(fakeEvt);
} else {
showFileImportError(
'Unsupported file type(s): ' + exts.map(e => '.' + e).join(', ')
+ '. Drop .zip, .shp, .geojson, .json, or .kml files.'
);
}
});
console.log('[FileImport] Map drop zone initialised');
}
// ============================================================================
// Utilities
// ============================================================================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============================================================================
// Message Log — persistent stack in the right panel
// ============================================================================
const MESSAGE_LOG_MAX = 50;
const MSG_CONFIG = {
error: { icon: 'bi-x-circle-fill', color: 'var(--destructive, #dc3545)' },
warning: { icon: 'bi-exclamation-triangle-fill', color: 'var(--warning, #ffc107)' },
success: { icon: 'bi-check-circle-fill', color: 'var(--success, #198754)' },
info: { icon: 'bi-info-circle-fill', color: 'var(--primary, #0d6efd)' },
};
/**
* Append a message to the persistent log in the right panel.
* Also logs to the browser console.
*
* @param {'error'|'warning'|'success'|'info'} type
* @param {string} text
*/
function logMessage(type, text) {
const cfg = MSG_CONFIG[type] || MSG_CONFIG.info;
// Console mirror
const consoleFn = type === 'error' ? console.error
: type === 'warning' ? console.warn
: console.log;
consoleFn('[App]', text);
const log = document.getElementById('message-log');
if (!log) return;
// Remove the "No messages yet" placeholder if present
const placeholder = log.querySelector('.text-muted');
if (placeholder) placeholder.remove();
// Build the entry
const entry = document.createElement('div');
entry.className = 'list-group-item message-log-entry py-2 px-3';
const now = new Date();
const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
entry.innerHTML =
`<div class="d-flex align-items-start gap-2">` +
`<i class="bi ${cfg.icon} flex-shrink-0 mt-1" style="color:${cfg.color}"></i>` +
`<div class="flex-grow-1 text-break"><small>${escapeHtml(text)}</small></div>` +
`<small class="text-muted flex-shrink-0 ms-1">${time}</small>` +
`</div>`;
// Prepend (newest first)
log.prepend(entry);
// Cap the list
while (log.children.length > MESSAGE_LOG_MAX) {
log.lastElementChild.remove();
}
}
/** Wire up the "clear" button */
function initMessageLog() {
const btn = document.getElementById('clear-message-log');
if (btn) {
btn.addEventListener('click', () => {
const log = document.getElementById('message-log');
if (log) {
log.innerHTML = '<div class="text-center text-muted py-3"><small>No messages yet.</small></div>';
}
});
}
}
// ============================================================================
// 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
// ============================================================================
function showError(message) {
logMessage('error', message);
const el = document.getElementById('error-message');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 5000);
}
}
function showSuccess(message) {
logMessage('success', message);
const el = document.getElementById('success-message');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 3000);
}
}
function showWarning(message) {
logMessage('warning', message);
const el = document.getElementById('warning-message');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 5000);
}
}
// ============================================================================
// Fieldwork Mode
// ============================================================================
function initFieldworkMode() {
const toggle = document.getElementById('fieldwork-mode-toggle');
if (!toggle) return;
// Restore saved preference
const saved = localStorage.getItem('fieldwork-mode');
if (saved === 'true') {
document.documentElement.classList.add('fieldwork-mode');
toggle.checked = true;
}
toggle.addEventListener('change', () => {
document.documentElement.classList.toggle('fieldwork-mode', toggle.checked);
localStorage.setItem('fieldwork-mode', toggle.checked);
console.log('[Settings] Fieldwork mode', toggle.checked ? 'ON' : 'OFF');
});
}
// ============================================================================
// Dark Mode
// ============================================================================
function initDarkMode() {
const toggle = document.getElementById('dark-mode-toggle');
if (!toggle) return;
function applyDark(on) {
document.documentElement.classList.toggle('dark-mode', on);
// Bootstrap 5.3 built-in dark mode support
document.documentElement.setAttribute('data-bs-theme', on ? 'dark' : 'light');
}
// Restore saved preference
const saved = localStorage.getItem('dark-mode');
if (saved === 'true') {
toggle.checked = true;
applyDark(true);
}
toggle.addEventListener('change', () => {
applyDark(toggle.checked);
localStorage.setItem('dark-mode', toggle.checked);
console.log('[Settings] Dark mode', toggle.checked ? 'ON' : 'OFF');
});
}
// ============================================================================
// Measurement System
// ============================================================================
function initMeasurementSystem() {
const toggle = document.getElementById('measurement-system-toggle');
const label = document.getElementById('measurement-system-label');
if (!toggle) return;
function updateLabel() {
if (label) label.textContent = toggle.checked ? 'Imperial' : 'Metric';
}
// Restore saved preference
const saved = localStorage.getItem('measurement-system');
if (saved === 'imperial') {
toggle.checked = true;
}
updateLabel();
// Apply saved setting to the scale bar on load
mapView?.setScaleBarUnits(saved || 'metric');
toggle.addEventListener('change', () => {
const system = toggle.checked ? 'imperial' : 'metric';
localStorage.setItem('measurement-system', system);
updateLabel();
mapView?.setScaleBarUnits(system);
console.log('[Settings] Measurement system:', system);
});
}
/**
* Default base map selector — persisted in localStorage.
* Keys must match those handled by MapView.setBaseMap().
*/
function initDefaultBasemap() {
const select = document.getElementById('default-basemap-select');
if (!select) return;
// Restore saved preference (default: topo)
const saved = localStorage.getItem('default-basemap') || 'topo';
select.value = saved;
select.addEventListener('change', () => {
const key = select.value;
localStorage.setItem('default-basemap', key);
mapView?.setBaseMap(key);
console.log('[Settings] Default base map:', key);
});
// Keep the dropdown in sync when the user switches via the floating
// base-map picker (or any other UI) — MapView fires `basemapchange`
// from setBaseMap().
mapView?.getMap()?.on('basemapchange', (evt) => {
if (evt?.key && select.value !== evt.key) {
select.value = evt.key;
try { localStorage.setItem('default-basemap', evt.key); } catch {}
}
});
}
/**
* Offline Map Tiles card — shows per-provider cache stats and offers a
* "Clear cached tiles" button. Stats refresh whenever the Settings panel
* is opened so the numbers are always current.
*/
function initOfflineTileCache() {
const statsEl = document.getElementById('tile-cache-stats');
const clearBtn = document.getElementById('clear-tiles-btn');
const offcanvas = document.getElementById('offcanvasBottom');
if (!statsEl || !clearBtn || !offcanvas) return;
/** Format a byte count into a human-friendly string. */
function fmtBytes(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// Track in-flight refresh so rapid calls don't overlap and to allow a
// controllerchange handler to know when a refresh is already underway.
let refreshInFlight = null;
/** Render the stats panel. */
async function refresh() {
if (refreshInFlight) return refreshInFlight;
// If the SW hasn't taken control yet, give the user a friendly hint
// instead of immediately failing. The wait inside getTileCacheStats()
// will resolve once the SW becomes available, at which point this
// refresh completes normally — no reload needed.
const swActive = !!navigator.serviceWorker?.controller;
statsEl.innerHTML = swActive
? '<div class="text-muted fst-italic">Loading…</div>'
: '<div class="text-muted fst-italic">Initialising service worker…</div>';
refreshInFlight = (async () => {
try {
const stats = await getTileCacheStats();
if (!stats) {
statsEl.innerHTML = `
<div class="text-muted fst-italic">
Tile cache stats unavailable. Try reloading the page if this persists.
</div>`;
return;
}
const total = stats.totals;
const rows = stats.byProvider
.filter((p) => p.count > 0)
.map((p) => `
<tr>
<td>${escapeHtml(p.label)}</td>
<td class="text-end">${p.count.toLocaleString()} / ${p.limit.toLocaleString()}</td>
<td class="text-end">${fmtBytes(p.estBytes)}</td>
<td class="text-end pe-0" style="width:2.2rem;">
<button type="button" class="btn btn-sm btn-link text-danger p-0 provider-clear-btn"
data-cache="${escapeHtml(p.key)}" data-label="${escapeHtml(p.label)}"
title="Clear ${escapeHtml(p.label)} tiles only">
<i class="bi bi-trash3"></i>
</button>
</td>
</tr>`).join('');
let storageNote = '';
const est = await getStorageEstimate();
if (est && est.quota > 0) {
const pct = ((est.usage / est.quota) * 100).toFixed(1);
storageNote = `
<div class="mt-2 text-muted">
Total app storage: ${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} available (${pct}%)
</div>`;
}
if (total.count === 0) {
statsEl.innerHTML = `
<div class="text-muted">
No tiles cached yet. Pan and zoom the map to start caching tiles automatically.
</div>${storageNote}`;
clearBtn.disabled = true;
return;
}
statsEl.innerHTML = `
<div class="mb-1">
<strong>${total.count.toLocaleString()}</strong> tiles cached, ~${fmtBytes(total.estBytes)} on this device
</div>
<table class="table table-sm mb-0" style="font-size:0.85em;">
<thead><tr>
<th>Base map</th>
<th class="text-end">Cached / limit</th>
<th class="text-end">Approx. size</th>
<th class="text-end pe-0" style="width:2.2rem;"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>${storageNote}`;
clearBtn.disabled = false;
// Per-provider Clear — confirm, clear that bucket only, refresh
statsEl.querySelectorAll('.provider-clear-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const cacheName = btn.dataset.cache;
const label = btn.dataset.label || cacheName;
if (!confirm(`Clear cached "${label}" tiles?\n\nOther providers are not affected. The tiles will re-download as you browse online.`)) {
return;
}
btn.disabled = true;
const ok = await clearTileCacheForProvider(cacheName);
if (ok) {
console.log(`[Settings] Cleared tile cache for ${label}`);
} else {
console.warn(`[Settings] Could not clear tile cache for ${label}`);
}
await refresh();
});
});
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
// Clear button — confirm, then clear, then refresh
clearBtn.addEventListener('click', async () => {
if (!confirm('Clear all cached map tiles from this device? You will need to be online to view them again.')) {
return;
}
clearBtn.disabled = true;
const ok = await clearTileCaches();
if (ok) {
console.log('[Settings] Tile caches cleared');
} else {
console.warn('[Settings] Tile-cache clear failed');
}
await refresh();
});
// Refresh stats whenever the Settings offcanvas opens
offcanvas.addEventListener('show.bs.offcanvas', refresh);
// Auto-refresh when a (new) service worker takes control of the page —
// makes the panel populate as soon as the SW is available, even if the
// user is staring at it during initial install or during an SW update.
onServiceWorkerControllerChange(() => {
console.log('[Settings] SW controller changed → refreshing tile-cache stats');
refresh();
});
// Also do an initial render so the card isn't empty if Settings is open
// immediately on load.
refresh();
}
/**
* Offline-download dialog (Phase 2). Allows users to pre-fetch tiles for a
* chosen extent and zoom range so they can use the map without connectivity.
*/
function initOfflineDownloadDialog() {
const triggerBtn = document.getElementById('download-tiles-btn');
const modalEl = document.getElementById('offline-download-modal');
if (!triggerBtn || !modalEl) return;
const modal = Modal.getOrCreateInstance(modalEl);
// ----- Element refs -----
const formView = document.getElementById('offline-download-form-view');
const progressView = document.getElementById('offline-download-progress-view');
const doneView = document.getElementById('offline-download-done-view');
const cancelBtn = document.getElementById('offline-download-cancel-btn');
const startBtn = document.getElementById('offline-download-start-btn');
const closeDoneBtn = document.getElementById('offline-download-close-done-btn');
const headerCloseBtn = document.getElementById('offline-download-close-btn');
const basemapSelect = document.getElementById('offline-basemap-select');
const minZoomInput = document.getElementById('offline-min-zoom');
const maxZoomInput = document.getElementById('offline-max-zoom');
const ackCheck = document.getElementById('offline-ack-check');
const estimateEl = document.getElementById('offline-estimate-detail');
const estimateBox = document.getElementById('offline-estimate');
const areaViewRadio = document.getElementById('offline-area-view');
const areaDistrictRadio = document.getElementById('offline-area-district');
const areaGhanaRadio = document.getElementById('offline-area-ghana');
const areaViewInfo = document.getElementById('offline-area-view-info');
const areaDistrictInfo = document.getElementById('offline-area-district-info');
const progressBar = document.getElementById('offline-progress-bar');
const progressPercent = document.getElementById('offline-progress-percent');
const progressCounts = document.getElementById('offline-progress-counts');
const progressOk = document.getElementById('offline-progress-ok');
const progressFailed = document.getElementById('offline-progress-failed');
const progressEta = document.getElementById('offline-progress-eta');
const doneTitle = document.getElementById('offline-done-title');
const doneDetail = document.getElementById('offline-done-detail');
// ----- State -----
let currentDownloader = null;
/** Format byte count for display. */
function fmtBytes(b) {
if (!b) return '0 KB';
if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB';
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
/** Format ms → human-readable duration. */
function fmtDuration(ms) {
if (!ms || ms < 1000) return '< 1 s';
const s = Math.round(ms / 1000);
if (s < 60) return s + ' s';
const m = Math.floor(s / 60);
const r = s % 60;
if (m < 60) return `${m} min ${r} s`;
const h = Math.floor(m / 60);
return `${h} h ${m % 60} min`;
}
/** Get the chosen extent based on the radio selection. Returns null if invalid. */
function getSelectedExtent() {
if (areaViewRadio.checked) {
return mapView?.getCurrentViewExtent() || null;
}
if (areaDistrictRadio.checked) {
return mapView?.getDistrictBoundaryExtent()?.extent || null;
}
if (areaGhanaRadio.checked) {
return GHANA_EXTENT_3857;
}
return null;
}
/** Recalculate and update the live estimate display. */
function updateEstimate() {
const baseMap = basemapSelect.value;
const minZ = parseInt(minZoomInput.value, 10);
const maxZ = parseInt(maxZoomInput.value, 10);
if (Number.isNaN(minZ) || Number.isNaN(maxZ) || minZ > maxZ) {
estimateEl.textContent = 'Invalid zoom range';
estimateBox.classList.replace('alert-info', 'alert-warning');
startBtn.disabled = true;
return;
}
const extent = getSelectedExtent();
if (!extent) {
estimateEl.textContent = 'Selected area is not available.';
estimateBox.classList.replace('alert-info', 'alert-warning');
startBtn.disabled = true;
return;
}
const tplMaxZoom = BASEMAP_TEMPLATES[baseMap]?.maxZoom ?? 19;
const effMaxZ = Math.min(maxZ, tplMaxZoom);
const count = countTiles(extent, minZ, effMaxZ);
const bytes = estimatedSizeBytes(count);
let warningHTML = '';
if (effMaxZ < maxZ) {
warningHTML = `<br><span class="text-warning">Zoom ${maxZ} is above this provider's max (${tplMaxZoom}); will clamp to ${tplMaxZoom}.</span>`;
}
if (count > 8000) {
warningHTML += `<br><span class="text-warning">More than 8 000 tiles — exceeds the per-provider cache limit. Earlier tiles will be evicted as new ones arrive.</span>`;
}
estimateEl.innerHTML =
`<strong>${count.toLocaleString()}</strong> tiles · ` +
`~${fmtBytes(bytes)}` +
warningHTML;
estimateBox.classList.toggle('alert-warning', !!warningHTML);
estimateBox.classList.toggle('alert-info', !warningHTML);
startBtn.disabled = !ackCheck.checked || count === 0;
}
/** Update the area-radio info labels (tile count + size estimate). */
function updateAreaInfos() {
const view = mapView?.getCurrentViewExtent();
if (view) {
areaViewInfo.textContent = ' · ready';
} else {
areaViewInfo.textContent = '';
}
const dist = mapView?.getDistrictBoundaryExtent();
if (dist) {
areaDistrictInfo.textContent = '';
areaDistrictRadio.disabled = false;
} else {
areaDistrictInfo.textContent = ' (not loaded — connect online to fetch)';
areaDistrictRadio.disabled = true;
if (areaDistrictRadio.checked) areaViewRadio.checked = true;
}
}
/** Reset the modal to its initial form state. */
function resetModal() {
formView.classList.remove('d-none');
progressView.classList.add('d-none');
doneView.classList.add('d-none');
startBtn.classList.remove('d-none');
cancelBtn.classList.remove('d-none');
cancelBtn.textContent = 'Cancel';
closeDoneBtn.classList.add('d-none');
headerCloseBtn.disabled = false;
ackCheck.checked = false;
startBtn.disabled = true;
currentDownloader = null;
}
// ----- Event wiring -----
triggerBtn.addEventListener('click', () => {
resetModal();
updateAreaInfos();
updateEstimate();
modal.show();
});
// Recalculate estimate on any input change
basemapSelect.addEventListener('change', updateEstimate);
minZoomInput.addEventListener('input', updateEstimate);
maxZoomInput.addEventListener('input', updateEstimate);
areaViewRadio.addEventListener('change', updateEstimate);
areaDistrictRadio.addEventListener('change', updateEstimate);
areaGhanaRadio.addEventListener('change', updateEstimate);
ackCheck.addEventListener('change', updateEstimate);
// Start the download
startBtn.addEventListener('click', async () => {
const baseMap = basemapSelect.value;
const minZ = parseInt(minZoomInput.value, 10);
const maxZ = parseInt(maxZoomInput.value, 10);
const extent = getSelectedExtent();
if (!extent) return;
// Switch UI to progress view
formView.classList.add('d-none');
progressView.classList.remove('d-none');
startBtn.classList.add('d-none');
cancelBtn.textContent = 'Cancel download';
headerCloseBtn.disabled = true;
progressBar.style.width = '0%';
progressBar.setAttribute('aria-valuenow', '0');
progressPercent.textContent = '0%';
progressCounts.textContent = '0 of 0 tiles';
progressOk.textContent = '0';
progressFailed.textContent = '0';
progressEta.textContent = '—';
currentDownloader = new OfflineTileDownloader({
baseMap,
extent3857: extent,
minZoom: minZ,
maxZoom: maxZ,
onProgress: (s) => {
if (s.total > 0) {
const pct = Math.min(100, Math.round((s.done / s.total) * 100));
progressBar.style.width = pct + '%';
progressBar.setAttribute('aria-valuenow', String(pct));
progressPercent.textContent = pct + '%';
progressCounts.textContent = `${s.done.toLocaleString()} of ${s.total.toLocaleString()} tiles`;
}
progressOk.textContent = s.ok.toLocaleString();
progressFailed.textContent = s.failed.toLocaleString();
progressEta.textContent = s.etaMs != null ? fmtDuration(s.etaMs) : '—';
},
});
let result;
try {
result = await currentDownloader.start();
} catch (err) {
console.error('[OfflineDownload] failed:', err);
result = { phase: 'error', done: 0, total: 0, ok: 0, failed: 0 };
}
// Switch UI to done view
progressView.classList.add('d-none');
doneView.classList.remove('d-none');
cancelBtn.classList.add('d-none');
closeDoneBtn.classList.remove('d-none');
headerCloseBtn.disabled = false;
if (result.phase === 'cancelled') {
doneTitle.textContent = 'Download cancelled';
doneDetail.innerHTML = `Stopped after <strong>${result.done.toLocaleString()}</strong> of ${result.total.toLocaleString()} tiles.<br>` +
`${result.ok.toLocaleString()} fetched · ${result.failed.toLocaleString()} failed.`;
} else if (result.phase === 'error') {
doneTitle.textContent = 'Download failed';
doneDetail.textContent = 'See console for details.';
} else {
doneTitle.textContent = 'Download complete';
doneDetail.innerHTML = `<strong>${result.ok.toLocaleString()}</strong> tiles cached` +
(result.failed > 0 ? `, ${result.failed.toLocaleString()} failed` : '') +
`.<br>Took ${fmtDuration(result.elapsedMs)}.`;
}
});
// Cancel button — either close modal (form view) or cancel download (progress view)
cancelBtn.addEventListener('click', () => {
if (currentDownloader) {
currentDownloader.cancel();
}
});
// When modal is fully hidden, reset for next time
modalEl.addEventListener('hidden.bs.modal', () => {
if (currentDownloader) currentDownloader.cancel();
resetModal();
});
}
/**
* 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
// ============================================================================
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}