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() }; }