Files
2026-06-11 23:15:42 +07:00

1379 lines
60 KiB
PHP

<?php
require_once __DIR__ . '/koneksi.php';
$is_admin = is_admin();
$admin_username = $is_admin ? ($_SESSION['admin_username'] ?? 'Admin') : '';
$admin_init = $is_admin ? mb_strtoupper(mb_substr($admin_username, 0, 1, 'UTF-8'), 'UTF-8') : '';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Pontianak — Visualisasi Choropleth</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.9.2/proj4.min.js"></script>
<style>
/* ── Reset & Tokens ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--sb-w: 260px;
--sb-bg: #201515; /* ink */
--sb-text: #939084; /* body-mid */
--sb-text-h: #fffefb; /* canvas */
--sb-sect: #c5c0b1; /* mute */
--hd-bg: #fffefb; /* canvas */
--hd-border: #e6e1d5; /* warm border */
--body-bg: #f8f4f0; /* canvas-soft */
--card-bg: #fffefb; /* canvas */
--card-border: #e6e1d5; /* warm border */
--text-1: #201515; /* ink */
--text-2: #2f2a26; /* ink-soft */
--text-m: #605d52; /* body */
--accent: #ff4f00; /* primary (Zapier Orange) */
--accent-h: #e04600; /* accent hover */
--accent-dim: rgba(255, 79, 0, 0.08);
--danger: #ef4444;
--danger-dim: rgba(239, 68, 68, 0.08);
--danger-bdr: rgba(239, 68, 68, 0.25);
--radius: 12px; /* rounded.md */
--radius-sm: 6px; /* rounded.sm */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--shadow: 0 6px 20px rgba(32, 21, 21, 0.08);
}
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text-1); overflow: hidden; }
/* ── App Shell ──────────────────────────────────────────────── */
.app-wrap { display: flex; height: 100vh; overflow: hidden; }
/* ── Sidebar ────────────────────────────────────────────────── */
.app-sidebar {
width: var(--sb-w); min-width: var(--sb-w);
background: var(--sb-bg);
display: flex; flex-direction: column;
height: 100vh; overflow: hidden;
transition: width .3s cubic-bezier(.4,0,.2,1), min-width .3s;
flex-shrink: 0; z-index: 50;
}
.app-sidebar.collapsed { width: 60px; min-width: 60px; }
.app-sidebar.collapsed .sb-hide { display: none; }
.app-sidebar.collapsed .sb-toggle-icon { transform: rotate(180deg); }
.app-sidebar.collapsed .sb-logo-text { display: none; }
.sb-logo {
display: flex; align-items: center; gap: 10px;
padding: 18px 16px 14px;
border-bottom: 1px solid rgba(255,255,255,.06);
flex-shrink: 0;
}
.sb-logo-icon {
width: 36px; height: 36px; border-radius: 9px; background: var(--accent);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.sb-logo-icon .lucide { width: 18px; height: 18px; color: #fff; }
.sb-logo-name { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; letter-spacing: -.3px; }
.sb-logo-sub { font-size: 10px; color: var(--sb-text); white-space: nowrap; }
.sb-body { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 0; }
.sb-body::-webkit-scrollbar { width: 4px; }
.sb-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
.sb-sect-label {
font-size: 10px; font-weight: 600; color: var(--sb-sect);
text-transform: uppercase; letter-spacing: .8px;
padding: 10px 16px 4px; white-space: nowrap;
}
/* ── Layer list in sidebar ──────────────────────────────────── */
.layer-list { padding: 4px 8px; }
.layer-empty { font-size: 12px; color: var(--sb-text); padding: 12px 8px; line-height: 1.6; }
.layer-item {
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07);
border-radius: 9px; padding: 10px 10px 8px; margin-bottom: 6px;
transition: background .15s;
}
.layer-item:hover { background: rgba(255,255,255,.07); }
.layer-item.is-hidden { opacity: .5; }
.layer-item-row { display: flex; align-items: center; gap: 8px; }
.layer-eye {
width: 28px; height: 28px; border-radius: 6px;
background: rgba(255,255,255,.08); border: none; cursor: pointer;
color: var(--sb-text); display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: background .15s, color .15s;
}
.layer-eye:hover { background: rgba(255,255,255,.15); color: #fff; }
.layer-eye .lucide { width: 14px; height: 14px; }
.layer-info { flex: 1; min-width: 0; }
.layer-name { font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.layer-attr { font-size: 10px; color: var(--sb-text); margin-top: 1px; }
.layer-del {
width: 26px; height: 26px; border-radius: 6px;
background: transparent; border: none; cursor: pointer;
color: var(--sb-text); display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: background .15s, color .15s;
}
.layer-del:hover { background: rgba(239,68,68,.15); color: #ef4444; }
.layer-del .lucide { width: 13px; height: 13px; }
.layer-pal { display: flex; gap: 3px; margin-top: 8px; padding-left: 36px; }
.pal-dot { width: 14px; height: 8px; border-radius: 2px; }
/* ── Sidebar footer / upload btn ────────────────────────────── */
.sb-footer { padding: 10px 8px; border-top: 1px solid rgba(255,255,255,.06); flex-shrink: 0; }
.btn-upload-sb {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 9px 12px; border-radius: 8px;
background: var(--accent); border: none; cursor: pointer;
color: #fff; font-family: var(--font); font-size: 12px; font-weight: 600;
transition: background .15s;
}
.btn-upload-sb:hover { background: var(--accent-h); }
.btn-upload-sb .lucide { width: 14px; height: 14px; flex-shrink: 0; }
.btn-upload-sb .sb-hide { white-space: nowrap; }
.sb-toggle-btn {
display: flex; align-items: center; gap: 0;
padding: 0 10px; height: 36px; width: 100%;
background: transparent; border: none; border-radius: 8px; cursor: pointer;
color: var(--sb-text); font-size: 12px; font-weight: 500; font-family: var(--font);
transition: background .15s, color .15s; white-space: nowrap;
}
.sb-toggle-btn:hover { background: rgba(255,255,255,.06); color: var(--sb-text-h); }
.sb-toggle-icon { width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: transform .3s; }
.sb-toggle-icon .lucide { width: 15px; height: 15px; }
/* ── Main area ──────────────────────────────────────────────── */
.app-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
/* ── Header ─────────────────────────────────────────────────── */
.app-header {
height: 56px; background: var(--hd-bg); border-bottom: 1px solid var(--hd-border);
display: flex; align-items: center; padding: 0 20px; gap: 14px; flex-shrink: 0; z-index: 40;
}
.hd-hamburger {
width: 34px; height: 34px; background: transparent; border: none; cursor: pointer;
border-radius: 7px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
transition: background .15s; flex-shrink: 0;
}
.hd-hamburger:hover { background: #ede8e2; }
.hd-hamburger span { display: block; width: 17px; height: 2px; background: var(--text-2); border-radius: 2px; }
.hd-title-wrap { flex: 1; min-width: 0; }
.hd-title { font-size: 16px; font-weight: 600; color: var(--text-1); letter-spacing: -.3px; line-height: 1.2; }
.hd-subtitle { font-size: 11px; color: var(--text-m); margin-top: 1px; }
.hd-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.hd-status { display: flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 500; color: #16a34a; }
.hd-status-dot { width: 6px; height: 6px; border-radius: 50%; background: #16a34a; animation: pulse-dot 2s ease-in-out infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1;} 50%{opacity:.4;} }
.hd-user {
display: flex; align-items: center; gap: 9px; padding: 5px 10px;
border-radius: 9px; border: 1px solid var(--card-border);
}
.hd-avatar {
width: 30px; height: 30px; border-radius: 50%;
background: #ede8e2; border: 1px solid #ddd8d2;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: var(--text-1); flex-shrink: 0;
}
.hd-user-name { font-size: 12px; font-weight: 600; color: var(--text-1); }
.hd-user-role { font-size: 10px; color: var(--text-m); }
.hd-logout {
background: transparent; border: none; color: var(--text-m); cursor: pointer;
padding: 3px; border-radius: 5px; transition: color .15s, background .15s;
display: flex; align-items: center;
}
.hd-logout:hover { color: var(--danger); background: #fef2f2; }
.hd-logout .lucide { width: 14px; height: 14px; }
.btn-login-link {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px;
background: var(--accent); color: #fff; text-decoration: none;
font-size: 12px; font-weight: 600; transition: background .15s;
}
.btn-login-link:hover { background: var(--accent-h); }
.btn-login-link .lucide { width: 14px; height: 14px; }
/* ── Map ────────────────────────────────────────────────────── */
.map-container { flex: 1; position: relative; overflow: hidden; }
#map { width: 100%; height: 100%; }
.leaflet-pane.is-hit-disabled,
.leaflet-pane.is-hit-disabled * {
pointer-events: none !important;
}
/* ── Legend overlay ─────────────────────────────────────────── */
.legend-panel {
position: absolute; bottom: 24px; left: 16px;
z-index: 1000; display: none;
display: flex; flex-direction: column; gap: 10px;
max-height: calc(100% - 48px); overflow-y: auto;
pointer-events: none;
}
.leg-block {
background: rgba(250,250,249,.94); border: 1px solid var(--card-border);
border-radius: 10px; padding: 12px 14px;
box-shadow: 0 2px 8px rgba(32,21,21,.1);
pointer-events: auto; min-width: 160px;
}
.leg-title { font-size: 11px; font-weight: 600; color: var(--text-1); margin-bottom: 2px; }
.leg-attr { font-size: 10px; color: var(--text-m); margin-bottom: 8px; }
.leg-row { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
.leg-swatch { width: 20px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.leg-range { font-size: 10px; color: var(--text-2); }
/* ── Choropleth popup ────────────────────────────────────────── */
.choro-popup-wrap .leaflet-popup-content-wrapper {
border-radius: 10px; padding: 0; overflow: hidden;
box-shadow: 0 4px 16px rgba(32,21,21,.15);
border: 1px solid var(--card-border);
}
.choro-popup-wrap .leaflet-popup-content { margin: 0; }
.choro-popup { max-height: 260px; overflow-y: auto; font-family: var(--font); }
.choro-popup table { width: 100%; border-collapse: collapse; }
.choro-popup tr:nth-child(odd) { background: #f5f0eb; }
.choro-popup tr:nth-child(even) { background: #fafaf9; }
.choro-popup .pp-k { padding: 5px 10px 5px 12px; font-size: 11px; font-weight: 600; color: var(--text-m); white-space: nowrap; }
.choro-popup .pp-v { padding: 5px 12px 5px 8px; font-size: 11px; color: var(--text-1); }
/* ── Toast ───────────────────────────────────────────────────── */
.toast {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
padding: 11px 18px; border-radius: 9px;
background: var(--text-1); color: #fff;
font-size: 13px; font-weight: 500;
box-shadow: 0 4px 14px rgba(32,21,21,.2);
transform: translateY(12px); opacity: 0;
transition: opacity .25s, transform .25s;
pointer-events: none; max-width: 320px; line-height: 1.4;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #166534; }
.toast.error { background: #991b1b; }
/* ── Modals ──────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 8000;
background: rgba(28,22,18,.55); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal-box {
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); width: min(480px, 100%);
box-shadow: var(--shadow); overflow: hidden;
}
.modal-box.modal-sm { width: min(360px, 100%); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px; border-bottom: 1px solid var(--card-border);
}
.modal-title { font-size: 15px; font-weight: 600; color: var(--text-1); }
.modal-close {
width: 30px; height: 30px; border-radius: 7px;
background: transparent; border: none; cursor: pointer;
color: var(--text-m); display: flex; align-items: center; justify-content: center;
transition: background .15s, color .15s;
}
.modal-close:hover { background: #ede8e2; color: var(--text-1); }
.modal-close .lucide { width: 16px; height: 16px; }
.modal-body { padding: 20px; max-height: 65vh; overflow-y: auto; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; }
.form-group input[type="text"],
.form-group input[type="file"],
.form-group select,
.form-group textarea {
width: 100%; padding: 10px 13px;
background: var(--body-bg); border: 1.5px solid var(--card-border); border-radius: var(--radius-sm);
color: var(--text-1); font-family: var(--font); font-size: 13px; outline: none;
transition: border-color .2s, background .2s, box-shadow .2s;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--text-1); background: var(--card-bg); box-shadow: 0 0 0 3px var(--accent-dim);
}
.form-group textarea { resize: vertical; min-height: 64px; }
.form-group input[type="file"] { padding: 8px 13px; cursor: pointer; }
.attr-hint { font-size: 11px; color: var(--text-m); margin-top: 5px; font-style: italic; }
.form-status { min-height: 18px; font-size: 12px; margin-top: 8px; }
.form-status.error { color: var(--danger); }
.form-status.info { color: var(--accent); }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 14px 20px; border-top: 1px solid var(--card-border);
background: #f5f0eb;
}
.btn-cancel {
padding: 9px 18px; border-radius: 8px;
background: transparent; border: 1.5px solid var(--card-border);
color: var(--text-2); font-family: var(--font); font-size: 13px; font-weight: 600;
cursor: pointer; transition: background .15s, border-color .15s;
}
.btn-cancel:hover { background: #ede8e2; border-color: #c8c3bd; }
.btn-primary {
padding: 9px 20px; border-radius: 8px;
background: var(--accent); border: none;
color: #fff; font-family: var(--font); font-size: 13px; font-weight: 600;
cursor: pointer; transition: background .15s; display: flex; align-items: center; gap: 7px;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-h); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
.btn-danger {
padding: 9px 20px; border-radius: 8px;
background: var(--danger); border: none;
color: #fff; font-family: var(--font); font-size: 13px; font-weight: 600;
cursor: pointer; transition: background .15s;
}
.btn-danger:hover { background: #dc2626; }
.btn-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.confirm-msg { font-size: 14px; color: var(--text-1); line-height: 1.6; margin-bottom: 4px; }
.confirm-sub { font-size: 12px; color: var(--text-m); margin-top: 6px; }
.pal-preview-row { display: flex; align-items: center; gap: 3px; }
.pal-dot-lg { width: 22px; height: 14px; border-radius: 3px; }
/* ── Palette preview in select ────────────────────────────── */
.pal-select-wrap { display: flex; flex-direction: column; gap: 6px; }
.pal-opt {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
border-radius: 8px; border: 1.5px solid var(--card-border); cursor: pointer;
transition: border-color .15s, background .15s;
}
.pal-opt.selected { border-color: var(--accent); background: var(--accent-dim); }
.pal-opt-dots { display: flex; gap: 2px; }
.pal-opt-dot { width: 16px; height: 10px; border-radius: 2px; }
.pal-opt-name { font-size: 12px; color: var(--text-2); font-weight: 500; }
/* Lucide default sizing */
.lucide { width: 16px; height: 16px; stroke-width: 2; display: inline-block; vertical-align: middle; }
/* ── Drawing toolbar ────────────────────────────────────────── */
.ft-toolbar {
position: absolute; top: 12px; left: 12px; z-index: 1000;
display: flex; gap: 4px;
background: rgba(250,250,249,.96); border: 1px solid var(--card-border);
border-radius: 10px; padding: 5px;
box-shadow: 0 2px 10px rgba(32,21,21,.1);
}
.ft-btn {
display: flex; align-items: center; gap: 6px; padding: 7px 13px;
border-radius: 7px; background: transparent; border: none; cursor: pointer;
font-family: var(--font); font-size: 12px; font-weight: 600; color: var(--text-2);
transition: background .15s, color .15s; white-space: nowrap;
}
.ft-btn:hover { background: #ede8e2; }
.ft-btn.active-point { background: rgba(46,160,67,.12); color: #2ea043; }
.ft-btn.active-jalan { background: rgba(56,139,253,.12); color: #388bfd; }
.ft-btn.active-parsil { background: rgba(234,179,8,.15); color: #a87c00; }
.ft-btn .lucide { width: 14px; height: 14px; }
/* ── Draw hint ───────────────────────────────────────────────── */
.draw-hint {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
z-index: 1000; background: rgba(28,22,18,.88); color: #fff;
border-radius: 9px; padding: 9px 18px; font-size: 12px; font-weight: 500;
display: none; align-items: center; gap: 10px; white-space: nowrap; backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0,0,0,.25);
}
.draw-hint.visible { display: flex; }
.draw-hint kbd {
background: rgba(255,255,255,.2); border-radius: 4px;
padding: 1px 6px; font-size: 11px; font-family: var(--font);
}
.draw-hint-actions { display: flex; align-items: center; gap: 6px; }
.draw-hint-btn {
border: 1px solid rgba(255,255,255,.25);
background: rgba(255,255,255,.12);
color: #fff;
border-radius: 6px;
padding: 5px 9px;
font-family: var(--font);
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.draw-hint-btn:hover { background: rgba(255,255,255,.2); }
.draw-hint-btn.primary { background: var(--accent); border-color: var(--accent); }
.draw-hint-btn.primary:hover { background: var(--accent-h); border-color: var(--accent-h); }
/* ── Jalan / Parsil inline legends ──────────────────────────── */
.side-legend {
position: absolute; right: 12px; bottom: 44px; z-index: 1000;
background: rgba(250,250,249,.96); border: 1px solid var(--card-border);
border-radius: 10px; padding: 12px 14px; min-width: 150px;
box-shadow: 0 2px 8px rgba(32,21,21,.1); display: none;
}
.side-legend.visible { display: block; }
/* ── Classic data rows ───────────────────────────────────────── */
.classic-section { padding: 0 8px 8px; }
.classic-row {
display: flex; align-items: center; gap: 8px;
padding: 7px 8px; border-radius: 7px; margin-bottom: 3px;
transition: background .15s;
}
.classic-row:hover { background: rgba(255,255,255,.05); }
.classic-count { font-size: 10px; color: var(--sb-text); margin-top: 1px; }
/* ── Leaflet Popup Customizations ───────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--card-bg) !important;
color: var(--text-1) !important;
border: 1px solid var(--card-border);
border-radius: var(--radius) !important;
box-shadow: var(--shadow) !important;
overflow: hidden;
}
.leaflet-popup-content {
margin: 0 !important;
font-family: var(--font) !important;
}
.leaflet-popup-tip {
background: var(--card-bg) !important;
border: 1px solid var(--card-border);
box-shadow: none !important;
}
/* ── Form Popup ─────────────────────────────────────────────── */
.form-popup {
padding: 16px 20px;
background: var(--card-bg);
}
.form-popup-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--card-border);
}
.form-popup-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.form-popup-icon.blue {
background: rgba(56, 139, 253, 0.12);
color: #388bfd;
}
.form-popup-icon.amber {
background: rgba(234, 179, 8, 0.15);
color: #a87c00;
}
.form-popup-title {
font-size: 14px;
font-weight: 700;
color: var(--text-1);
letter-spacing: -0.3px;
}
.form-popup-coords {
font-size: 10px;
color: var(--text-m);
font-family: var(--font);
margin-top: 2px;
}
.input-readonly {
width: 100%;
padding: 10px 13px;
background: var(--body-bg);
border: 1.5px solid var(--card-border);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-m);
font-weight: 500;
}
.btn-save {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--sb-text-h);
border: none;
border-radius: var(--radius);
font-family: var(--font);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-save:hover:not(:disabled) {
background: var(--accent-h);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Info Popup ─────────────────────────────────────────────── */
.info-popup {
padding: 16px 20px;
}
.info-popup-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.info-popup-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.info-popup-name {
font-size: 14px;
font-weight: 700;
color: var(--text-1);
letter-spacing: -0.3px;
line-height: 1.3;
}
.info-popup-id {
font-size: 10px;
color: var(--text-m);
font-family: var(--font);
margin-top: 2px;
}
.info-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--card-border);
font-size: 13px;
}
.info-row-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--body-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.info-row-label {
font-size: 10px;
color: var(--text-m);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.info-row-value {
font-weight: 500;
color: var(--text-1);
}
.status-badge-inline {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
}
.btn-hapus {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
margin-top: 12px;
padding: 9px;
background: transparent;
color: var(--danger);
border: 1.5px solid var(--danger-bdr);
border-radius: var(--radius);
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn-hapus:hover {
background: var(--danger-dim);
border-color: var(--danger);
}
</style>
</head>
<body>
<div class="app-wrap">
<!-- ═══ SIDEBAR ═══ -->
<aside class="app-sidebar" id="appSidebar">
<div class="sb-logo">
<div class="sb-logo-icon"><i data-lucide="map"></i></div>
<div class="sb-logo-text">
<div class="sb-logo-name">WebGIS Pontianak</div>
<div class="sb-logo-sub">Visualisasi Wilayah</div>
</div>
</div>
<div class="sb-body">
<div class="sb-sect-label sb-hide">Layer Choropleth</div>
<div class="layer-list" id="layerList">
<div class="layer-empty">Memuat layer...</div>
</div>
<div class="sb-sect-label sb-hide" style="margin-top:6px;">Data Klasik</div>
<div class="classic-section">
<div class="classic-row">
<button class="layer-eye" id="eyeWrapPoint" onclick="toggleClassic('point')" title="Toggle Lokasi Usaha">
<i data-lucide="eye" id="eyeClassicPoint"></i>
</button>
<div class="layer-info">
<div class="layer-name">📍 Lokasi Usaha</div>
<div class="classic-count" id="countPoint">Memuat...</div>
</div>
</div>
<div class="classic-row">
<button class="layer-eye" id="eyeWrapJalan" onclick="toggleClassic('jalan')" title="Toggle Data Jalan">
<i data-lucide="eye" id="eyeClassicJalan"></i>
</button>
<div class="layer-info">
<div class="layer-name">🛣️ Data Jalan</div>
<div class="classic-count" id="countJalan">Memuat...</div>
</div>
</div>
<div class="classic-row">
<button class="layer-eye" id="eyeWrapParsil" onclick="toggleClassic('parsil')" title="Toggle Parsil Tanah">
<i data-lucide="eye" id="eyeClassicParsil"></i>
</button>
<div class="layer-info">
<div class="layer-name">🏘️ Parsil Tanah</div>
<div class="classic-count" id="countParsil">Memuat...</div>
</div>
</div>
</div>
</div>
<div class="sb-footer">
<?php if ($is_admin): ?>
<button class="btn-upload-sb" onclick="openUploadModal()" style="margin-bottom:6px;">
<i data-lucide="upload-cloud"></i>
<span class="sb-hide">Upload Layer</span>
</button>
<?php endif; ?>
<button class="sb-toggle-btn" onclick="toggleSidebar()">
<span class="sb-toggle-icon"><i data-lucide="chevron-left"></i></span>
<span class="sb-hide">Sembunyikan</span>
</button>
</div>
</aside><!-- .app-sidebar -->
<!-- ═══ MAIN ═══ -->
<div class="app-main">
<header class="app-header">
<button class="hd-hamburger" onclick="toggleSidebar()" aria-label="Toggle sidebar">
<span></span><span></span><span></span>
</button>
<div class="hd-title-wrap">
<div class="hd-title">WebGIS Pontianak</div>
<div class="hd-subtitle">Choropleth — Data Spasial Wilayah</div>
</div>
<div class="hd-right">
<div class="hd-status"><div class="hd-status-dot"></div>Online</div>
<?php if ($is_admin): ?>
<div class="hd-user">
<div class="hd-avatar"><?= htmlspecialchars($admin_init) ?></div>
<div>
<div class="hd-user-name"><?= htmlspecialchars($admin_username) ?></div>
<div class="hd-user-role">Administrator</div>
</div>
<a href="logout.php" class="hd-logout" title="Keluar"><i data-lucide="log-out"></i></a>
</div>
<?php else: ?>
<a href="login.php" class="btn-login-link">
<i data-lucide="log-in"></i> Masuk sebagai Admin
</a>
<?php endif; ?>
</div>
</header>
<div class="map-container">
<div id="map"></div>
<div class="legend-panel" id="legendContainer" style="display:none;"></div>
<?php if ($is_admin): ?>
<div class="ft-toolbar" id="ftToolbar">
<button class="ft-btn" id="btnToolPoint" onclick="activateTool('point')" title="Tambah Lokasi Usaha">
<i data-lucide="map-pin"></i> Lokasi
</button>
<button class="ft-btn" id="btnToolJalan" onclick="activateTool('jalan')" title="Gambar Segmen Jalan">
<i data-lucide="route"></i> Jalan
</button>
<button class="ft-btn" id="btnToolParsil" onclick="activateTool('parsil')" title="Gambar Persil Tanah">
<i data-lucide="pentagon"></i> Parsil
</button>
</div>
<?php endif; ?>
<div class="draw-hint" id="drawHint">
<span>
✏️ Klik titik-titik di peta &nbsp;·&nbsp;
<kbd>Enter</kbd> / 2&times;klik selesai &nbsp;·&nbsp;
<kbd>Esc</kbd> batal
</span>
<span class="draw-hint-actions">
<button type="button" class="draw-hint-btn primary" onclick="finishActiveDrawing()">Selesai</button>
<button type="button" class="draw-hint-btn" onclick="cancelActiveDrawing()">Batal</button>
</span>
</div>
<!-- Legends for classic vector layers -->
<div class="side-legend" id="legendJalan">
<div class="leg-title">Status Jalan</div>
<div class="leg-row"><span class="leg-swatch" style="background:#ef4444"></span><span class="leg-range">Nasional</span></div>
<div class="leg-row"><span class="leg-swatch" style="background:#f97316"></span><span class="leg-range">Provinsi</span></div>
<div class="leg-row"><span class="leg-swatch" style="background:#eab308"></span><span class="leg-range">Kabupaten</span></div>
</div>
<div class="side-legend" id="legendParsil">
<div class="leg-title">Status Kepemilikan</div>
<div class="leg-row"><span class="leg-swatch" style="background:#3b82f6"></span><span class="leg-range">SHM</span></div>
<div class="leg-row"><span class="leg-swatch" style="background:#8b5cf6"></span><span class="leg-range">HGB</span></div>
<div class="leg-row"><span class="leg-swatch" style="background:#ec4899"></span><span class="leg-range">HGU</span></div>
<div class="leg-row"><span class="leg-swatch" style="background:#14b8a6"></span><span class="leg-range">HP</span></div>
</div>
</div>
</div><!-- .app-main -->
</div><!-- .app-wrap -->
<!-- ═══ UPLOAD MODAL (admin only) ═══ -->
<?php if ($is_admin): ?>
<div id="uploadModal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Upload Layer GeoJSON</span>
<button class="modal-close" onclick="closeUploadModal()"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Nama Layer *</label>
<input type="text" id="uNama" placeholder="cth. Kepadatan Penduduk Kecamatan" maxlength="150">
</div>
<div class="form-group">
<label>Deskripsi</label>
<textarea id="uDesc" placeholder="Keterangan singkat tentang layer ini (opsional)"></textarea>
</div>
<div class="form-group">
<label>File GeoJSON * <span style="font-weight:400;color:var(--text-m);">(.json / .geojson dari ArcGIS)</span></label>
<input type="file" id="uFile" accept=".json,.geojson,application/json">
<div class="attr-hint" id="fileHint">Pilih file untuk melihat atribut yang tersedia.</div>
</div>
<div class="form-group" id="attrGroup" style="display:none;">
<label>Atribut untuk Choropleth * <span style="font-weight:400;color:var(--text-m);">(kolom yang akan menentukan warna)</span></label>
<select id="uAttr"></select>
</div>
<div class="form-group">
<label>Palette Warna</label>
<div class="pal-select-wrap" id="palSelect">
<div class="pal-opt selected" data-pal="blue" onclick="selectPal(this,'blue')">
<div class="pal-opt-dots">
<div class="pal-opt-dot" style="background:#eff3ff;"></div>
<div class="pal-opt-dot" style="background:#bdd7e7;"></div>
<div class="pal-opt-dot" style="background:#6baed6;"></div>
<div class="pal-opt-dot" style="background:#2171b5;"></div>
<div class="pal-opt-dot" style="background:#084594;"></div>
</div>
<span class="pal-opt-name">Biru</span>
</div>
<div class="pal-opt" data-pal="orange" onclick="selectPal(this,'orange')">
<div class="pal-opt-dots">
<div class="pal-opt-dot" style="background:#feedde;"></div>
<div class="pal-opt-dot" style="background:#fdbe85;"></div>
<div class="pal-opt-dot" style="background:#fd8d3c;"></div>
<div class="pal-opt-dot" style="background:#e6550d;"></div>
<div class="pal-opt-dot" style="background:#a63603;"></div>
</div>
<span class="pal-opt-name">Oranye</span>
</div>
<div class="pal-opt" data-pal="green" onclick="selectPal(this,'green')">
<div class="pal-opt-dots">
<div class="pal-opt-dot" style="background:#edf8e9;"></div>
<div class="pal-opt-dot" style="background:#bae4b3;"></div>
<div class="pal-opt-dot" style="background:#74c476;"></div>
<div class="pal-opt-dot" style="background:#31a354;"></div>
<div class="pal-opt-dot" style="background:#006d2c;"></div>
</div>
<span class="pal-opt-name">Hijau</span>
</div>
<div class="pal-opt" data-pal="purple" onclick="selectPal(this,'purple')">
<div class="pal-opt-dots">
<div class="pal-opt-dot" style="background:#f2f0f7;"></div>
<div class="pal-opt-dot" style="background:#cbc9e2;"></div>
<div class="pal-opt-dot" style="background:#9e9ac8;"></div>
<div class="pal-opt-dot" style="background:#756bb1;"></div>
<div class="pal-opt-dot" style="background:#54278f;"></div>
</div>
<span class="pal-opt-name">Ungu</span>
</div>
</div>
</div>
<div class="form-status" id="uploadStatus"></div>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeUploadModal()">Batal</button>
<button class="btn-primary" id="btnUpload" onclick="submitUpload()">
<i data-lucide="upload-cloud"></i> Upload Layer
</button>
</div>
</div>
</div>
<?php endif; ?>
<!-- ═══ CONFIRM MODAL ═══ -->
<div id="confirmModal" class="modal-overlay" style="display:none;">
<div class="modal-box modal-sm">
<div class="modal-header">
<span class="modal-title">Konfirmasi</span>
</div>
<div class="modal-body">
<p class="confirm-msg" id="confirmMsg"></p>
<p class="confirm-sub">Tindakan ini tidak dapat dibatalkan.</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="resolveConfirm(false)">Batal</button>
<button class="btn-danger" onclick="resolveConfirm(true)">Ya, Hapus</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast" role="alert" aria-live="polite"></div>
<script>
// ── Globals ──────────────────────────────────────────────────
window._IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
window._CSRF_TOKEN = <?= json_encode($is_admin ? csrf_token() : '') ?>;
function escapeHTML(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── Toast ────────────────────────────────────────────────────
let _toastTimer;
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (type ? ' ' + type : '');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => { el.className = 'toast'; }, 3200);
}
// ── Confirm dialog ───────────────────────────────────────────
let _confirmResolve = null;
function showDeleteConfirm(msg) {
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmMsg').textContent = msg;
document.getElementById('confirmModal').style.display = 'flex';
});
}
function resolveConfirm(val) {
document.getElementById('confirmModal').style.display = 'none';
if (_confirmResolve) { _confirmResolve(val); _confirmResolve = null; }
}
document.getElementById('confirmModal').addEventListener('click', function(e) {
if (e.target === this) resolveConfirm(false);
});
// ── Sidebar toggle ───────────────────────────────────────────
function toggleSidebar() {
document.getElementById('appSidebar').classList.toggle('collapsed');
if (typeof map !== 'undefined') setTimeout(() => map.invalidateSize(), 310);
}
// ── Upload modal ─────────────────────────────────────────────
<?php if ($is_admin): ?>
let _selectedPal = 'blue';
function selectPal(el, pal) {
_selectedPal = pal;
document.querySelectorAll('.pal-opt').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
}
function openUploadModal() {
document.getElementById('uploadModal').style.display = 'flex';
document.getElementById('uNama').focus();
}
function closeUploadModal() {
document.getElementById('uploadModal').style.display = 'none';
document.getElementById('uNama').value = '';
document.getElementById('uDesc').value = '';
document.getElementById('uFile').value = '';
document.getElementById('attrGroup').style.display = 'none';
document.getElementById('fileHint').textContent = 'Pilih file untuk melihat atribut yang tersedia.';
document.getElementById('uploadStatus').textContent = '';
document.getElementById('uploadStatus').className = 'form-status';
document.getElementById('btnUpload').disabled = false;
_selectedPal = 'blue';
document.querySelectorAll('.pal-opt').forEach(o => o.classList.remove('selected'));
document.querySelector('[data-pal="blue"]').classList.add('selected');
}
document.getElementById('uploadModal').addEventListener('click', function(e) {
if (e.target === this) closeUploadModal();
});
// UTM zone defs for common Indonesia zones
if (typeof proj4 !== 'undefined') {
proj4.defs('EPSG:32647', '+proj=utm +zone=47 +datum=WGS84 +units=m +no_defs'); // Sumatera barat
proj4.defs('EPSG:32648', '+proj=utm +zone=48 +datum=WGS84 +units=m +no_defs'); // Sumatera timur / Kalbar barat
proj4.defs('EPSG:32649', '+proj=utm +zone=49 +datum=WGS84 +units=m +no_defs'); // Kalbar / Kalteng
proj4.defs('EPSG:32650', '+proj=utm +zone=50 +datum=WGS84 +units=m +no_defs'); // Kaltim / Sulawesi barat
proj4.defs('EPSG:32651', '+proj=utm +zone=51 +datum=WGS84 +units=m +no_defs'); // Sulawesi tengah
proj4.defs('EPSG:32752', '+proj=utm +zone=52 +south +datum=WGS84 +units=m +no_defs'); // NTB
proj4.defs('EPSG:32754', '+proj=utm +zone=54 +south +datum=WGS84 +units=m +no_defs'); // Maluku
}
// Guess UTM CRS from a sample coordinate [x, y]
function guessUtmCrs(x, y) {
if (typeof proj4 === 'undefined') return null;
// x is easting (~100k-900k for UTM), y is northing
if (x < 100000 || x > 900000) return null;
// Estimate longitude from easting: CM of each zone = zone*6 - 183
// easting = 500000 + (lng - CM)*scale; approximate: lng ≈ (x-500000)/111320 + CM
const lngApprox = (x - 500000) / 111320 * (180 / Math.PI); // rough
const candidates = [
{ epsg: 'EPSG:32647', cm: 99, south: false },
{ epsg: 'EPSG:32648', cm: 105, south: false },
{ epsg: 'EPSG:32649', cm: 111, south: false },
{ epsg: 'EPSG:32650', cm: 117, south: false },
{ epsg: 'EPSG:32651', cm: 123, south: false },
];
let best = null, bestDist = Infinity;
for (const c of candidates) {
const approxLng = c.cm + lngApprox;
// Try converting and check if result is plausible Indonesia coords
try {
const [lng, lat] = proj4(c.epsg, 'WGS84', [x, y]);
if (lng >= 94 && lng <= 142 && lat >= -12 && lat <= 8) {
const dist = Math.abs(lng - 108); // rough centre of Indonesia
if (dist < bestDist) { bestDist = dist; best = c.epsg; }
}
} catch (_) {}
}
return best;
}
// Reproject all coordinates in a GeoJSON FeatureCollection
function reprojectGeoJSON(gj, fromCRS) {
function rc(c) { return proj4(fromCRS, 'WGS84', c); }
function rr(ring) { return ring.map(rc); }
function rg(geom) {
if (!geom) return geom;
switch (geom.type) {
case 'Point': return { ...geom, coordinates: rc(geom.coordinates) };
case 'MultiPoint':
case 'LineString': return { ...geom, coordinates: geom.coordinates.map(rc) };
case 'MultiLineString':
case 'Polygon': return { ...geom, coordinates: geom.coordinates.map(rr) };
case 'MultiPolygon': return { ...geom, coordinates: geom.coordinates.map(p => p.map(rr)) };
default: return geom;
}
}
return { ...gj, features: gj.features.map(f => ({ ...f, geometry: rg(f.geometry) })) };
}
// Convert ArcGIS / non-standard JSON → standard GeoJSON FeatureCollection
function esriToGeoJSON(data) {
if (data.type === 'FeatureCollection') return data; // already standard
// GeometryCollection: wrap each geometry as a Feature (no attribute data)
if (data.type === 'GeometryCollection') {
return {
type: 'FeatureCollection',
_srcType: 'GeometryCollection',
features: (data.geometries || []).map((geom, i) => ({
type: 'Feature',
properties: { id: i + 1 },
geometry: geom,
})),
};
}
// ESRI JSON (from ArcGIS REST / JSON export)
const firstFeat = Array.isArray(data.features) ? data.features[0] : null;
if (!data.geometryType && !(firstFeat && firstFeat.attributes)) return data; // unknown format
function convertGeom(g) {
if (!g) return null;
if (g.rings) return { type: 'Polygon', coordinates: g.rings };
if (g.paths) return { type: 'MultiLineString', coordinates: g.paths };
if (g.points) return { type: 'MultiPoint', coordinates: g.points };
if (g.x !== undefined) return { type: 'Point', coordinates: [g.x, g.y] };
return null;
}
return {
type: 'FeatureCollection',
_srcType: 'ESRI',
features: (data.features || []).map(f => ({
type: 'Feature',
properties: f.attributes || {},
geometry: convertGeom(f.geometry),
})),
};
}
let _parsedGeoJSON = null;
document.getElementById('uFile').addEventListener('change', function() {
_parsedGeoJSON = null;
const file = this.files[0];
if (!file) return;
const hint = document.getElementById('fileHint');
hint.textContent = 'Membaca file...';
const reader = new FileReader();
reader.onload = function(e) {
try {
const raw = JSON.parse(e.target.result);
const gj = esriToGeoJSON(raw);
if (!gj || gj.type !== 'FeatureCollection' || !gj.features?.length) {
hint.textContent = 'File bukan GeoJSON FeatureCollection yang valid.';
document.getElementById('attrGroup').style.display = 'none';
return;
}
const props = gj.features[0].properties || {};
const keys = Object.keys(props);
if (keys.length === 0) {
hint.textContent = 'Fitur tidak memiliki atribut/properties.';
document.getElementById('attrGroup').style.display = 'none';
return;
}
// Check if coordinates look projected (not lat/lng) and try to reproject
let reprojectNote = '';
try {
const c0 = gj.features[0]?.geometry?.coordinates;
const sample = Array.isArray(c0?.[0]?.[0]) ? c0[0][0] : (Array.isArray(c0?.[0]) ? c0[0] : c0);
if (sample && (Math.abs(sample[0]) > 180 || Math.abs(sample[1]) > 90)) {
const crs = guessUtmCrs(sample[0], sample[1]);
if (crs) {
Object.assign(gj, reprojectGeoJSON(gj, crs));
reprojectNote = ` · Koordinat direprojeksikan dari ${crs} ke WGS84 otomatis.`;
} else {
reprojectNote = ' ⚠️ Koordinat bukan lat/lng dan CRS tidak terdeteksi — posisi di peta mungkin salah.';
}
}
} catch (reprojErr) {
reprojectNote = ' ⚠️ Gagal reproject koordinat: ' + reprojErr.message;
}
if (gj._srcType === 'GeometryCollection' && keys.length <= 1) {
// Only has auto-generated 'id', warn but still allow upload
hint.style.color = '#b45309';
hint.textContent = `⚠️ File tidak memiliki atribut data (hanya geometri). Layer akan tampil sebagai peta wilayah dengan warna nomor urut.${reprojectNote}`;
} else {
hint.style.color = reprojectNote.startsWith(' ⚠️') ? '#b45309' : '';
const srcNote = gj._srcType && gj._srcType !== 'GeometryCollection' ? ` (dikonversi dari ${gj._srcType})` : '';
hint.textContent = `Terdeteksi ${gj.features.length.toLocaleString('id-ID')} fitur · ${keys.length} atribut${srcNote}.${reprojectNote}`;
}
_parsedGeoJSON = gj;
const sel = document.getElementById('uAttr');
sel.innerHTML = keys.map(k => `<option value="${escapeHTML(k)}">${escapeHTML(k)}</option>`).join('');
document.getElementById('attrGroup').style.display = 'block';
} catch {
hint.textContent = 'Gagal membaca file. Pastikan format JSON/GeoJSON valid.';
document.getElementById('attrGroup').style.display = 'none';
}
};
reader.readAsText(file);
});
async function submitUpload() {
const nama = document.getElementById('uNama').value.trim();
const deskripsi= document.getElementById('uDesc').value.trim();
const file = document.getElementById('uFile').files[0];
const attrSel = document.getElementById('uAttr');
const attr_key = attrSel.value.trim();
const statusEl = document.getElementById('uploadStatus');
const btn = document.getElementById('btnUpload');
if (!nama) { statusEl.textContent = 'Nama layer wajib diisi.'; statusEl.className = 'form-status error'; return; }
if (!_parsedGeoJSON) { statusEl.textContent = 'Pilih file GeoJSON terlebih dahulu (atau tunggu file selesai dibaca).'; statusEl.className = 'form-status error'; return; }
if (!attr_key) { statusEl.textContent = 'Pilih atribut untuk choropleth.'; statusEl.className = 'form-status error'; return; }
btn.disabled = true;
statusEl.textContent = 'Mengupload...';
statusEl.className = 'form-status info';
const gjBlob = new Blob([JSON.stringify(_parsedGeoJSON)], { type: 'application/json' });
const fd = new FormData();
fd.append('nama', nama);
fd.append('deskripsi', deskripsi);
fd.append('geojson', gjBlob, file ? file.name : 'layer.geojson');
fd.append('attribute_key', attr_key);
fd.append('palette', _selectedPal);
fd.append('csrf_token', window._CSRF_TOKEN || '');
try {
const res = await fetch('api/choropleth/upload.php', { method: 'POST', body: fd });
const j = await res.json();
if (j.status !== 'success') throw new Error(j.message);
const newMeta = {
id: j.id,
nama: nama,
deskripsi: deskripsi,
attribute_key: attr_key,
palette: _selectedPal,
is_visible: true,
};
closeUploadModal();
showToast(`Layer "${escapeHTML(nama)}" berhasil diupload!`, 'success');
if (typeof window._choro !== 'undefined') window._choro.addLayer(newMeta);
} catch (err) {
statusEl.textContent = err.message || 'Gagal mengupload layer.';
statusEl.className = 'form-status error';
btn.disabled = false;
}
}
<?php endif; ?>
// ── Globals required by old modules (point/jalan/parsil) ────
var currentMode = 0; // 0=view, 1=point, 2=jalan/parsil
var currentSubMode = '';
var activeTool = null;
window._dlFocusFns = {};
function syncModeGlobals() {
window.currentMode = currentMode;
window.currentSubMode = currentSubMode;
window.activeTool = activeTool;
}
syncModeGlobals();
window._resetActiveTool = function() {
activeTool = null; currentMode = 0; currentSubMode = '';
syncModeGlobals();
setDrawBlockingLayerHitTesting(true);
setChoroplethHitTesting(true);
['Point','Jalan','Parsil'].forEach(function(t) {
var btn = document.getElementById('btnTool' + t);
if (btn) btn.className = 'ft-btn';
});
var lJ = document.getElementById('legendJalan');
var lP = document.getElementById('legendParsil');
var dH = document.getElementById('drawHint');
if (lJ) lJ.classList.remove('visible');
if (lP) lP.classList.remove('visible');
if (dH) dH.classList.remove('visible');
var mc = document.getElementById('map');
if (mc) mc.style.cursor = '';
};
function activateTool(tool) {
if (activeTool === tool) tool = null; // toggle off
if (typeof window._jalanStopDraw === 'function') window._jalanStopDraw();
if (typeof window._parsilStopDraw === 'function') window._parsilStopDraw();
activeTool = tool;
['Point','Jalan','Parsil'].forEach(function(t) {
var btn = document.getElementById('btnTool' + t);
if (btn) btn.className = 'ft-btn' + (tool === t.toLowerCase() ? ' active-' + t.toLowerCase() : '');
});
var lJ = document.getElementById('legendJalan');
var lP = document.getElementById('legendParsil');
var dH = document.getElementById('drawHint');
if (lJ) lJ.classList.remove('visible');
if (lP) lP.classList.remove('visible');
if (tool === 'point') {
currentMode = 1; currentSubMode = 'point';
syncModeGlobals();
setDrawBlockingLayerHitTesting(false);
setChoroplethHitTesting(false);
if (dH) dH.classList.add('visible');
} else if (tool === 'jalan') {
currentMode = 2; currentSubMode = 'jalan';
syncModeGlobals();
setDrawBlockingLayerHitTesting(false);
setChoroplethHitTesting(false);
if (lJ) lJ.classList.add('visible');
if (dH) dH.classList.add('visible');
if (typeof window._jalanStartDraw === 'function') window._jalanStartDraw();
} else if (tool === 'parsil') {
currentMode = 2; currentSubMode = 'parsil';
syncModeGlobals();
setDrawBlockingLayerHitTesting(false);
setChoroplethHitTesting(false);
if (lP) lP.classList.add('visible');
if (dH) dH.classList.add('visible');
if (typeof window._parsilStartDraw === 'function') window._parsilStartDraw();
} else {
currentMode = 0; currentSubMode = '';
syncModeGlobals();
setDrawBlockingLayerHitTesting(true);
setChoroplethHitTesting(true);
if (dH) dH.classList.remove('visible');
}
var mc = document.getElementById('map');
if (mc) mc.style.cursor = tool ? 'crosshair' : '';
}
function finishActiveDrawing() {
if (activeTool === 'jalan' && typeof window._jalanFinishDraw === 'function') {
window._jalanFinishDraw();
} else if (activeTool === 'parsil' && typeof window._parsilFinishDraw === 'function') {
window._parsilFinishDraw();
}
}
function cancelActiveDrawing() {
if (activeTool === 'jalan' && typeof window._jalanStopDraw === 'function') {
window._jalanStopDraw();
} else if (activeTool === 'parsil' && typeof window._parsilStopDraw === 'function') {
window._parsilStopDraw();
} else if (typeof window._resetActiveTool === 'function') {
window._resetActiveTool();
}
}
function updateCount(n, icon) {
const m = {
'📍': 'countPoint', 'Point': 'countPoint',
'🛣️': 'countJalan', 'Jalan': 'countJalan',
'🏘️': 'countParsil', 'Parsil': 'countParsil'
};
const el = document.getElementById(m[icon]);
if (el) el.textContent = n + ' data';
}
window.dlRefreshList = function(type, items) {
const m = { 'Point': 'countPoint', 'Jalan': 'countJalan', 'Parsil': 'countParsil' };
const el = document.getElementById(m[type]);
if (el) el.textContent = (items?.length ?? 0) + ' data';
};
var _classicVisible = { point: true, jalan: true, parsil: true };
function setPaneHitTesting(paneName, enabled) {
if (typeof map === 'undefined' || !map.getPane) return;
const pane = map.getPane(paneName);
if (!pane) return;
pane.style.pointerEvents = enabled ? '' : 'none';
pane.classList.toggle('is-hit-disabled', !enabled);
}
function setDrawBlockingLayerHitTesting(enabled) {
['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane'].forEach(function(paneName) {
setPaneHitTesting(paneName, enabled);
});
}
function setChoroplethHitTesting(enabled) {
setPaneHitTesting('choroplethPane', enabled);
if (window._choro && typeof window._choro.setHitTesting === 'function') {
window._choro.setHitTesting(enabled);
}
}
function toggleClassic(type) {
_classicVisible[type] = !_classicVisible[type];
const v = _classicVisible[type];
const cap = type.charAt(0).toUpperCase() + type.slice(1);
const ic = document.getElementById('eyeClassic' + cap);
if (ic) { ic.setAttribute('data-lucide', v ? 'eye' : 'eye-off'); lucide.createIcons(); }
if (type === 'point' && typeof window._pointToggleLayer === 'function') _pointToggleLayer(v);
if (type === 'jalan' && typeof window._jalanToggleLayer === 'function') _jalanToggleLayer(v);
if (type === 'parsil' && typeof window._parsilToggleLayer === 'function') _parsilToggleLayer(v);
}
// ── Map init ────────────────────────────────────────────────
const map = L.map('map', {
center: [-0.0557, 109.3487],
zoom: 13,
zoomControl: false,
});
map.on('popupopen', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
[
['choroplethPane', 410],
['parsilPane', 430],
['jalanPane', 440],
['pointPane', 620],
['drawPane', 630],
].forEach(function ([name, zIndex]) {
map.createPane(name);
map.getPane(name).style.zIndex = zIndex;
});
L.control.zoom({ position: 'bottomright' }).addTo(map);
// Base layers
const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
});
const cartoLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
});
cartoLayer.addTo(map);
L.control.layers(
{ 'Carto Light': cartoLayer, 'OpenStreetMap': osmLayer },
{},
{ position: 'topright' }
).addTo(map);
// Boot Lucide
lucide.createIcons();
</script>
<!-- Load choropleth module then init -->
<script src="modules/choropleth.js?v=<?= filemtime(__DIR__ . '/modules/choropleth.js') ?>"></script>
<script>
if (typeof window.initChoropleth === 'function') window.initChoropleth();
</script>
<!-- Load classic data modules (point/jalan/parsil) — view-only, currentMode=0 -->
<script src="modules/point.js?v=<?= filemtime(__DIR__ . '/modules/point.js') ?>"></script>
<script src="modules/jalan.js?v=<?= filemtime(__DIR__ . '/modules/jalan.js') ?>"></script>
<script src="modules/parsil.js?v=<?= filemtime(__DIR__ . '/modules/parsil.js') ?>"></script>
<script>
if (typeof initPoint === 'function') initPoint();
if (typeof initJalan === 'function') initJalan();
if (typeof initParsil === 'function') initParsil();
</script>
</body>
</html>