1233 lines
66 KiB
HTML
1233 lines
66 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>GIS Pontianak</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root {
|
||
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
||
--accent: #00d4aa; --accent2: #ff6b35; --text: #e6edf3;
|
||
--muted: #8b949e; --danger: #f85149; --panel-w: 340px;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family:'DM Sans',sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||
|
||
/* ── HEADER ── */
|
||
header { height:52px; background:var(--surface); border-bottom:1px solid var(--border); display:flex; align-items:center; padding:0 16px; gap:10px; z-index:1000; flex-shrink:0; }
|
||
.logo { font-family:'Space Mono',monospace; font-size:15px; font-weight:700; color:var(--accent); letter-spacing:-.5px; }
|
||
.logo span { color:var(--accent2); }
|
||
.badge { background:rgba(0,212,170,.12); color:var(--accent); font-size:11px; font-weight:600; padding:3px 8px; border-radius:4px; border:1px solid rgba(0,212,170,.3); }
|
||
|
||
/* Draw toolbar in header */
|
||
.draw-toolbar { display:flex; align-items:center; gap:6px; margin-left:auto; }
|
||
.draw-toolbar .sep { width:1px; height:22px; background:var(--border); margin:0 4px; }
|
||
.tool-btn {
|
||
display:flex; align-items:center; gap:5px; padding:6px 11px;
|
||
border-radius:6px; border:1px solid var(--border); background:var(--bg);
|
||
color:var(--muted); font-size:12px; font-weight:600; cursor:pointer;
|
||
font-family:'DM Sans',sans-serif; transition:all .2s; white-space:nowrap;
|
||
}
|
||
.tool-btn:hover { border-color:var(--accent); color:var(--accent); background:rgba(0,212,170,.06); }
|
||
.tool-btn.active { border-color:var(--accent2); color:#fff; background:var(--accent2); }
|
||
.tool-btn.active-poly { border-color:#a371f7; color:#fff; background:#7c3aed; }
|
||
.tool-btn .tb-icon { font-size:14px; }
|
||
.hint { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||
.hint .dot { width:7px; height:7px; background:var(--accent); border-radius:50%; animation:pulse 2s infinite; }
|
||
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(1.4)} }
|
||
|
||
/* ── SEARCH BAR ── */
|
||
.search-wrap {
|
||
position: relative;
|
||
flex: 1;
|
||
max-width: 320px;
|
||
}
|
||
.search-wrap .s-icon {
|
||
position: absolute; left:11px; top:50%; transform:translateY(-50%);
|
||
font-size:14px; pointer-events:none; opacity:.5;
|
||
}
|
||
#search-input {
|
||
width:100%; background:var(--bg); border:1px solid var(--border);
|
||
border-radius:8px; padding:7px 32px 7px 34px;
|
||
font-size:13px; color:var(--text); font-family:'DM Sans',sans-serif;
|
||
outline:none; transition:border-color .2s, box-shadow .2s;
|
||
}
|
||
#search-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(0,212,170,.12); }
|
||
#search-input::placeholder { color:var(--muted); }
|
||
.search-clear {
|
||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||
background:none; border:none; color:var(--muted); cursor:pointer;
|
||
font-size:14px; padding:2px 4px; border-radius:4px; line-height:1;
|
||
display:none; transition:color .2s;
|
||
}
|
||
.search-clear:hover { color:var(--danger); }
|
||
#search-input:not(:placeholder-shown) ~ .search-clear { display:block; }
|
||
|
||
/* Dropdown */
|
||
#search-dropdown {
|
||
display:none; position:absolute; top:calc(100% + 6px); left:0; right:0;
|
||
background:var(--surface); border:1px solid var(--border); border-radius:10px;
|
||
box-shadow:0 8px 32px rgba(0,0,0,.5); z-index:9000; overflow:hidden;
|
||
max-height:380px; overflow-y:auto;
|
||
}
|
||
#search-dropdown.open { display:block; animation:fadeIn .15s ease; }
|
||
#search-dropdown::-webkit-scrollbar { width:4px; }
|
||
#search-dropdown::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
|
||
|
||
.sd-section-label {
|
||
padding:8px 14px 4px; font-size:10px; font-weight:700; text-transform:uppercase;
|
||
letter-spacing:.8px; color:var(--muted); border-top:1px solid var(--border);
|
||
}
|
||
.sd-section-label:first-child { border-top:none; }
|
||
|
||
.sd-item {
|
||
display:flex; align-items:center; gap:10px; padding:9px 14px;
|
||
cursor:pointer; transition:background .15s;
|
||
}
|
||
.sd-item:hover, .sd-item.focused { background:rgba(0,212,170,.07); }
|
||
.sd-item .sd-icon { font-size:16px; flex-shrink:0; width:22px; text-align:center; }
|
||
.sd-item .sd-body { flex:1; min-width:0; }
|
||
.sd-item .sd-name {
|
||
font-size:13px; font-weight:600; color:var(--text);
|
||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||
}
|
||
.sd-item .sd-name mark {
|
||
background:rgba(0,212,170,.25); color:var(--accent); border-radius:2px;
|
||
padding:0 1px; font-weight:700;
|
||
}
|
||
.sd-item .sd-meta { font-size:11px; color:var(--muted); margin-top:1px; }
|
||
.sd-item .sd-badge {
|
||
font-size:9px; font-weight:700; padding:2px 6px; border-radius:3px;
|
||
text-transform:uppercase; letter-spacing:.4px; flex-shrink:0;
|
||
}
|
||
.sd-no-results { padding:20px 14px; text-align:center; color:var(--muted); font-size:13px; }
|
||
|
||
/* ── MAIN LAYOUT ── */
|
||
.main { flex:1; display:flex; overflow:hidden; }
|
||
#map { flex:1; }
|
||
.leaflet-container { cursor:crosshair !important; }
|
||
body.draw-line .leaflet-container { cursor:crosshair !important; }
|
||
body.draw-poly .leaflet-container { cursor:crosshair !important; }
|
||
body.move-mode .leaflet-container { cursor:grab !important; }
|
||
|
||
/* ── PANEL ── */
|
||
.panel { width:var(--panel-w); background:var(--surface); border-left:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden; flex-shrink:0; }
|
||
.panel-tabs { display:flex; border-bottom:1px solid var(--border); }
|
||
.tab-btn { flex:1; padding:10px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.8px; background:none; border:none; color:var(--muted); cursor:pointer; border-bottom:2px solid transparent; transition:all .2s; font-family:'DM Sans',sans-serif; }
|
||
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||
.tab-pane { display:none; flex:1; flex-direction:column; overflow:hidden; }
|
||
.tab-pane.active { display:flex; }
|
||
.panel-header { padding:12px 16px 10px; border-bottom:1px solid var(--border); }
|
||
.panel-header h2 { font-family:'Space Mono',monospace; font-size:12px; color:var(--accent); text-transform:uppercase; letter-spacing:1px; }
|
||
.panel-header p { font-size:11px; color:var(--muted); margin-top:2px; }
|
||
.records-list { flex:1; overflow-y:auto; padding:10px; display:flex; flex-direction:column; gap:7px; }
|
||
.records-list::-webkit-scrollbar { width:4px; }
|
||
.records-list::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
|
||
|
||
/* ── RECORD CARDS ── */
|
||
.record-card { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:11px 13px; cursor:pointer; transition:border-color .2s,transform .15s; position:relative; overflow:hidden; }
|
||
.record-card::before { content:''; position:absolute; left:0;top:0;bottom:0; width:3px; border-radius:3px 0 0 3px; background:var(--accent); }
|
||
.record-card[data-layer="polyline"]::before { background:var(--accent2); }
|
||
.record-card[data-layer="polygon"]::before { background:#a371f7; }
|
||
.record-card:hover { border-color:var(--accent); transform:translateX(2px); }
|
||
.rc-name { font-weight:600; font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.rc-sub { font-size:11px; color:var(--muted); margin-top:2px; font-family:'Space Mono',monospace; }
|
||
.rc-meta { display:flex; align-items:center; gap:6px; margin-top:6px; flex-wrap:wrap; }
|
||
.rc-type { font-size:10px; padding:2px 7px; border-radius:3px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; }
|
||
.rc-delete { position:absolute; top:8px;right:8px; background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:2px 5px; border-radius:4px; transition:color .2s,background .2s; }
|
||
.rc-delete:hover { color:var(--danger); background:rgba(248,81,73,.1); }
|
||
.empty-state { text-align:center; padding:36px 16px; color:var(--muted); }
|
||
.empty-state .es-icon { font-size:28px; margin-bottom:8px; opacity:.4; }
|
||
.empty-state p { font-size:12px; line-height:1.6; }
|
||
.panel-footer { padding:10px 14px; border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
|
||
.panel-footer span { font-size:11px; color:var(--muted); font-family:'Space Mono',monospace; }
|
||
#export-btn { font-size:11px; padding:5px 12px; background:rgba(0,212,170,.1); color:var(--accent); border:1px solid rgba(0,212,170,.3); border-radius:5px; cursor:pointer; font-family:'DM Sans',sans-serif; font-weight:600; transition:background .2s; }
|
||
#export-btn:hover { background:rgba(0,212,170,.2); }
|
||
|
||
/* ── MODAL ── */
|
||
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9999; align-items:center; justify-content:center; backdrop-filter:blur(4px); }
|
||
.modal-overlay.active { display:flex; }
|
||
.modal { background:var(--surface); border:1px solid var(--border); border-radius:14px; width:400px; max-width:calc(100vw - 32px); animation:slideUp .25s ease; overflow:hidden; }
|
||
@keyframes slideUp { from{opacity:0;transform:translateY(20px) scale(.97)} to{opacity:1;transform:translateY(0) scale(1)} }
|
||
.modal-head { padding:18px 20px 14px; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; }
|
||
.modal-head h3 { font-family:'Space Mono',monospace; font-size:14px; color:var(--text); }
|
||
.modal-head .coords-tag { font-size:10px; color:var(--muted); font-family:'Space Mono',monospace; margin-top:3px; }
|
||
.modal-close { background:none; border:none; color:var(--muted); font-size:18px; cursor:pointer; padding:2px 6px; border-radius:6px; transition:color .2s,background .2s; line-height:1; }
|
||
.modal-close:hover { color:var(--danger); background:rgba(248,81,73,.1); }
|
||
.modal-body { padding:18px 20px; display:flex; flex-direction:column; gap:14px; }
|
||
.field label { display:block; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.8px; color:var(--muted); margin-bottom:5px; }
|
||
.field input[type="text"],.field input[type="tel"],.field input[type="number"] {
|
||
width:100%; background:var(--bg); border:1px solid var(--border); border-radius:7px;
|
||
padding:9px 12px; font-size:14px; color:var(--text); font-family:'DM Sans',sans-serif;
|
||
transition:border-color .2s,box-shadow .2s; outline:none;
|
||
}
|
||
.field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(0,212,170,.15); }
|
||
.field input::placeholder { color:var(--muted); }
|
||
.field input[readonly] { color:var(--muted); cursor:default; }
|
||
|
||
/* Status select */
|
||
.field select {
|
||
width:100%; background:var(--bg); border:1px solid var(--border); border-radius:7px;
|
||
padding:9px 12px; font-size:14px; color:var(--text); font-family:'DM Sans',sans-serif;
|
||
outline:none; cursor:pointer; transition:border-color .2s;
|
||
appearance:none; -webkit-appearance:none;
|
||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238b949e' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||
background-repeat:no-repeat; background-position:right 12px center;
|
||
}
|
||
.field select:focus { border-color:var(--accent); }
|
||
.field select option { background:var(--surface); }
|
||
|
||
/* 24h toggle */
|
||
.toggle-group { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||
.toggle-group label { cursor:pointer; }
|
||
.toggle-group input[type="radio"] { display:none; }
|
||
.toggle-option { display:flex; align-items:center; justify-content:center; gap:8px; padding:11px 10px; border:1px solid var(--border); border-radius:10px; font-size:13px; font-weight:600; color:var(--muted); background:var(--bg); transition:all .2s; }
|
||
.toggle-group input[value="yes"]:checked + .toggle-option { border-color:#56d364; color:#56d364; background:rgba(86,211,100,.08); box-shadow:0 0 0 2px rgba(86,211,100,.2); }
|
||
.toggle-group input[value="no"]:checked + .toggle-option { border-color:#ff7b72; color:#ff7b72; background:rgba(255,123,114,.08); box-shadow:0 0 0 2px rgba(255,123,114,.2); }
|
||
|
||
/* Road status color swatches */
|
||
.status-preview { display:flex; align-items:center; gap:8px; margin-top:6px; }
|
||
.swatch { width:28px; height:6px; border-radius:3px; }
|
||
|
||
.modal-foot { padding:12px 20px 18px; display:flex; gap:10px; }
|
||
.btn-cancel { flex:1; padding:10px; background:var(--bg); border:1px solid var(--border); color:var(--muted); border-radius:8px; cursor:pointer; font-size:14px; font-family:'DM Sans',sans-serif; font-weight:500; transition:border-color .2s,color .2s; }
|
||
.btn-cancel:hover { border-color:var(--muted); color:var(--text); }
|
||
.btn-save { flex:2; padding:10px; background:var(--accent); border:none; color:#0d1117; border-radius:8px; cursor:pointer; font-size:14px; font-family:'DM Sans',sans-serif; font-weight:700; transition:opacity .2s,transform .1s; }
|
||
.btn-save:hover { opacity:.9; }
|
||
.btn-save:active { transform:scale(.98); }
|
||
|
||
/* ── LEAFLET POPUP ── */
|
||
.leaflet-popup-content-wrapper { background:var(--surface) !important; border:1px solid var(--border) !important; color:var(--text) !important; border-radius:10px !important; box-shadow:0 8px 32px rgba(0,0,0,.6) !important; font-family:'DM Sans',sans-serif !important; }
|
||
.leaflet-popup-tip { background:var(--surface) !important; }
|
||
.leaflet-popup-content { margin:14px 16px !important; min-width:160px; }
|
||
.pin-popup h4 { font-size:14px; font-weight:600; color:var(--text); margin-bottom:4px; }
|
||
.pin-popup .pp-sub { font-family:'Space Mono',monospace; font-size:11px; color:var(--muted); margin-bottom:8px; }
|
||
.pin-popup .pp-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||
.pin-popup .pp-badge { display:inline-block; font-size:10px; padding:2px 8px; border-radius:3px; font-weight:700; }
|
||
.pin-popup .pp-actions { display:flex; gap:8px; margin-top:10px; }
|
||
.pin-popup .pp-del, .pin-popup .pp-move {
|
||
flex:1; font-size:11px; padding:5px 0; text-align:center;
|
||
cursor:pointer; background:none; border:1px solid; border-radius:5px;
|
||
font-family:'DM Sans',sans-serif; font-weight:600; transition:opacity .2s,background .2s;
|
||
}
|
||
.pin-popup .pp-move { color:var(--accent2); border-color:rgba(255,107,53,.4); }
|
||
.pin-popup .pp-move:hover { background:rgba(255,107,53,.12); }
|
||
.pin-popup .pp-del { color:var(--danger); border-color:rgba(248,81,73,.4); }
|
||
.pin-popup .pp-del:hover { background:rgba(248,81,73,.1); }
|
||
|
||
/* ── DRAWING HINT BAR ── */
|
||
#draw-hint {
|
||
display:none; position:fixed; bottom:60px; left:50%; transform:translateX(-50%);
|
||
background:var(--surface); border:1px solid var(--border); border-radius:10px;
|
||
padding:10px 20px; font-size:13px; color:var(--text); z-index:5000;
|
||
box-shadow:0 4px 24px rgba(0,0,0,.5); white-space:nowrap;
|
||
display:flex; align-items:center; gap:10px;
|
||
}
|
||
#draw-hint.visible { display:flex; }
|
||
#draw-hint .dh-cancel { background:none; border:1px solid var(--border); color:var(--muted); border-radius:6px; padding:4px 12px; cursor:pointer; font-family:'DM Sans',sans-serif; font-size:12px; font-weight:600; transition:all .2s; }
|
||
#draw-hint .dh-cancel:hover { border-color:var(--danger); color:var(--danger); }
|
||
#draw-hint .dh-finish { background:var(--accent); border:none; color:#0d1117; border-radius:6px; padding:4px 12px; cursor:pointer; font-family:'DM Sans',sans-serif; font-size:12px; font-weight:700; }
|
||
|
||
/* ── TOAST ── */
|
||
#toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%) translateY(30px); background:var(--accent); color:#0d1117; font-size:13px; font-weight:700; padding:10px 22px; border-radius:8px; z-index:99999; opacity:0; transition:opacity .3s,transform .3s; pointer-events:none; white-space:nowrap; }
|
||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="logo">GIS<span>.</span>PTK</div>
|
||
<span class="badge">Pontianak</span>
|
||
|
||
<!-- Search Bar -->
|
||
<div class="search-wrap">
|
||
<span class="s-icon">🔍</span>
|
||
<input type="text" id="search-input" placeholder="Cari titik, jalan, kavling…" autocomplete="off" spellcheck="false"/>
|
||
<button class="search-clear" id="search-clear" title="Hapus">✕</button>
|
||
<div id="search-dropdown"></div>
|
||
</div>
|
||
|
||
<div class="draw-toolbar">
|
||
<div class="hint"><div class="dot"></div><span id="header-hint">Klik peta = tambah titik</span></div>
|
||
<div class="sep"></div>
|
||
<button class="tool-btn" id="tool-point" title="Mode: Tambah Titik"> <span class="tb-icon">📍</span> Titik</button>
|
||
<button class="tool-btn" id="tool-polyline" title="Gambar Jalan (Polyline)"><span class="tb-icon">〰️</span> Jalan</button>
|
||
<button class="tool-btn" id="tool-polygon" title="Gambar Kavling (Polygon)"><span class="tb-icon">⬡</span> Kavling</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="main">
|
||
<div id="map"></div>
|
||
|
||
<aside class="panel">
|
||
<!-- Tabs -->
|
||
<div class="panel-tabs">
|
||
<button class="tab-btn active" data-tab="tab-points">📍 Titik</button>
|
||
<button class="tab-btn" data-tab="tab-roads">〰️ Jalan</button>
|
||
<button class="tab-btn" data-tab="tab-kavling">⬡ Kavling</button>
|
||
</div>
|
||
|
||
<!-- Tab: Points -->
|
||
<div class="tab-pane active" id="tab-points">
|
||
<div class="panel-header">
|
||
<h2>Data Titik</h2>
|
||
<p id="pt-count-text">Belum ada data</p>
|
||
</div>
|
||
<div class="records-list" id="records-list"></div>
|
||
<div class="panel-footer">
|
||
<span id="pt-count">0 titik</span>
|
||
<button id="export-btn">↓ Export JSON</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Roads (Polyline) -->
|
||
<div class="tab-pane" id="tab-roads">
|
||
<div class="panel-header">
|
||
<h2>Data Jalan</h2>
|
||
<p id="rd-count-text">Belum ada data</p>
|
||
</div>
|
||
<div class="records-list" id="roads-list"></div>
|
||
<div class="panel-footer">
|
||
<span id="rd-count">0 jalan</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Kavling (Polygon) -->
|
||
<div class="tab-pane" id="tab-kavling">
|
||
<div class="panel-header">
|
||
<h2>Data Kavling</h2>
|
||
<p id="kv-count-text">Belum ada data</p>
|
||
</div>
|
||
<div class="records-list" id="kavling-list"></div>
|
||
<div class="panel-footer">
|
||
<span id="kv-count">0 kavling</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- ── MODAL: Point ── -->
|
||
<div class="modal-overlay" id="modal-point">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<div><h3>📍 Tambah Titik Lokasi</h3><div class="coords-tag" id="pt-coords">–</div></div>
|
||
<button class="modal-close" data-close="modal-point">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field"><label>Nama Lokasi</label><input type="text" id="pt-name" placeholder="cth. Toko Maju, Rumah Budi…" autocomplete="off"/></div>
|
||
<div class="field"><label>Nomor Telepon</label><input type="tel" id="pt-phone" placeholder="cth. 081234567890" autocomplete="off"/></div>
|
||
<div class="field">
|
||
<label>Buka 24 Jam?</label>
|
||
<div class="toggle-group">
|
||
<label><input type="radio" name="is24h" value="yes" checked/><div class="toggle-option"><span>🟢</span> Ya, 24 Jam</div></label>
|
||
<label><input type="radio" name="is24h" value="no"/><div class="toggle-option"><span>🔴</span> Tidak</div></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn-cancel" data-close="modal-point">Batal</button>
|
||
<button class="btn-save" id="pt-save">💾 Simpan Titik</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── MODAL: Road (Polyline) ── -->
|
||
<div class="modal-overlay" id="modal-road">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<div><h3>〰️ Simpan Data Jalan</h3><div class="coords-tag" id="rd-info">–</div></div>
|
||
<button class="modal-close" data-close="modal-road">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field"><label>Nama Jalan</label><input type="text" id="rd-name" placeholder="cth. Jl. Ahmad Yani…" autocomplete="off"/></div>
|
||
<div class="field">
|
||
<label>Status Jalan</label>
|
||
<select id="rd-status">
|
||
<option value="nasional">Jalan Nasional</option>
|
||
<option value="provinsi">Jalan Provinsi</option>
|
||
<option value="kabupaten">Jalan Kabupaten</option>
|
||
</select>
|
||
<div class="status-preview">
|
||
<div class="swatch" id="rd-swatch" style="background:#ff6b35"></div>
|
||
<span id="rd-swatch-label" style="font-size:11px;color:var(--muted)">Nasional — merah</span>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Panjang Jalan (otomatis)</label>
|
||
<input type="text" id="rd-length" readonly placeholder="Dihitung otomatis…"/>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn-cancel" data-close="modal-road">Batal</button>
|
||
<button class="btn-save" id="rd-save">💾 Simpan Jalan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── MODAL: Kavling (Polygon) ── -->
|
||
<div class="modal-overlay" id="modal-kavling">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<div><h3>⬡ Simpan Data Kavling</h3><div class="coords-tag" id="kv-info">–</div></div>
|
||
<button class="modal-close" data-close="modal-kavling">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field"><label>Nama / Nomor Kavling</label><input type="text" id="kv-name" placeholder="cth. Kavling A-01, Blok B No.5…" autocomplete="off"/></div>
|
||
<div class="field">
|
||
<label>Status Kepemilikan</label>
|
||
<select id="kv-status">
|
||
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
|
||
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||
<option value="HP">Sertifikat Hak Pakai (HP)</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>Luas Tanah (otomatis)</label>
|
||
<input type="text" id="kv-area" readonly placeholder="Dihitung otomatis…"/>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn-cancel" data-close="modal-kavling">Batal</button>
|
||
<button class="btn-save" id="kv-save">💾 Simpan Kavling</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Drawing hint bar -->
|
||
<div id="draw-hint">
|
||
<span id="draw-hint-text">Klik peta untuk mulai menggambar</span>
|
||
<button class="dh-finish" id="dh-finish">✅ Selesai</button>
|
||
<button class="dh-cancel" id="dh-cancel">✕ Batal</button>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||
<script>
|
||
// ════════════════════════════════════════════════════════════
|
||
// CONFIG
|
||
// ════════════════════════════════════════════════════════════
|
||
const PONTIANAK = [-0.0264, 109.3425];
|
||
const ZOOM = 15;
|
||
const API_POINT = 'api/locations.php';
|
||
const API_ROAD = 'api/roads.php';
|
||
const API_KAVLING = 'api/kavlings.php';
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// API HELPERS
|
||
// ════════════════════════════════════════════════════════════
|
||
const api = {
|
||
async get(url) { const r=await fetch(url); const j=await r.json(); if(!j.success) throw new Error(j.error||'Gagal'); return j.data; },
|
||
async post(url,body) { const r=await fetch(url,{method:'POST', headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); const j=await r.json(); if(!j.success) throw new Error(j.error||'Gagal'); return j.data; },
|
||
async patch(url,body) { const r=await fetch(url,{method:'PATCH', headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); const j=await r.json(); if(!j.success) throw new Error(j.error||'Gagal'); return j.data; },
|
||
async del(url) { const r=await fetch(url,{method:'DELETE'}); const j=await r.json(); if(!j.success) throw new Error(j.error||'Gagal'); },
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// MAP
|
||
// ════════════════════════════════════════════════════════════
|
||
const map = L.map('map',{zoomControl:true}).setView(PONTIANAK,ZOOM);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
|
||
attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom:20
|
||
}).addTo(map);
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// ROAD STATUS CONFIG
|
||
// ════════════════════════════════════════════════════════════
|
||
const ROAD_COLOR = { nasional:'#ff6b35', provinsi:'#58a6ff', kabupaten:'#56d364' };
|
||
const ROAD_LABEL = { nasional:'Nasional', provinsi:'Provinsi', kabupaten:'Kabupaten' };
|
||
const KV_COLOR = { SHM:'#f0883e', HGB:'#a371f7', HGU:'#58a6ff', HP:'#56d364' };
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// STATE
|
||
// ════════════════════════════════════════════════════════════
|
||
let currentMode = 'point'; // 'point' | 'polyline' | 'polygon'
|
||
let drawingPoints = []; // latlngs clicked so far
|
||
let previewLayer = null; // live preview layer on map
|
||
let pendingLatLng = null;
|
||
let moveMode = false;
|
||
const markerMap = {}; // id → marker
|
||
const roadMap = {}; // id → polyline
|
||
const kavlingMap = {}; // id → polygon
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// POINT ICON
|
||
// ════════════════════════════════════════════════════════════
|
||
function makeIcon(is24h) {
|
||
const color = (is24h==1||is24h===true) ? '#56d364' : '#ff7b72';
|
||
const emoji = (is24h==1||is24h===true) ? '🟢' : '🔴';
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="44" viewBox="0 0 34 44">
|
||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="rgba(0,0,0,.5)"/></filter>
|
||
<path d="M17 2C9.27 2 3 8.27 3 16c0 10 14 26 14 26S31 26 31 16C31 8.27 24.73 2 17 2Z" fill="${color}" filter="url(#sh)"/>
|
||
<circle cx="17" cy="16" r="8" fill="rgba(0,0,0,.2)"/>
|
||
<text x="17" y="20.5" font-size="11" text-anchor="middle" fill="white">${emoji}</text>
|
||
</svg>`;
|
||
return L.divIcon({html:svg,iconSize:[34,44],iconAnchor:[17,44],popupAnchor:[0,-46],className:''});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// TOOLBAR MODE SWITCHING
|
||
// ════════════════════════════════════════════════════════════
|
||
const btnPoint = document.getElementById('tool-point');
|
||
const btnPolyline = document.getElementById('tool-polyline');
|
||
const btnPolygon = document.getElementById('tool-polygon');
|
||
const headerHint = document.getElementById('header-hint');
|
||
const drawHint = document.getElementById('draw-hint');
|
||
const drawHintTxt = document.getElementById('draw-hint-text');
|
||
|
||
function setMode(mode) {
|
||
cancelDrawing();
|
||
currentMode = mode;
|
||
btnPoint .classList.toggle('active', mode==='point');
|
||
btnPolyline.classList.toggle('active', mode==='polyline');
|
||
btnPolygon .classList.toggle('active-poly', mode==='polygon');
|
||
document.body.classList.toggle('draw-line', mode==='polyline');
|
||
document.body.classList.toggle('draw-poly', mode==='polygon');
|
||
if(mode==='point'){
|
||
headerHint.textContent = 'Klik peta = tambah titik';
|
||
drawHint.classList.remove('visible');
|
||
} else if(mode==='polyline'){
|
||
headerHint.textContent = 'Klik peta untuk menggambar jalan';
|
||
drawHintTxt.textContent = 'Klik peta untuk menambah titik jalan. Min. 2 titik.';
|
||
drawHint.classList.add('visible');
|
||
} else {
|
||
headerHint.textContent = 'Klik peta untuk menggambar kavling';
|
||
drawHintTxt.textContent = 'Klik peta untuk menambah sudut kavling. Min. 3 titik.';
|
||
drawHint.classList.add('visible');
|
||
}
|
||
}
|
||
|
||
btnPoint .onclick = () => setMode('point');
|
||
btnPolyline.onclick = () => setMode('polyline');
|
||
btnPolygon .onclick = () => setMode('polygon');
|
||
|
||
function cancelDrawing() {
|
||
drawingPoints = [];
|
||
if(previewLayer){ map.removeLayer(previewLayer); previewLayer=null; }
|
||
drawHint.classList.remove('visible');
|
||
document.body.classList.remove('draw-line','draw-poly');
|
||
}
|
||
|
||
document.getElementById('dh-cancel').onclick = () => { cancelDrawing(); setMode('point'); };
|
||
|
||
document.getElementById('dh-finish').onclick = () => {
|
||
if(currentMode==='polyline') finishPolyline();
|
||
else if(currentMode==='polygon') finishPolygon();
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// MAP CLICK HANDLER
|
||
// ════════════════════════════════════════════════════════════
|
||
map.on('click', function(e){
|
||
if(moveMode) return;
|
||
if(currentMode==='point'){
|
||
openPointModal(e.latlng);
|
||
} else if(currentMode==='polyline'){
|
||
addDrawPoint(e.latlng);
|
||
updatePolylinePreview();
|
||
} else if(currentMode==='polygon'){
|
||
addDrawPoint(e.latlng);
|
||
updatePolygonPreview();
|
||
}
|
||
});
|
||
|
||
function addDrawPoint(latlng){
|
||
drawingPoints.push(latlng);
|
||
const min = currentMode==='polyline' ? 2 : 3;
|
||
const n = drawingPoints.length;
|
||
drawHintTxt.textContent = `${n} titik dipilih. Butuh min. ${min}. Klik "Selesai" jika sudah.`;
|
||
}
|
||
|
||
function updatePolylinePreview(){
|
||
if(previewLayer) map.removeLayer(previewLayer);
|
||
if(drawingPoints.length >= 2)
|
||
previewLayer = L.polyline(drawingPoints,{color:'#ff6b35',weight:4,opacity:.8,dashArray:'8 4'}).addTo(map);
|
||
}
|
||
|
||
function updatePolygonPreview(){
|
||
if(previewLayer) map.removeLayer(previewLayer);
|
||
if(drawingPoints.length >= 3)
|
||
previewLayer = L.polygon(drawingPoints,{color:'#a371f7',fillColor:'#7c3aed',fillOpacity:.25,weight:3,dashArray:'8 4'}).addTo(map);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// FINISH POLYLINE → open road modal
|
||
// ════════════════════════════════════════════════════════════
|
||
function finishPolyline(){
|
||
if(drawingPoints.length < 2){ showToast('⚠ Minimal 2 titik untuk jalan!'); return; }
|
||
const lengthM = calcPolylineLength(drawingPoints);
|
||
document.getElementById('rd-name').value = '';
|
||
document.getElementById('rd-status').value = 'nasional';
|
||
document.getElementById('rd-length').value = formatLength(lengthM);
|
||
document.getElementById('rd-info').textContent = `${drawingPoints.length} titik · ${formatLength(lengthM)}`;
|
||
updateRoadSwatch('nasional');
|
||
document.getElementById('modal-road').classList.add('active');
|
||
setTimeout(()=>document.getElementById('rd-name').focus(),50);
|
||
}
|
||
|
||
function finishPolygon(){
|
||
if(drawingPoints.length < 3){ showToast('⚠ Minimal 3 titik untuk kavling!'); return; }
|
||
const areaM2 = calcPolygonArea(drawingPoints);
|
||
document.getElementById('kv-name').value = '';
|
||
document.getElementById('kv-status').value = 'SHM';
|
||
document.getElementById('kv-area').value = formatArea(areaM2);
|
||
document.getElementById('kv-info').textContent = `${drawingPoints.length} sudut · ${formatArea(areaM2)}`;
|
||
document.getElementById('modal-kavling').classList.add('active');
|
||
setTimeout(()=>document.getElementById('kv-name').focus(),50);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// ROAD STATUS SWATCH UPDATE
|
||
// ════════════════════════════════════════════════════════════
|
||
document.getElementById('rd-status').addEventListener('change', function(){
|
||
updateRoadSwatch(this.value);
|
||
});
|
||
function updateRoadSwatch(status){
|
||
const labels = {nasional:'Nasional — merah/oranye', provinsi:'Provinsi — biru', kabupaten:'Kabupaten — hijau'};
|
||
document.getElementById('rd-swatch').style.background = ROAD_COLOR[status]||'#888';
|
||
document.getElementById('rd-swatch-label').textContent = labels[status]||'';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// GEO CALCULATIONS (using Leaflet)
|
||
// ════════════════════════════════════════════════════════════
|
||
function calcPolylineLength(pts){
|
||
let total = 0;
|
||
for(let i=1;i<pts.length;i++) total += pts[i-1].distanceTo(pts[i]);
|
||
return total;
|
||
}
|
||
function calcPolygonArea(pts){
|
||
// Shoelace on spherical coords using Leaflet's CRS
|
||
const poly = L.polygon(pts);
|
||
const lls = poly.getLatLngs()[0];
|
||
// Convert to meters using approximate planar for small areas
|
||
const R = 6371000;
|
||
let area = 0;
|
||
for(let i=0,j=lls.length-1;i<lls.length;j=i++){
|
||
const xi = lls[i].lng * Math.PI/180 * R * Math.cos(lls[i].lat*Math.PI/180);
|
||
const yi = lls[i].lat * Math.PI/180 * R;
|
||
const xj = lls[j].lng * Math.PI/180 * R * Math.cos(lls[j].lat*Math.PI/180);
|
||
const yj = lls[j].lat * Math.PI/180 * R;
|
||
area += (xj+xi)*(yj-yi);
|
||
}
|
||
return Math.abs(area/2);
|
||
}
|
||
function formatLength(m){
|
||
return m >= 1000 ? `${(m/1000).toFixed(2)} km` : `${Math.round(m)} m`;
|
||
}
|
||
function formatArea(m2){
|
||
return m2 >= 10000 ? `${(m2/10000).toFixed(4)} ha` : `${Math.round(m2)} m²`;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// POINT MODAL
|
||
// ════════════════════════════════════════════════════════════
|
||
function openPointModal(latlng){
|
||
pendingLatLng = latlng;
|
||
document.getElementById('pt-name').value = '';
|
||
document.getElementById('pt-phone').value = '';
|
||
document.querySelector('input[name="is24h"][value="yes"]').checked = true;
|
||
document.getElementById('pt-coords').textContent = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
|
||
document.getElementById('modal-point').classList.add('active');
|
||
setTimeout(()=>document.getElementById('pt-name').focus(),50);
|
||
}
|
||
|
||
document.getElementById('pt-save').onclick = async function(){
|
||
const name = document.getElementById('pt-name').value.trim();
|
||
const phone = document.getElementById('pt-phone').value.trim();
|
||
const is24h = document.querySelector('input[name="is24h"]:checked').value==='yes' ? 1 : 0;
|
||
if(!name){ shake(document.getElementById('pt-name')); return; }
|
||
const btn = this; btn.disabled=true; btn.textContent='⏳ Menyimpan…';
|
||
try {
|
||
const saved = await api.post(API_POINT,{name,phone,is24h,lat:pendingLatLng.lat,lng:pendingLatLng.lng});
|
||
placeMarker(saved);
|
||
await refreshPoints();
|
||
closeModal('modal-point');
|
||
showToast(`✓ "${saved.name}" disimpan`);
|
||
} catch(err){ showToast('❌ '+err.message); }
|
||
finally{ btn.disabled=false; btn.textContent='💾 Simpan Titik'; }
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// ROAD MODAL SAVE
|
||
// ════════════════════════════════════════════════════════════
|
||
document.getElementById('rd-save').onclick = async function(){
|
||
const name = document.getElementById('rd-name').value.trim();
|
||
const status = document.getElementById('rd-status').value;
|
||
if(!name){ shake(document.getElementById('rd-name')); return; }
|
||
const coords = drawingPoints.map(p=>([p.lat,p.lng]));
|
||
const length = calcPolylineLength(drawingPoints);
|
||
const btn = this; btn.disabled=true; btn.textContent='⏳ Menyimpan…';
|
||
try {
|
||
const saved = await api.post(API_ROAD,{name,status,length_m:Math.round(length),coords:JSON.stringify(coords)});
|
||
if(previewLayer){ map.removeLayer(previewLayer); previewLayer=null; }
|
||
placeRoad(saved);
|
||
drawingPoints=[];
|
||
await refreshRoads();
|
||
closeModal('modal-road');
|
||
setMode('point');
|
||
showToast(`✓ Jalan "${saved.name}" disimpan`);
|
||
} catch(err){ showToast('❌ '+err.message); }
|
||
finally{ btn.disabled=false; btn.textContent='💾 Simpan Jalan'; }
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// KAVLING MODAL SAVE
|
||
// ════════════════════════════════════════════════════════════
|
||
document.getElementById('kv-save').onclick = async function(){
|
||
const name = document.getElementById('kv-name').value.trim();
|
||
const status = document.getElementById('kv-status').value;
|
||
if(!name){ shake(document.getElementById('kv-name')); return; }
|
||
const coords = drawingPoints.map(p=>([p.lat,p.lng]));
|
||
const area = calcPolygonArea(drawingPoints);
|
||
const btn = this; btn.disabled=true; btn.textContent='⏳ Menyimpan…';
|
||
try {
|
||
const saved = await api.post(API_KAVLING,{name,status,area_m2:Math.round(area),coords:JSON.stringify(coords)});
|
||
if(previewLayer){ map.removeLayer(previewLayer); previewLayer=null; }
|
||
placeKavling(saved);
|
||
drawingPoints=[];
|
||
await refreshKavlings();
|
||
closeModal('modal-kavling');
|
||
setMode('point');
|
||
showToast(`✓ Kavling "${saved.name}" disimpan`);
|
||
} catch(err){ showToast('❌ '+err.message); }
|
||
finally{ btn.disabled=false; btn.textContent='💾 Simpan Kavling'; }
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// PLACE LAYERS ON MAP
|
||
// ════════════════════════════════════════════════════════════
|
||
function placeMarker(rec){
|
||
const open = (rec.is24h==1||rec.is24h===true);
|
||
const badge = open
|
||
? `<span class="pp-badge" style="background:rgba(86,211,100,.15);color:#56d364;border:1px solid rgba(86,211,100,.3)">🟢 Buka 24 Jam</span>`
|
||
: `<span class="pp-badge" style="background:rgba(255,123,114,.15);color:#ff7b72;border:1px solid rgba(255,123,114,.3)">🔴 Tidak 24 Jam</span>`;
|
||
const marker = L.marker([rec.lat,rec.lng],{icon:makeIcon(rec.is24h),draggable:false}).addTo(map);
|
||
marker.bindPopup(`<div class="pin-popup">
|
||
<h4>${escHtml(rec.name)}</h4>
|
||
<div class="pp-sub">${escHtml(rec.phone||'—')}</div>
|
||
<div class="pp-row">${badge}</div>
|
||
<div class="pp-actions">
|
||
<button class="pp-move" onclick="enableMoveMode('${rec.id}')">✋ Pindah</button>
|
||
<button class="pp-del" onclick="removePoint('${rec.id}')">🗑 Hapus</button>
|
||
</div></div>`,{maxWidth:230});
|
||
markerMap[rec.id]=marker;
|
||
}
|
||
|
||
function placeRoad(rec){
|
||
const coords = typeof rec.coords==='string' ? JSON.parse(rec.coords) : rec.coords;
|
||
const color = ROAD_COLOR[rec.status]||'#888';
|
||
const line = L.polyline(coords,{color,weight:5,opacity:.9}).addTo(map);
|
||
line.bindPopup(`<div class="pin-popup">
|
||
<h4>${escHtml(rec.name)}</h4>
|
||
<div class="pp-sub">${formatLength(rec.length_m)}</div>
|
||
<div class="pp-row">
|
||
<span class="pp-badge" style="background:${color}22;color:${color};border:1px solid ${color}44">
|
||
${ROAD_LABEL[rec.status]||rec.status}
|
||
</span>
|
||
</div>
|
||
<div class="pp-actions">
|
||
<button class="pp-del" onclick="removeRoad('${rec.id}')">🗑 Hapus</button>
|
||
</div></div>`,{maxWidth:200});
|
||
roadMap[rec.id]=line;
|
||
}
|
||
|
||
function placeKavling(rec){
|
||
const coords = typeof rec.coords==='string' ? JSON.parse(rec.coords) : rec.coords;
|
||
const color = KV_COLOR[rec.status]||'#888';
|
||
const poly = L.polygon(coords,{color,fillColor:color,fillOpacity:.25,weight:3}).addTo(map);
|
||
poly.bindPopup(`<div class="pin-popup">
|
||
<h4>${escHtml(rec.name)}</h4>
|
||
<div class="pp-sub">${formatArea(rec.area_m2)}</div>
|
||
<div class="pp-row">
|
||
<span class="pp-badge" style="background:${color}22;color:${color};border:1px solid ${color}44">
|
||
${escHtml(rec.status)}
|
||
</span>
|
||
</div>
|
||
<div class="pp-actions">
|
||
<button class="pp-del" onclick="removeKavling('${rec.id}')">🗑 Hapus</button>
|
||
</div></div>`,{maxWidth:200});
|
||
kavlingMap[rec.id]=poly;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// REMOVE
|
||
// ════════════════════════════════════════════════════════════
|
||
window.removePoint = async function(id){
|
||
try{
|
||
await api.del(`${API_POINT}?id=${id}`);
|
||
if(markerMap[id]){map.closePopup();map.removeLayer(markerMap[id]);delete markerMap[id];}
|
||
await refreshPoints(); showToast('Titik dihapus');
|
||
}catch(e){showToast('❌ '+e.message);}
|
||
};
|
||
window.removeRoad = async function(id){
|
||
try{
|
||
await api.del(`${API_ROAD}?id=${id}`);
|
||
if(roadMap[id]){map.closePopup();map.removeLayer(roadMap[id]);delete roadMap[id];}
|
||
await refreshRoads(); showToast('Jalan dihapus');
|
||
}catch(e){showToast('❌ '+e.message);}
|
||
};
|
||
window.removeKavling = async function(id){
|
||
try{
|
||
await api.del(`${API_KAVLING}?id=${id}`);
|
||
if(kavlingMap[id]){map.closePopup();map.removeLayer(kavlingMap[id]);delete kavlingMap[id];}
|
||
await refreshKavlings(); showToast('Kavling dihapus');
|
||
}catch(e){showToast('❌ '+e.message);}
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// MOVE MODE (Points)
|
||
// ════════════════════════════════════════════════════════════
|
||
window.enableMoveMode = function(id){
|
||
map.closePopup(); moveMode=true;
|
||
document.body.classList.add('move-mode');
|
||
const marker = markerMap[id];
|
||
if(!marker) return;
|
||
marker.dragging.enable();
|
||
showToast('✋ Seret pin ke lokasi baru, lalu klik Selesai');
|
||
const doneBtn = document.createElement('button');
|
||
doneBtn.innerHTML = '✅ Selesai Pindah';
|
||
doneBtn.style.cssText='position:fixed;bottom:70px;left:50%;transform:translateX(-50%);z-index:9998;background:var(--accent);color:#0d1117;font-family:DM Sans,sans-serif;font-weight:700;font-size:14px;padding:12px 28px;border:none;border-radius:10px;cursor:pointer;box-shadow:0 4px 20px rgba(0,212,170,.4)';
|
||
document.body.appendChild(doneBtn);
|
||
doneBtn.onclick = async () => {
|
||
const ll = marker.getLatLng();
|
||
marker.dragging.disable(); moveMode=false;
|
||
document.body.classList.remove('move-mode'); doneBtn.remove();
|
||
try{
|
||
await api.patch(`${API_POINT}?id=${id}`,{lat:ll.lat,lng:ll.lng});
|
||
await refreshPoints(); showToast('📍 Posisi diperbarui');
|
||
}catch(e){showToast('❌ '+e.message);}
|
||
};
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// SIDE PANEL RENDER
|
||
// ════════════════════════════════════════════════════════════
|
||
async function refreshPoints(){
|
||
try{
|
||
const recs = await api.get(API_POINT);
|
||
const list = document.getElementById('records-list');
|
||
document.getElementById('pt-count').textContent = `${recs.length} titik`;
|
||
document.getElementById('pt-count-text').textContent = recs.length ? `${recs.length} lokasi tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('📍','Klik peta untuk menambah titik'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const open=r.is24h==1; const c=open?'#56d364':'#ff7b72'; const bg=open?'rgba(86,211,100,.1)':'rgba(255,123,114,.1)';
|
||
return `<div class="record-card" data-layer="point" onclick="focusPoint('${r.id}',${r.lat},${r.lng})">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removePoint('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${escHtml(r.phone||'—')}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${bg};color:${c}">${open?'🟢 24 Jam':'🔴 Non-24h'}</span>
|
||
<span style="font-size:10px;color:var(--muted);font-family:Space Mono,monospace">${parseFloat(r.lat).toFixed(4)},${parseFloat(r.lng).toFixed(4)}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
}catch(e){ document.getElementById('pt-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
}
|
||
|
||
async function refreshRoads(){
|
||
try{
|
||
const recs = await api.get(API_ROAD);
|
||
const list = document.getElementById('roads-list');
|
||
document.getElementById('rd-count').textContent = `${recs.length} jalan`;
|
||
document.getElementById('rd-count-text').textContent = recs.length ? `${recs.length} jalan tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('〰️','Gambar jalan dengan tombol "Jalan" di atas'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const c=ROAD_COLOR[r.status]||'#888';
|
||
return `<div class="record-card" data-layer="polyline" onclick="focusRoad('${r.id}')">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removeRoad('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${formatLength(r.length_m)}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${c}22;color:${c};border:1px solid ${c}44">${ROAD_LABEL[r.status]||r.status}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
}catch(e){ document.getElementById('rd-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
}
|
||
|
||
async function refreshKavlings(){
|
||
try{
|
||
const recs = await api.get(API_KAVLING);
|
||
const list = document.getElementById('kavling-list');
|
||
document.getElementById('kv-count').textContent = `${recs.length} kavling`;
|
||
document.getElementById('kv-count-text').textContent = recs.length ? `${recs.length} kavling tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('⬡','Gambar kavling dengan tombol "Kavling" di atas'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const c=KV_COLOR[r.status]||'#888';
|
||
return `<div class="record-card" data-layer="polygon" onclick="focusKavling('${r.id}')">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removeKavling('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${formatArea(r.area_m2)}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${c}22;color:${c};border:1px solid ${c}44">${escHtml(r.status)}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
}catch(e){ document.getElementById('kv-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
}
|
||
|
||
window.focusPoint = (id,lat,lng)=>{ map.setView([lat,lng],17,{animate:true}); if(markerMap[id]) markerMap[id].openPopup(); };
|
||
window.focusRoad = (id)=>{ if(roadMap[id]){ map.fitBounds(roadMap[id].getBounds(),{padding:[30,30]}); roadMap[id].openPopup(); }};
|
||
window.focusKavling = (id)=>{ if(kavlingMap[id]){ map.fitBounds(kavlingMap[id].getBounds(),{padding:[30,30]}); kavlingMap[id].openPopup(); }};
|
||
|
||
function emptyState(icon,msg){ return `<div class="empty-state"><div class="es-icon">${icon}</div><p>${msg}</p></div>`; }
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// TABS
|
||
// ════════════════════════════════════════════════════════════
|
||
document.querySelectorAll('.tab-btn').forEach(btn=>{
|
||
btn.onclick=()=>{
|
||
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-pane').forEach(p=>p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById(btn.dataset.tab).classList.add('active');
|
||
};
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// MODAL CLOSE HELPERS
|
||
// ════════════════════════════════════════════════════════════
|
||
function closeModal(id){ document.getElementById(id).classList.remove('active'); }
|
||
document.querySelectorAll('[data-close]').forEach(el=>{
|
||
el.onclick=()=>closeModal(el.dataset.close);
|
||
});
|
||
document.querySelectorAll('.modal-overlay').forEach(ov=>{
|
||
ov.addEventListener('click',e=>{ if(e.target===ov) ov.classList.remove('active'); });
|
||
});
|
||
document.addEventListener('keydown',e=>{ if(e.key==='Escape') document.querySelectorAll('.modal-overlay.active').forEach(m=>m.classList.remove('active')); });
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// EXPORT
|
||
// ════════════════════════════════════════════════════════════
|
||
document.getElementById('export-btn').onclick = async function(){
|
||
const d = await api.get(API_POINT);
|
||
const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});
|
||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`gis_ptk_${Date.now()}.json`; a.click();
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// SEARCH ENGINE
|
||
// ════════════════════════════════════════════════════════════
|
||
const searchInput = document.getElementById('search-input');
|
||
const searchDropdown = document.getElementById('search-dropdown');
|
||
const searchClear = document.getElementById('search-clear');
|
||
|
||
// In-memory cache — refreshed on every data load
|
||
let searchCache = { points:[], roads:[], kavlings:[] };
|
||
|
||
function updateSearchCache(type, data) {
|
||
searchCache[type] = data;
|
||
}
|
||
|
||
// Fuzzy-ish match: checks if every character of query appears in order in str
|
||
// Also does simple substring match for fast partial hits
|
||
function matchScore(str, query) {
|
||
if (!str) return 0;
|
||
const s = str.toLowerCase();
|
||
const q = query.toLowerCase().trim();
|
||
if (!q) return 0;
|
||
// Exact substring — highest score
|
||
if (s.includes(q)) return 100 + (100 - s.indexOf(q));
|
||
// Word-start match — good score
|
||
const words = s.split(/\s+/);
|
||
for (const w of words) { if (w.startsWith(q)) return 80; }
|
||
// Fuzzy: all chars in order
|
||
let qi = 0;
|
||
for (let i = 0; i < s.length && qi < q.length; i++) {
|
||
if (s[i] === q[qi]) qi++;
|
||
}
|
||
if (qi === q.length) return 40 + qi;
|
||
// Partial: at least half chars match
|
||
let hits = 0;
|
||
for (const c of q) { if (s.includes(c)) hits++; }
|
||
if (hits / q.length >= 0.6) return 20 + hits;
|
||
return 0;
|
||
}
|
||
|
||
function highlightMatch(str, query) {
|
||
if (!query.trim()) return escHtml(str);
|
||
const q = query.trim();
|
||
const idx = str.toLowerCase().indexOf(q.toLowerCase());
|
||
if (idx >= 0) {
|
||
return escHtml(str.slice(0,idx))
|
||
+ `<mark>${escHtml(str.slice(idx, idx+q.length))}</mark>`
|
||
+ escHtml(str.slice(idx+q.length));
|
||
}
|
||
// fuzzy highlight: bold matching chars
|
||
let result = ''; let qi = 0; const ql = q.toLowerCase();
|
||
for (const ch of str) {
|
||
if (qi < ql.length && ch.toLowerCase() === ql[qi]) {
|
||
result += `<mark>${escHtml(ch)}</mark>`; qi++;
|
||
} else { result += escHtml(ch); }
|
||
}
|
||
return result;
|
||
}
|
||
|
||
let dropdownFocusIdx = -1;
|
||
let dropdownItems = [];
|
||
|
||
function runSearch(query) {
|
||
dropdownFocusIdx = -1;
|
||
dropdownItems = [];
|
||
|
||
if (!query.trim()) { closeDropdown(); return; }
|
||
|
||
const q = query.trim();
|
||
|
||
// Score + filter each type
|
||
const pts = searchCache.points
|
||
.map(r => ({ ...r, _type:'point', _score: Math.max(matchScore(r.name,q), matchScore(r.phone,q)) }))
|
||
.filter(r => r._score > 0).sort((a,b)=>b._score-a._score).slice(0,5);
|
||
|
||
const rds = searchCache.roads
|
||
.map(r => ({ ...r, _type:'road', _score: Math.max(matchScore(r.name,q), matchScore(r.status,q)) }))
|
||
.filter(r => r._score > 0).sort((a,b)=>b._score-a._score).slice(0,4);
|
||
|
||
const kvs = searchCache.kavlings
|
||
.map(r => ({ ...r, _type:'kavling', _score: Math.max(matchScore(r.name,q), matchScore(r.status,q)) }))
|
||
.filter(r => r._score > 0).sort((a,b)=>b._score-a._score).slice(0,4);
|
||
|
||
if (!pts.length && !rds.length && !kvs.length) {
|
||
searchDropdown.innerHTML = `<div class="sd-no-results">🔍 Tidak ditemukan untuk "<strong>${escHtml(q)}</strong>"</div>`;
|
||
searchDropdown.classList.add('open');
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
if (pts.length) {
|
||
html += `<div class="sd-section-label">📍 Titik Lokasi</div>`;
|
||
pts.forEach(r => {
|
||
const open = r.is24h==1;
|
||
const badge = open
|
||
? `<span class="sd-badge" style="background:rgba(86,211,100,.15);color:#56d364">24 Jam</span>`
|
||
: `<span class="sd-badge" style="background:rgba(255,123,114,.15);color:#ff7b72">Non-24h</span>`;
|
||
html += `<div class="sd-item" data-idx="${dropdownItems.length}" onclick="selectSearchResult('point','${r.id}',${r.lat},${r.lng})">
|
||
<span class="sd-icon">📍</span>
|
||
<div class="sd-body">
|
||
<div class="sd-name">${highlightMatch(r.name, q)}</div>
|
||
<div class="sd-meta">${escHtml(r.phone||'—')}</div>
|
||
</div>${badge}</div>`;
|
||
dropdownItems.push({ type:'point', id:r.id, lat:r.lat, lng:r.lng });
|
||
});
|
||
}
|
||
|
||
if (rds.length) {
|
||
html += `<div class="sd-section-label">〰️ Jalan</div>`;
|
||
const statusLabel = {nasional:'Nasional',provinsi:'Provinsi',kabupaten:'Kabupaten'};
|
||
rds.forEach(r => {
|
||
const c = ROAD_COLOR[r.status]||'#888';
|
||
html += `<div class="sd-item" data-idx="${dropdownItems.length}" onclick="selectSearchResult('road','${r.id}')">
|
||
<span class="sd-icon">〰️</span>
|
||
<div class="sd-body">
|
||
<div class="sd-name">${highlightMatch(r.name, q)}</div>
|
||
<div class="sd-meta">${formatLength(r.length_m)}</div>
|
||
</div>
|
||
<span class="sd-badge" style="background:${c}22;color:${c}">${statusLabel[r.status]||r.status}</span>
|
||
</div>`;
|
||
dropdownItems.push({ type:'road', id:r.id });
|
||
});
|
||
}
|
||
|
||
if (kvs.length) {
|
||
html += `<div class="sd-section-label">⬡ Kavling</div>`;
|
||
kvs.forEach(r => {
|
||
const c = KV_COLOR[r.status]||'#888';
|
||
html += `<div class="sd-item" data-idx="${dropdownItems.length}" onclick="selectSearchResult('kavling','${r.id}')">
|
||
<span class="sd-icon">⬡</span>
|
||
<div class="sd-body">
|
||
<div class="sd-name">${highlightMatch(r.name, q)}</div>
|
||
<div class="sd-meta">${formatArea(r.area_m2)}</div>
|
||
</div>
|
||
<span class="sd-badge" style="background:${c}22;color:${c}">${escHtml(r.status)}</span>
|
||
</div>`;
|
||
dropdownItems.push({ type:'kavling', id:r.id });
|
||
});
|
||
}
|
||
|
||
searchDropdown.innerHTML = html;
|
||
searchDropdown.classList.add('open');
|
||
}
|
||
|
||
window.selectSearchResult = function(type, id, lat, lng) {
|
||
closeDropdown();
|
||
searchInput.value = '';
|
||
|
||
if (type === 'point') {
|
||
focusPoint(id, lat, lng);
|
||
// Switch to points tab
|
||
document.querySelector('[data-tab="tab-points"]').click();
|
||
} else if (type === 'road') {
|
||
focusRoad(id);
|
||
document.querySelector('[data-tab="tab-roads"]').click();
|
||
} else if (type === 'kavling') {
|
||
focusKavling(id);
|
||
document.querySelector('[data-tab="tab-kavling"]').click();
|
||
}
|
||
};
|
||
|
||
function closeDropdown() {
|
||
searchDropdown.classList.remove('open');
|
||
dropdownFocusIdx = -1;
|
||
}
|
||
|
||
// Keyboard navigation
|
||
searchInput.addEventListener('keydown', e => {
|
||
const items = searchDropdown.querySelectorAll('.sd-item');
|
||
if (!items.length) return;
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
dropdownFocusIdx = Math.min(dropdownFocusIdx + 1, items.length - 1);
|
||
updateDropdownFocus(items);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
dropdownFocusIdx = Math.max(dropdownFocusIdx - 1, 0);
|
||
updateDropdownFocus(items);
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (dropdownFocusIdx >= 0 && dropdownItems[dropdownFocusIdx]) {
|
||
const d = dropdownItems[dropdownFocusIdx];
|
||
selectSearchResult(d.type, d.id, d.lat, d.lng);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
closeDropdown(); searchInput.blur();
|
||
}
|
||
});
|
||
|
||
function updateDropdownFocus(items) {
|
||
items.forEach(el => el.classList.remove('focused'));
|
||
if (dropdownFocusIdx >= 0 && items[dropdownFocusIdx]) {
|
||
items[dropdownFocusIdx].classList.add('focused');
|
||
items[dropdownFocusIdx].scrollIntoView({block:'nearest'});
|
||
}
|
||
}
|
||
|
||
// Debounced input handler
|
||
let searchTimer;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
const q = searchInput.value;
|
||
if (!q.trim()) { closeDropdown(); return; }
|
||
searchTimer = setTimeout(() => runSearch(q), 180);
|
||
});
|
||
|
||
searchInput.addEventListener('focus', () => {
|
||
if (searchInput.value.trim()) runSearch(searchInput.value);
|
||
});
|
||
|
||
// Close when clicking outside
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.search-wrap')) closeDropdown();
|
||
});
|
||
|
||
searchClear.addEventListener('click', () => {
|
||
searchInput.value = ''; closeDropdown(); searchInput.focus();
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// PATCH refreshPoints / refreshRoads / refreshKavlings
|
||
// to also update the search cache
|
||
// ════════════════════════════════════════════════════════════
|
||
const _origRefreshPoints = refreshPoints;
|
||
const _origRefreshRoads = refreshRoads;
|
||
const _origRefreshKavlings = refreshKavlings;
|
||
|
||
refreshPoints = async function() {
|
||
try {
|
||
const recs = await api.get(API_POINT);
|
||
updateSearchCache('points', recs);
|
||
// (panel render inline below to avoid double-fetch)
|
||
const list = document.getElementById('records-list');
|
||
document.getElementById('pt-count').textContent = `${recs.length} titik`;
|
||
document.getElementById('pt-count-text').textContent = recs.length ? `${recs.length} lokasi tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('📍','Klik peta untuk menambah titik'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const open=r.is24h==1; const c=open?'#56d364':'#ff7b72'; const bg=open?'rgba(86,211,100,.1)':'rgba(255,123,114,.1)';
|
||
return `<div class="record-card" data-layer="point" onclick="focusPoint('${r.id}',${r.lat},${r.lng})">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removePoint('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${escHtml(r.phone||'—')}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${bg};color:${c}">${open?'🟢 24 Jam':'🔴 Non-24h'}</span>
|
||
<span style="font-size:10px;color:var(--muted);font-family:Space Mono,monospace">${parseFloat(r.lat).toFixed(4)},${parseFloat(r.lng).toFixed(4)}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
} catch(e) { document.getElementById('pt-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
};
|
||
|
||
refreshRoads = async function() {
|
||
try {
|
||
const recs = await api.get(API_ROAD);
|
||
updateSearchCache('roads', recs);
|
||
const list = document.getElementById('roads-list');
|
||
document.getElementById('rd-count').textContent = `${recs.length} jalan`;
|
||
document.getElementById('rd-count-text').textContent = recs.length ? `${recs.length} jalan tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('〰️','Gambar jalan dengan tombol "Jalan" di atas'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const c=ROAD_COLOR[r.status]||'#888';
|
||
return `<div class="record-card" data-layer="polyline" onclick="focusRoad('${r.id}')">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removeRoad('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${formatLength(r.length_m)}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${c}22;color:${c};border:1px solid ${c}44">${ROAD_LABEL[r.status]||r.status}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
} catch(e) { document.getElementById('rd-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
};
|
||
|
||
refreshKavlings = async function() {
|
||
try {
|
||
const recs = await api.get(API_KAVLING);
|
||
updateSearchCache('kavlings', recs);
|
||
const list = document.getElementById('kavling-list');
|
||
document.getElementById('kv-count').textContent = `${recs.length} kavling`;
|
||
document.getElementById('kv-count-text').textContent = recs.length ? `${recs.length} kavling tersimpan` : 'Belum ada data';
|
||
if(!recs.length){ list.innerHTML=emptyState('⬡','Gambar kavling dengan tombol "Kavling" di atas'); return; }
|
||
list.innerHTML = recs.map(r=>{
|
||
const c=KV_COLOR[r.status]||'#888';
|
||
return `<div class="record-card" data-layer="polygon" onclick="focusKavling('${r.id}')">
|
||
<button class="rc-delete" onclick="event.stopPropagation();removeKavling('${r.id}')">✕</button>
|
||
<div class="rc-name">${escHtml(r.name)}</div>
|
||
<div class="rc-sub">${formatArea(r.area_m2)}</div>
|
||
<div class="rc-meta">
|
||
<span class="rc-type" style="background:${c}22;color:${c};border:1px solid ${c}44">${escHtml(r.status)}</span>
|
||
</div></div>`;
|
||
}).join('');
|
||
} catch(e) { document.getElementById('kv-count-text').textContent='⚠ Hubungkan ke server PHP'; }
|
||
};
|
||
function escHtml(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function shake(el){ el.style.borderColor='var(--danger)'; el.style.boxShadow='0 0 0 3px rgba(248,81,73,.2)'; setTimeout(()=>{el.style.borderColor='';el.style.boxShadow='';},1000); }
|
||
function showToast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2500); }
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// INIT
|
||
// ════════════════════════════════════════════════════════════
|
||
(async()=>{
|
||
try {
|
||
const [points,roads,kavlings] = await Promise.all([
|
||
api.get(API_POINT).catch(()=>[]),
|
||
api.get(API_ROAD).catch(()=>[]),
|
||
api.get(API_KAVLING).catch(()=>[]),
|
||
]);
|
||
// Populate map layers
|
||
points.forEach(placeMarker);
|
||
roads.forEach(placeRoad);
|
||
kavlings.forEach(placeKavling);
|
||
// Populate search cache
|
||
updateSearchCache('points', points);
|
||
updateSearchCache('roads', roads);
|
||
updateSearchCache('kavlings', kavlings);
|
||
// Render side panels (use cached data directly — no extra fetch)
|
||
refreshPoints();
|
||
refreshRoads();
|
||
refreshKavlings();
|
||
} catch(e) {
|
||
document.getElementById('pt-count-text').textContent='⚠ Hubungkan ke server PHP';
|
||
}
|
||
setMode('point');
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |