Files
WEBGIS_Project/frontend/poverty-map.html
T
2026-06-11 13:04:28 +07:00

512 lines
22 KiB
HTML

<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGIS Poverty Mapping Gereja</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
:root {
--bg-dark: #0f172a;
--bg-panel: #111827;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border: rgba(148, 163, 184, 0.24);
--accent: #7c3aed;
--accent-2: #06b6d4;
--red: #dc2626;
--orange: #f97316;
--yellow: #facc15;
--green: #22c55e;
--radius: 22px;
--shadow: 0 22px 60px rgba(15, 23, 42, 0.32);
}
* { box-sizing: border-box; }
body {
margin: 0;
overflow: hidden;
font-family: "Inter", system-ui, sans-serif;
color: var(--text-main);
background: linear-gradient(135deg, #130f2e 0%, #111827 54%, #312e81 100%);
}
button, input, select { font: inherit; }
a { color: inherit; text-decoration: none; }
.app { display: grid; grid-template-columns: 350px minmax(0, 1fr); min-height: 100vh; }
.sidebar {
position: relative;
z-index: 900;
height: 100vh;
overflow-y: auto;
padding: 22px 18px;
background:
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, .3), transparent 30%),
linear-gradient(180deg, var(--bg-dark), var(--bg-panel));
box-shadow: var(--shadow);
border-right: 1px solid rgba(255,255,255,.08);
}
.brand { margin-bottom: 18px; }
.brand small { color: #a5b4fc; font-weight: 800; text-transform: uppercase; }
.brand h1 { margin: 8px 0 8px; font-size: 1.55rem; line-height: 1.12; }
.brand p { margin: 0; color: #cbd5e1; line-height: 1.45; font-size: .9rem; }
.panel {
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.07);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 14px;
backdrop-filter: blur(12px);
}
.panel h2 { margin: 0 0 12px; font-size: .95rem; }
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stat { background: rgba(15,23,42,.46); border: 1px solid var(--border); border-radius: 18px; padding: 12px; }
.stat span { display: block; color: var(--text-muted); font-size: .73rem; font-weight: 800; }
.stat strong { display: block; margin-top: 6px; font-size: 1.32rem; }
.field { display: grid; gap: 7px; margin-bottom: 10px; color: #e5e7eb; font-size: .82rem; font-weight: 800; }
input, select {
width: 100%;
border: 1px solid var(--border);
border-radius: 15px;
padding: 11px 12px;
outline: none;
color: #f8fafc;
background: rgba(15, 23, 42, .62);
}
input:focus, select:focus { border-color: #a78bfa; box-shadow: 0 0 0 4px rgba(124,58,237,.16); }
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid var(--border);
background: rgba(15,23,42,.42);
border-radius: 16px;
padding: 11px 12px;
margin-top: 8px;
}
.switch input { width: auto; }
.legend { display: grid; gap: 9px; color: #e5e7eb; font-size: .84rem; }
.legend span { display: flex; align-items: center; gap: 9px; }
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 4px rgba(255,255,255,.08); }
.list { display: grid; gap: 9px; max-height: 360px; overflow: auto; padding-right: 2px; }
.family-item {
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(15,23,42,.42);
padding: 11px;
cursor: pointer;
}
.family-item:hover { border-color: rgba(167,139,250,.72); background: rgba(124,58,237,.18); }
.family-item strong { display: block; font-size: .9rem; }
.family-item small { color: #cbd5e1; line-height: 1.5; }
.badge { display: inline-flex; border-radius: 999px; padding: 4px 9px; font-size: .7rem; font-weight: 900; margin-top: 7px; }
.sangat { background: #fee2e2; color: #7f1d1d; }
.butuh { background: #ffedd5; color: #7c2d12; }
.rentan { background: #fef9c3; color: #713f12; }
.mampu { background: #dcfce7; color: #14532d; }
#map { height: 100vh; width: 100%; background: #eef2f7; }
.map-top {
position: absolute;
z-index: 800;
top: 18px;
right: 18px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.map-card {
border: 1px solid rgba(15,23,42,.12);
background: rgba(255,255,255,.92);
color: #0f172a;
border-radius: 18px;
box-shadow: var(--shadow);
padding: 12px 14px;
backdrop-filter: blur(14px);
}
.map-card strong { display: block; font-size: 1.05rem; }
.map-card span { color: #64748b; font-size: .78rem; font-weight: 700; }
.mobile-toggle {
position: fixed;
display: none;
z-index: 1200;
top: 14px;
left: 14px;
width: 44px;
height: 44px;
border: 0;
border-radius: 14px;
background: #0f172a;
color: white;
box-shadow: var(--shadow);
cursor: pointer;
}
.leaflet-popup-content-wrapper { border-radius: 20px; overflow: hidden; padding: 0; }
.leaflet-popup-content { margin: 0; width: 300px !important; }
.popup { color: #0f172a; padding: 16px; }
.popup h3 { margin: 0 0 6px; font-size: 1.02rem; }
.popup p { margin: 0 0 10px; color: #475569; line-height: 1.4; }
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 10px 0; }
.popup-grid div { border-radius: 12px; background: #f8fafc; padding: 9px; }
.popup-grid span { display: block; color: #64748b; font-size: .7rem; font-weight: 800; }
.popup-grid strong { display: block; margin-top: 3px; font-size: .86rem; }
.history { border-top: 1px solid #e5e7eb; margin-top: 10px; padding-top: 10px; }
.history ul { margin: 8px 0 0 18px; padding: 0; color: #334155; }
.toast {
position: fixed;
right: 22px;
bottom: 22px;
background: #0f172a;
color: white;
padding: 13px 16px;
border-radius: 14px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: .2s ease;
z-index: 2000;
}
.toast.show { opacity: 1; transform: translateY(0); }
.ibadah-legend { display: none; }
.ibadah-legend.active { display: block; }
.ibadah-map-marker {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 999px;
color: white;
border: 3px solid white;
box-shadow: 0 10px 24px rgba(15, 23, 42, .28);
font-size: 15px;
}
@media (max-width: 880px) {
body { overflow: hidden; }
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(88vw, 350px);
transform: translateX(-104%);
transition: transform .24s ease;
}
.sidebar.open { transform: translateX(0); }
.mobile-toggle { display: block; }
.map-top { top: 70px; left: 14px; right: 14px; }
}
</style>
</head>
<body>
<button class="mobile-toggle" id="mobileToggle" aria-label="Menu"></button>
<div class="toast" id="toast"></div>
<div class="app">
<aside class="sidebar" id="sidebar">
<div class="brand">
<small>Gereja Peduli</small>
<h1>WebGIS Poverty Mapping</h1>
<p>Peta prioritas keluarga penerima bantuan berbasis SAW, terpusat, dan mudah dipantau.</p>
</div>
<section class="panel">
<h2>Statistik</h2>
<div class="stats">
<div class="stat"><span>Total keluarga</span><strong id="statTotal">0</strong></div>
<div class="stat"><span>Prioritas tinggi</span><strong id="statHigh">0</strong></div>
<div class="stat"><span>Tersalurkan</span><strong id="statAid">Rp0</strong></div>
<div class="stat"><span>Wilayah</span><strong id="statArea">0</strong></div>
</div>
</section>
<section class="panel">
<h2>Filter Peta</h2>
<label class="field">Kategori
<select id="categoryFilter">
<option value="">Semua Kategori</option>
<option value="Sangat Membutuhkan">Sangat Membutuhkan</option>
<option value="Membutuhkan">Membutuhkan</option>
<option value="Rentan">Rentan</option>
<option value="Relatif Mampu">Relatif Mampu</option>
</select>
</label>
<label class="field">Pencarian Keluarga
<input id="searchInput" placeholder="Nama, alamat, atau wilayah" />
</label>
<label class="switch"><span>Heatmap persebaran</span><input id="heatToggle" type="checkbox" checked /></label>
<label class="switch"><span>⛪ Tempat Ibadah</span><input id="ibadahToggle" type="checkbox" /></label>
</section>
<section class="panel">
<h2>Legenda</h2>
<div class="legend">
<span><i class="dot" style="background: var(--red)"></i>Sangat Membutuhkan</span>
<span><i class="dot" style="background: var(--orange)"></i>Membutuhkan</span>
<span><i class="dot" style="background: var(--yellow)"></i>Rentan</span>
<span><i class="dot" style="background: var(--green)"></i>Relatif Mampu</span>
</div>
</section>
<section class="panel ibadah-legend" id="ibadahLegendPanel">
<h2>Legenda Tempat Ibadah</h2>
<div class="legend">
<span><i class="dot" style="background:#7c3aed"></i>Gereja Katolik</span>
<span><i class="dot" style="background:#2563eb"></i>GPIB</span>
<span><i class="dot" style="background:#0891b2"></i>GKI</span>
<span><i class="dot" style="background:#16a34a"></i>HKBP</span>
<span><i class="dot" style="background:#ea580c"></i>Pentakosta</span>
</div>
</section>
<section class="panel">
<h2>Statistik Wilayah</h2>
<div id="areaStats"></div>
</section>
<section class="panel">
<h2>Daftar Keluarga</h2>
<div class="list" id="familyList"></div>
</section>
<section class="panel">
<a href="/admin-poverty.html">Buka Admin Poverty</a><br />
<a href="/">Kembali ke WebGIS SPBU</a>
</section>
</aside>
<main style="position: relative">
<div class="map-top">
<div class="map-card"><span>Ranking tertinggi</span><strong id="topFamily">-</strong></div>
<div class="map-card"><span>Rata-rata skor</span><strong id="avgScore">0.000</strong></div>
</div>
<div id="map"></div>
</main>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = "/api/poverty";
const state = { rows: [], dashboard: null, markers: new Map(), ibadah: [], ibadahLoaded: false };
const map = L.map("map", { zoomControl: true }).setView([-0.0263, 109.3425], 12);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "&copy; OpenStreetMap" }).addTo(map);
const markerLayer = L.layerGroup().addTo(map);
const heatLayer = L.layerGroup().addTo(map);
const ibadahLayer = L.layerGroup();
const el = (id) => document.getElementById(id);
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
const rp = (value) => value === null || value === undefined ? "Disembunyikan" : new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 }).format(Number(value || 0));
const score = (value) => Number(value || 0).toFixed(3);
const badgeClass = (kategori) => kategori === "Sangat Membutuhkan" ? "sangat" : kategori === "Membutuhkan" ? "butuh" : kategori === "Rentan" ? "rentan" : "mampu";
const color = (kategori) => kategori === "Sangat Membutuhkan" ? "#dc2626" : kategori === "Membutuhkan" ? "#f97316" : kategori === "Rentan" ? "#facc15" : "#22c55e";
const ibadahColors = {
"Gereja Katolik": "#7c3aed",
GPIB: "#2563eb",
GKI: "#0891b2",
HKBP: "#16a34a",
Pentakosta: "#ea580c",
};
function toast(message) {
el("toast").textContent = message;
el("toast").classList.add("show");
setTimeout(() => el("toast").classList.remove("show"), 2200);
}
async function fetchJson(path) {
const response = await fetch(`${API}${path}`);
const payload = await response.json();
if (!response.ok || payload.success === false) throw new Error(payload.message || "Gagal memuat data");
return payload;
}
function params() {
const kategori = encodeURIComponent(el("categoryFilter").value);
const search = encodeURIComponent(el("searchInput").value.trim());
return `kategori=${kategori}&search=${search}`;
}
async function loadData() {
const [mapPayload, dashboardPayload] = await Promise.all([
fetchJson(`/map?${params()}`),
fetchJson("/dashboard")
]);
state.rows = mapPayload.data || [];
state.dashboard = dashboardPayload.data || {};
renderAll();
}
function renderStats() {
const d = state.dashboard || {};
el("statTotal").textContent = d.total_keluarga || 0;
el("statHigh").textContent = d.prioritas_tinggi || 0;
el("statAid").textContent = rp(d.total_bantuan_tersalurkan || 0);
el("statArea").textContent = Object.keys(d.wilayah || {}).length;
const sorted = [...state.rows].sort((a, b) => Number(a.ranking || 99) - Number(b.ranking || 99));
el("topFamily").textContent = sorted[0]?.nama_kepala_keluarga || "-";
const avg = state.rows.length ? state.rows.reduce((sum, item) => sum + Number(item.skor_saw || 0), 0) / state.rows.length : 0;
el("avgScore").textContent = score(avg);
}
function renderAreaStats() {
const area = state.rows.reduce((acc, item) => {
acc[item.wilayah] = (acc[item.wilayah] || 0) + 1;
return acc;
}, {});
const max = Math.max(1, ...Object.values(area));
el("areaStats").innerHTML = Object.entries(area).map(([name, count]) => `
<div style="display:grid;grid-template-columns:1fr 42px;gap:10px;align-items:center;margin:9px 0;color:#e5e7eb;font-size:.84rem">
<span>${esc(name)}</span><strong>${count}</strong>
<div style="grid-column:1/-1;height:9px;border-radius:99px;background:rgba(255,255,255,.12);overflow:hidden"><div style="height:100%;width:${(count / max) * 100}%;background:linear-gradient(90deg,#7c3aed,#06b6d4)"></div></div>
</div>
`).join("") || "<p style='color:#cbd5e1'>Belum ada data wilayah.</p>";
}
function popupHtml(item) {
const history = (item.riwayat_bantuan || []).length
? `<ul>${item.riwayat_bantuan.map((row) => `<li>${esc(row.jenis_bantuan)} - ${rp(row.jumlah_bantuan)} (${String(row.tanggal_diterima || "").slice(0, 10)})</li>`).join("")}</ul>`
: "<p>Belum ada riwayat bantuan.</p>";
return `
<div class="popup">
<h3>${esc(item.nama_kepala_keluarga)}</h3>
<p>${esc(item.alamat)}<br>${esc(item.wilayah)}</p>
<span class="badge ${badgeClass(item.kategori)}">${esc(item.kategori)}</span>
<div class="popup-grid">
<div><span>Tanggungan</span><strong>${item.jumlah_tanggungan}</strong></div>
<div><span>Pendapatan</span><strong>${rp(item.pendapatan_bulanan)}</strong></div>
<div><span>Skor SAW</span><strong>${score(item.skor_saw)}</strong></div>
<div><span>Ranking</span><strong>#${item.ranking || "-"}</strong></div>
</div>
<div class="history"><strong>Riwayat Bantuan</strong>${history}</div>
</div>
`;
}
function ibadahIcon(jenis) {
const markerColor = ibadahColors[jenis] || "#64748b";
return L.divIcon({
className: "",
html: `<div class="ibadah-map-marker" style="background:${markerColor}">⛪</div>`,
iconSize: [30, 30],
iconAnchor: [15, 30],
popupAnchor: [0, -28],
});
}
function ibadahPopupHtml(item) {
const markerColor = ibadahColors[item.jenis] || "#64748b";
return `
<div class="popup">
<h3>${esc(item.nama)}</h3>
<span class="badge" style="background:${markerColor}22;color:${markerColor};border:1px solid ${markerColor}44">${esc(item.jenis)}</span>
<p style="margin-top:10px">${esc(item.alamat)}<br>${esc(item.kelurahan)}</p>
</div>
`;
}
function renderIbadahMarkers() {
ibadahLayer.clearLayers();
state.ibadah.forEach((item) => {
const latlng = [Number(item.lat), Number(item.lng)];
if (!Number.isFinite(latlng[0]) || !Number.isFinite(latlng[1])) return;
L.marker(latlng, { icon: ibadahIcon(item.jenis) })
.bindPopup(ibadahPopupHtml(item))
.addTo(ibadahLayer);
});
}
async function loadIbadahLayer(force = false) {
if (state.ibadahLoaded && !force) return;
const payload = await fetchJson("/ibadah");
state.ibadah = payload.data || [];
state.ibadahLoaded = true;
renderIbadahMarkers();
}
function renderMarkers() {
markerLayer.clearLayers();
heatLayer.clearLayers();
state.markers.clear();
state.rows.forEach((item) => {
const latlng = [Number(item.latitude), Number(item.longitude)];
if (!Number.isFinite(latlng[0]) || !Number.isFinite(latlng[1])) return;
if (el("heatToggle").checked) {
L.circle(latlng, {
radius: 260 + Number(item.skor_saw || 0) * 720,
stroke: false,
fillColor: color(item.kategori),
fillOpacity: item.kategori === "Sangat Membutuhkan" ? .23 : .14,
}).addTo(heatLayer);
}
const marker = L.circleMarker(latlng, {
radius: 9,
color: "#ffffff",
weight: 2,
fillColor: color(item.kategori),
fillOpacity: .94,
}).bindPopup(popupHtml(item)).addTo(markerLayer);
state.markers.set(String(item.id_keluarga), marker);
});
}
function renderList() {
el("familyList").innerHTML = state.rows.map((item) => `
<article class="family-item" data-id="${item.id_keluarga}">
<strong>#${item.ranking || "-"} ${esc(item.nama_kepala_keluarga)}</strong>
<small>${esc(item.wilayah)}<br>Skor ${score(item.skor_saw)} - ${item.jumlah_tanggungan} tanggungan</small>
<br><span class="badge ${badgeClass(item.kategori)}">${esc(item.kategori)}</span>
</article>
`).join("") || "<p style='color:#cbd5e1'>Tidak ada keluarga sesuai filter.</p>";
document.querySelectorAll(".family-item").forEach((item) => {
item.addEventListener("click", () => {
const marker = state.markers.get(item.dataset.id);
if (!marker) return;
map.setView(marker.getLatLng(), 17);
marker.openPopup();
if (window.innerWidth <= 880) el("sidebar").classList.remove("open");
});
});
}
function renderAll() {
renderStats();
renderAreaStats();
renderMarkers();
renderList();
if (state.rows.length) {
const bounds = L.latLngBounds(state.rows.map((item) => [item.latitude, item.longitude]));
if (bounds.isValid()) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 });
}
}
let debounce;
["categoryFilter", "heatToggle"].forEach((id) => el(id).addEventListener("change", () => loadData().catch((error) => toast(error.message))));
el("ibadahToggle").addEventListener("change", async () => {
try {
if (el("ibadahToggle").checked) {
await loadIbadahLayer(true);
ibadahLayer.addTo(map);
el("ibadahLegendPanel").classList.add("active");
} else {
map.removeLayer(ibadahLayer);
el("ibadahLegendPanel").classList.remove("active");
}
} catch (error) {
el("ibadahToggle").checked = false;
el("ibadahLegendPanel").classList.remove("active");
toast(error.message);
}
});
el("searchInput").addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => loadData().catch((error) => toast(error.message)), 260);
});
el("mobileToggle").addEventListener("click", () => el("sidebar").classList.toggle("open"));
window.addEventListener("resize", () => map.invalidateSize());
loadData().catch((error) => toast(error.message));
</script>
</body>
</html>