Files
2026-06-10 22:12:35 +07:00

1771 lines
98 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>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS — Manajemen Jalan & Parsil Tanah</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--primary:#2B7FD4; --primary-light:#5BA4E6; --primary-pale:#EBF4FF; --primary-dark:#1A5FA8;
--bg:#F0F6FF; --surface:#FFFFFF; --surface2:#F7FAFF; --border:#C8DEFF; --border-light:#E4EFFF;
--text-primary:#0F2A4A; --text-secondary:#4A6380; --text-muted:#8BAAC8;
--jalan-nasional:#E63946; --jalan-provinsi:#F4A261; --jalan-kabupaten:#2A9D8F;
--shm:#4361EE; --hgb:#7209B7; --hgu:#F72585; --hp:#4CC9F0;
--success:#10B981; --danger:#EF4444; --amber:#F59E0B;
--rusak-baru:#FF0000; --rusak-sedang:#FFA500; --rusak-lama:#808080; --rusak-sering:#FF00FF;
--radius:14px; --radius-sm:8px;
--shadow:0 4px 24px rgba(43,127,212,.10); --shadow-lg:0 8px 40px rgba(43,127,212,.18);
--tr:all .22s cubic-bezier(.4,0,.2,1);
}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Plus Jakarta Sans',sans-serif;background:var(--bg);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column;}
/* TOPBAR */
.topbar{background:linear-gradient(135deg,#1A5FA8,#2B7FD4 60%,#38BDF8);color:#fff;padding:0 24px;height:64px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 20px rgba(26,95,168,.3);z-index:1000;flex-shrink:0;}
.topbar-brand{display:flex;align-items:center;gap:12px;}
.topbar-icon{width:38px;height:38px;background:rgba(255,255,255,.2);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:18px;}
.topbar-title{font-size:17px;font-weight:700;letter-spacing:-.3px;}
.topbar-subtitle{font-size:11px;opacity:.75;margin-top:1px;}
.topbar-nav{display:flex;gap:6px;}
.tab-btn{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:rgba(255,255,255,.85);padding:8px 18px;border-radius:10px;font-size:13px;font-weight:600;font-family:inherit;cursor:pointer;transition:var(--tr);display:flex;align-items:center;gap:7px;}
.tab-btn:hover{background:rgba(255,255,255,.22);color:#fff;}
.tab-btn.active{background:#fff;color:var(--primary-dark);border-color:#fff;box-shadow:0 2px 12px rgba(0,0,0,.15);}
/* LAYOUT */
.app-layout{display:flex;flex:1;overflow:hidden;}
/* SIDEBAR */
.sidebar{width:340px;background:var(--surface);border-right:1px solid var(--border-light);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;position:relative;}
/* PANELS */
.panel{display:flex;flex-direction:column;height:100%;position:absolute;inset:0;transition:transform .28s cubic-bezier(.4,0,.2,1);background:var(--surface);}
.panel.list-panel{transform:translateX(0);z-index:1;}
.panel.list-panel.hidden{transform:translateX(-100%);}
.panel.edit-panel{transform:translateX(100%);z-index:2;}
.panel.edit-panel.open{transform:translateX(0);}
/* LIST PANEL header */
.sidebar-header{padding:16px 16px 12px;border-bottom:1px solid var(--border-light);background:var(--surface2);flex-shrink:0;}
.sidebar-title{font-size:14px;font-weight:700;color:var(--text-primary);margin-bottom:10px;display:flex;align-items:center;gap:8px;}
.draw-tip{background:linear-gradient(135deg,#EFF6FF,#EBF4FF);border:1.5px solid var(--primary-light);border-radius:var(--radius-sm);padding:10px 12px;margin-bottom:10px;}
.draw-tip-title{font-weight:700;font-size:12px;color:var(--primary-dark);margin-bottom:4px;}
.draw-tip-steps{display:flex;flex-direction:column;gap:3px;}
.draw-tip-step{display:flex;align-items:flex-start;gap:6px;font-size:11px;color:var(--text-secondary);line-height:1.4;}
.step-num{background:var(--primary);color:#fff;border-radius:50%;width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:1px;}
.sb-search{position:relative;}
.sb-search input{width:100%;padding:8px 12px 8px 34px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;background:#fff;color:var(--text-primary);transition:var(--tr);}
.sb-search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(43,127,212,.12);}
.sb-search::before{content:'🔍';position:absolute;left:10px;top:50%;transform:translateY(-50%);font-size:12px;pointer-events:none;}
.filter-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
.chip{padding:3px 9px;border-radius:20px;font-size:11px;font-weight:600;border:1.5px solid var(--border);cursor:pointer;transition:var(--tr);font-family:inherit;background:var(--surface);color:var(--text-secondary);}
.chip.active-all{background:var(--primary);color:#fff;border-color:var(--primary);}
.chip.active-nasional{background:var(--jalan-nasional);color:#fff;border-color:var(--jalan-nasional);}
.chip.active-provinsi{background:var(--jalan-provinsi);color:#fff;border-color:var(--jalan-provinsi);}
.chip.active-kabupaten{background:var(--jalan-kabupaten);color:#fff;border-color:var(--jalan-kabupaten);}
.chip.active-shm{background:var(--shm);color:#fff;border-color:var(--shm);}
.chip.active-hgb{background:var(--hgb);color:#fff;border-color:var(--hgb);}
.chip.active-hgu{background:var(--hgu);color:#fff;border-color:var(--hgu);}
.chip.active-hp{background:var(--hp);color:#fff;border-color:var(--hp);}
.chip.active-baru{background:var(--rusak-baru);color:#fff;border-color:var(--rusak-baru);}
.chip.active-1bln{background:#E53935;color:#fff;border-color:#E53935;}
.chip.active-3thn{background:#F59E0B;color:#fff;border-color:#F59E0B;}
.chip.active-ringan{background:#22C55E;color:#fff;border-color:#22C55E;}
.chip.active-sedang{background:#F59E0B;color:#fff;border-color:#F59E0B;}
.chip.active-berat{background:#EF4444;color:#fff;border-color:#EF4444;}
/* Filter dropdowns for laporan */
.filter-dropdowns{display:flex;flex-direction:row;gap:5px;margin-top:8px;}
.filter-row{display:flex;align-items:center;gap:4px;flex:1;}
.filter-row label{font-size:10px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}
.filter-select{flex:1;padding:3px 8px;border:1.5px solid var(--border);border-radius:20px;font-size:11px;font-family:inherit;font-weight:600;color:var(--text-secondary);background:var(--surface);cursor:pointer;transition:var(--tr);}
.filter-select:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(43,127,212,.12);}
.filter-select.active-filter{border-color:var(--primary);background:var(--primary);color:#fff;font-weight:700;}
.stats-strip{display:flex;gap:6px;padding:10px 16px;border-bottom:1px solid var(--border-light);background:var(--primary-pale);flex-shrink:0;}
.stat-chip{flex:1;background:#fff;border:1px solid var(--border);border-radius:8px;padding:7px 8px;text-align:center;}
.stat-chip .num{font-size:18px;font-weight:700;color:var(--primary);display:block;line-height:1;}
.stat-chip .lbl{font-size:9px;color:var(--text-muted);margin-top:2px;display:block;}
.data-list{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px;}
.data-list::-webkit-scrollbar{width:4px;}
.data-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
.data-card{background:var(--surface);border:1px solid var(--border-light);border-radius:var(--radius-sm);padding:9px 12px 9px 16px;cursor:pointer;transition:var(--tr);position:relative;}
.data-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;border-radius:4px 0 0 4px;}
.data-card.jalan-nasional::before{background:var(--jalan-nasional);}
.data-card.jalan-provinsi::before{background:var(--jalan-provinsi);}
.data-card.jalan-kabupaten::before{background:var(--jalan-kabupaten);}
.data-card.shm::before{background:var(--shm);}
.data-card.hgb::before{background:var(--hgb);}
.data-card.hgu::before{background:var(--hgu);}
.data-card.hp::before{background:var(--hp);}
.data-card.rusak-baru::before{background:var(--rusak-baru);}
.data-card.rusak-lama::before{background:var(--rusak-lama);}
.data-card.rusak-sering::before{background:var(--rusak-sering);}
.data-card:hover{border-color:var(--primary-light);background:var(--primary-pale);transform:translateX(2px);box-shadow:var(--shadow);}
.data-card.selected{border-color:var(--primary);background:var(--primary-pale);box-shadow:0 0 0 2px rgba(43,127,212,.15);}
.card-row-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:5px;}
.card-name{font-size:13px;font-weight:700;color:var(--text-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.card-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
.badge{font-size:10px;font-weight:600;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;}
.badge-nasional{background:#FEE2E2;color:var(--jalan-nasional);}
.badge-provinsi{background:#FEF3C7;color:#D97706;}
.badge-kabupaten{background:#D1FAE5;color:#065F46;}
.badge-shm{background:#EEF2FF;color:var(--shm);}
.badge-hgb{background:#F3E8FF;color:var(--hgb);}
.badge-hgu{background:#FFF0F6;color:var(--hgu);}
.badge-hp{background:#E0F7FA;color:#0277BD;}
.badge-ringan{background:#DCFCE7;color:#15803D;}
.badge-sedang{background:#FEF3C7;color:#D97706;}
.badge-berat{background:#FEE2E2;color:#DC2626;}
.badge-sering{background:#FDF4FF;color:#9333EA;}
.card-measure{font-size:11px;color:var(--text-secondary);font-family:'DM Mono',monospace;white-space:nowrap;}
.card-actions{display:flex;gap:4px;flex-shrink:0;}
.btn-sm{padding:4px 8px;border-radius:6px;font-size:11px;font-weight:600;font-family:inherit;cursor:pointer;border:1px solid transparent;transition:var(--tr);display:flex;align-items:center;gap:3px;white-space:nowrap;flex-shrink:0;}
.btn-edit{background:var(--primary-pale);color:var(--primary);border-color:var(--border);}
.btn-edit:hover{background:var(--primary);color:#fff;}
.btn-del{background:#FEF2F2;color:var(--danger);border-color:#FEE2E2;}
.btn-del:hover{background:var(--danger);color:#fff;}
.legend-panel{padding:8px 16px;border-top:1px solid var(--border-light);background:var(--surface2);flex-shrink:0;}
.legend-title{font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:7px;}
.legend-items{display:flex;flex-direction:column;gap:4px;}
.legend-item{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
.legend-line{width:18px;height:4px;border-radius:2px;flex-shrink:0;}
.collapsible-header{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;}
.collapsible-header:hover .collapse-toggle{color:var(--primary);}
.collapse-toggle{font-size:11px;color:var(--text-muted);background:var(--border-light);border:none;border-radius:4px;padding:1px 6px;cursor:pointer;font-family:inherit;font-weight:600;transition:var(--tr);flex-shrink:0;}
.collapse-toggle:hover{background:var(--primary);color:#fff;}
.collapsible-body{overflow:hidden;transition:max-height .25s ease, opacity .2s ease;}
.collapsible-body.collapsed{max-height:0!important;opacity:0;pointer-events:none;}
/* Laporan tip box */
.laporan-tip{background:linear-gradient(135deg,#FFF5F5,#FEE2E2);border:1.5px solid #FECACA;border-radius:var(--radius-sm);padding:10px 12px;margin-bottom:10px;}
.laporan-tip-title{font-weight:700;font-size:12px;color:#991B1B;margin-bottom:4px;}
.laporan-tip-steps{display:flex;flex-direction:column;gap:3px;}
.laporan-tip-step{display:flex;align-items:flex-start;gap:6px;font-size:11px;color:#7F1D1D;line-height:1.4;}
.step-num-red{background:#DC2626;color:#fff;border-radius:50%;width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:1px;}
/* Sering rusak warning banner */
.sering-rusak-banner{background:linear-gradient(135deg,#FDF4FF,#F3E8FF);border:1.5px solid #D8B4FE;border-radius:var(--radius-sm);padding:8px 12px;margin-bottom:8px;}
.sering-rusak-title{font-size:11px;font-weight:700;color:#7C3AED;margin-bottom:4px;display:flex;align-items:center;gap:5px;}
.sering-rusak-item{font-size:11px;color:#6D28D9;padding:2px 0;}
/* EDIT PANEL */
.ep-header{padding:13px 14px 11px;background:linear-gradient(135deg,#FFFBEB,#FEF3C7);border-bottom:2px solid var(--amber);display:flex;align-items:center;gap:10px;flex-shrink:0;}
.ep-back{width:32px;height:32px;border-radius:8px;background:#fff;border:1px solid #FDE68A;cursor:pointer;font-size:16px;color:#92400E;display:flex;align-items:center;justify-content:center;transition:var(--tr);flex-shrink:0;}
.ep-back:hover{background:var(--danger);color:#fff;border-color:var(--danger);}
.ep-title-wrap{flex:1;min-width:0;}
.ep-name{font-size:13px;font-weight:700;color:#78350F;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ep-sub{font-size:10px;color:#B45309;margin-top:1px;}
.ep-body{flex:1;overflow-y:auto;padding:16px;}
.ep-body::-webkit-scrollbar{width:4px;}
.ep-body::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
.fg{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;}
.fg-2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;}
.fl{font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;}
input,select,textarea{padding:9px 11px;border:1.5px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-primary);background:var(--surface2);transition:var(--tr);width:100%;}
textarea{resize:vertical;min-height:70px;}
input[type=file]{padding:6px 11px;cursor:pointer;}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--amber);background:#fff;box-shadow:0 0 0 3px rgba(245,158,11,.1);}
input[readonly]{background:var(--primary-pale);color:var(--primary-dark);font-family:'DM Mono',monospace;font-weight:600;cursor:default;}
.sdiv{font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.8px;display:flex;align-items:center;gap:8px;margin:4px 0 8px;}
.sdiv::after{content:'';flex:1;height:1px;background:var(--border-light);}
.cs{background:var(--primary-pale);border:1.5px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;}
.cs-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
.cs-lbl{font-size:11px;font-weight:700;color:var(--primary);text-transform:uppercase;letter-spacing:.5px;}
.cs-cnt{font-size:10px;background:var(--primary);color:#fff;border-radius:20px;padding:2px 8px;font-weight:600;}
.coord-list{display:flex;flex-direction:column;gap:2px;max-height:130px;overflow-y:auto;}
.coord-list::-webkit-scrollbar{width:3px;}
.coord-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
.crd{display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:4px;font-family:'DM Mono',monospace;font-size:11px;color:var(--text-secondary);background:#fff;border:1px solid var(--border-light);}
.crd-n{background:var(--border-light);color:var(--text-muted);border-radius:4px;padding:1px 5px;font-weight:700;font-size:10px;min-width:20px;text-align:center;flex-shrink:0;}
.crd-coords{flex:1;display:flex;gap:5px;flex-wrap:wrap;}
.crd-del{width:18px;height:18px;border-radius:4px;border:none;background:none;color:var(--text-muted);cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:var(--tr);}
.crd-del:hover{background:#FEE2E2;color:var(--danger);}
.ep-footer{padding:12px 14px;border-top:1px solid var(--border-light);background:var(--surface2);display:flex;gap:8px;flex-shrink:0;}
.ep-btn{flex:1;padding:9px 10px;border-radius:var(--radius-sm);font-size:12px;font-weight:700;font-family:inherit;cursor:pointer;transition:var(--tr);display:flex;align-items:center;justify-content:center;gap:5px;border:none;}
.ep-save{background:linear-gradient(135deg,var(--success),#059669);color:#fff;box-shadow:0 3px 12px rgba(16,185,129,.25);}
.ep-save:hover{transform:translateY(-1px);}
.ep-cancel{background:var(--surface);color:var(--text-secondary);border:1.5px solid var(--border);}
.ep-cancel:hover{border-color:var(--danger);color:var(--danger);}
/* MAP */
.map-wrapper{flex:1;position:relative;overflow:hidden;}
#map{width:100%;height:100%;}
.map-toolbar{position:absolute;top:14px;right:14px;z-index:900;display:flex;flex-direction:column;gap:8px;}
.map-tool-btn{background:#fff;border:1px solid var(--border);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:600;font-family:inherit;cursor:pointer;display:flex;align-items:center;gap:7px;color:var(--text-primary);transition:var(--tr);box-shadow:var(--shadow);white-space:nowrap;}
.map-tool-btn:hover{background:var(--primary-pale);border-color:var(--primary);color:var(--primary);}
/* vertex toolbar hidden */
.vtb{display:none;}
/* Draw banner */
.draw-banner{position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:900;background:var(--primary);color:#fff;padding:8px 20px;border-radius:30px;font-size:13px;font-weight:600;display:none;align-items:center;gap:8px;box-shadow:var(--shadow-lg);pointer-events:none;white-space:nowrap;animation:pulseB 2s ease-in-out infinite;}
.draw-banner.show{display:flex;}
.draw-banner.laporan{background:#DC2626;}
@keyframes pulseB{0%,100%{box-shadow:0 4px 20px rgba(43,127,212,.4);}50%{box-shadow:0 4px 32px rgba(43,127,212,.75);}}
.coord-display{position:absolute;bottom:14px;left:14px;z-index:900;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-family:'DM Mono',monospace;font-size:11px;color:var(--text-secondary);box-shadow:var(--shadow);}
/* ADD MODAL */
.modal-overlay{position:fixed;inset:0;background:rgba(15,42,74,.45);backdrop-filter:blur(4px);z-index:2000;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:var(--tr);}
.modal-overlay.show{opacity:1;visibility:visible;}
.modal{background:#fff;border-radius:18px;width:500px;max-width:calc(100vw - 32px);max-height:calc(100vh - 48px);overflow:hidden;display:flex;flex-direction:column;box-shadow:0 24px 80px rgba(15,42,74,.25);transform:translateY(20px) scale(.97);transition:var(--tr);}
.modal-overlay.show .modal{transform:none;}
.modal-header{padding:18px 22px 14px;background:linear-gradient(135deg,var(--primary-pale),#E0EEFF);border-bottom:1px solid var(--border-light);display:flex;align-items:center;justify-content:space-between;}
.modal-title{font-size:16px;font-weight:700;color:var(--primary-dark);display:flex;align-items:center;gap:10px;}
.modal-close{width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:#fff;cursor:pointer;font-size:15px;display:flex;align-items:center;justify-content:center;transition:var(--tr);color:var(--text-secondary);}
.modal-close:hover{background:var(--danger);color:#fff;border-color:var(--danger);}
.modal-body{padding:18px 22px;overflow-y:auto;flex:1;}
.modal-body::-webkit-scrollbar{width:4px;}
.modal-body::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
.modal-footer{padding:14px 22px;border-top:1px solid var(--border-light);display:flex;justify-content:flex-end;gap:10px;background:var(--surface2);}
.mg{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;}
.mfull{grid-column:1/-1;}
.mfl{font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:5px;}
.btn-cancel{padding:9px 18px;border:1.5px solid var(--border);border-radius:var(--radius-sm);background:#fff;color:var(--text-secondary);font-size:13px;font-weight:600;font-family:inherit;cursor:pointer;transition:var(--tr);}
.btn-cancel:hover{border-color:var(--danger);color:var(--danger);}
.btn-save{padding:9px 22px;border:none;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--primary),var(--primary-light));color:#fff;font-size:13px;font-weight:700;font-family:inherit;cursor:pointer;transition:var(--tr);box-shadow:0 3px 12px rgba(43,127,212,.3);display:flex;align-items:center;gap:8px;}
.btn-save:hover{transform:translateY(-1px);}
.btn-save-red{background:linear-gradient(135deg,#DC2626,#EF4444);}
/* Anggaran info box */
.anggaran-box{background:linear-gradient(135deg,#EFF6FF,#DBEAFE);border:1.5px solid #93C5FD;border-radius:var(--radius-sm);padding:10px 12px;margin-top:4px;}
.anggaran-pengelola{font-size:13px;font-weight:700;color:#1D4ED8;}
.anggaran-sumber{font-size:11px;color:#3B82F6;margin-top:2px;}
/* Foto preview */
.foto-preview{width:100%;border-radius:8px;margin-top:8px;display:none;max-height:180px;object-fit:cover;border:2px solid var(--border);}
/* Toast */
.toast-container{position:fixed;bottom:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:#fff;border-radius:12px;padding:12px 18px;box-shadow:0 4px 30px rgba(0,0,0,.12);display:flex;align-items:center;gap:10px;font-size:13px;font-weight:600;min-width:260px;border-left:4px solid transparent;animation:sIn .3s ease;}
.toast.success{border-color:var(--success);color:#065F46;}
.toast.error{border-color:var(--danger);color:#7F1D1D;}
.toast.info{border-color:var(--primary);color:var(--primary-dark);}
@keyframes sIn{from{transform:translateX(80px);opacity:0;}to{transform:translateX(0);opacity:1;}}
.empty-state{text-align:center;padding:40px 20px;color:var(--text-muted);}
.empty-state .icon{font-size:40px;margin-bottom:12px;}
.empty-state p{font-size:13px;line-height:1.6;}
/* Leaflet popup */
body.edit-mode .leaflet-popup{display:none!important;}
.leaflet-popup-content-wrapper{border-radius:12px!important;box-shadow:var(--shadow-lg)!important;border:1px solid var(--border)!important;font-family:'Plus Jakarta Sans',sans-serif!important;}
.leaflet-popup-content{margin:0!important;}
.popup-content{padding:14px 16px;min-width:220px;}
.popup-title{font-size:14px;font-weight:700;color:var(--text-primary);margin-bottom:6px;}
.popup-row{font-size:12px;color:var(--text-secondary);margin-bottom:3px;display:flex;gap:6px;}
.popup-row strong{color:var(--text-primary);min-width:70px;}
.popup-row.highlight{color:#1D4ED8;font-weight:600;}
.popup-actions{margin-top:10px;display:flex;gap:6px;}
.pb{flex:1;padding:6px;border-radius:6px;border:1px solid var(--border);font-size:11px;font-weight:600;font-family:inherit;cursor:pointer;transition:var(--tr);text-align:center;}
.pb-edit{background:var(--primary-pale);color:var(--primary);}
.pb-del{background:#FEF2F2;color:var(--danger);}
.pb-edit:hover{background:var(--primary);color:#fff;}
.pb-del:hover{background:var(--danger);color:#fff;}
.popup-foto{width:100%;max-height:120px;object-fit:cover;border-radius:8px;margin:8px 0 4px;border:1px solid var(--border);}
/* Vertex marker dots */
.vm-dot{width:22px;height:22px;border-radius:50%;background:#fff;border:3px solid #F59E0B;box-shadow:0 2px 10px rgba(0,0,0,.4);cursor:grab;transition:transform .1s,background .1s;}
.vm-dot:hover,.vm-dot.hov{background:#FEF3C7;transform:scale(1.25);}
.vm-dot:active{cursor:grabbing;}
/* Leaflet Layer Control Styling & Alignment Fix */
.leaflet-control-layers {
border-radius: 10px !important;
border: 1px solid var(--border) !important;
box-shadow: var(--shadow) !important;
}
.leaflet-control-layers-expanded {
font-family: inherit !important;
padding: 8px 12px !important;
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(8px);
}
.leaflet-control-layers-selector {
width: auto !important;
height: auto !important;
padding: 0 !important;
margin: 0 8px 0 0 !important;
background: none !important;
border: none !important;
box-shadow: none !important;
cursor: pointer;
display: inline-block !important;
}
.leaflet-control-layers label {
display: flex !important;
align-items: center !important;
font-size: 13px !important;
color: var(--text-primary) !important;
cursor: pointer !important;
margin-bottom: 6px !important;
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid var(--border-light) !important;
}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-brand">
<div class="topbar-icon">🗺️</div>
<div>
<div class="topbar-title">WebGIS Manajemen Infrastruktur</div>
<div class="topbar-subtitle">Sistem Informasi Geografis — Jalan, Parsil & Laporan Kerusakan</div>
</div>
</div>
<div class="topbar-nav">
<button class="tab-btn active" id="tab-jalan" onclick="switchTab('jalan')">🛣️ Data Jalan</button>
<button class="tab-btn" id="tab-parsil" onclick="switchTab('parsil')">🏘️ Parsil Tanah</button>
<button class="tab-btn" id="tab-laporan" onclick="switchTab('laporan')">⚠️ Laporan Kerusakan</button>
</div>
</div>
<div class="app-layout">
<aside class="sidebar">
<!-- LIST PANEL -->
<div class="panel list-panel" id="panel-list">
<div class="sidebar-header">
<div class="sidebar-title" id="sidebar-title"><span>🛣️</span> Manajemen Jalan</div>
<div id="draw-tip-box" class="draw-tip">
<div class="collapsible-header" onclick="toggleCollapse('draw-tip-steps-body','draw-tip-toggle')">
<div class="draw-tip-title" style="margin-bottom:0">✏️ Cara Tambah Data</div>
<button class="collapse-toggle" id="draw-tip-toggle"></button>
</div>
<div class="collapsible-body" id="draw-tip-steps-body" style="max-height:120px;opacity:1;margin-top:6px">
<div class="draw-tip-steps">
<div class="draw-tip-step"><div class="step-num">1</div><span>Klik <b>"Gambar di Peta"</b> di kanan</span></div>
<div class="draw-tip-step"><div class="step-num">2</div><span>Klik peta untuk menambah titik koordinat</span></div>
<div class="draw-tip-step"><div class="step-num">3</div><span><b>Double-click</b> untuk selesai &amp; isi data</span></div>
</div>
</div>
</div>
<div class="sb-search"><input type="text" id="search-input" placeholder="Cari data..." oninput="filterData()"></div>
<div class="filter-chips" id="filter-chips">
<button class="chip active-all" onclick="setFilter('all')">Semua</button>
<button class="chip" onclick="setFilter('Jalan Nasional')">Nasional</button>
<button class="chip" onclick="setFilter('Jalan Provinsi')">Provinsi</button>
<button class="chip" onclick="setFilter('Jalan Kabupaten')">Kabupaten</button>
</div>
</div>
<div class="stats-strip">
<div class="stat-chip"><span class="num" id="stat-total">0</span><span class="lbl">Total</span></div>
<div class="stat-chip"><span class="num" id="stat-2">0</span><span class="lbl" id="stat-2-lbl">Nasional</span></div>
<div class="stat-chip"><span class="num" id="stat-3">0</span><span class="lbl" id="stat-3-lbl">Provinsi</span></div>
<div class="stat-chip"><span class="num" id="stat-4">0</span><span class="lbl" id="stat-4-lbl">Kabupaten</span></div>
</div>
<div id="sering-rusak-container"></div>
<div class="data-list" id="data-list"><div class="empty-state"><div class="icon">📡</div><p>Memuat data...</p></div></div>
<div class="legend-panel">
<div class="collapsible-header" onclick="toggleCollapse('legend-items-body','legend-toggle')">
<div class="legend-title" id="legend-title" style="margin-bottom:0">Legenda Jalan</div>
<button class="collapse-toggle" id="legend-toggle"></button>
</div>
<div class="collapsible-body" id="legend-items-body" style="max-height:120px;opacity:1;margin-top:6px">
<div class="legend-items" id="legend-items">
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-nasional)"></div><span>Jalan Nasional</span></div>
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-provinsi)"></div><span>Jalan Provinsi</span></div>
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-kabupaten)"></div><span>Jalan Kabupaten</span></div>
</div>
</div>
</div>
</div>
<!-- EDIT PANEL -->
<div class="panel edit-panel" id="panel-edit">
<div class="ep-header">
<button class="ep-back" onclick="closeEditPanel()"></button>
<div class="ep-title-wrap">
<div class="ep-name" id="ep-name">Edit Data</div>
<div class="ep-sub" id="ep-sub">✥ Drag shape untuk geser · Drag titik untuk geser vertex</div>
</div>
</div>
<div class="ep-body">
<!-- Jalan form -->
<div id="ep-form-jalan">
<div class="fg"><div class="fl">Nama Jalan *</div><input type="text" id="ep-j-nama" placeholder="Contoh: Jalan Ahmad Yani"></div>
<div class="fg-2">
<div class="fg" style="margin-bottom:0"><div class="fl">Status *</div>
<select id="ep-j-status"><option value="">-- Pilih --</option><option value="Jalan Nasional">Jalan Nasional</option><option value="Jalan Provinsi">Jalan Provinsi</option><option value="Jalan Kabupaten">Jalan Kabupaten</option></select>
</div>
<div class="fg" style="margin-bottom:0"><div class="fl">Panjang (meter)</div><input type="text" id="ep-j-panjang" readonly placeholder="Otomatis"></div>
</div>
<div style="margin-top:12px"><div class="sdiv">📍 Koordinat Polyline</div>
<div class="cs"><div class="cs-hdr"><div class="cs-lbl">📌 Titik Jalan</div><div class="cs-cnt" id="ep-j-cnt">0 titik</div></div><div class="coord-list" id="ep-j-cl"></div></div>
</div>
</div>
<!-- Parsil form -->
<div id="ep-form-parsil" style="display:none">
<div class="fg-2">
<div class="fg" style="margin-bottom:0"><div class="fl">No. Sertifikat</div><input type="text" id="ep-p-nosert" placeholder="SHM-2024-001"></div>
<div class="fg" style="margin-bottom:0"><div class="fl">Status *</div>
<select id="ep-p-status"><option value="">-- Pilih --</option><option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option></select>
</div>
</div>
<div class="fg-2" style="margin-top:10px">
<div class="fg" style="margin-bottom:0"><div class="fl">Luas (m²)</div><input type="text" id="ep-p-luas" readonly></div>
<div class="fg" style="margin-bottom:0"><div class="fl">Nama Pemilik</div><input type="text" id="ep-p-pemilik"></div>
</div>
<div style="margin-top:12px"><div class="sdiv">📍 Koordinat Polygon</div>
<div class="cs"><div class="cs-hdr"><div class="cs-lbl">📌 Titik Parsil</div><div class="cs-cnt" id="ep-p-cnt">0 titik</div></div><div class="coord-list" id="ep-p-cl"></div></div>
</div>
</div>
</div>
<div class="ep-footer">
<button class="ep-btn ep-cancel" onclick="closeEditPanel()">✕ Batal</button>
<button class="ep-btn ep-save" onclick="saveEditData()">💾 Simpan</button>
</div>
</div>
</aside>
<div class="map-wrapper">
<div id="map"></div>
<div class="draw-banner" id="draw-banner">✏️ <span id="banner-text">Klik titik, Double-click selesai</span></div>
<div class="map-toolbar">
<button class="map-tool-btn" onclick="map.setView(defaultCenter,defaultZoom)">🏠 Reset</button>
<button class="map-tool-btn" id="btn-draw" onclick="startDraw()">✏️ Gambar di Peta</button>
<button class="map-tool-btn" id="btn-cancel-draw" onclick="cancelDraw()" style="display:none;background:#FEF2F2;color:var(--danger);border-color:#FEE2E2;">✕ Batal Gambar</button>
</div>
<div class="coord-display" id="coord-display">Lat: — | Lng: —</div>
</div>
</div>
<!-- ADD MODAL (Jalan & Parsil) -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-title">🛣️ Tambah Data Jalan</div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<div id="add-form-jalan">
<div class="mg">
<div class="mfull"><label class="mfl">Nama Jalan *</label><input type="text" id="add-j-nama" placeholder="Contoh: Jalan Ahmad Yani"></div>
<div><label class="mfl">Status Jalan *</label>
<select id="add-j-status"><option value="">-- Pilih --</option><option value="Jalan Nasional">Jalan Nasional</option><option value="Jalan Provinsi">Jalan Provinsi</option><option value="Jalan Kabupaten">Jalan Kabupaten</option></select>
</div>
<div><label class="mfl">Panjang (meter)</label><input type="text" id="add-j-panjang" readonly></div>
<div class="mfull"><div class="sdiv" style="margin-top:4px">📍 Koordinat</div>
<div class="cs"><div class="cs-hdr"><div class="cs-lbl">📌 Titik Polyline</div><div class="cs-cnt" id="add-j-cnt">0 titik</div></div><div class="coord-list" id="add-j-cl"></div></div>
</div>
</div>
</div>
<div id="add-form-parsil" style="display:none">
<div class="mg">
<div><label class="mfl">No. Sertifikat</label><input type="text" id="add-p-nosert" placeholder="SHM-2024-001"></div>
<div><label class="mfl">Status *</label><select id="add-p-status"><option value="">-- Pilih --</option><option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option></select></div>
<div><label class="mfl">Luas (m²)</label><input type="text" id="add-p-luas" readonly></div>
<div><label class="mfl">Nama Pemilik</label><input type="text" id="add-p-pemilik"></div>
<div class="mfull"><div class="sdiv" style="margin-top:4px">📍 Koordinat</div>
<div class="cs"><div class="cs-hdr"><div class="cs-lbl">📌 Titik Polygon</div><div class="cs-cnt" id="add-p-cnt">0 titik</div></div><div class="coord-list" id="add-p-cl"></div></div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeModal()">Batal</button>
<button class="btn-save" onclick="saveNew()">💾 Simpan Data</button>
</div>
</div>
</div>
<!-- MODAL LAPORAN KERUSAKAN -->
<div class="modal-overlay" id="modal-laporan">
<div class="modal">
<div class="modal-header" style="background:linear-gradient(135deg,#FFF5F5,#FEE2E2);border-bottom:1px solid #FECACA;">
<div class="modal-title" style="color:#991B1B;">⚠️ Laporan Kerusakan Jalan</div>
<button class="modal-close" onclick="closeLaporModal()"></button>
</div>
<div class="modal-body">
<!-- Auto-detected info -->
<div class="fg">
<div class="fl">📍 Koordinat Titik (Otomatis)</div>
<input type="text" id="lapor-coords" readonly>
</div>
<div class="fg">
<div class="fl">🛣️ Jalan Terdekat (Otomatis)</div>
<input type="text" id="lapor-jalan-nama" readonly placeholder="Deteksi otomatis...">
</div>
<div class="fg">
<div class="fl">🏛️ Instansi Penanggung Jawab</div>
<div class="anggaran-box">
<div class="anggaran-pengelola" id="lapor-pengelola"></div>
<div class="anggaran-sumber" id="lapor-sumber"></div>
</div>
</div>
<div class="fg-2">
<div class="fg" style="margin-bottom:0">
<div class="fl">Kategori Kerusakan *</div>
<select id="lapor-kategori">
<option value="Ringan">🟢 Ringan</option>
<option value="Sedang" selected>🟡 Sedang</option>
<option value="Berat">🔴 Berat</option>
</select>
</div>
<div class="fg" style="margin-bottom:0">
<div class="fl">Tanggal Laporan</div>
<input type="date" id="lapor-tanggal">
</div>
</div>
<div class="fg">
<div class="fl">Nama Pelapor</div>
<input type="text" id="lapor-nama-pelapor" placeholder="Nama (opsional, boleh anonim)">
</div>
<div class="fg">
<div class="fl">Keterangan Kerusakan</div>
<textarea id="lapor-keterangan" placeholder="Deskripsikan kondisi kerusakan..."></textarea>
</div>
<div class="fg">
<div class="fl">📷 Foto Kerusakan (Geotagging)</div>
<input type="file" id="lapor-foto" accept="image/*" onchange="previewFoto(this)">
<img id="lapor-foto-preview" class="foto-preview" alt="Preview foto">
<div style="font-size:10px;color:var(--text-muted);margin-top:4px;">💡 Koordinat GPS dari foto akan digunakan jika tersedia</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeLaporModal()">Batal</button>
<button class="btn-save btn-save-red" onclick="saveLaporan()">🚀 Kirim Laporan</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ══════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════
let currentTab = 'jalan', currentFilter = 'all', currentSearch = '';
let currentFilterTingkat = 'all', currentFilterPeriode = 'all';
// FIX #1: allData.laporan diinisialisasi sebagai array kosong
let allData = { jalan: [], parsil: [], laporan: [] };
let seringRusakList = []; // jalan yang sering rusak (>= 3 laporan / 3 tahun)
let mapLayers = { jalan: {}, parsil: {}, laporan: {} };
let dataLoaded = { jalan: false, parsil: false, laporan: false };
// Draw
let isDrawMode = false, drawPts = [], drawMkrs = [], drawSolid = null, drawGhost = null, drawFill = null;
// Edit
let isEditMode = false, editId = null, editType = null;
let editCoords = [], editOrigCoords = [];
let vMkrs = [], midMkrs = [], editLyr = null;
var _sdOn = false, _sdAnchor = null, _sdOrig = null;
const defaultCenter = [-0.0263, 109.3425], defaultZoom = 15, API = 'api';
const JC = { 'Jalan Nasional': '#E63946', 'Jalan Provinsi': '#F4A261', 'Jalan Kabupaten': '#2A9D8F' };
const PC = { SHM: '#4361EE', HGB: '#7209B7', HGU: '#F72585', HP: '#4CC9F0' };
// ── Anggaran berdasarkan status jalan ──
const ANGGARAN = {
'Jalan Nasional': { pengelola: 'Kementerian PUPR', sumber: 'APBN (Anggaran Pendapatan & Belanja Negara)' },
'Jalan Provinsi': { pengelola: 'Dinas PUPR Provinsi', sumber: 'APBD Provinsi' },
'Jalan Kabupaten': { pengelola: 'Dinas PUPR Kabupaten/Kota', sumber: 'APBD Kabupaten/Kota' },
};
// ══════════════════════════════════════════════
// LOCAL STORAGE
// ══════════════════════════════════════════════
function saveLS() {
try {
localStorage.setItem('wg_j', JSON.stringify(allData.jalan));
localStorage.setItem('wg_p', JSON.stringify(allData.parsil));
localStorage.setItem('wg_l', JSON.stringify(allData.laporan));
} catch(e) {}
}
function loadLS() {
try {
const j = localStorage.getItem('wg_j'), p = localStorage.getItem('wg_p'), l = localStorage.getItem('wg_l');
if (j) allData.jalan = JSON.parse(j);
if (p) allData.parsil = JSON.parse(p);
if (l) allData.laporan = JSON.parse(l);
return !!(j || p || l);
} catch(e) { return false; }
}
// ══════════════════════════════════════════════
// SPATIAL UTILITIES
// ══════════════════════════════════════════════
function findJalanByPoint(lat, lng) {
if (!allData.jalan.length) return null;
let point = turf.point([lng, lat]);
let terdekat = null, jarakMin = Infinity;
allData.jalan.forEach(j => {
if (!j.koordinat || j.koordinat.length < 2) return;
try {
let line = turf.lineString(j.koordinat.map(c => [c[1], c[0]]));
let distance = turf.pointToLineDistance(point, line, { units: 'kilometers' });
if (distance < 0.1 && distance < jarakMin) { // toleransi 100m
jarakMin = distance;
terdekat = j;
}
} catch(e) {}
});
return terdekat;
}
// Hitung usia laporan dalam bulan
function usiaBulan(tanggal) {
return (new Date() - new Date(tanggal)) / (1000 * 60 * 60 * 24 * 30);
}
// Warna marker berdasarkan usia & status sering rusak
function getStyleLaporan(item) {
const bln = usiaBulan(item.tanggal_laporan);
const isSering = seringRusakList.some(s => s.jalan_id === item.jalan_id);
if (isSering) return { color: '#9333EA', fillColor: '#C084FC', radius: 10 }; // Sering rusak = ungu
if (bln <= 1) return { color: '#DC2626', fillColor: '#EF4444', radius: 9 }; // < 1 bulan = merah
if (bln <= 12) return { color: '#D97706', fillColor: '#F59E0B', radius: 8 }; // < 1 tahun = oranye
if (bln <= 36) return { color: '#6B7280', fillColor: '#9CA3AF', radius: 7 }; // < 3 tahun = abu
return { color: '#9CA3AF', fillColor: '#D1D5DB', radius: 6 }; // > 3 tahun = abu muda
}
// ══════════════════════════════════════════════
// GeoJSON BUILDERS
// ══════════════════════════════════════════════
function gjLine(n, s, p, lc) {
return { type:'Feature', properties:{ nama_jalan:n, status_jalan:s, panjang_meter:p }, geometry:{ type:'LineString', coordinates:lc.map(([a,b])=>[b,a]) }};
}
function gjPoly(ns, s, l, pm, lc) {
const c = lc.map(([a,b]) => [b,a]);
if (c.length && (c[0][0] !== c[c.length-1][0] || c[0][1] !== c[c.length-1][1])) c.push(c[0]);
return { type:'Feature', properties:{ nomor_sertifikat:ns, status_kepemilikan:s, luas_meter2:l, nama_pemilik:pm }, geometry:{ type:'Polygon', coordinates:[c] }};
}
// ══════════════════════════════════════════════
// MAP
// ══════════════════════════════════════════════
const map = L.map('map', { center: defaultCenter, zoom: defaultZoom, zoomControl: true, doubleClickZoom: false });
const osmLyr = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap', maxZoom:19 });
const satLyr = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution:'© Esri', maxZoom:19 });
osmLyr.addTo(map);
// Layer groups for overlay control
const lgJalan = L.layerGroup().addTo(map);
const lgParsil = L.layerGroup().addTo(map);
const lgLaporan = L.layerGroup().addTo(map);
L.control.layers(
{ '🗺️ OpenStreetMap': osmLyr, '🛰️ Satelit': satLyr },
{ '🛣️ Jalan': lgJalan, '🏘️ Parsil Tanah': lgParsil, '⚠️ Laporan Kerusakan': lgLaporan },
{ position: 'topleft' }
).addTo(map);
map.on('mousemove', e => {
document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(7)} | Lng: ${e.latlng.lng.toFixed(7)}`;
if (isDrawMode && drawPts.length >= 1) updateGhost(e.latlng);
});
map.on('click', function(e) {
if (isDrawMode) {
addDrawPt(e.latlng.lat, e.latlng.lng);
return;
}
// FIX #2: Klik peta di tab laporan = buka modal laporan
if (currentTab === 'laporan' && !isEditMode) {
openLaporModal(e.latlng.lat, e.latlng.lng);
}
});
map.on('dblclick', e => {
if (!isDrawMode) return;
e.originalEvent.preventDefault(); e.originalEvent.stopPropagation();
if (drawPts.length > 0) { const m = drawMkrs.pop(); if (m && map.hasLayer(m)) map.removeLayer(m); drawPts.pop(); }
finishDraw();
});
// ══════════════════════════════════════════════
// TAB
// ══════════════════════════════════════════════
function switchTab(tab) {
if (isEditMode) closeEditPanel();
if (isDrawMode) cancelDraw();
currentTab = tab;
currentFilter = 'all';
currentFilterTingkat = 'all';
currentFilterPeriode = 'all';
currentSearch = '';
document.getElementById('search-input').value = '';
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(`tab-${tab}`).classList.add('active');
const isLaporan = tab === 'laporan';
// Update sidebar title
const titles = { jalan:'<span>🛣️</span> Manajemen Jalan', parsil:'<span>🏘️</span> Parsil Tanah', laporan:'<span>⚠️</span> Laporan Kerusakan Jalan' };
document.getElementById('sidebar-title').innerHTML = titles[tab];
// Update draw tip
if (isLaporan) {
document.getElementById('draw-tip-box').className = 'laporan-tip';
document.getElementById('draw-tip-box').innerHTML = `
<div class="collapsible-header" onclick="toggleCollapse('draw-tip-steps-body','draw-tip-toggle')">
<div class="laporan-tip-title" style="margin-bottom:0">📍 Cara Buat Laporan</div>
<button class="collapse-toggle" id="draw-tip-toggle">▲</button>
</div>
<div class="collapsible-body" id="draw-tip-steps-body" style="max-height:120px;opacity:1;margin-top:6px">
<div class="laporan-tip-steps">
<div class="laporan-tip-step"><div class="step-num-red">1</div><span>Klik titik lokasi kerusakan di peta</span></div>
<div class="laporan-tip-step"><div class="step-num-red">2</div><span>Isi form (jalan &amp; pengelola otomatis)</span></div>
<div class="laporan-tip-step"><div class="step-num-red">3</div><span>Upload foto &amp; kirim laporan</span></div>
</div>
</div>`;
document.getElementById('btn-draw').style.display = 'none';
document.getElementById('btn-cancel-draw').style.display = 'none';
} else {
document.getElementById('draw-tip-box').className = 'draw-tip';
document.getElementById('draw-tip-box').innerHTML = `
<div class="collapsible-header" onclick="toggleCollapse('draw-tip-steps-body','draw-tip-toggle')">
<div class="draw-tip-title" style="margin-bottom:0">✏️ Cara Tambah Data</div>
<button class="collapse-toggle" id="draw-tip-toggle">▲</button>
</div>
<div class="collapsible-body" id="draw-tip-steps-body" style="max-height:120px;opacity:1;margin-top:6px">
<div class="draw-tip-steps">
<div class="draw-tip-step"><div class="step-num">1</div><span>Klik <b>"Gambar di Peta"</b> di kanan</span></div>
<div class="draw-tip-step"><div class="step-num">2</div><span>Klik peta untuk menambah titik koordinat</span></div>
<div class="draw-tip-step"><div class="step-num">3</div><span><b>Double-click</b> untuk selesai &amp; isi data</span></div>
</div>
</div>`;
document.getElementById('btn-draw').style.display = '';
}
// FIX #3: Filter chips per tab
if (isLaporan) {
document.getElementById('filter-chips').innerHTML = `
<div class="filter-dropdowns">
<div class="filter-row">
<select class="filter-select" id="filter-tingkat" onchange="applyLaporanFilter()">
<option value="all">🔘 Semua Tingkat</option>
<option value="Ringan">🟢 Ringan</option>
<option value="Sedang">🟡 Sedang</option>
<option value="Berat">🔴 Berat</option>
</select>
</div>
<div class="filter-row">
<select class="filter-select" id="filter-periode" onchange="applyLaporanFilter()">
<option value="all">📅 Semua Periode</option>
<optgroup label="Per Bulan">
<option value="bln_1">1 Bulan Terakhir</option>
<option value="bln_2">2 Bulan Terakhir</option>
<option value="bln_3">3 Bulan Terakhir</option>
<option value="bln_6">6 Bulan Terakhir</option>
<option value="bln_9">9 Bulan Terakhir</option>
</optgroup>
<optgroup label="Per Tahun">
<option value="thn_1">1 Tahun Terakhir</option>
<option value="thn_2">2 Tahun Terakhir</option>
<option value="thn_3">3 Tahun Terakhir</option>
</optgroup>
</select>
</div>
</div>`;
document.getElementById('search-input').placeholder = 'Cari nama jalan / pelapor...';
} else if (tab === 'jalan') {
document.getElementById('filter-chips').innerHTML = `
<button class="chip active-all" onclick="setFilter('all')">Semua</button>
<button class="chip" onclick="setFilter('Jalan Nasional')">Nasional</button>
<button class="chip" onclick="setFilter('Jalan Provinsi')">Provinsi</button>
<button class="chip" onclick="setFilter('Jalan Kabupaten')">Kabupaten</button>`;
document.getElementById('search-input').placeholder = 'Cari data...';
} else {
document.getElementById('filter-chips').innerHTML = `
<button class="chip active-all" onclick="setFilter('all')">Semua</button>
<button class="chip" onclick="setFilter('SHM')">SHM</button>
<button class="chip" onclick="setFilter('HGB')">HGB</button>
<button class="chip" onclick="setFilter('HGU')">HGU</button>
<button class="chip" onclick="setFilter('HP')">HP</button>`;
document.getElementById('search-input').placeholder = 'Cari data...';
}
// Update legend
if (isLaporan) {
document.getElementById('legend-title').textContent = 'Legenda Kerusakan';
document.getElementById('legend-items').innerHTML = `
<div class="legend-item"><div class="legend-dot" style="background:#EF4444"></div><span>Baru (&lt; 1 bulan)</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#F59E0B"></div><span>112 bulan lalu</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#9CA3AF"></div><span>13 tahun lalu</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#C084FC"></div><span>⚠️ Sering Rusak</span></div>`;
} else if (tab === 'jalan') {
document.getElementById('legend-title').textContent = 'Legenda Jalan';
document.getElementById('legend-items').innerHTML = `
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-nasional)"></div><span>Jalan Nasional</span></div>
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-provinsi)"></div><span>Jalan Provinsi</span></div>
<div class="legend-item"><div class="legend-line" style="background:var(--jalan-kabupaten)"></div><span>Jalan Kabupaten</span></div>`;
} else {
document.getElementById('legend-title').textContent = 'Legenda Parsil';
document.getElementById('legend-items').innerHTML = `
<div class="legend-item"><div class="legend-dot" style="background:var(--shm)"></div><span>SHM</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--hgb)"></div><span>HGB</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--hgu)"></div><span>HGU</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--hp)"></div><span>HP</span></div>`;
}
document.getElementById('sering-rusak-container').innerHTML = '';
loadData();
}
// ══════════════════════════════════════════════
// FILTER
// ══════════════════════════════════════════════
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.chip').forEach(c => {
c.className = 'chip';
const t = c.textContent.trim();
if (t === 'Semua' && f === 'all') c.classList.add('active-all');
if (t === 'Nasional' && f === 'Jalan Nasional') c.classList.add('active-nasional');
if (t === 'Provinsi' && f === 'Jalan Provinsi') c.classList.add('active-provinsi');
if (t === 'Kabupaten' && f === 'Jalan Kabupaten') c.classList.add('active-kabupaten');
if (t === 'SHM' && f === 'SHM') c.classList.add('active-shm');
if (t === 'HGB' && f === 'HGB') c.classList.add('active-hgb');
if (t === 'HGU' && f === 'HGU') c.classList.add('active-hgu');
if (t === 'HP' && f === 'HP') c.classList.add('active-hp');
});
renderList();
}
// FIX #4: applyLaporanFilter membaca kedua dropdown
function applyLaporanFilter() {
const tingkat = document.getElementById('filter-tingkat') ? document.getElementById('filter-tingkat').value : 'all';
const periode = document.getElementById('filter-periode') ? document.getElementById('filter-periode').value : 'all';
// Update visual state on dropdowns
const ds = document.querySelectorAll('.filter-select');
ds.forEach(d => {
d.classList.toggle('active-filter', d.value !== 'all');
});
// Store filter state
currentFilterTingkat = tingkat;
currentFilterPeriode = periode;
renderList();
renderMapLayers();
}
// Legacy alias kept for safety
function setFilterLaporan(f) {
currentFilter = f;
renderList();
renderMapLayers();
}
function filterData() {
currentSearch = document.getElementById('search-input').value.toLowerCase();
renderList();
}
// ══════════════════════════════════════════════
// DATA
// ══════════════════════════════════════════════
async function loadData() {
document.getElementById('data-list').innerHTML = `<div class="empty-state"><div class="icon">⏳</div><p>Memuat data...</p></div>`;
// Fetch current tab data first if not loaded
await fetchTabData(currentTab);
// Render current tab list and update stats
renderList();
updateStats();
if (currentTab === 'laporan') renderSeringRusak();
// Render whatever map layers we have
renderMapLayers();
// Fetch other tabs data in background
const otherTabs = ['jalan', 'parsil', 'laporan'].filter(t => t !== currentTab);
otherTabs.forEach(async (tab) => {
if (!dataLoaded[tab]) {
await fetchTabData(tab);
renderMapLayers();
if (tab === 'laporan' && currentTab === 'laporan') {
renderSeringRusak();
}
}
});
}
async function fetchTabData(tab) {
if (dataLoaded[tab]) return;
if (tab === 'laporan') {
try {
const r = await fetch(`${API}/laporan.php`);
const j = await r.json();
if (j.success) {
allData.laporan = j.data || [];
seringRusakList = j.sering_rusak || [];
saveLS();
dataLoaded.laporan = true;
return;
}
} catch(e) {}
loadDemoLaporan();
} else {
const ep = tab === 'jalan' ? 'jalan.php' : 'parsil.php';
try {
const r = await fetch(`${API}/${ep}`);
const j = await r.json();
if (j.success) {
allData[tab] = j.data || [];
saveLS();
dataLoaded[tab] = true;
return;
}
} catch(e) {}
// Check if we have local storage fallback
if (loadLS() && allData[tab] && allData[tab].length) {
dataLoaded[tab] = true;
showToast('⚠️ Mode demo aktif.', 'info');
} else {
loadDemo(tab);
}
}
}
function loadDemo(tab) {
if (tab === 'jalan' && !allData.jalan.length) {
allData.jalan = [
mkJ(1,'Jalan Ahmad Yani','Jalan Nasional',1250.5,[[-0.0220,109.3300],[-0.0235,109.3340],[-0.0250,109.3375],[-0.0263,109.3410]],'2024-01-15'),
mkJ(2,'Jalan Gajah Mada','Jalan Provinsi',870.3,[[-0.0280,109.3280],[-0.0270,109.3330],[-0.0260,109.3380]],'2024-01-20'),
mkJ(3,'Jalan Pahlawan','Jalan Kabupaten',540.7,[[-0.0300,109.3420],[-0.0290,109.3450],[-0.0280,109.3480]],'2024-02-01'),
];
}
if (tab === 'parsil' && !allData.parsil.length) {
allData.parsil = [
mkP(1,'SHM-2024-001','SHM',450,'Budi Santoso',[[-0.0255,109.3355],[-0.0255,109.3370],[-0.0245,109.3370],[-0.0245,109.3355]],'2024-01-10'),
mkP(2,'HGB-2024-002','HGB',620.5,'PT. Maju Jaya',[[-0.0270,109.3395],[-0.0270,109.3415],[-0.0258,109.3415],[-0.0258,109.3395]],'2024-01-25'),
mkP(3,'HGU-2024-003','HGU',1200,'CV. Agro Lestari',[[-0.0285,109.3310],[-0.0285,109.3335],[-0.0268,109.3335],[-0.0268,109.3310]],'2024-02-05'),
];
}
saveLS();
dataLoaded[tab] = true;
showToast('⚠️ Mode demo aktif.', 'info');
}
function loadDemoLaporan() {
if (allData.laporan && allData.laporan.length) {
dataLoaded.laporan = true;
return;
}
const now = new Date();
const sub = (d) => { const t=new Date(now); t.setDate(t.getDate()-d); return t.toISOString().split('T')[0]; };
allData.laporan = [
mkL(1, 1, 'Jalan Ahmad Yani', 'Jalan Nasional', 'Budi Hartono', 'Berat', sub(2), 'Lubang besar berbahaya', -0.0228, 109.3320),
mkL(2, 1, 'Jalan Ahmad Yani', 'Jalan Nasional', 'Siti Rahayu', 'Sedang', sub(60), 'Retak memanjang di bahu jalan', -0.0242, 109.3355),
mkL(3, 1, 'Jalan Ahmad Yani', 'Jalan Nasional', 'Andi Wijaya', 'Berat', sub(420), 'Jalan ambles pasca hujan deras', -0.0250, 109.3370),
mkL(4, 2, 'Jalan Gajah Mada', 'Jalan Provinsi', 'Hendra', 'Ringan', sub(7), 'Aspal mengelupas', -0.0274, 109.3305),
mkL(5, 2, 'Jalan Gajah Mada', 'Jalan Provinsi', 'Dewi Lestari', 'Sedang', sub(240), 'Lubang di jalur lambat', -0.0265, 109.3350),
mkL(6, 3, 'Jalan Pahlawan', 'Jalan Kabupaten','Rizky', 'Berat', sub(21), 'Gorong-gorong rusak, genangan', -0.0295, 109.3435),
];
seringRusakList = [{ jalan_id: 1, nama_jalan: 'Jalan Ahmad Yani', jumlah_laporan: 3 }];
saveLS();
dataLoaded.laporan = true;
showToast('⚠️ Mode demo aktif.', 'info');
}
function mkJ(id,n,s,p,lc,d){return{id,nama_jalan:n,status_jalan:s,panjang_meter:p,koordinat:lc,geojson:gjLine(n,s,p,lc),created_at:d};}
function mkP(id,ns,s,l,pm,lc,d){return{id,nama_parsil:ns,nomor_sertifikat:ns,status_kepemilikan:s,luas_meter2:l,koordinat:lc,geojson:gjPoly(ns,s,l,pm,lc),nama_pemilik:pm,created_at:d};}
function mkL(id,jid,nj,sj,np,kat,tgl,ket,lat,lng){
return{id,jalan_id:jid,nama_jalan:nj,status_jalan:sj,nama_pelapor:np,kategori_rusak:kat,tanggal_laporan:tgl,keterangan:ket,foto_url:null,lat,lng,
info_anggaran: ANGGARAN[sj]||{pengelola:'Tidak Diketahui',sumber:'-'},
geojson:{type:'Feature',geometry:{type:'Point',coordinates:[lng,lat]},properties:{nama_jalan:nj,status_jalan:sj,kategori_rusak:kat}}};
}
// ══════════════════════════════════════════════
// SERING RUSAK BANNER
// ══════════════════════════════════════════════
function renderSeringRusak() {
const container = document.getElementById('sering-rusak-container');
if (!seringRusakList.length || currentTab !== 'laporan') { container.innerHTML = ''; return; }
container.innerHTML = `
<div class="sering-rusak-banner" style="margin:8px 10px 0;">
<div class="sering-rusak-title">⚠️ Jalan Sering Rusak (≥3x / 3 Tahun)</div>
${seringRusakList.map(s => `<div class="sering-rusak-item">🔴 ${s.nama_jalan}${s.jumlah_laporan}x laporan</div>`).join('')}
</div>`;
}
// ══════════════════════════════════════════════
// STATS
// ══════════════════════════════════════════════
function updateStats() {
const totalEl = document.getElementById('stat-total');
const s2El = document.getElementById('stat-2');
const s2Lbl = document.getElementById('stat-2-lbl');
const s3El = document.getElementById('stat-3');
const s3Lbl = document.getElementById('stat-3-lbl');
const s4El = document.getElementById('stat-4');
const s4Lbl = document.getElementById('stat-4-lbl');
if (!totalEl || !s2El || !s2Lbl || !s3El || !s3Lbl || !s4El || !s4Lbl) return;
if (currentTab === 'jalan') {
const total = allData.jalan.length;
const nasional = allData.jalan.filter(j => j.status_jalan === 'Jalan Nasional').length;
const provinsi = allData.jalan.filter(j => j.status_jalan === 'Jalan Provinsi').length;
const kabupaten = allData.jalan.filter(j => j.status_jalan === 'Jalan Kabupaten').length;
totalEl.textContent = total;
s2Lbl.textContent = 'Nasional';
s2El.textContent = nasional;
s3Lbl.textContent = 'Provinsi';
s3El.textContent = provinsi;
s4Lbl.textContent = 'Kabupaten';
s4El.textContent = kabupaten;
} else if (currentTab === 'parsil') {
const total = allData.parsil.length;
const shm = allData.parsil.filter(p => p.status_kepemilikan === 'SHM').length;
const hgb = allData.parsil.filter(p => p.status_kepemilikan === 'HGB').length;
const lainnya = allData.parsil.filter(p => p.status_kepemilikan !== 'SHM' && p.status_kepemilikan !== 'HGB').length;
totalEl.textContent = total;
s2Lbl.textContent = 'SHM';
s2El.textContent = shm;
s3Lbl.textContent = 'HGB';
s3El.textContent = hgb;
s4Lbl.textContent = 'Lainnya';
s4El.textContent = lainnya;
} else if (currentTab === 'laporan') {
const total = allData.laporan.length;
const ringan = allData.laporan.filter(l => l.kategori_rusak === 'Ringan').length;
const sedang = allData.laporan.filter(l => l.kategori_rusak === 'Sedang').length;
const berat = allData.laporan.filter(l => l.kategori_rusak === 'Berat').length;
totalEl.textContent = total;
s2Lbl.textContent = 'Ringan';
s2El.textContent = ringan;
s3Lbl.textContent = 'Sedang';
s3El.textContent = sedang;
s4Lbl.textContent = 'Berat';
s4El.textContent = berat;
}
}
// ══════════════════════════════════════════════
// RENDER LIST
// ══════════════════════════════════════════════
function renderList() {
const con = document.getElementById('data-list');
// FIX #6: renderList handle tab laporan
if (currentTab === 'laporan') {
let filtered = allData.laporan.filter(item => {
const bln = usiaBulan(item.tanggal_laporan);
const matchSearch = !currentSearch ||
(item.nama_jalan||'').toLowerCase().includes(currentSearch) ||
(item.nama_pelapor||'').toLowerCase().includes(currentSearch) ||
(item.keterangan||'').toLowerCase().includes(currentSearch);
// Filter tingkat kerusakan
let matchTingkat = true;
if (currentFilterTingkat && currentFilterTingkat !== 'all') {
matchTingkat = item.kategori_rusak === currentFilterTingkat;
}
// Filter periode
let matchPeriode = true;
if (currentFilterPeriode && currentFilterPeriode !== 'all') {
const val = currentFilterPeriode;
if (val.startsWith('bln_')) {
const bulan = parseInt(val.split('_')[1]);
matchPeriode = bln <= bulan;
} else if (val.startsWith('thn_')) {
const tahun = parseInt(val.split('_')[1]);
matchPeriode = bln <= tahun * 12;
}
}
return matchSearch && matchTingkat && matchPeriode;
});
if (!filtered.length) {
con.innerHTML = `<div class="empty-state"><div class="icon">🔍</div><p>Tidak ada laporan.<br>Klik titik di peta untuk membuat laporan.</p></div>`;
return;
}
con.innerHTML = filtered.map(item => {
const isSering = seringRusakList.some(s => s.jalan_id === item.jalan_id);
const bln = usiaBulan(item.tanggal_laporan);
const cardClass = isSering ? 'rusak-sering' : (bln <= 1 ? 'rusak-baru' : 'rusak-lama');
const badgeKat = `badge-${item.kategori_rusak.toLowerCase()}`;
const ang = item.info_anggaran || ANGGARAN[item.status_jalan] || {};
return `<div class="data-card ${cardClass}" id="card-laporan-${item.id}" onclick="hlLaporan(${item.id})">
<div class="card-row-top">
<div class="card-name">${item.nama_jalan||'Tidak diketahui'}${isSering?' <span style="color:#9333EA">⚠️</span>':''}</div>
<div class="card-actions">
<button class="btn-sm btn-del" onclick="event.stopPropagation();delLaporan(${item.id})">🗑️</button>
</div>
</div>
<div class="card-meta">
<span class="badge ${badgeKat}">${item.kategori_rusak}</span>
<span class="card-measure">📅 ${item.tanggal_laporan}</span>
</div>
${ang.pengelola?`<div style="font-size:10px;color:#3B82F6;margin-top:3px">🏛️ ${ang.pengelola}</div>`:''}
${item.nama_pelapor?`<div style="font-size:10px;color:var(--text-muted);margin-top:1px">👤 ${item.nama_pelapor}</div>`:''}
</div>`;
}).join('');
requestAnimationFrame(checkCardActions);
return;
}
const filtered = allData[currentTab].filter(item => {
const nm = currentTab === 'jalan' ? item.nama_jalan : item.nama_parsil;
const st = currentTab === 'jalan' ? item.status_jalan : item.status_kepemilikan;
return (!currentSearch || (nm||'').toLowerCase().includes(currentSearch)) && (currentFilter === 'all' || st === currentFilter);
});
if (!filtered.length) { con.innerHTML = `<div class="empty-state"><div class="icon">🔍</div><p>Tidak ada data.<br>Gambar di peta untuk tambah.</p></div>`; return; }
con.innerHTML = filtered.map(item => {
if (currentTab === 'jalan') {
const cls = item.status_jalan.toLowerCase().replace('jalan ','');
return `<div class="data-card jalan-${cls}" id="card-jalan-${item.id}" onclick="hlFeature('jalan',${item.id})">
<div class="card-row-top">
<div class="card-name">${item.nama_jalan}</div>
<div class="card-actions">
<button class="btn-sm btn-edit" onclick="event.stopPropagation();openEdit('jalan',${item.id})">✏️</button>
<button class="btn-sm btn-del" onclick="event.stopPropagation();delData('jalan',${item.id},'${escQ(item.nama_jalan)}')">🗑️</button>
</div>
</div>
<div class="card-meta"><span class="badge badge-${cls}">${item.status_jalan}</span><span class="card-measure">📏 ${fmt(item.panjang_meter)} m</span></div>
</div>`;
} else {
const cls = item.status_kepemilikan.toLowerCase();
const dn = item.nomor_sertifikat || item.nama_pemilik || '—';
return `<div class="data-card ${cls}" id="card-parsil-${item.id}" onclick="hlFeature('parsil',${item.id})">
<div class="card-row-top">
<div class="card-name">${dn}</div>
<div class="card-actions">
<button class="btn-sm btn-edit" onclick="event.stopPropagation();openEdit('parsil',${item.id})">✏️</button>
<button class="btn-sm btn-del" onclick="event.stopPropagation();delData('parsil',${item.id},'${escQ(dn)}')">🗑️</button>
</div>
</div>
<div class="card-meta"><span class="badge badge-${cls}">${item.status_kepemilikan}</span><span class="card-measure">📐 ${fmt(item.luas_meter2)} m²</span></div>
${item.nama_pemilik?`<div style="font-size:11px;color:var(--text-secondary);margin-top:3px">👤 ${item.nama_pemilik}</div>`:''}
</div>`;
}
}).join('');
requestAnimationFrame(checkCardActions);
}
function checkCardActions() { /* no-op: actions now absolute positioned */ }
function toggleCollapse(bodyId, toggleId) {
var body = document.getElementById(bodyId);
var btn = document.getElementById(toggleId);
if (!body) return;
var isCollapsed = body.classList.contains('collapsed');
if (isCollapsed) {
body.classList.remove('collapsed');
if (btn) btn.textContent = '▲';
} else {
body.classList.add('collapsed');
if (btn) btn.textContent = '▼';
}
}
function renderMapLayers() {
// Clear all layer groups
lgJalan.clearLayers();
lgParsil.clearLayers();
lgLaporan.clearLayers();
mapLayers = { jalan: {}, parsil: {}, laporan: {} };
// Render Jalan
allData.jalan.forEach(item => addJLyr(item));
// Render Parsil
allData.parsil.forEach(item => addPLyr(item));
// Render Laporan
let toShow = allData.laporan || [];
if (currentFilterTingkat && currentFilterTingkat !== 'all') {
toShow = toShow.filter(i => i.kategori_rusak === currentFilterTingkat);
}
if (currentFilterPeriode && currentFilterPeriode !== 'all') {
const val = currentFilterPeriode;
if (val.startsWith('bln_')) {
const bulan = parseInt(val.split('_')[1]);
toShow = toShow.filter(i => usiaBulan(i.tanggal_laporan) <= bulan);
} else if (val.startsWith('thn_')) {
const tahun = parseInt(val.split('_')[1]);
toShow = toShow.filter(i => usiaBulan(i.tanggal_laporan) <= tahun * 12);
}
}
toShow.forEach(item => {
if (item.lat == null || item.lng == null) return;
const style = getStyleLaporan(item);
const isSering = seringRusakList.some(s => s.jalan_id === item.jalan_id);
const ang = item.info_anggaran || ANGGARAN[item.status_jalan] || {};
const marker = L.circleMarker([item.lat, item.lng], {
radius: style.radius || 8,
color: style.color,
fillColor: style.fillColor,
weight: isSering ? 3 : 2,
opacity: 1,
fillOpacity: 0.8,
dashArray: isSering ? '4,3' : null
}).addTo(lgLaporan);
const fotoHtml = item.foto_url ? `<img src="${item.foto_url}" class="popup-foto" alt="Foto kerusakan">` : '';
const seringBadge = isSering ? `<div style="background:#F3E8FF;color:#7C3AED;border-radius:6px;padding:4px 8px;font-size:11px;font-weight:700;margin-top:6px">⚠️ Jalan Ini Sering Rusak</div>` : '';
marker.bindPopup(`
<div class="popup-content">
<div class="popup-title">⚠️ ${item.kategori_rusak}${item.nama_jalan||'Tidak diketahui'}</div>
${fotoHtml}
<div class="popup-row"><strong>Status:</strong> ${item.status_jalan||'-'}</div>
<div class="popup-row highlight"><strong>Pengelola:</strong> ${ang.pengelola||'-'}</div>
<div class="popup-row" style="color:#059669"><strong>Anggaran:</strong> ${ang.sumber||'-'}</div>
<div class="popup-row"><strong>Tanggal:</strong> ${item.tanggal_laporan}</div>
${item.nama_pelapor?`<div class="popup-row"><strong>Pelapor:</strong> ${item.nama_pelapor}</div>`:''}
${item.keterangan?`<div class="popup-row"><strong>Ket:</strong> ${item.keterangan}</div>`:''}
${seringBadge}
<div class="popup-actions">
<button class="pb pb-del" onclick="delLaporan(${item.id})">🗑️ Hapus</button>
</div>
</div>`);
marker.on('click', () => hlCardLaporan(item.id));
mapLayers.laporan[item.id] = marker;
});
}
function addJLyr(item) {
if (!item.koordinat || item.koordinat.length < 2) return;
const color = JC[item.status_jalan] || '#888';
const poly = L.polyline(item.koordinat, { color, weight: 4, opacity: 0.85 });
poly.bindPopup(popJ(item));
poly.on('click', () => {
if (currentTab === 'jalan') hlCard('jalan', item.id);
});
poly.addTo(lgJalan);
mapLayers.jalan[item.id] = poly;
}
function addPLyr(item) {
if (!item.koordinat || item.koordinat.length < 3) return;
const color = PC[item.status_kepemilikan] || '#888';
const poly = L.polygon(item.koordinat, { color, weight: 2.5, fillColor: color, fillOpacity: .25, opacity: .9 });
poly.bindPopup(popP(item));
poly.on('click', () => {
if (currentTab === 'parsil') hlCard('parsil', item.id);
});
poly.addTo(lgParsil);
mapLayers.parsil[item.id] = poly;
}
function popJ(item) { return `<div class="popup-content"><div class="popup-title">🛣️ ${item.nama_jalan}</div><div class="popup-row"><strong>Status:</strong> ${item.status_jalan}</div><div class="popup-row"><strong>Panjang:</strong> ${fmt(item.panjang_meter)} m</div><div class="popup-actions"><button class="pb pb-edit" onclick="openEdit('jalan',${item.id})">✏️ Edit</button><button class="pb pb-del" onclick="delData('jalan',${item.id},'${escQ(item.nama_jalan)}')">🗑️ Hapus</button></div></div>`; }
function popP(item) { const dn=item.nomor_sertifikat||item.nama_pemilik||'—'; return `<div class="popup-content"><div class="popup-title">🏘️ ${dn}</div><div class="popup-row"><strong>Status:</strong> ${item.status_kepemilikan}</div><div class="popup-row"><strong>Luas:</strong> ${fmt(item.luas_meter2)} m²</div>${item.nama_pemilik?`<div class="popup-row"><strong>Pemilik:</strong> ${item.nama_pemilik}</div>`:''}<div class="popup-actions"><button class="pb pb-edit" onclick="openEdit('parsil',${item.id})">✏️ Edit</button><button class="pb pb-del" onclick="delData('parsil',${item.id},'${escQ(dn)}')">🗑️ Hapus</button></div></div>`; }
function hlFeature(type, id) {
const l = mapLayers[type][id];
if (l) {
if (type === 'jalan' && !map.hasLayer(lgJalan)) map.addLayer(lgJalan);
if (type === 'parsil' && !map.hasLayer(lgParsil)) map.addLayer(lgParsil);
map.closePopup();
l.openPopup();
zoomTo(type, id);
}
hlCard(type, id);
}
function zoomTo(type, id) { const l = mapLayers[type][id]; if (l) map.fitBounds(l.getBounds(), { padding: [60, 60] }); }
function hlCard(type, id) {
document.querySelectorAll('.data-card.selected').forEach(c => c.classList.remove('selected'));
const c = document.getElementById(`card-${type}-${id}`);
if (c) { c.classList.add('selected'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); }
}
function hlLaporan(id) {
const l = mapLayers.laporan[id];
if (l) {
if (!map.hasLayer(lgLaporan)) map.addLayer(lgLaporan);
map.closePopup();
l.openPopup();
map.panTo(l.getLatLng());
}
hlCardLaporan(id);
}
function hlCardLaporan(id) {
document.querySelectorAll('.data-card.selected').forEach(c => c.classList.remove('selected'));
const c = document.getElementById(`card-laporan-${id}`);
if (c) { c.classList.add('selected'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); }
}
function zoomToLaporan(id) { const l = mapLayers.laporan[id]; if (l) map.setView(l.getLatLng(), 17); }
// ══════════════════════════════════════════════
// LAPORAN MODAL
// ══════════════════════════════════════════════
function openLaporModal(lat, lng) {
// Set koordinat di modal
document.getElementById('lapor-coords').value = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
document.getElementById('modal-laporan').dataset.lat = lat;
document.getElementById('modal-laporan').dataset.lng = lng;
// Deteksi jalan terdekat otomatis
const jalan = findJalanByPoint(lat, lng);
if (jalan) {
document.getElementById('lapor-jalan-nama').value = jalan.nama_jalan;
document.getElementById('modal-laporan').dataset.jalanId = jalan.id;
document.getElementById('modal-laporan').dataset.statusJalan = jalan.status_jalan;
const ang = ANGGARAN[jalan.status_jalan] || { pengelola: 'Tidak Diketahui', sumber: '-' };
document.getElementById('lapor-pengelola').textContent = ang.pengelola;
document.getElementById('lapor-sumber').textContent = ang.sumber;
} else {
document.getElementById('lapor-jalan-nama').value = 'Tidak terdeteksi (di luar rute jalan)';
document.getElementById('modal-laporan').dataset.jalanId = 0;
document.getElementById('modal-laporan').dataset.statusJalan = '';
document.getElementById('lapor-pengelola').textContent = 'Tidak diketahui';
document.getElementById('lapor-sumber').textContent = 'Perlu konfirmasi manual';
}
// Set tanggal hari ini
document.getElementById('lapor-tanggal').value = new Date().toISOString().split('T')[0];
// Reset field lainnya
document.getElementById('lapor-kategori').value = 'Sedang';
document.getElementById('lapor-nama-pelapor').value = '';
document.getElementById('lapor-keterangan').value = '';
document.getElementById('lapor-foto').value = '';
document.getElementById('lapor-foto-preview').style.display = 'none';
document.getElementById('modal-laporan').classList.add('show');
}
// FIX #9: closeLaporModal referensikan field yang benar
function closeLaporModal() {
document.getElementById('modal-laporan').classList.remove('show');
document.getElementById('lapor-nama-pelapor').value = '';
document.getElementById('lapor-keterangan').value = '';
document.getElementById('lapor-foto').value = '';
document.getElementById('lapor-foto-preview').style.display = 'none';
}
function previewFoto(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const preview = document.getElementById('lapor-foto-preview');
preview.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
// FIX #10: saveLaporan — payload lengkap, field references benar
async function saveLaporan() {
const lat = parseFloat(document.getElementById('modal-laporan').dataset.lat);
const lng = parseFloat(document.getElementById('modal-laporan').dataset.lng);
const jalanId = parseInt(document.getElementById('modal-laporan').dataset.jalanId || 0);
const statusJalan = document.getElementById('modal-laporan').dataset.statusJalan || '';
const namaJalan = document.getElementById('lapor-jalan-nama').value;
const kategori = document.getElementById('lapor-kategori').value;
const tanggal = document.getElementById('lapor-tanggal').value;
const namaPelapor = document.getElementById('lapor-nama-pelapor').value.trim() || 'Anonim';
const keterangan = document.getElementById('lapor-keterangan').value.trim();
if (!tanggal) { showToast('❌ Tanggal laporan wajib diisi', 'error'); return; }
// Foto base64 jika ada
let fotoBase64 = null;
const fotoFile = document.getElementById('lapor-foto').files[0];
if (fotoFile) {
fotoBase64 = await new Promise(res => {
const r = new FileReader();
r.onload = e => res(e.target.result);
r.readAsDataURL(fotoFile);
});
}
const payload = {
lat, lng, jalan_id: jalanId, nama_jalan: namaJalan,
status_jalan: statusJalan, nama_pelapor: namaPelapor,
kategori_rusak: kategori, tanggal_laporan: tanggal,
keterangan, foto_base64: fotoBase64
};
try {
const r = await fetch(`${API}/laporan.php`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json();
if (j.success) {
showToast('✅ Laporan berhasil dikirim!', 'success');
closeLaporModal();
loadData();
return;
}
showToast('❌ ' + (j.message||'Gagal kirim'), 'error');
} catch(e) {
// Demo fallback
const newItem = mkL(Date.now(), jalanId, namaJalan, statusJalan, namaPelapor, kategori, tanggal, keterangan, lat, lng);
if (fotoBase64) newItem.foto_url = fotoBase64;
allData.laporan.unshift(newItem);
// Cek sering rusak
const count = allData.laporan.filter(i => i.jalan_id === jalanId && usiaBulan(i.tanggal_laporan) <= 36).length;
if (count >= 3 && !seringRusakList.find(s => s.jalan_id === jalanId)) {
seringRusakList.push({ jalan_id: jalanId, nama_jalan: namaJalan, jumlah_laporan: count });
}
saveLS();
closeLaporModal();
renderList(); renderMapLayers(); updateStats(); renderSeringRusak();
showToast('✅ Laporan tersimpan (demo)', 'success');
}
}
async function delLaporan(id) {
if (!confirm('Hapus laporan ini? Tidak bisa dibatalkan.')) return;
try {
const r = await fetch(`${API}/laporan.php?id=${id}`, { method:'DELETE' });
const j = await r.json();
if (j.success) { showToast('🗑️ Laporan dihapus', 'success'); }
} catch(e) {}
if (mapLayers.laporan[id]) {
lgLaporan.removeLayer(mapLayers.laporan[id]);
delete mapLayers.laporan[id];
}
allData.laporan = allData.laporan.filter(d => d.id !== id);
saveLS(); renderList(); updateStats(); renderSeringRusak();
showToast('🗑️ Dihapus', 'success');
}
// ══════════════════════════════════════════════
// DRAW
// ══════════════════════════════════════════════
function startDraw() {
if (currentTab === 'laporan') return; // Laporan tidak pakai draw
if (isEditMode) closeEditPanel();
isDrawMode = true; drawPts = []; clearDrawLayers();
document.getElementById('draw-banner').classList.add('show');
document.getElementById('draw-banner').classList.remove('laporan');
document.getElementById('banner-text').textContent = `Gambar ${currentTab === 'jalan' ? 'Polyline' : 'Polygon'} — Klik titik, Double-click selesai`;
document.getElementById('btn-draw').style.display = 'none';
document.getElementById('btn-cancel-draw').style.display = '';
map.getContainer().style.cursor = 'crosshair';
showToast('✏️ Klik untuk titik, double-click untuk selesai.', 'info');
}
function cancelDraw() {
isDrawMode = false; clearDrawLayers(); drawPts = [];
document.getElementById('draw-banner').classList.remove('show');
document.getElementById('btn-draw').style.display = '';
document.getElementById('btn-cancel-draw').style.display = 'none';
map.getContainer().style.cursor = '';
}
function clearDrawLayers() {
drawMkrs.forEach(m => { if (map.hasLayer(m)) map.removeLayer(m); }); drawMkrs = [];
if (drawSolid) { map.removeLayer(drawSolid); drawSolid = null; }
if (drawGhost) { map.removeLayer(drawGhost); drawGhost = null; }
if (drawFill) { map.removeLayer(drawFill); drawFill = null; }
}
function addDrawPt(lat, lng) {
drawPts.push([lat, lng]);
const m = L.marker([lat, lng], { icon: L.divIcon({ className:'', html:`<div style="background:#2B7FD4;color:#fff;border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3)">${drawPts.length}</div>`, iconSize:[22,22], iconAnchor:[11,11] }) }).addTo(map);
drawMkrs.push(m); rebuildDrawSolid();
}
function rebuildDrawSolid() {
if (drawSolid) { map.removeLayer(drawSolid); drawSolid = null; }
if (drawFill) { map.removeLayer(drawFill); drawFill = null; }
if (currentTab === 'jalan' && drawPts.length >= 2) drawSolid = L.polyline(drawPts, { color:'#2B7FD4', weight:4 }).addTo(map);
if (currentTab === 'parsil') {
if (drawPts.length >= 2) drawSolid = L.polyline([...drawPts, drawPts[0]], { color:'#2B7FD4', weight:4 }).addTo(map);
if (drawPts.length >= 3) drawFill = L.polygon(drawPts, { color:'#2B7FD4', fillColor:'#2B7FD4', fillOpacity:.15, weight:0 }).addTo(map);
}
}
function updateGhost(ll) {
if (drawGhost) { map.removeLayer(drawGhost); drawGhost = null; }
if (!drawPts.length) return;
const lp = drawPts[drawPts.length-1];
drawGhost = L.polyline([[lp[0],lp[1]],[ll.lat,ll.lng]], { color:'#9CA3AF', weight:2.5, opacity:.55, dashArray:'8,7' }).addTo(map);
}
function finishDraw() {
if (drawPts.length < 2 || (currentTab === 'parsil' && drawPts.length < 3)) { showToast('❌ Titik kurang.', 'error'); cancelDraw(); return; }
isDrawMode = false; if (drawGhost) { map.removeLayer(drawGhost); drawGhost = null; }
document.getElementById('draw-banner').classList.remove('show');
document.getElementById('btn-draw').style.display = ''; document.getElementById('btn-cancel-draw').style.display = 'none';
map.getContainer().style.cursor = '';
openAddModal(drawPts.slice());
}
// ══════════════════════════════════════════════
// ADD MODAL (Jalan & Parsil)
// ══════════════════════════════════════════════
function openAddModal(coords) {
const isJ = currentTab === 'jalan';
document.getElementById('modal-title').innerHTML = isJ ? '🛣️ Tambah Data Jalan' : '🏘️ Tambah Data Parsil';
document.getElementById('add-form-jalan').style.display = isJ ? '' : 'none';
document.getElementById('add-form-parsil').style.display = isJ ? 'none' : '';
if (isJ) {
document.getElementById('add-j-nama').value = ''; document.getElementById('add-j-status').value = '';
document.getElementById('add-j-panjang').value = coords.length >= 2 ? calcLen(coords).toFixed(2) : '';
renderCL('add-j', coords, false);
} else {
document.getElementById('add-p-nosert').value = ''; document.getElementById('add-p-status').value = '';
document.getElementById('add-p-luas').value = coords.length >= 3 ? calcArea(coords).toFixed(2) : '';
document.getElementById('add-p-pemilik').value = '';
renderCL('add-p', coords, false);
}
document.getElementById('modal-overlay').dataset.coords = JSON.stringify(coords);
document.getElementById('modal-overlay').classList.add('show');
}
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); clearDrawLayers(); drawPts = []; }
async function saveNew() {
const isJ = currentTab === 'jalan';
const coords = JSON.parse(document.getElementById('modal-overlay').dataset.coords || '[]');
let payload, ep;
if (isJ) {
const nm = document.getElementById('add-j-nama').value.trim(); const st = document.getElementById('add-j-status').value;
if (!nm) { showToast('❌ Nama jalan wajib!', 'error'); return; } if (!st) { showToast('❌ Status wajib!', 'error'); return; }
if (coords.length < 2) { showToast('❌ Min 2 titik!', 'error'); return; }
const p = calcLen(coords); payload = { nama_jalan:nm, status_jalan:st, panjang_meter:p, koordinat:coords, geojson:gjLine(nm,st,p,coords) }; ep = 'jalan.php';
} else {
const st = document.getElementById('add-p-status').value; const ns = document.getElementById('add-p-nosert').value.trim(); const pm = document.getElementById('add-p-pemilik').value.trim();
if (!st) { showToast('❌ Status wajib!', 'error'); return; } if (coords.length < 3) { showToast('❌ Min 3 titik!', 'error'); return; }
const l = calcArea(coords); const nm = ns||pm||`Parsil-${Date.now()}`;
payload = { nama_parsil:nm, status_kepemilikan:st, nomor_sertifikat:ns, luas_meter2:l, koordinat:coords, geojson:gjPoly(ns,st,l,pm,coords), nama_pemilik:pm }; ep = 'parsil.php';
}
try { const r = await fetch(`${API}/${ep}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) }); const j = await r.json(); if (j.success) { applyAdd(payload, j.id); showToast(`✅ ${j.message}`, 'success'); return; } } catch(e) {}
applyAdd(payload, null); showToast('✅ Tersimpan (lokal)', 'success');
}
function applyAdd(p, nid) { allData[currentTab].push({ id:nid||Date.now(), ...p, created_at:new Date().toISOString() }); saveLS(); closeModal(); renderList(); renderMapLayers(); updateStats(); }
// ══════════════════════════════════════════════
// COORD LIST RENDERER
// ══════════════════════════════════════════════
function renderCL(prefix, coords, withDelete) {
document.getElementById(`${prefix}-cnt`).textContent = `${coords.length} titik`;
document.getElementById(`${prefix}-cl`).innerHTML = coords.map((pt, i) => `
<div class="crd">
<span class="crd-n">${i+1}</span>
<span class="crd-coords"><span>Lat: ${pt[0].toFixed(6)}</span><span style="color:var(--border)">|</span><span>Lng: ${pt[1].toFixed(6)}</span></span>
${withDelete ? `<button class="crd-del" onclick="delCoordRow(${i})" title="Hapus titik">✕</button>` : ''}
</div>`).join('');
}
function delCoordRow(i) {
if (!isEditMode) return;
var minPts = editType === 'jalan' ? 2 : 3;
if (editCoords.length <= minPts) {
showToast("Min " + minPts + " titik diperlukan!", "error");
return;
}
editCoords.splice(i, 1);
var pr = editType === 'jalan' ? 'ep-j' : 'ep-p';
renderCL(pr, editCoords, true);
if (editType === 'jalan') document.getElementById("ep-j-panjang").value = calcLen(editCoords).toFixed(2);
else document.getElementById("ep-p-luas").value = calcArea(editCoords).toFixed(2);
buildEditLyr();
buildVMkrs();
showToast("Titik " + (i+1) + " dihapus", "info");
}
// ══════════════════════════════════════════════
// EDIT PANEL
// ══════════════════════════════════════════════
function openEdit(type, id) {
if (isDrawMode) cancelDraw();
if (isEditMode) cleanupEdit(false);
var data = allData[type].find(d => d.id === id);
if (!data) return;
isEditMode = true; editId = id; editType = type;
editOrigCoords = (data.koordinat||[]).map(c => [c[0], c[1]]);
editCoords = editOrigCoords.map(c => [c[0], c[1]]);
document.body.classList.add('edit-mode');
map.closePopup();
document.getElementById('panel-list').classList.add('hidden');
document.getElementById('panel-edit').classList.add('open');
var isJ = type === 'jalan';
document.getElementById('ep-name').textContent = isJ ? data.nama_jalan : (data.nomor_sertifikat||data.nama_pemilik||'Parsil');
document.getElementById('ep-form-jalan').style.display = isJ ? '' : 'none';
document.getElementById('ep-form-parsil').style.display = isJ ? 'none' : '';
if (isJ) {
document.getElementById('ep-j-nama').value = data.nama_jalan;
document.getElementById('ep-j-status').value = data.status_jalan;
document.getElementById('ep-j-panjang').value = fmt(data.panjang_meter);
renderCL('ep-j', editCoords, true);
} else {
document.getElementById('ep-p-nosert').value = data.nomor_sertifikat||'';
document.getElementById('ep-p-status').value = data.status_kepemilikan;
document.getElementById('ep-p-luas').value = fmt(data.luas_meter2);
document.getElementById('ep-p-pemilik').value = data.nama_pemilik||'';
renderCL('ep-p', editCoords, true);
}
if (mapLayers[type][id]) {
const lyr = mapLayers[type][id];
if (type === 'jalan') lgJalan.removeLayer(lyr);
else if (type === 'parsil') lgParsil.removeLayer(lyr);
}
buildEditLyr(); buildVMkrs();
map.getContainer().style.cursor = '';
zoomTo(type, id);
}
function closeEditPanel() {
if (editId && editType) {
var d = allData[editType].find(x => x.id === editId);
if (d) editType === 'jalan' ? addJLyr(d) : addPLyr(d);
}
cleanupEdit(true);
}
function cleanupEdit(slideBack) {
isEditMode = false;
document.body.classList.remove('edit-mode');
clearVMkrs();
if (editLyr && map.hasLayer(editLyr)) { map.removeLayer(editLyr); editLyr = null; }
map.off('mousemove.sd'); map.off('mouseup.sd');
_sdOn = false; _sdAnchor = null; _sdOrig = null;
map.dragging.enable();
map.getContainer().style.cursor = '';
if (slideBack) {
document.getElementById('panel-edit').classList.remove('open');
document.getElementById('panel-list').classList.remove('hidden');
}
editId = null; editType = null; editCoords = []; editOrigCoords = [];
}
function buildEditLyr() {
if (editLyr && map.hasLayer(editLyr)) map.removeLayer(editLyr);
_sdOn = false; map.off('mousemove.sd'); map.off('mouseup.sd');
const AMB = '#F59E0B';
if (editType === 'jalan') editLyr = L.polyline(editCoords, { color:AMB, weight:8, opacity:0.6, interactive:true });
else editLyr = L.polygon(editCoords, { color:AMB, weight:4, fillColor:AMB, fillOpacity:0.3, interactive:true });
editLyr.addTo(map);
setTimeout(() => { const el = editLyr.getElement(); if (el) { el.style.cursor = 'move'; el.style.pointerEvents = 'auto'; } }, 100);
editLyr.on('mousedown', function(ev) {
if (ev.originalEvent.target.classList.contains('vm-dot')) return;
L.DomEvent.stopPropagation(ev); L.DomEvent.preventDefault(ev);
map.dragging.disable(); _sdOn = true; _sdAnchor = ev.latlng;
_sdOrig = JSON.parse(JSON.stringify(editCoords));
map.getContainer().style.cursor = 'grabbing';
const onMove = (moveEv) => {
if (!_sdOn) return;
const cur = map.mouseEventToLatLng(moveEv);
const dLat = cur.lat - _sdAnchor.lat, dLng = cur.lng - _sdAnchor.lng;
editCoords = _sdOrig.map(p => [p[0]+dLat, p[1]+dLng]);
editLyr.setLatLngs(editCoords);
vMkrs.forEach((vm, i) => { if (editCoords[i]) vm.setLatLng(editCoords[i]); });
};
const onUp = () => {
_sdOn = false; map.dragging.enable(); map.getContainer().style.cursor = '';
window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp);
updateEpCoords();
};
window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
});
}
function clearVMkrs() {
vMkrs.forEach(m => { try { map.removeLayer(m); } catch(e) {} }); vMkrs = [];
midMkrs.forEach(m => { try { map.removeLayer(m); } catch(e) {} }); midMkrs = [];
}
function buildVMkrs() {
clearVMkrs();
if (!isEditMode || !editCoords || !editCoords.length) return;
editCoords.forEach(function(pt, i) {
var vm = L.marker([pt[0], pt[1]], {
draggable: true, autoPan: true,
icon: L.divIcon({ className:'', html:`<div class="vm-dot" id="vmd-${i}"></div>`, iconSize:[22,22], iconAnchor:[11,11] }),
zIndexOffset: 700, interactive: true, bubblingMouseEvents: false
}).addTo(map);
vm.on('mouseover', () => { var el=document.getElementById('vmd-'+i); if(el)el.classList.add('hov'); });
vm.on('mouseout', () => { var el=document.getElementById('vmd-'+i); if(el)el.classList.remove('hov'); });
vm.on('mousedown', () => { _sdOn=false; map.dragging.disable(); });
vm.on('drag', ev => { editCoords[i]=[ev.latlng.lat,ev.latlng.lng]; editLyr.setLatLngs(editCoords); });
vm.on('dragend', () => { map.dragging.enable(); editCoords[i]=[vm.getLatLng().lat,vm.getLatLng().lng]; editLyr.setLatLngs(editCoords); updateEpCoords(); });
vMkrs.push(vm);
});
}
function rebuildEditLyr() { buildEditLyr(); }
function rebuildVMkrs() { buildVMkrs(); }
function clearAllVMkrs() { clearVMkrs(); }
function setVtxMode() {}
function updateEpCoords() {
var pr = editType === 'jalan' ? 'ep-j' : 'ep-p';
renderCL(pr, editCoords, true);
if (editType === 'jalan') document.getElementById('ep-j-panjang').value = calcLen(editCoords).toFixed(2);
else document.getElementById('ep-p-luas').value = calcArea(editCoords).toFixed(2);
}
async function saveEditData() {
const isJ = editType === 'jalan'; let payload, ep;
if (isJ) {
const nm = document.getElementById('ep-j-nama').value.trim(); const st = document.getElementById('ep-j-status').value;
if (!nm) { showToast('❌ Nama wajib!', 'error'); return; } if (!st) { showToast('❌ Status wajib!', 'error'); return; }
if (editCoords.length < 2) { showToast('❌ Min 2 titik!', 'error'); return; }
const p = calcLen(editCoords);
payload = { nama_jalan:nm, status_jalan:st, panjang_meter:p, koordinat:editCoords.map(c=>[...c]), geojson:gjLine(nm,st,p,editCoords) }; ep = 'jalan.php';
} else {
const st = document.getElementById('ep-p-status').value; const ns = document.getElementById('ep-p-nosert').value.trim(); const pm = document.getElementById('ep-p-pemilik').value.trim();
if (!st) { showToast('❌ Status wajib!', 'error'); return; } if (editCoords.length < 3) { showToast('❌ Min 3 titik!', 'error'); return; }
const l = calcArea(editCoords); const nm = ns||pm||`Parsil-${editId}`;
payload = { nama_parsil:nm, status_kepemilikan:st, nomor_sertifikat:ns, luas_meter2:l, koordinat:editCoords.map(c=>[...c]), geojson:gjPoly(ns,st,l,pm,editCoords), nama_pemilik:pm }; ep = 'parsil.php';
}
try { const r = await fetch(`${API}/${ep}?id=${editId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) }); const j = await r.json(); if (j.success) { applyEdit(payload); showToast(`✅ ${j.message}`, 'success'); return; } } catch(e) {}
applyEdit(payload); showToast('✅ Tersimpan (lokal)', 'success');
}
function applyEdit(payload) {
const idx = allData[editType].findIndex(d => d.id === editId);
if (idx !== -1) allData[editType][idx] = { ...allData[editType][idx], ...payload };
saveLS(); const sid = editId, stype = editType;
cleanupEdit(true); renderList(); renderMapLayers(); updateStats();
setTimeout(() => zoomTo(stype, sid), 300);
}
async function delData(type, id, name) {
if (!confirm(`Hapus "${name}"?\nTidak bisa dibatalkan.`)) return;
const ep = type === 'jalan' ? 'jalan.php' : 'parsil.php';
try { const r = await fetch(`${API}/${ep}?id=${id}`, { method:'DELETE' }); const j = await r.json(); if (j.success) { showToast(`🗑️ ${j.message}`, 'success'); delLocal(type, id); return; } } catch(e) {}
delLocal(type, id);
}
function delLocal(type, id) {
if (mapLayers[type][id]) {
const lyr = mapLayers[type][id];
if (type === 'jalan') lgJalan.removeLayer(lyr);
else if (type === 'parsil') lgParsil.removeLayer(lyr);
delete mapLayers[type][id];
}
allData[type] = allData[type].filter(d => d.id !== id);
saveLS(); renderList(); updateStats(); showToast('🗑️ Dihapus', 'success');
}
// ══════════════════════════════════════════════
// UTILS
// ══════════════════════════════════════════════
function calcLen(pts) { let t=0; for(let i=0;i<pts.length-1;i++) t+=L.latLng(pts[i][0],pts[i][1]).distanceTo(L.latLng(pts[i+1][0],pts[i+1][1])); return t; }
function calcArea(pts) {
if (pts.length < 3) return 0;
const R=6378137; let a=0; const n=pts.length;
for (let i=0;i<n;i++) { const j=(i+1)%n; const la1=pts[i][0]*Math.PI/180,la2=pts[j][0]*Math.PI/180,dl=(pts[j][1]-pts[i][1])*Math.PI/180; a+=dl*(2+Math.sin(la1)+Math.sin(la2)); }
return Math.abs(a*R*R/2);
}
function showToast(msg, type='info') {
const c = document.getElementById('toast-container'); const t = document.createElement('div');
t.className = `toast ${type}`; t.innerHTML = `<span>${{success:'✅',error:'❌',info:'️'}[type]||'️'}</span> ${msg}`;
c.appendChild(t); setTimeout(() => { t.style.animation='sIn .3s ease reverse'; setTimeout(() => t.remove(), 300); }, 3500);
}
function fmt(n) { return n ? parseFloat(n).toLocaleString('id-ID', { maximumFractionDigits:2 }) : '0'; }
function escQ(s) { return (s||'').replace(/'/g, "\\'"); }
document.getElementById('modal-overlay').addEventListener('click', function(e) { if (e.target === this) closeModal(); });
document.getElementById('modal-laporan').addEventListener('click', function(e) { if (e.target === this) closeLaporModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (isDrawMode) cancelDraw();
else if (isEditMode) closeEditPanel();
else if (document.getElementById('modal-laporan').classList.contains('show')) closeLaporModal();
else closeModal();
}
});
loadLS(); loadData();
</script>
</body>
</html>