diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..2c639ad
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,34 @@
+name: Lint
+
+on:
+ pull_request:
+ paths:
+ - 'static/**'
+ - 'package.json'
+ - 'biome.json'
+ push:
+ branches:
+ - main
+ paths:
+ - 'static/**'
+ - 'package.json'
+ - 'biome.json'
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run Biome
+ run: npm run lint
diff --git a/.gitignore b/.gitignore
index 6b5c196..d99ae3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,8 +9,15 @@ instance/
# Virtual environment
venv/
+.venv/
env/
+# Node.js
+node_modules/
+
+# Biome
+.biome/
+
# IDEs and editors
.vscode/
.idea/
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..ede00b5
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
+ "organizeImports": {
+ "enabled": true
+ },
+ "linter": {
+ "rules": {
+ "recommended": true,
+ "complexity": {
+ "noForEach": "off"
+ },
+ "style": {
+ "noUselessElse": "off",
+ "useExponentiationOperator": "off",
+ "noParameterAssign": "off"
+ },
+ "suspicious": {
+ "noAssignInExpressions": "off"
+ }
+ },
+ "ignore": ["**/.venv/**", "**/node_modules/**", "**/__pycache__/**"]
+ },
+ "formatter": {
+ "ignore": ["**/.venv/**", "**/node_modules/**", "**/__pycache__/**"]
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ec65e1e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,179 @@
+{
+ "name": "btcmap-admin",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "btcmap-admin",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0"
+ }
+ },
+ "node_modules/@biomejs/biome": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
+ "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "1.9.4",
+ "@biomejs/cli-darwin-x64": "1.9.4",
+ "@biomejs/cli-linux-arm64": "1.9.4",
+ "@biomejs/cli-linux-arm64-musl": "1.9.4",
+ "@biomejs/cli-linux-x64": "1.9.4",
+ "@biomejs/cli-linux-x64-musl": "1.9.4",
+ "@biomejs/cli-win32-arm64": "1.9.4",
+ "@biomejs/cli-win32-x64": "1.9.4"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
+ "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
+ "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
+ "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
+ "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
+ "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
+ "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
+ "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
+ "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..20b53ff
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "btcmap-admin",
+ "version": "0.1.0",
+ "description": "BTC Map Admin - Frontend dev tools",
+ "private": true,
+ "scripts": {
+ "lint": "biome check .",
+ "format": "biome format --write .",
+ "lint:fix": "biome check --write ."
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0"
+ }
+}
diff --git a/static/css/map.css b/static/css/map.css
index a23f0de..2574f2a 100644
--- a/static/css/map.css
+++ b/static/css/map.css
@@ -1,5 +1,5 @@
#map {
- height: 400px;
- width: 100%;
- margin-bottom: 20px;
+ height: 400px;
+ width: 100%;
+ margin-bottom: 20px;
}
diff --git a/static/css/style.css b/static/css/style.css
index db9b71b..90fc3f3 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1,80 +1,80 @@
body {
- padding-top: 5rem;
+ padding-top: 5rem;
}
.starter-template {
- padding: 3rem 1.5rem;
- text-align: center;
+ padding: 3rem 1.5rem;
+ text-align: center;
}
.form-signin {
- width: 100%;
- max-width: 330px;
- padding: 15px;
- margin: auto;
+ width: 100%;
+ max-width: 330px;
+ padding: 15px;
+ margin: auto;
}
.form-signin .form-control {
- position: relative;
- box-sizing: border-box;
- height: auto;
- padding: 10px;
- font-size: 16px;
+ position: relative;
+ box-sizing: border-box;
+ height: auto;
+ padding: 10px;
+ font-size: 16px;
}
.form-signin .form-control:focus {
- z-index: 2;
+ z-index: 2;
}
.form-signin input[type="password"] {
- margin-bottom: 10px;
- border-top-left-radius: 0;
- border-top-right-radius: 0;
+ margin-bottom: 10px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
}
/* Area details table - prevent field value overflow */
#tags-table {
- table-layout: fixed;
- width: 100%;
+ table-layout: fixed;
+ width: 100%;
}
#tags-table th:first-child,
#tags-table td:first-child {
- width: 15%;
+ width: 15%;
}
#tags-table th:nth-child(2),
#tags-table td:nth-child(2) {
- width: 65%;
+ width: 65%;
}
#tags-table th:last-child,
#tags-table td:last-child {
- width: 20%;
- white-space: nowrap;
+ width: 20%;
+ white-space: nowrap;
}
/* Action buttons - standalone with spacing */
#tags-table .btn-group {
- display: flex;
- flex-wrap: wrap;
- gap: 0.25rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
}
#tags-table .btn-group .btn {
- border-radius: 0.25rem !important;
+ border-radius: 0.25rem !important;
}
#tags-table .tag-value-cell {
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
}
#tags-table .tag-value-content {
- display: block;
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
- white-space: normal;
+ display: block;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ white-space: normal;
}
diff --git a/static/js/map-editor.js b/static/js/map-editor.js
index 690e83a..4b5c84a 100644
--- a/static/js/map-editor.js
+++ b/static/js/map-editor.js
@@ -1,16 +1,16 @@
/**
* Map Editor Component
- *
+ *
* Provides Leaflet map with draw controls for polygon editing,
* shape editing tools (simplify/buffer/merge), and a raw GeoJSON text editor.
- *
+ *
* Usage:
* const mapEditor = initMapEditor({
* containerId: 'map',
* initialGeoJson: existingGeoJson || null,
* onGeoJsonChange: (geometry) => { window.currentGeoJson = geometry; }
* });
- *
+ *
* Returns:
* {
* map: Leaflet map instance,
@@ -18,637 +18,679 @@
* getCurrentGeoJson: () => geometry object or null,
* updateGeoJson: (geojson) => void,
* }
- *
+ *
* Requires:
* - Leaflet and Leaflet.draw loaded
* - Turf.js loaded (for shape editing)
*/
function initMapEditor(options = {}) {
- const {
- containerId = 'map',
- initialGeoJson = null,
- onGeoJsonChange = null
- } = options;
-
- // State
- let currentGeoJson = null;
- let geoJsonLayer = null;
- let previouslySavedGeoJson = null;
-
- // Shape editor state
- let originalShapeGeoJson = null; // Original shape before editing
- let shapePreviewLayer = null; // Preview layer for shape edits
- let originalShapeLayer = null; // Reference layer showing original
-
- // DOM Elements
- const elements = {
- mapContainer: document.getElementById(containerId),
- // Raw GeoJSON editor
- editGeoJsonBtn: document.getElementById('edit-geojson-btn'),
- geoJsonEditor: document.getElementById('geojson-editor'),
- geoJsonInput: document.getElementById('geojson-input'),
- showBtn: document.getElementById('show-geojson-btn'),
- saveLocallyBtn: document.getElementById('save-locally-btn'),
- cancelBtn: document.getElementById('cancel-geojson-btn'),
- // Shape editor
- editShapeBtn: document.getElementById('edit-shape-btn'),
- shapeEditor: document.getElementById('shape-editor'),
- shapeSimplifySlider: document.getElementById('shape-simplify-slider'),
- shapeSimplifyValue: document.getElementById('shape-simplify-value'),
- shapeBufferSlider: document.getElementById('shape-buffer-slider'),
- shapeBufferValue: document.getElementById('shape-buffer-value'),
- shapeMegaSimplifyCheckbox: document.getElementById('shape-mega-simplify'),
- shapeTightnessSlider: document.getElementById('shape-tightness-slider'),
- shapeTightnessValue: document.getElementById('shape-tightness-value'),
- shapePointsCount: document.getElementById('shape-points-count'),
- shapeShowOriginal: document.getElementById('shape-show-original'),
- applyShapeBtn: document.getElementById('apply-shape-btn'),
- cancelShapeBtn: document.getElementById('cancel-shape-btn')
- };
-
- // Initialize map
- if (!elements.mapContainer) {
- console.error('[MapEditor] Map container not found:', containerId);
- return null;
- }
-
- const map = L.map(containerId).setView([0, 0], 2);
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution: '© OpenStreetMap contributors'
- }).addTo(map);
-
- // Initialize draw control
- const drawnItems = new L.FeatureGroup();
- map.addLayer(drawnItems);
-
- const drawControl = new L.Control.Draw({
- draw: {
- polygon: {
- allowIntersection: false,
- showArea: true,
- shapeOptions: {
- color: '#3388ff',
- weight: 3
- }
- },
- rectangle: {
- shapeOptions: {
- color: '#3388ff',
- weight: 3
- }
- },
- circle: false,
- circlemarker: false,
- marker: false,
- polyline: false
- },
- edit: {
- featureGroup: drawnItems,
- remove: true,
- edit: true
- }
- });
- map.addControl(drawControl);
-
- // Update GeoJSON from drawn items
- function updateGeoJsonFromDrawnItems() {
- if (drawnItems.getLayers().length === 0) {
- currentGeoJson = null;
- if (elements.geoJsonInput) {
- elements.geoJsonInput.value = '';
- }
- if (onGeoJsonChange) {
- onGeoJsonChange(null);
- }
- return;
- }
-
- const layer = drawnItems.getLayers()[0];
- const geoJson = layer.toGeoJSON();
- currentGeoJson = geoJson.geometry;
-
- // Keep textarea in sync
- if (elements.geoJsonInput) {
- elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
- }
-
- map.fitBounds(drawnItems.getBounds());
-
- if (onGeoJsonChange) {
- onGeoJsonChange(currentGeoJson);
- }
- }
-
- // Update map with GeoJSON
- function updateGeoJson(geoJson) {
- try {
- // Remove existing geoJsonLayer
- if (geoJsonLayer) {
- map.removeLayer(geoJsonLayer);
- }
-
- // Clear drawn items
- drawnItems.clearLayers();
-
- if (!geoJson) {
- currentGeoJson = null;
- if (elements.geoJsonInput) {
- elements.geoJsonInput.value = '';
- }
- if (onGeoJsonChange) {
- onGeoJsonChange(null);
- }
- return true;
- }
-
- // Handle string input
- if (typeof geoJson === 'string') {
- geoJson = JSON.parse(geoJson);
- }
-
- // Extract geometry if wrapped in Feature
- let geometry = geoJson;
- if (geoJson.type === 'Feature') {
- geometry = geoJson.geometry;
- } else if (geoJson.type === 'FeatureCollection' && geoJson.features && geoJson.features.length > 0) {
- geometry = geoJson.features[0].geometry;
- }
-
- // Store just the geometry
- currentGeoJson = geometry;
-
- // Wrap back as Feature for display in Leaflet
- const featureCollection = {
- "type": "Feature",
- "geometry": geometry
- };
-
- // Create GeoJSON feature and add to map
- geoJsonLayer = L.geoJSON(featureCollection).addTo(map);
- map.fitBounds(geoJsonLayer.getBounds());
-
- // Also add to drawnItems for editing
- if (geoJsonLayer.getLayers().length > 0) {
- const layer = geoJsonLayer.getLayers()[0];
- drawnItems.addLayer(layer);
- }
-
- // Update textarea with current GeoJSON (keep them in sync)
- if (elements.geoJsonInput) {
- elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
- }
-
- if (onGeoJsonChange) {
- onGeoJsonChange(currentGeoJson);
- }
-
- return true;
- } catch (error) {
- console.error("[MapEditor] Error adding GeoJSON to map:", error);
- if (typeof showToast === 'function') {
- showToast('Error', `Invalid GeoJSON: ${error.message}`, 'error');
- }
- return false;
- }
- }
-
- // Handle draw created
- map.on(L.Draw.Event.CREATED, function(e) {
- const layer = e.layer;
- // Clear existing and add new
- drawnItems.clearLayers();
- drawnItems.addLayer(layer);
- updateGeoJsonFromDrawnItems();
- });
-
- // Handle draw edited
- map.on(L.Draw.Event.EDITED, function(e) {
- updateGeoJsonFromDrawnItems();
- });
-
- // Handle draw deleted
- map.on(L.Draw.Event.DELETED, function(e) {
- currentGeoJson = null;
- if (geoJsonLayer) {
- map.removeLayer(geoJsonLayer);
- geoJsonLayer = null;
- }
- if (elements.geoJsonInput) {
- elements.geoJsonInput.value = '';
- }
- if (onGeoJsonChange) {
- onGeoJsonChange(null);
- }
- });
-
- // Raw GeoJSON editor functionality
- function showEditor() {
- if (elements.geoJsonEditor) {
- elements.geoJsonEditor.style.display = 'block';
- }
- if (currentGeoJson && elements.geoJsonInput) {
- elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
- previouslySavedGeoJson = JSON.parse(JSON.stringify(currentGeoJson));
- }
- }
-
- function hideEditor() {
- if (elements.geoJsonEditor) {
- elements.geoJsonEditor.style.display = 'none';
- }
- }
-
- function showGeoJsonPreview() {
- try {
- const geoJson = JSON.parse(elements.geoJsonInput.value);
- if (updateGeoJson(geoJson)) {
- if (typeof showToast === 'function') {
- showToast('Success', 'GeoJSON preview updated', 'success');
- }
- }
- } catch (error) {
- console.error("[MapEditor] Error parsing GeoJSON:", error);
- if (typeof showToast === 'function') {
- showToast('Error', 'Invalid GeoJSON: ' + error.message, 'error');
- }
- }
- }
-
- function saveLocally() {
- try {
- const geoJson = JSON.parse(elements.geoJsonInput.value);
- if (updateGeoJson(geoJson)) {
- hideEditor();
- previouslySavedGeoJson = currentGeoJson;
- if (typeof showToast === 'function') {
- showToast('Success', 'GeoJSON saved locally', 'success');
- }
- }
- } catch (error) {
- console.error("[MapEditor] Error saving GeoJSON:", error);
- if (typeof showToast === 'function') {
- showToast('Error', 'Invalid GeoJSON: ' + error.message, 'error');
- }
- }
- }
-
- function cancelEditing() {
- hideEditor();
- if (previouslySavedGeoJson) {
- updateGeoJson(previouslySavedGeoJson);
- }
- }
-
- // ============================================
- // Shape Editor functionality
- // ============================================
-
- // Convert linear slider (0-10) to logarithmic tolerance
- function sliderToTolerance(sliderVal) {
- if (sliderVal === 0) return 0;
- const minLog = Math.log10(0.000001);
- const maxLog = Math.log10(0.01);
- const logVal = minLog + (sliderVal / 10) * (maxLog - minLog);
- return Math.pow(10, logVal);
- }
-
- function formatTolerance(val) {
- if (val === 0) return '0';
- if (val < 0.00001) return val.toExponential(1);
- if (val < 0.0001) return val.toFixed(6);
- if (val < 0.001) return val.toFixed(5);
- if (val < 0.01) return val.toFixed(4);
- return val.toFixed(3);
- }
-
- // Convert tightness slider (0-10) to concavity for turf.convex()
- // 0 = pure convex (concavity = Infinity)
- // 10 = tighter fit (concavity = 1)
- function sliderToConcavity(sliderVal) {
- if (sliderVal === 0) return Infinity;
- return Math.max(1, 21 - (sliderVal * 2));
- }
-
- function formatTightness(val) {
- if (val === 0) return 'Loose';
- if (val === 10) return 'Tight';
- return val.toString();
- }
-
- function countGeojsonPoints(geojson) {
- let count = 0;
- function countCoords(coords) {
- if (typeof coords[0] === 'number') {
- count++;
- } else {
- coords.forEach(c => countCoords(c));
- }
- }
- const geometry = geojson.geometry || geojson;
- if (geometry && geometry.coordinates) {
- countCoords(geometry.coordinates);
- }
- return count;
- }
-
- function showShapeEditor() {
- if (!currentGeoJson) {
- if (typeof showToast === 'function') {
- showToast('Warning', 'No shape to edit. Draw or import a polygon first.', 'warning');
- }
- return;
- }
-
- // Store original shape
- originalShapeGeoJson = JSON.parse(JSON.stringify(currentGeoJson));
-
- // Hide raw GeoJSON editor if open
- hideEditor();
-
- // Reset sliders to neutral (no changes)
- if (elements.shapeSimplifySlider) {
- elements.shapeSimplifySlider.value = 0;
- elements.shapeSimplifyValue.textContent = '0';
- }
- if (elements.shapeBufferSlider) {
- elements.shapeBufferSlider.value = 0;
- elements.shapeBufferValue.textContent = '0';
- }
- if (elements.shapeMegaSimplifyCheckbox) {
- elements.shapeMegaSimplifyCheckbox.checked = false;
- }
- if (elements.shapeTightnessSlider) {
- elements.shapeTightnessSlider.value = 0;
- elements.shapeTightnessSlider.disabled = true;
- elements.shapeTightnessValue.textContent = 'Loose';
- }
-
- // Show editor
- if (elements.shapeEditor) {
- elements.shapeEditor.style.display = 'block';
- }
-
- // Show original shape and initial preview
- updateOriginalShapeLayer();
- processAndPreviewShape();
- }
-
- function hideShapeEditor() {
- if (elements.shapeEditor) {
- elements.shapeEditor.style.display = 'none';
- }
- // Remove preview layers
- if (shapePreviewLayer) {
- map.removeLayer(shapePreviewLayer);
- shapePreviewLayer = null;
- }
- if (originalShapeLayer) {
- map.removeLayer(originalShapeLayer);
- originalShapeLayer = null;
- }
- }
-
- function updateOriginalShapeLayer() {
- // Remove existing layer
- if (originalShapeLayer) {
- map.removeLayer(originalShapeLayer);
- originalShapeLayer = null;
- }
-
- if (!originalShapeGeoJson || !elements.shapeShowOriginal || !elements.shapeShowOriginal.checked) {
- return;
- }
-
- // Wrap as Feature if needed
- let feature = originalShapeGeoJson;
- if (originalShapeGeoJson.type !== 'Feature' && originalShapeGeoJson.type !== 'FeatureCollection') {
- feature = { type: 'Feature', geometry: originalShapeGeoJson, properties: {} };
- }
-
- // Create dashed line style for reference
- originalShapeLayer = L.geoJSON(feature, {
- style: {
- color: '#ff6600',
- weight: 2,
- dashArray: '5, 5',
- fillOpacity: 0,
- interactive: false
- }
- }).addTo(map);
- }
-
- function processAndPreviewShape() {
- if (!originalShapeGeoJson) return;
-
- try {
- // Wrap as Feature if needed
- let feature = originalShapeGeoJson;
- if (originalShapeGeoJson.type !== 'Feature' && originalShapeGeoJson.type !== 'FeatureCollection') {
- feature = { type: 'Feature', geometry: originalShapeGeoJson, properties: {} };
- }
-
- const tolerance = sliderToTolerance(parseFloat(elements.shapeSimplifySlider?.value || 0));
- const buffer = parseFloat(elements.shapeBufferSlider?.value || 0);
-
- let processed = feature;
-
- // Apply buffer if > 0
- if (buffer > 0 && typeof turf !== 'undefined') {
- processed = turf.buffer(processed, buffer, { units: 'kilometers' });
- }
-
- // Apply simplification if > 0
- if (tolerance > 0 && typeof turf !== 'undefined') {
- processed = turf.simplify(processed, {
- tolerance: tolerance,
- highQuality: true
- });
- }
-
- // Apply mega simplify if checked
- if (elements.shapeMegaSimplifyCheckbox?.checked && typeof turf !== 'undefined') {
- const concavity = sliderToConcavity(parseFloat(elements.shapeTightnessSlider?.value || 0));
-
- try {
- // Use turf.convex with concavity parameter
- processed = turf.convex(processed, { concavity: concavity });
-
- if (!processed) {
- console.warn('[MapEditor] Convex hull returned null');
- processed = turf.convex(feature);
- }
- } catch (e) {
- console.warn('[MapEditor] Convex hull failed:', e.message);
- // Keep original processed value
- }
- }
-
- // Remove existing preview layer
- if (shapePreviewLayer) {
- map.removeLayer(shapePreviewLayer);
- }
-
- // Hide the main drawn items while previewing
- drawnItems.eachLayer(layer => {
- layer.setStyle({ opacity: 0, fillOpacity: 0 });
- });
-
- // Add preview layer
- shapePreviewLayer = L.geoJSON(processed, {
- style: {
- color: '#3388ff',
- weight: 3,
- fillColor: '#3388ff',
- fillOpacity: 0.2
- }
- }).addTo(map);
-
- // Fit bounds
- map.fitBounds(shapePreviewLayer.getBounds());
-
- // Update point count
- const count = countGeojsonPoints(processed);
- if (elements.shapePointsCount) {
- elements.shapePointsCount.textContent = `Points: ${count}`;
- }
-
- } catch (error) {
- console.error('[MapEditor] Error processing shape:', error);
- if (typeof showToast === 'function') {
- showToast('Error', `Error processing: ${error.message}`, 'error');
- }
- }
- }
-
- function applyShapeChanges() {
- if (!shapePreviewLayer) {
- if (typeof showToast === 'function') {
- showToast('Warning', 'No changes to apply', 'warning');
- }
- return;
- }
-
- // Get geometry from preview layer
- const previewGeoJson = shapePreviewLayer.toGeoJSON();
- let geometry = previewGeoJson;
- if (previewGeoJson.type === 'FeatureCollection' && previewGeoJson.features.length > 0) {
- geometry = previewGeoJson.features[0].geometry;
- } else if (previewGeoJson.type === 'Feature') {
- geometry = previewGeoJson.geometry;
- }
-
- // Hide shape editor
- hideShapeEditor();
-
- // Restore visibility of drawn items
- drawnItems.eachLayer(layer => {
- layer.setStyle({ opacity: 1, fillOpacity: 0.2 });
- });
-
- // Apply the processed geometry
- updateGeoJson(geometry);
-
- if (typeof showToast === 'function') {
- showToast('Success', 'Shape changes applied', 'success');
- }
- }
-
- function cancelShapeEditing() {
- hideShapeEditor();
-
- // Restore visibility of drawn items
- drawnItems.eachLayer(layer => {
- layer.setStyle({ opacity: 1, fillOpacity: 0.2 });
- });
-
- // Restore original shape
- if (originalShapeGeoJson) {
- updateGeoJson(originalShapeGeoJson);
- }
- originalShapeGeoJson = null;
- }
-
- // Set up event listeners - Raw GeoJSON editor
- if (elements.editGeoJsonBtn) {
- elements.editGeoJsonBtn.addEventListener('click', showEditor);
- }
- if (elements.showBtn) {
- elements.showBtn.addEventListener('click', showGeoJsonPreview);
- }
- if (elements.saveLocallyBtn) {
- elements.saveLocallyBtn.addEventListener('click', saveLocally);
- }
- if (elements.cancelBtn) {
- elements.cancelBtn.addEventListener('click', cancelEditing);
- }
-
- // Set up event listeners - Shape editor
- if (elements.editShapeBtn) {
- elements.editShapeBtn.addEventListener('click', showShapeEditor);
- }
- if (elements.shapeSimplifySlider) {
- elements.shapeSimplifySlider.addEventListener('input', function() {
- const tolerance = sliderToTolerance(parseFloat(this.value));
- elements.shapeSimplifyValue.textContent = formatTolerance(tolerance);
- processAndPreviewShape();
- });
- }
- if (elements.shapeBufferSlider) {
- elements.shapeBufferSlider.addEventListener('input', function() {
- elements.shapeBufferValue.textContent = this.value;
- processAndPreviewShape();
- });
- }
- if (elements.shapeMegaSimplifyCheckbox) {
- elements.shapeMegaSimplifyCheckbox.addEventListener('change', function() {
- if (elements.shapeTightnessSlider) {
- elements.shapeTightnessSlider.disabled = !this.checked;
- }
- processAndPreviewShape();
- });
- }
- if (elements.shapeTightnessSlider) {
- elements.shapeTightnessSlider.addEventListener('input', function() {
- const val = parseInt(this.value);
- if (elements.shapeTightnessValue) {
- elements.shapeTightnessValue.textContent = formatTightness(val);
- }
- processAndPreviewShape();
- });
- }
- if (elements.shapeShowOriginal) {
- elements.shapeShowOriginal.addEventListener('change', updateOriginalShapeLayer);
- }
- if (elements.applyShapeBtn) {
- elements.applyShapeBtn.addEventListener('click', applyShapeChanges);
- }
- if (elements.cancelShapeBtn) {
- elements.cancelShapeBtn.addEventListener('click', cancelShapeEditing);
- }
-
- // Load initial GeoJSON if provided
- if (initialGeoJson) {
- // Delay slightly to ensure map is fully initialized
- setTimeout(() => {
- updateGeoJson(initialGeoJson);
- }, 100);
- }
-
- // Return public API
- return {
- map,
- drawnItems,
- getCurrentGeoJson: () => currentGeoJson,
- updateGeoJson,
- showEditor,
- hideEditor,
- showShapeEditor,
- hideShapeEditor
- };
+ const {
+ containerId = "map",
+ initialGeoJson = null,
+ onGeoJsonChange = null,
+ } = options;
+
+ // State
+ let currentGeoJson = null;
+ let geoJsonLayer = null;
+ let previouslySavedGeoJson = null;
+
+ // Shape editor state
+ let originalShapeGeoJson = null; // Original shape before editing
+ let shapePreviewLayer = null; // Preview layer for shape edits
+ let originalShapeLayer = null; // Reference layer showing original
+
+ // DOM Elements
+ const elements = {
+ mapContainer: document.getElementById(containerId),
+ // Raw GeoJSON editor
+ editGeoJsonBtn: document.getElementById("edit-geojson-btn"),
+ geoJsonEditor: document.getElementById("geojson-editor"),
+ geoJsonInput: document.getElementById("geojson-input"),
+ showBtn: document.getElementById("show-geojson-btn"),
+ saveLocallyBtn: document.getElementById("save-locally-btn"),
+ cancelBtn: document.getElementById("cancel-geojson-btn"),
+ // Shape editor
+ editShapeBtn: document.getElementById("edit-shape-btn"),
+ shapeEditor: document.getElementById("shape-editor"),
+ shapeSimplifySlider: document.getElementById("shape-simplify-slider"),
+ shapeSimplifyValue: document.getElementById("shape-simplify-value"),
+ shapeBufferSlider: document.getElementById("shape-buffer-slider"),
+ shapeBufferValue: document.getElementById("shape-buffer-value"),
+ shapeMegaSimplifyCheckbox: document.getElementById("shape-mega-simplify"),
+ shapeTightnessSlider: document.getElementById("shape-tightness-slider"),
+ shapeTightnessValue: document.getElementById("shape-tightness-value"),
+ shapePointsCount: document.getElementById("shape-points-count"),
+ shapeShowOriginal: document.getElementById("shape-show-original"),
+ applyShapeBtn: document.getElementById("apply-shape-btn"),
+ cancelShapeBtn: document.getElementById("cancel-shape-btn"),
+ };
+
+ // Initialize map
+ if (!elements.mapContainer) {
+ console.error("[MapEditor] Map container not found:", containerId);
+ return null;
+ }
+
+ const map = L.map(containerId).setView([0, 0], 2);
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ attribution: "© OpenStreetMap contributors",
+ }).addTo(map);
+
+ // Initialize draw control
+ const drawnItems = new L.FeatureGroup();
+ map.addLayer(drawnItems);
+
+ const drawControl = new L.Control.Draw({
+ draw: {
+ polygon: {
+ allowIntersection: false,
+ showArea: true,
+ shapeOptions: {
+ color: "#3388ff",
+ weight: 3,
+ },
+ },
+ rectangle: {
+ shapeOptions: {
+ color: "#3388ff",
+ weight: 3,
+ },
+ },
+ circle: false,
+ circlemarker: false,
+ marker: false,
+ polyline: false,
+ },
+ edit: {
+ featureGroup: drawnItems,
+ remove: true,
+ edit: true,
+ },
+ });
+ map.addControl(drawControl);
+
+ // Update GeoJSON from drawn items
+ function updateGeoJsonFromDrawnItems() {
+ if (drawnItems.getLayers().length === 0) {
+ currentGeoJson = null;
+ if (elements.geoJsonInput) {
+ elements.geoJsonInput.value = "";
+ }
+ if (onGeoJsonChange) {
+ onGeoJsonChange(null);
+ }
+ return;
+ }
+
+ const layer = drawnItems.getLayers()[0];
+ const geoJson = layer.toGeoJSON();
+ currentGeoJson = geoJson.geometry;
+
+ // Keep textarea in sync
+ if (elements.geoJsonInput) {
+ elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
+ }
+
+ map.fitBounds(drawnItems.getBounds());
+
+ if (onGeoJsonChange) {
+ onGeoJsonChange(currentGeoJson);
+ }
+ }
+
+ // Update map with GeoJSON
+ function updateGeoJson(geoJson) {
+ try {
+ // Remove existing geoJsonLayer
+ if (geoJsonLayer) {
+ map.removeLayer(geoJsonLayer);
+ }
+
+ // Clear drawn items
+ drawnItems.clearLayers();
+
+ if (!geoJson) {
+ currentGeoJson = null;
+ if (elements.geoJsonInput) {
+ elements.geoJsonInput.value = "";
+ }
+ if (onGeoJsonChange) {
+ onGeoJsonChange(null);
+ }
+ return true;
+ }
+
+ // Handle string input
+ if (typeof geoJson === "string") {
+ geoJson = JSON.parse(geoJson);
+ }
+
+ // Extract geometry if wrapped in Feature
+ let geometry = geoJson;
+ if (geoJson.type === "Feature") {
+ geometry = geoJson.geometry;
+ } else if (
+ geoJson.type === "FeatureCollection" &&
+ geoJson.features &&
+ geoJson.features.length > 0
+ ) {
+ geometry = geoJson.features[0].geometry;
+ }
+
+ // Store just the geometry
+ currentGeoJson = geometry;
+
+ // Wrap back as Feature for display in Leaflet
+ const featureCollection = {
+ type: "Feature",
+ geometry: geometry,
+ };
+
+ // Create GeoJSON feature and add to map
+ geoJsonLayer = L.geoJSON(featureCollection).addTo(map);
+ map.fitBounds(geoJsonLayer.getBounds());
+
+ // Also add to drawnItems for editing
+ if (geoJsonLayer.getLayers().length > 0) {
+ const layer = geoJsonLayer.getLayers()[0];
+ drawnItems.addLayer(layer);
+ }
+
+ // Update textarea with current GeoJSON (keep them in sync)
+ if (elements.geoJsonInput) {
+ elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
+ }
+
+ if (onGeoJsonChange) {
+ onGeoJsonChange(currentGeoJson);
+ }
+
+ return true;
+ } catch (error) {
+ console.error("[MapEditor] Error adding GeoJSON to map:", error);
+ if (typeof showToast === "function") {
+ showToast("Error", `Invalid GeoJSON: ${error.message}`, "error");
+ }
+ return false;
+ }
+ }
+
+ // Handle draw created
+ map.on(L.Draw.Event.CREATED, (e) => {
+ const layer = e.layer;
+ // Clear existing and add new
+ drawnItems.clearLayers();
+ drawnItems.addLayer(layer);
+ updateGeoJsonFromDrawnItems();
+ });
+
+ // Handle draw edited
+ map.on(L.Draw.Event.EDITED, (e) => {
+ updateGeoJsonFromDrawnItems();
+ });
+
+ // Handle draw deleted
+ map.on(L.Draw.Event.DELETED, (e) => {
+ currentGeoJson = null;
+ if (geoJsonLayer) {
+ map.removeLayer(geoJsonLayer);
+ geoJsonLayer = null;
+ }
+ if (elements.geoJsonInput) {
+ elements.geoJsonInput.value = "";
+ }
+ if (onGeoJsonChange) {
+ onGeoJsonChange(null);
+ }
+ });
+
+ // Raw GeoJSON editor functionality
+ function showEditor() {
+ if (elements.geoJsonEditor) {
+ elements.geoJsonEditor.style.display = "block";
+ }
+ if (currentGeoJson && elements.geoJsonInput) {
+ elements.geoJsonInput.value = JSON.stringify(currentGeoJson, null, 2);
+ previouslySavedGeoJson = JSON.parse(JSON.stringify(currentGeoJson));
+ }
+ }
+
+ function hideEditor() {
+ if (elements.geoJsonEditor) {
+ elements.geoJsonEditor.style.display = "none";
+ }
+ }
+
+ function showGeoJsonPreview() {
+ try {
+ const geoJson = JSON.parse(elements.geoJsonInput.value);
+ if (updateGeoJson(geoJson)) {
+ if (typeof showToast === "function") {
+ showToast("Success", "GeoJSON preview updated", "success");
+ }
+ }
+ } catch (error) {
+ console.error("[MapEditor] Error parsing GeoJSON:", error);
+ if (typeof showToast === "function") {
+ showToast("Error", `Invalid GeoJSON: ${error.message}`, "error");
+ }
+ }
+ }
+
+ function saveLocally() {
+ try {
+ const geoJson = JSON.parse(elements.geoJsonInput.value);
+ if (updateGeoJson(geoJson)) {
+ hideEditor();
+ previouslySavedGeoJson = currentGeoJson;
+ if (typeof showToast === "function") {
+ showToast("Success", "GeoJSON saved locally", "success");
+ }
+ }
+ } catch (error) {
+ console.error("[MapEditor] Error saving GeoJSON:", error);
+ if (typeof showToast === "function") {
+ showToast("Error", `Invalid GeoJSON: ${error.message}`, "error");
+ }
+ }
+ }
+
+ function cancelEditing() {
+ hideEditor();
+ if (previouslySavedGeoJson) {
+ updateGeoJson(previouslySavedGeoJson);
+ }
+ }
+
+ // ============================================
+ // Shape Editor functionality
+ // ============================================
+
+ // Convert linear slider (0-10) to logarithmic tolerance
+ function sliderToTolerance(sliderVal) {
+ if (sliderVal === 0) return 0;
+ const minLog = Math.log10(0.000001);
+ const maxLog = Math.log10(0.01);
+ const logVal = minLog + (sliderVal / 10) * (maxLog - minLog);
+ return Math.pow(10, logVal);
+ }
+
+ function formatTolerance(val) {
+ if (val === 0) return "0";
+ if (val < 0.00001) return val.toExponential(1);
+ if (val < 0.0001) return val.toFixed(6);
+ if (val < 0.001) return val.toFixed(5);
+ if (val < 0.01) return val.toFixed(4);
+ return val.toFixed(3);
+ }
+
+ // Convert tightness slider (0-10) to concavity for turf.convex()
+ // 0 = pure convex (concavity = Infinity)
+ // 10 = tighter fit (concavity = 1)
+ function sliderToConcavity(sliderVal) {
+ if (sliderVal === 0) return Number.POSITIVE_INFINITY;
+ return Math.max(1, 21 - sliderVal * 2);
+ }
+
+ function formatTightness(val) {
+ if (val === 0) return "Loose";
+ if (val === 10) return "Tight";
+ return val.toString();
+ }
+
+ function countGeojsonPoints(geojson) {
+ let count = 0;
+ function countCoords(coords) {
+ if (typeof coords[0] === "number") {
+ count++;
+ } else {
+ coords.forEach((c) => countCoords(c));
+ }
+ }
+ const geometry = geojson.geometry || geojson;
+ if (geometry?.coordinates) {
+ countCoords(geometry.coordinates);
+ }
+ return count;
+ }
+
+ function showShapeEditor() {
+ if (!currentGeoJson) {
+ if (typeof showToast === "function") {
+ showToast(
+ "Warning",
+ "No shape to edit. Draw or import a polygon first.",
+ "warning",
+ );
+ }
+ return;
+ }
+
+ // Store original shape
+ originalShapeGeoJson = JSON.parse(JSON.stringify(currentGeoJson));
+
+ // Hide raw GeoJSON editor if open
+ hideEditor();
+
+ // Reset sliders to neutral (no changes)
+ if (elements.shapeSimplifySlider) {
+ elements.shapeSimplifySlider.value = 0;
+ elements.shapeSimplifyValue.textContent = "0";
+ }
+ if (elements.shapeBufferSlider) {
+ elements.shapeBufferSlider.value = 0;
+ elements.shapeBufferValue.textContent = "0";
+ }
+ if (elements.shapeMegaSimplifyCheckbox) {
+ elements.shapeMegaSimplifyCheckbox.checked = false;
+ }
+ if (elements.shapeTightnessSlider) {
+ elements.shapeTightnessSlider.value = 0;
+ elements.shapeTightnessSlider.disabled = true;
+ elements.shapeTightnessValue.textContent = "Loose";
+ }
+
+ // Show editor
+ if (elements.shapeEditor) {
+ elements.shapeEditor.style.display = "block";
+ }
+
+ // Show original shape and initial preview
+ updateOriginalShapeLayer();
+ processAndPreviewShape();
+ }
+
+ function hideShapeEditor() {
+ if (elements.shapeEditor) {
+ elements.shapeEditor.style.display = "none";
+ }
+ // Remove preview layers
+ if (shapePreviewLayer) {
+ map.removeLayer(shapePreviewLayer);
+ shapePreviewLayer = null;
+ }
+ if (originalShapeLayer) {
+ map.removeLayer(originalShapeLayer);
+ originalShapeLayer = null;
+ }
+ }
+
+ function updateOriginalShapeLayer() {
+ // Remove existing layer
+ if (originalShapeLayer) {
+ map.removeLayer(originalShapeLayer);
+ originalShapeLayer = null;
+ }
+
+ if (
+ !originalShapeGeoJson ||
+ !elements.shapeShowOriginal ||
+ !elements.shapeShowOriginal.checked
+ ) {
+ return;
+ }
+
+ // Wrap as Feature if needed
+ let feature = originalShapeGeoJson;
+ if (
+ originalShapeGeoJson.type !== "Feature" &&
+ originalShapeGeoJson.type !== "FeatureCollection"
+ ) {
+ feature = {
+ type: "Feature",
+ geometry: originalShapeGeoJson,
+ properties: {},
+ };
+ }
+
+ // Create dashed line style for reference
+ originalShapeLayer = L.geoJSON(feature, {
+ style: {
+ color: "#ff6600",
+ weight: 2,
+ dashArray: "5, 5",
+ fillOpacity: 0,
+ interactive: false,
+ },
+ }).addTo(map);
+ }
+
+ function processAndPreviewShape() {
+ if (!originalShapeGeoJson) return;
+
+ try {
+ // Wrap as Feature if needed
+ let feature = originalShapeGeoJson;
+ if (
+ originalShapeGeoJson.type !== "Feature" &&
+ originalShapeGeoJson.type !== "FeatureCollection"
+ ) {
+ feature = {
+ type: "Feature",
+ geometry: originalShapeGeoJson,
+ properties: {},
+ };
+ }
+
+ const tolerance = sliderToTolerance(
+ Number.parseFloat(elements.shapeSimplifySlider?.value || 0),
+ );
+ const buffer = Number.parseFloat(elements.shapeBufferSlider?.value || 0);
+
+ let processed = feature;
+
+ // Apply buffer if > 0
+ if (buffer > 0 && typeof turf !== "undefined") {
+ processed = turf.buffer(processed, buffer, {
+ units: "kilometers",
+ });
+ }
+
+ // Apply simplification if > 0
+ if (tolerance > 0 && typeof turf !== "undefined") {
+ processed = turf.simplify(processed, {
+ tolerance: tolerance,
+ highQuality: true,
+ });
+ }
+
+ // Apply mega simplify if checked
+ if (
+ elements.shapeMegaSimplifyCheckbox?.checked &&
+ typeof turf !== "undefined"
+ ) {
+ const concavity = sliderToConcavity(
+ Number.parseFloat(elements.shapeTightnessSlider?.value || 0),
+ );
+
+ try {
+ // Use turf.convex with concavity parameter
+ processed = turf.convex(processed, {
+ concavity: concavity,
+ });
+
+ if (!processed) {
+ console.warn("[MapEditor] Convex hull returned null");
+ processed = turf.convex(feature);
+ }
+ } catch (e) {
+ console.warn("[MapEditor] Convex hull failed:", e.message);
+ // Keep original processed value
+ }
+ }
+
+ // Remove existing preview layer
+ if (shapePreviewLayer) {
+ map.removeLayer(shapePreviewLayer);
+ }
+
+ // Hide the main drawn items while previewing
+ drawnItems.eachLayer((layer) => {
+ layer.setStyle({ opacity: 0, fillOpacity: 0 });
+ });
+
+ // Add preview layer
+ shapePreviewLayer = L.geoJSON(processed, {
+ style: {
+ color: "#3388ff",
+ weight: 3,
+ fillColor: "#3388ff",
+ fillOpacity: 0.2,
+ },
+ }).addTo(map);
+
+ // Fit bounds
+ map.fitBounds(shapePreviewLayer.getBounds());
+
+ // Update point count
+ const count = countGeojsonPoints(processed);
+ if (elements.shapePointsCount) {
+ elements.shapePointsCount.textContent = `Points: ${count}`;
+ }
+ } catch (error) {
+ console.error("[MapEditor] Error processing shape:", error);
+ if (typeof showToast === "function") {
+ showToast("Error", `Error processing: ${error.message}`, "error");
+ }
+ }
+ }
+
+ function applyShapeChanges() {
+ if (!shapePreviewLayer) {
+ if (typeof showToast === "function") {
+ showToast("Warning", "No changes to apply", "warning");
+ }
+ return;
+ }
+
+ // Get geometry from preview layer
+ const previewGeoJson = shapePreviewLayer.toGeoJSON();
+ let geometry = previewGeoJson;
+ if (
+ previewGeoJson.type === "FeatureCollection" &&
+ previewGeoJson.features.length > 0
+ ) {
+ geometry = previewGeoJson.features[0].geometry;
+ } else if (previewGeoJson.type === "Feature") {
+ geometry = previewGeoJson.geometry;
+ }
+
+ // Hide shape editor
+ hideShapeEditor();
+
+ // Restore visibility of drawn items
+ drawnItems.eachLayer((layer) => {
+ layer.setStyle({ opacity: 1, fillOpacity: 0.2 });
+ });
+
+ // Apply the processed geometry
+ updateGeoJson(geometry);
+
+ if (typeof showToast === "function") {
+ showToast("Success", "Shape changes applied", "success");
+ }
+ }
+
+ function cancelShapeEditing() {
+ hideShapeEditor();
+
+ // Restore visibility of drawn items
+ drawnItems.eachLayer((layer) => {
+ layer.setStyle({ opacity: 1, fillOpacity: 0.2 });
+ });
+
+ // Restore original shape
+ if (originalShapeGeoJson) {
+ updateGeoJson(originalShapeGeoJson);
+ }
+ originalShapeGeoJson = null;
+ }
+
+ // Set up event listeners - Raw GeoJSON editor
+ if (elements.editGeoJsonBtn) {
+ elements.editGeoJsonBtn.addEventListener("click", showEditor);
+ }
+ if (elements.showBtn) {
+ elements.showBtn.addEventListener("click", showGeoJsonPreview);
+ }
+ if (elements.saveLocallyBtn) {
+ elements.saveLocallyBtn.addEventListener("click", saveLocally);
+ }
+ if (elements.cancelBtn) {
+ elements.cancelBtn.addEventListener("click", cancelEditing);
+ }
+
+ // Set up event listeners - Shape editor
+ if (elements.editShapeBtn) {
+ elements.editShapeBtn.addEventListener("click", showShapeEditor);
+ }
+ if (elements.shapeSimplifySlider) {
+ elements.shapeSimplifySlider.addEventListener("input", function () {
+ const tolerance = sliderToTolerance(Number.parseFloat(this.value));
+ elements.shapeSimplifyValue.textContent = formatTolerance(tolerance);
+ processAndPreviewShape();
+ });
+ }
+ if (elements.shapeBufferSlider) {
+ elements.shapeBufferSlider.addEventListener("input", function () {
+ elements.shapeBufferValue.textContent = this.value;
+ processAndPreviewShape();
+ });
+ }
+ if (elements.shapeMegaSimplifyCheckbox) {
+ elements.shapeMegaSimplifyCheckbox.addEventListener("change", function () {
+ if (elements.shapeTightnessSlider) {
+ elements.shapeTightnessSlider.disabled = !this.checked;
+ }
+ processAndPreviewShape();
+ });
+ }
+ if (elements.shapeTightnessSlider) {
+ elements.shapeTightnessSlider.addEventListener("input", function () {
+ const val = Number.parseInt(this.value);
+ if (elements.shapeTightnessValue) {
+ elements.shapeTightnessValue.textContent = formatTightness(val);
+ }
+ processAndPreviewShape();
+ });
+ }
+ if (elements.shapeShowOriginal) {
+ elements.shapeShowOriginal.addEventListener(
+ "change",
+ updateOriginalShapeLayer,
+ );
+ }
+ if (elements.applyShapeBtn) {
+ elements.applyShapeBtn.addEventListener("click", applyShapeChanges);
+ }
+ if (elements.cancelShapeBtn) {
+ elements.cancelShapeBtn.addEventListener("click", cancelShapeEditing);
+ }
+
+ // Load initial GeoJSON if provided
+ if (initialGeoJson) {
+ // Delay slightly to ensure map is fully initialized
+ setTimeout(() => {
+ updateGeoJson(initialGeoJson);
+ }, 100);
+ }
+
+ // Return public API
+ return {
+ map,
+ drawnItems,
+ getCurrentGeoJson: () => currentGeoJson,
+ updateGeoJson,
+ showEditor,
+ hideEditor,
+ showShapeEditor,
+ hideShapeEditor,
+ };
}
// Export for module systems if available
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = { initMapEditor };
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = { initMapEditor };
}
diff --git a/static/js/osm-geojson-generator.js b/static/js/osm-geojson-generator.js
index 0c1a660..e0492a7 100644
--- a/static/js/osm-geojson-generator.js
+++ b/static/js/osm-geojson-generator.js
@@ -1,16 +1,16 @@
/**
* OSM GeoJSON Generator Component
- *
+ *
* Provides OSM search functionality with Turf.js processing
* for simplification and buffering of boundaries.
- *
+ *
* Usage:
* initOsmGeojsonGenerator({
* mapEditor: mapEditorInstance,
* onApply: (geometry) => mapEditor.updateGeoJson(geometry),
* onPopulationFound: (population, date) => { ... }
* });
- *
+ *
* Requires:
* - Turf.js loaded
* - mapEditor instance from map-editor.js
@@ -19,480 +19,515 @@
*/
function initOsmGeojsonGenerator(options = {}) {
- const {
- mapEditor = null,
- onApply = null,
- onPopulationFound = null
- } = options;
-
- if (!mapEditor) {
- console.error('[OsmGeojsonGenerator] mapEditor is required');
- return null;
- }
-
- // DOM Elements
- const elements = {
- searchInput: document.getElementById('osm-search-input'),
- searchBtn: document.getElementById('osm-search-btn'),
- resultsContainer: document.getElementById('search-results-container'),
- resultsSelect: document.getElementById('search-results'),
- controls: document.getElementById('geojson-controls'),
- simplifySlider: document.getElementById('simplify-slider'),
- simplifyValue: document.getElementById('simplify-value'),
- bufferSlider: document.getElementById('buffer-slider'),
- bufferValue: document.getElementById('buffer-value'),
- megaSimplifyCheckbox: document.getElementById('mega-simplify'),
- tightnessSlider: document.getElementById('tightness-slider'),
- tightnessValue: document.getElementById('tightness-value'),
- pointsCount: document.getElementById('points-count'),
- showOriginalBoundary: document.getElementById('show-original-boundary'),
- applyBtn: document.getElementById('apply-geojson-btn'),
- loading: document.getElementById('geojson-loading'),
- error: document.getElementById('geojson-error')
- };
-
- // State
- let originalOsmGeojson = null; // Full-detail from Nominatim
- let processedGeojson = null; // After simplification/buffer
- let originalBoundaryLayer = null; // Reference layer showing original
- let previewLayer = null; // Preview layer for processed result
- let osmSearchResults = []; // Store search results
-
- // Helper functions
- function showError(message) {
- if (elements.error) {
- elements.error.textContent = message;
- elements.error.style.display = 'block';
- }
- }
-
- function hideError() {
- if (elements.error) {
- elements.error.style.display = 'none';
- }
- }
-
- function showLoading(show) {
- if (elements.loading) {
- elements.loading.style.display = show ? 'block' : 'none';
- }
- }
-
- function countGeojsonPoints(geojson) {
- let count = 0;
-
- function countCoords(coords) {
- if (typeof coords[0] === 'number') {
- count++;
- } else {
- coords.forEach(c => countCoords(c));
- }
- }
-
- const geometry = geojson.geometry || geojson;
- if (geometry && geometry.coordinates) {
- countCoords(geometry.coordinates);
- }
-
- return count;
- }
-
- // Convert linear slider (0-10) to logarithmic tolerance
- // 0 = 0 (no simplification), 1 = 0.000001, 5 = 0.0001, 10 = 0.01
- function sliderToTolerance(sliderVal) {
- if (sliderVal === 0) return 0;
- // Map 1-10 to 0.000001-0.01 logarithmically (6 orders of magnitude)
- const minLog = Math.log10(0.000001); // -6
- const maxLog = Math.log10(0.01); // -2
- const logVal = minLog + (sliderVal / 10) * (maxLog - minLog);
- return Math.pow(10, logVal);
- }
-
- function formatTolerance(val) {
- if (val === 0) return '0';
- if (val < 0.00001) return val.toExponential(1);
- if (val < 0.0001) return val.toFixed(6);
- if (val < 0.001) return val.toFixed(5);
- if (val < 0.01) return val.toFixed(4);
- return val.toFixed(3);
- }
-
- // Convert tightness slider (0-10) to concavity for turf.convex()
- // 0 = pure convex (concavity = Infinity)
- // 10 = tighter fit (concavity = 1)
- function sliderToConcavity(sliderVal) {
- if (sliderVal === 0) return Infinity; // Pure convex hull
- // Map 1-10 to concavity values (higher = looser, lower = tighter)
- // 1 = 20 (loose), 10 = 1 (tight)
- return Math.max(1, 21 - (sliderVal * 2));
- }
-
- function formatTightness(val) {
- if (val === 0) return 'Loose';
- if (val === 10) return 'Tight';
- return val.toString();
- }
-
- // Search OSM via Nominatim
- async function searchOSM() {
- const query = elements.searchInput.value.trim();
- if (!query) {
- if (typeof showToast === 'function') {
- showToast('Warning', 'Please enter a search term', 'warning');
- }
- return;
- }
-
- hideError();
- elements.resultsContainer.style.display = 'none';
- elements.controls.style.display = 'none';
- showLoading(true);
-
- try {
- const response = await apiFetch(`/api/search_osm?q=${encodeURIComponent(query)}`);
- const results = await response.json();
-
- if (results.error) {
- throw new Error(results.error);
- }
-
- if (results.length === 0) {
- showError('No administrative areas found. Try a different search term.');
- return;
- }
-
- // Store results and populate dropdown
- osmSearchResults = results;
- elements.resultsSelect.innerHTML = '';
- results.forEach((r, index) => {
- const option = document.createElement('option');
- option.value = index;
- option.textContent = r.display_name;
- elements.resultsSelect.appendChild(option);
- });
-
- elements.resultsContainer.style.display = 'block';
-
- } catch (error) {
- console.error('[OsmGeojsonGenerator] Search error:', error);
- showError(error.message || 'Search failed');
- } finally {
- showLoading(false);
- }
- }
-
- // Handle search result selection
- async function onSearchResultSelect() {
- const index = elements.resultsSelect.value;
- if (index === '') return;
-
- const result = osmSearchResults[parseInt(index)];
- if (!result || !result.geojson) {
- showError('Selected place has no boundary data');
- return;
- }
-
- // Check if there's already a polygon, confirm before replacing
- const currentGeoJson = mapEditor.getCurrentGeoJson();
- if (currentGeoJson && mapEditor.drawnItems.getLayers().length > 0) {
- const confirmed = await confirmPolygonReplacement();
- if (!confirmed) {
- elements.resultsSelect.value = '';
- return;
- }
- }
-
- // Store the original GeoJSON
- originalOsmGeojson = result.geojson;
-
- // Handle population data from OSM extratags
- if (result.extratags && result.extratags.population && onPopulationFound) {
- const populationValue = parseInt(result.extratags.population, 10);
- if (!isNaN(populationValue)) {
- const today = new Date().toISOString().split('T')[0];
- onPopulationFound(populationValue, today);
- }
- }
-
- // Reset sliders to defaults
- elements.simplifySlider.value = 5; // Maps to ~0.001 tolerance
- elements.simplifyValue.textContent = formatTolerance(sliderToTolerance(5));
- elements.bufferSlider.value = 0.1;
- elements.bufferValue.textContent = '0.1';
-
- // Reset mega simplify controls
- if (elements.megaSimplifyCheckbox) {
- elements.megaSimplifyCheckbox.checked = false;
- }
- if (elements.tightnessSlider) {
- elements.tightnessSlider.value = 0;
- elements.tightnessSlider.disabled = true;
- }
- if (elements.tightnessValue) {
- elements.tightnessValue.textContent = 'Loose';
- }
-
- // Show controls and process
- elements.controls.style.display = 'block';
-
- // Show original boundary and process
- updateOriginalBoundaryLayer();
- processAndPreviewGeojson();
- }
-
- // Confirmation dialog for replacing existing polygon
- function confirmPolygonReplacement() {
- return new Promise((resolve) => {
- const result = confirm('Replace existing polygon with the selected OSM boundary?');
- resolve(result);
- });
- }
-
- // Show/hide original boundary reference layer
- function updateOriginalBoundaryLayer() {
- const map = mapEditor.map;
-
- // Remove existing layer
- if (originalBoundaryLayer) {
- map.removeLayer(originalBoundaryLayer);
- originalBoundaryLayer = null;
- }
-
- if (!originalOsmGeojson || !elements.showOriginalBoundary.checked) {
- return;
- }
-
- // Wrap as Feature if needed
- let feature = originalOsmGeojson;
- if (originalOsmGeojson.type !== 'Feature' && originalOsmGeojson.type !== 'FeatureCollection') {
- feature = { type: 'Feature', geometry: originalOsmGeojson, properties: {} };
- }
-
- // Create dashed line style for reference
- originalBoundaryLayer = L.geoJSON(feature, {
- style: {
- color: '#ff6600',
- weight: 2,
- dashArray: '5, 5',
- fillOpacity: 0,
- interactive: false
- }
- }).addTo(map);
- }
-
- // Process GeoJSON with Turf.js and preview
- function processAndPreviewGeojson() {
- if (!originalOsmGeojson) return;
-
- const map = mapEditor.map;
-
- try {
- // Wrap as Feature if needed
- let feature = originalOsmGeojson;
- if (originalOsmGeojson.type !== 'Feature' && originalOsmGeojson.type !== 'FeatureCollection') {
- feature = { type: 'Feature', geometry: originalOsmGeojson, properties: {} };
- }
-
- const tolerance = sliderToTolerance(parseFloat(elements.simplifySlider.value));
- const buffer = parseFloat(elements.bufferSlider.value);
-
- let processed = feature;
-
- // Apply buffer if > 0
- if (buffer > 0) {
- processed = turf.buffer(processed, buffer, { units: 'kilometers' });
- }
-
- // Apply simplification if > 0
- if (tolerance > 0) {
- processed = turf.simplify(processed, {
- tolerance: tolerance,
- highQuality: true
- });
- }
-
- // Apply mega simplify if checked
- if (elements.megaSimplifyCheckbox && elements.megaSimplifyCheckbox.checked) {
- const concavity = sliderToConcavity(parseFloat(elements.tightnessSlider.value));
-
- try {
- // Use turf.convex with concavity parameter
- // concavity: 1 = tight, Infinity = pure convex hull
- processed = turf.convex(processed, { concavity: concavity });
-
- if (!processed) {
- console.warn('[OsmGeojsonGenerator] Convex hull returned null');
- // Fallback: try pure convex
- processed = turf.convex(feature);
- }
- } catch (e) {
- console.warn('[OsmGeojsonGenerator] Convex hull failed:', e.message);
- // Keep original processed value
- }
- }
-
- processedGeojson = processed;
-
- // Remove existing preview layer
- if (previewLayer) {
- map.removeLayer(previewLayer);
- }
-
- // Add preview layer
- previewLayer = L.geoJSON(processed, {
- style: {
- color: '#3388ff',
- weight: 3,
- fillColor: '#3388ff',
- fillOpacity: 0.2
- }
- }).addTo(map);
-
- // Fit bounds
- map.fitBounds(previewLayer.getBounds());
-
- // Update point count
- const count = countGeojsonPoints(processed);
- elements.pointsCount.textContent = `Points: ${count}`;
-
- } catch (error) {
- console.error('[OsmGeojsonGenerator] Error processing GeoJSON:', error);
- showError(`Error processing: ${error.message}`);
- }
- }
-
- // Apply the processed GeoJSON to the map for editing
- function applyProcessedGeojson() {
- if (!processedGeojson) {
- if (typeof showToast === 'function') {
- showToast('Warning', 'No processed GeoJSON to apply', 'warning');
- }
- return;
- }
-
- const map = mapEditor.map;
-
- // Extract geometry
- const geometry = processedGeojson.geometry || processedGeojson;
-
- // Remove preview layer
- if (previewLayer) {
- map.removeLayer(previewLayer);
- previewLayer = null;
- }
-
- // Remove original boundary layer
- if (originalBoundaryLayer) {
- map.removeLayer(originalBoundaryLayer);
- originalBoundaryLayer = null;
- }
-
- // Call the onApply callback
- if (onApply) {
- onApply(geometry);
- }
-
- // Hide controls and reset state
- elements.controls.style.display = 'none';
- elements.resultsContainer.style.display = 'none';
- elements.searchInput.value = '';
- elements.resultsSelect.innerHTML = '';
- originalOsmGeojson = null;
- processedGeojson = null;
- osmSearchResults = [];
-
- if (typeof showToast === 'function') {
- showToast('Success', 'GeoJSON applied. You can now edit the polygon on the map.', 'success');
- }
- }
-
- // Set up event listeners
- if (elements.searchBtn) {
- elements.searchBtn.addEventListener('click', searchOSM);
- }
-
- if (elements.searchInput) {
- elements.searchInput.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- searchOSM();
- }
- });
- }
-
- if (elements.resultsSelect) {
- elements.resultsSelect.addEventListener('change', onSearchResultSelect);
- }
-
- if (elements.simplifySlider) {
- elements.simplifySlider.addEventListener('input', function() {
- const tolerance = sliderToTolerance(parseFloat(this.value));
- elements.simplifyValue.textContent = formatTolerance(tolerance);
- processAndPreviewGeojson();
- });
- }
-
- if (elements.bufferSlider) {
- elements.bufferSlider.addEventListener('input', function() {
- elements.bufferValue.textContent = this.value;
- processAndPreviewGeojson();
- });
- }
-
- if (elements.showOriginalBoundary) {
- elements.showOriginalBoundary.addEventListener('change', updateOriginalBoundaryLayer);
- }
-
- // Mega simplify checkbox
- if (elements.megaSimplifyCheckbox) {
- elements.megaSimplifyCheckbox.addEventListener('change', function() {
- // Enable/disable tightness slider based on checkbox
- if (elements.tightnessSlider) {
- elements.tightnessSlider.disabled = !this.checked;
- }
- processAndPreviewGeojson();
- });
- }
-
- // Tightness slider
- if (elements.tightnessSlider) {
- elements.tightnessSlider.addEventListener('input', function() {
- const val = parseInt(this.value);
- if (elements.tightnessValue) {
- elements.tightnessValue.textContent = formatTightness(val);
- }
- processAndPreviewGeojson();
- });
- }
-
- if (elements.applyBtn) {
- elements.applyBtn.addEventListener('click', applyProcessedGeojson);
- }
-
- // Return public API (minimal, mostly self-contained)
- return {
- searchOSM,
- reset: () => {
- const map = mapEditor.map;
- if (previewLayer) {
- map.removeLayer(previewLayer);
- previewLayer = null;
- }
- if (originalBoundaryLayer) {
- map.removeLayer(originalBoundaryLayer);
- originalBoundaryLayer = null;
- }
- elements.controls.style.display = 'none';
- elements.resultsContainer.style.display = 'none';
- elements.searchInput.value = '';
- originalOsmGeojson = null;
- processedGeojson = null;
- osmSearchResults = [];
- }
- };
+ const {
+ mapEditor = null,
+ onApply = null,
+ onPopulationFound = null,
+ } = options;
+
+ if (!mapEditor) {
+ console.error("[OsmGeojsonGenerator] mapEditor is required");
+ return null;
+ }
+
+ // DOM Elements
+ const elements = {
+ searchInput: document.getElementById("osm-search-input"),
+ searchBtn: document.getElementById("osm-search-btn"),
+ resultsContainer: document.getElementById("search-results-container"),
+ resultsSelect: document.getElementById("search-results"),
+ controls: document.getElementById("geojson-controls"),
+ simplifySlider: document.getElementById("simplify-slider"),
+ simplifyValue: document.getElementById("simplify-value"),
+ bufferSlider: document.getElementById("buffer-slider"),
+ bufferValue: document.getElementById("buffer-value"),
+ megaSimplifyCheckbox: document.getElementById("mega-simplify"),
+ tightnessSlider: document.getElementById("tightness-slider"),
+ tightnessValue: document.getElementById("tightness-value"),
+ pointsCount: document.getElementById("points-count"),
+ showOriginalBoundary: document.getElementById("show-original-boundary"),
+ applyBtn: document.getElementById("apply-geojson-btn"),
+ loading: document.getElementById("geojson-loading"),
+ error: document.getElementById("geojson-error"),
+ };
+
+ // State
+ let originalOsmGeojson = null; // Full-detail from Nominatim
+ let processedGeojson = null; // After simplification/buffer
+ let originalBoundaryLayer = null; // Reference layer showing original
+ let previewLayer = null; // Preview layer for processed result
+ let osmSearchResults = []; // Store search results
+
+ // Helper functions
+ function showError(message) {
+ if (elements.error) {
+ elements.error.textContent = message;
+ elements.error.style.display = "block";
+ }
+ }
+
+ function hideError() {
+ if (elements.error) {
+ elements.error.style.display = "none";
+ }
+ }
+
+ function showLoading(show) {
+ if (elements.loading) {
+ elements.loading.style.display = show ? "block" : "none";
+ }
+ }
+
+ function countGeojsonPoints(geojson) {
+ let count = 0;
+
+ function countCoords(coords) {
+ if (typeof coords[0] === "number") {
+ count++;
+ } else {
+ coords.forEach((c) => countCoords(c));
+ }
+ }
+
+ const geometry = geojson.geometry || geojson;
+ if (geometry?.coordinates) {
+ countCoords(geometry.coordinates);
+ }
+
+ return count;
+ }
+
+ // Convert linear slider (0-10) to logarithmic tolerance
+ // 0 = 0 (no simplification), 1 = 0.000001, 5 = 0.0001, 10 = 0.01
+ function sliderToTolerance(sliderVal) {
+ if (sliderVal === 0) return 0;
+ // Map 1-10 to 0.000001-0.01 logarithmically (6 orders of magnitude)
+ const minLog = Math.log10(0.000001); // -6
+ const maxLog = Math.log10(0.01); // -2
+ const logVal = minLog + (sliderVal / 10) * (maxLog - minLog);
+ return Math.pow(10, logVal);
+ }
+
+ function formatTolerance(val) {
+ if (val === 0) return "0";
+ if (val < 0.00001) return val.toExponential(1);
+ if (val < 0.0001) return val.toFixed(6);
+ if (val < 0.001) return val.toFixed(5);
+ if (val < 0.01) return val.toFixed(4);
+ return val.toFixed(3);
+ }
+
+ // Convert tightness slider (0-10) to concavity for turf.convex()
+ // 0 = pure convex (concavity = Infinity)
+ // 10 = tighter fit (concavity = 1)
+ function sliderToConcavity(sliderVal) {
+ if (sliderVal === 0) return Number.POSITIVE_INFINITY; // Pure convex hull
+ // Map 1-10 to concavity values (higher = looser, lower = tighter)
+ // 1 = 20 (loose), 10 = 1 (tight)
+ return Math.max(1, 21 - sliderVal * 2);
+ }
+
+ function formatTightness(val) {
+ if (val === 0) return "Loose";
+ if (val === 10) return "Tight";
+ return val.toString();
+ }
+
+ // Search OSM via Nominatim
+ async function searchOSM() {
+ const query = elements.searchInput.value.trim();
+ if (!query) {
+ if (typeof showToast === "function") {
+ showToast("Warning", "Please enter a search term", "warning");
+ }
+ return;
+ }
+
+ hideError();
+ elements.resultsContainer.style.display = "none";
+ elements.controls.style.display = "none";
+ showLoading(true);
+
+ try {
+ const response = await apiFetch(
+ `/api/search_osm?q=${encodeURIComponent(query)}`,
+ );
+ const results = await response.json();
+
+ if (results.error) {
+ throw new Error(results.error);
+ }
+
+ if (results.length === 0) {
+ showError(
+ "No administrative areas found. Try a different search term.",
+ );
+ return;
+ }
+
+ // Store results and populate dropdown
+ osmSearchResults = results;
+ elements.resultsSelect.innerHTML =
+ '';
+ results.forEach((r, index) => {
+ const option = document.createElement("option");
+ option.value = index;
+ option.textContent = r.display_name;
+ elements.resultsSelect.appendChild(option);
+ });
+
+ elements.resultsContainer.style.display = "block";
+ } catch (error) {
+ console.error("[OsmGeojsonGenerator] Search error:", error);
+ showError(error.message || "Search failed");
+ } finally {
+ showLoading(false);
+ }
+ }
+
+ // Handle search result selection
+ async function onSearchResultSelect() {
+ const index = elements.resultsSelect.value;
+ if (index === "") return;
+
+ const result = osmSearchResults[Number.parseInt(index)];
+ if (!result || !result.geojson) {
+ showError("Selected place has no boundary data");
+ return;
+ }
+
+ // Check if there's already a polygon, confirm before replacing
+ const currentGeoJson = mapEditor.getCurrentGeoJson();
+ if (currentGeoJson && mapEditor.drawnItems.getLayers().length > 0) {
+ const confirmed = await confirmPolygonReplacement();
+ if (!confirmed) {
+ elements.resultsSelect.value = "";
+ return;
+ }
+ }
+
+ // Store the original GeoJSON
+ originalOsmGeojson = result.geojson;
+
+ // Handle population data from OSM extratags
+ if (result.extratags?.population && onPopulationFound) {
+ const populationValue = Number.parseInt(result.extratags.population, 10);
+ if (!Number.isNaN(populationValue)) {
+ const today = new Date().toISOString().split("T")[0];
+ onPopulationFound(populationValue, today);
+ }
+ }
+
+ // Reset sliders to defaults
+ elements.simplifySlider.value = 5; // Maps to ~0.001 tolerance
+ elements.simplifyValue.textContent = formatTolerance(sliderToTolerance(5));
+ elements.bufferSlider.value = 0.1;
+ elements.bufferValue.textContent = "0.1";
+
+ // Reset mega simplify controls
+ if (elements.megaSimplifyCheckbox) {
+ elements.megaSimplifyCheckbox.checked = false;
+ }
+ if (elements.tightnessSlider) {
+ elements.tightnessSlider.value = 0;
+ elements.tightnessSlider.disabled = true;
+ }
+ if (elements.tightnessValue) {
+ elements.tightnessValue.textContent = "Loose";
+ }
+
+ // Show controls and process
+ elements.controls.style.display = "block";
+
+ // Show original boundary and process
+ updateOriginalBoundaryLayer();
+ processAndPreviewGeojson();
+ }
+
+ // Confirmation dialog for replacing existing polygon
+ function confirmPolygonReplacement() {
+ return new Promise((resolve) => {
+ const result = confirm(
+ "Replace existing polygon with the selected OSM boundary?",
+ );
+ resolve(result);
+ });
+ }
+
+ // Show/hide original boundary reference layer
+ function updateOriginalBoundaryLayer() {
+ const map = mapEditor.map;
+
+ // Remove existing layer
+ if (originalBoundaryLayer) {
+ map.removeLayer(originalBoundaryLayer);
+ originalBoundaryLayer = null;
+ }
+
+ if (!originalOsmGeojson || !elements.showOriginalBoundary.checked) {
+ return;
+ }
+
+ // Wrap as Feature if needed
+ let feature = originalOsmGeojson;
+ if (
+ originalOsmGeojson.type !== "Feature" &&
+ originalOsmGeojson.type !== "FeatureCollection"
+ ) {
+ feature = {
+ type: "Feature",
+ geometry: originalOsmGeojson,
+ properties: {},
+ };
+ }
+
+ // Create dashed line style for reference
+ originalBoundaryLayer = L.geoJSON(feature, {
+ style: {
+ color: "#ff6600",
+ weight: 2,
+ dashArray: "5, 5",
+ fillOpacity: 0,
+ interactive: false,
+ },
+ }).addTo(map);
+ }
+
+ // Process GeoJSON with Turf.js and preview
+ function processAndPreviewGeojson() {
+ if (!originalOsmGeojson) return;
+
+ const map = mapEditor.map;
+
+ try {
+ // Wrap as Feature if needed
+ let feature = originalOsmGeojson;
+ if (
+ originalOsmGeojson.type !== "Feature" &&
+ originalOsmGeojson.type !== "FeatureCollection"
+ ) {
+ feature = {
+ type: "Feature",
+ geometry: originalOsmGeojson,
+ properties: {},
+ };
+ }
+
+ const tolerance = sliderToTolerance(
+ Number.parseFloat(elements.simplifySlider.value),
+ );
+ const buffer = Number.parseFloat(elements.bufferSlider.value);
+
+ let processed = feature;
+
+ // Apply buffer if > 0
+ if (buffer > 0) {
+ processed = turf.buffer(processed, buffer, {
+ units: "kilometers",
+ });
+ }
+
+ // Apply simplification if > 0
+ if (tolerance > 0) {
+ processed = turf.simplify(processed, {
+ tolerance: tolerance,
+ highQuality: true,
+ });
+ }
+
+ // Apply mega simplify if checked
+ if (elements.megaSimplifyCheckbox?.checked) {
+ const concavity = sliderToConcavity(
+ Number.parseFloat(elements.tightnessSlider.value),
+ );
+
+ try {
+ // Use turf.convex with concavity parameter
+ // concavity: 1 = tight, Infinity = pure convex hull
+ processed = turf.convex(processed, {
+ concavity: concavity,
+ });
+
+ if (!processed) {
+ console.warn("[OsmGeojsonGenerator] Convex hull returned null");
+ // Fallback: try pure convex
+ processed = turf.convex(feature);
+ }
+ } catch (e) {
+ console.warn("[OsmGeojsonGenerator] Convex hull failed:", e.message);
+ // Keep original processed value
+ }
+ }
+
+ processedGeojson = processed;
+
+ // Remove existing preview layer
+ if (previewLayer) {
+ map.removeLayer(previewLayer);
+ }
+
+ // Add preview layer
+ previewLayer = L.geoJSON(processed, {
+ style: {
+ color: "#3388ff",
+ weight: 3,
+ fillColor: "#3388ff",
+ fillOpacity: 0.2,
+ },
+ }).addTo(map);
+
+ // Fit bounds
+ map.fitBounds(previewLayer.getBounds());
+
+ // Update point count
+ const count = countGeojsonPoints(processed);
+ elements.pointsCount.textContent = `Points: ${count}`;
+ } catch (error) {
+ console.error("[OsmGeojsonGenerator] Error processing GeoJSON:", error);
+ showError(`Error processing: ${error.message}`);
+ }
+ }
+
+ // Apply the processed GeoJSON to the map for editing
+ function applyProcessedGeojson() {
+ if (!processedGeojson) {
+ if (typeof showToast === "function") {
+ showToast("Warning", "No processed GeoJSON to apply", "warning");
+ }
+ return;
+ }
+
+ const map = mapEditor.map;
+
+ // Extract geometry
+ const geometry = processedGeojson.geometry || processedGeojson;
+
+ // Remove preview layer
+ if (previewLayer) {
+ map.removeLayer(previewLayer);
+ previewLayer = null;
+ }
+
+ // Remove original boundary layer
+ if (originalBoundaryLayer) {
+ map.removeLayer(originalBoundaryLayer);
+ originalBoundaryLayer = null;
+ }
+
+ // Call the onApply callback
+ if (onApply) {
+ onApply(geometry);
+ }
+
+ // Hide controls and reset state
+ elements.controls.style.display = "none";
+ elements.resultsContainer.style.display = "none";
+ elements.searchInput.value = "";
+ elements.resultsSelect.innerHTML =
+ '';
+ originalOsmGeojson = null;
+ processedGeojson = null;
+ osmSearchResults = [];
+
+ if (typeof showToast === "function") {
+ showToast(
+ "Success",
+ "GeoJSON applied. You can now edit the polygon on the map.",
+ "success",
+ );
+ }
+ }
+
+ // Set up event listeners
+ if (elements.searchBtn) {
+ elements.searchBtn.addEventListener("click", searchOSM);
+ }
+
+ if (elements.searchInput) {
+ elements.searchInput.addEventListener("keypress", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ searchOSM();
+ }
+ });
+ }
+
+ if (elements.resultsSelect) {
+ elements.resultsSelect.addEventListener("change", onSearchResultSelect);
+ }
+
+ if (elements.simplifySlider) {
+ elements.simplifySlider.addEventListener("input", function () {
+ const tolerance = sliderToTolerance(Number.parseFloat(this.value));
+ elements.simplifyValue.textContent = formatTolerance(tolerance);
+ processAndPreviewGeojson();
+ });
+ }
+
+ if (elements.bufferSlider) {
+ elements.bufferSlider.addEventListener("input", function () {
+ elements.bufferValue.textContent = this.value;
+ processAndPreviewGeojson();
+ });
+ }
+
+ if (elements.showOriginalBoundary) {
+ elements.showOriginalBoundary.addEventListener(
+ "change",
+ updateOriginalBoundaryLayer,
+ );
+ }
+
+ // Mega simplify checkbox
+ if (elements.megaSimplifyCheckbox) {
+ elements.megaSimplifyCheckbox.addEventListener("change", function () {
+ // Enable/disable tightness slider based on checkbox
+ if (elements.tightnessSlider) {
+ elements.tightnessSlider.disabled = !this.checked;
+ }
+ processAndPreviewGeojson();
+ });
+ }
+
+ // Tightness slider
+ if (elements.tightnessSlider) {
+ elements.tightnessSlider.addEventListener("input", function () {
+ const val = Number.parseInt(this.value);
+ if (elements.tightnessValue) {
+ elements.tightnessValue.textContent = formatTightness(val);
+ }
+ processAndPreviewGeojson();
+ });
+ }
+
+ if (elements.applyBtn) {
+ elements.applyBtn.addEventListener("click", applyProcessedGeojson);
+ }
+
+ // Return public API (minimal, mostly self-contained)
+ return {
+ searchOSM,
+ reset: () => {
+ const map = mapEditor.map;
+ if (previewLayer) {
+ map.removeLayer(previewLayer);
+ previewLayer = null;
+ }
+ if (originalBoundaryLayer) {
+ map.removeLayer(originalBoundaryLayer);
+ originalBoundaryLayer = null;
+ }
+ elements.controls.style.display = "none";
+ elements.resultsContainer.style.display = "none";
+ elements.searchInput.value = "";
+ originalOsmGeojson = null;
+ processedGeojson = null;
+ osmSearchResults = [];
+ },
+ };
}
// Export for module systems if available
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = { initOsmGeojsonGenerator };
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = { initOsmGeojsonGenerator };
}
diff --git a/static/js/script.js b/static/js/script.js
index ef4af9e..19ff379 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -1,139 +1,163 @@
/**
* Centralized API fetch wrapper that handles session expiration gracefully.
* When the session expires, shows a toast notification and redirects to login.
- *
+ *
* @param {string} url - The URL to fetch
* @param {Object} options - Fetch options (method, headers, body, etc.)
* @returns {Promise} - The fetch response
* @throws {Error} - Throws an error if session expired (after redirect initiated)
*/
async function apiFetch(url, options = {}) {
- const response = await fetch(url, options);
-
- // Check for 401 status (session expired)
- if (response.status === 401) {
- try {
- const data = await response.clone().json();
- if (data.session_expired) {
- showToast('Session Expired', 'Your session has expired. Redirecting to login...', 'warning');
- setTimeout(() => {
- window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
- }, 1500);
- throw new Error('Session expired');
- }
- } catch (e) {
- // If we can't parse JSON or it's our own error, still handle as session expired
- if (e.message === 'Session expired') {
- throw e;
- }
- // For other parse errors, check if we got redirected to login page
- const text = await response.clone().text();
- if (text.includes('Login') || response.url.includes('/login')) {
- showToast('Session Expired', 'Your session has expired. Redirecting to login...', 'warning');
- setTimeout(() => {
- window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
- }, 1500);
- throw new Error('Session expired');
- }
- }
- }
-
- // Also check if we were redirected to login page (for cases where 401 isn't returned)
- if (response.redirected && response.url.includes('/login')) {
- showToast('Session Expired', 'Your session has expired. Redirecting to login...', 'warning');
- setTimeout(() => {
- window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
- }, 1500);
- throw new Error('Session expired');
- }
-
- return response;
+ const response = await fetch(url, options);
+
+ // Check for 401 status (session expired)
+ if (response.status === 401) {
+ try {
+ const data = await response.clone().json();
+ if (data.session_expired) {
+ showToast(
+ "Session Expired",
+ "Your session has expired. Redirecting to login...",
+ "warning",
+ );
+ setTimeout(() => {
+ window.location.href = `/login?next=${encodeURIComponent(window.location.href)}`;
+ }, 1500);
+ throw new Error("Session expired");
+ }
+ } catch (e) {
+ // If we can't parse JSON or it's our own error, still handle as session expired
+ if (e.message === "Session expired") {
+ throw e;
+ }
+ // For other parse errors, check if we got redirected to login page
+ const text = await response.clone().text();
+ if (text.includes("Login") || response.url.includes("/login")) {
+ showToast(
+ "Session Expired",
+ "Your session has expired. Redirecting to login...",
+ "warning",
+ );
+ setTimeout(() => {
+ window.location.href = `/login?next=${encodeURIComponent(window.location.href)}`;
+ }, 1500);
+ throw new Error("Session expired");
+ }
+ }
+ }
+
+ // Also check if we were redirected to login page (for cases where 401 isn't returned)
+ if (response.redirected && response.url.includes("/login")) {
+ showToast(
+ "Session Expired",
+ "Your session has expired. Redirecting to login...",
+ "warning",
+ );
+ setTimeout(() => {
+ window.location.href = `/login?next=${encodeURIComponent(window.location.href)}`;
+ }, 1500);
+ throw new Error("Session expired");
+ }
+
+ return response;
}
function editTag(areaId, tagName, tagValue) {
- const newValue = prompt(`Edit ${tagName}:`, tagValue);
- if (newValue !== null && newValue !== tagValue) {
- setAreaTag(areaId, tagName, newValue);
- }
+ const newValue = prompt(`Edit ${tagName}:`, tagValue);
+ if (newValue !== null && newValue !== tagValue) {
+ setAreaTag(areaId, tagName, newValue);
+ }
}
function addTag(areaId) {
- const tagName = prompt("Enter tag name:");
- if (tagName) {
- const tagValue = prompt("Enter tag value:");
- if (tagValue) {
- setAreaTag(areaId, tagName, tagValue);
- }
- }
+ const tagName = prompt("Enter tag name:");
+ if (tagName) {
+ const tagValue = prompt("Enter tag value:");
+ if (tagValue) {
+ setAreaTag(areaId, tagName, tagValue);
+ }
+ }
}
function setAreaTag(areaId, tagName, tagValue) {
- apiFetch('/api/set_area_tag', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ id: areaId, name: tagName, value: tagValue }),
- })
- .then(response => response.json())
- .then(data => {
- if (data.error) {
- showToast('Error', data.error.message || 'Failed to update tag', 'error');
- } else {
- location.reload();
- }
- })
- .catch(error => {
- if (error.message !== 'Session expired') {
- showToast('Error', 'Failed to update tag', 'error');
- }
- });
+ apiFetch("/api/set_area_tag", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id: areaId, name: tagName, value: tagValue }),
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.error) {
+ showToast(
+ "Error",
+ data.error.message || "Failed to update tag",
+ "error",
+ );
+ } else {
+ location.reload();
+ }
+ })
+ .catch((error) => {
+ if (error.message !== "Session expired") {
+ showToast("Error", "Failed to update tag", "error");
+ }
+ });
}
function removeTag(areaId, tagName) {
- apiFetch('/api/remove_area_tag', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ id: areaId, tag: tagName }),
- })
- .then(response => response.json())
- .then(data => {
- if (data.error) {
- showToast('Error', data.error.message || 'Failed to remove tag', 'error');
- } else {
- showToast('Success', 'Tag removed successfully', 'success');
- setTimeout(() => location.reload(), 1000);
- }
- })
- .catch(error => {
- if (error.message !== 'Session expired') {
- showToast('Error', 'Failed to remove tag', 'error');
- }
- });
+ apiFetch("/api/remove_area_tag", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id: areaId, tag: tagName }),
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.error) {
+ showToast(
+ "Error",
+ data.error.message || "Failed to remove tag",
+ "error",
+ );
+ } else {
+ showToast("Success", "Tag removed successfully", "success");
+ setTimeout(() => location.reload(), 1000);
+ }
+ })
+ .catch((error) => {
+ if (error.message !== "Session expired") {
+ showToast("Error", "Failed to remove tag", "error");
+ }
+ });
}
function removeArea(areaId) {
- apiFetch('/api/remove_area', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ id: areaId }),
- })
- .then(response => response.json())
- .then(data => {
- if (data.error) {
- showToast('Error', data.error.message || 'Failed to remove area', 'error');
- } else {
- showToast('Success', 'Area removed successfully', 'success');
- setTimeout(() => window.location.href = '/select_area', 1500);
- }
- })
- .catch(error => {
- if (error.message !== 'Session expired') {
- showToast('Error', 'Failed to remove area', 'error');
- }
- });
+ apiFetch("/api/remove_area", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id: areaId }),
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.error) {
+ showToast(
+ "Error",
+ data.error.message || "Failed to remove area",
+ "error",
+ );
+ } else {
+ showToast("Success", "Area removed successfully", "success");
+ setTimeout(() => (window.location.href = "/select_area"), 1500);
+ }
+ })
+ .catch((error) => {
+ if (error.message !== "Session expired") {
+ showToast("Error", "Failed to remove area", "error");
+ }
+ });
}
diff --git a/static/js/validation.js b/static/js/validation.js
index f7a0dae..e275f11 100644
--- a/static/js/validation.js
+++ b/static/js/validation.js
@@ -1,65 +1,78 @@
// Common validation functions
function validateKey(key, existingKeys) {
- key = key ? key.trim() : '';
- if (!key) {
- return { isValid: false, message: 'Key cannot be empty' };
- }
- if (existingKeys && existingKeys.includes(key)) {
- return { isValid: false, message: 'Key already exists' };
- }
- if (!/^[a-zA-Z][a-zA-Z0-9_:]*$/.test(key)) {
- return { isValid: false, message: 'Key must start with a letter and contain only letters, numbers, underscores, and colons' };
- }
- return { isValid: true };
+ key = key ? key.trim() : "";
+ if (!key) {
+ return { isValid: false, message: "Key cannot be empty" };
+ }
+ if (existingKeys?.includes(key)) {
+ return { isValid: false, message: "Key already exists" };
+ }
+ if (!/^[a-zA-Z][a-zA-Z0-9_:]*$/.test(key)) {
+ return {
+ isValid: false,
+ message:
+ "Key must start with a letter and contain only letters, numbers, underscores, and colons",
+ };
+ }
+ return { isValid: true };
}
function validateNumericValue(value, type) {
- if (!value || value.trim() === '') {
- return { isValid: false, message: 'Value cannot be empty' };
- }
+ if (!value || value.trim() === "") {
+ return { isValid: false, message: "Value cannot be empty" };
+ }
- value = value.toString().trim();
-
- if (type === 'integer') {
- if (!/^\d+$/.test(value)) {
- return { isValid: false, message: 'Value must be a valid integer (no decimal points)' };
- }
- const num = parseInt(value, 10);
- if (num < 0) {
- return { isValid: false, message: 'Value must be non-negative' };
- }
- return { isValid: true, value: num };
- } else if (type === 'number') {
- if (!/^\d*\.?\d*$/.test(value)) {
- return { isValid: false, message: 'Value must contain only digits and at most one decimal point' };
- }
- const num = parseFloat(value);
- if (isNaN(num)) {
- return { isValid: false, message: 'Value must be a valid number' };
- }
- if (num < 0) {
- return { isValid: false, message: 'Value must be non-negative' };
- }
- return { isValid: true, value: num };
- }
+ value = value.toString().trim();
- return { isValid: true, value: value.trim() };
+ if (type === "integer") {
+ if (!/^\d+$/.test(value)) {
+ return {
+ isValid: false,
+ message: "Value must be a valid integer (no decimal points)",
+ };
+ }
+ const num = Number.parseInt(value, 10);
+ if (num < 0) {
+ return { isValid: false, message: "Value must be non-negative" };
+ }
+ return { isValid: true, value: num };
+ } else if (type === "number") {
+ if (!/^\d*\.?\d*$/.test(value)) {
+ return {
+ isValid: false,
+ message: "Value must contain only digits and at most one decimal point",
+ };
+ }
+ const num = Number.parseFloat(value);
+ if (Number.isNaN(num)) {
+ return { isValid: false, message: "Value must be a valid number" };
+ }
+ if (num < 0) {
+ return { isValid: false, message: "Value must be non-negative" };
+ }
+ return { isValid: true, value: num };
+ }
+
+ return { isValid: true, value: value.trim() };
}
function validateValue(value, requirements) {
- if (!value || value.trim() === '') {
- return { isValid: false, message: 'Value cannot be empty' };
- }
+ if (!value || value.trim() === "") {
+ return { isValid: false, message: "Value cannot be empty" };
+ }
- if (requirements && requirements.type) {
- if (requirements.type === 'integer' || requirements.type === 'number') {
- return validateNumericValue(value, requirements.type);
- } else if (requirements.allowed_values) {
- if (!requirements.allowed_values.includes(value)) {
- return { isValid: false, message: `Value must be one of: ${requirements.allowed_values.join(', ')}` };
- }
- }
- }
+ if (requirements?.type) {
+ if (requirements.type === "integer" || requirements.type === "number") {
+ return validateNumericValue(value, requirements.type);
+ } else if (requirements.allowed_values) {
+ if (!requirements.allowed_values.includes(value)) {
+ return {
+ isValid: false,
+ message: `Value must be one of: ${requirements.allowed_values.join(", ")}`,
+ };
+ }
+ }
+ }
- return { isValid: true, value: value.trim() };
+ return { isValid: true, value: value.trim() };
}