pwaLUPMIS2/main.js
2026-03-04 12:59:40 +01:00

1586 lines
51 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,
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';
// Map measurement and drawing tools
import { MapTools } from './src/components/MapTools.js';
// PWA module (registers Service Worker, handles install/offline)
import { initPWA, isOnline, onOfflineChange } from './src/pwa.js';
// Remote database API (PostgreSQL backend)
import { getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints } 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
mapView = new MapView('map', {
center: [-1.5, 7.5], // Ghana
zoom: 7,
basemap: 'osm'
});
// Initialize map measurement tools
mapTools = new MapTools(mapView.getMap());
// Handle measurement results
mapTools.onMeasureComplete((result) => {
console.log('[MapTools] Measurement complete:', result);
// When an area polygon is completed, show the attribute form popup
if (result.type === 'polygon' && result.coordinate) {
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 mode the Select interaction handles clicks for geometry
// editing — don't interfere with it.
if (currentMode === 'draw') {
console.log('[MapClick] In draw mode, Select interaction handles clicks');
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 === '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);
// 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();
} 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();
}
});
console.log('[App] Initialized successfully');
}
// ============================================================================
// UI Initialization
// ============================================================================
function initUI() {
console.log('[initUI] Starting UI initialization...');
// 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());
}
// 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');
if (!statsContainer || !tbody) return;
try {
const stats = await getTableStats();
tbody.innerHTML = stats.map(t => `
<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 pe-3"><span class="badge bg-secondary">${t.count}</span></td>
</tr>
`).join('');
statsContainer.classList.remove('d-none');
// Attach click handlers to table name links
tbody.querySelectorAll('.table-name-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTableContent(link.dataset.table);
});
});
} catch (error) {
console.error('[App] Failed to load table stats:', error);
tbody.innerHTML = `<tr><td colspan="2" class="text-danger ps-3">Failed to load</td></tr>`;
statsContainer.classList.remove('d-none');
}
}
// ============================================================================
// 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, fetch fresh data from the API
if (isOnline()) {
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, fetch fresh data from the API
if (isOnline()) {
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;
const features = [];
for (const parcel of parcels) {
// API may use 'polygon', 'boundary', 'geom' or 'wkt' for the WKT field
const wkt = parcel.polygon || parcel.boundary || parcel.geom || parcel.wkt;
const geometry = parseWKT(wkt);
if (!geometry) continue;
// Collect all properties except the raw WKT geometry
const properties = { _layerType: 'parcel' };
for (const [key, value] of Object.entries(parcel)) {
if (key === 'polygon' || key === 'boundary' || key === 'geom' || key === 'wkt') 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, fetch fresh data from the API
if (isOnline()) {
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, fetch fresh data from the API
if (isOnline()) {
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);
}
}
/**
* 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, fetch fresh data from the API
if (isOnline()) {
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');
}
// ============================================================================
// Utilities
// ============================================================================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) {
const el = document.getElementById('error-message');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
// Auto-hide after 5 seconds
setTimeout(() => {
el.classList.add('d-none');
}, 5000);
} else {
console.error(message);
}
}
function showSuccess(message) {
const el = document.getElementById('success-message');
if (el) {
el.querySelector('.message-text').textContent = message;
el.classList.remove('d-none');
// Auto-hide after 3 seconds
setTimeout(() => {
el.classList.add('d-none');
}, 3000);
} else {
console.log(message);
}
}
// ============================================================================
// Start Application
// ============================================================================
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}