1771 lines
98 KiB
HTML
1771 lines
98 KiB
HTML
<!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 & 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 & pengelola otomatis)</span></div>
|
||
<div class="laporan-tip-step"><div class="step-num-red">3</div><span>Upload foto & 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 & 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 (< 1 bulan)</span></div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#F59E0B"></div><span>1–12 bulan lalu</span></div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#9CA3AF"></div><span>1–3 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>
|