files for /src
Mapview.js must be placed into /src/components
This commit is contained in:
parent
923d353fec
commit
876e884509
837
MapView.js
Normal file
837
MapView.js
Normal file
@ -0,0 +1,837 @@
|
|||||||
|
/**
|
||||||
|
* MapView Component
|
||||||
|
*
|
||||||
|
* OpenLayers map with ol-ext LayerSwitcher for base map selection.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { MapView } from './components/MapView.js';
|
||||||
|
*
|
||||||
|
* const map = new MapView('map', {
|
||||||
|
* center: [-1.5, 7.5], // Ghana
|
||||||
|
* zoom: 7,
|
||||||
|
* basemap: 'osm'
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* map.onClick((lon, lat) => console.log('Clicked:', lon, lat));
|
||||||
|
* map.addMarker(lon, lat, { name: 'Point A' });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Map from 'ol/Map';
|
||||||
|
import View from 'ol/View';
|
||||||
|
import Overlay from 'ol/Overlay';
|
||||||
|
import TileLayer from 'ol/layer/Tile';
|
||||||
|
import LayerGroup from 'ol/layer/Group';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import OSM from 'ol/source/OSM';
|
||||||
|
import XYZ from 'ol/source/XYZ';
|
||||||
|
import { fromLonLat, toLonLat } from 'ol/proj';
|
||||||
|
import { Point } from 'ol/geom';
|
||||||
|
import Feature from 'ol/Feature';
|
||||||
|
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
|
||||||
|
|
||||||
|
// ol-ext LayerSwitcher
|
||||||
|
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
|
||||||
|
|
||||||
|
// ol-ext GeolocationButton
|
||||||
|
import GeolocationButton from 'ol-ext/control/GeolocationButton';
|
||||||
|
|
||||||
|
// CSS imports
|
||||||
|
import 'ol/ol.css';
|
||||||
|
import 'ol-ext/dist/ol-ext.css';
|
||||||
|
|
||||||
|
export class MapView {
|
||||||
|
constructor(targetId, options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.markerSource = new VectorSource();
|
||||||
|
this.clickCallbacks = [];
|
||||||
|
|
||||||
|
// Category emoji mapping
|
||||||
|
this.categoryEmojis = {
|
||||||
|
'water': '💧',
|
||||||
|
'school': '🏫',
|
||||||
|
'health': '🏥',
|
||||||
|
'market': '🏪',
|
||||||
|
'hotel': '🏨',
|
||||||
|
'restaurant': '🍽️',
|
||||||
|
'default': '📍',
|
||||||
|
'other': '📌'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create emoji style helper
|
||||||
|
this.createEmojiStyle = (emoji, fontSize = 24) => {
|
||||||
|
return new Style({
|
||||||
|
text: new Text({
|
||||||
|
text: emoji,
|
||||||
|
font: `${fontSize}px sans-serif`,
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
textAlign: 'center',
|
||||||
|
offsetY: -5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default marker style (pin emoji)
|
||||||
|
this.defaultStyle = this.createEmojiStyle('📍', 32);
|
||||||
|
|
||||||
|
// Selected marker style (larger)
|
||||||
|
this.selectedStyle = this.createEmojiStyle('📍', 42);
|
||||||
|
|
||||||
|
// Initialize category styles with emojis
|
||||||
|
this.categoryStyles = {};
|
||||||
|
for (const [category, emoji] of Object.entries(this.categoryEmojis)) {
|
||||||
|
this.categoryStyles[category] = this.createEmojiStyle(emoji, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create base layers group
|
||||||
|
const baseLayers = this.createBaseLayers(options.basemap || 'osm');
|
||||||
|
|
||||||
|
// Markers layer
|
||||||
|
this.markersLayer = new VectorLayer({
|
||||||
|
title: 'Markers',
|
||||||
|
source: this.markerSource,
|
||||||
|
style: (feature) => this.getFeatureStyle(feature),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
this.map = new Map({
|
||||||
|
target: targetId,
|
||||||
|
layers: [
|
||||||
|
baseLayers,
|
||||||
|
this.markersLayer,
|
||||||
|
],
|
||||||
|
view: new View({
|
||||||
|
center: fromLonLat(options.center || [0, 0]),
|
||||||
|
zoom: options.zoom || 2,
|
||||||
|
minZoom: options.minZoom || 2,
|
||||||
|
maxZoom: options.maxZoom || 19,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add LayerSwitcher control
|
||||||
|
const layerSwitcher = new LayerSwitcher({
|
||||||
|
collapsed: true,
|
||||||
|
mouseover: true,
|
||||||
|
extent: false,
|
||||||
|
trash: false,
|
||||||
|
oninfo: null,
|
||||||
|
});
|
||||||
|
this.map.addControl(layerSwitcher);
|
||||||
|
|
||||||
|
// Add GeolocationButton control
|
||||||
|
const geolocationButton = new GeolocationButton({
|
||||||
|
title: 'My Location',
|
||||||
|
delay: 3000, // Auto-center duration
|
||||||
|
zoom: 16, // Zoom level when centering on location
|
||||||
|
});
|
||||||
|
this.map.addControl(geolocationButton);
|
||||||
|
|
||||||
|
// Store reference for external access
|
||||||
|
this.geolocationButton = geolocationButton;
|
||||||
|
|
||||||
|
// Track selected feature
|
||||||
|
this.selectedFeature = null;
|
||||||
|
|
||||||
|
// Create popup overlay for hover
|
||||||
|
this.createPopup();
|
||||||
|
|
||||||
|
// Create Add Location popup form
|
||||||
|
this.createAddLocationPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the popup overlay element and add to map
|
||||||
|
*/
|
||||||
|
createPopup() {
|
||||||
|
// Create popup container element
|
||||||
|
this.popupElement = document.createElement('div');
|
||||||
|
this.popupElement.className = 'map-popup';
|
||||||
|
this.popupElement.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 280px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create the overlay
|
||||||
|
this.popup = new Overlay({
|
||||||
|
element: this.popupElement,
|
||||||
|
positioning: 'bottom-center',
|
||||||
|
offset: [0, -15],
|
||||||
|
stopEvent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.addOverlay(this.popup);
|
||||||
|
|
||||||
|
// Set up hover handler
|
||||||
|
this.setupHoverPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the hover popup behavior
|
||||||
|
*/
|
||||||
|
setupHoverPopup() {
|
||||||
|
let currentFeature = null;
|
||||||
|
|
||||||
|
this.map.on('pointermove', (evt) => {
|
||||||
|
if (evt.dragging) {
|
||||||
|
this.hidePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
|
||||||
|
|
||||||
|
if (feature && feature !== currentFeature) {
|
||||||
|
currentFeature = feature;
|
||||||
|
this.showPopup(feature, evt.coordinate);
|
||||||
|
} else if (!feature && currentFeature) {
|
||||||
|
currentFeature = null;
|
||||||
|
this.hidePopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor
|
||||||
|
this.map.getTargetElement().style.cursor = feature ? 'pointer' : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide popup when mouse leaves the map
|
||||||
|
this.map.getTargetElement().addEventListener('mouseleave', () => {
|
||||||
|
this.hidePopup();
|
||||||
|
currentFeature = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show popup with feature attributes
|
||||||
|
*/
|
||||||
|
showPopup(feature, coordinate) {
|
||||||
|
const name = feature.get('name') || 'Unnamed';
|
||||||
|
const category = feature.get('category') || 'default';
|
||||||
|
const description = feature.get('description');
|
||||||
|
const lon = feature.get('lon');
|
||||||
|
const lat = feature.get('lat');
|
||||||
|
const emoji = this.categoryEmojis[category] || '📍';
|
||||||
|
|
||||||
|
// Build popup content
|
||||||
|
let html = `
|
||||||
|
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px;">
|
||||||
|
${emoji} ${this.escapeHtml(name)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Category badge
|
||||||
|
const categoryColors = {
|
||||||
|
'water': '#3b82f6',
|
||||||
|
'school': '#f59e0b',
|
||||||
|
'health': '#ef4444',
|
||||||
|
'market': '#8b5cf6',
|
||||||
|
'hotel': '#8b5cf6',
|
||||||
|
'restaurant': '#8b5cf6',
|
||||||
|
'default': '#2d5016',
|
||||||
|
'other': '#6b7280'
|
||||||
|
};
|
||||||
|
const catColor = categoryColors[category] || '#6b7280';
|
||||||
|
html += `
|
||||||
|
<div style="margin-bottom: 6px;">
|
||||||
|
<span style="
|
||||||
|
background: ${catColor}20;
|
||||||
|
color: ${catColor};
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
">${category}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Description if available
|
||||||
|
if (description) {
|
||||||
|
html += `
|
||||||
|
<div style="color: #555; font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
|
||||||
|
${this.escapeHtml(description)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinates
|
||||||
|
if (lon !== undefined && lat !== undefined) {
|
||||||
|
html += `
|
||||||
|
<div style="color: #888; font-size: 11px; font-family: monospace;">
|
||||||
|
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popupElement.innerHTML = html;
|
||||||
|
this.popup.setPosition(coordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the popup
|
||||||
|
*/
|
||||||
|
hidePopup() {
|
||||||
|
this.popup.setPosition(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Add Location popup form overlay
|
||||||
|
*/
|
||||||
|
createAddLocationPopup() {
|
||||||
|
// Create popup container element
|
||||||
|
this.addLocationPopupElement = document.createElement('div');
|
||||||
|
this.addLocationPopupElement.className = 'map-add-location-popup';
|
||||||
|
this.addLocationPopupElement.innerHTML = `
|
||||||
|
<div class="add-location-popup-header">
|
||||||
|
<span>➕ Add Location</span>
|
||||||
|
<button type="button" class="add-location-popup-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="map-add-location-form">
|
||||||
|
<div class="add-location-popup-field">
|
||||||
|
<label for="map-location-name">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="map-location-name" name="name" required placeholder="e.g., Water Point A">
|
||||||
|
</div>
|
||||||
|
<div class="add-location-popup-field">
|
||||||
|
<label for="map-location-category">Category</label>
|
||||||
|
<select id="map-location-category" name="category">
|
||||||
|
<option value="default">📍 Default</option>
|
||||||
|
<option value="water">💧 Water Point</option>
|
||||||
|
<option value="school">🏫 School</option>
|
||||||
|
<option value="health">🏥 Health Facility</option>
|
||||||
|
<option value="market">🏪 Market</option>
|
||||||
|
<option value="other">📌 Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="add-location-popup-field">
|
||||||
|
<label for="map-location-description">Description</label>
|
||||||
|
<textarea id="map-location-description" name="description" rows="2" placeholder="Optional notes..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="add-location-popup-coords">
|
||||||
|
<small>📍 <span id="map-location-coords"></span></small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="add-location-popup-submit">➕ Add Location</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create the overlay
|
||||||
|
this.addLocationPopup = new Overlay({
|
||||||
|
element: this.addLocationPopupElement,
|
||||||
|
positioning: 'bottom-center',
|
||||||
|
offset: [0, -10],
|
||||||
|
stopEvent: true, // Prevent click from propagating
|
||||||
|
autoPan: true,
|
||||||
|
autoPanAnimation: {
|
||||||
|
duration: 250,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.addOverlay(this.addLocationPopup);
|
||||||
|
|
||||||
|
// Store clicked coordinates
|
||||||
|
this.addLocationCoords = null;
|
||||||
|
|
||||||
|
// Set up close button handler
|
||||||
|
const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
this.hideAddLocationPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store form submit callbacks
|
||||||
|
this.addLocationCallbacks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Add Location popup at the specified coordinate
|
||||||
|
*/
|
||||||
|
showAddLocationPopup(coordinate) {
|
||||||
|
const [lon, lat] = toLonLat(coordinate);
|
||||||
|
this.addLocationCoords = { lon, lat };
|
||||||
|
|
||||||
|
// Update coordinates display
|
||||||
|
const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
|
||||||
|
coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// Position and show popup
|
||||||
|
this.addLocationPopup.setPosition(coordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the Add Location popup
|
||||||
|
*/
|
||||||
|
hideAddLocationPopup() {
|
||||||
|
this.addLocationPopup.setPosition(undefined);
|
||||||
|
this.addLocationCoords = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for when a location is submitted via the map popup
|
||||||
|
* Callback receives: { name, category, description, lon, lat }
|
||||||
|
*/
|
||||||
|
onAddLocation(callback) {
|
||||||
|
this.addLocationCallbacks.push(callback);
|
||||||
|
|
||||||
|
// Set up form submit handler (only once)
|
||||||
|
if (this.addLocationCallbacks.length === 1) {
|
||||||
|
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.addLocationCoords) return;
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const data = {
|
||||||
|
name: formData.get('name'),
|
||||||
|
category: formData.get('category'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
lon: this.addLocationCoords.lon,
|
||||||
|
lat: this.addLocationCoords.lat,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call all registered callbacks
|
||||||
|
this.addLocationCallbacks.forEach(cb => cb(data));
|
||||||
|
|
||||||
|
// Hide popup after submission
|
||||||
|
this.hideAddLocationPopup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create base layers group for LayerSwitcher
|
||||||
|
*/
|
||||||
|
createBaseLayers(defaultBasemap) {
|
||||||
|
|
||||||
|
|
||||||
|
const topoLayer = new TileLayer({
|
||||||
|
title: 'Topographic',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'topo',
|
||||||
|
source: new XYZ({
|
||||||
|
url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
attributions: 'Map data: © OpenTopoMap',
|
||||||
|
maxZoom: 17,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cartoLightLayer = new TileLayer({
|
||||||
|
title: 'Carto Light',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'carto-light',
|
||||||
|
source: new XYZ({
|
||||||
|
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
|
||||||
|
attributions: '© CARTO',
|
||||||
|
maxZoom: 19,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cartoDarkLayer = new TileLayer({
|
||||||
|
title: 'Carto Dark',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'carto-dark',
|
||||||
|
source: new XYZ({
|
||||||
|
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
attributions: '© CARTO',
|
||||||
|
maxZoom: 19,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const osmCycleLayer = new TileLayer({
|
||||||
|
title: 'OSM Cycle map',
|
||||||
|
type: 'base',
|
||||||
|
visible: false, //defaultBasemap === 'osm',
|
||||||
|
source: new OSM({
|
||||||
|
"url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const satelliteLayer = new TileLayer({
|
||||||
|
title: 'Satellite',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'satellite',
|
||||||
|
source: new XYZ({
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attributions: 'Tiles © Esri',
|
||||||
|
maxZoom: 19,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const googleLayer = new TileLayer({
|
||||||
|
title: 'Google Sat',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'googlesat',
|
||||||
|
source: new XYZ({
|
||||||
|
// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
|
||||||
|
attributions: 'Tiles © Google',
|
||||||
|
maxZoom: 19,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const osmLayer = new TileLayer({
|
||||||
|
title: 'OpenStreetMap',
|
||||||
|
type: 'base',
|
||||||
|
visible: defaultBasemap === 'osm',
|
||||||
|
source: new OSM(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Return LayerGroup for LayerSwitcher
|
||||||
|
return new LayerGroup({
|
||||||
|
title: 'Base Maps',
|
||||||
|
layers: [
|
||||||
|
cartoLightLayer,
|
||||||
|
cartoDarkLayer,
|
||||||
|
satelliteLayer,
|
||||||
|
osmCycleLayer,
|
||||||
|
topoLayer,
|
||||||
|
googleLayer,
|
||||||
|
osmLayer,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get style for a feature (handles selection state)
|
||||||
|
*/
|
||||||
|
getFeatureStyle(feature) {
|
||||||
|
const category = feature.get('category') || 'default';
|
||||||
|
const emoji = this.categoryEmojis[category] || '📍';
|
||||||
|
|
||||||
|
if (feature === this.selectedFeature) {
|
||||||
|
// Return selected style with the correct emoji and highlight
|
||||||
|
return [
|
||||||
|
// Background highlight circle
|
||||||
|
new Style({
|
||||||
|
image: new Circle({
|
||||||
|
radius: 22,
|
||||||
|
fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }),
|
||||||
|
stroke: new Stroke({ color: '#dc2626', width: 3 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Emoji on top, larger
|
||||||
|
new Style({
|
||||||
|
text: new Text({
|
||||||
|
text: emoji,
|
||||||
|
font: '40px sans-serif',
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
textAlign: 'center',
|
||||||
|
offsetY: -5,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for custom style
|
||||||
|
const customStyle = feature.get('style');
|
||||||
|
if (customStyle) {
|
||||||
|
return customStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return category-based emoji style
|
||||||
|
if (this.categoryStyles[category]) {
|
||||||
|
return this.categoryStyles[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.defaultStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set category-based styles with emojis
|
||||||
|
* @param {Object} styles - Map of category to config { emoji, fontSize }
|
||||||
|
*/
|
||||||
|
setCategoryStyles(styles) {
|
||||||
|
for (const [category, config] of Object.entries(styles)) {
|
||||||
|
// Update emoji mapping if provided
|
||||||
|
if (config.emoji) {
|
||||||
|
this.categoryEmojis[category] = config.emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create/update style
|
||||||
|
const emoji = this.categoryEmojis[category] || '📍';
|
||||||
|
const fontSize = config.fontSize || 28;
|
||||||
|
|
||||||
|
this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh markers
|
||||||
|
this.markerSource.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single marker
|
||||||
|
*/
|
||||||
|
addMarker(lon, lat, properties = {}) {
|
||||||
|
console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties);
|
||||||
|
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new Point(fromLonLat([lon, lat])),
|
||||||
|
...properties,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store original coordinates for easy access
|
||||||
|
feature.set('lon', lon);
|
||||||
|
feature.set('lat', lat);
|
||||||
|
|
||||||
|
this.markerSource.addFeature(feature);
|
||||||
|
console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length);
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple markers from an array of location objects
|
||||||
|
*/
|
||||||
|
addMarkers(locations) {
|
||||||
|
console.log('[MapView] Adding', locations.length, 'markers');
|
||||||
|
|
||||||
|
const features = locations.map((loc) => {
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
||||||
|
id: loc.id,
|
||||||
|
name: loc.name,
|
||||||
|
description: loc.description,
|
||||||
|
category: loc.category,
|
||||||
|
lon: loc.longitude,
|
||||||
|
lat: loc.latitude,
|
||||||
|
});
|
||||||
|
return feature;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.markerSource.addFeatures(features);
|
||||||
|
console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length);
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all markers
|
||||||
|
*/
|
||||||
|
clearMarkers() {
|
||||||
|
this.markerSource.clear();
|
||||||
|
this.selectedFeature = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific marker by feature or ID
|
||||||
|
*/
|
||||||
|
removeMarker(featureOrId) {
|
||||||
|
if (typeof featureOrId === 'object') {
|
||||||
|
this.markerSource.removeFeature(featureOrId);
|
||||||
|
} else {
|
||||||
|
const feature = this.markerSource.getFeatures().find(
|
||||||
|
f => f.get('id') === featureOrId
|
||||||
|
);
|
||||||
|
if (feature) {
|
||||||
|
this.markerSource.removeFeature(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all markers
|
||||||
|
*/
|
||||||
|
getMarkers() {
|
||||||
|
return this.markerSource.getFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find marker by ID
|
||||||
|
*/
|
||||||
|
findMarker(id) {
|
||||||
|
return this.markerSource.getFeatures().find(f => f.get('id') === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a marker (highlights it)
|
||||||
|
*/
|
||||||
|
selectMarker(featureOrId) {
|
||||||
|
if (typeof featureOrId === 'object') {
|
||||||
|
this.selectedFeature = featureOrId;
|
||||||
|
} else {
|
||||||
|
this.selectedFeature = this.findMarker(featureOrId);
|
||||||
|
}
|
||||||
|
this.markerSource.changed();
|
||||||
|
return this.selectedFeature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear selection
|
||||||
|
*/
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedFeature = null;
|
||||||
|
this.markerSource.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom to a specific location
|
||||||
|
*/
|
||||||
|
zoomTo(lon, lat, zoom = 15) {
|
||||||
|
this.map.getView().animate({
|
||||||
|
center: fromLonLat([lon, lat]),
|
||||||
|
zoom: zoom,
|
||||||
|
duration: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit view to show all markers
|
||||||
|
*/
|
||||||
|
fitToMarkers(padding = 50) {
|
||||||
|
const extent = this.markerSource.getExtent();
|
||||||
|
if (extent && extent[0] !== Infinity) {
|
||||||
|
this.map.getView().fit(extent, {
|
||||||
|
padding: [padding, padding, padding, padding],
|
||||||
|
duration: 500,
|
||||||
|
maxZoom: 16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current map center in lon/lat
|
||||||
|
*/
|
||||||
|
getCenter() {
|
||||||
|
const center = this.map.getView().getCenter();
|
||||||
|
return toLonLat(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current zoom level
|
||||||
|
*/
|
||||||
|
getZoom() {
|
||||||
|
return this.map.getView().getZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set map center
|
||||||
|
*/
|
||||||
|
setCenter(lon, lat) {
|
||||||
|
this.map.getView().setCenter(fromLonLat([lon, lat]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set zoom level
|
||||||
|
*/
|
||||||
|
setZoom(zoom) {
|
||||||
|
this.map.getView().setZoom(zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register click callback
|
||||||
|
* Callback receives (lon, lat, feature, event)
|
||||||
|
*/
|
||||||
|
onClick(callback) {
|
||||||
|
this.clickCallbacks.push(callback);
|
||||||
|
|
||||||
|
// Set up click handler if this is the first callback
|
||||||
|
if (this.clickCallbacks.length === 1) {
|
||||||
|
this.map.on('click', (evt) => {
|
||||||
|
const [lon, lat] = toLonLat(evt.coordinate);
|
||||||
|
|
||||||
|
// Check if clicked on a feature
|
||||||
|
let clickedFeature = null;
|
||||||
|
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
||||||
|
clickedFeature = feature;
|
||||||
|
return true; // Stop at first feature
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call all registered callbacks
|
||||||
|
for (const cb of this.clickCallbacks) {
|
||||||
|
cb(lon, lat, clickedFeature, evt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const index = this.clickCallbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
this.clickCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register pointer move callback (for hover effects)
|
||||||
|
*/
|
||||||
|
onPointerMove(callback) {
|
||||||
|
this.map.on('pointermove', (evt) => {
|
||||||
|
if (evt.dragging) return;
|
||||||
|
|
||||||
|
const [lon, lat] = toLonLat(evt.coordinate);
|
||||||
|
|
||||||
|
let hoveredFeature = null;
|
||||||
|
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
||||||
|
hoveredFeature = feature;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change cursor
|
||||||
|
this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';
|
||||||
|
|
||||||
|
callback(lon, lat, hoveredFeature, evt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable cursor change on marker hover
|
||||||
|
* Note: This is now handled automatically by the popup system
|
||||||
|
*/
|
||||||
|
enableHoverCursor() {
|
||||||
|
// Cursor changes are now handled by setupHoverPopup()
|
||||||
|
// This method is kept for backwards compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenLayers map instance for advanced usage
|
||||||
|
*/
|
||||||
|
getMap() {
|
||||||
|
return this.map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the marker source for advanced usage
|
||||||
|
*/
|
||||||
|
getMarkerSource() {
|
||||||
|
return this.markerSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the markers layer for advanced usage
|
||||||
|
*/
|
||||||
|
getMarkersLayer() {
|
||||||
|
return this.markersLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update map size (call after container resize)
|
||||||
|
*/
|
||||||
|
updateSize() {
|
||||||
|
this.map.updateSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export OpenLayers utilities for convenience
|
||||||
|
export { fromLonLat, toLonLat };
|
||||||
|
|
||||||
|
export default MapView;
|
||||||
551
database.js
Normal file
551
database.js
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* Database Module
|
||||||
|
*
|
||||||
|
* Uses SQLocal directly with BroadcastChannel for cross-tab coordination.
|
||||||
|
*
|
||||||
|
* Why this approach instead of SharedWorker?
|
||||||
|
* - SQLocal already uses its own internal worker for OPFS access
|
||||||
|
* - Wrapping it in another SharedWorker adds complexity and causes issues
|
||||||
|
* - BroadcastChannel provides simple cross-tab communication
|
||||||
|
* - Each tab has its own SQLocal instance but they share the same OPFS database file
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { sql, dbReady, addLocation, getLocations } from './database.js';
|
||||||
|
*
|
||||||
|
* await dbReady;
|
||||||
|
* await addLocation('Point A', -1.5, 7.5);
|
||||||
|
* const locations = await getLocations();
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SQLocal } from 'sqlocal';
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
const DATABASE_PATH = 'lupmis.sqlite3';
|
||||||
|
const BROADCAST_CHANNEL = 'lupmis-db-sync';
|
||||||
|
|
||||||
|
// Create SQLocal instance
|
||||||
|
const db = new SQLocal(DATABASE_PATH);
|
||||||
|
|
||||||
|
// Get the sql tagged template function
|
||||||
|
const { sql } = db;
|
||||||
|
|
||||||
|
console.log('[Database] SQLocal instance created for:', DATABASE_PATH);
|
||||||
|
|
||||||
|
// Export sql for direct queries
|
||||||
|
export { sql };
|
||||||
|
|
||||||
|
// Create broadcast channel for cross-tab coordination
|
||||||
|
const channel = new BroadcastChannel(BROADCAST_CHANNEL);
|
||||||
|
|
||||||
|
// Track if database is ready
|
||||||
|
let isReady = false;
|
||||||
|
let readyResolve;
|
||||||
|
let readyReject;
|
||||||
|
|
||||||
|
export const dbReady = new Promise((resolve, reject) => {
|
||||||
|
readyResolve = resolve;
|
||||||
|
readyReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database change listeners
|
||||||
|
const changeListeners = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to database changes (from any tab)
|
||||||
|
* @param {Function} listener - Called with { table, action, id }
|
||||||
|
* @returns {Function} Unsubscribe function
|
||||||
|
*/
|
||||||
|
export function onDatabaseChange(listener) {
|
||||||
|
changeListeners.add(listener);
|
||||||
|
return () => changeListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from other tabs
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
const { type, payload } = event.data;
|
||||||
|
if (type === 'DB_CHANGE') {
|
||||||
|
// Notify local listeners about changes from other tabs
|
||||||
|
for (const listener of changeListeners) {
|
||||||
|
try {
|
||||||
|
listener(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Database] Change listener error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a database change to other tabs
|
||||||
|
*/
|
||||||
|
function broadcastChange(table, action, id = null) {
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'DB_CHANGE',
|
||||||
|
payload: { table, action, id, timestamp: Date.now() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also notify local listeners
|
||||||
|
for (const listener of changeListeners) {
|
||||||
|
try {
|
||||||
|
listener({ table, action, id, timestamp: Date.now(), local: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Database] Change listener error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Database Initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database schema
|
||||||
|
*/
|
||||||
|
export async function initSchema() {
|
||||||
|
try {
|
||||||
|
console.log('[Database] Initializing schema...');
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
const testResult = await sql`SELECT sqlite_version() as version`;
|
||||||
|
console.log('[Database] SQLite version:', testResult[0]?.version);
|
||||||
|
|
||||||
|
// Create locations table
|
||||||
|
console.log('[Database] Creating locations table...');
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS locations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT DEFAULT 'default',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Verify table exists
|
||||||
|
const tablesAfterLocations = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
||||||
|
console.log('[Database] Locations table exists:', tablesAfterLocations.length > 0);
|
||||||
|
|
||||||
|
// Create sync_log table
|
||||||
|
console.log('[Database] Creating sync_log table...');
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
record_id INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
||||||
|
|
||||||
|
// Final verification
|
||||||
|
const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
||||||
|
console.log('[Database] All tables:', allTables.map(t => t.name));
|
||||||
|
|
||||||
|
isReady = true;
|
||||||
|
readyResolve(true);
|
||||||
|
console.log('[Database] ✓ Schema initialized');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] ✗ Schema init failed:', error);
|
||||||
|
readyReject(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Location Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new location
|
||||||
|
*/
|
||||||
|
export async function addLocation(name, longitude, latitude, options = {}) {
|
||||||
|
const { description = null, category = 'default' } = options;
|
||||||
|
|
||||||
|
console.log('[Database] Adding location:', name, longitude, latitude, category);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check table exists first
|
||||||
|
const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
||||||
|
console.log('[Database] Table check before insert:', tableCheck);
|
||||||
|
|
||||||
|
if (tableCheck.length === 0) {
|
||||||
|
console.error('[Database] ✗ locations table does not exist!');
|
||||||
|
throw new Error('locations table does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert - using explicit values
|
||||||
|
console.log('[Database] Executing INSERT...');
|
||||||
|
await sql`
|
||||||
|
INSERT INTO locations (name, longitude, latitude, description, category)
|
||||||
|
VALUES (${name}, ${longitude}, ${latitude}, ${description}, ${category})
|
||||||
|
`;
|
||||||
|
console.log('[Database] INSERT completed');
|
||||||
|
|
||||||
|
// Get the ID
|
||||||
|
const idResult = await sql`SELECT last_insert_rowid() as id`;
|
||||||
|
const newId = idResult[0]?.id;
|
||||||
|
console.log('[Database] New ID:', newId);
|
||||||
|
|
||||||
|
// Verify it was actually inserted
|
||||||
|
const verifyResult = await sql`SELECT * FROM locations WHERE id = ${newId}`;
|
||||||
|
console.log('[Database] Verify insert:', verifyResult);
|
||||||
|
|
||||||
|
if (verifyResult.length === 0) {
|
||||||
|
console.error('[Database] ✗ Insert verification failed - row not found!');
|
||||||
|
throw new Error('Insert verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for sync
|
||||||
|
await sql`
|
||||||
|
INSERT INTO sync_log (table_name, record_id, action)
|
||||||
|
VALUES ('locations', ${newId}, 'INSERT')
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Broadcast to other tabs
|
||||||
|
broadcastChange('locations', 'INSERT', newId);
|
||||||
|
|
||||||
|
console.log('[Database] ✓ Location added:', newId);
|
||||||
|
return { id: newId };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] ✗ Failed to add location:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all locations
|
||||||
|
*/
|
||||||
|
export async function getLocations(options = {}) {
|
||||||
|
const { category = null, limit = 1000 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First check if table exists
|
||||||
|
const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
||||||
|
console.log('[Database] getLocations - table exists:', tableCheck.length > 0);
|
||||||
|
|
||||||
|
if (tableCheck.length === 0) {
|
||||||
|
console.warn('[Database] locations table does not exist yet');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let results;
|
||||||
|
if (category) {
|
||||||
|
results = await sql`
|
||||||
|
SELECT * FROM locations
|
||||||
|
WHERE category = ${category}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
results = await sql`
|
||||||
|
SELECT * FROM locations
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Database] getLocations returned', results.length, 'rows');
|
||||||
|
return results;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] getLocations error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocation(id) {
|
||||||
|
try {
|
||||||
|
const results = await sql`SELECT * FROM locations WHERE id = ${id}`;
|
||||||
|
return results[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] getLocation error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a location
|
||||||
|
*/
|
||||||
|
export async function updateLocation(id, updates) {
|
||||||
|
const { name, longitude, latitude, description, category } = updates;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const location = await getLocation(id);
|
||||||
|
if (!location) {
|
||||||
|
throw new Error(`Location ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE locations
|
||||||
|
SET
|
||||||
|
name = ${name ?? location.name},
|
||||||
|
longitude = ${longitude ?? location.longitude},
|
||||||
|
latitude = ${latitude ?? location.latitude},
|
||||||
|
description = ${description ?? location.description},
|
||||||
|
category = ${category ?? location.category},
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
synced = 0
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Log for sync
|
||||||
|
await sql`
|
||||||
|
INSERT INTO sync_log (table_name, record_id, action)
|
||||||
|
VALUES ('locations', ${id}, 'UPDATE')
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Broadcast to other tabs
|
||||||
|
broadcastChange('locations', 'UPDATE', id);
|
||||||
|
console.log('[Database] ✓ Location updated:', id);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] ✗ updateLocation error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a location
|
||||||
|
*/
|
||||||
|
export async function deleteLocation(id) {
|
||||||
|
try {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO sync_log (table_name, record_id, action)
|
||||||
|
VALUES ('locations', ${id}, 'DELETE')
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`DELETE FROM locations WHERE id = ${id}`;
|
||||||
|
|
||||||
|
// Broadcast to other tabs
|
||||||
|
broadcastChange('locations', 'DELETE', id);
|
||||||
|
console.log('[Database] ✓ Location deleted:', id);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] ✗ deleteLocation error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location count
|
||||||
|
*/
|
||||||
|
export async function getLocationCount() {
|
||||||
|
try {
|
||||||
|
const result = await sql`SELECT COUNT(*) as count FROM locations`;
|
||||||
|
return result[0]?.count ?? 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Database] getLocationCount error:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sync Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unsynced changes
|
||||||
|
*/
|
||||||
|
export async function getUnsyncedChanges() {
|
||||||
|
return sql`SELECT * FROM sync_log WHERE synced = 0 ORDER BY timestamp ASC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark changes as synced
|
||||||
|
*/
|
||||||
|
export async function markSynced(syncLogIds) {
|
||||||
|
if (!syncLogIds.length) return;
|
||||||
|
for (const id of syncLogIds) {
|
||||||
|
await sql`UPDATE sync_log SET synced = 1 WHERE id = ${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get locations that need syncing
|
||||||
|
*/
|
||||||
|
export async function getUnsyncedLocations() {
|
||||||
|
return sql`SELECT * FROM locations WHERE synced = 0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark locations as synced
|
||||||
|
*/
|
||||||
|
export async function markLocationsSynced(ids) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
await sql`UPDATE locations SET synced = 1 WHERE id = ${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export / Import
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export database for backup
|
||||||
|
*/
|
||||||
|
export async function exportDatabase() {
|
||||||
|
return db.getDatabaseFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import database from backup
|
||||||
|
*/
|
||||||
|
export async function importDatabase(data) {
|
||||||
|
await db.overwriteDatabaseFile(data);
|
||||||
|
broadcastChange('*', 'IMPORT', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download database as file
|
||||||
|
*/
|
||||||
|
export async function downloadDatabase(filename = 'lupmis-backup.sqlite3') {
|
||||||
|
const data = await exportDatabase();
|
||||||
|
const blob = new Blob([data], { type: 'application/x-sqlite3' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to GeoJSON
|
||||||
|
export async function exportToGeoJSON() {
|
||||||
|
const locations = await getLocations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: locations.map((loc) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
id: loc.id,
|
||||||
|
name: loc.name,
|
||||||
|
category: loc.category,
|
||||||
|
notes: loc.notes,
|
||||||
|
created_at: loc.created_at,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [loc.lon, loc.lat],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility & Debug
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database status
|
||||||
|
*/
|
||||||
|
export async function getDatabaseStatus() {
|
||||||
|
try {
|
||||||
|
const tables = await sql`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const locationCount = await getLocationCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: isReady,
|
||||||
|
databasePath: DATABASE_PATH,
|
||||||
|
tables: tables.map(t => t.name),
|
||||||
|
locationCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function - call from console to test
|
||||||
|
export async function testDatabase() {
|
||||||
|
console.log('=== DATABASE TEST ===');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check connection
|
||||||
|
const version = await sql`SELECT sqlite_version() as v`;
|
||||||
|
console.log('1. SQLite version:', version[0].v);
|
||||||
|
|
||||||
|
// 2. Check tables
|
||||||
|
const tables = await sql`SELECT name FROM sqlite_master WHERE type='table'`;
|
||||||
|
console.log('2. Tables:', tables.map(t => t.name));
|
||||||
|
|
||||||
|
// 3. Try to insert a test row
|
||||||
|
console.log('3. Inserting test row...');
|
||||||
|
await sql`INSERT INTO locations (name, longitude, latitude, category) VALUES ('TEST', -1.0, 7.0, 'test')`;
|
||||||
|
|
||||||
|
// 4. Read it back
|
||||||
|
const rows = await sql`SELECT * FROM locations WHERE name = 'TEST'`;
|
||||||
|
console.log('4. Test row:', rows);
|
||||||
|
|
||||||
|
// 5. Count all rows
|
||||||
|
const count = await sql`SELECT COUNT(*) as c FROM locations`;
|
||||||
|
console.log('5. Total rows:', count[0].c);
|
||||||
|
|
||||||
|
// 6. Delete test row
|
||||||
|
await sql`DELETE FROM locations WHERE name = 'TEST'`;
|
||||||
|
console.log('6. Test row deleted');
|
||||||
|
|
||||||
|
console.log('=== TEST PASSED ===');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('=== TEST FAILED ===', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose to window for debugging
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.testDatabase = testDatabase;
|
||||||
|
window.dbStatus = getDatabaseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDatabase() {
|
||||||
|
channel.close();
|
||||||
|
if (db.destroy) {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
sql,
|
||||||
|
dbReady,
|
||||||
|
initSchema,
|
||||||
|
addLocation,
|
||||||
|
getLocations,
|
||||||
|
getLocation,
|
||||||
|
updateLocation,
|
||||||
|
deleteLocation,
|
||||||
|
getLocationCount,
|
||||||
|
getUnsyncedChanges,
|
||||||
|
getUnsyncedLocations,
|
||||||
|
markSynced,
|
||||||
|
markLocationsSynced,
|
||||||
|
exportDatabase,
|
||||||
|
importDatabase,
|
||||||
|
downloadDatabase,
|
||||||
|
getDatabaseStatus,
|
||||||
|
testDatabase,
|
||||||
|
onDatabaseChange,
|
||||||
|
closeDatabase
|
||||||
|
};
|
||||||
317
pwa.js
Normal file
317
pwa.js
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* PWA Module
|
||||||
|
*
|
||||||
|
* Handles Progressive Web App functionality:
|
||||||
|
* - Service Worker registration
|
||||||
|
* - Install prompt handling
|
||||||
|
* - Offline detection
|
||||||
|
* - Update notifications
|
||||||
|
*
|
||||||
|
* Note: The Service Worker (sw.js) handles caching.
|
||||||
|
* The SharedWorker (shared-db-worker.js) handles database.
|
||||||
|
* They are separate workers with different purposes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service Worker Registration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let swRegistration = null;
|
||||||
|
|
||||||
|
export async function registerServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
console.warn('[PWA] Service Workers not supported');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
swRegistration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PWA] Service Worker registered:', swRegistration.scope);
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
swRegistration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = swRegistration.installing;
|
||||||
|
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// New version available
|
||||||
|
console.log('[PWA] New version available');
|
||||||
|
showUpdateNotification();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return swRegistration;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PWA] Service Worker registration failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Install Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let deferredPrompt = null;
|
||||||
|
let installButton = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize install prompt handling
|
||||||
|
* @param {string|HTMLElement} buttonSelector - Button element or selector
|
||||||
|
*/
|
||||||
|
export function initInstallPrompt(buttonSelector = '#install-btn') {
|
||||||
|
installButton = typeof buttonSelector === 'string'
|
||||||
|
? document.querySelector(buttonSelector)
|
||||||
|
: buttonSelector;
|
||||||
|
|
||||||
|
if (!installButton) {
|
||||||
|
console.warn('[PWA] Install button not found:', buttonSelector);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initially hide the button
|
||||||
|
installButton.style.display = 'none';
|
||||||
|
|
||||||
|
// Listen for the beforeinstallprompt event
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
|
||||||
|
// Show the install button
|
||||||
|
installButton.style.display = 'block';
|
||||||
|
console.log('[PWA] Install prompt ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle install button click
|
||||||
|
installButton.addEventListener('click', async () => {
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
// Show manual instructions for Safari
|
||||||
|
showManualInstallInstructions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
console.log('[PWA] Install prompt outcome:', outcome);
|
||||||
|
|
||||||
|
deferredPrompt = null;
|
||||||
|
installButton.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide button if app is already installed
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('[PWA] App installed');
|
||||||
|
deferredPrompt = null;
|
||||||
|
installButton.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if running as installed PWA
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
installButton.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showManualInstallInstructions() {
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
let message = 'To install this app:\n\n';
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
message += '1. Tap the Share button (square with arrow)\n';
|
||||||
|
message += '2. Scroll down and tap "Add to Home Screen"';
|
||||||
|
} else if (isSafari) {
|
||||||
|
message += '1. Click File menu\n';
|
||||||
|
message += '2. Click "Add to Dock"';
|
||||||
|
} else {
|
||||||
|
message += '1. Click the menu button (three dots)\n';
|
||||||
|
message += '2. Click "Install" or "Add to Home Screen"';
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Offline Detection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let offlineIndicator = null;
|
||||||
|
const offlineListeners = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize offline detection
|
||||||
|
* @param {string|HTMLElement} indicatorSelector - Element to show when offline
|
||||||
|
*/
|
||||||
|
export function initOfflineDetection(indicatorSelector = '#offline-indicator') {
|
||||||
|
offlineIndicator = typeof indicatorSelector === 'string'
|
||||||
|
? document.querySelector(indicatorSelector)
|
||||||
|
: indicatorSelector;
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
updateOfflineUI(!navigator.onLine);
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
console.log('[PWA] Back online');
|
||||||
|
updateOfflineUI(false);
|
||||||
|
notifyOfflineListeners(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
console.log('[PWA] Gone offline');
|
||||||
|
updateOfflineUI(true);
|
||||||
|
notifyOfflineListeners(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOfflineUI(isOffline) {
|
||||||
|
if (offlineIndicator) {
|
||||||
|
offlineIndicator.style.display = isOffline ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also toggle a class on body for CSS styling
|
||||||
|
document.body.classList.toggle('is-offline', isOffline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to offline state changes
|
||||||
|
* @param {Function} listener - Callback(isOffline: boolean)
|
||||||
|
* @returns {Function} Unsubscribe function
|
||||||
|
*/
|
||||||
|
export function onOfflineChange(listener) {
|
||||||
|
offlineListeners.add(listener);
|
||||||
|
// Immediately call with current state
|
||||||
|
listener(!navigator.onLine);
|
||||||
|
return () => offlineListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyOfflineListeners(isOffline) {
|
||||||
|
for (const listener of offlineListeners) {
|
||||||
|
try {
|
||||||
|
listener(isOffline);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PWA] Offline listener error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently online
|
||||||
|
*/
|
||||||
|
export function isOnline() {
|
||||||
|
return navigator.onLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Update Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let updateCallback = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for when updates are available
|
||||||
|
* @param {Function} callback - Called when new version is ready
|
||||||
|
*/
|
||||||
|
export function onUpdateAvailable(callback) {
|
||||||
|
updateCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateNotification() {
|
||||||
|
if (updateCallback) {
|
||||||
|
updateCallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior
|
||||||
|
if (confirm('A new version is available. Reload now?')) {
|
||||||
|
applyUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply pending update (reload with new version)
|
||||||
|
*/
|
||||||
|
export function applyUpdate() {
|
||||||
|
if (swRegistration?.waiting) {
|
||||||
|
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Communication with Service Worker
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the service worker
|
||||||
|
* @param {Object} message - Message to send
|
||||||
|
*/
|
||||||
|
export function postToServiceWorker(message) {
|
||||||
|
navigator.serviceWorker.controller?.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the service worker to cache specific modules
|
||||||
|
* @param {string[]} moduleNames - Array of module names to cache
|
||||||
|
*/
|
||||||
|
export function cacheModules(moduleNames) {
|
||||||
|
postToServiceWorker({
|
||||||
|
type: 'CACHE_MODULES',
|
||||||
|
payload: { modules: moduleNames }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the service worker to clear user-specific caches
|
||||||
|
* (Call this on logout)
|
||||||
|
*/
|
||||||
|
export function clearUserCaches() {
|
||||||
|
postToServiceWorker({
|
||||||
|
type: 'CLEAR_USER_CACHE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auto-initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all PWA features
|
||||||
|
* @param {Object} options
|
||||||
|
*/
|
||||||
|
export async function initPWA(options = {}) {
|
||||||
|
const {
|
||||||
|
installButton = '#install-btn',
|
||||||
|
offlineIndicator = '#offline-indicator',
|
||||||
|
autoRegisterSW = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (autoRegisterSW) {
|
||||||
|
await registerServiceWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
initInstallPrompt(installButton);
|
||||||
|
initOfflineDetection(offlineIndicator);
|
||||||
|
|
||||||
|
console.log('[PWA] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for direct use
|
||||||
|
export default {
|
||||||
|
registerServiceWorker,
|
||||||
|
initInstallPrompt,
|
||||||
|
initOfflineDetection,
|
||||||
|
initPWA,
|
||||||
|
isOnline,
|
||||||
|
onOfflineChange,
|
||||||
|
onUpdateAvailable,
|
||||||
|
applyUpdate,
|
||||||
|
postToServiceWorker,
|
||||||
|
cacheModules,
|
||||||
|
clearUserCaches
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user