"use strict"; // ── Map setup ────────────────────────────────────────────────────────────── const map = L.map("map", { zoomControl: true }).setView([36.6484, 9.6146], 13); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", maxZoom: 19, }).addTo(map); let pin = null; let previewLayer = null; // rectangle/circle/hex for pin mode let currentLat = null; let currentLon = null; let currentShape = "circle"; let currentMode = "pin"; // "pin" | "area" | "route" let currentPolygon = null; // [[lng, lat], ...] when drawn/imported let drawHandler = null; let clipDrawHandler = null; // draw handler for route-mode clip polygon const drawnItems = new L.FeatureGroup().addTo(map); // Route state let currentRoute = null; // [[lon, lat], ...] parsed from GPX or null let routeLayer = null; // L.polyline for GPX preview let routePreviewLayer = null; // L.rectangle for two-point bbox preview let routePointA = null; // { lat, lon, marker } let routePointB = null; // { lat, lon, marker } let routeClipPolygon = null; // [[lng, lat], ...] optional clip in route mode // Size/scale state let scaleMode = "size"; // "size" | "scale" // ── DOM refs ─────────────────────────────────────────────────────────────── const searchInput = document.getElementById("search"); const searchBtn = document.getElementById("search-btn"); const modeControl = document.getElementById("mode-control"); const coordsDisplay = document.getElementById("coords-display"); const coordsLabel = document.getElementById("coords-label"); const coordsText = document.getElementById("coords-text"); const clearPolygon = document.getElementById("clear-polygon"); const noPinMsg = document.getElementById("no-pin-msg"); const pinOptions = document.getElementById("pin-options"); const areaOptions = document.getElementById("area-options"); const routeOptions = document.getElementById("route-options"); const radiusSlider = document.getElementById("radius"); const radiusVal = document.getElementById("radius-val"); const shapeControl = document.getElementById("shape-control"); const drawHint = document.getElementById("draw-hint"); const geojsonFile = document.getElementById("geojson-file"); const smoothSlider = document.getElementById("smooth-boundary"); const smoothVal = document.getElementById("smooth-val"); const gpxFile = document.getElementById("gpx-file"); const routeWidthSlider = document.getElementById("route-width"); const routeWidthVal = document.getElementById("route-width-val"); const spanBufferSlider = document.getElementById("span-buffer"); const spanBufferVal = document.getElementById("span-buffer-val"); const addClipPolyBtn = document.getElementById("add-clip-polygon-btn"); const sizeScaleControl = document.getElementById("size-scale-control"); const sizeInputWrap = document.getElementById("size-input-wrap"); const scaleInputWrap = document.getElementById("scale-input-wrap"); const sizeSlider = document.getElementById("size-slider"); const sizeVal = document.getElementById("size-val"); const scaleInput = document.getElementById("scale-input"); const scaleSizeHint = document.getElementById("scale-size-hint"); const exagSlider = document.getElementById("exag"); const exagVal = document.getElementById("exag-val"); const bldgExagSlider = document.getElementById("bldg-exag"); const bldgExagVal = document.getElementById("bldg-exag-val"); const colorsSelect = document.getElementById("colors"); const buildingsBox = document.getElementById("buildings"); const roofBox = document.getElementById("roof-shapes"); const racewayBox = document.getElementById("raceway"); const waterwaysBox = document.getElementById("waterways"); const noTerrainBox = document.getElementById("no-terrain"); const noTerrainBaseBox = document.getElementById("no-terrain-base"); const contourInput = document.getElementById("contour"); const waterSlider = document.getElementById("water-depth"); const waterVal = document.getElementById("water-val"); const roadSlider = document.getElementById("road-depth"); const roadVal = document.getElementById("road-val"); const borderSlider = document.getElementById("border-width"); const borderVal = document.getElementById("border-val"); const demSelect = document.getElementById("dem-source"); const demHint = document.getElementById("dem-hint"); const racewayWidthSlider = document.getElementById("raceway-width"); const racewayWidthVal = document.getElementById("raceway-width-val"); const waterwayWidthSlider = document.getElementById("waterway-width"); const waterwayWidthVal = document.getElementById("waterway-width-val"); const colorDepthSlider = document.getElementById("color-depth"); const colorDepthVal = document.getElementById("color-depth-val"); const minBuildingArea = document.getElementById("min-building-area"); const scaleDisplay = document.getElementById("scale-display"); const minFeature = document.getElementById("min-feature"); const generateBtn = document.getElementById("generate-btn"); const previewBtn = document.getElementById("preview-btn"); const statusArea = document.getElementById("status-area"); const statusIcon = document.getElementById("status-icon"); const statusMsg = document.getElementById("status-msg"); const elapsedDiv = document.getElementById("elapsed"); const downloadBtn = document.getElementById("download-btn"); // ── GPX parsing ──────────────────────────────────────────────────────────── function parseGpxText(text) { let doc; try { doc = new DOMParser().parseFromString(text, "application/xml"); } catch { alert("Failed to parse GPX file."); return null; } const parseErr = doc.querySelector("parsererror"); if (parseErr) { alert("GPX file contains XML errors."); return null; } const root = doc.documentElement; function getPoints(tagLocal) { // Try namespaced and un-namespaced const pts = []; // getElementsByTagNameNS with "*" matches any ns const els = root.getElementsByTagNameNS("*", tagLocal); for (const el of els) { const lat = el.getAttribute("lat"); const lon = el.getAttribute("lon"); if (lat === null || lon === null) continue; const latF = parseFloat(lat); const lonF = parseFloat(lon); if (isNaN(latF) || isNaN(lonF)) continue; pts.push([lonF, latF]); } return pts; } let points = getPoints("trkpt"); if (!points.length) { points = getPoints("rtept"); } if (!points.length) { alert("No track or route points found in the GPX file."); return null; } // Downsample to <=5000 points, always keeping the last if (points.length > 5000) { const step = (points.length - 1) / 4999; const sampled = []; for (let i = 0; i < 4999; i++) { sampled.push(points[Math.round(i * step)]); } sampled.push(points[points.length - 1]); points = sampled; } return points; } // ── GeoJSON import ───────────────────────────────────────────────────────── function parseGeoJsonText(text) { let geojson; try { geojson = JSON.parse(text); } catch { alert("Failed to parse GeoJSON — invalid JSON."); return; } let geometry = null; if (geojson.type === "FeatureCollection") { const feat = geojson.features.find(f => f.geometry && (f.geometry.type === "Polygon" || f.geometry.type === "MultiPolygon") ); if (!feat) { alert("No Polygon or MultiPolygon feature found in FeatureCollection."); return; } geometry = feat.geometry; } else if (geojson.type === "Feature") { geometry = geojson.geometry; } else { geometry = geojson; } if (!geometry) { alert("Could not read geometry from GeoJSON."); return; } let ring = null; if (geometry.type === "Polygon") { ring = geometry.coordinates[0]; // exterior ring, drop holes } else if (geometry.type === "MultiPolygon") { ring = geometry.coordinates[0][0]; // first polygon's exterior ring } else { alert(`Unsupported geometry type "${geometry.type}" — only Polygon and MultiPolygon are supported.`); return; } // ring is [[lng, lat], ...] in GeoJSON currentPolygon = ring.map(c => [c[0], c[1]]); // Draw on map drawnItems.clearLayers(); const latLngs = currentPolygon.map(c => [c[1], c[0]]); const layer = L.polygon(latLngs, { color: "#3b82f6", fillOpacity: 0.1, weight: 2 }); drawnItems.addLayer(layer); map.fitBounds(layer.getBounds()); drawHint.classList.add("hidden"); coordsLabel.textContent = "Area"; coordsText.textContent = `${currentPolygon.length} vertices (imported)`; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; if (drawHandler) { drawHandler.disable(); drawHandler = null; } updateScale(); } // ── Route helpers ────────────────────────────────────────────────────────── function clearRouteState() { currentRoute = null; if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; } clearRoutePreview(); clearTwoPointMarkers(); routeClipPolygon = null; drawnItems.clearLayers(); } function clearTwoPointMarkers() { if (routePointA) { map.removeLayer(routePointA.marker); routePointA = null; } if (routePointB) { map.removeLayer(routePointB.marker); routePointB = null; } clearRoutePreview(); } function clearRoutePreview() { if (routePreviewLayer) { map.removeLayer(routePreviewLayer); routePreviewLayer = null; } } function refreshRoutePreview() { clearRoutePreview(); const buf = parseFloat(spanBufferSlider.value); if (currentRoute && currentRoute.length >= 2) { // GPX: draw polyline if (routeLayer) map.removeLayer(routeLayer); const latLngs = currentRoute.map(c => [c[1], c[0]]); routeLayer = L.polyline(latLngs, { color: "#D23723", weight: 3, opacity: 0.8 }).addTo(map); // Also show buffered bbox const lons = currentRoute.map(c => c[0]); const lats = currentRoute.map(c => c[1]); const minLon = Math.min(...lons), maxLon = Math.max(...lons); const minLat = Math.min(...lats), maxLat = Math.max(...lats); const dLon = (maxLon - minLon) * buf; const dLat = (maxLat - minLat) * buf; const sw = [minLat - dLat, minLon - dLon]; const ne = [maxLat + dLat, maxLon + dLon]; routePreviewLayer = L.rectangle([sw, ne], { color: "#3b82f6", fillOpacity: 0.06, weight: 1.5, dashArray: "4 4" }).addTo(map); return; } if (routePointA && routePointB) { // Two-point: bbox rectangle const lons = [routePointA.lon, routePointB.lon]; const lats = [routePointA.lat, routePointB.lat]; const minLon = Math.min(...lons), maxLon = Math.max(...lons); const minLat = Math.min(...lats), maxLat = Math.max(...lats); const dLon = (maxLon - minLon) * buf; const dLat = (maxLat - minLat) * buf; const sw = [minLat - dLat, minLon - dLon]; const ne = [maxLat + dLat, maxLon + dLon]; routePreviewLayer = L.rectangle([sw, ne], { color: "#3b82f6", fillOpacity: 0.08, weight: 2 }).addTo(map); } } function routeSpanM() { const buf = parseFloat(spanBufferSlider.value); let lons, lats; if (currentRoute && currentRoute.length >= 2) { lons = currentRoute.map(c => c[0]); lats = currentRoute.map(c => c[1]); } else if (routePointA && routePointB) { lons = [routePointA.lon, routePointB.lon]; lats = [routePointA.lat, routePointB.lat]; } else { return null; } const minLon = Math.min(...lons), maxLon = Math.max(...lons); const minLat = Math.min(...lats), maxLat = Math.max(...lats); const latMid = (minLat + maxLat) / 2; const dy = (maxLat - minLat) * (1 + 2 * buf) * 111111; const dx = (maxLon - minLon) * (1 + 2 * buf) * 111111 * Math.cos(latMid * Math.PI / 180); return Math.max(dx, dy); } // 300 km span check (server limit) function routeSpanTooLarge() { const span = routeSpanM(); return span !== null && span > 300000; } // ── Scale display ────────────────────────────────────────────────────────── function updateScale() { let spanM = null; if (currentMode === "pin") { spanM = parseInt(radiusSlider.value, 10) * 2; } else if (currentMode === "area" && currentPolygon) { const lats = currentPolygon.map(c => c[1]); const lons = currentPolygon.map(c => c[0]); const latMid = (Math.min(...lats) + Math.max(...lats)) / 2; const dy = (Math.max(...lats) - Math.min(...lats)) * 111111; const dx = (Math.max(...lons) - Math.min(...lons)) * 111111 * Math.cos(latMid * Math.PI / 180); spanM = Math.sqrt(dx * dx + dy * dy); } else if (currentMode === "route") { spanM = routeSpanM(); } if (spanM === null) { scaleDisplay.textContent = "—"; minFeature.textContent = "—"; return; } const sizeMm = parseInt(sizeSlider.value, 10); let computedScale; if (scaleMode === "scale") { computedScale = parseInt(scaleInput.value, 10) || 25000; const modelSizeMm = (spanM * 1000) / computedScale; scaleSizeHint.textContent = `Model size ≈ ${modelSizeMm.toFixed(0)} mm`; if (modelSizeMm > 256) { scaleSizeHint.classList.add("scale-size-warn"); } else { scaleSizeHint.classList.remove("scale-size-warn"); } } else { computedScale = Math.round((spanM * 1000) / sizeMm); } scaleDisplay.textContent = computedScale.toLocaleString(); // min printable feature: span / 800 minFeature.textContent = Math.max(1, Math.round(spanM / 800)); } // ── Preview geometry helpers ─────────────────────────────────────────────── function squareBounds(lat, lon, r) { const dlat = r / 111111; const dlon = r / (111111 * Math.cos(lat * Math.PI / 180)); return [[lat - dlat, lon - dlon], [lat + dlat, lon + dlon]]; } function hexPoints(lat, lon, r) { const pts = []; for (let i = 0; i < 6; i++) { const angle = i * 2 * Math.PI / 6; const dlat = r * Math.sin(angle) / 111111; const dlon = r * Math.cos(angle) / (111111 * Math.cos(lat * Math.PI / 180)); pts.push([lat + dlat, lon + dlon]); } return pts; } function buildPreviewLayer(lat, lon, r, shape) { if (shape === "circle") { return L.circle([lat, lon], { radius: r, color: "#3b82f6", fillOpacity: 0.08, weight: 2 }); } else if (shape === "hexagon") { return L.polygon(hexPoints(lat, lon, r), { color: "#3b82f6", fillOpacity: 0.08, weight: 2 }); } else { return L.rectangle(squareBounds(lat, lon, r), { color: "#3b82f6", fillOpacity: 0.08, weight: 2 }); } } function refreshPreview() { if (currentMode !== "pin" || currentLat === null) return; const r = parseInt(radiusSlider.value, 10); if (previewLayer) { map.removeLayer(previewLayer); } previewLayer = buildPreviewLayer(currentLat, currentLon, r, currentShape); previewLayer.addTo(map); } function setPin(lat, lon) { currentLat = lat; currentLon = lon; if (pin) pin.setLatLng([lat, lon]); else pin = L.marker([lat, lon]).addTo(map); refreshPreview(); coordsLabel.textContent = "Selected location"; coordsText.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; clearPolygon.classList.add("hidden"); coordsDisplay.classList.remove("hidden"); noPinMsg.classList.add("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; updateScale(); } // ── Mode switching ───────────────────────────────────────────────────────── function clearDrawnPolygon() { currentPolygon = null; drawnItems.clearLayers(); coordsDisplay.classList.add("hidden"); drawHint.classList.remove("hidden"); generateBtn.disabled = true; generateBtn.textContent = "Draw an area first"; updateScale(); startDrawHandler(); } function startDrawHandler() { if (drawHandler) { drawHandler.disable(); drawHandler = null; } drawHandler = new L.Draw.Polygon(map, { allowIntersection: false, showArea: false, shapeOptions: { color: "#3b82f6", fillOpacity: 0.1, weight: 2 }, icon: new L.DivIcon({ iconSize: new L.Point(8, 8), className: "leaflet-div-icon leaflet-editing-icon", }), }); drawHandler.enable(); } function enterPinMode() { currentMode = "pin"; if (drawHandler) { drawHandler.disable(); drawHandler = null; } if (clipDrawHandler) { clipDrawHandler.disable(); clipDrawHandler = null; } clearRouteState(); pinOptions.classList.remove("hidden"); areaOptions.classList.add("hidden"); routeOptions.classList.add("hidden"); noPinMsg.classList.toggle("hidden", currentLat !== null); coordsDisplay.classList.toggle("hidden", currentLat === null); clearPolygon.classList.add("hidden"); if (currentLat !== null) { coordsLabel.textContent = "Selected location"; coordsText.textContent = `${currentLat.toFixed(5)}, ${currentLon.toFixed(5)}`; generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; refreshPreview(); } else { generateBtn.disabled = true; generateBtn.textContent = "Set a location first"; } updateScale(); } function enterAreaMode() { currentMode = "area"; currentLat = null; currentLon = null; if (pin) { map.removeLayer(pin); pin = null; } if (previewLayer) { map.removeLayer(previewLayer); previewLayer = null; } if (clipDrawHandler) { clipDrawHandler.disable(); clipDrawHandler = null; } clearRouteState(); pinOptions.classList.add("hidden"); areaOptions.classList.remove("hidden"); routeOptions.classList.add("hidden"); noPinMsg.classList.add("hidden"); if (currentPolygon) { drawHint.classList.add("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; } else { coordsDisplay.classList.add("hidden"); generateBtn.disabled = true; generateBtn.textContent = "Draw an area first"; drawHint.classList.remove("hidden"); startDrawHandler(); } updateScale(); } function enterRouteMode() { currentMode = "route"; currentLat = null; currentLon = null; currentPolygon = null; if (pin) { map.removeLayer(pin); pin = null; } if (previewLayer) { map.removeLayer(previewLayer); previewLayer = null; } if (drawHandler) { drawHandler.disable(); drawHandler = null; } pinOptions.classList.add("hidden"); areaOptions.classList.add("hidden"); routeOptions.classList.remove("hidden"); noPinMsg.classList.add("hidden"); coordsDisplay.classList.add("hidden"); // Restore any existing route state if (currentRoute && currentRoute.length >= 2) { coordsLabel.textContent = "Route"; coordsText.textContent = `${currentRoute.length} points`; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; refreshRoutePreview(); } else if (routePointA && routePointB) { coordsLabel.textContent = "Route"; coordsText.textContent = "Two points selected"; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; refreshRoutePreview(); } else { generateBtn.disabled = true; generateBtn.textContent = "Load GPX or pick two points"; } updateScale(); } modeControl.addEventListener("click", (e) => { const btn = e.target.closest(".seg-btn"); if (!btn) return; modeControl.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active")); btn.classList.add("active"); const mode = btn.dataset.mode; if (mode === "area") enterAreaMode(); else if (mode === "route") enterRouteMode(); else enterPinMode(); }); // ── Polygon draw events (area + route clip) ──────────────────────────────── map.on(L.Draw.Event.CREATED, (e) => { if (currentMode === "route" && clipDrawHandler) { // This is a route clip polygon clipDrawHandler.disable(); clipDrawHandler = null; addClipPolyBtn.textContent = "Add clip polygon"; const latlngs = e.layer.getLatLngs()[0]; routeClipPolygon = latlngs.map(ll => [ll.lng, ll.lat]); drawnItems.clearLayers(); drawnItems.addLayer(e.layer); return; } // Normal area draw drawnItems.clearLayers(); drawnItems.addLayer(e.layer); const latlngs = e.layer.getLatLngs()[0]; currentPolygon = latlngs.map(ll => [ll.lng, ll.lat]); drawHint.classList.add("hidden"); coordsLabel.textContent = "Area"; coordsText.textContent = `${currentPolygon.length} vertices`; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; if (drawHandler) { drawHandler.disable(); drawHandler = null; } updateScale(); }); clearPolygon.addEventListener("click", (e) => { e.preventDefault(); if (currentMode === "area") { clearDrawnPolygon(); } else if (currentMode === "route") { clearRouteState(); gpxFile.value = ""; coordsDisplay.classList.add("hidden"); generateBtn.disabled = true; generateBtn.textContent = "Load GPX or pick two points"; updateScale(); } }); // ── Map click ────────────────────────────────────────────────────────────── map.on("click", (e) => { if (currentMode === "pin") { setPin(e.latlng.lat, e.latlng.lng); return; } if (currentMode === "route" && !currentRoute) { // Two-point mode: place A then B if (!routePointA) { const marker = L.marker([e.latlng.lat, e.latlng.lng], { icon: L.divIcon({ html: "A", className: "route-point-icon", iconSize: [16, 16] }) }).addTo(map); routePointA = { lat: e.latlng.lat, lon: e.latlng.lng, marker }; } else if (!routePointB) { const marker = L.marker([e.latlng.lat, e.latlng.lng], { icon: L.divIcon({ html: "B", className: "route-point-icon", iconSize: [16, 16] }) }).addTo(map); routePointB = { lat: e.latlng.lat, lon: e.latlng.lng, marker }; coordsLabel.textContent = "Route"; coordsText.textContent = "Two points selected"; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; refreshRoutePreview(); updateScale(); } } }); // ── GPX file input ───────────────────────────────────────────────────────── gpxFile.addEventListener("change", () => { const file = gpxFile.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const points = parseGpxText(ev.target.result); if (!points) { gpxFile.value = ""; return; } currentRoute = points; // Clear two-point markers since GPX takes precedence clearTwoPointMarkers(); coordsLabel.textContent = "Route"; coordsText.textContent = `${points.length} points (GPX)`; clearPolygon.classList.remove("hidden"); coordsDisplay.classList.remove("hidden"); if (routeSpanTooLarge()) { alert("Route span exceeds 300 km — please use a shorter section."); clearRouteState(); gpxFile.value = ""; coordsDisplay.classList.add("hidden"); generateBtn.disabled = true; generateBtn.textContent = "Load GPX or pick two points"; return; } generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; refreshRoutePreview(); // Fit map to route if (routeLayer) map.fitBounds(routeLayer.getBounds(), { padding: [20, 20] }); updateScale(); }; reader.readAsText(file); }); // ── GeoJSON file input ───────────────────────────────────────────────────── geojsonFile.addEventListener("change", () => { const file = geojsonFile.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { parseGeoJsonText(ev.target.result); }; reader.readAsText(file); }); // ── Add clip polygon (route mode) ───────────────────────────────────────── addClipPolyBtn.addEventListener("click", () => { if (clipDrawHandler) { clipDrawHandler.disable(); clipDrawHandler = null; addClipPolyBtn.textContent = "Add clip polygon"; return; } clipDrawHandler = new L.Draw.Polygon(map, { allowIntersection: false, showArea: false, shapeOptions: { color: "#f59e0b", fillOpacity: 0.1, weight: 2 }, icon: new L.DivIcon({ iconSize: new L.Point(8, 8), className: "leaflet-div-icon leaflet-editing-icon", }), }); clipDrawHandler.enable(); addClipPolyBtn.textContent = "Cancel clip polygon"; }); // ── Size / scale toggle ──────────────────────────────────────────────────── sizeScaleControl.addEventListener("click", (e) => { const btn = e.target.closest(".seg-btn"); if (!btn) return; sizeScaleControl.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active")); btn.classList.add("active"); scaleMode = btn.dataset.sizemode; if (scaleMode === "scale") { sizeInputWrap.classList.add("hidden"); scaleInputWrap.classList.remove("hidden"); } else { sizeInputWrap.classList.remove("hidden"); scaleInputWrap.classList.add("hidden"); scaleSizeHint.textContent = ""; scaleSizeHint.classList.remove("scale-size-warn"); } updateScale(); }); // ── Slider / control events ──────────────────────────────────────────────── radiusSlider.addEventListener("input", () => { radiusVal.textContent = radiusSlider.value; refreshPreview(); updateScale(); }); exagSlider.addEventListener("input", () => { exagVal.textContent = parseFloat(exagSlider.value).toFixed(1); }); bldgExagSlider.addEventListener("input", () => { const v = parseFloat(bldgExagSlider.value); bldgExagVal.textContent = v === 0 ? "match terrain" : `${v.toFixed(1)}×`; }); waterSlider.addEventListener("input", () => { waterVal.textContent = parseFloat(waterSlider.value).toFixed(1); }); roadSlider.addEventListener("input", () => { roadVal.textContent = parseFloat(roadSlider.value).toFixed(2); }); borderSlider.addEventListener("input", () => { borderVal.textContent = parseInt(borderSlider.value, 10); }); smoothSlider.addEventListener("input", () => { smoothVal.textContent = parseInt(smoothSlider.value, 10); }); routeWidthSlider.addEventListener("input", () => { routeWidthVal.textContent = parseFloat(routeWidthSlider.value).toFixed(1); }); spanBufferSlider.addEventListener("input", () => { spanBufferVal.textContent = parseFloat(spanBufferSlider.value).toFixed(2); refreshRoutePreview(); updateScale(); }); sizeSlider.addEventListener("input", () => { sizeVal.textContent = parseInt(sizeSlider.value, 10); updateScale(); }); scaleInput.addEventListener("input", () => { updateScale(); }); racewayWidthSlider.addEventListener("input", () => { racewayWidthVal.textContent = parseFloat(racewayWidthSlider.value).toFixed(1); }); waterwayWidthSlider.addEventListener("input", () => { waterwayWidthVal.textContent = parseFloat(waterwayWidthSlider.value).toFixed(1); }); colorDepthSlider.addEventListener("input", () => { colorDepthVal.textContent = parseFloat(colorDepthSlider.value).toFixed(1); }); // ── Shape selector ───────────────────────────────────────────────────────── shapeControl.addEventListener("click", (e) => { const btn = e.target.closest(".seg-btn"); if (!btn) return; shapeControl.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active")); btn.classList.add("active"); currentShape = btn.dataset.shape; refreshPreview(); }); // ── no_terrain checkbox guard ────────────────────────────────────────────── noTerrainBox.addEventListener("change", () => { if (noTerrainBox.checked) { buildingsBox.checked = true; buildingsBox.disabled = true; noTerrainBaseBox.checked = false; noTerrainBaseBox.disabled = true; } else { buildingsBox.disabled = false; noTerrainBaseBox.disabled = false; } }); // ── Nominatim search ─────────────────────────────────────────────────────── async function doSearch() { const q = searchInput.value.trim(); if (!q) return; searchBtn.disabled = true; searchBtn.textContent = "…"; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`; const res = await fetch(url, { headers: { "Accept-Language": "en" } }); const data = await res.json(); if (!data.length) { alert(`No results for "${q}"`); return; } const { lat, lon, boundingbox } = data[0]; const llat = parseFloat(lat), llon = parseFloat(lon); if (currentMode === "pin") setPin(llat, llon); if (boundingbox) { map.fitBounds([[parseFloat(boundingbox[0]), parseFloat(boundingbox[2])], [parseFloat(boundingbox[1]), parseFloat(boundingbox[3])]], { maxZoom: 14 }); } else { map.setView([llat, llon], 13); } } catch { alert("Search failed — check your connection."); } finally { searchBtn.disabled = false; searchBtn.textContent = "Go"; } } searchBtn.addEventListener("click", doSearch); searchInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); }); // ── Job polling ──────────────────────────────────────────────────────────── let pollTimer = null; function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } function showStatus(icon, msg, elapsed, showDownload, jobId) { statusArea.classList.remove("hidden"); statusIcon.innerHTML = icon; statusMsg.textContent = msg; elapsedDiv.textContent = elapsed || ""; if (showDownload && jobId) { downloadBtn.href = `/api/jobs/${jobId}/download`; downloadBtn.classList.remove("hidden"); previewBtn.classList.remove("hidden"); previewBtn.onclick = () => window.openPreview && window.openPreview(jobId); } else { downloadBtn.classList.add("hidden"); previewBtn.classList.add("hidden"); } } async function pollStatus(jobId) { try { const res = await fetch(`/api/jobs/${jobId}`); if (!res.ok) { stopPolling(); showStatus("❌", "Job lost (server restarted?)", ""); return; } const data = await res.json(); const elapsed = data.elapsed_s != null ? `${Math.round(data.elapsed_s)}s elapsed` : ""; if (data.status === "queued") { showStatus('', "Queued…", ""); } else if (data.status === "running") { showStatus('', "Generating…", elapsed); } else if (data.status === "ready") { stopPolling(); showStatus("✅", "Ready to download", elapsed, true, jobId); generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; } else if (data.status === "error") { stopPolling(); showStatus("❌", "Error", "", false); elapsedDiv.textContent = data.error || "Unknown error"; generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; } } catch { // network blip — keep polling } } // ── Generate ─────────────────────────────────────────────────────────────── generateBtn.addEventListener("click", async () => { // Guard: mode-specific readiness if (currentMode === "pin" && currentLat == null) return; if (currentMode === "area" && !currentPolygon) return; if (currentMode === "route" && !currentRoute && !(routePointA && routePointB)) return; // Guard: span limit if (currentMode === "route" && routeSpanTooLarge()) { alert("Route span exceeds 300 km — please use a shorter route or reduce the span buffer."); return; } // Guard: no_terrain + route if (noTerrainBox.checked && currentMode === "route") { alert("Skip terrain cannot be combined with Route mode."); return; } stopPolling(); generateBtn.disabled = true; generateBtn.textContent = "Submitting…"; downloadBtn.classList.add("hidden"); const contourVal = contourInput.value ? parseFloat(contourInput.value) : null; const bldgExagRaw = parseFloat(bldgExagSlider.value); const minBldgVal = minBuildingArea.value !== "" ? parseFloat(minBuildingArea.value) : null; // Shared params common to all modes const shared = { terrain_exag: parseFloat(exagSlider.value), colors: parseInt(colorsSelect.value, 10), no_buildings: !buildingsBox.checked, roof_shapes: roofBox.checked, raceway: racewayBox.checked, waterways: waterwaysBox.checked, contour_interval: contourVal, building_exag: bldgExagRaw > 0 ? bldgExagRaw : null, water_depth_mm: parseFloat(waterSlider.value), road_depth_mm: parseFloat(roadSlider.value), border_width_mm: parseInt(borderSlider.value, 10), dem_source: demSelect.value, raceway_width: parseFloat(racewayWidthSlider.value), waterway_width: parseFloat(waterwayWidthSlider.value), color_depth_mm: parseFloat(colorDepthSlider.value), min_building_area: minBldgVal, no_terrain: noTerrainBox.checked, no_terrain_base: noTerrainBaseBox.checked, }; // Size or scale — send only the active one if (scaleMode === "scale") { shared.scale = parseInt(scaleInput.value, 10) || null; } else { shared.size = parseInt(sizeSlider.value, 10); } let body; if (currentMode === "route" && currentRoute) { // GPX route mode — optionally with a clip polygon body = { route: currentRoute, route_width: parseFloat(routeWidthSlider.value), span_buffer: parseFloat(spanBufferSlider.value), ...(routeClipPolygon ? { polygon: routeClipPolygon, smooth_boundary: parseInt(smoothSlider.value, 10), } : {}), ...shared, }; } else if (currentMode === "route" && routePointA && routePointB) { // Two-point route mode body = { lat: routePointA.lat, lon: routePointA.lon, to_lat: routePointB.lat, to_lon: routePointB.lon, span_buffer: parseFloat(spanBufferSlider.value), ...shared, }; } else if (currentMode === "area") { body = { polygon: currentPolygon, smooth_boundary: parseInt(smoothSlider.value, 10), ...shared, }; } else { // Pin mode body = { lat: currentLat, lon: currentLon, radius: parseInt(radiusSlider.value, 10), shape: currentShape, ...shared, }; } try { const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.status === 429) { generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; showStatus("⚠️", "Rate limit reached", "Try again in an hour."); return; } if (res.status === 503) { generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; showStatus("⏳", "Server busy", "Try again shortly."); return; } if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${res.status}`); } const { job_id } = await res.json(); showStatus('', "Queued…", ""); pollTimer = setInterval(() => pollStatus(job_id), 2000); } catch (err) { generateBtn.disabled = false; generateBtn.textContent = "Generate terrain map"; showStatus("❌", "Submission failed", err.message); } }); // ── Init ─────────────────────────────────────────────────────────────────── updateScale(); // Default location: Majaz al Bab (Medjez el Bab), Béja Governorate, Tunisia setPin(36.6484, 9.6146); (async () => { try { const res = await fetch("/api/dem-sources"); const data = await res.json(); if (!data.key_configured) { demHint.classList.remove("hidden"); Array.from(demSelect.options).forEach(opt => { const src = data.sources.find(s => s.id === opt.value); if (src && !src.available) { opt.disabled = true; opt.text += " (API key not configured)"; } }); } } catch { /* leave defaults */ } })();