Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.
Drawing & editing toolkit
* Polygon Divide tool — sub-button under Split, divides a polygon into
N equal-area pieces via binary search; user picks the cutting edge
* UPN pick phase after Split and Divide — non-picked pieces have their
identifier fields cleared automatically
* Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
hybrid lockstep extension; bold A/B labels on selected polygons
* Persistent vertex highlights — all vertices of the selected polygon
rendered as dots while edit mode is on, without subclassing ol-ext
* Toast notifications for merge/split/divide outcomes
* Shapefile import — addGeoJSONLayer now includes an image style so
Point features render (previously invisible)
Background & overlay layers
* DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
* DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
* Contours hillshade — get_contours_hillshade.php → local SQLite cache
* OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
style (black 3.5 px outer, #F0F1F0 1.5 px inner)
* External Source dialog — green + button in LayerSwitcher lets users
add WMS / WFS / XYZ layers at runtime
* Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
legendUrl, onlineOnly options
* TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
* Legend panel — bottom-right, auto-shown for visible layers that
register a legendUrl
* Default base map setting in Settings, persisted in localStorage;
setBaseMap() on MapView
Offline tile cache (Phase 1 + 2)
* Service worker: per-host tile caches (osm / topo / satellite /
carto-light / carto-dark), counter-based eviction to prevent
iOS Safari memory-pressure reloads, GET_TILE_STATS /
CLEAR_TILE_CACHES message API
* pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
getTileCacheStats, clearTileCaches, getStorageEstimate
* Settings: Offline Map Tiles card with per-provider stats + clear
* Phase 2 download dialog: form to pick base map, area (current view /
district / Ghana), zoom range; live tile-count + size estimate;
progress bar with cancel; OfflineTileDownloader class with
concurrency + throttling
Local database management
* osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
* CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
* Local Database Tables card: per-row Clear button (cached layers
only) + 'Refresh cached layers' header button with reload prompt
Build & infrastructure
* Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
* chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
can't be split further)
* Toast notification module (src/toast.js)
* Units module (src/units.js) for metric / imperial conversions
* PDF export module (src/pdf-export.js)
Documentation & SQL
* Topographic_Background_Layers_for_LUPMIS2.docx — research report
* OpenTopography_Workflow.svg/.png — ETL pipeline diagram
* LUPMIS2_Development_Status_Report.docx — April update section
* sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
land-use parcel specification (Feb 2026, revised), with PostGIS
geometry column and standard indices
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3019 lines
102 KiB
JavaScript
3019 lines
102 KiB
JavaScript
/**
|
|
* 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,
|
|
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';
|
|
|
|
// 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, 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 } from './src/remotedb.js';
|
|
|
|
// Map instance (global for access across functions)
|
|
let mapView = null;
|
|
let mapTools = null;
|
|
|
|
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
|
|
let currentMode = 'addLocation';
|
|
|
|
// ============================================================================
|
|
// Application Initialization
|
|
// ============================================================================
|
|
|
|
async function initApp() {
|
|
console.log('[App] Initializing...');
|
|
|
|
// 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());
|
|
|
|
// 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': '📌'
|
|
|
|
// Set up map click handler immediately after map creation
|
|
mapView.onClick((lon, lat, feature, evt) => {
|
|
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;
|
|
}
|
|
|
|
// 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) => {
|
|
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();
|
|
loadCollectorZones();
|
|
loadParcels();
|
|
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();
|
|
|
|
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)',
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)',
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
// Prefer the GeoJSON geometry (sp_boundary) if available; fall back to WKT
|
|
let geometry = null;
|
|
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.polygon || parcel.geom || parcel.wkt;
|
|
geometry = parseWKT(wkt);
|
|
}
|
|
if (!geometry) continue;
|
|
|
|
// Collect all properties except bulky geometry fields
|
|
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'wkt', 'textboundary', 'sp_boundary']);
|
|
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)',
|
|
};
|
|
|
|
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: [] };
|
|
const 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)',
|
|
};
|
|
|
|
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,
|
|
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)',
|
|
};
|
|
|
|
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:
|
|
'© <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) {
|
|
importedFileLayers.push(layer);
|
|
totalFeatures += fc.features.length;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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>';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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>${p.label}</td>
|
|
<td class="text-end">${p.count.toLocaleString()} / ${p.limit.toLocaleString()}</td>
|
|
<td class="text-end">${fmtBytes(p.estBytes)}</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>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>${storageNote}`;
|
|
clearBtn.disabled = false;
|
|
} 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();
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Start Application
|
|
// ============================================================================
|
|
|
|
// Wait for DOM to be ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initApp);
|
|
} else {
|
|
initApp();
|
|
}
|