Files
superbia-17 2b42e822eb first commit
2026-06-06 23:34:20 +07:00

1233 lines
66 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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>