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 = `
| ${message} |
`;
}
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 = `
${id} |
${ADM1_TH || "-"} |
${ADM2_TH || "-"} |
${ADM3_TH || "-"} |
${formatNumber(area_m2, 2)} |
${formatNumber(area_rai, 2)} |
`;
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 }) => `${label}: ${value ?? "-"}
`)
.join("");
return ``;
}
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");
}
}