/** * 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 = `

No locations yet.

Click the map or fill the form above!
`; return; } // Category emoji mapping const categoryEmojis = { 'water': '💧', 'school': '🏫', 'health': '🏥', 'market': '🏪', 'default': '📍', 'other': '📌' }; container.innerHTML = locations.map(loc => { const emoji = categoryEmojis[loc.category] || '📍'; return `
${emoji} ${escapeHtml(loc.name)}
${loc.latitude.toFixed(5)}, ${loc.longitude.toFixed(5)}
${loc.category}
${loc.description ? `${escapeHtml(loc.description)}` : ''}
`; }).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 => ` ${escapeHtml(t.name)} ${t.count} `).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 = `Failed to load`; 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 = `
Loading...
`; modalInfo.textContent = ''; // Open the modal const modal = new Modal(document.getElementById('tableContentModal')); modal.show(); try { const { columns, rows } = await getTableContent(tableName); if (rows.length === 0) { modalBody.innerHTML = `
Table is empty
`; modalInfo.textContent = '0 rows'; return; } // Build a responsive table const headerCells = columns.map(c => `${escapeHtml(c)}`).join(''); const bodyRows = rows.map(row => { const cells = columns.map(c => { let val = row[c]; if (val === null || val === undefined) return 'NULL'; val = String(val); // Truncate long values for display const display = val.length > 120 ? val.substring(0, 120) + '...' : val; return `${escapeHtml(display)}`; }).join(''); return `${cells}`; }).join(''); modalBody.innerHTML = `
${headerCells}${bodyRows}
`; 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 = `
Failed to load: ${escapeHtml(error.message)}
`; } } // ============================================================================ // 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 = `
Ready: ${status.ready ? 'Yes' : 'No'}
Online: ${isOnline() ? 'Yes' : 'Offline'}
Database: ${status.databasePath || 'N/A'}
Tables: ${status.tables.map(t => `${t}`).join('')}
Locations: ${status.locationCount}
`; } // 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(); }