Spaces:
Running
Running
| const state = { | |
| adm1: "", | |
| adm2: "", | |
| adm3: "", | |
| }; | |
| const elements = { | |
| adm1: document.getElementById("adm1Select"), | |
| adm2: document.getElementById("adm2Select"), | |
| adm3: document.getElementById("adm3Select"), | |
| tableBody: document.getElementById("featureTable"), | |
| totalAreaRai: document.getElementById("totalAreaRai"), | |
| itemCount: document.getElementById("itemCount"), | |
| averageAreaRai: document.getElementById("averageAreaRai"), | |
| loadingOverlay: document.getElementById("loadingOverlay"), | |
| clearButton: document.getElementById("clearFilters"), | |
| }; | |
| declareLoadingRow("Loading data..."); | |
| setLoading(true); | |
| let allFeatures = []; | |
| let map; | |
| let layerControl; | |
| let adminLayer; | |
| let palmLayer; | |
| let layerLookup = new Map(); | |
| let activeFeatureId = null; | |
| let activeRowId = null; | |
| initMap(); | |
| loadData(); | |
| attachEventListeners(); | |
| function declareLoadingRow(message) { | |
| elements.tableBody.innerHTML = `<tr><td colspan="6">${message}</td></tr>`; | |
| } | |
| function initMap() { | |
| map = L.map("map", { | |
| preferCanvas: true, | |
| zoomControl: true, | |
| }); | |
| const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | |
| maxZoom: 19, | |
| attribution: "© OpenStreetMap contributors", | |
| }).addTo(map); | |
| const googleHybrid = L.tileLayer( | |
| "https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", | |
| { | |
| maxZoom: 21, | |
| subdomains: ["mt0", "mt1", "mt2", "mt3"], | |
| attribution: "© Google", | |
| } | |
| ); | |
| layerControl = L.control.layers( | |
| { | |
| "OpenStreetMap": osm, | |
| "Google Hybrid": googleHybrid, | |
| }, | |
| {}, | |
| { collapsed: false } | |
| ).addTo(map); | |
| if (L.control && typeof L.control.measure === "function") { | |
| L.control | |
| .measure({ | |
| position: "topleft", | |
| primaryLengthUnit: "kilometers", | |
| secondaryLengthUnit: "meters", | |
| primaryAreaUnit: "hectares", | |
| secondaryAreaUnit: "sqmeters", | |
| }) | |
| .addTo(map); | |
| } else { | |
| console.warn("Leaflet measure plugin is unavailable; measure control disabled."); | |
| } | |
| window.addEventListener("resize", () => map.invalidateSize()); | |
| map.createPane("adminPane"); | |
| map.createPane("palmPane"); | |
| map.getPane("adminPane").style.zIndex = 410; | |
| map.getPane("palmPane").style.zIndex = 420; | |
| adminLayer = L.geoJSON(null, { | |
| pane: "adminPane", | |
| style: { | |
| color: "#ffbf00ff", | |
| weight: 0.8, | |
| fillOpacity: 0, | |
| }, | |
| }).addTo(map); | |
| palmLayer = L.geoJSON(null, { | |
| pane: "palmPane", | |
| style: palmDefaultStyle, | |
| onEachFeature: onPalmFeature, | |
| }).addTo(map); | |
| layerControl.addOverlay(adminLayer, "Administrative Boundaries"); | |
| layerControl.addOverlay(palmLayer, "Palm Predictions"); | |
| } | |
| async function loadData() { | |
| try { | |
| setLoading(true); | |
| const [palmResponse, adminResponse] = await Promise.all([ | |
| fetch("palm_pred_all_admin.geojson"), | |
| fetch("admin_bnd.geojson"), | |
| ]); | |
| if (!palmResponse.ok || !adminResponse.ok) { | |
| throw new Error("Unable to load GeoJSON files."); | |
| } | |
| const [palmData, adminData] = await Promise.all([ | |
| palmResponse.json(), | |
| adminResponse.json(), | |
| ]); | |
| allFeatures = palmData.features; | |
| adminLayer.addData(adminData); | |
| initializeSelects(); | |
| updateDashboard({ fitBounds: true }); | |
| setLoading(false); | |
| } catch (error) { | |
| console.error(error); | |
| declareLoadingRow("Failed to load data. Check console for details."); | |
| setLoading(false); | |
| } | |
| } | |
| function attachEventListeners() { | |
| elements.adm1.addEventListener("change", () => { | |
| state.adm1 = elements.adm1.value; | |
| state.adm2 = ""; | |
| state.adm3 = ""; | |
| const baseForAdm2 = state.adm1 | |
| ? allFeatures.filter((feature) => feature.properties.ADM1_TH === state.adm1) | |
| : allFeatures; | |
| const baseForAdm3 = state.adm1 ? baseForAdm2 : allFeatures; | |
| populateSelect(elements.adm2, baseForAdm2, "ADM2_TH", Boolean(baseForAdm2.length)); | |
| populateSelect(elements.adm3, baseForAdm3, "ADM3_TH", Boolean(baseForAdm3.length)); | |
| updateDashboard({ fitBounds: true }); | |
| }); | |
| elements.adm2.addEventListener("change", () => { | |
| state.adm2 = elements.adm2.value; | |
| state.adm3 = ""; | |
| const baseForAdm3 = allFeatures.filter((feature) => { | |
| if (state.adm1 && feature.properties.ADM1_TH !== state.adm1) return false; | |
| if (state.adm2 && feature.properties.ADM2_TH !== state.adm2) return false; | |
| return true; | |
| }); | |
| populateSelect(elements.adm3, baseForAdm3, "ADM3_TH", Boolean(baseForAdm3.length)); | |
| updateDashboard({ fitBounds: true }); | |
| }); | |
| elements.adm3.addEventListener("change", () => { | |
| state.adm3 = elements.adm3.value; | |
| updateDashboard({ fitBounds: true }); | |
| }); | |
| if (elements.clearButton) { | |
| elements.clearButton.addEventListener("click", () => { | |
| state.adm1 = ""; | |
| state.adm2 = ""; | |
| state.adm3 = ""; | |
| elements.adm1.value = ""; | |
| elements.adm2.value = ""; | |
| elements.adm3.value = ""; | |
| elements.adm2.disabled = elements.adm2.options.length <= 1; | |
| elements.adm3.disabled = elements.adm3.options.length <= 1; | |
| updateDashboard({ fitBounds: true }); | |
| }); | |
| } | |
| } | |
| function initializeSelects() { | |
| populateSelect(elements.adm1, allFeatures, "ADM1_TH", Boolean(allFeatures.length)); | |
| populateSelect(elements.adm2, allFeatures, "ADM2_TH", Boolean(allFeatures.length)); | |
| populateSelect(elements.adm3, allFeatures, "ADM3_TH", Boolean(allFeatures.length)); | |
| } | |
| function populateSelect(select, features, propertyKey, enable) { | |
| const selectedValue = select.value; | |
| const values = getUniqueValues(features, propertyKey); | |
| select.innerHTML = ""; | |
| const defaultOption = document.createElement("option"); | |
| defaultOption.value = ""; | |
| defaultOption.textContent = "ทั้งหมด"; | |
| select.appendChild(defaultOption); | |
| values | |
| .sort((a, b) => a.localeCompare(b, "th", { sensitivity: "base" })) | |
| .forEach((value) => { | |
| const option = document.createElement("option"); | |
| option.value = value; | |
| option.textContent = value; | |
| select.appendChild(option); | |
| }); | |
| select.disabled = !enable || values.length === 0; | |
| if (!select.disabled && values.includes(selectedValue)) { | |
| select.value = selectedValue; | |
| } else { | |
| select.value = ""; | |
| } | |
| } | |
| function getUniqueValues(features, propertyKey) { | |
| const set = new Set(); | |
| features.forEach((feature) => { | |
| const value = feature.properties[propertyKey]; | |
| if (value || value === 0) { | |
| set.add(value); | |
| } | |
| }); | |
| return Array.from(set); | |
| } | |
| function applyFilters(features) { | |
| return features.filter((feature) => { | |
| const { ADM1_TH, ADM2_TH, ADM3_TH } = feature.properties; | |
| if (state.adm1 && ADM1_TH !== state.adm1) return false; | |
| if (state.adm2 && ADM2_TH !== state.adm2) return false; | |
| if (state.adm3 && ADM3_TH !== state.adm3) return false; | |
| return true; | |
| }); | |
| } | |
| function updateDashboard({ fitBounds } = { fitBounds: false }) { | |
| if (!allFeatures.length) return; | |
| const filtered = applyFilters(allFeatures); | |
| updateSummary(filtered); | |
| updatePalmLayer(filtered, { fitBounds }); | |
| renderTable(filtered); | |
| } | |
| function updateSummary(features) { | |
| const totals = features.reduce( | |
| (acc, feature) => { | |
| const { area_rai } = feature.properties; | |
| acc.area_rai += Number(area_rai) || 0; | |
| return acc; | |
| }, | |
| { area_rai: 0 } | |
| ); | |
| const totalAreaRai = totals.area_rai; | |
| const count = features.length; | |
| const averageAreaRai = count ? totalAreaRai / count : 0; | |
| elements.totalAreaRai.textContent = formatNumber(totalAreaRai, 2); | |
| elements.itemCount.textContent = formatNumber(count, 0); | |
| elements.averageAreaRai.textContent = formatNumber(averageAreaRai, 2); | |
| } | |
| function renderTable(features) { | |
| elements.tableBody.innerHTML = ""; | |
| activeRowId = null; | |
| if (!features.length) { | |
| declareLoadingRow("No features match the selected filters."); | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| features.forEach((feature) => { | |
| const { id, ADM1_TH, ADM2_TH, ADM3_TH, area_m2, area_rai } = feature.properties; | |
| const row = document.createElement("tr"); | |
| row.dataset.featureId = id; | |
| row.innerHTML = ` | |
| <td>${id}</td> | |
| <td>${ADM1_TH || "-"}</td> | |
| <td>${ADM2_TH || "-"}</td> | |
| <td>${ADM3_TH || "-"}</td> | |
| <td>${formatNumber(area_m2, 2)}</td> | |
| <td>${formatNumber(area_rai, 2)}</td> | |
| `; | |
| row.addEventListener("click", () => { | |
| setActiveRow(id); | |
| highlightMapFeature(id, { adjustView: true, openPopup: true }); | |
| }); | |
| fragment.appendChild(row); | |
| }); | |
| elements.tableBody.appendChild(fragment); | |
| // Highlight the first feature by default for quick context. | |
| const firstId = features[0].properties.id; | |
| setActiveRow(firstId); | |
| highlightMapFeature(firstId, { adjustView: false, openPopup: false }); | |
| } | |
| function palmDefaultStyle() { | |
| return { | |
| color: "#ffffffff", | |
| weight: 0.6, | |
| fillColor: "#649e66ff", | |
| fillOpacity: 0.7, | |
| }; | |
| } | |
| function palmHighlightStyle() { | |
| return { | |
| color: "#ff8800", | |
| weight: 1.2, | |
| fillColor: "#ffe0b2", | |
| fillOpacity: 0.65, | |
| }; | |
| } | |
| function onPalmFeature(feature, layer) { | |
| const { id } = feature.properties; | |
| layerLookup.set(id, layer); | |
| const popupHtml = buildPopupContent(feature.properties); | |
| layer.bindPopup(popupHtml); | |
| layer.on("click", () => { | |
| setActiveRow(id); | |
| highlightMapFeature(id, { adjustView: false, openPopup: false }); | |
| layer.openPopup(); | |
| }); | |
| } | |
| function updatePalmLayer(features, { fitBounds }) { | |
| layerLookup.clear(); | |
| palmLayer.clearLayers(); | |
| activeFeatureId = null; | |
| if (!features.length) { | |
| return; | |
| } | |
| palmLayer.addData(features); | |
| if (fitBounds) { | |
| const bounds = palmLayer.getBounds(); | |
| if (bounds.isValid()) { | |
| map.fitBounds(bounds, { padding: [20, 20] }); | |
| } | |
| } | |
| } | |
| function highlightMapFeature(id, { adjustView = false, openPopup = false } = {}) { | |
| if (activeFeatureId && layerLookup.has(activeFeatureId)) { | |
| const previousLayer = layerLookup.get(activeFeatureId); | |
| palmLayer.resetStyle(previousLayer); | |
| previousLayer.closePopup(); | |
| } | |
| activeFeatureId = null; | |
| const layer = layerLookup.get(id); | |
| if (!layer) return; | |
| activeFeatureId = id; | |
| layer.setStyle(palmHighlightStyle()); | |
| layer.bringToFront(); | |
| if (adjustView) { | |
| const bounds = layer.getBounds(); | |
| if (bounds.isValid()) { | |
| map.fitBounds(bounds, { maxZoom: 16, padding: [20, 20] }); | |
| } | |
| } | |
| if (openPopup) { | |
| layer.openPopup(); | |
| } | |
| } | |
| function setActiveRow(id) { | |
| if (activeRowId !== null) { | |
| const previousRow = elements.tableBody.querySelector( | |
| `tr[data-feature-id="${activeRowId}"]` | |
| ); | |
| if (previousRow) { | |
| previousRow.classList.remove("active"); | |
| } | |
| } | |
| const row = elements.tableBody.querySelector(`tr[data-feature-id="${id}"]`); | |
| if (!row) { | |
| activeRowId = null; | |
| return; | |
| } | |
| activeRowId = id; | |
| row.classList.add("active"); | |
| row.scrollIntoView({ block: "nearest" }); | |
| } | |
| function buildPopupContent(properties) { | |
| const rows = [ | |
| { label: "ADM1_TH", value: properties.ADM1_TH }, | |
| { label: "ADM2_TH", value: properties.ADM2_TH }, | |
| { label: "ADM3_TH", value: properties.ADM3_TH }, | |
| { label: "Area (m2)", value: formatNumber(properties.area_m2, 2) }, | |
| { label: "Area (Rai)", value: formatNumber(properties.area_rai, 2) }, | |
| ]; | |
| const details = rows | |
| .map(({ label, value }) => `<div><strong>${label}:</strong> ${value ?? "-"}</div>`) | |
| .join(""); | |
| return `<div class="popup-content"><h3>Palm Plot #${properties.id}</h3>${details}</div>`; | |
| } | |
| function formatNumber(value, fractionDigits = 0) { | |
| if (value === null || value === undefined || Number.isNaN(Number(value))) { | |
| return "-"; | |
| } | |
| return new Intl.NumberFormat("en-US", { | |
| minimumFractionDigits: 0, | |
| maximumFractionDigits: fractionDigits, | |
| }).format(Number(value)); | |
| } | |
| function setLoading(isLoading) { | |
| document.body.classList.toggle("is-loading", Boolean(isLoading)); | |
| if (elements.loadingOverlay) { | |
| elements.loadingOverlay.setAttribute("aria-hidden", isLoading ? "false" : "true"); | |
| } | |
| } | |