+ ```
+2. Buka folder `webgis/`.
+3. Klik dua kali pada **`index.html`** untuk membukanya langsung di browser.
+4. Aplikasi siap digunakan. β
+
+> Direkomendasikan menggunakan **Google Chrome** atau **Mozilla Firefox** versi terbaru.
+> Anda juga bisa menggunakan ekstensi **Live Server** di VSCode untuk pengalaman pengembangan yang lebih baik.
+
+---
+
+## πΊοΈ Panduan Penggunaan
+
+### Menambah Data Baru
+1. Pilih layer yang ingin diisi melalui **navigasi tab** di bagian atas (SPBU, Jalan, Parsil, Warga, Rumah Ibadah).
+2. Klik tombol **`+ Tambah`** di sidebar kiri.
+3. **Klik pada peta** untuk menandai lokasi (untuk Point), atau **gambar garis/poligon** (untuk Jalan/Parsil).
+4. Isi formulir atribut yang muncul, lalu klik **Simpan Data**.
+
+### Mengedit atau Menghapus Data
+- Klik marker/layer di peta β Popup muncul β Klik **Edit** atau **Hapus**.
+- Atau klik kartu di sidebar β gunakan tombol **Edit** / **Hapus** / **Zoom**.
+
+### Melihat Dashboard Analitik
+- Klik tombol **π Dashboard Analitik** di topbar kanan.
+- Tab **Ringkasan Utama**: Statistik jumlah KK, distribusi kemiskinan, status jalan.
+- Tab **Distribusi Bansos**: Peta panas jangkauan Rumah Ibadah, beban distribusi per *hub*.
+
+---
+
+## π οΈ Teknologi yang Digunakan
+
+| Teknologi | Versi | Fungsi |
+|-----------|-------|--------|
+| HTML5 & CSS3 | β | Struktur & tampilan UI |
+| Vanilla JavaScript | ES6+ | Logika aplikasi & state management |
+| [Leaflet.js](https://leafletjs.com/) | 1.9.4 | Render peta interaktif |
+| [Leaflet Draw](https://github.com/Leaflet/Leaflet.draw) | 1.0.4 | Digitasi titik, garis, dan poligon |
+| [DM Sans & DM Mono](https://fonts.google.com/) | β | Tipografi UI |
+| LocalStorage API | β | Basis data *client-side* |
+| OpenStreetMap | β | Tile peta dasar |
+
+---
+
+## π§ Panduan Modifikasi Kode
+
+| Ingin mengubah... | Buka file... |
+|-------------------|--------------|
+| Warna marker atau label layer | [`js/config/config.js`](js/config/config.js) |
+| Isian form input | [`js/components/forms.js`](js/components/forms.js) |
+| Tampilan popup peta | [`js/components/popup.js`](js/components/popup.js) |
+| Tampilan kartu di sidebar | [`js/components/card.js`](js/components/card.js) |
+| Logika & tampilan dashboard | [`js/modules/dashboard.js`](js/modules/dashboard.js) |
+| Cara layer dirender di peta | [`js/modules/map.js`](js/modules/map.js) |
+| Tema / warna UI global | [`css/style.css`](css/style.css) |
+
+---
+
+## π Lisensi
+
+Proyek ini dibuat untuk keperluan akademik mata kuliah **Sistem Informasi Geografis (SIG)**. Bebas digunakan dan dimodifikasi untuk keperluan non-komersial.
+
+---
+
+*Dikembangkan dengan β€οΈ untuk mendukung pemerataan distribusi Bantuan Sosial berbasis data geospasial.*
diff --git a/pertemuan_3/UAS_SIG b/pertemuan_3/UAS_SIG
new file mode 160000
index 0000000..30bf02d
--- /dev/null
+++ b/pertemuan_3/UAS_SIG
@@ -0,0 +1 @@
+Subproject commit 30bf02df8b737d68262f42931ee51ce9754963d3
diff --git a/pertemuan_3/css/style.css b/pertemuan_3/css/style.css
new file mode 100644
index 0000000..d55bb55
--- /dev/null
+++ b/pertemuan_3/css/style.css
@@ -0,0 +1,420 @@
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ DESIGN TOKENS
+βββββββββββββββββββββββββββββββββββββββββββ */
+:root{
+ --bg0:#07090f;--bg1:#0d1120;--bg2:#131929;--bg3:#1a2235;--bg4:#212d42;--bg5:#2a3a52;
+ --border0:#1a2438;--border1:#243045;--border2:#2e3e58;--border3:#3d5070;
+ --text0:#f0f4ff;--text1:#c8d4ea;--text2:#8fa3c0;--text3:#5a7292;--text4:#3a5070;
+ --accent:#5b9cf6;--accent2:#3a7de4;--accent-glow:rgba(91,156,246,.18);
+ --green:#34d47e;--green2:#22aa60;--green-s:rgba(52,212,126,.14);
+ --red:#f05c5c;--red2:#c83e3e;--red-s:rgba(240,92,92,.14);
+ --amber:#f0a030;--amber2:#c47c18;--amber-s:rgba(240,160,48,.14);
+ --purple:#9b7bfa;--purple2:#7a5ae0;--purple-s:rgba(155,123,250,.14);
+ --teal:#2ec4b6;--teal2:#1ea090;--teal-s:rgba(46,196,182,.14);
+ --orange:#f07840;--orange2:#c45820;--orange-s:rgba(240,120,64,.14);
+ --pink:#f06090;--pink-s:rgba(240,96,144,.14);
+ --font:'DM Sans',system-ui,sans-serif;
+ --mono:'DM Mono',monospace;
+ --r4:4px;--r6:6px;--r8:8px;--r10:10px;--r12:12px;--r16:16px;--rfull:9999px;
+ --shadow-sm:0 2px 8px rgba(0,0,0,.35);
+ --shadow-md:0 4px 20px rgba(0,0,0,.45);
+ --shadow-lg:0 8px 40px rgba(0,0,0,.6);
+ --transition:.15s ease;
+}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;overflow:hidden;font-family:var(--font)}
+body{background:var(--bg0);color:var(--text0);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
+button{font-family:var(--font);cursor:pointer;border:none;outline:none;background:none}
+input,select,textarea{font-family:var(--font);outline:none}
+::-webkit-scrollbar{width:4px;height:4px}
+::-webkit-scrollbar-thumb{background:var(--border2);border-radius:var(--rfull)}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ LAYOUT
+βββββββββββββββββββββββββββββββββββββββββββ */
+.app{display:flex;flex-direction:column;height:100vh}
+.topbar{height:52px;background:var(--bg1);border-bottom:1px solid var(--border1);
+ display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0;z-index:500}
+.main{display:flex;flex:1;overflow:hidden}
+.sidebar{width:320px;background:var(--bg1);border-right:1px solid var(--border0);
+ display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
+.map-area{flex:1;position:relative}
+#map{width:100%;height:100%}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ TOPBAR
+βββββββββββββββββββββββββββββββββββββββββββ */
+.brand{display:flex;align-items:center;gap:10px}
+.brand-mark{width:32px;height:32px;border-radius:var(--r8);
+ background:linear-gradient(135deg,var(--accent),var(--purple));
+ display:flex;align-items:center;justify-content:center;flex-shrink:0}
+.brand-mark svg{width:17px;height:17px;fill:#fff}
+.brand-name{font-size:14px;font-weight:700;color:var(--text0);letter-spacing:-.3px;line-height:1.1}
+.brand-name span{display:block;font-size:10px;font-weight:400;color:var(--text3);letter-spacing:.2px}
+.topbar-div{width:1px;height:26px;background:var(--border1)}
+.nav-pills{display:flex;gap:3px}
+.nav-pill{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:var(--r6);
+ font-size:12px;font-weight:500;color:var(--text2);transition:background var(--transition),color var(--transition)}
+.nav-pill:hover{background:var(--bg3);color:var(--text0)}
+.nav-pill.active{background:var(--accent);color:#fff}
+.nav-pill.active .pill-badge{background:rgba(255,255,255,.25);color:#fff}
+.pill-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
+.pill-badge{min-width:17px;height:17px;padding:0 4px;border-radius:var(--rfull);
+ background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;
+ display:inline-flex;align-items:center;justify-content:center}
+.topbar-spacer{flex:1}
+/* Dashboard toggle */
+.dash-btn{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:var(--r6);
+ font-size:12px;font-weight:600;background:var(--purple-s);color:var(--purple);
+ border:1px solid rgba(155,123,250,.25);transition:background var(--transition)}
+.dash-btn:hover{background:rgba(155,123,250,.25)}
+.topbar-stat{font-size:11px;color:var(--text3);display:flex;align-items:center;gap:5px}
+.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green)}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ SIDEBAR
+βββββββββββββββββββββββββββββββββββββββββββ */
+.sidebar-head{padding:12px 14px;border-bottom:1px solid var(--border0);
+ display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
+.sidebar-title{font-size:13px;font-weight:700;color:var(--text0)}
+.sidebar-sub{font-size:10px;color:var(--text3);margin-top:1px}
+.btn-add{display:flex;align-items:center;gap:4px;padding:5px 10px;border-radius:var(--r6);
+ font-size:11px;font-weight:700;background:var(--accent-glow);color:var(--accent);
+ border:1px solid rgba(91,156,246,.25);transition:background var(--transition)}
+.btn-add:hover{background:rgba(91,156,246,.28)}
+.sidebar-body{flex:1;overflow-y:auto;padding:10px}
+
+/* Data Cards */
+.dcard{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
+ margin-bottom:8px;overflow:hidden;transition:border-color var(--transition)}
+.dcard:hover{border-color:var(--border2)}
+.dcard.active{border-color:var(--accent)}
+.dcard-head{padding:9px 11px;display:flex;align-items:center;gap:8px;cursor:pointer}
+.dcard-head:hover{background:var(--bg3)}
+.dcard-sym{width:9px;height:9px;flex-shrink:0}
+.dcard-sym.circle{border-radius:50%}
+.dcard-sym.line{height:4px;border-radius:2px;width:20px}
+.dcard-sym.poly{border-radius:2px;width:13px;border:2px solid currentColor;background:transparent}
+.dcard-name{font-size:12px;font-weight:600;color:var(--text0);flex:1;
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dcard-attrs{padding:4px 11px 8px;border-top:1px solid var(--border0)}
+.attr{display:flex;justify-content:space-between;padding:3px 0;
+ border-bottom:1px solid var(--border0);font-size:11px}
+.attr:last-child{border-bottom:none}
+.attr-k{color:var(--text3)}
+.attr-v{color:var(--text2);font-weight:500;text-align:right;max-width:60%}
+.dcard-acts{display:flex;gap:5px;padding:7px 11px;border-top:1px solid var(--border0);background:var(--bg1)}
+
+/* Badges */
+.badge{display:inline-flex;align-items:center;padding:1px 7px;border-radius:var(--rfull);font-size:10px;font-weight:700}
+.badge-green {background:var(--green-s);color:var(--green)}
+.badge-red {background:var(--red-s);color:var(--red)}
+.badge-amber {background:var(--amber-s);color:var(--amber)}
+.badge-accent {background:var(--accent-glow);color:var(--accent)}
+.badge-purple {background:var(--purple-s);color:var(--purple)}
+.badge-teal {background:var(--teal-s);color:var(--teal)}
+.badge-orange {background:var(--orange-s);color:var(--orange)}
+.badge-pink {background:var(--pink-s);color:var(--pink)}
+
+/* Btn small */
+.bsm{padding:3px 9px;border-radius:var(--r4);font-size:10px;font-weight:700;border:1px solid transparent;transition:background var(--transition)}
+.bsm-zoom {background:var(--green-s);color:var(--green);border-color:rgba(52,212,126,.2)}
+.bsm-zoom:hover{background:rgba(52,212,126,.24)}
+.bsm-edit {background:var(--accent-glow);color:var(--accent);border-color:rgba(91,156,246,.2)}
+.bsm-edit:hover{background:rgba(91,156,246,.25)}
+.bsm-del {background:var(--red-s);color:var(--red);border-color:rgba(240,92,92,.2)}
+.bsm-del:hover{background:rgba(240,92,92,.24)}
+
+/* Empty state */
+.empty{text-align:center;padding:48px 20px;color:var(--text4)}
+.empty svg{margin:0 auto 12px;display:block;opacity:.2}
+.empty p{font-size:12px;line-height:1.7}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ MAP OVERLAYS
+βββββββββββββββββββββββββββββββββββββββββββ */
+.map-legend{position:absolute;bottom:20px;left:12px;z-index:800;
+ background:var(--bg1);border:1px solid var(--border1);border-radius:var(--r10);
+ padding:10px 13px;min-width:160px;box-shadow:var(--shadow-md)}
+.legend-title{font-size:9px;font-weight:700;color:var(--text3);
+ text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px}
+.legend-row{display:flex;align-items:center;gap:8px;margin-bottom:5px;
+ font-size:11px;color:var(--text2)}
+.legend-row:last-child{margin-bottom:0}
+.leg-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
+.leg-l{width:20px;height:4px;border-radius:2px;flex-shrink:0}
+.leg-p{width:13px;height:11px;border-radius:2px;flex-shrink:0;border:2px solid}
+
+.draw-bar{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:800;
+ background:var(--bg1);border:1px solid var(--border2);border-radius:var(--rfull);
+ padding:7px 16px;font-size:11px;font-weight:600;color:var(--text0);
+ display:flex;align-items:center;gap:10px;box-shadow:var(--shadow-md);white-space:nowrap}
+.draw-bar.hidden{display:none}
+.draw-pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;
+ animation:pulse 1.5s ease-in-out infinite}
+@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}
+.btn-cancel-draw{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
+ border-radius:var(--r4);padding:3px 9px;font-size:10px;font-weight:700;transition:background var(--transition)}
+.btn-cancel-draw:hover{background:rgba(240,92,92,.24)}
+
+/* Leaflet popup */
+.leaflet-popup-content-wrapper{background:var(--bg2)!important;border:1px solid var(--border2)!important;
+ border-radius:var(--r10)!important;box-shadow:var(--shadow-lg)!important;color:var(--text0)!important}
+.leaflet-popup-tip{background:var(--bg2)!important}
+.leaflet-popup-content{margin:0!important;padding:0!important}
+.leaflet-popup-close-button{color:var(--text2)!important;font-size:14px!important;top:8px!important;right:10px!important}
+.popup-wrap{padding:12px 14px;min-width:220px;max-width:300px}
+.popup-title{font-size:13px;font-weight:700;color:var(--text0);margin-bottom:8px;
+ display:flex;align-items:center;gap:7px}
+.popup-sym{width:10px;height:10px;border-radius:50%;flex-shrink:0}
+.popup-section{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
+ letter-spacing:.8px;margin:8px 0 4px;padding-bottom:4px;border-bottom:1px solid var(--border0)}
+.popup-row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;
+ border-bottom:1px solid var(--border0);font-size:11px}
+.popup-row:last-of-type{border-bottom:none}
+.popup-k{color:var(--text3)}
+.popup-v{color:var(--text1);font-weight:500;text-align:right;max-width:55%}
+.popup-acts{display:flex;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--border0)}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ MODAL
+βββββββββββββββββββββββββββββββββββββββββββ */
+.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;
+ display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);
+ animation:fadeIn .15s ease}
+.modal-ov.hidden{display:none}
+@keyframes fadeIn{from{opacity:0}to{opacity:1}}
+.modal{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r16);
+ width:520px;max-width:calc(100vw - 24px);max-height:88vh;overflow-y:auto;
+ box-shadow:var(--shadow-lg);animation:mIn .18s ease}
+@keyframes mIn{from{transform:translateY(-10px) scale(.97);opacity:0}to{transform:none;opacity:1}}
+.modal-head{padding:16px 18px 12px;border-bottom:1px solid var(--border0);
+ display:flex;align-items:center;justify-content:space-between;
+ position:sticky;top:0;background:var(--bg2);z-index:1}
+.modal-head-left{display:flex;align-items:center;gap:10px}
+.modal-icon{width:30px;height:30px;border-radius:var(--r8);display:flex;align-items:center;justify-content:center;font-size:15px}
+.modal-title{font-size:14px;font-weight:700;color:var(--text0)}
+.modal-sub{font-size:10px;color:var(--text3);margin-top:1px}
+.modal-close{width:26px;height:26px;border-radius:var(--r6);background:var(--bg3);
+ border:1px solid var(--border1);color:var(--text2);font-size:12px;
+ display:flex;align-items:center;justify-content:center;transition:background var(--transition)}
+.modal-close:hover{background:var(--bg4);color:var(--text0)}
+.modal-body{padding:16px 18px}
+.modal-foot{padding:12px 18px;border-top:1px solid var(--border0);
+ display:flex;gap:8px;justify-content:flex-end}
+
+/* Form */
+.form-sect{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
+ letter-spacing:.8px;margin:14px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--border0)}
+.form-sect:first-child{margin-top:0}
+.form-row{display:grid;gap:10px;margin-bottom:10px}
+.form-row.cols2{grid-template-columns:1fr 1fr}
+.form-row.cols3{grid-template-columns:1fr 1fr 1fr}
+.fg{display:flex;flex-direction:column;gap:5px}
+.flabel{font-size:11px;font-weight:600;color:var(--text2);letter-spacing:.2px}
+.flabel .req{color:var(--red);margin-left:2px}
+.finput{padding:8px 10px;background:var(--bg3);border:1px solid var(--border1);
+ border-radius:var(--r6);color:var(--text0);font-size:12px;font-family:var(--font);
+ transition:border-color var(--transition),box-shadow var(--transition);width:100%}
+.finput:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,156,246,.12)}
+.finput:disabled{opacity:.45;cursor:not-allowed}
+.finput[readonly]{background:var(--bg4);color:var(--text2);cursor:default}
+select.finput{cursor:pointer}
+select.finput option{background:var(--bg3)}
+textarea.finput{resize:vertical;min-height:64px}
+.info-box{background:var(--bg4);border:1px solid var(--border1);border-radius:var(--r6);
+ padding:8px 10px;margin-bottom:10px;font-size:11px;color:var(--text2);line-height:1.6}
+.info-box strong{color:var(--text0)}
+.info-box.warn{border-color:rgba(240,160,48,.3);background:var(--amber-s)}
+
+/* Radio toggle */
+.rtoggle{display:flex;gap:6px}
+.ropt{flex:1;display:flex;align-items:center;justify-content:center;gap:5px;
+ padding:8px;border-radius:var(--r6);border:1px solid var(--border1);cursor:pointer;
+ font-size:11px;font-weight:600;transition:all var(--transition);color:var(--text3);
+ background:var(--bg3);user-select:none}
+.ropt input{display:none}
+.ropt.yes{border-color:rgba(52,212,126,.35);color:var(--green);background:var(--green-s)}
+.ropt.no {border-color:rgba(240,92,92,.35);color:var(--red);background:var(--red-s)}
+
+/* Buttons */
+.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;
+ padding:8px 18px;border-radius:var(--r6);font-size:13px;font-weight:600;
+ font-family:var(--font);transition:background var(--transition)}
+.btn-primary{background:var(--accent);color:#fff}
+.btn-primary:hover{background:var(--accent2)}
+.btn-ghost{background:var(--bg3);color:var(--text2);border:1px solid var(--border1)}
+.btn-ghost:hover{background:var(--bg4);color:var(--text0)}
+.hidden{display:none!important}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ DASHBOARD PANEL
+βββββββββββββββββββββββββββββββββββββββββββ */
+.dash-panel{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:8000;
+ display:flex;align-items:stretch;justify-content:flex-end;backdrop-filter:blur(4px);
+ animation:fadeIn .15s ease}
+.dash-panel.hidden{display:none}
+.dash-inner{width:680px;max-width:95vw;background:var(--bg1);border-left:1px solid var(--border1);
+ display:flex;flex-direction:column;overflow:hidden}
+.dash-head{padding:18px 20px;border-bottom:1px solid var(--border0);
+ display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
+.dash-head-title{font-size:16px;font-weight:700;color:var(--text0)}
+.dash-head-sub{font-size:11px;color:var(--text3);margin-top:2px}
+.dash-body{flex:1;overflow-y:auto;padding:18px 20px}
+
+/* Stat cards */
+.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px}
+.stat-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
+ padding:12px 14px;position:relative;overflow:hidden}
+.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
+.stat-card.c-accent::before{background:var(--accent)}
+.stat-card.c-green::before{background:var(--green)}
+.stat-card.c-amber::before{background:var(--amber)}
+.stat-card.c-purple::before{background:var(--purple)}
+.stat-card.c-red::before{background:var(--red)}
+.stat-card.c-teal::before{background:var(--teal)}
+.sc-val{font-size:26px;font-weight:700;color:var(--text0);font-family:var(--mono);line-height:1}
+.sc-label{font-size:10px;color:var(--text3);margin-top:4px}
+.sc-sub{font-size:11px;color:var(--text2);margin-top:6px}
+
+/* Dash sections */
+.dash-sect{margin-bottom:20px}
+.dash-sect-title{font-size:11px;font-weight:700;color:var(--text2);
+ text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px;
+ padding-bottom:6px;border-bottom:1px solid var(--border0)}
+.risk-table{width:100%;border-collapse:collapse;font-size:11px}
+.risk-table th{text-align:left;padding:6px 10px;color:var(--text3);font-weight:600;
+ border-bottom:1px solid var(--border0);font-size:10px}
+.risk-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
+.risk-table tr:last-child td{border-bottom:none}
+.risk-table tr:hover td{background:var(--bg2)}
+.risk-bar-wrap{display:flex;align-items:center;gap:7px}
+.risk-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1}
+.risk-bar-fill{height:100%;border-radius:3px;transition:width .3s ease}
+.prog-row{display:flex;align-items:center;justify-content:space-between;
+ padding:6px 0;border-bottom:1px solid var(--border0);font-size:11px}
+.prog-row:last-child{border-bottom:none}
+.prog-name{color:var(--text1)}
+.prog-count{font-family:var(--mono);font-weight:600;color:var(--text0)}
+.intervensi-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
+.intervensi-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r8);padding:10px 12px}
+.ic-label{font-size:10px;color:var(--text3);margin-bottom:4px}
+.ic-val{font-size:13px;font-weight:700;color:var(--text0)}
+.ic-sub{font-size:10px;color:var(--text2);margin-top:3px}
+
+/* Toast */
+.toasts{position:fixed;bottom:22px;right:22px;z-index:10000;display:flex;flex-direction:column;gap:7px;pointer-events:none}
+.toast{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r10);
+ padding:10px 14px;font-size:12px;color:var(--text0);box-shadow:var(--shadow-lg);
+ display:flex;align-items:center;gap:9px;min-width:240px;pointer-events:all;
+ animation:tIn .2s ease}
+.toast.success{border-left:3px solid var(--green)}
+.toast.error{border-left:3px solid var(--red)}
+.toast.info{border-left:3px solid var(--accent)}
+@keyframes tIn{from{transform:translateX(110%);opacity:0}to{transform:none;opacity:1}}
+@keyframes tOut{from{transform:none;opacity:1}to{transform:translateX(110%);opacity:0}}
+.toast.out{animation:tOut .2s ease forwards}
+
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ BANSOS DISTRIBUTION SYSTEM
+βββββββββββββββββββββββββββββββββββββββββββ */
+
+/* History table (multi-entry) inside modal */
+.history-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:11px}
+.history-table th{text-align:left;padding:5px 8px;color:var(--text3);font-weight:600;
+ font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg3)}
+.history-table td{padding:6px 8px;border-bottom:1px solid var(--border0);color:var(--text1)}
+.history-table tr:last-child td{border-bottom:none}
+.history-table tr:hover td{background:var(--bg3)}
+.history-table .status-aktif{color:var(--green);font-weight:700}
+.history-table .status-berhenti{color:var(--red);font-weight:700}
+.history-table .status-proses{color:var(--amber);font-weight:700}
+
+/* Dynamic add-row button */
+.btn-add-row{display:inline-flex;align-items:center;gap:4px;margin-top:6px;
+ padding:4px 10px;border-radius:var(--r4);font-size:10px;font-weight:700;
+ background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25);
+ cursor:pointer;transition:background var(--transition)}
+.btn-add-row:hover{background:rgba(52,212,126,.24)}
+.btn-del-row{padding:2px 7px;border-radius:var(--r4);font-size:10px;font-weight:700;
+ background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
+ cursor:pointer;transition:background var(--transition)}
+.btn-del-row:hover{background:rgba(240,92,92,.24)}
+
+/* Radius coverage indicator in popup */
+.coverage-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
+ border-radius:var(--rfull);font-size:10px;font-weight:700;margin-top:4px}
+.coverage-badge.covered{background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25)}
+.coverage-badge.uncovered{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.25)}
+.coverage-badge svg{width:10px;height:10px;fill:currentColor;flex-shrink:0}
+
+/* Radius circle legend */
+.legend-radius{display:flex;align-items:center;gap:7px;margin-top:6px;
+ padding-top:6px;border-top:1px solid var(--border0);font-size:10px;color:var(--text3)}
+.leg-radius-sym{width:14px;height:14px;border-radius:50%;border:2px dashed var(--teal);
+ background:rgba(46,196,182,.08);flex-shrink:0}
+
+/* Dashboard distribution tab */
+.dash-tab-bar{display:flex;gap:3px;margin-bottom:14px;background:var(--bg2);
+ border:1px solid var(--border0);border-radius:var(--r8);padding:3px}
+.dash-tab{flex:1;padding:6px 12px;border-radius:var(--r6);font-size:11px;font-weight:600;
+ color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
+.dash-tab.active{background:var(--accent);color:#fff}
+.dash-tab:not(.active):hover{background:var(--bg3);color:var(--text1)}
+
+/* Coverage summary cards */
+.coverage-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
+.coverage-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
+ padding:12px 14px;display:flex;align-items:center;gap:12px}
+.cc-icon{width:34px;height:34px;border-radius:var(--r8);display:flex;align-items:center;
+ justify-content:center;font-size:16px;flex-shrink:0}
+.cc-icon.green{background:var(--green-s)}
+.cc-icon.red{background:var(--red-s)}
+.cc-icon.amber{background:var(--amber-s)}
+.cc-icon.teal{background:var(--teal-s)}
+.cc-info{min-width:0}
+.cc-val{font-size:20px;font-weight:700;color:var(--text0);font-family:var(--mono)}
+.cc-label{font-size:10px;color:var(--text3);margin-top:1px}
+
+/* Hub assignment table */
+.hub-table{width:100%;border-collapse:collapse;font-size:11px}
+.hub-table th{text-align:left;padding:7px 10px;color:var(--text3);font-weight:600;
+ font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg2);
+ position:sticky;top:0}
+.hub-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
+.hub-table tr:last-child td{border-bottom:none}
+.hub-table tr:hover td{background:var(--bg2)}
+.hub-name-cell{display:flex;align-items:center;gap:7px}
+.hub-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
+.hub-count-bar{display:flex;align-items:center;gap:7px}
+.hub-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1;min-width:60px}
+.hub-bar-fill{height:100%;border-radius:3px;transition:width .4s ease}
+
+/* Multi-section form tab inside modal */
+.form-tabs{display:flex;gap:2px;background:var(--bg3);border-radius:var(--r6);
+ padding:3px;margin-bottom:14px}
+.form-tab{flex:1;padding:5px 8px;border-radius:var(--r4);font-size:10px;font-weight:700;
+ color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
+.form-tab.active{background:var(--accent);color:#fff}
+.form-tab:not(.active):hover{background:var(--bg4);color:var(--text1)}
+.form-section-panel{display:none}
+.form-section-panel.active{display:block}
+
+/* Pendidikan tag badges */
+.pendidikan-grid{display:flex;flex-wrap:wrap;gap:5px;margin-top:4px}
+.pddk-tag{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;
+ border-radius:var(--rfull);font-size:10px;font-weight:600;cursor:pointer;
+ border:1px solid var(--border1);background:var(--bg3);color:var(--text3);
+ transition:all var(--transition);user-select:none}
+.pddk-tag.selected{background:var(--accent-glow);border-color:rgba(91,156,246,.4);color:var(--accent)}
+
+/* Penyakit checklist */
+.penyakit-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
+.penyakit-check{display:flex;align-items:center;gap:6px;padding:5px 8px;
+ border-radius:var(--r6);border:1px solid var(--border0);background:var(--bg3);
+ cursor:pointer;font-size:11px;color:var(--text2);transition:all var(--transition);user-select:none}
+.penyakit-check input{accent-color:var(--red);width:12px;height:12px}
+.penyakit-check.checked{border-color:rgba(240,92,92,.35);background:var(--red-s);color:var(--red)}
+
+/* Badge pink (already in root vars) */
+.badge-pink{background:var(--pink-s);color:var(--pink)}
\ No newline at end of file
diff --git a/webgis/index.html b/pertemuan_3/index.html
similarity index 100%
rename from webgis/index.html
rename to pertemuan_3/index.html
diff --git a/webgis/js/app.js b/pertemuan_3/js/app.js
similarity index 95%
rename from webgis/js/app.js
rename to pertemuan_3/js/app.js
index d0f3e72..17d0b92 100644
--- a/webgis/js/app.js
+++ b/pertemuan_3/js/app.js
@@ -5,7 +5,7 @@ const App = {
state:{ activeLayer:'spbu' },
_groups:{},
- init(){
+ async init(){
// Map
this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
@@ -49,14 +49,15 @@ const App = {
document.getElementById('modal-close').addEventListener('click', Modal.close);
document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); });
+ await Store.load();
this.refresh();
},
crud:{
edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); },
- delete(ln,id){
+ async delete(ln,id){
if(!confirm('Yakin hapus data ini?')) return;
- Store.remove(ln,id); App._map.closePopup();
+ await Store.remove(ln,id); App._map.closePopup();
App.refresh(); Toast.info('Data dihapus');
},
},
diff --git a/pertemuan_3/js/components/card.js b/pertemuan_3/js/components/card.js
new file mode 100644
index 0000000..881419b
--- /dev/null
+++ b/pertemuan_3/js/components/card.js
@@ -0,0 +1,63 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 7. CARD BUILDERS (sidebar)
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Card = {
+ _acts:(ln,id)=>`
+ Zoom
+ Edit
+ Hapus
`,
+
+ spbu(r){ const clr=CFG.spbu.color(r),bdg=CFG.spbu.badge(r);
+ return `
+
+
+ ${H.esc(r.nama)} ${bdg.t}
+
+
WA ${H.esc(r.wa||'-')}
+
BBM ${H.esc(r.jenis_bbm||'-')}
+
${this._acts('spbu',r.id)}
`; },
+
+ jalan(r){ const clr=CFG.jalan.color(r),bdg=CFG.jalan.badge(r);
+ return `
+
+
+ ${H.esc(r.nama)} ${bdg.t}
+
+
Panjang ${H.fmDist(r.panjang)}
+
Kondisi ${H.esc(r.kondisi||'-')}
+
${this._acts('jalan',r.id)}
`; },
+
+ parsil(r){ const clr=CFG.parsil.color(r),bdg=CFG.parsil.badge(r);
+ return `
+
+
+ ${H.esc(r.nama)} ${bdg.t}
+
+
Luas ${H.fmArea(r.luas)}
+
Sengketa ${H.esc(r.sengketa||'-')}
+
${this._acts('parsil',r.id)}
`; },
+
+ warga(r){ const clr=CFG.warga.color(r),bdg=CFG.warga.badge(r);
+ const pendapatan=r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-';
+ const histCount=r.history_bansos?.length||0;
+ return `
+
+
+ ${H.esc(r.nama)} ${bdg.t}
+
+
Anggota ${r.jml_anggota||'-'} jiwa
+
Pendapatan ${pendapatan}
+
Bansos ${H.esc(r.bansos||'-')}
+
Riwayat ${histCount} program
+
${this._acts('warga',r.id)}
`; },
+
+ rumahIbadah(r){ const clr=CFG.rumahIbadah.color(r),bdg=CFG.rumahIbadah.badge(r);
+ return `
+
+
+ ${H.esc(r.nama)} ${bdg.t}
+
+
Ketua ${H.esc(r.ketua||'-')}
+
Binaan ${r.jml_binaan||'-'} KK
+
${this._acts('rumahIbadah',r.id)}
`; },
+};
diff --git a/pertemuan_3/js/components/forms.js b/pertemuan_3/js/components/forms.js
new file mode 100644
index 0000000..4ebe467
--- /dev/null
+++ b/pertemuan_3/js/components/forms.js
@@ -0,0 +1,361 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 5. FORM BUILDERS
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Forms = {
+ spbu(rec, latlng){
+ const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
+ const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
+ const v=f=>rec?H.esc(rec[f]||''):'';
+ return `
+ π Koordinat: ${lat}, ${lng}
+ Identitas & Operasional
+
+
+ Aksesibilitas & Sosial
+
+ Catatan Lapangan
+ Catatan / Keterangan Tambahan
+
`;
+ },
+
+ jalan(rec, len){
+ const pj=len??rec?.panjang??0; const v=f=>rec?H.esc(rec[f]||''):'';
+ return `
+ π Panjang terukur otomatis: ${H.fmDist(pj)}
+
+ Identitas Ruas Jalan
+
+ Kondisi & Infrastruktur
+
+ Dampak Sosial-Ekonomi
+
+ Catatan Kondisi
+
`;
+ },
+
+ parsil(rec, area){
+ const ls=area??rec?.luas??0; const v=f=>rec?H.esc(rec[f]||''):'';
+ return `
+ π Luas terukur otomatis: ${H.fmArea(ls)}
+
+ Identitas Parsil
+
+ Status Kepemilikan
+
+ Penilaian & Pemanfaatan
+
+ Catatan Tambahan
+
`;
+ },
+
+ warga(rec, latlng){
+ const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
+ const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
+ const v=f=>rec?H.esc(rec[f]||''):'';
+ const c=v=>rec?.penyakit?.includes(v)?'checked':'';
+ const hist=rec?.history_bansos||[];
+ const ri = Store.all('rumahIbadah');
+ const hubOpts = ['Belum ditugaskan', ...ri.map(r=>r.nama)].map(opt=>`${opt} `).join('');
+
+ return `
+ β οΈ Data warga bersifat sensitif. Pastikan survei akurat.
+ π Koordinat: ${lat}, ${lng}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ },
+
+ rumahIbadah(rec, latlng){
+ const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
+ const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
+ const v=f=>rec?H.esc(rec[f]||''):'';
+ return `
+ π Koordinat: ${lat}, ${lng}
+ Identitas Rumah Ibadah
+
+
+ Kapasitas & Jangkauan
+
+ Program Sosial
+
+ Catatan / Kebutuhan Dukungan
+
`;
+ },
+
+ readAll(ln){
+ const g = id => document.getElementById(id)?.value?.trim();
+ if(ln==='spbu'){
+ const nm=g('f-nama'); if(!nm){Toast.error('Nama SPBU wajib diisi!');return null;}
+ const buka=document.querySelector('input[name="buka24"]:checked')?.value!=='false';
+ return {nama:nm,wa:g('f-wa'),buka24:buka,jenis_bbm:g('f-jenis_bbm'),disabilitas:g('f-disabilitas'),harga_bbm:g('f-harga_bbm'),catatan:g('f-catatan')};
+ }
+ if(ln==='jalan'){
+ const nm=g('f-nama'); if(!nm){Toast.error('Nama jalan wajib diisi!');return null;}
+ return {nama:nm,status:g('f-status'),panjang:parseFloat(g('f-panjang'))||0,kondisi:g('f-kondisi'),tahun_perbaikan:g('f-tahun_perbaikan'),lebar:g('f-lebar'),angkutan:g('f-angkutan'),akses_publik:g('f-akses_publik'),catatan:g('f-catatan')};
+ }
+ if(ln==='parsil'){
+ const nm=g('f-nama'); if(!nm){Toast.error('Nama parsil wajib diisi!');return null;}
+ return {nama:nm,status:g('f-status'),luas:parseFloat(g('f-luas'))||0,nib:g('f-nib'),sengketa:g('f-sengketa'),njop:g('f-njop'),penggunaan:g('f-penggunaan'),catatan:g('f-catatan')};
+ }
+ if(ln==='warga'){
+ const nm=g('f-nama'); if(!nm){Toast.error('Nama KK wajib diisi!');return null;}
+ const penyakit=[...document.querySelectorAll('input[name="penyakit"]:checked')].map(el=>el.value);
+ const history_bansos=[];
+ document.querySelectorAll('#hist-tbody tr').forEach(tr=>{
+ const prog=tr.querySelector('.hist-program')?.value?.trim();
+ const thn=tr.querySelector('.hist-tahun')?.value?.trim();
+ const sts=tr.querySelector('.hist-status')?.value;
+ if(prog) history_bansos.push({program:prog,tahun:parseInt(thn)||new Date().getFullYear(),status:sts});
+ });
+ return {
+ nama: nm, nik: g('f-nik'), no_kk: g('f-no_kk'), wa: g('f-wa'), tingkat_kemiskinan: g('f-tingkat_kemiskinan'),
+ jml_anggota: parseInt(g('f-jml_anggota'))||0, jml_lansia: parseInt(g('f-jml_lansia'))||0,
+ alamat: g('f-alamat'), bansos: g('f-bansos'), hub_ri: g('f-hub_ri'),
+ pendapatan: g('f-pendapatan'), pekerjaan: g('f-pekerjaan'),
+ kondisi_rumah: g('f-kondisi_rumah'), kepemilikan_aset: g('f-kepemilikan_aset'),
+ air_bersih: g('f-air_bersih'), listrik: g('f-listrik'),
+ pendidikan_kk: g('f-pendidikan_kk'), jml_anak: parseInt(g('f-jml_anak'))||0,
+ anak_putus_sekolah: parseInt(g('f-anak_putus_sekolah'))||0, penerima_beasiswa: g('f-penerima_beasiswa'),
+ bpjs: g('f-bpjs'), jml_sakit_kronis: parseInt(g('f-jml_sakit_kronis'))||0,
+ penyakit, history_bansos, catatan: g('f-catatan'),
+ };
+ }
+ if(ln==='rumahIbadah'){
+ const nm=g('f-nama'); if(!nm){Toast.error('Nama Rumah Ibadah wajib diisi!');return null;}
+ return {
+ nama: nm, jenis: g('f-jenis'), ketua: g('f-ketua'), wa: g('f-wa'),
+ kapasitas: parseInt(g('f-kapasitas'))||0, radius: parseFloat(g('f-radius'))||300,
+ jml_binaan: parseInt(g('f-jml_binaan'))||0, program: g('f-program'),
+ dana_sosial: g('f-dana_sosial'), catatan: g('f-catatan')
+ };
+ }
+ }
+};
diff --git a/pertemuan_3/js/components/legend.js b/pertemuan_3/js/components/legend.js
new file mode 100644
index 0000000..755fa00
--- /dev/null
+++ b/pertemuan_3/js/components/legend.js
@@ -0,0 +1,18 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 10. LEGEND
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+function renderLegend(ln){
+ const el=document.getElementById('legend-items');
+ const rows=CFG[ln].legend||[];
+ el.innerHTML=rows.map(r=>{
+ let sym='';
+ if(r.sym==='c') sym=` `;
+ else if(r.sym==='l') sym=` `;
+ else sym=` `;
+ return `${sym}${r.l}
`;
+ }).join('');
+ // Add radius note for rumahIbadah and bansos
+ if(ln==='rumahIbadah'||ln==='warga'){
+ el.innerHTML+=` Radius Hub 300m
`;
+ }
+}
diff --git a/pertemuan_3/js/components/popup.js b/pertemuan_3/js/components/popup.js
new file mode 100644
index 0000000..ae05da7
--- /dev/null
+++ b/pertemuan_3/js/components/popup.js
@@ -0,0 +1,82 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 6. POPUP BUILDERS
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Popup = {
+ _row:(k,v)=>``,
+ _sect:t=>``,
+ _acts:(ln,id)=>``,
+
+ spbu(r){ const cfg=CFG.spbu,clr=cfg.color(r),bdg=cfg.badge(r);
+ return ``; },
+
+ jalan(r){ const cfg=CFG.jalan,bdg=cfg.badge(r);
+ return ``; },
+
+ parsil(r){ const cfg=CFG.parsil,bdg=cfg.badge(r);
+ return ``; },
+
+ warga(r){ const cfg=CFG.warga,clr=cfg.color(r),bdg=cfg.badge(r);
+ const ri=Store.all('rumahIbadah');
+ let nearestRI=null, nearestDist=Infinity;
+ ri.forEach(hub=>{
+ if(hub.lat==null||r.lat==null) return;
+ const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
+ if(d
+
+ ${covered?'Terjangkau Hub: '+H.esc(nearestRI.nama)+' ('+Math.round(nearestDist)+' m)':'Tidak Terjangkau ('+Math.round(nearestDist)+' m dari RI)'}
+ `
+ :'Belum ada Rumah Ibadah Hub
';
+ const histHtml=r.history_bansos?.length
+ ?r.history_bansos.map(h=>`${H.esc(h.program)} (${h.tahun||'-'}) ${H.esc(h.status)}
`).join('')
+ :'Belum ada riwayat bansos
';
+ return ``; },
+
+ rumahIbadah(r){ const cfg=CFG.rumahIbadah,clr=cfg.color(r),bdg=cfg.badge(r);
+ return ``; },
+};
diff --git a/pertemuan_3/js/config/config.js b/pertemuan_3/js/config/config.js
new file mode 100644
index 0000000..6952a35
--- /dev/null
+++ b/pertemuan_3/js/config/config.js
@@ -0,0 +1,57 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 4. LAYER CONFIG (atribut + warna per layer)
+ ββ Ini adalah "kontrak data" antara programmer & client ββ
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const CFG = {
+ spbu: {
+ label:'Data SPBU', sub:'Stasiun Bahan Bakar Umum', icon:'β½', drawType:'point',
+ color: r => r.buka24?'#34d47e':'#f05c5c',
+ badge: r => r.buka24?{t:'24 Jam',c:'badge-green'}:{t:'Tutup',c:'badge-red'},
+ legend:[{l:'Buka 24 Jam',clr:'#34d47e',sym:'c'},{l:'Tidak 24 Jam',clr:'#f05c5c',sym:'c'}],
+ },
+ jalan: {
+ label:'Data Jalan', sub:'Ruas jalan berdasarkan status', icon:'π£οΈ', drawType:'polyline',
+ statuses:['Nasional','Provinsi','Kabupaten','Desa'],
+ colors:{'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'},
+ badgeClass:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'},
+ kondisi:['Baik','Sedang','Rusak Ringan','Rusak Berat'],
+ color: r => ({'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'}[r.status]||'#888'),
+ badge: r => ({t:r.status, c:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'}[r.status]||'badge-accent'}),
+ legend:[{l:'Nasional',clr:'#f05c5c',sym:'l'},{l:'Provinsi',clr:'#f0a030',sym:'l'},{l:'Kabupaten',clr:'#5b9cf6',sym:'l'},{l:'Desa',clr:'#34d47e',sym:'l'}],
+ },
+ parsil: {
+ label:'Data Parsil Tanah', sub:'Persil kavling berdasarkan kepemilikan', icon:'ποΈ', drawType:'polygon',
+ statuses:['SHM','HGB','HGU','HP','Belum Bersertifikat'],
+ colors:{'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'},
+ badgeClass:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'},
+ color: r => ({'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'}[r.status]||'#888'),
+ badge: r => ({t:r.status,c:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'}[r.status]||'badge-accent'}),
+ legend:[{l:'SHM',clr:'#34d47e',sym:'p'},{l:'HGB',clr:'#5b9cf6',sym:'p'},{l:'HGU',clr:'#f0a030',sym:'p'},{l:'HP',clr:'#9b7bfa',sym:'p'},{l:'Belum Sertifikat',clr:'#f05c5c',sym:'p'}],
+ },
+ warga: {
+ label:'Data Warga & Bansos', sub:'Keluarga rentan & sasaran distribusi bansos', icon:'π¨βπ©βπ§', drawType:'point',
+ tingkat:['Sangat Miskin','Miskin','Hampir Miskin','Rentan'],
+ colors:{'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'},
+ badgeClass:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'},
+ color: r => ({'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[r.tingkat_kemiskinan]||'#888'),
+ badge: r => ({t:r.tingkat_kemiskinan,c:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'}[r.tingkat_kemiskinan]||'badge-accent'}),
+ legend:[{l:'Sangat Miskin',clr:'#f05c5c',sym:'c'},{l:'Miskin',clr:'#f0a030',sym:'c'},{l:'Hampir Miskin',clr:'#f0c040',sym:'c'},{l:'Rentan',clr:'#5b9cf6',sym:'c'}],
+ bantuan:['PKH','BPNT/Sembako','BLT Dana Desa','BLT BBM','PIP/KIP','KIS/BPJS','Zakat/Infaq RI','Bansos Pemda','Belum Ada'],
+ kondisiRumah:['Permanen','Semi Permanen','Tidak Layak Huni','Menumpang'],
+ pekerjaan:['Buruh Harian','Petani/Nelayan','Pedagang Kecil','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
+ statusPenerimaan:['Aktif Menerima','Belum Menerima','Proses Verifikasi','Pernah Menerima'],
+ sumberPendapatan:['Buruh Harian','Pedagang Kecil','Petani/Nelayan','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
+ jenisPenyakit:['Hipertensi','Diabetes','TBC','Jantung','Stroke','Kanker','Gangguan Jiwa','Disabilitas','Tidak Ada'],
+ tingkatPendidikan:['Tidak Sekolah','SD/Sederajat','SMP/Sederajat','SMA/Sederajat','D3/S1','S2/S3'],
+ },
+ rumahIbadah: {
+ label:'Rumah Ibadah', sub:'Masjid, Gereja, Pura, Vihara, Klenteng', icon:'π', drawType:'point',
+ jenis:['Masjid','Musholla','Gereja Kristen','Gereja Katolik','Pura','Vihara','Klenteng'],
+ colors:{'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'},
+ badgeClass:{'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'},
+ color: r => ({'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'}[r.jenis]||'#888'),
+ badge: r => ({t:r.jenis,c:({'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'}[r.jenis]||'badge-teal')}),
+ legend:[{l:'Masjid/Musholla',clr:'#2ec4b6',sym:'c'},{l:'Gereja',clr:'#5b9cf6',sym:'c'},{l:'Pura',clr:'#f0a030',sym:'c'},{l:'Vihara/Klenteng',clr:'#9b7bfa',sym:'c'}],
+ coverageRadius: 300,
+ },
+};
diff --git a/pertemuan_3/js/modules/dashboard.js b/pertemuan_3/js/modules/dashboard.js
new file mode 100644
index 0000000..6ae5095
--- /dev/null
+++ b/pertemuan_3/js/modules/dashboard.js
@@ -0,0 +1,182 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 12. DASHBOARD ANALITIK
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Dashboard = {
+ toggle(){
+ document.getElementById('dash-panel').classList.toggle('hidden');
+ if(!document.getElementById('dash-panel').classList.contains('hidden')) this.render();
+ },
+ render(){
+ const db=Store.allDb();
+ const warga=db.warga||[], ri=db.rumahIbadah||[], jalan=db.jalan||[], parsil=db.parsil||[];
+ const totalWarga=warga.reduce((s,r)=>s+(parseInt(r.jml_anggota)||1),0);
+ const miskinBerat=warga.filter(r=>r.tingkat_kemiskinan==='Sangat Miskin'||r.tingkat_kemiskinan==='Miskin').length;
+ const blmBansos=warga.filter(r=>r.bansos==='Belum Ada').length;
+ const blmSertif=parsil.filter(r=>r.status==='Belum Bersertifikat').length;
+ const jalanRusak=jalan.filter(r=>r.kondisi==='Rusak Berat'||r.kondisi==='Rusak Ringan').length;
+ const totalRI=ri.length;
+
+ // Radius coverage for Warga layer
+ let coveredKK=0, uncoveredKK=0;
+ const coverageHubs = ri.map(hub=>({ ...hub, count:0 }));
+ warga.forEach(b => {
+ if(!b.lat) return;
+ let nearestDist=Infinity, nearestHub=null;
+ ri.forEach(hub=>{
+ if(!hub.lat) return;
+ const d=L.latLng(b.lat,b.lng).distanceTo(L.latLng(hub.lat,hub.lng));
+ if(dh.id===nearestHub.id);
+ if(h) h.count++;
+ } else {
+ uncoveredKK++;
+ }
+ });
+
+ // tingkat kemiskinan breakdown
+ const tkBreak={};
+ CFG.warga.tingkat.forEach(t=>{ tkBreak[t]=warga.filter(r=>r.tingkat_kemiskinan===t).length; });
+ const maxTK=Math.max(...Object.values(tkBreak),1);
+
+ // bansos breakdown
+ const bansosBreak={};
+ CFG.warga.bantuan.forEach(b=>{ const c=warga.filter(r=>r.bansos===b).length; if(c>0) bansosBreak[b]=c; });
+
+ // jalan status breakdown
+ const jalanBreak={};
+ CFG.jalan.statuses.forEach(s=>{ jalanBreak[s]=jalan.filter(r=>r.status===s).length; });
+
+ document.getElementById('dash-body').innerHTML=`
+
+
Ringkasan Utama
+
Distribusi Bansos
+
+
+
+
+
+
${H.fmNum(warga.length)}
KK Warga Miskin Terdata
${H.fmNum(totalWarga)} jiwa total
+
${H.fmNum(miskinBerat)}
Miskin & Sangat Miskin
${warga.length?Math.round(miskinBerat/warga.length*100):0}% dari total KK
+
${H.fmNum(blmBansos)}
KK Belum Terima Bansos
Perlu intervensi segera
+
${H.fmNum(totalRI)}
Rumah Ibadah Aktif
Agen lapangan terdaftar
+
${H.fmNum(blmSertif)}
Parsil Belum Bersertifikat
Kandidat program PTSL
+
${H.fmNum(jalanRusak)}
Ruas Jalan Rusak
Dari ${H.fmNum(jalan.length)} ruas terdata
+
+
+
+
Sebaran Tingkat Kemiskinan
+
Kategori Jumlah KK Proporsi
+ ${CFG.warga.tingkat.map(t=>{
+ const n=tkBreak[t]||0, pct=warga.length?Math.round(n/warga.length*100):0;
+ const clr={'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[t];
+ return \`\${t} \${H.fmNum(n)} KK \`;
+ }).join('')}
+
+
+
+
+
Penerima Program Bantuan Sosial
+ ${Object.entries(bansosBreak).length?Object.entries(bansosBreak).sort((a,b)=>b[1]-a[1]).map(([b,n])=>`
+
${b} ${n} KK
`).join('')
+ :'
Belum ada data warga tercatat.
'}
+
+
+
+
Status Ruas Jalan
+
Status Jumlah Ruas Total Panjang
+ ${CFG.jalan.statuses.map(s=>{
+ const ruas=jalan.filter(r=>r.status===s);
+ const pj=ruas.reduce((t,r)=>t+(r.panjang||0),0);
+ return \`\${s} \${ruas.length} ruas \${H.fmDist(pj)} \`;
+ }).join('')}
+
+
+
+
+
Rekomendasi Intervensi Kebijakan
+
+
π Prioritas Bansos
+
${H.fmNum(blmBansos)} KK
+
Warga belum menerima bantuan apapun
+
π Prioritas PTSL
+
${H.fmNum(blmSertif)} Parsil
+
Lahan tanpa sertifikat, akses kredit terbatas
+
π£οΈ Prioritas Perbaikan Jalan
+
${H.fmNum(jalanRusak)} Ruas
+
Jalan rusak ringan-berat perlu perbaikan
+
π Coverage Rumah Ibadah
+
${H.fmNum(totalRI)} RI Aktif
+
Total dana sosial: Rp ${H.fmNum(ri.reduce((t,r)=>t+(parseInt(r.dana_sosial)||0),0))}
+
+
+
+
+
+
+
+
+
β
+
+
${coveredKK} KK
+
Terjangkau Hub (β€300m)
+
+
+
+
β
+
+
${uncoveredKK} KK
+
Di Luar Jangkauan Hub
+
+
+
+
π
+
+
${totalRI} Rumah Ibadah
+
Agen Distribusi Lapangan Terdaftar
+
+
+
+
+
+
Beban Distribusi per Rumah Ibadah
+
+ Nama Hub Kapasitas Beban KK
+
+ ${coverageHubs.sort((a,b)=>b.count-a.count).map(h=>{
+ const clr=CFG.rumahIbadah.color(h);
+ const max = Math.max(...coverageHubs.map(x=>x.count), 1);
+ const pct = Math.round(h.count/max*100);
+ return \`
+ \${H.esc(h.nama)}
+ \${h.jml_binaan||'-'} KK
+
+
+
+ \`;
+ }).join('')}
+
+
+ ${coverageHubs.length===0?'
Belum ada data Rumah Ibadah.
':''}
+
+
`;
+ },
+
+ switchTab(tabId){
+ document.querySelectorAll('.dash-tab').forEach(t=>t.classList.remove('active'));
+ document.querySelectorAll('[id^="dash-panel-"]').forEach(p=>p.style.display='none');
+
+ if(tabId === 'ringkasan'){
+ document.querySelectorAll('.dash-tab')[0].classList.add('active');
+ document.getElementById('dash-panel-ringkasan').style.display='block';
+ } else {
+ document.querySelectorAll('.dash-tab')[1].classList.add('active');
+ document.getElementById('dash-panel-distribusi').style.display='block';
+ }
+ }
+};
diff --git a/pertemuan_3/js/modules/draw.js b/pertemuan_3/js/modules/draw.js
new file mode 100644
index 0000000..4717ddf
--- /dev/null
+++ b/pertemuan_3/js/modules/draw.js
@@ -0,0 +1,38 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 8. DRAW MANAGER
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Draw = (() => {
+ let _map, _drawn, _pending, _handler, _active=false;
+ const BAR=()=>document.getElementById('draw-bar');
+ const TXT=()=>document.getElementById('draw-indicator-text');
+
+ function init(m){
+ _map=m; _drawn=new L.FeatureGroup().addTo(_map);
+ _map.on(L.Draw.Event.CREATED, e=>{
+ _drawn.clearLayers(); _drawn.addLayer(e.layer);
+ _pending=e.layer; stop(); Modal.openAdd(App.state.activeLayer, e.layer);
+ });
+ _map.on('click', e=>{
+ if(!_active||App.state.activeLayer!=='spbu'&&App.state.activeLayer!=='warga'&&App.state.activeLayer!=='rumahIbadah') return;
+ const mk=L.marker(e.latlng);
+ _drawn.clearLayers(); _drawn.addLayer(mk);
+ _pending=mk; stop(); Modal.openAdd(App.state.activeLayer, mk);
+ });
+ document.getElementById('btn-cancel').addEventListener('click', cancel);
+ }
+
+ function start(ln){
+ _active=true;
+ const msgs={spbu:'Klik peta untuk menandai lokasi SPBU',jalan:'Klik titik-titik ruas jalan β klik ganda untuk selesai',parsil:'Klik titik-titik batas kavling β klik ganda untuk selesai',warga:'Klik peta untuk menandai lokasi rumah warga miskin',rumahIbadah:'Klik peta untuk menandai lokasi rumah ibadah'};
+ document.getElementById('draw-text').textContent=msgs[ln]||'Mode gambar aktif';
+ BAR().classList.remove('hidden');
+ if(ln==='jalan'){ _handler=new L.Draw.Polyline(_map,{shapeOptions:{color:'#f0a030',weight:4}}); _handler.enable(); }
+ else if(ln==='parsil'){ _handler=new L.Draw.Polygon(_map,{shapeOptions:{color:'#9b7bfa',weight:2,fillOpacity:.2}}); _handler.enable(); }
+ }
+
+ function stop(){ _active=false; BAR().classList.add('hidden'); if(_handler){_handler.disable();_handler=null;} }
+ function cancel(){ stop(); _drawn.clearLayers(); _pending=null; }
+ function clearPending(){ _drawn.clearLayers(); _pending=null; }
+
+ return {init, start, stop, cancel, clearPending};
+})();
diff --git a/pertemuan_3/js/modules/map.js b/pertemuan_3/js/modules/map.js
new file mode 100644
index 0000000..ee7bc7e
--- /dev/null
+++ b/pertemuan_3/js/modules/map.js
@@ -0,0 +1,63 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 13. MAP RENDER
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+function renderMap(){
+ const db=Store.allDb();
+ // SPBU
+ App._groups.spbu.clearLayers();
+ db.spbu.forEach(r=>{
+ const m=L.circleMarker([r.lat,r.lng],{radius:9,fillColor:CFG.spbu.color(r),color:'#fff',weight:2,fillOpacity:1});
+ m.bindPopup(Popup.spbu(r),{maxWidth:320});
+ m.on('click',()=>highlightCard(r.id));
+ App._groups.spbu.addLayer(m);
+ });
+ // Warga
+ App._groups.warga.clearLayers();
+ db.warga.forEach(r=>{
+ const clr=CFG.warga.color(r);
+ // Check if in any RI radius
+ const inRadius=db.rumahIbadah.some(hub=>{
+ if(!hub.lat) return false;
+ const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
+ return d<=(parseFloat(hub.radius)||300);
+ });
+ const iconHtml=`π¨βπ©βπ§
${inRadius?'':'
'}`;
+ const icon=L.divIcon({html:`${iconHtml}
`,className:'',iconSize:[22,22],iconAnchor:[11,11]});
+ const m=L.marker([r.lat,r.lng],{icon});
+ m.bindPopup(Popup.warga(r),{maxWidth:360});
+ m.on('click',()=>highlightCard(r.id));
+ App._groups.warga.addLayer(m);
+ });
+ // Rumah Ibadah
+ App._groups.rumahIbadah.clearLayers();
+ db.rumahIbadah.forEach(r=>{
+ const clr=CFG.rumahIbadah.color(r);
+ const m=L.circleMarker([r.lat,r.lng],{radius:10,fillColor:clr,color:'#fff',weight:2.5,fillOpacity:1});
+ m.bindPopup(Popup.rumahIbadah(r),{maxWidth:320});
+ m.on('click',()=>highlightCard(r.id));
+ App._groups.rumahIbadah.addLayer(m);
+ // Always draw radius 300m coverage circle
+ const radiusM=parseFloat(r.radius)||300;
+ const ring=L.circle([r.lat,r.lng],{radius:radiusM,color:clr,weight:1.5,fillColor:clr,fillOpacity:.06,dashArray:'6 4'});
+ App._groups.rumahIbadah.addLayer(ring);
+ });
+ // Jalan
+ App._groups.jalan.clearLayers();
+ db.jalan.forEach(r=>{
+ const l=L.polyline(r.coords,{color:CFG.jalan.color(r),weight:4,opacity:.9});
+ l.bindPopup(Popup.jalan(r),{maxWidth:300});
+ l.on('click',()=>highlightCard(r.id));
+ App._groups.jalan.addLayer(l);
+ });
+ // Parsil
+ App._groups.parsil.clearLayers();
+ db.parsil.forEach(r=>{
+ const clr=CFG.parsil.color(r);
+ const p=L.polygon(r.coords,{color:clr,weight:2,fillColor:clr,fillOpacity:.2});
+ p.bindPopup(Popup.parsil(r),{maxWidth:300});
+ p.on('click',()=>highlightCard(r.id));
+ App._groups.parsil.addLayer(p);
+ });
+}
diff --git a/pertemuan_3/js/modules/modal.js b/pertemuan_3/js/modules/modal.js
new file mode 100644
index 0000000..6bcfe6f
--- /dev/null
+++ b/pertemuan_3/js/modules/modal.js
@@ -0,0 +1,119 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 9. MODAL MANAGER
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Modal = (() => {
+ let _ln=null, _editId=null, _geo=null;
+ const ov=()=>document.getElementById('modal-ov');
+
+ function openAdd(ln, leafletLayer){
+ _ln=ln; _editId=null; _geo=leafletLayer;
+ const cfg=CFG[ln];
+ document.getElementById('modal-icon').textContent=cfg.icon;
+ document.getElementById('modal-title').textContent='Tambah '+cfg.label;
+ document.getElementById('modal-sub').textContent='Isi atribut data baru';
+ let html='';
+ if(ln==='spbu') html=Forms.spbu(null, leafletLayer.getLatLng());
+ else if(ln==='jalan') html=Forms.jalan(null, H.geoLen(leafletLayer.getLatLngs()));
+ else if(ln==='parsil') html=Forms.parsil(null, H.geoArea(leafletLayer.getLatLngs()[0]));
+ else if(ln==='warga') html=Forms.warga(null, leafletLayer.getLatLng());
+ else if(ln==='rumahIbadah') html=Forms.rumahIbadah(null, leafletLayer.getLatLng());
+ document.getElementById('modal-body').innerHTML=html;
+ _bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
+ setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
+ }
+
+ function openEdit(ln, id){
+ _ln=ln; _editId=id; _geo=null;
+ const rec=Store.find(ln,id); if(!rec) return;
+ const cfg=CFG[ln];
+ document.getElementById('modal-icon').textContent=cfg.icon;
+ document.getElementById('modal-title').textContent='Edit '+cfg.label;
+ document.getElementById('modal-sub').textContent=H.esc(rec.nama);
+ let html='';
+ if(ln==='spbu') html=Forms.spbu(rec,null);
+ else if(ln==='jalan') html=Forms.jalan(rec,null);
+ else if(ln==='parsil') html=Forms.parsil(rec,null);
+ else if(ln==='warga') html=Forms.warga(rec,null);
+ else if(ln==='rumahIbadah') html=Forms.rumahIbadah(rec,null);
+ document.getElementById('modal-body').innerHTML=html;
+ _bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
+ setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
+ }
+
+ function close(){ ov().classList.add('hidden'); Draw.clearPending(); }
+
+ async function submit(){
+ const data=Forms.readAll(_ln); if(!data) return;
+ if(_editId){
+ data.updatedAt=H.today();
+ await Store.update(_ln,_editId,data);
+ Toast.success('Data berhasil diperbarui');
+ } else {
+ const geo=_geo;
+ if(_ln==='spbu'||_ln==='warga'||_ln==='rumahIbadah'){
+ const ll=geo.getLatLng(); data.lat=ll.lat; data.lng=ll.lng;
+ } else if(_ln==='jalan'){
+ data.coords=geo.getLatLngs().map(l=>[l.lat,l.lng]);
+ } else if(_ln==='parsil'){
+ data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]);
+ }
+ data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today();
+ await Store.add(_ln,data);
+ Toast.success('Data berhasil disimpan');
+ }
+ close(); App.refresh();
+ }
+
+ function _bindRadio(){
+ document.querySelectorAll('.ropt').forEach(el=>{
+ el.addEventListener('click',()=>{
+ const parent=el.closest('.rtoggle'); if(!parent) return;
+ parent.querySelectorAll('.ropt').forEach(r=>r.classList.remove('yes','no'));
+ const v=el.dataset.v; el.classList.add(v==='true'?'yes':'no');
+ const inp=el.querySelector('input'); if(inp) inp.checked=true;
+ });
+ });
+ // Bind penyakit checkboxes
+ document.querySelectorAll('.penyakit-check input').forEach(inp=>{
+ inp.addEventListener('change', e=>{
+ if(e.target.checked) e.target.parentElement.classList.add('checked');
+ else e.target.parentElement.classList.remove('checked');
+ });
+ });
+ }
+
+ function _bindFormTabs(){
+ const tabs=document.querySelectorAll('.form-tab');
+ if(!tabs.length) return;
+ tabs.forEach(tab=>{
+ tab.addEventListener('click',()=>{
+ tabs.forEach(t=>t.classList.remove('active'));
+ document.querySelectorAll('.form-section-panel').forEach(p=>p.classList.remove('active'));
+ tab.classList.add('active');
+ const panelId='panel-'+tab.dataset.panel;
+ document.getElementById(panelId)?.classList.add('active');
+ });
+ });
+ }
+
+ function _bindHistBtn(){
+ const btn=document.getElementById('btn-add-hist');
+ if(!btn) return;
+ btn.addEventListener('click',()=>{
+ const tbody=document.getElementById('hist-tbody');
+ if(!tbody) return;
+ const tr=document.createElement('tr');
+ tr.innerHTML=`
+
+
+
+ Aktif Berhenti Dalam Proses
+
+
+ β `;
+ tbody.appendChild(tr);
+ });
+ }
+
+ return {openAdd, openEdit, close, submit};
+})();
diff --git a/pertemuan_3/js/modules/sidebar.js b/pertemuan_3/js/modules/sidebar.js
new file mode 100644
index 0000000..6c8a2be
--- /dev/null
+++ b/pertemuan_3/js/modules/sidebar.js
@@ -0,0 +1,31 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 11. SIDEBAR
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+function renderSidebar(ln){
+ const cfg=CFG[ln];
+ document.getElementById('sb-title').textContent=cfg.label;
+ document.getElementById('sb-sub').textContent=cfg.sub;
+ const data=Store.all(ln);
+ const list=document.getElementById('sb-list');
+ if(!data.length){
+ list.innerHTML=`Belum ada data. Klik Tambah untuk mulai digitasi.
`;
+ return;
+ }
+ list.innerHTML=data.map(r=>Card[ln](r)).join('');
+}
+
+function updateBadges(){
+ const db=Store.allDb();
+ ['spbu','jalan','parsil','warga','rumahIbadah'].forEach(k=>{
+ const el=document.getElementById('badge-'+k);
+ if(el) el.textContent=db[k]?.length||0;
+ });
+ const total=Object.values(db).reduce((s,a)=>s+a.length,0);
+ document.getElementById('total-count').textContent=total+' fitur';
+}
+
+function highlightCard(id){
+ document.querySelectorAll('.dcard').forEach(c=>c.classList.remove('active'));
+ const c=document.getElementById('card-'+id);
+ if(c){ c.classList.add('active'); c.scrollIntoView({behavior:'smooth',block:'nearest'}); }
+}
diff --git a/pertemuan_3/js/utils/helpers.js b/pertemuan_3/js/utils/helpers.js
new file mode 100644
index 0000000..2d8aacc
--- /dev/null
+++ b/pertemuan_3/js/utils/helpers.js
@@ -0,0 +1,18 @@
+'use strict';
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 1. HELPERS
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const H = {
+ uid: () => Math.random().toString(36).slice(2,9) + Date.now().toString(36),
+ esc: s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),
+ fmDist: m => m==null?'-': m>=1000? (m/1000).toFixed(2)+' km': Math.round(m).toLocaleString('id-ID')+' m',
+ fmArea: m => m==null?'-': m>=10000? (m/10000).toFixed(4)+' ha': Math.round(m).toLocaleString('id-ID')+' mΒ²',
+ fmNum: n => (n||0).toLocaleString('id-ID'),
+ today: () => new Date().toISOString().slice(0,10),
+ geoLen: lls => { let t=0; for(let i=0;i {
+ const R=6371008.8; let a=0,n=lls.length;
+ for(let i=0;i {
+ const API_URL = '../api/index.php'; // Or absolute URL if hosted elsewhere
+
+ let db = {
+ spbu: [],
+ jalan: [],
+ parsil: [],
+ warga: [],
+ rumahIbadah: []
+ };
+
+ const request = async (action, payload = {}) => {
+ try {
+ const res = await fetch(`${API_URL}?action=${action}`, {
+ method: action === 'alldb' ? 'GET' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: action === 'alldb' ? null : JSON.stringify(payload)
+ });
+ return await res.json();
+ } catch (err) {
+ console.error('API Error:', err);
+ throw err;
+ }
+ };
+
+ return {
+ load: async () => {
+ try {
+ const data = await request('alldb');
+ if (data) {
+ db = { ...db, ...data };
+ }
+ } catch (e) {
+ console.error("Gagal load data dari server", e);
+ }
+ },
+ all: ln => db[ln]||[],
+ find: (ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
+ add: async (ln,rec) => {
+ db[ln].push(rec);
+ await request('add', { layer: ln, data: rec });
+ },
+ update: async (ln,id,patch) => {
+ const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
+ db[ln][i]={...db[ln][i],...patch};
+ await request('update', { layer: ln, id: id, patch: patch });
+ },
+ remove: async (ln,id) => {
+ db[ln]=db[ln].filter(r=>r.id!==id);
+ await request('remove', { layer: ln, id: id });
+ },
+ allDb: () => db,
+ };
+})();
diff --git a/pertemuan_3/js/utils/toast.js b/pertemuan_3/js/utils/toast.js
new file mode 100644
index 0000000..6622d78
--- /dev/null
+++ b/pertemuan_3/js/utils/toast.js
@@ -0,0 +1,9 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ 3. TOAST
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+const Toast = {
+ _show(msg,type){ const c=document.getElementById('toasts'),el=document.createElement('div');
+ el.className=`toast ${type}`;el.innerHTML=`${{success:'β',error:'β',info:'βΉ'}[type]||'βΉ'} ${H.esc(msg)}`;
+ c.appendChild(el);setTimeout(()=>{el.classList.add('out');el.addEventListener('animationend',()=>el.remove(),{once:true})},2800); },
+ success: m=>Toast._show(m,'success'), error: m=>Toast._show(m,'error'), info: m=>Toast._show(m,'info'),
+};
diff --git a/pertemuan_3/p.text b/pertemuan_3/p.text
new file mode 100644
index 0000000..39eae7b
--- /dev/null
+++ b/pertemuan_3/p.text
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/webgis/js/utils/store.js b/webgis/js/utils/store.js
deleted file mode 100644
index 23480d1..0000000
--- a/webgis/js/utils/store.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- 2. STORAGE
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-const Store = (() => {
- const KEYS = { spbu:'wg2_spbu', jalan:'wg2_jalan', parsil:'wg2_parsil', warga:'wg2_warga', rumahIbadah:'wg2_ri' };
- const _r = k => { try{ return JSON.parse(localStorage.getItem(k)||'[]') }catch{return[]} };
- const _w = (k,d) => { try{ localStorage.setItem(k,JSON.stringify(d)) }catch{} };
- let db = Object.fromEntries(Object.entries(KEYS).map(([k,v])=>[k,_r(v)]));
- return {
- all: ln => db[ln]||[],
- find:(ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
- add:(ln,rec) => { db[ln].push(rec); _w(KEYS[ln],db[ln]); },
- update:(ln,id,patch) => {
- const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
- db[ln][i]={...db[ln][i],...patch}; _w(KEYS[ln],db[ln]);
- },
- remove:(ln,id) => { db[ln]=db[ln].filter(r=>r.id!==id); _w(KEYS[ln],db[ln]); },
- allDb: () => db,
- };
-})();