/**
* 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,
updateExternalImportFeatureGeometry,
deleteExternalImportFeature,
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, formatUTM } 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';
// ----- Import-spinner helpers ---------------------------------------------
// Shown between "user dropped a file" and "mapping modal opens" — Shapefile
// zip decompression in particular can take several seconds on big files.
function showImportSpinner(filename) {
const overlay = document.getElementById('import-spinner-overlay');
const nameEl = document.getElementById('import-spinner-filename');
if (!overlay) return;
if (nameEl) nameEl.textContent = filename || '';
overlay.classList.remove('d-none');
overlay.classList.add('d-flex');
}
function hideImportSpinner() {
const overlay = document.getElementById('import-spinner-overlay');
if (!overlay) return;
overlay.classList.add('d-none');
overlay.classList.remove('d-flex');
}
// 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: 'none' | 'addLocation' | 'measureCircle' |
// 'measureLine' | 'measureArea' | 'draw' | 'embed-permit'.
// Default is 'none' — a neutral state where map clicks do not trigger
// the Add-Location popup. Users explicitly opt into Add-Location by
// pressing the Add button in the bottom dock; pressing it again toggles
// the mode back off. Measurement / Draw tools also return to 'none'
// when toggled off, rather than implicitly re-entering Add-Location.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'none';
// ============================================================================
// 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 = `
🛑
No district assigned
${escapeHtml(name)}, your user profile is not associated with any
district. LUPMIS2 cannot load the relevant map data without one.
Please contact the system administrator to have a district assigned
to your account.
`;
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);
}
});
// When the user modifies an imported feature with the EditBar (Draw mode +
// Select + drag a vertex/edge), persist the new geometry back to the
// matching external_import_features row so the next upload reflects it.
// Non-imported features (drawn parcels, server parcels, etc.) carry no
// _externalImportId / _clientUuid tags, so this is a quick no-op for them.
mapView.onFeatureModified(async (feature) => {
const importId = feature.get('_externalImportId');
const clientUuid = feature.get('_clientUuid');
if (importId == null || !clientUuid) return;
try {
const wkt = wktFormat.writeGeometry(feature.getGeometry(), {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
await updateExternalImportFeatureGeometry(clientUuid, wkt);
console.log('[App] Imported feature geometry updated in staging:', clientUuid);
} catch (error) {
console.warn('[App] Failed to persist imported-feature edit:', error);
showError('Could not save the edit locally: ' + 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');
// Guard against a stale district: if the authenticated user's district
// has changed since the last load (e.g. an admin reassigned them in the
// remote DB), every locally-cached, district-scoped layer is now wrong.
// Wipe them so the fresh-fetch path repopulates from the correct
// district instead of showing the previous one. Must run BEFORE any
// district-scoped load below.
await enforceDistrictConsistency();
// Show database status
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();
// 9b. GPS coordinate format (lat/lon · UTM · both)
initCoordinateFormat();
// 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 — toggles. Clicking it once activates the
// mode (subsequent map-clicks open the Add-Location popup); clicking it
// again returns to the neutral 'none' mode so accidental map taps don't
// trigger the popup.
if (addLocationBtn) {
addLocationBtn.addEventListener('click', () => {
console.log('[Button] Add Location clicked, currentMode is:', currentMode);
if (currentMode === 'addLocation') {
setMode('none', null); // toggle off
} else {
setMode('addLocation', addLocationBtn); // activate
}
});
}
// Circle measurement button
if (measureCircleBtn) {
measureCircleBtn.addEventListener('click', () => {
console.log('[Button] Circle clicked, currentMode is:', currentMode);
if (currentMode === 'measureCircle') {
// Toggle off — return to the neutral 'none' mode (no implicit
// Add-Location re-activation).
setMode('none', null);
} else {
setMode('measureCircle', measureCircleBtn);
}
});
}
// Line measurement button
if (measureLineBtn) {
measureLineBtn.addEventListener('click', () => {
console.log('[Button] Line clicked, currentMode is:', currentMode);
if (currentMode === 'measureLine') {
setMode('none', null);
} else {
setMode('measureLine', measureLineBtn);
}
});
}
// Area measurement button
if (measureAreaBtn) {
measureAreaBtn.addEventListener('click', () => {
console.log('[Button] Area clicked, currentMode is:', currentMode);
if (currentMode === 'measureArea') {
setMode('none', null);
} else {
setMode('measureArea', measureAreaBtn);
}
});
}
// Draw / Edit button
if (drawBtn) {
drawBtn.addEventListener('click', () => {
console.log('[Button] Draw clicked, currentMode is:', currentMode);
if (currentMode === 'draw') {
setMode('none', null);
} 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 = `
`;
}).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 = `
Failed to load
`;
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 = `
Loading...
`;
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 = `
${total.count.toLocaleString()} tiles cached, ~${fmtBytes(total.estBytes)} on this device
Base map
Cached / limit
Approx. size
${rows}
${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 = ` Zoom ${maxZ} is above this provider's max (${tplMaxZoom}); will clamp to ${tplMaxZoom}.`;
}
if (count > 8000) {
warningHTML += ` More than 8 000 tiles — exceeds the per-provider cache limit. Earlier tiles will be evicted as new ones arrive.`;
}
estimateEl.innerHTML =
`${count.toLocaleString()} 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 ${result.done.toLocaleString()} of ${result.total.toLocaleString()} tiles. ` +
`${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 = `${result.ok.toLocaleString()} tiles cached` +
(result.failed > 0 ? `, ${result.failed.toLocaleString()} failed` : '') +
`. 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 landingLink = document.getElementById('menu-landing-link');
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 });
landingLink?.classList.remove('d-none'); // stay-signed-in route to the hub
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 = '';
avatarEl.style.background = 'var(--brand-orange-warm, #ff9e1b)';
nameEl.textContent = 'No session injected';
emailEl.textContent = '';
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
landingLink?.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 = '';
avatarEl.style.background = 'var(--brand-gray-medium, #7a7a7a)';
nameEl.textContent = 'Not signed in';
emailEl.textContent = '';
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
landingLink?.classList.add('d-none'); // sign-in link below already points to the hub
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.
/**
* Logout flow:
* 1. Refuse if offline (login needs the server — see guard below).
* 2. Confirm with the user.
* 3. Wipe district-scoped local caches so the next (possibly different) user
* on this device can't briefly see the previous user's cached map data.
* 4. Navigate to the PWA's OWN logout endpoint (/?logout=1). That endpoint
* runs session_destroy() — clearing the PHPSESSID session that holds the
* user's district_id — and then redirects to the central portal's
* /user-logout, which performs the full SSO logout (invalidates the token
* and clears sso_auth_token). We let the server chain own the SSO side, so
* there is no separate client-side token-invalidation call.
*/
async function handleSignOut(session) {
// Guard: logging back in requires the SSO server (a session can only be
// created by validating the token online). If we let the user log out while
// offline, they would be stranded on this device — unable to sign back in
// until connectivity returns — and the server-side session_destroy() at
// /?logout=1 could not run anyway. So we refuse offline logout outright,
// which also protects against an accidental tap wiping the working session
// during fieldwork. The app keeps functioning offline on the cached session,
// so there is no need to log out until back online.
if (!isOnline()) {
alert(
'You appear to be offline.\n\n' +
'Logging out needs a connection to the sign-in server, and you would not ' +
'be able to sign back in until you are online again.\n\n' +
'Please try again once you have a connection. You can keep working — the ' +
'app stays signed in while offline.'
);
return;
}
if (!confirm(`Log out, ${session?.full_name || session?.username || 'user'}?`)) {
return;
}
// Drop locally-cached, district-scoped layers and the last-district marker
// so a different next user starts clean.
try {
await clearAllCachedLayers();
localStorage.removeItem(LAST_DISTRICT_KEY);
} catch (err) {
console.warn('[Logout] Cache clear failed (continuing):', err);
}
// Hand off to the server logout endpoint: it destroys the PWA's PHP session,
// then redirects to https://lupmis4luspa.org/user-logout for the full SSO
// logout (token invalidation + sso_auth_token cleared by the portal).
window.location.href = '/?logout=1';
}
// ============================================================================
// Start Application
// ============================================================================
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}