431 lines
19 KiB
HTML
431 lines
19 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); }
|
|
@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 mendukung mode privasi.</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">Role Akses
|
|
<select id="roleSelect">
|
|
<option value="admin">Admin - semua data</option>
|
|
<option value="pengurus">Pengurus - data sensitif disembunyikan</option>
|
|
</select>
|
|
</label>
|
|
<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>Mode Privasi</span><input id="privacyToggle" type="checkbox" /></label>
|
|
<label class="switch"><span>Heatmap persebaran</span><input id="heatToggle" type="checkbox" checked /></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">
|
|
<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() };
|
|
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: "© OpenStreetMap" }).addTo(map);
|
|
const markerLayer = L.layerGroup().addTo(map);
|
|
const heatLayer = L.layerGroup().addTo(map);
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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";
|
|
|
|
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 role = el("roleSelect").value;
|
|
const privacy = el("privacyToggle").checked ? "1" : "0";
|
|
const kategori = encodeURIComponent(el("categoryFilter").value);
|
|
const search = encodeURIComponent(el("searchInput").value.trim());
|
|
return `role=${role}&privacy=${privacy}&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 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;
|
|
["roleSelect", "categoryFilter", "privacyToggle", "heatToggle"].forEach((id) => el(id).addEventListener("change", () => loadData().catch((error) => 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>
|