Files
tistopandita 5386300498 add poverty
2026-06-11 13:56:18 +07:00

946 lines
43 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<meta name="description" content="Peta distribusi bantuan sosial gereja berbasis WebGIS — analisis kemiskinan SAW, buffer, routing, dan pemetaan lokasi." />
<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" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
<style>
:root {
--bg-dark: #0f172a;
--bg-panel: #111827;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border: rgba(148, 163, 184, 0.22);
--accent: #7c3aed;
--accent-2: #06b6d4;
--red: #dc2626;
--orange: #f97316;
--yellow: #facc15;
--green: #22c55e;
--radius: 20px;
--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: 360px minmax(0, 1fr); min-height: 100vh; }
/* ── Sidebar ────────────────────────────────────────────────────── */
.sidebar {
position: relative;
z-index: 900;
height: 100vh;
overflow-y: auto;
padding: 20px 16px;
background:
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, .28), transparent 30%),
linear-gradient(180deg, var(--bg-dark), var(--bg-panel));
box-shadow: var(--shadow);
border-right: 1px solid rgba(255,255,255,.08);
scrollbar-width: thin;
scrollbar-color: rgba(124,58,237,.3) transparent;
}
.brand { margin-bottom: 16px; }
.brand small { color: #a5b4fc; font-weight: 800; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; }
.brand h1 { margin: 6px 0 6px; font-size: 1.45rem; line-height: 1.12; }
.brand p { margin: 0; color: #cbd5e1; line-height: 1.45; font-size: .86rem; }
.panel {
border: 1px solid rgba(255,255,255,.11);
background: rgba(255,255,255,.06);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 12px;
backdrop-filter: blur(12px);
}
.panel h2 {
margin: 0 0 11px;
font-size: .88rem;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
color: #a5b4fc;
}
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { background: rgba(15,23,42,.5); border: 1px solid var(--border); border-radius: 16px; padding: 11px; }
.stat span { display: block; color: var(--text-muted); font-size: .7rem; font-weight: 800; text-transform: uppercase; }
.stat strong { display: block; margin-top: 5px; font-size: 1.28rem; }
.field { display: grid; gap: 6px; margin-bottom: 10px; color: #e5e7eb; font-size: .8rem; font-weight: 800; }
.field:last-child { margin-bottom: 0; }
input[type="text"], input[type="number"], select, textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 13px;
padding: 10px 11px;
outline: none;
color: #f8fafc;
background: rgba(15,23,42,.65);
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus, textarea:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 3px rgba(124,58,237,.18);
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--border);
background: rgba(15,23,42,.38);
border-radius: 14px;
padding: 10px 11px;
margin-top: 7px;
font-size: .82rem;
font-weight: 700;
}
.switch input { width: auto; }
.legend { display: grid; gap: 8px; color: #e5e7eb; font-size: .82rem; }
.legend span { display: flex; align-items: center; gap: 8px; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; box-shadow: 0 0 0 3px rgba(255,255,255,.08); }
.list { display: grid; gap: 8px; max-height: 280px; overflow: auto; padding-right: 2px; }
.family-item {
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(15,23,42,.42);
padding: 10px;
cursor: pointer;
transition: .15s;
}
.family-item:hover { border-color: rgba(167,139,250,.7); background: rgba(124,58,237,.18); }
.family-item strong { display: block; font-size: .88rem; }
.family-item small { color: #cbd5e1; line-height: 1.5; font-size: .76rem; }
.badge { display: inline-flex; border-radius: 999px; padding: 3px 9px; font-size: .69rem; font-weight: 900; margin-top: 6px; }
.sangat { background: #fee2e2; color: #7f1d1d; }
.butuh { background: #ffedd5; color: #7c2d12; }
.rentan { background: #fef9c3; color: #713f12; }
.mampu { background: #dcfce7; color: #14532d; }
.status-menunggu { background: #fef3c7; color: #92400e; }
.status-diverifikasi{ background: #dbeafe; color: #1e40af; }
.status-disetujui { background: #dcfce7; color: #14532d; }
.status-ditolak { background: #fee2e2; color: #7f1d1d; }
.status-selesai { background: #e0e7ff; color: #3730a3; }
/* Buffer panel buttons */
.radius-btns { display: flex; gap: 7px; margin-top: 8px; flex-wrap: wrap; }
.radius-btn {
flex: 1;
min-width: 56px;
border: 1px solid rgba(167,139,250,.5);
border-radius: 12px;
padding: 9px 6px;
background: rgba(124,58,237,.18);
color: #c4b5fd;
font-size: .78rem;
font-weight: 800;
cursor: pointer;
transition: .15s;
text-align: center;
}
.radius-btn:hover, .radius-btn.active {
background: rgba(124,58,237,.5);
border-color: #a78bfa;
color: #fff;
}
.radius-btn.clear-btn {
background: rgba(220,38,38,.15);
border-color: rgba(220,38,38,.4);
color: #fca5a5;
}
.radius-btn.clear-btn:hover { background: rgba(220,38,38,.35); border-color: #f87171; color: #fff; }
.church-select { margin-bottom: 8px; }
.buffer-result {
margin-top: 10px;
padding: 10px;
border-radius: 12px;
background: rgba(124,58,237,.18);
border: 1px solid rgba(167,139,250,.3);
font-size: .82rem;
color: #e5e7eb;
display: none;
}
.buffer-result.show { display: block; }
.buffer-result strong { color: #c4b5fd; }
/* Nearest church panel */
.nearest-result {
padding: 10px;
border-radius: 12px;
background: rgba(6,182,212,.1);
border: 1px solid rgba(6,182,212,.28);
color: #e5e7eb;
font-size: .82rem;
margin-top: 8px;
display: none;
}
.nearest-result.show { display: block; }
.nearest-result h4 { margin: 0 0 5px; font-size: .88rem; color: #67e8f9; }
.nearest-result p { margin: 3px 0; }
/* Map */
#map { height: 100vh; width: 100%; background: #eef2f7; }
.map-top {
position: absolute;
z-index: 800;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.map-card {
border: 1px solid rgba(15,23,42,.12);
background: rgba(255,255,255,.93);
color: #0f172a;
border-radius: 16px;
box-shadow: var(--shadow);
padding: 10px 14px;
backdrop-filter: blur(12px);
}
.map-card strong { display: block; font-size: 1rem; }
.map-card span { color: #64748b; font-size: .75rem; font-weight: 700; }
/* Mobile */
.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;
font-size: 1.1rem;
}
/* Popup */
.leaflet-popup-content-wrapper { border-radius: 20px; overflow: hidden; padding: 0; }
.leaflet-popup-content { margin: 0; width: 310px !important; }
.popup { color: #0f172a; padding: 16px; }
.popup h3 { margin: 0 0 5px; font-size: 1rem; }
.popup p { margin: 0 0 8px; color: #475569; line-height: 1.4; font-size: .86rem; }
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 8px 0; }
.popup-grid div { border-radius: 11px; background: #f8fafc; padding: 8px; }
.popup-grid span { display: block; color: #64748b; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
.popup-grid strong { display: block; margin-top: 2px; font-size: .84rem; }
.history { border-top: 1px solid #e5e7eb; margin-top: 8px; padding-top: 8px; }
.history ul { margin: 6px 0 0 16px; padding: 0; color: #334155; font-size: .82rem; }
.nearest-info { margin-top: 8px; padding: 8px; border-radius: 11px; background: #eff6ff; border: 1px solid #bfdbfe; }
.nearest-info p { margin: 2px 0; color: #1e3a8a; font-size: .8rem; }
.popup-btn {
display: block;
width: 100%;
margin-top: 8px;
border: 0;
border-radius: 11px;
padding: 9px 12px;
font-weight: 800;
font-size: .82rem;
cursor: pointer;
text-align: center;
transition: .15s;
}
.popup-btn.primary { background: #7c3aed; color: #fff; }
.popup-btn.primary:hover { background: #6d28d9; }
.popup-btn.route { background: #0891b2; color: #fff; }
.popup-btn.route:hover { background: #0e7490; }
/* Routing control */
.leaflet-routing-container { display: none; } /* hide default panel */
/* Toast */
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: #0f172a;
color: white;
padding: 12px 16px;
border-radius: 13px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: .22s ease;
z-index: 2000;
font-size: .9rem;
max-width: 340px;
}
.toast.show { opacity: 1; transform: translateY(0); }
/* Ibadah legend */
.ibadah-legend { display: none; }
.ibadah-legend.active { display: block; }
/* Map markers */
.ibadah-map-marker {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 999px;
color: white;
border: 3px solid white;
box-shadow: 0 8px 22px rgba(15,23,42,.28);
font-size: 14px;
}
@media (max-width: 900px) {
body { overflow: hidden; }
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(90vw, 360px);
transform: translateX(-105%);
transition: transform .24s ease;
z-index: 1100;
}
.sidebar.open { transform: translateX(0); }
.mobile-toggle { display: block; }
.map-top { top: 68px; left: 12px; right: 12px; }
}
</style>
</head>
<body>
<button class="mobile-toggle" id="mobileToggle" aria-label="Buka 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, buffer analysis, dan routing OSM.</p>
</div>
<!-- Stats -->
<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>
<!-- Filter -->
<section class="panel">
<h2>Filter Peta</h2>
<label class="field">Kategori Kemiskinan
<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">Cari Keluarga
<input id="searchInput" type="text" placeholder="Nama, alamat, atau wilayah…" />
</label>
<label class="switch"><span>🌡️ Heatmap Persebaran</span><input id="heatToggle" type="checkbox" checked /></label>
<label class="switch"><span>⛪ Tampilkan Gereja</span><input id="ibadahToggle" type="checkbox" /></label>
</section>
<!-- ═══ Buffer Analysis ═══════════════════════════════════════ -->
<section class="panel" id="bufferPanel">
<h2>📍 Buffer Analysis</h2>
<label class="field church-select">Pilih Gereja
<select id="bufferChurchSelect">
<option value="">— Memuat gereja… —</option>
</select>
</label>
<div class="radius-btns">
<button class="radius-btn" data-radius="1000" id="btn1km">1 km</button>
<button class="radius-btn" data-radius="3000" id="btn3km">3 km</button>
<button class="radius-btn" data-radius="5000" id="btn5km">5 km</button>
<button class="radius-btn clear-btn" id="btnClearBuffer">Hapus</button>
</div>
<div class="buffer-result" id="bufferResult">
Gereja: <strong id="bufferChurchName"></strong><br/>
Radius: <strong id="bufferRadiusLabel"></strong><br/>
Keluarga dalam radius: <strong id="bufferCount">0</strong><br/>
Prioritas tinggi: <strong id="bufferHighCount">0</strong>
</div>
</section>
<!-- ═══ Nearest Church Analysis ══════════════════════════════ -->
<section class="panel">
<h2>🔍 Nearest Church Analysis</h2>
<p style="font-size:.8rem;color:#94a3b8;margin:0 0 8px">Klik marker keluarga di peta untuk melihat gereja terdekat.</p>
<div class="nearest-result" id="nearestResult">
<h4 id="nearestName"></h4>
<p>📍 <span id="nearestAlamat"></span></p>
<p>📏 Jarak: <strong id="nearestJarak"></strong></p>
<p>⛪ Jenis: <span id="nearestJenis"></span></p>
<p>🏠 Kapasitas: <span id="nearestKapasitas"></span> keluarga</p>
</div>
</section>
<!-- ═══ Legenda ═══════════════════════════════════════════════ -->
<section class="panel">
<h2>Legenda Keluarga</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 Gereja</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>
<!-- Statistik Wilayah -->
<section class="panel">
<h2>Distribusi Wilayah</h2>
<div id="areaStats"><p style="color:#94a3b8;font-size:.82rem">Memuat…</p></div>
</section>
<!-- Daftar Keluarga -->
<section class="panel">
<h2>Daftar Keluarga</h2>
<div class="list" id="familyList"></div>
</section>
<!-- Link -->
<section class="panel" style="display:flex;gap:10px;flex-wrap:wrap">
<a href="/admin-poverty.html" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.3);font-size:.82rem;font-weight:700">⚙️ Admin</a>
<a href="/pengajuan.html" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(6,182,212,.18);border:1px solid rgba(6,182,212,.3);font-size:.82rem;font-weight:700">📋 Ajukan Bantuan</a>
<a href="/" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(255,255,255,.07);border:1px solid var(--border);font-size:.82rem;font-weight:700">⛽ SPBU</a>
</section>
</aside>
<main style="position:relative">
<div class="map-top">
<div class="map-card"><span>Ranking #1</span><strong id="topFamily">-</strong></div>
<div class="map-card"><span>Rata-rata Skor SAW</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 src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.min.js"></script>
<script>
/* ══════════════════════════════════════════════════════════════════
STATE & MAP INIT
══════════════════════════════════════════════════════════════════ */
const API = "/api/poverty";
const state = {
rows: [],
dashboard: null,
markers: new Map(),
ibadah: [],
ibadahLoaded: false,
bufferCircle: null,
bufferHighlights: [],
activeRadius: null,
routingControl: null,
};
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; <a href='https://openstreetmap.org'>OpenStreetMap</a>",
}).addTo(map);
const markerLayer = L.layerGroup().addTo(map);
const heatLayer = L.layerGroup().addTo(map);
const ibadahLayer = L.layerGroup();
/* ══════════════════════════════════════════════════════════════════
HELPERS
══════════════════════════════════════════════════════════════════ */
const el = (id) => document.getElementById(id);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) =>
({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
const rp = (v) => v == null ? "Disembunyikan"
: new Intl.NumberFormat("id-ID", { style:"currency", currency:"IDR", maximumFractionDigits:0 }).format(Number(v||0));
const fmt = (v) => Number(v||0).toFixed(3);
const fmtM = (m) => m >= 1000 ? `${(m/1000).toFixed(1)} km` : `${m} m`;
const badgeClass = (k) =>
k === "Sangat Membutuhkan" ? "sangat" : k === "Membutuhkan" ? "butuh" : k === "Rentan" ? "rentan" : "mampu";
const markerColor = (k) =>
k === "Sangat Membutuhkan" ? "#dc2626" : k === "Membutuhkan" ? "#f97316" : k === "Rentan" ? "#facc15" : "#22c55e";
const ibadahColors = {
"Gereja Katolik":"#7c3aed", GPIB:"#2563eb", GKI:"#0891b2", HKBP:"#16a34a", Pentakosta:"#ea580c",
};
let toastTimer;
function toast(msg, duration = 2800) {
el("toast").textContent = msg;
el("toast").classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el("toast").classList.remove("show"), duration);
}
async function fetchJson(path) {
const r = await fetch(`${API}${path}`);
const payload = await r.json();
if (!r.ok || payload.success === false) throw new Error(payload.message || "Gagal memuat data");
return payload;
}
function params() {
const k = encodeURIComponent(el("categoryFilter").value);
const s = encodeURIComponent(el("searchInput").value.trim());
return `kategori=${k}&search=${s}`;
}
/* ══════════════════════════════════════════════════════════════════
LOAD DATA
══════════════════════════════════════════════════════════════════ */
async function loadData() {
const [mapPay, dashPay] = await Promise.all([
fetchJson(`/map?${params()}`),
fetchJson("/dashboard"),
]);
state.rows = mapPay.data || [];
state.dashboard = dashPay.data || {};
renderAll();
}
/* ══════════════════════════════════════════════════════════════════
RENDER STATS
══════════════════════════════════════════════════════════════════ */
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((s,r)=>s+Number(r.skor_saw||0),0)/state.rows.length : 0;
el("avgScore").textContent = fmt(avg);
}
function renderAreaStats() {
const area = state.rows.reduce((acc, r) => { acc[r.wilayah]=(acc[r.wilayah]||0)+1; return acc; }, {});
const max = Math.max(1, ...Object.values(area));
el("areaStats").innerHTML = Object.entries(area).map(([name,count]) => `
<div style="margin:8px 0;font-size:.8rem;color:#e5e7eb">
<div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>${esc(name)}</span><strong>${count}</strong></div>
<div style="height:7px;border-radius:99px;background:rgba(255,255,255,.1);overflow:hidden">
<div style="height:100%;width:${(count/max)*100}%;background:linear-gradient(90deg,#7c3aed,#06b6d4)"></div>
</div>
</div>`).join("") || `<p style="color:#94a3b8;font-size:.82rem">Belum ada data.</p>`;
}
/* ══════════════════════════════════════════════════════════════════
POPUP HTML KELUARGA
══════════════════════════════════════════════════════════════════ */
function popupHtml(item, nearestChurch) {
const hist = (item.riwayat_bantuan || []).length
? `<ul>${item.riwayat_bantuan.map(r=>`<li>${esc(r.jenis_bantuan)} ${rp(r.jumlah_bantuan)} (${String(r.tanggal_diterima||"").slice(0,10)})</li>`).join("")}</ul>`
: "<p>Belum ada riwayat bantuan.</p>";
const nearestHtml = nearestChurch ? `
<div class="nearest-info">
<p>⛪ <strong>Gereja terdekat:</strong> ${esc(nearestChurch.nama)}</p>
<p>📏 Jarak: <strong>${fmtM(nearestChurch.jarak_meter)}</strong></p>
<p>🏠 Kapasitas: ${nearestChurch.kapasitas_bantuan || 0} keluarga</p>
</div>` : "";
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>${fmt(item.skor_saw)}</strong></div>
<div><span>Ranking</span><strong>#${item.ranking||"-"}</strong></div>
</div>
${nearestHtml}
<div class="history"><strong>Riwayat Bantuan</strong>${hist}</div>
${nearestChurch ? `<button class="popup-btn route" onclick="showRoute(${item.latitude},${item.longitude},${nearestChurch.lat},${nearestChurch.lng},'${esc(nearestChurch.nama)}')">🗺️ Tampilkan Rute ke Gereja Terdekat</button>` : ""}
</div>`;
}
/* ══════════════════════════════════════════════════════════════════
POPUP HTML IBADAH (GEREJA)
══════════════════════════════════════════════════════════════════ */
function ibadahPopupHtml(item) {
const mc = ibadahColors[item.jenis] || "#64748b";
return `
<div class="popup">
<h3>${esc(item.nama)}</h3>
<span class="badge" style="background:${mc}22;color:${mc};border:1px solid ${mc}44">${esc(item.jenis)}</span>
<p style="margin-top:9px">${esc(item.alamat)}<br/>${esc(item.kelurahan)}</p>
<div class="popup-grid">
<div><span>Kapasitas Bantuan</span><strong>${item.kapasitas_bantuan || 0} KK</strong></div>
<div><span>Koordinat</span><strong>${Number(item.lat).toFixed(5)}, ${Number(item.lng).toFixed(5)}</strong></div>
</div>
${item.keterangan ? `<p style="margin-top:8px;font-size:.8rem;color:#475569"><em>${esc(item.keterangan)}</em></p>` : ""}
<button class="popup-btn primary" onclick="runBufferFromChurch(${item.id}, 1000)">📍 Buffer 1 km dari sini</button>
</div>`;
}
/* ══════════════════════════════════════════════════════════════════
MARKERS RENDER
══════════════════════════════════════════════════════════════════ */
function renderMarkers() {
markerLayer.clearLayers();
heatLayer.clearLayers();
state.markers.clear();
state.rows.forEach((item) => {
const ll = [Number(item.latitude), Number(item.longitude)];
if (!Number.isFinite(ll[0]) || !Number.isFinite(ll[1])) return;
if (el("heatToggle").checked) {
L.circle(ll, {
radius: 280 + Number(item.skor_saw||0)*800,
stroke: false,
fillColor: markerColor(item.kategori),
fillOpacity: item.kategori === "Sangat Membutuhkan" ? .22 : .12,
}).addTo(heatLayer);
}
// Find nearest church from loaded ibadah list (client-side)
let nearest = null;
if (state.ibadah.length) {
let minDist = Infinity;
state.ibadah.forEach((church) => {
const d = L.latLng(ll).distanceTo(L.latLng([Number(church.lat), Number(church.lng)]));
if (d < minDist) { minDist = d; nearest = { ...church, jarak_meter: Math.round(d) }; }
});
}
const marker = L.circleMarker(ll, {
radius: 9,
color: "#ffffff",
weight: 2,
fillColor: markerColor(item.kategori),
fillOpacity: .92,
}).addTo(markerLayer);
marker.bindPopup(popupHtml(item, nearest), { maxWidth: 320 });
// On popup open: update nearest panel and show in sidebar
marker.on("popupopen", () => {
if (nearest) {
el("nearestResult").classList.add("show");
el("nearestName").textContent = nearest.nama;
el("nearestAlamat").textContent = nearest.alamat + ", " + nearest.kelurahan;
el("nearestJarak").textContent = fmtM(nearest.jarak_meter);
el("nearestJenis").textContent = nearest.jenis;
el("nearestKapasitas").textContent = nearest.kapasitas_bantuan || 0;
}
});
state.markers.set(String(item.id_keluarga), marker);
});
}
/* ══════════════════════════════════════════════════════════════════
IBADAH (GEREJA) LAYER
══════════════════════════════════════════════════════════════════ */
function ibadahIcon(jenis) {
const c = ibadahColors[jenis] || "#64748b";
return L.divIcon({
className: "",
html: `<div class="ibadah-map-marker" style="background:${c}">⛪</div>`,
iconSize: [30,30], iconAnchor: [15,30], popupAnchor: [0,-28],
});
}
function renderIbadahMarkers() {
ibadahLayer.clearLayers();
state.ibadah.forEach((item) => {
const ll = [Number(item.lat), Number(item.lng)];
if (!Number.isFinite(ll[0]) || !Number.isFinite(ll[1])) return;
L.marker(ll, { icon: ibadahIcon(item.jenis) })
.bindPopup(ibadahPopupHtml(item))
.addTo(ibadahLayer);
});
// Populate buffer church select
populateChurchSelect();
// Re-render markers to update nearest info
renderMarkers();
}
async function loadIbadahLayer(force = false) {
if (state.ibadahLoaded && !force) return;
const payload = await fetchJson("/ibadah");
state.ibadah = payload.data || [];
state.ibadahLoaded = true;
renderIbadahMarkers();
}
function populateChurchSelect() {
const sel = el("bufferChurchSelect");
sel.innerHTML = `<option value="">— Pilih Gereja —</option>` +
state.ibadah.map(c => `<option value="${c.id}" data-lat="${c.lat}" data-lng="${c.lng}">${esc(c.nama)} (${esc(c.jenis)})</option>`).join("");
}
/* ══════════════════════════════════════════════════════════════════
BUFFER ANALYSIS
══════════════════════════════════════════════════════════════════ */
function clearBuffer() {
if (state.bufferCircle) { map.removeLayer(state.bufferCircle); state.bufferCircle = null; }
state.bufferHighlights.forEach((c) => map.removeLayer(c));
state.bufferHighlights = [];
state.activeRadius = null;
el("bufferResult").classList.remove("show");
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => b.classList.remove("active"));
}
function runBuffer(churchId, radiusM) {
clearBuffer();
const option = el("bufferChurchSelect").querySelector(`option[value="${churchId}"]`);
if (!option || !churchId) { toast("Pilih gereja terlebih dahulu."); return; }
const church = state.ibadah.find(c => String(c.id) === String(churchId));
if (!church) return;
const lat = Number(church.lat);
const lng = Number(church.lng);
state.activeRadius = radiusM;
state.bufferCircle = L.circle([lat, lng], {
radius: radiusM,
color: "#7c3aed",
fillColor: "#7c3aed",
fillOpacity: .1,
weight: 2,
dashArray: "6 4",
}).addTo(map);
const inRadius = [];
state.rows.forEach((item) => {
const dist = L.latLng([lat, lng]).distanceTo(L.latLng([Number(item.latitude), Number(item.longitude)]));
if (dist <= radiusM) {
inRadius.push(item);
const hl = L.circleMarker([Number(item.latitude), Number(item.longitude)], {
radius: 11,
color: "#fff",
weight: 2,
fillColor: "#7c3aed",
fillOpacity: .85,
});
state.bufferHighlights.push(hl);
hl.addTo(map);
}
});
map.fitBounds(state.bufferCircle.getBounds(), { padding: [30,30] });
const highCount = inRadius.filter(r => r.kategori === "Sangat Membutuhkan").length;
el("bufferChurchName").textContent = church.nama;
el("bufferRadiusLabel").textContent = fmtM(radiusM);
el("bufferCount").textContent = inRadius.length;
el("bufferHighCount").textContent = highCount;
el("bufferResult").classList.add("show");
toast(`Buffer ${fmtM(radiusM)}: ditemukan ${inRadius.length} keluarga, ${highCount} prioritas tinggi.`);
}
// Called from popup button on ibadah marker
window.runBufferFromChurch = (churchId, radiusM) => {
// Select the church in the select
el("bufferChurchSelect").value = churchId;
// Activate button
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => {
b.classList.toggle("active", Number(b.dataset.radius) === radiusM);
});
runBuffer(churchId, radiusM);
map.closePopup();
};
/* ══════════════════════════════════════════════════════════════════
ROUTING (Leaflet Routing Machine + OSRM)
══════════════════════════════════════════════════════════════════ */
window.showRoute = (fromLat, fromLng, toLat, toLng, churchName) => {
if (state.routingControl) {
map.removeControl(state.routingControl);
state.routingControl = null;
}
toast(`🗺️ Memuat rute ke ${churchName}…`, 5000);
state.routingControl = L.Routing.control({
waypoints: [
L.latLng(fromLat, fromLng),
L.latLng(toLat, toLng),
],
router: L.Routing.osrmv1({
serviceUrl: "https://router.project-osrm.org/route/v1",
}),
routeWhileDragging: false,
addWaypoints: false,
fitSelectedRoutes: true,
lineOptions: {
styles: [{ color: "#7c3aed", weight: 5, opacity: .85 }],
},
createMarker: () => null, // keep original markers
}).addTo(map);
state.routingControl.on("routesfound", (e) => {
const r = e.routes[0];
const dist = (r.summary.totalDistance / 1000).toFixed(2);
const dur = Math.round(r.summary.totalTime / 60);
toast(`✅ Rute ke ${churchName}: ${dist} km, ±${dur} menit.`, 5000);
});
state.routingControl.on("routingerror", () => {
toast("⚠️ Gagal memuat rute. Periksa koneksi internet.");
});
map.closePopup();
};
// Expose clear route globally
window.clearRoute = () => {
if (state.routingControl) { map.removeControl(state.routingControl); state.routingControl = null; }
};
/* ══════════════════════════════════════════════════════════════════
FAMILY LIST
══════════════════════════════════════════════════════════════════ */
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 ${fmt(item.skor_saw)} · ${item.jumlah_tanggungan} tanggungan</small>
<br><span class="badge ${badgeClass(item.kategori)}">${esc(item.kategori)}</span>
</article>`).join("") || `<p style='color:#94a3b8;font-size:.82rem'>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 <= 900) el("sidebar").classList.remove("open");
});
});
}
/* ══════════════════════════════════════════════════════════════════
RENDER ALL
══════════════════════════════════════════════════════════════════ */
function renderAll() {
renderStats();
renderAreaStats();
renderMarkers();
renderList();
if (state.rows.length) {
const bounds = L.latLngBounds(
state.rows.filter(r => Number.isFinite(Number(r.latitude))).map(r => [r.latitude, r.longitude])
);
if (bounds.isValid()) map.fitBounds(bounds, { padding:[40,40], maxZoom:15 });
}
}
/* ══════════════════════════════════════════════════════════════════
EVENT LISTENERS
══════════════════════════════════════════════════════════════════ */
// Filter / search
["categoryFilter","heatToggle"].forEach(id =>
el(id).addEventListener("change", () => loadData().catch(e => toast(e.message))));
let debounce;
el("searchInput").addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => loadData().catch(e => toast(e.message)), 280);
});
// Ibadah toggle
el("ibadahToggle").addEventListener("change", async () => {
try {
if (el("ibadahToggle").checked) {
await loadIbadahLayer(true);
ibadahLayer.addTo(map);
el("ibadahLegendPanel").classList.add("active");
el("bufferPanel").style.opacity = "1";
} else {
map.removeLayer(ibadahLayer);
el("ibadahLegendPanel").classList.remove("active");
clearBuffer();
}
} catch (e) {
el("ibadahToggle").checked = false;
el("ibadahLegendPanel").classList.remove("active");
toast(e.message);
}
});
// Buffer radius buttons
document.querySelectorAll(".radius-btn[data-radius]").forEach((btn) => {
btn.addEventListener("click", () => {
const churchId = el("bufferChurchSelect").value;
const radiusM = Number(btn.dataset.radius);
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
// Auto-load ibadah if needed
if (!state.ibadahLoaded) {
loadIbadahLayer(true).then(() => {
ibadahLayer.addTo(map);
el("ibadahToggle").checked = true;
el("ibadahLegendPanel").classList.add("active");
runBuffer(churchId, radiusM);
}).catch(e => toast(e.message));
} else {
runBuffer(churchId, radiusM);
}
});
});
el("btnClearBuffer").addEventListener("click", clearBuffer);
// Church select change clears buffer
el("bufferChurchSelect").addEventListener("change", () => {
clearBuffer();
if (state.activeRadius) {
const btn = document.querySelector(`.radius-btn[data-radius="${state.activeRadius}"]`);
if (btn) btn.classList.remove("active");
}
});
// Mobile
el("mobileToggle").addEventListener("click", () => el("sidebar").classList.toggle("open"));
window.addEventListener("resize", () => map.invalidateSize());
/* ══════════════════════════════════════════════════════════════════
INIT
══════════════════════════════════════════════════════════════════ */
loadData().catch(e => toast(e.message));
// Pre-load ibadah layer quietly so nearest-church info is available from start
loadIbadahLayer(true).catch(() => {/* ignore silent preload errors */});
</script>
</body>
</html>