Compare commits

..

11 Commits

Author SHA1 Message Date
naukyy c03d7a076d fix coolify 2026-06-14 18:19:50 +07:00
naukyy 44b8b55349 debug deployment coolify 2026-06-14 18:09:20 +07:00
naukyy ef28d028b6 debug coolify 2026-06-14 17:55:15 +07:00
naukyy 0d994831c9 setup coolify 2026-06-14 17:28:43 +07:00
naukyy 28d6bfd1df readme 2026-06-13 03:56:40 +07:00
naukyy f5bccb302c docker 2026-06-13 03:53:21 +07:00
naukyy d18baa80e0 fix all bug 2026-06-13 02:10:51 +07:00
naukyy d9cff90874 portal 2026-06-13 01:07:36 +07:00
naukyy ed184225a5 tampilkan data fix database 2026-06-13 00:58:44 +07:00
naukyy 1c96173d55 update admin light/dark mode 2026-06-13 00:31:36 +07:00
naukyy 2c123f5af2 feat: Initial commit - WebGIS Smart City Project by Naufal Zaky Ramadhan (D1041231071) 2026-06-13 00:00:33 +07:00
71 changed files with 4045 additions and 1068 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* jalan.php * jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+3 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* kavling.php * kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
@@ -21,6 +21,7 @@ switch ($method) {
'geometry' => json_decode($row['geojson']), 'geometry' => json_decode($row['geojson']),
'properties' => [ 'properties' => [
'id' => $row['id'], 'id' => $row['id'],
'nama' => $row['nama_pemilik'],
'nama_pemilik' => $row['nama_pemilik'], 'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'], 'luas' => $row['luas'],
'created_at' => $row['created_at'] 'created_at' => $row['created_at']
+2 -2
View File
@@ -1,5 +1,5 @@
<?php <?php
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+265 -87
View File
@@ -1,33 +1,88 @@
/* ═══════════════════════════════════════════════════
WebGIS P01 — main.css | Dark / Light Theme
═══════════════════════════════════════════════════ */
/* ── Design Tokens ── */
:root { :root {
--primary: #6366F1; --ease: cubic-bezier(0.4, 0, 0.2, 1);
--primary-hover: #4F46E5; --transition: all 0.25s var(--ease);
--bg-dark: #0F172A;
--bg-glass: rgba(15, 23, 42, 0.75);
--border-glass: rgba(255, 255, 255, 0.1);
--text-light: #F8FAFC;
--text-muted: #94A3B8;
--radius: 20px;
--shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--font: 'Inter', sans-serif; --font: 'Inter', sans-serif;
--radius: 16px;
} }
* { box-sizing: border-box; } /* ── Dark Theme (default) ── */
[data-theme="dark"] {
--primary: #818CF8;
--primary-deep: #6366F1;
--primary-glow: rgba(99,102,241,0.3);
--bg-dark: #0A0F1E;
--bg-glass: rgba(17,24,39,0.82);
--bg-input: rgba(255,255,255,0.05);
--border-glass: rgba(255,255,255,0.08);
--border-focus: #6366F1;
--text-main: #F1F5F9;
--text-muted: #94A3B8;
--shadow: 0 25px 50px -12px rgba(0,0,0,0.55);
--dot-color: rgba(255,255,255,0.025);
--btn-spbu: #10B981;
--btn-jalan: #F59E0B;
--btn-kavling: #3B82F6;
--btn-rumah: #8B5CF6;
--btn-warga: #EF4444;
}
/* ── Light Theme ── */
[data-theme="light"] {
--primary: #4F46E5;
--primary-deep: #3730A3;
--primary-glow: rgba(79,70,229,0.15);
--bg-dark: #F0F4FF;
--bg-glass: rgba(255,255,255,0.92);
--bg-input: #FFFFFF;
--border-glass: #E2E8F0;
--border-focus: #4F46E5;
--text-main: #0F172A;
--text-muted: #64748B;
--shadow: 0 25px 50px -12px rgba(79,70,229,0.12);
--dot-color: rgba(79,70,229,0.035);
--btn-spbu: #059669;
--btn-jalan: #D97706;
--btn-kavling: #2563EB;
--btn-rumah: #7C3AED;
--btn-warga: #DC2626;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: var(--font); font-family: var(--font);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-light); color: var(--text-main);
overflow: hidden; overflow: hidden;
transition: background 0.3s var(--ease), color 0.3s var(--ease);
} }
/* Dot grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
z-index: 0;
}
/* ── Map ── */
#map { #map {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
z-index: 1; z-index: 1;
} }
/* Floating Dock */ /* ── Floating Dock ── */
.floating-dock { .floating-dock {
position: absolute; position: absolute;
top: 24px; top: 24px;
@@ -37,90 +92,178 @@ body {
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
border-radius: var(--radius); border-radius: 20px;
padding: 24px; padding: 20px;
width: 340px; width: 320px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
overflow-y: auto; overflow-y: auto;
transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
} }
/* Custom Scrollbar for Dock */ .floating-dock::-webkit-scrollbar { width: 4px; }
.floating-dock::-webkit-scrollbar { width: 6px; } .floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 10px; }
.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } /* ── Dock Top Bar ── */
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; } .dock-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Buttons */ .btn-back-portal {
.menu-group { display: flex; flex-direction: column; gap: 10px; } display: inline-flex;
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; } align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
white-space: nowrap;
}
.btn-back-portal:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
.dock-controls {
display: flex;
gap: 6px;
}
.btn-theme-dock {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: var(--transition);
}
.btn-theme-dock:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
/* ── Header ── */
.header h2 {
margin: 0 0 6px 0;
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), #C084FC);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Buttons ── */
.menu-group { display: flex; flex-direction: column; gap: 8px; }
.menu-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
margin-bottom: 2px;
}
.btn-action { .btn-action {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
color: var(--text-light); color: var(--text-main);
padding: 14px 16px; padding: 11px 14px;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-family: var(--font); font-family: var(--font);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: var(--transition);
font-size: 0.9rem; font-size: 0.875rem;
text-align: left; text-align: left;
} }
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; } .btn-action svg { width: 18px; height: 18px; flex-shrink: 0; }
.btn-action:hover { .btn-action:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255,255,255,0.08);
transform: translateX(4px); transform: translateX(3px);
border-color: var(--primary);
}
[data-theme="light"] .btn-action:hover {
background: #EEF2FF;
} }
.btn-action.active { .btn-action.active {
background: var(--primary); background: var(--primary-deep);
border-color: var(--primary); border-color: var(--primary-deep);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4); color: white;
box-shadow: 0 4px 12px var(--primary-glow);
} }
/* Button Variants */ /* Button color variants */
.btn-spbu svg { color: #10B981; } .btn-spbu svg { color: var(--btn-spbu); }
.btn-jalan svg { color: #F59E0B; } .btn-jalan svg { color: var(--btn-jalan); }
.btn-kavling svg { color: #3B82F6; } .btn-kavling svg { color: var(--btn-kavling); }
.btn-rumah svg { color: #8B5CF6; } .btn-rumah svg { color: var(--btn-rumah); }
.btn-warga svg { color: #EF4444; } .btn-warga svg { color: var(--btn-warga); }
/* Form Panel */ /* ── Form Panel ── */
.form-panel { .form-panel {
background: rgba(0, 0, 0, 0.4); background: rgba(0,0,0,0.3);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 14px;
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
} }
.form-group { display: flex; flex-direction: column; gap: 6px; } [data-theme="light"] .form-panel {
.form-group label { font-size: 0.8rem; color: var(--text-muted); } background: rgba(79,70,229,0.04);
}
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
.form-control { .form-control {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
padding: 10px 12px; padding: 9px 12px;
border-radius: 8px; border-radius: 8px;
color: white; color: var(--text-main);
font-family: var(--font); font-family: var(--font);
font-size: 0.9rem; font-size: 0.875rem;
outline: none; outline: none;
transition: border 0.2s; transition: border 0.2s;
} }
.form-control:focus { border-color: var(--primary); } .form-control:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--primary-glow); }
.form-control::placeholder { color: var(--text-muted); }
.checkbox-group { .checkbox-group {
display: flex; display: flex;
@@ -128,78 +271,113 @@ body {
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
} }
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; } .checkbox-group input { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); } .checkbox-group span { font-size: 0.82rem; color: var(--text-main); }
.btn-submit { .btn-submit {
background: var(--primary); background: var(--primary-deep);
color: white; color: white;
border: none; border: none;
padding: 12px; padding: 11px;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
font-family: var(--font);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: var(--transition);
font-size: 0.875rem;
} }
.btn-submit:hover { background: var(--primary-hover); } .btn-submit:hover { filter: brightness(1.1); transform: translateY(-1px); }
/* Leaflet Customizations */ /* ── Leaflet Customizations ── */
.leaflet-draw-toolbar { display: none !important; } .leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; } .leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-color: var(--border-glass) !important; border-color: var(--border-glass) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.leaflet-control-zoom a:hover { background: var(--primary) !important; } .leaflet-control-zoom a:hover { background: var(--primary-deep) !important; color: white !important; }
/* Custom Leaflet Layer Control style to match theme */
.leaflet-control-layers {
background: var(--bg-glass) !important;
backdrop-filter: blur(24px) !important;
-webkit-backdrop-filter: blur(24px) !important;
border: 1px solid var(--border-glass) !important;
border-radius: 14px !important;
box-shadow: var(--shadow) !important;
color: var(--text-main) !important;
font-family: var(--font) !important;
padding: 10px 14px !important;
transition: var(--transition);
}
.leaflet-control-layers-expanded {
min-width: 140px;
}
.leaflet-control-layers label {
margin-bottom: 6px !important;
font-size: 0.85rem !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
color: var(--text-main) !important;
font-weight: 500;
}
.leaflet-control-layers label:last-child {
margin-bottom: 0 !important;
}
.leaflet-control-layers input[type="checkbox"] {
cursor: pointer !important;
width: 15px !important;
height: 15px !important;
accent-color: var(--primary-deep) !important;
}
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important; backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important; border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-radius: 16px !important; border-radius: 14px !important;
box-shadow: var(--shadow) !important; box-shadow: var(--shadow) !important;
} }
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; } .leaflet-popup-tip { background: var(--bg-glass) !important; }
.leaflet-popup-content { margin: 16px !important; font-family: var(--font); } .leaflet-popup-content { margin: 14px !important; font-family: var(--font); }
.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; } .leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); } .leaflet-popup-content p { margin: 4px 0; font-size: 0.85rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete { .leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1); background: rgba(239,68,68,0.1);
color: #EF4444; color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239,68,68,0.3);
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
margin-top: 12px; margin-top: 10px;
width: 100%; width: 100%;
transition: all 0.2s; font-family: var(--font);
font-size: 0.85rem;
transition: var(--transition);
} }
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; } .leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */ /* ── Toast ── */
.custom-icon div {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.custom-icon:hover div {
transform: scale(1.1) translateY(-4px);
}
/* Toast Notification */
.toast-container { .toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 9999; position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
} }
.toast { .toast {
background: var(--bg-glass); backdrop-filter: blur(16px); background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary); border: 1px solid var(--border-glass); border-left: 4px solid var(--primary-deep);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem; padding: 14px 18px; border-radius: 12px; color: var(--text-main); font-size: 0.875rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: var(--shadow); animation: slideIn 0.3s var(--ease);
} }
.toast.error { border-left-color: #EF4444; } .toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; } .toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
+99 -13
View File
@@ -1,56 +1,142 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 01</title> <title>WebGIS Pertemuan 01: Geometri Dasar</title>
<meta name="description" content="Modul 01 — Menggambar dan mengelola geometri spasial (titik, garis, poligon) berbasis WebGIS.">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<script>
// Global error interceptor to assist in visual debugging
window.onerror = function(message, source, lineno, colno, error) {
const errorMsg = `JS Error: ${message} (at ${source ? source.split('/').pop() : 'unknown'}:${lineno}:${colno})`;
console.error(errorMsg, error);
const showInterval = setInterval(() => {
const toastContainer = document.getElementById('toast-container');
if (toastContainer) {
clearInterval(showInterval);
const t = document.createElement('div');
t.className = 'toast error';
t.innerHTML = errorMsg;
toastContainer.appendChild(t);
}
}, 100);
return false;
};
window.onunhandledrejection = function(event) {
const errorMsg = `Promise Error: ${event.reason?.message || event.reason}`;
console.error(errorMsg, event.reason);
const showInterval = setInterval(() => {
const toastContainer = document.getElementById('toast-container');
if (toastContainer) {
clearInterval(showInterval);
const t = document.createElement('div');
t.className = 'toast error';
t.innerHTML = errorMsg;
toastContainer.appendChild(t);
}
}, 100);
};
</script>
</head> </head>
<body> <body>
<!-- Peta --> <!-- Map container -->
<div id="map"></div> <div id="map"></div>
<!-- Floating Dock --> <!-- Floating Dock -->
<div class="floating-dock"> <div class="floating-dock">
<div class="header">
<h2>P01: Geometri</h2> <!-- Dock top bar: back + theme toggle -->
<p>Sistem Informasi Geografis dengan arsitektur modern.</p> <div class="dock-topbar">
<a href="../../portal.html" class="btn-back-portal" title="Kembali ke Portal">
<i class="fas fa-chevron-left"></i>
Portal
</a>
<div class="dock-controls">
<button class="btn-theme-dock" id="themeToggle" title="Toggle tema" aria-label="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
</div>
</div> </div>
<!-- Header -->
<div class="header">
<h2>P01: Geometri Dasar</h2>
<p>Sistem Informasi Geografis — CRUD spasial titik, garis, dan area.</p>
</div>
<!-- Layer Peta (Centang) -->
<div class="menu-group">
<div class="menu-title">Layer Peta</div>
<div class="form-panel" style="gap: 10px;">
<label class="checkbox-group">
<input type="checkbox" id="layer-spbu" checked>
<span>Stasiun SPBU</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-jalan" checked>
<span>Jalur Jalan</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-kavling" checked>
<span>Kavling Tanah</span>
</label>
</div>
</div>
<!-- Spatial tools -->
<div class="menu-group"> <div class="menu-group">
<div class="menu-title">Alat Spasial</div> <div class="menu-title">Alat Spasial</div>
<button id="btn-draw-spbu" class="btn-action btn-spbu"> <button id="btn-draw-spbu" class="btn-action btn-spbu">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Tambah SPBU Tambah SPBU
</button> </button>
<button id="btn-draw-jalan" class="btn-action btn-jalan"> <button id="btn-draw-jalan" class="btn-action btn-jalan">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
Tambah Jalan Tambah Jalan
</button> </button>
<button id="btn-draw-kavling" class="btn-action btn-kavling"> <button id="btn-draw-kavling" class="btn-action btn-kavling">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
Tambah Kavling Tambah Kavling
</button> </button>
</div> </div>
<!-- Dynamic Form Container --> <!-- Dynamic form container -->
<div id="form-container" style="display: none;"></div> <div id="form-container" style="display: none;"></div>
</div> </div>
<!-- Toast Notification Container --> <!-- Toast -->
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script> <script>
// ── Theme toggle ──
const html = document.documentElement;
const btn = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
const saved = localStorage.getItem('portal-theme') || 'dark';
setTheme(saved);
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('portal-theme', next);
});
function setTheme(t) {
html.setAttribute('data-theme', t);
icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
</script>
<script type="module" src="js/app.js?v=20260613-9"></script>
</body> </body>
</html> </html>
+109 -16
View File
@@ -6,6 +6,13 @@ import { spbuService, jalanService, kavlingService } from './services/api.servic
let appMap; let appMap;
let drawControl; let drawControl;
// Global layer object tracker
const mapLayers = {
spbu: null,
jalan: null,
kavling: null
};
// Custom SVG Icons Generator // Custom SVG Icons Generator
export const createIcon = (svgPath, color) => { export const createIcon = (svgPath, color) => {
return L.divIcon({ return L.divIcon({
@@ -29,30 +36,96 @@ window.showToast = (msg, type='success') => {
setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000); setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
}; };
document.addEventListener('DOMContentLoaded', async () => { const setupCheckboxListeners = () => {
appMap = initMap('map'); const layerConfigs = [
drawControl = setupDrawControls(appMap, handleGeometryCreated); { id: 'layer-spbu', key: 'spbu' },
await loadAllData(); { id: 'layer-jalan', key: 'jalan' },
}); { id: 'layer-kavling', key: 'kavling' }
];
const loadAllData = async () => { layerConfigs.forEach(({ id, key }) => {
const checkbox = document.getElementById(id);
if (checkbox) {
checkbox.addEventListener('change', (e) => {
const layer = mapLayers[key];
if (layer) {
if (e.target.checked) {
if (!appMap.hasLayer(layer)) {
appMap.addLayer(layer);
}
} else {
if (appMap.hasLayer(layer)) {
appMap.removeLayer(layer);
}
}
}
});
}
});
};
const loadSPBUData = async () => {
try { try {
const spbu = await spbuService.getAll(); const spbu = await spbuService.getAll();
L.geoJSON(spbu, { mapLayers.spbu = L.geoJSON(spbu, {
pointToLayer: (f, latlng) => L.marker(latlng, { icon: iconSPBU(f.properties.buka_24_jam) }), pointToLayer: (f, latlng) => L.marker(latlng, { icon: iconSPBU(f.properties.buka_24_jam) }),
onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties)
}).addTo(appMap); });
const jalan = await jalanService.getAll(); const checkbox = document.getElementById('layer-spbu');
L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap); if (checkbox && checkbox.checked) {
mapLayers.spbu.addTo(appMap);
const kavling = await kavlingService.getAll(); }
L.geoJSON(kavling, { style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 }, onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) }).addTo(appMap);
} catch (e) { } catch (e) {
window.showToast("Gagal meload data: "+e.message, 'error'); console.error("Gagal meload data SPBU:", e);
window.showToast("Gagal meload data SPBU: " + e.message, 'error');
} }
}; };
const loadJalanData = async () => {
try {
const jalan = await jalanService.getAll();
mapLayers.jalan = L.geoJSON(jalan, {
style: { color: '#F59E0B', weight: 4 },
onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties)
});
const checkbox = document.getElementById('layer-jalan');
if (checkbox && checkbox.checked) {
mapLayers.jalan.addTo(appMap);
}
} catch (e) {
console.error("Gagal meload data Jalan:", e);
window.showToast("Gagal meload data Jalan: " + e.message, 'error');
}
};
const loadKavlingData = async () => {
try {
const kavling = await kavlingService.getAll();
mapLayers.kavling = L.geoJSON(kavling, {
style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 },
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
});
const checkbox = document.getElementById('layer-kavling');
if (checkbox && checkbox.checked) {
mapLayers.kavling.addTo(appMap);
}
} catch (e) {
console.error("Gagal meload data Kavling:", e);
window.showToast("Gagal meload data Kavling: " + e.message, 'error');
}
};
const loadAllData = async () => {
await Promise.allSettled([
loadSPBUData(),
loadJalanData(),
loadKavlingData()
]);
};
const bindPopup = (layer, type, props) => { const bindPopup = (layer, type, props) => {
const ext = props.buka_24_jam !== undefined ? `<p>Buka 24 Jam: <strong style="color:${props.buka_24_jam?'#10B981':'#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></p>` : ''; const ext = props.buka_24_jam !== undefined ? `<p>Buka 24 Jam: <strong style="color:${props.buka_24_jam?'#10B981':'#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></p>` : '';
layer.bindPopup(` layer.bindPopup(`
@@ -82,7 +155,14 @@ const handleGeometryCreated = (type, geometry, layer) => {
try { try {
if(type==='spbu') await spbuService.create(payload); if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload); if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload); if(type==='kavling') {
const kavlingPayload = {
nama_pemilik: payload.nama,
luas: payload.luas || 0,
geometry: payload.geometry
};
await kavlingService.create(kavlingPayload);
}
window.showToast('Berhasil disimpan'); window.showToast('Berhasil disimpan');
setTimeout(()=>location.reload(), 800); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); } } catch(e) { window.showToast('Gagal simpan', 'error'); }
@@ -90,3 +170,16 @@ const handleGeometryCreated = (type, geometry, layer) => {
appMap.removeLayer(tempLayer); appMap.removeLayer(tempLayer);
}); });
}; };
const init = async () => {
appMap = initMap('map');
setupCheckboxListeners();
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+18 -8
View File
@@ -2,14 +2,23 @@ export const renderForm = (type, geometry, onSubmit, onCancel) => {
const container = document.getElementById('form-container'); const container = document.getElementById('form-container');
container.style.display = 'flex'; container.style.display = 'flex';
const extraFields = type === 'spbu' ? ` let extraFields = '';
<div class="form-group"> if (type === 'spbu') {
<label class="checkbox-group"> extraFields = `
<input type="checkbox" id="input-24jam"> <div class="form-group">
<span>Buka 24 Jam</span> <label class="checkbox-group">
</label> <input type="checkbox" id="input-24jam">
</div> <span>Buka 24 Jam</span>
` : ''; </label>
</div>
`;
} else if (type === 'kavling') {
extraFields = `
<div class="form-group">
<input type="number" id="input-luas" class="form-control" placeholder="Luas (m²)" required min="1">
</div>
`;
}
container.innerHTML = ` container.innerHTML = `
<div class="form-panel"> <div class="form-panel">
@@ -36,6 +45,7 @@ export const renderForm = (type, geometry, onSubmit, onCancel) => {
deskripsi: document.getElementById('input-deskripsi').value, deskripsi: document.getElementById('input-deskripsi').value,
}; };
if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked; if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
if (type === 'kavling') payload.luas = document.getElementById('input-luas').value;
onSubmit(payload); onSubmit(payload);
container.style.display = 'none'; container.style.display = 'none';
+17 -2
View File
@@ -14,12 +14,27 @@ export const initMap = (containerId) => {
position: 'bottomright' position: 'bottomright'
}).addTo(map); }).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI) const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
// Tile Layer Premium (CartoDB Dark Matter / Light All)
const tileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20 maxZoom: 20
}).addTo(map); }).addTo(map);
// Dynamic theme updater for tile layer
const observer = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
tileLayer.setUrl(dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
);
});
observer.observe(document.documentElement, { attributes: true });
return map; return map;
}; };
+2 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* jalan.php * jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+3 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* kavling.php * kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
@@ -21,6 +21,7 @@ switch ($method) {
'geometry' => json_decode($row['geojson']), 'geometry' => json_decode($row['geojson']),
'properties' => [ 'properties' => [
'id' => $row['id'], 'id' => $row['id'],
'nama' => $row['nama_pemilik'],
'nama_pemilik' => $row['nama_pemilik'], 'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'], 'luas' => $row['luas'],
'created_at' => $row['created_at'] 'created_at' => $row['created_at']
+19 -3
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* rumah_ibadah.php * rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine. * Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
require_once '../utils/geo_helper.php'; require_once '../utils/geo_helper.php';
@@ -31,7 +31,23 @@ switch ($method) {
// Panggil geo_helper untuk mendapatkan warga miskin // Panggil geo_helper untuk mendapatkan warga miskin
$warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius); $warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
sendSuccess($warga, 'Data jangkauan berhasil dihitung');
// Format ke GeoJSON FeatureCollection sesuai kebutuhan frontend
$features = [];
foreach ($warga as $w) {
$features[] = [
'type' => 'Feature',
'geometry' => $w['geometry'],
'properties' => [
'id' => $w['id'],
'nama' => $w['nama_kk'],
'penghasilan' => $w['penghasilan'],
'jumlah_tanggungan' => $w['jumlah_tanggungan'],
'jarak_km' => $w['jarak_km']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data jangkauan berhasil dihitung');
} else { } else {
// GET Semua Rumah Ibadah (GeoJSON) // GET Semua Rumah Ibadah (GeoJSON)
+2 -2
View File
@@ -1,5 +1,5 @@
<?php <?php
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+2 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* warga_miskin.php * warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin. * Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+265 -87
View File
@@ -1,33 +1,88 @@
/* ═══════════════════════════════════════════════════
WebGIS P01 — main.css | Dark / Light Theme
═══════════════════════════════════════════════════ */
/* ── Design Tokens ── */
:root { :root {
--primary: #6366F1; --ease: cubic-bezier(0.4, 0, 0.2, 1);
--primary-hover: #4F46E5; --transition: all 0.25s var(--ease);
--bg-dark: #0F172A;
--bg-glass: rgba(15, 23, 42, 0.75);
--border-glass: rgba(255, 255, 255, 0.1);
--text-light: #F8FAFC;
--text-muted: #94A3B8;
--radius: 20px;
--shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--font: 'Inter', sans-serif; --font: 'Inter', sans-serif;
--radius: 16px;
} }
* { box-sizing: border-box; } /* ── Dark Theme (default) ── */
[data-theme="dark"] {
--primary: #818CF8;
--primary-deep: #6366F1;
--primary-glow: rgba(99,102,241,0.3);
--bg-dark: #0A0F1E;
--bg-glass: rgba(17,24,39,0.82);
--bg-input: rgba(255,255,255,0.05);
--border-glass: rgba(255,255,255,0.08);
--border-focus: #6366F1;
--text-main: #F1F5F9;
--text-muted: #94A3B8;
--shadow: 0 25px 50px -12px rgba(0,0,0,0.55);
--dot-color: rgba(255,255,255,0.025);
--btn-spbu: #10B981;
--btn-jalan: #F59E0B;
--btn-kavling: #3B82F6;
--btn-rumah: #8B5CF6;
--btn-warga: #EF4444;
}
/* ── Light Theme ── */
[data-theme="light"] {
--primary: #4F46E5;
--primary-deep: #3730A3;
--primary-glow: rgba(79,70,229,0.15);
--bg-dark: #F0F4FF;
--bg-glass: rgba(255,255,255,0.92);
--bg-input: #FFFFFF;
--border-glass: #E2E8F0;
--border-focus: #4F46E5;
--text-main: #0F172A;
--text-muted: #64748B;
--shadow: 0 25px 50px -12px rgba(79,70,229,0.12);
--dot-color: rgba(79,70,229,0.035);
--btn-spbu: #059669;
--btn-jalan: #D97706;
--btn-kavling: #2563EB;
--btn-rumah: #7C3AED;
--btn-warga: #DC2626;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: var(--font); font-family: var(--font);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-light); color: var(--text-main);
overflow: hidden; overflow: hidden;
transition: background 0.3s var(--ease), color 0.3s var(--ease);
} }
/* Dot grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
z-index: 0;
}
/* ── Map ── */
#map { #map {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
z-index: 1; z-index: 1;
} }
/* Floating Dock */ /* ── Floating Dock ── */
.floating-dock { .floating-dock {
position: absolute; position: absolute;
top: 24px; top: 24px;
@@ -37,90 +92,178 @@ body {
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
border-radius: var(--radius); border-radius: 20px;
padding: 24px; padding: 20px;
width: 340px; width: 320px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
overflow-y: auto; overflow-y: auto;
transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
} }
/* Custom Scrollbar for Dock */ .floating-dock::-webkit-scrollbar { width: 4px; }
.floating-dock::-webkit-scrollbar { width: 6px; } .floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 10px; }
.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } /* ── Dock Top Bar ── */
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; } .dock-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Buttons */ .btn-back-portal {
.menu-group { display: flex; flex-direction: column; gap: 10px; } display: inline-flex;
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; } align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
white-space: nowrap;
}
.btn-back-portal:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
.dock-controls {
display: flex;
gap: 6px;
}
.btn-theme-dock {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: var(--transition);
}
.btn-theme-dock:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
/* ── Header ── */
.header h2 {
margin: 0 0 6px 0;
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), #C084FC);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Buttons ── */
.menu-group { display: flex; flex-direction: column; gap: 8px; }
.menu-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
margin-bottom: 2px;
}
.btn-action { .btn-action {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
color: var(--text-light); color: var(--text-main);
padding: 14px 16px; padding: 11px 14px;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-family: var(--font); font-family: var(--font);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: var(--transition);
font-size: 0.9rem; font-size: 0.875rem;
text-align: left; text-align: left;
} }
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; } .btn-action svg { width: 18px; height: 18px; flex-shrink: 0; }
.btn-action:hover { .btn-action:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255,255,255,0.08);
transform: translateX(4px); transform: translateX(3px);
border-color: var(--primary);
}
[data-theme="light"] .btn-action:hover {
background: #EEF2FF;
} }
.btn-action.active { .btn-action.active {
background: var(--primary); background: var(--primary-deep);
border-color: var(--primary); border-color: var(--primary-deep);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4); color: white;
box-shadow: 0 4px 12px var(--primary-glow);
} }
/* Button Variants */ /* Button color variants */
.btn-spbu svg { color: #10B981; } .btn-spbu svg { color: var(--btn-spbu); }
.btn-jalan svg { color: #F59E0B; } .btn-jalan svg { color: var(--btn-jalan); }
.btn-kavling svg { color: #3B82F6; } .btn-kavling svg { color: var(--btn-kavling); }
.btn-rumah svg { color: #8B5CF6; } .btn-rumah svg { color: var(--btn-rumah); }
.btn-warga svg { color: #EF4444; } .btn-warga svg { color: var(--btn-warga); }
/* Form Panel */ /* ── Form Panel ── */
.form-panel { .form-panel {
background: rgba(0, 0, 0, 0.4); background: rgba(0,0,0,0.3);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 14px;
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
} }
.form-group { display: flex; flex-direction: column; gap: 6px; } [data-theme="light"] .form-panel {
.form-group label { font-size: 0.8rem; color: var(--text-muted); } background: rgba(79,70,229,0.04);
}
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
.form-control { .form-control {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
padding: 10px 12px; padding: 9px 12px;
border-radius: 8px; border-radius: 8px;
color: white; color: var(--text-main);
font-family: var(--font); font-family: var(--font);
font-size: 0.9rem; font-size: 0.875rem;
outline: none; outline: none;
transition: border 0.2s; transition: border 0.2s;
} }
.form-control:focus { border-color: var(--primary); } .form-control:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--primary-glow); }
.form-control::placeholder { color: var(--text-muted); }
.checkbox-group { .checkbox-group {
display: flex; display: flex;
@@ -128,78 +271,113 @@ body {
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
} }
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; } .checkbox-group input { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); } .checkbox-group span { font-size: 0.82rem; color: var(--text-main); }
.btn-submit { .btn-submit {
background: var(--primary); background: var(--primary-deep);
color: white; color: white;
border: none; border: none;
padding: 12px; padding: 11px;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
font-family: var(--font);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: var(--transition);
font-size: 0.875rem;
} }
.btn-submit:hover { background: var(--primary-hover); } .btn-submit:hover { filter: brightness(1.1); transform: translateY(-1px); }
/* Leaflet Customizations */ /* ── Leaflet Customizations ── */
.leaflet-draw-toolbar { display: none !important; } .leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; } .leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-color: var(--border-glass) !important; border-color: var(--border-glass) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.leaflet-control-zoom a:hover { background: var(--primary) !important; } .leaflet-control-zoom a:hover { background: var(--primary-deep) !important; color: white !important; }
/* Custom Leaflet Layer Control style to match theme */
.leaflet-control-layers {
background: var(--bg-glass) !important;
backdrop-filter: blur(24px) !important;
-webkit-backdrop-filter: blur(24px) !important;
border: 1px solid var(--border-glass) !important;
border-radius: 14px !important;
box-shadow: var(--shadow) !important;
color: var(--text-main) !important;
font-family: var(--font) !important;
padding: 10px 14px !important;
transition: var(--transition);
}
.leaflet-control-layers-expanded {
min-width: 140px;
}
.leaflet-control-layers label {
margin-bottom: 6px !important;
font-size: 0.85rem !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
color: var(--text-main) !important;
font-weight: 500;
}
.leaflet-control-layers label:last-child {
margin-bottom: 0 !important;
}
.leaflet-control-layers input[type="checkbox"] {
cursor: pointer !important;
width: 15px !important;
height: 15px !important;
accent-color: var(--primary-deep) !important;
}
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important; backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important; border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-radius: 16px !important; border-radius: 14px !important;
box-shadow: var(--shadow) !important; box-shadow: var(--shadow) !important;
} }
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; } .leaflet-popup-tip { background: var(--bg-glass) !important; }
.leaflet-popup-content { margin: 16px !important; font-family: var(--font); } .leaflet-popup-content { margin: 14px !important; font-family: var(--font); }
.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; } .leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); } .leaflet-popup-content p { margin: 4px 0; font-size: 0.85rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete { .leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1); background: rgba(239,68,68,0.1);
color: #EF4444; color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239,68,68,0.3);
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
margin-top: 12px; margin-top: 10px;
width: 100%; width: 100%;
transition: all 0.2s; font-family: var(--font);
font-size: 0.85rem;
transition: var(--transition);
} }
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; } .leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */ /* ── Toast ── */
.custom-icon div {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.custom-icon:hover div {
transform: scale(1.1) translateY(-4px);
}
/* Toast Notification */
.toast-container { .toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 9999; position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
} }
.toast { .toast {
background: var(--bg-glass); backdrop-filter: blur(16px); background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary); border: 1px solid var(--border-glass); border-left: 4px solid var(--primary-deep);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem; padding: 14px 18px; border-radius: 12px; color: var(--text-main); font-size: 0.875rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: var(--shadow); animation: slideIn 0.3s var(--ease);
} }
.toast.error { border-left-color: #EF4444; } .toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; } .toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
+120 -16
View File
@@ -1,50 +1,154 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 02</title> <title>WebGIS Pertemuan 02: Haversine &amp; Tematik</title>
<meta name="description" content="Modul 02 — Pemetaan sosial dan analisis radius jarak Haversine berbasis WebGIS.">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<style> <style>
.result-item { background: rgba(255,255,255,0.05); padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-glass); display: flex; justify-content: space-between; align-items: center; } .result-item {
.badge { background: var(--primary); padding: 2px 6px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: white; } background: var(--bg-input);
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-glass);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.badge {
background: var(--primary-deep);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.68rem;
font-weight: 700;
color: white;
}
</style> </style>
</head> </head>
<body> <body>
<!-- Map container -->
<div id="map"></div> <div id="map"></div>
<!-- Floating Dock -->
<div class="floating-dock"> <div class="floating-dock">
<!-- Dock top bar -->
<div class="dock-topbar">
<a href="../../portal.html" class="btn-back-portal" title="Kembali ke Portal">
<i class="fas fa-chevron-left"></i>
Portal
</a>
<div class="dock-controls">
<button class="btn-theme-dock" id="themeToggle" title="Toggle tema" aria-label="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
</div>
</div>
<!-- Header -->
<div class="header"> <div class="header">
<h2>P02: Tematik & Jarak</h2> <h2>P02: Tematik &amp; Jarak</h2>
<p>Kumulatif P01 + Analisis radius jarak Haversine.</p> <p>Kumulatif P01 + Analisis radius jarak Haversine.</p>
</div> </div>
<!-- Layer Peta (Centang) -->
<div class="menu-group">
<div class="menu-title">Layer Peta</div>
<div class="form-panel" style="gap: 10px;">
<label class="checkbox-group">
<input type="checkbox" id="layer-spbu" checked>
<span>Stasiun SPBU</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-jalan" checked>
<span>Jalur Jalan</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-kavling" checked>
<span>Kavling Tanah</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-rumah" checked>
<span>Rumah Ibadah</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-warga" checked>
<span>Warga Miskin</span>
</label>
</div>
</div>
<!-- Entitas Dasar (P01) -->
<div class="menu-group"> <div class="menu-group">
<div class="menu-title">Entitas Dasar (P01)</div> <div class="menu-title">Entitas Dasar (P01)</div>
<button id="btn-draw-spbu" class="btn-action btn-spbu"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg> Tambah SPBU</button> <button id="btn-draw-spbu" class="btn-action btn-spbu">
<button id="btn-draw-jalan" class="btn-action btn-jalan"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg> Tambah Jalan</button> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<button id="btn-draw-kavling" class="btn-action btn-kavling"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg> Tambah Kavling</button> Tambah SPBU
</button>
<button id="btn-draw-jalan" class="btn-action btn-jalan">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
Tambah Jalan
</button>
<button id="btn-draw-kavling" class="btn-action btn-kavling">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
Tambah Kavling
</button>
</div> </div>
<div class="menu-group" style="margin-top: 5px;"> <!-- Entitas Sosial (P02) -->
<div class="menu-group" style="margin-top:4px;">
<div class="menu-title">Entitas Sosial (P02)</div> <div class="menu-title">Entitas Sosial (P02)</div>
<button id="btn-draw-rumah" class="btn-action btn-rumah"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg> Tambah Rumah Ibadah</button> <button id="btn-draw-rumah" class="btn-action btn-rumah">
<button id="btn-draw-warga" class="btn-action btn-warga"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg> Tambah Warga Miskin</button> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>
Tambah Rumah Ibadah
</button>
<button id="btn-draw-warga" class="btn-action btn-warga">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
Tambah Warga Miskin
</button>
</div> </div>
<div id="instruction-panel" class="form-panel" style="margin-top: 5px;"> <!-- Analisis Jarak -->
<h3 style="margin:0 0 5px 0; font-size:0.9rem; color:var(--text-main);">Analisis Jarak</h3> <div id="instruction-panel" class="form-panel" style="margin-top:2px;">
<p style="font-size:0.8rem; margin:0 0 10px 0; color:var(--text-muted);">Klik [Cek Radius] di popup Rumah Ibadah mana pun.</p> <h3 style="margin:0 0 4px 0; font-size:0.85rem; color:var(--text-main); font-weight:600;">Analisis Jarak</h3>
<div id="radius-results" style="max-height:150px; overflow-y:auto; display:flex; flex-direction:column; gap:8px;"></div> <p style="font-size:0.78rem; margin:0 0 8px 0; color:var(--text-muted);">Klik [Cek Radius] di popup Rumah Ibadah mana pun.</p>
<div id="radius-results" style="max-height:140px; overflow-y:auto; display:flex; flex-direction:column; gap:6px;"></div>
</div> </div>
<!-- Dynamic form container -->
<div id="form-container" style="display: none;"></div> <div id="form-container" style="display: none;"></div>
</div> </div>
<!-- Toast -->
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script> <script>
// ── Theme toggle ──
const html = document.documentElement;
const btn = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
const saved = localStorage.getItem('portal-theme') || 'dark';
setTheme(saved);
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('portal-theme', next);
});
function setTheme(t) {
html.setAttribute('data-theme', t);
icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
</script>
<script type="module" src="js/app.js?v=20260613-9"></script>
</body> </body>
</html> </html>
+52 -17
View File
@@ -29,17 +29,6 @@ window.showToast = (msg, type='success') => {
setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000); setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
}; };
document.addEventListener('DOMContentLoaded', async () => {
appMap = initMap('map');
// Bind extra draw events for 02
document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
});
const loadAllData = async () => { const loadAllData = async () => {
try { try {
const [spbu, jalan, kavling, rumah, warga] = await Promise.all([ const [spbu, jalan, kavling, rumah, warga] = await Promise.all([
@@ -47,11 +36,33 @@ const loadAllData = async () => {
rumahIbadahService.getAll(), wargaMiskinService.getAll() rumahIbadahService.getAll(), wargaMiskinService.getAll()
]); ]);
L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap); const spbuLayer = L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap);
L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap); const jalanLayer = L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
L.geoJSON(kavling, { style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 }, onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) }).addTo(appMap); const kavlingLayer = L.geoJSON(kavling, { style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 }, onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) }).addTo(appMap);
L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties, f) }).addTo(appMap); const rumahLayer = L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties, f) }).addTo(appMap);
L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap); const wargaLayer = L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap);
// Bind checkbox events
document.getElementById('layer-spbu').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(spbuLayer);
else appMap.removeLayer(spbuLayer);
});
document.getElementById('layer-jalan').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(jalanLayer);
else appMap.removeLayer(jalanLayer);
});
document.getElementById('layer-kavling').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(kavlingLayer);
else appMap.removeLayer(kavlingLayer);
});
document.getElementById('layer-rumah').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(rumahLayer);
else appMap.removeLayer(rumahLayer);
});
document.getElementById('layer-warga').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(wargaLayer);
else appMap.removeLayer(wargaLayer);
});
} catch (e) { } catch (e) {
window.showToast("Gagal meload data: "+e.message, 'error'); window.showToast("Gagal meload data: "+e.message, 'error');
} }
@@ -115,10 +126,34 @@ const handleGeometryCreated = (type, geometry, layer) => {
try { try {
if(type==='spbu') await spbuService.create(payload); if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload); if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload); if(type==='kavling') {
const kavlingPayload = {
nama_pemilik: payload.nama,
luas: payload.luas || 0,
geometry: payload.geometry
};
await kavlingService.create(kavlingPayload);
}
if(type==='rumah_ibadah') await rumahIbadahService.create(payload); if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
if(type==='warga_miskin') await wargaMiskinService.create(payload); if(type==='warga_miskin') await wargaMiskinService.create(payload);
window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800); window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); } } catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => appMap.removeLayer(tempLayer)); }, () => appMap.removeLayer(tempLayer));
}; };
const init = async () => {
appMap = initMap('map');
// Bind extra draw events for 02
document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+17 -2
View File
@@ -14,12 +14,27 @@ export const initMap = (containerId) => {
position: 'bottomright' position: 'bottomright'
}).addTo(map); }).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI) const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
// Tile Layer Premium (CartoDB Dark Matter / Light All)
const tileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20 maxZoom: 20
}).addTo(map); }).addTo(map);
// Dynamic theme updater for tile layer
const observer = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
tileLayer.setUrl(dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
);
});
observer.observe(document.documentElement, { attributes: true });
return map; return map;
}; };
+1 -1
View File
@@ -42,7 +42,7 @@ export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = { export const haversineService = {
getDalamRadius: async (id, radius) => { getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); const res = await fetch(`${BASE_URL}/02/backend/api/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
const json = await res.json(); const json = await res.json();
return json.data; return json.data;
} }
+2 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* jalan.php * jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+3 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* kavling.php * kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
@@ -21,6 +21,7 @@ switch ($method) {
'geometry' => json_decode($row['geojson']), 'geometry' => json_decode($row['geojson']),
'properties' => [ 'properties' => [
'id' => $row['id'], 'id' => $row['id'],
'nama' => $row['nama_pemilik'],
'nama_pemilik' => $row['nama_pemilik'], 'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'], 'luas' => $row['luas'],
'created_at' => $row['created_at'] 'created_at' => $row['created_at']
+19 -3
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* rumah_ibadah.php * rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine. * Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
require_once '../utils/geo_helper.php'; require_once '../utils/geo_helper.php';
@@ -31,7 +31,23 @@ switch ($method) {
// Panggil geo_helper untuk mendapatkan warga miskin // Panggil geo_helper untuk mendapatkan warga miskin
$warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius); $warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
sendSuccess($warga, 'Data jangkauan berhasil dihitung');
// Format ke GeoJSON FeatureCollection sesuai kebutuhan frontend
$features = [];
foreach ($warga as $w) {
$features[] = [
'type' => 'Feature',
'geometry' => $w['geometry'],
'properties' => [
'id' => $w['id'],
'nama' => $w['nama_kk'],
'penghasilan' => $w['penghasilan'],
'jumlah_tanggungan' => $w['jumlah_tanggungan'],
'jarak_km' => $w['jarak_km']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data jangkauan berhasil dihitung');
} else { } else {
// GET Semua Rumah Ibadah (GeoJSON) // GET Semua Rumah Ibadah (GeoJSON)
+2 -2
View File
@@ -1,5 +1,5 @@
<?php <?php
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+5 -17
View File
@@ -1,11 +1,11 @@
<?php <?php
/** /**
* statistik.php * statistik.php
* Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains). * Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains).
* Menghitung kepadatan warga miskin dalam setiap kavling. * Menghitung kepadatan warga miskin dalam setiap kavling.
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
@@ -31,37 +31,25 @@ if ($method === 'GET') {
$stmt = $pdo->query($sql); $stmt = $pdo->query($sql);
$features = []; $features = [];
$total_warga = 0;
while ($row = $stmt->fetch()) { while ($row = $stmt->fetch()) {
$jumlah = (int) $row['jumlah_warga']; $jumlah = (int) $row['jumlah_warga'];
$total_warga += $jumlah;
$features[] = [ $features[] = [
'type' => 'Feature', 'type' => 'Feature',
'geometry' => json_decode($row['geojson']), 'geometry' => json_decode($row['geojson']),
'properties' => [ 'properties' => [
'id' => $row['id'], 'id' => $row['id'],
'nama' => $row['nama_pemilik'],
'nama_pemilik' => $row['nama_pemilik'], 'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'], 'luas' => $row['luas'],
'jumlah_warga_miskin' => $jumlah,
'jumlah_warga' => $jumlah, 'jumlah_warga' => $jumlah,
'total_tanggungan' => (int) $row['total_tanggungan'] 'total_tanggungan' => (int) $row['total_tanggungan']
] ]
]; ];
} }
$response = [ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data statistik spasial berhasil dihitung');
'statistik' => [
'total_kavling' => count($features),
'total_warga_terpetakan' => $total_warga
],
'geojson' => [
'type' => 'FeatureCollection',
'features' => $features
]
];
sendSuccess($response, 'Data statistik spasial berhasil dihitung');
} catch (PDOException $e) { } catch (PDOException $e) {
sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500); sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500);
} }
+2 -2
View File
@@ -1,10 +1,10 @@
<?php <?php
/** /**
* warga_miskin.php * warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin. * Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
*/ */
require_once '../core_config/database.php'; require_once __DIR__ . '/../../../webgis_app/core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
$pdo = Database::getConnection(); $pdo = Database::getConnection();
+265 -87
View File
@@ -1,33 +1,88 @@
/* ═══════════════════════════════════════════════════
WebGIS P01 — main.css | Dark / Light Theme
═══════════════════════════════════════════════════ */
/* ── Design Tokens ── */
:root { :root {
--primary: #6366F1; --ease: cubic-bezier(0.4, 0, 0.2, 1);
--primary-hover: #4F46E5; --transition: all 0.25s var(--ease);
--bg-dark: #0F172A;
--bg-glass: rgba(15, 23, 42, 0.75);
--border-glass: rgba(255, 255, 255, 0.1);
--text-light: #F8FAFC;
--text-muted: #94A3B8;
--radius: 20px;
--shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--font: 'Inter', sans-serif; --font: 'Inter', sans-serif;
--radius: 16px;
} }
* { box-sizing: border-box; } /* ── Dark Theme (default) ── */
[data-theme="dark"] {
--primary: #818CF8;
--primary-deep: #6366F1;
--primary-glow: rgba(99,102,241,0.3);
--bg-dark: #0A0F1E;
--bg-glass: rgba(17,24,39,0.82);
--bg-input: rgba(255,255,255,0.05);
--border-glass: rgba(255,255,255,0.08);
--border-focus: #6366F1;
--text-main: #F1F5F9;
--text-muted: #94A3B8;
--shadow: 0 25px 50px -12px rgba(0,0,0,0.55);
--dot-color: rgba(255,255,255,0.025);
--btn-spbu: #10B981;
--btn-jalan: #F59E0B;
--btn-kavling: #3B82F6;
--btn-rumah: #8B5CF6;
--btn-warga: #EF4444;
}
/* ── Light Theme ── */
[data-theme="light"] {
--primary: #4F46E5;
--primary-deep: #3730A3;
--primary-glow: rgba(79,70,229,0.15);
--bg-dark: #F0F4FF;
--bg-glass: rgba(255,255,255,0.92);
--bg-input: #FFFFFF;
--border-glass: #E2E8F0;
--border-focus: #4F46E5;
--text-main: #0F172A;
--text-muted: #64748B;
--shadow: 0 25px 50px -12px rgba(79,70,229,0.12);
--dot-color: rgba(79,70,229,0.035);
--btn-spbu: #059669;
--btn-jalan: #D97706;
--btn-kavling: #2563EB;
--btn-rumah: #7C3AED;
--btn-warga: #DC2626;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: var(--font); font-family: var(--font);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-light); color: var(--text-main);
overflow: hidden; overflow: hidden;
transition: background 0.3s var(--ease), color 0.3s var(--ease);
} }
/* Dot grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
z-index: 0;
}
/* ── Map ── */
#map { #map {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
z-index: 1; z-index: 1;
} }
/* Floating Dock */ /* ── Floating Dock ── */
.floating-dock { .floating-dock {
position: absolute; position: absolute;
top: 24px; top: 24px;
@@ -37,90 +92,178 @@ body {
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
border-radius: var(--radius); border-radius: 20px;
padding: 24px; padding: 20px;
width: 340px; width: 320px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
overflow-y: auto; overflow-y: auto;
transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
} }
/* Custom Scrollbar for Dock */ .floating-dock::-webkit-scrollbar { width: 4px; }
.floating-dock::-webkit-scrollbar { width: 6px; } .floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 10px; }
.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } /* ── Dock Top Bar ── */
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; } .dock-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Buttons */ .btn-back-portal {
.menu-group { display: flex; flex-direction: column; gap: 10px; } display: inline-flex;
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; } align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
white-space: nowrap;
}
.btn-back-portal:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
.dock-controls {
display: flex;
gap: 6px;
}
.btn-theme-dock {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-input);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: var(--transition);
}
.btn-theme-dock:hover {
background: var(--primary-deep);
border-color: var(--primary-deep);
color: white;
}
/* ── Header ── */
.header h2 {
margin: 0 0 6px 0;
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), #C084FC);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Buttons ── */
.menu-group { display: flex; flex-direction: column; gap: 8px; }
.menu-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
margin-bottom: 2px;
}
.btn-action { .btn-action {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
color: var(--text-light); color: var(--text-main);
padding: 14px 16px; padding: 11px 14px;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-family: var(--font); font-family: var(--font);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: var(--transition);
font-size: 0.9rem; font-size: 0.875rem;
text-align: left; text-align: left;
} }
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; } .btn-action svg { width: 18px; height: 18px; flex-shrink: 0; }
.btn-action:hover { .btn-action:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255,255,255,0.08);
transform: translateX(4px); transform: translateX(3px);
border-color: var(--primary);
}
[data-theme="light"] .btn-action:hover {
background: #EEF2FF;
} }
.btn-action.active { .btn-action.active {
background: var(--primary); background: var(--primary-deep);
border-color: var(--primary); border-color: var(--primary-deep);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4); color: white;
box-shadow: 0 4px 12px var(--primary-glow);
} }
/* Button Variants */ /* Button color variants */
.btn-spbu svg { color: #10B981; } .btn-spbu svg { color: var(--btn-spbu); }
.btn-jalan svg { color: #F59E0B; } .btn-jalan svg { color: var(--btn-jalan); }
.btn-kavling svg { color: #3B82F6; } .btn-kavling svg { color: var(--btn-kavling); }
.btn-rumah svg { color: #8B5CF6; } .btn-rumah svg { color: var(--btn-rumah); }
.btn-warga svg { color: #EF4444; } .btn-warga svg { color: var(--btn-warga); }
/* Form Panel */ /* ── Form Panel ── */
.form-panel { .form-panel {
background: rgba(0, 0, 0, 0.4); background: rgba(0,0,0,0.3);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 14px;
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
} }
.form-group { display: flex; flex-direction: column; gap: 6px; } [data-theme="light"] .form-panel {
.form-group label { font-size: 0.8rem; color: var(--text-muted); } background: rgba(79,70,229,0.04);
}
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
.form-control { .form-control {
background: rgba(255, 255, 255, 0.05); background: var(--bg-input);
border: 1px solid var(--border-glass); border: 1px solid var(--border-glass);
padding: 10px 12px; padding: 9px 12px;
border-radius: 8px; border-radius: 8px;
color: white; color: var(--text-main);
font-family: var(--font); font-family: var(--font);
font-size: 0.9rem; font-size: 0.875rem;
outline: none; outline: none;
transition: border 0.2s; transition: border 0.2s;
} }
.form-control:focus { border-color: var(--primary); } .form-control:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--primary-glow); }
.form-control::placeholder { color: var(--text-muted); }
.checkbox-group { .checkbox-group {
display: flex; display: flex;
@@ -128,78 +271,113 @@ body {
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
} }
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; } .checkbox-group input { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); } .checkbox-group span { font-size: 0.82rem; color: var(--text-main); }
.btn-submit { .btn-submit {
background: var(--primary); background: var(--primary-deep);
color: white; color: white;
border: none; border: none;
padding: 12px; padding: 11px;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
font-family: var(--font);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: var(--transition);
font-size: 0.875rem;
} }
.btn-submit:hover { background: var(--primary-hover); } .btn-submit:hover { filter: brightness(1.1); transform: translateY(-1px); }
/* Leaflet Customizations */ /* ── Leaflet Customizations ── */
.leaflet-draw-toolbar { display: none !important; } .leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; } .leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-color: var(--border-glass) !important; border-color: var(--border-glass) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.leaflet-control-zoom a:hover { background: var(--primary) !important; } .leaflet-control-zoom a:hover { background: var(--primary-deep) !important; color: white !important; }
/* Custom Leaflet Layer Control style to match theme */
.leaflet-control-layers {
background: var(--bg-glass) !important;
backdrop-filter: blur(24px) !important;
-webkit-backdrop-filter: blur(24px) !important;
border: 1px solid var(--border-glass) !important;
border-radius: 14px !important;
box-shadow: var(--shadow) !important;
color: var(--text-main) !important;
font-family: var(--font) !important;
padding: 10px 14px !important;
transition: var(--transition);
}
.leaflet-control-layers-expanded {
min-width: 140px;
}
.leaflet-control-layers label {
margin-bottom: 6px !important;
font-size: 0.85rem !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
color: var(--text-main) !important;
font-weight: 500;
}
.leaflet-control-layers label:last-child {
margin-bottom: 0 !important;
}
.leaflet-control-layers input[type="checkbox"] {
cursor: pointer !important;
width: 15px !important;
height: 15px !important;
accent-color: var(--primary-deep) !important;
}
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
background: var(--bg-glass) !important; background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important; backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important; border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important; color: var(--text-main) !important;
border-radius: 16px !important; border-radius: 14px !important;
box-shadow: var(--shadow) !important; box-shadow: var(--shadow) !important;
} }
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; } .leaflet-popup-tip { background: var(--bg-glass) !important; }
.leaflet-popup-content { margin: 16px !important; font-family: var(--font); } .leaflet-popup-content { margin: 14px !important; font-family: var(--font); }
.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; } .leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); } .leaflet-popup-content p { margin: 4px 0; font-size: 0.85rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete { .leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1); background: rgba(239,68,68,0.1);
color: #EF4444; color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239,68,68,0.3);
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
margin-top: 12px; margin-top: 10px;
width: 100%; width: 100%;
transition: all 0.2s; font-family: var(--font);
font-size: 0.85rem;
transition: var(--transition);
} }
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; } .leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */ /* ── Toast ── */
.custom-icon div {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.custom-icon:hover div {
transform: scale(1.1) translateY(-4px);
}
/* Toast Notification */
.toast-container { .toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 9999; position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
} }
.toast { .toast {
background: var(--bg-glass); backdrop-filter: blur(16px); background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary); border: 1px solid var(--border-glass); border-left: 4px solid var(--primary-deep);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem; padding: 14px 18px; border-radius: 12px; color: var(--text-main); font-size: 0.875rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: var(--shadow); animation: slideIn 0.3s var(--ease);
} }
.toast.error { border-left-color: #EF4444; } .toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; } .toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
+131 -19
View File
@@ -1,53 +1,165 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 03</title> <title>WebGIS Pertemuan 03: Analisis Spasial</title>
<meta name="description" content="Modul 03 — Choropleth Map dinamis berbasis Point-in-Polygon berbasis WebGIS.">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<style> <style>
.result-item { background: rgba(255,255,255,0.05); padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-glass); display: flex; justify-content: space-between; align-items: center; } .result-item {
.badge { background: var(--primary); padding: 2px 6px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: white; } background: var(--bg-input);
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-glass);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.badge {
background: var(--primary-deep);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.68rem;
font-weight: 700;
color: white;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.stat-row strong { font-weight: 700; }
</style> </style>
</head> </head>
<body> <body>
<!-- Map container -->
<div id="map"></div> <div id="map"></div>
<!-- Floating Dock -->
<div class="floating-dock"> <div class="floating-dock">
<!-- Dock top bar -->
<div class="dock-topbar">
<a href="../../portal.html" class="btn-back-portal" title="Kembali ke Portal">
<i class="fas fa-chevron-left"></i>
Portal
</a>
<div class="dock-controls">
<button class="btn-theme-dock" id="themeToggle" title="Toggle tema" aria-label="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
</div>
</div>
<!-- Header -->
<div class="header"> <div class="header">
<h2>P03: Analisis Spasial</h2> <h2>P03: Analisis Spasial</h2>
<p>Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).</p> <p>Kumulatif P01 &amp; P02 + Choropleth Map (Point-in-Polygon).</p>
</div> </div>
<!-- Layer Peta (Centang) -->
<div class="menu-group">
<div class="menu-title">Layer Peta</div>
<div class="form-panel" style="gap: 10px;">
<label class="checkbox-group">
<input type="checkbox" id="layer-spbu" checked>
<span>Stasiun SPBU</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-jalan" checked>
<span>Jalur Jalan</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-rumah" checked>
<span>Rumah Ibadah</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-warga" checked>
<span>Warga Miskin</span>
</label>
<label class="checkbox-group">
<input type="checkbox" id="layer-choro" checked>
<span>Choropleth Kepadatan</span>
</label>
</div>
</div>
<!-- Draw Tools -->
<div class="menu-group"> <div class="menu-group">
<div class="menu-title">Draw Tools</div> <div class="menu-title">Draw Tools</div>
<button id="btn-draw-spbu" class="btn-action btn-spbu"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg> Tambah SPBU</button> <button id="btn-draw-spbu" class="btn-action btn-spbu">
<button id="btn-draw-jalan" class="btn-action btn-jalan"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg> Tambah Jalan</button> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<button id="btn-draw-kavling" class="btn-action btn-kavling"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg> Tambah Kavling</button> Tambah SPBU
<button id="btn-draw-rumah" class="btn-action btn-rumah"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg> Tambah Rumah Ibadah</button> </button>
<button id="btn-draw-warga" class="btn-action btn-warga"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg> Tambah Warga Miskin</button> <button id="btn-draw-jalan" class="btn-action btn-jalan">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
Tambah Jalan
</button>
<button id="btn-draw-kavling" class="btn-action btn-kavling">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
Tambah Kavling
</button>
<button id="btn-draw-rumah" class="btn-action btn-rumah">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>
Tambah Rumah Ibadah
</button>
<button id="btn-draw-warga" class="btn-action btn-warga">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
Tambah Warga Miskin
</button>
</div> </div>
<div id="choropleth-panel" class="form-panel" style="margin-top: 5px; background: rgba(16, 185, 129, 0.1); border-color: rgba(16, 185, 129, 0.3);"> <!-- Choropleth stats panel -->
<h3 style="margin:0 0 5px 0; font-size:0.9rem; color:#10B981;">Statistik Kavling (Choropleth)</h3> <div id="choropleth-panel" class="form-panel" style="margin-top:4px; background:rgba(16,185,129,0.07); border-color:rgba(16,185,129,0.25);">
<p style="font-size:0.8rem; margin:0 0 10px 0; color:var(--text-muted);">Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.</p> <h3 style="margin:0 0 8px 0; font-size:0.85rem; color:#10B981; font-weight:600;">
<div style="display:flex; justify-content:space-between; margin-bottom:8px;"> <i class="fas fa-chart-area" style="margin-right:6px;"></i>Statistik Kavling (Choropleth)
<span style="font-size:0.8rem;">Total Penduduk Miskin:</span> </h3>
<strong id="stat-total-warga" style="color:white;">0</strong> <p style="font-size:0.78rem; margin:0 0 10px 0; color:var(--text-muted);">Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.</p>
<div class="stat-row" style="margin-bottom:6px;">
<span>Total Penduduk Miskin:</span>
<strong id="stat-total-warga" style="color:var(--text-main);">0</strong>
</div> </div>
<div style="display:flex; justify-content:space-between;"> <div class="stat-row">
<span style="font-size:0.8rem;">Kavling Padat (>2 warga):</span> <span>Kavling Padat (&gt;2 warga):</span>
<strong id="stat-kavling-merah" style="color:#EF4444;">0</strong> <strong id="stat-kavling-merah" style="color:#EF4444;">0</strong>
</div> </div>
</div> </div>
<!-- Dynamic form container -->
<div id="form-container" style="display: none;"></div> <div id="form-container" style="display: none;"></div>
</div> </div>
<!-- Toast -->
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script> <script>
// ── Theme toggle ──
const html = document.documentElement;
const btn = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
const saved = localStorage.getItem('portal-theme') || 'dark';
setTheme(saved);
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('portal-theme', next);
});
function setTheme(t) {
html.setAttribute('data-theme', t);
icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
</script>
<script type="module" src="js/app.js?v=20260613-9"></script>
</body> </body>
</html> </html>
+49 -14
View File
@@ -30,14 +30,6 @@ window.showToast = (msg, type='success') => {
setTimeout(() => { t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000); setTimeout(() => { t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
}; };
document.addEventListener('DOMContentLoaded', async () => {
appMap = initMap('map');
document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
});
const loadAllData = async () => { const loadAllData = async () => {
try { try {
const [spbu, jalan, choro, rumah, warga] = await Promise.all([ const [spbu, jalan, choro, rumah, warga] = await Promise.all([
@@ -45,13 +37,13 @@ const loadAllData = async () => {
rumahIbadahService.getAll(), wargaMiskinService.getAll() rumahIbadahService.getAll(), wargaMiskinService.getAll()
]); ]);
L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap); const spbuLayer = L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap);
L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap); const jalanLayer = L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties) }).addTo(appMap); const rumahLayer = L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties) }).addTo(appMap);
L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap); const wargaLayer = L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap);
let merahCount = 0; let merahCount = 0;
L.geoJSON(choro, { const choroLayer = L.geoJSON(choro, {
style: (f) => { style: (f) => {
const count = f.properties.jumlah_warga_miskin; const count = f.properties.jumlah_warga_miskin;
let c = '#10B981'; // Hijau (0) let c = '#10B981'; // Hijau (0)
@@ -62,6 +54,28 @@ const loadAllData = async () => {
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
}).addTo(appMap); }).addTo(appMap);
// Bind checkbox events
document.getElementById('layer-spbu').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(spbuLayer);
else appMap.removeLayer(spbuLayer);
});
document.getElementById('layer-jalan').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(jalanLayer);
else appMap.removeLayer(jalanLayer);
});
document.getElementById('layer-rumah').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(rumahLayer);
else appMap.removeLayer(rumahLayer);
});
document.getElementById('layer-warga').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(wargaLayer);
else appMap.removeLayer(wargaLayer);
});
document.getElementById('layer-choro').addEventListener('change', (e) => {
if (e.target.checked) appMap.addLayer(choroLayer);
else appMap.removeLayer(choroLayer);
});
document.getElementById('stat-total-warga').innerText = warga.features.length; document.getElementById('stat-total-warga').innerText = warga.features.length;
document.getElementById('stat-kavling-merah').innerText = merahCount; document.getElementById('stat-kavling-merah').innerText = merahCount;
@@ -101,10 +115,31 @@ const handleGeometryCreated = (type, geometry, layer) => {
try { try {
if(type==='spbu') await spbuService.create(payload); if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload); if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload); if(type==='kavling') {
const kavlingPayload = {
nama_pemilik: payload.nama,
luas: payload.luas || 0,
geometry: payload.geometry
};
await kavlingService.create(kavlingPayload);
}
if(type==='rumah_ibadah') await rumahIbadahService.create(payload); if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
if(type==='warga_miskin') await wargaMiskinService.create(payload); if(type==='warga_miskin') await wargaMiskinService.create(payload);
window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800); window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); } } catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => appMap.removeLayer(tempLayer)); }, () => appMap.removeLayer(tempLayer));
}; };
const init = async () => {
appMap = initMap('map');
document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+17 -2
View File
@@ -14,12 +14,27 @@ export const initMap = (containerId) => {
position: 'bottomright' position: 'bottomright'
}).addTo(map); }).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI) const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
// Tile Layer Premium (CartoDB Dark Matter / Light All)
const tileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20 maxZoom: 20
}).addTo(map); }).addTo(map);
// Dynamic theme updater for tile layer
const observer = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
tileLayer.setUrl(dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
);
});
observer.observe(document.documentElement, { attributes: true });
return map; return map;
}; };
+1 -1
View File
@@ -42,7 +42,7 @@ export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = { export const haversineService = {
getDalamRadius: async (id, radius) => { getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); const res = await fetch(`${BASE_URL}/02/backend/api/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
const json = await res.json(); const json = await res.json();
return json.data; return json.data;
} }
+11 -5
View File
@@ -1,16 +1,22 @@
FROM php:8.2-apache FROM php:8.2-apache
# Install PHP extensions & enable Apache modules
RUN docker-php-ext-install pdo_mysql mysqli \ RUN docker-php-ext-install pdo_mysql mysqli \
&& a2enmod rewrite headers && a2enmod rewrite headers
COPY index.html /var/www/html/index.html # Copy application files
COPY portal.html /var/www/html/index.html
COPY portal.html /var/www/html/portal.html
COPY favicon.ico /var/www/html/favicon.ico
COPY webgis_app/ /var/www/html/webgis_app/
COPY db_scripts/ /var/www/html/db_scripts/
COPY 01/ /var/www/html/01/ COPY 01/ /var/www/html/01/
COPY 02/ /var/www/html/02/ COPY 02/ /var/www/html/02/
COPY 03/ /var/www/html/03/ COPY 03/ /var/www/html/03/
COPY final/ /var/www/html/final/
COPY project_final/ /var/www/html/project_final/
COPY database/ /var/www/html/database/
RUN chown -R www-data:www-data /var/www/html # Set proper permissions
RUN chown -R www-data:www-data /var/www/html \
&& find /var/www/html -type d -exec chmod 755 {} \; \
&& find /var/www/html -type f -exec chmod 644 {} \;
EXPOSE 80 EXPOSE 80
+220 -61
View File
@@ -1,75 +1,183 @@
# D1041231071 — WebGIS Smart City Project # 🗺️ WebGIS Smart City Pontianak — Portal & Aplikasi Spasial
**Naufal Zaky R | UAS WebGIS** **Tugas Akhir / UAS — Sistem Informasi Geografis (SIG)**
Aplikasi WebGIS berbasis PHP + MySQL untuk pemetaan data kemiskinan, fasilitas umum, laporan warga, dan analisis blank spot bantuan sosial di Kota Pontianak. Selamat datang di repositori **WebGIS Smart City Pontianak**. Repositori ini merupakan wadah proyek akhir mata kuliah Sistem Informasi Geografis yang mengintegrasikan seluruh modul praktikum pembelajaran dari dasar hingga aplikasi final berbasis spasial.
## Stack Teknologi ---
- PHP 8.2 Apache ## 👤 Identitas Mahasiswa
- MySQL / MariaDB + PostGIS * **Nama**: Naufal Zaky Ramadhan
- Leaflet.js (peta interaktif) * **NIM**: D1041231071
- PDO MySQL (koneksi database) * **Program Studi**: Rekayasa Sistem Komputer
* **Mata Kuliah**: Sistem Informasi Geografis (2025/2026)
* **Status Proyek**: Produksi / Selesai Deploy (Coolify & XAMPP)
## Cara Deploy (Coolify) ---
1. Buat database MySQL/MariaDB di Coolify. ## 📂 Struktur Repositori
2. Import `db_scripts/webgis_naufal_zaky.sql` ke database tersebut.
3. Buat aplikasi baru dari repo ini.
4. Pilih build menggunakan `Dockerfile` di root repository.
5. Set environment variable:
```env ```text
APP_BASE_PATH=
SESSION_SECURE=true
CORS_ALLOWED_ORIGIN=
DB_HOST=nama-service-database
DB_PORT=3306
DB_DATABASE=webgis_db
DB_USERNAME=user_database
DB_PASSWORD=password_database
```
Aplikasi final berjalan di `/webgis_app`. Gunakan `APP_BASE_PATH=/webgis_app` untuk deploy.
## Akun Awal
Setelah import SQL:
- Admin: `admin / admin123`
- Pengguna: `pengguna / user123`
> Ganti password akun demo setelah deploy jika aplikasi dibuka publik.
## Struktur Project
```
D1041231071_NaufalZakyR_UAS_WebGISProject/ D1041231071_NaufalZakyR_UAS_WebGISProject/
├── beranda.html Halaman pemilih progres ├── portal.html # Halaman Hub utama (Directory & Navigator Portal)
├── webgis_app/ ← Aplikasi final (main app) ├── 01/ # Modul Pertemuan 01 - Geometri Spasial Dasar
│ ├── panel_admin/ ← Halaman & fitur admin │ ├── backend/ # API lokal Modul 01
── panel_user/ ← Halaman & fitur pengguna ── frontend/ # Tampilan Leaflet & Leaflet Draw (Geometri Dasar)
│ ├── endpoints/ ← API endpoint PHP ├── 02/ # Modul Pertemuan 02 - Analisis Proksimitas Jarak
│ ├── core_config/ ← Konfigurasi (DB, session, auth) │ ├── backend/ # API lokal Modul 02
│ └── public_assets/ ← CSS & JS (global, panel, map) │ └── frontend/ # Tampilan Peta Haversine & Layer Tematik
├── db_scripts/ ← SQL scripts database ├── 03/ # Modul Pertemuan 03 - Analisis Spasial Lanjut
│ ├── buat_tabel.sql │ ├── backend/ # API lokal Modul 03
── data_awal_wilayah.sql ── frontend/ # Tampilan Peta Choropleth (Point-in-Polygon)
│ ├── data_awal_pengguna.sql ├── webgis_app/ # APLIKASI UTAMA (UAS - Integrated Smart City WebGIS)
── webgis_naufal_zaky.sql ── core_config/ # Konfigurasi inti (Database, Session, Auth, Env Loader)
├── version_01/ ← Progres pertemuan 1 │ ├── endpoints/ # Handler API data spasial (GeoJSON CRUD & Analisis)
├── version_02/ ← Progres pertemuan 2 │ ├── api/ # API Router / Wrappers
├── version_03/ ← Progres pertemuan 3 │ ├── panel_admin/ # Dashboard & Manajemen Data Spasial Admin
├── legacy_version/ ← Versi lama (referensi) │ ├── panel_user/ # Dashboard, Laporan Warga & Peta Interaktif User
├── Dockerfile ← Docker image untuk Coolify │ └── public_assets/ # File statis (Aesthetic CSS global, JS Peta, & Ikon)
── .env ← Konfigurasi environment ── db_scripts/ # Script skema SQL & Seed Data awal
├── Dockerfile # Konfigurasi containerization Apache + PHP 8.2 untuk Deploy
├── .env.example # Contoh konfigurasi environment variable
└── README.md # Dokumentasi teknis proyek
``` ```
## Jalankan di XAMPP Lokal ---
Set konfigurasi di `.env`: ## 🌐 Fitur Utama Pada Portal (`portal.html`)
Halaman `portal.html` di root repositori bertindak sebagai **pintu gerbang interaktif (Dashboard Portal Hub)** yang menghubungkan seluruh progres pengerjaan praktikum dari awal semester hingga aplikasi final UAS.
### Karakteristik & Fitur Portal:
* **Rich Modern UI**: Menggunakan skema desain *sleek dark mode* secara default, dengan dukungan *theme toggle* (Dark/Light) yang tersimpan secara lokal menggunakan `localStorage`.
* **Micro-Animations**: Efek hover interaktif, kartu portal dinamis dengan glassmorphic gradient, serta ambient glow orbs yang mempercantik estetika antarmuka.
* **Milestone Directory**:
1. **Modul 01 (Geometri Dasar)**: Pembelajaran cara menggambar dan menyimpan tipe data spasial: Titik/Point (direpresentasikan sebagai SPBU), Garis/LineString (Jalan), dan Poligon/Polygon (Kavling Tanah) menggunakan plugin **Leaflet Draw** ke database MySQL.
2. **Modul 02 (Haversine & Tematik)**: Pengenalan analisis jarak melengkung bumi (formula Haversine) secara real-time untuk mendeteksi sebaran Warga Miskin dalam radius bantuan Rumah Ibadah, serta visualisasi tematik sebaran sosial.
3. **Modul 03 (Analisis Spasial - Point in Polygon)**: Implementasi algoritma spasial untuk mendeteksi titik Warga Miskin yang berada di dalam koordinat Poligon Kavling secara dinamis guna mewarnai kavling berdasarkan tingkat kepadatan warga (Choropleth Map).
4. **Smart City WebGIS App (UAS)**: Integrasi penuh dari modul 01, 02, dan 03 ke dalam sebuah aplikasi web multi-peran dengan fitur pelaporan warga dan ulasan fasilitas kota.
---
## 🚀 Aplikasi Utama (`webgis_app` / Proyek Final UAS)
Aplikasi **Smart City WebGIS** merupakan hasil kompilasi dan penyempurnaan dari seluruh modul pembelajaran. Aplikasi ini ditujukan untuk memetakan sarana perkotaan, menganalisis daerah rentan sosial, serta membuka jalur interaksi dua arah antara masyarakat dan pemerintah daerah.
### 🛠️ Stack Teknologi & Arsitektur Spasial
* **Bahasa Pemrograman**: PHP Native (Terstruktur, kompatibel PHP 8.0 - 8.3)
* **Peta & Visualisasi**: Leaflet.js, Leaflet.Draw, FontAwesome, & Google Fonts (Inter, Space Grotesk)
* **Sistem Database**: MySQL / MariaDB dengan dukungan **Spatial Index** (`SPATIAL INDEX`) dan fungsi spasial OpenGIS (`ST_Contains`, `ST_Distance_Sphere`, `ST_GeomFromText`).
* **Security & Auth**: Proteksi Session aman, enkripsi password menggunakan `bcrypt` (`password_verify`), Prepared Statements untuk mencegah SQL Injection, dan middleware batasan hak akses peran.
---
### 🛡️ Fitur Bagian Admin (`panel_admin/`)
Panel khusus administrator perkotaan untuk mengelola seluruh data geospasial secara dinamis.
#### 1. Dashboard Analitik Spasial
* Menampilkan jumlah total objek spasial terdaftar (SPBU, Ruas Jalan, Rumah Ibadah, Kavling, Warga Miskin).
* **MySQL Spatial Counters**: Menghitung secara real-time jumlah **Kawasan Rawan** (Kawasan kumuh dengan lebih dari 3 warga miskin di dalamnya) dan jumlah **Blank Spot Bansos** (Warga miskin yang belum tercover bantuan radius rumah ibadah).
* Menampilkan daftar entitas terbaru yang baru saja didaftarkan oleh sistem atau dilaporkan oleh warga.
#### 2. Peta Interaktif Admin & Drawing Tools
* Integrasi **Leaflet.Draw** custom. Admin dapat menambah data langsung dengan menggambar di peta:
* Menitikkan **SPBU** (Data Point + status operasional 24 jam).
* Menitikkan **Rumah Ibadah** (Data Point + radius bantuan sosial).
* Menitikkan **Warga Miskin** (Data Point + data ekonomi pendapatan/tanggungan).
* Menarik garis **Jalur Jalan** (Data LineString + klasifikasi jalan).
* Menggambar area **Kavling Tanah** (Data Polygon + pemilik & luas tanah).
* Menggambar **Kawasan Kumuh** (Data Polygon).
* **Sinc-Data CRUD**: Klik objek apa saja di peta untuk melihat detail atribut, mengedit properti, mengubah letak koordinat, atau menghapusnya langsung dari peta.
#### 3. Data Tables Manager
* Modul CRUD berbasis tabel interaktif untuk seluruh 8 entitas data.
* Fitur pencarian instan tanpa reload, status badge dinamis, serta integrasi input data atribut spasial secara manual.
* **Moderasi Laporan Warga**: Admin dapat melihat keluhan lokasi dari warga, melihat titik masalah di peta, dan mengubah status laporan menjadi *Diproses*, *Selesai*, atau *Ditolak*.
---
### 👥 Fitur Bagian Pengguna (`panel_user/`)
Panel publik yang diperuntukkan bagi warga kota Pontianak untuk memantau kota dan berpartisipasi aktif.
#### 1. Peta Interaktif User (Read-Only)
* Menampilkan seluruh layer spasial perkotaan yang dapat diaktifkan/dinonaktifkan melalui menu kontrol layer di sidebar.
* **Legenda Dinamis**: Panduan simbol warna pada peta (seperti SPBU 24 jam berwarna hijau, SPBU terbatas berwarna jingga, dsb).
* **Analisis Spasial Publik**: Visualisasi langsung peta Choropleth kepadatan dan sebaran Blank Spot bantuan sosial di wilayah sekitar.
#### 2. Alat Spasial "SPBU Terdekat" (Proximity Search)
* Pengguna dapat menekan tombol **SPBU Terdekat** pada peta.
* Sistem akan mendeteksi titik tengah koordinat layar peta pengguna (`map.getCenter()`) dan melakukan query spasial ke backend.
* Backend memproses pencarian menggunakan rumus **Haversine** (`ST_Distance_Sphere`) untuk mengurutkan stasiun pengisian bahan bakar terdekat lengkap dengan informasi jarak (meter/kilometer) dan durasi operasionalnya.
#### 3. Laporan Warga (Citizen Crowdsourcing)
* Pengguna dapat mengirimkan pengaduan masalah infrastruktur (misalnya: Jalan Rusak, Banjir, Lampu Jalan Mati, Fasilitas Terbengkalai).
* **Location Tagging**: Dilengkapi dengan mini-map Leaflet interaktif di dalam modal. Pengguna cukup mengklik lokasi kerusakan di peta untuk menangkap koordinat Latitude dan Longitude secara presisi.
* Pengguna dapat melihat daftar laporan miliknya beserta status perkembangannya (Menunggu, Diproses, Selesai). Laporan yang masih berstatus "Menunggu" dapat dibatalkan/dihapus oleh pengguna.
#### 4. Ulasan & Rating Fasilitas Kota
* Pengguna dapat memberikan rating (skala 1 - 5 bintang) dan ulasan tertulis pada fasilitas publik seperti SPBU dan Rumah Ibadah.
* Ulasan ini akan tampil pada popup informasi ketika fasilitas tersebut diklik di peta utama oleh pengguna lain.
---
### 📊 Detail Implementasi Query Spasial MySQL
Aplikasi ini menggunakan keunggulan MySQL Spatial Geometris untuk efisiensi kalkulasi data peta:
1. **Deteksi Kawasan Rawan (Kawasan Kumuh Padat Penduduk Miskin)**
Menghitung jumlah warga miskin yang posisinya berada di dalam poligon kawasan kumuh menggunakan fungsi `ST_Contains`:
```sql
SELECT k.id, k.nama_kawasan, COUNT(w.id) as jumlah_warga
FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id HAVING jumlah_warga > 3;
```
2. **Deteksi Blank Spot Bantuan Sosial**
Mendeteksi warga miskin yang lokasinya berada di luar radius pelayanan bantuan seluruh rumah ibadah di kota menggunakan `ST_Distance_Sphere`:
```sql
SELECT w.id, w.nama_kk
FROM warga_miskin w
WHERE NOT EXISTS (
SELECT 1 FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
);
```
3. **Pencarian SPBU Terdekat (Proximity Query)**
Mencari SPBU dengan radius terdekat dari koordinat input (`:user_lon`, `:user_lat`):
```sql
SELECT nama, buka_24_jam,
ST_Distance_Sphere(geom, ST_GeomFromText('POINT(user_lon user_lat)', 4326)) AS jarak_meter
FROM spbu
ORDER BY jarak_meter ASC LIMIT 5;
```
---
## ⚙️ Cara Menjalankan Project
### 💻 A. Menjalankan di XAMPP Lokal (Windows)
#### 1. Persiapan Direktori Proyek
Salin folder proyek ini ke direktori web root XAMPP Anda (biasanya `C:\xampp\htdocs\`). Pastikan nama foldernya sesuai:
`C:\xampp\htdocs\D1041231071_NaufalZakyR_UAS_WebGISProject`
#### 2. Konfigurasi Database
1. Aktifkan service **Apache** dan **MySQL** melalui XAMPP Control Panel.
2. Buka browser dan akses `http://localhost/phpmyadmin/`.
3. Buat database baru bernama `webgis_db`.
4. Import file SQL database awal yang terletak di `db_scripts/webgis_naufal_zaky.sql` ke dalam database `webgis_db`.
> 💡 **Fitur Migrasi Otomatis**: Alternatif lain, Anda cukup membuat database kosong `webgis_db`. Saat aplikasi pertama kali dijalankan, sistem koneksi PDO akan mendeteksi database kosong dan secara otomatis mengimpor tabel serta data awal tanpa perlu manual phpMyAdmin!
#### 3. Pengaturan Environment (.env)
Buat file bernama `.env` di direktori utama proyek (sejajar dengan `portal.html`) dengan isi sebagai berikut:
```env ```env
APP_BASE_PATH=/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app APP_BASE_PATH=/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app
SESSION_SECURE=false
CORS_ALLOWED_ORIGIN=http://localhost
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=webgis_db DB_DATABASE=webgis_db
@@ -77,5 +185,56 @@ DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=
``` ```
Import `db_scripts/webgis_naufal_zaky.sql` ke phpMyAdmin, lalu buka: #### 4. Mengakses Aplikasi
`http://localhost/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app/login.php` * Untuk membuka halaman **Portal Proyek**:
`http://localhost/D1041231071_NaufalZakyR_UAS_WebGISProject/portal.html`
* Untuk langsung menuju **Aplikasi WebGIS Utama**:
`http://localhost/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app/`
---
### 🐳 B. Menjalankan Menggunakan Docker
Repositori ini telah dilengkapi dengan `Dockerfile` siap pakai untuk containerization.
1. Buka terminal di direktori proyek dan jalankan perintah build image:
```bash
docker build -t webgis-smartcity .
```
2. Jalankan container dengan menghubungkannya ke container database Anda:
```bash
docker run -d -p 8080:80 --name webgis-app webgis-smartcity
```
3. Akses aplikasi di `http://localhost:8080/`.
---
### ☁️ C. Deploying to Coolify (Production)
Jika Anda men-deploy proyek ini ke cloud server menggunakan platform **Coolify**:
1. Hubungkan akun GitHub/GitLab Anda ke Coolify.
2. Buat database MySQL / MariaDB resource di panel Coolify.
3. Tambahkan aplikasi baru dari repositori Git ini.
4. Set buildpack ke **Dockerfile** (Coolify akan mendeteksi `Dockerfile` di root secara otomatis).
5. Definisikan **Environment Variables** di panel aplikasi Coolify sesuai konfigurasi database yang dibuat:
```env
APP_BASE_PATH=
SESSION_SECURE=true
DB_HOST=mysql-service-name
DB_PORT=3306
DB_DATABASE=webgis_db
DB_USERNAME=coolify_user
DB_PASSWORD=coolify_password
```
---
## 🔑 Kredensial Akun Pengujian (Demo)
Gunakan akun berikut untuk masuk ke sistem di halaman login (`webgis_app/login.php`):
| Peran (Role) | Username | Password | Deskripsi Akses |
| :--- | :--- | :--- | :--- |
| **Administrator** | `admin` | `admin123` | Akses penuh edit spasial peta, CRUD tabel, dan moderasi laporan warga. |
| **Warga Publik** | `pengguna` | `user123` | Akses view peta interaktif, Proximity SPBU, kirim ulasan, dan input laporan warga. |
> [!WARNING]
> *Demi keamanan data publik, harap segera mengubah kata sandi default atau menghapus akun demo ini melalui menu **Manajemen Pengguna** setelah sistem di-deploy ke server produksi.*
+159 -26
View File
@@ -21,28 +21,112 @@ SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================ -- ============================================================
INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
( (
'SPBU Bundaran Digulis Untan', 'SPBU COCO 61.781.01 Ahmad Yani',
'SPBU terdekat dari kampus', 'Jl. Jend. Ahmad Yani No.3, Pontianak (Dekat Bundaran Digulis Untan)',
1, 1,
ST_GeomFromText('POINT(109.3440 -0.0565)') ST_GeomFromText('POINT(109.344381 -0.056461)')
), ),
( (
'SPBU Ahmad Yani', 'SPBU 64.781.11 Imam Bonjol',
'Dekat Mega Mall (24 Jam)', 'Jl. Imam Bonjol No.30, Pontianak Tenggara',
1, 1,
ST_GeomFromText('POINT(109.3360 -0.0450)') ST_GeomFromText('POINT(109.349692 -0.038148)')
), ),
( (
'SPBU Imam Bonjol', 'SPBU 64.781.06 Kota Baru',
'SPBU pinggir kota', 'Jl. Prof. Dr. M. Yamin, Kota Baru, Pontianak Selatan',
0, 1,
ST_GeomFromText('POINT(109.3410 -0.0380)') ST_GeomFromText('POINT(109.317589 -0.054378)')
), ),
( (
'SPBU Kota Baru', 'SPBU 64.781.01 Adi Sucipto',
'Jl. Sultan Abdurrahman', 'Jl. Adi Sucipto, Pontianak Tenggara',
0, 0,
ST_GeomFromText('POINT(109.3245 -0.0487)') ST_GeomFromText('POINT(109.362241 -0.061730)')
),
(
'SPBU 64.781.03 KH. Ahmad Dahlan',
'Jl. KH. Ahmad Dahlan No.12, Pontianak Kota',
0,
ST_GeomFromText('POINT(109.333830 -0.033621)')
),
(
'SPBU 64.781.05 Tanjung Pura',
'Jl. Tanjung Pura, Pontianak Selatan',
1,
ST_GeomFromText('POINT(109.336184 -0.026788)')
),
(
'SPBU 64.781.12 Gusti Hamzah',
'Jl. Gusti Hamzah (Pancasila), Pontianak Kota',
0,
ST_GeomFromText('POINT(109.319760 -0.029415)')
),
(
'SPBU 64.781.14 Husein Hamzah',
'Jl. Husein Hamzah (Pal 3), Pontianak Barat',
0,
ST_GeomFromText('POINT(109.294121 -0.027963)')
),
(
'SPBU 64.781.15 RE Martadinata',
'Jl. RE Martadinata, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.314227 -0.016335)')
),
(
'SPBU 63.781.02 28 Oktober',
'Jl. 28 Oktober, Pontianak Utara',
0,
ST_GeomFromText('POINT(109.366405 0.007621)')
),
(
'SPBU 64.782.01 Khatulistiwa',
'Jl. Khatulistiwa, Pontianak Utara',
1,
ST_GeomFromText('POINT(109.325983 0.035415)')
),
(
'SPBU OSO MT. Haryono',
'Jl. Letjen MT Haryono, Pontianak Selatan',
0,
ST_GeomFromText('POINT(109.3367485 -0.0448924)')
),
(
'SPBU 64.781.18 Danau Sentarum',
'Jl. Danau Sentarum, Pontianak Kota',
0,
ST_GeomFromText('POINT(109.311283 -0.046342)')
),
(
'SPBU 64.781.19 Serdam',
'Jl. Sungai Raya Dalam, Pontianak Tenggara',
0,
ST_GeomFromText('POINT(109.358241 -0.076389)')
),
(
'SPBU 64.781.02 Hasanuddin',
'Jl. Hasanuddin, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.321873 -0.024502)')
),
(
'SPBU 64.781.21 Dr. Wahidin',
'Jl. Dr. Wahidin S., Pontianak Kota',
0,
ST_GeomFromText('POINT(109.308210 -0.034502)')
),
(
'SPBU 64.781.17 Komyos Sudarso',
'Jl. Komodor Yos Sudarso, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.299100 -0.019500)')
),
(
'SPBU 64.781.19 HOS Cokroaminoto',
'Jl. HOS Cokroaminoto, Pontianak Kota',
1,
ST_GeomFromText('POINT(109.337500 -0.031500)')
); );
-- ============================================================ -- ============================================================
@@ -53,25 +137,49 @@ INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
'Masjid Raya Mujahidin', 'Masjid Raya Mujahidin',
'Islam', 'Islam',
1000, 1000,
ST_GeomFromText('POINT(109.3356 -0.0454)') ST_GeomFromText('POINT(109.3377 -0.0414)')
), ),
( (
'Gereja Katedral St. Yosef', 'Masjid Jami Sultan Syarif Abdurrahman',
'Islam',
1000,
ST_GeomFromText('POINT(109.3522 -0.0229)')
),
(
'Gereja Katedral Santo Yosef',
'Katolik', 'Katolik',
1000, 1000,
ST_GeomFromText('POINT(109.3368 -0.0298)') ST_GeomFromText('POINT(109.3384 -0.0274)')
),
(
'Gereja Kristen Immanuel (GPIB)',
'Kristen',
1000,
ST_GeomFromText('POINT(109.3375 -0.0292)')
),
(
'Gereja GKKB Pontianak',
'Kristen',
1000,
ST_GeomFromText('POINT(109.3386 -0.0336)')
), ),
( (
'Vihara Bodhisatva Karaniya Metta', 'Vihara Bodhisatva Karaniya Metta',
'Buddha', 'Buddha',
1000, 1000,
ST_GeomFromText('POINT(109.3468 -0.0233)') ST_GeomFromText('POINT(109.3432 -0.0215)')
), ),
( (
'Masjid Jami Keraton Kadriyah', 'Maha Vihara Maitreya',
'Islam', 'Buddha',
1000, 1000,
ST_GeomFromText('POINT(109.3523 -0.0229)') ST_GeomFromText('POINT(109.3649 -0.0732)')
),
(
'Pura Giripati Mulawarman',
'Hindu',
1000,
ST_GeomFromText('POINT(109.3802 -0.0663)')
); );
-- ============================================================ -- ============================================================
@@ -79,19 +187,44 @@ INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
-- ============================================================ -- ============================================================
INSERT INTO jalan (nama, jenis_jalan, geom) VALUES INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
( (
'Jalan Ahmad Yani', 'Jalan Jenderal Ahmad Yani',
'Arteri Primer', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)') ST_GeomFromText('LINESTRING(109.3621 -0.0768, 109.3444 -0.0565, 109.3360 -0.0450, 109.3330 -0.0330)')
), ),
( (
'Jalan Gajah Mada', 'Jalan Gajah Mada',
'Kolektor', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)') ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3380 -0.0290, 109.3450 -0.0250)')
),
(
'Jalan Tanjung Pura',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3300 -0.0300, 109.3360 -0.0260, 109.3480 -0.0240, 109.3550 -0.0270)')
), ),
( (
'Jalan Imam Bonjol', 'Jalan Imam Bonjol',
'Lokal', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)') ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350, 109.3590 -0.0400)')
),
(
'Jalan Adi Sucipto',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3590 -0.0400, 109.3650 -0.0600, 109.3750 -0.0700)')
),
(
'Jalan Khatulistiwa',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3450 0.0050, 109.3350 0.0150, 109.3250 0.0350, 109.3150 0.0550)')
),
(
'Jalan Komodor Yos Sudarso',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3250 -0.0280, 109.3150 -0.0240, 109.3000 -0.0180, 109.2850 -0.0150)')
),
(
'Jalan Prof. M. Yamin',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3245 -0.0487, 109.3170 -0.0540, 109.3050 -0.0620)')
); );
-- ============================================================ -- ============================================================
+159 -26
View File
@@ -61,28 +61,112 @@ CREATE TABLE spbu (
INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
( (
'SPBU Bundaran Digulis Untan', 'SPBU COCO 61.781.01 Ahmad Yani',
'SPBU terdekat dari kampus', 'Jl. Jend. Ahmad Yani No.3, Pontianak (Dekat Bundaran Digulis Untan)',
1, 1,
ST_GeomFromText('POINT(109.3440 -0.0565)') ST_GeomFromText('POINT(109.344381 -0.056461)')
), ),
( (
'SPBU Ahmad Yani', 'SPBU 64.781.11 Imam Bonjol',
'Dekat Mega Mall (24 Jam)', 'Jl. Imam Bonjol No.30, Pontianak Tenggara',
1, 1,
ST_GeomFromText('POINT(109.3360 -0.0450)') ST_GeomFromText('POINT(109.349692 -0.038148)')
), ),
( (
'SPBU Imam Bonjol', 'SPBU 64.781.06 Kota Baru',
'SPBU pinggir kota', 'Jl. Prof. Dr. M. Yamin, Kota Baru, Pontianak Selatan',
0, 1,
ST_GeomFromText('POINT(109.3410 -0.0380)') ST_GeomFromText('POINT(109.317589 -0.054378)')
), ),
( (
'SPBU Kota Baru', 'SPBU 64.781.01 Adi Sucipto',
'Jl. Sultan Abdurrahman', 'Jl. Adi Sucipto, Pontianak Tenggara',
0, 0,
ST_GeomFromText('POINT(109.3245 -0.0487)') ST_GeomFromText('POINT(109.362241 -0.061730)')
),
(
'SPBU 64.781.03 KH. Ahmad Dahlan',
'Jl. KH. Ahmad Dahlan No.12, Pontianak Kota',
0,
ST_GeomFromText('POINT(109.333830 -0.033621)')
),
(
'SPBU 64.781.05 Tanjung Pura',
'Jl. Tanjung Pura, Pontianak Selatan',
1,
ST_GeomFromText('POINT(109.336184 -0.026788)')
),
(
'SPBU 64.781.12 Gusti Hamzah',
'Jl. Gusti Hamzah (Pancasila), Pontianak Kota',
0,
ST_GeomFromText('POINT(109.319760 -0.029415)')
),
(
'SPBU 64.781.14 Husein Hamzah',
'Jl. Husein Hamzah (Pal 3), Pontianak Barat',
0,
ST_GeomFromText('POINT(109.294121 -0.027963)')
),
(
'SPBU 64.781.15 RE Martadinata',
'Jl. RE Martadinata, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.314227 -0.016335)')
),
(
'SPBU 63.781.02 28 Oktober',
'Jl. 28 Oktober, Pontianak Utara',
0,
ST_GeomFromText('POINT(109.366405 0.007621)')
),
(
'SPBU 64.782.01 Khatulistiwa',
'Jl. Khatulistiwa, Pontianak Utara',
1,
ST_GeomFromText('POINT(109.325983 0.035415)')
),
(
'SPBU OSO MT. Haryono',
'Jl. Letjen MT Haryono, Pontianak Selatan',
0,
ST_GeomFromText('POINT(109.3367485 -0.0448924)')
),
(
'SPBU 64.781.18 Danau Sentarum',
'Jl. Danau Sentarum, Pontianak Kota',
0,
ST_GeomFromText('POINT(109.311283 -0.046342)')
),
(
'SPBU 64.781.19 Serdam',
'Jl. Sungai Raya Dalam, Pontianak Tenggara',
0,
ST_GeomFromText('POINT(109.358241 -0.076389)')
),
(
'SPBU 64.781.02 Hasanuddin',
'Jl. Hasanuddin, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.321873 -0.024502)')
),
(
'SPBU 64.781.21 Dr. Wahidin',
'Jl. Dr. Wahidin S., Pontianak Kota',
0,
ST_GeomFromText('POINT(109.308210 -0.034502)')
),
(
'SPBU 64.781.17 Komyos Sudarso',
'Jl. Komodor Yos Sudarso, Pontianak Barat',
0,
ST_GeomFromText('POINT(109.299100 -0.019500)')
),
(
'SPBU 64.781.19 HOS Cokroaminoto',
'Jl. HOS Cokroaminoto, Pontianak Kota',
1,
ST_GeomFromText('POINT(109.337500 -0.031500)')
); );
CREATE TABLE rumah_ibadah ( CREATE TABLE rumah_ibadah (
@@ -100,25 +184,49 @@ INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
'Masjid Raya Mujahidin', 'Masjid Raya Mujahidin',
'Islam', 'Islam',
1000, 1000,
ST_GeomFromText('POINT(109.3356 -0.0454)') ST_GeomFromText('POINT(109.3377 -0.0414)')
), ),
( (
'Gereja Katedral St. Yosef', 'Masjid Jami Sultan Syarif Abdurrahman',
'Islam',
1000,
ST_GeomFromText('POINT(109.3522 -0.0229)')
),
(
'Gereja Katedral Santo Yosef',
'Katolik', 'Katolik',
1000, 1000,
ST_GeomFromText('POINT(109.3368 -0.0298)') ST_GeomFromText('POINT(109.3384 -0.0274)')
),
(
'Gereja Kristen Immanuel (GPIB)',
'Kristen',
1000,
ST_GeomFromText('POINT(109.3375 -0.0292)')
),
(
'Gereja GKKB Pontianak',
'Kristen',
1000,
ST_GeomFromText('POINT(109.3386 -0.0336)')
), ),
( (
'Vihara Bodhisatva Karaniya Metta', 'Vihara Bodhisatva Karaniya Metta',
'Buddha', 'Buddha',
1000, 1000,
ST_GeomFromText('POINT(109.3468 -0.0233)') ST_GeomFromText('POINT(109.3432 -0.0215)')
), ),
( (
'Masjid Jami Keraton Kadriyah', 'Maha Vihara Maitreya',
'Islam', 'Buddha',
1000, 1000,
ST_GeomFromText('POINT(109.3523 -0.0229)') ST_GeomFromText('POINT(109.3649 -0.0732)')
),
(
'Pura Giripati Mulawarman',
'Hindu',
1000,
ST_GeomFromText('POINT(109.3802 -0.0663)')
); );
CREATE TABLE jalan ( CREATE TABLE jalan (
@@ -132,19 +240,44 @@ CREATE TABLE jalan (
INSERT INTO jalan (nama, jenis_jalan, geom) VALUES INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
( (
'Jalan Ahmad Yani', 'Jalan Jenderal Ahmad Yani',
'Arteri Primer', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)') ST_GeomFromText('LINESTRING(109.3621 -0.0768, 109.3444 -0.0565, 109.3360 -0.0450, 109.3330 -0.0330)')
), ),
( (
'Jalan Gajah Mada', 'Jalan Gajah Mada',
'Kolektor', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)') ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3380 -0.0290, 109.3450 -0.0250)')
),
(
'Jalan Tanjung Pura',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3300 -0.0300, 109.3360 -0.0260, 109.3480 -0.0240, 109.3550 -0.0270)')
), ),
( (
'Jalan Imam Bonjol', 'Jalan Imam Bonjol',
'Lokal', 'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)') ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350, 109.3590 -0.0400)')
),
(
'Jalan Adi Sucipto',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3590 -0.0400, 109.3650 -0.0600, 109.3750 -0.0700)')
),
(
'Jalan Khatulistiwa',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3450 0.0050, 109.3350 0.0150, 109.3250 0.0350, 109.3150 0.0550)')
),
(
'Jalan Komodor Yos Sudarso',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3250 -0.0280, 109.3150 -0.0240, 109.3000 -0.0180, 109.2850 -0.0150)')
),
(
'Jalan Prof. M. Yamin',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3245 -0.0487, 109.3170 -0.0540, 109.3050 -0.0620)')
); );
CREATE TABLE kavling ( CREATE TABLE kavling (
View File
-320
View File
@@ -1,320 +0,0 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal WebGIS - Sistem Informasi Geografis</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
:root {
/* Matching webgis_app/main.css */
--primary: #2563EB;
--primary-light: #3B82F6;
--primary-dark: #1D4ED8;
--accent: #10B981;
--bg-base: #F9FAFB;
--bg-surface: #FFFFFF;
--border-light: #E5E7EB;
--border-hover: #D1D5DB;
--text-primary: #111827;
--text-secondary: #4B5563;
--text-muted: #6B7280;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition: all 0.2s ease-in-out;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.navbar {
width: 100%;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-sm);
}
.navbar-brand {
font-size: 1.2rem;
font-weight: 700;
color: var(--primary);
display: flex;
align-items: center;
gap: 10px;
}
.header {
text-align: center;
margin-top: 60px;
margin-bottom: 50px;
animation: slideDown 0.4s ease-out;
padding: 0 20px;
}
.header-icon {
font-size: 3rem;
color: var(--primary);
margin-bottom: 16px;
background: #EFF6FF;
width: 80px;
height: 80px;
line-height: 80px;
border-radius: 50%;
display: inline-block;
}
.header h1 {
font-size: 2.5rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.header p {
font-size: 1.05rem;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
width: 100%;
max-width: 1000px;
padding: 0 24px 60px 24px;
animation: fadeIn 0.6s ease-out forwards;
opacity: 0;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 30px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-sm);
transition: var(--transition);
position: relative;
overflow: hidden;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-md);
border-color: var(--border-hover);
}
.card-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 20px;
transition: var(--transition);
}
.card:nth-child(1) .card-icon-wrap { background: #EFF6FF; color: var(--primary); }
.card:nth-child(2) .card-icon-wrap { background: #ECFDF5; color: var(--accent); }
.card:nth-child(3) .card-icon-wrap { background: #FEF2F2; color: #EF4444; }
.card-number {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.card-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 10px;
color: var(--text-primary);
}
.card-desc {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
flex-grow: 1;
}
.card-footer {
margin-top: 24px;
display: flex;
align-items: center;
font-weight: 600;
color: var(--primary);
font-size: 0.9rem;
transition: var(--transition);
}
.card-footer i {
margin-left: 8px;
font-size: 0.8rem;
transition: transform 0.2s ease;
}
.card:hover .card-footer {
color: var(--primary-dark);
}
.card:hover .card-footer i {
transform: translateX(4px);
}
/* Final Card styling */
.card.final {
background: var(--primary);
border: 1px solid var(--primary-dark);
color: white;
}
.card.final .card-icon-wrap {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.card.final .card-number {
color: rgba(255, 255, 255, 0.8);
}
.card.final .card-title {
color: white;
}
.card.final .card-desc {
color: rgba(255, 255, 255, 0.9);
}
.card.final .card-footer {
color: white;
}
.card.final:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
background: var(--primary-dark);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<i class="fas fa-map-marked-alt"></i>
WebGIS Portal
</div>
</nav>
<header class="header">
<div class="header-icon">
<i class="fas fa-globe-asia"></i>
</div>
<h1>Sistem Informasi Geografis</h1>
<p>Direktori tugas dan proyek akhir mata kuliah Sistem Informasi Geografis. Pilih modul pembelajaran di bawah ini untuk memulai.</p>
</header>
<div class="grid-container">
<!-- Pertemuan 01 -->
<a href="01/frontend/" class="card" style="animation-delay: 0.1s;">
<div class="card-icon-wrap">
<i class="fas fa-draw-polygon"></i>
</div>
<div class="card-number">Pertemuan 01</div>
<h2 class="card-title">Geometri Dasar</h2>
<p class="card-desc">Fitur menggambar peta dasar dan CRUD spasial.</p>
<div class="card-footer">
Buka Modul <i class="fas fa-arrow-right"></i>
</div>
</a>
<!-- Pertemuan 02 -->
<a href="02/frontend/" class="card" style="animation-delay: 0.15s;">
<div class="card-icon-wrap">
<i class="fas fa-route"></i>
</div>
<div class="card-number">Pertemuan 02</div>
<h2 class="card-title">Haversine & Tematik</h2>
<p class="card-desc">Pemetaan sosial dan query pencarian radius jarak.</p>
<div class="card-footer">
Buka Modul <i class="fas fa-arrow-right"></i>
</div>
</a>
<!-- Pertemuan 03 -->
<a href="03/frontend/" class="card" style="animation-delay: 0.2s;">
<div class="card-icon-wrap">
<i class="fas fa-layer-group"></i>
</div>
<div class="card-number">Pertemuan 03</div>
<h2 class="card-title">Analisis Spasial</h2>
<p class="card-desc">Visualisasi Peta Choropleth dinamis (Point-in-Polygon).</p>
<div class="card-footer">
Buka Modul <i class="fas fa-arrow-right"></i>
</div>
</a>
<!-- Final (UAS) -->
<a href="webgis_app/" class="card final" style="animation-delay: 0.25s;">
<div class="card-icon-wrap">
<i class="fas fa-laptop-code"></i>
</div>
<div class="card-number">Proyek Akhir</div>
<h2 class="card-title">Final UAS</h2>
<p class="card-desc">Sistem komprehensif untuk evaluasi akhir semester.</p>
<div class="card-footer">
Buka Aplikasi <i class="fas fa-arrow-right"></i>
</div>
</a>
</div>
</body>
</html>
+846
View File
@@ -0,0 +1,846 @@
<!DOCTYPE html>
<html lang="id" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Portal — Sistem Informasi Geografis</title>
<meta name="description"
content="Portal WebGIS — Direktori modul dan proyek akhir mata kuliah Sistem Informasi Geografis.">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
/* ══ DESIGN TOKENS ══ */
:root {
--font-sans: 'Inter', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--transition: all 0.25s var(--ease);
}
/* ══ DARK THEME (default) ══ */
[data-theme="dark"] {
--bg-page: #0A0F1E;
--bg-surface: #111827;
--bg-card: #141D2F;
--bg-card-hover: #1A2540;
--border: rgba(255, 255, 255, 0.07);
--border-hover: rgba(99, 102, 241, 0.5);
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--primary: #6366F1;
--primary-glow: rgba(99, 102, 241, 0.25);
--accent: #06B6D4;
--accent-2: #10B981;
--accent-3: #F43F5E;
--nav-bg: rgba(10, 15, 30, 0.85);
--glass: rgba(17, 24, 39, 0.8);
--shine: rgba(255, 255, 255, 0.03);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 40px rgba(99, 102, 241, 0.15);
--gradient-hero: linear-gradient(135deg, #0A0F1E 0%, #111827 50%, #0D1B2E 100%);
--dot-color: rgba(255, 255, 255, 0.04);
}
/* ══ LIGHT THEME ══ */
[data-theme="light"] {
--bg-page: #F0F4FF;
--bg-surface: #FFFFFF;
--bg-card: #FFFFFF;
--bg-card-hover: #F8FAFF;
--border: #E2E8F0;
--border-hover: #6366F1;
--text-primary: #0F172A;
--text-secondary: #475569;
--text-muted: #94A3B8;
--primary: #4F46E5;
--primary-glow: rgba(79, 70, 229, 0.12);
--accent: #0891B2;
--accent-2: #059669;
--accent-3: #E11D48;
--nav-bg: rgba(240, 244, 255, 0.9);
--glass: rgba(255, 255, 255, 0.9);
--shine: rgba(255, 255, 255, 0.5);
--shadow-card: 0 4px 24px rgba(79, 70, 229, 0.08);
--shadow-glow: 0 0 40px rgba(79, 70, 229, 0.1);
--gradient-hero: linear-gradient(135deg, #EEF2FF 0%, #F0F4FF 50%, #E8F4FF 100%);
--dot-color: rgba(79, 70, 229, 0.04);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
transition: background 0.3s var(--ease), color 0.3s var(--ease);
overflow-x: hidden;
}
/* ── Dot grid background ── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
z-index: 0;
}
/* ── Ambient glow orbs ── */
.orb {
position: fixed;
border-radius: 50%;
filter: blur(120px);
pointer-events: none;
z-index: 0;
transition: opacity 0.4s;
}
.orb-1 {
width: 500px;
height: 500px;
background: var(--primary-glow);
top: -100px;
left: -100px;
opacity: 0.6;
}
.orb-2 {
width: 400px;
height: 400px;
background: rgba(6, 182, 212, 0.1);
bottom: -80px;
right: -80px;
opacity: 0.5;
}
/* ── Navbar ── */
.navbar {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 0 32px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
transition: var(--transition);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
text-decoration: none;
}
.brand-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), var(--accent));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
color: white;
flex-shrink: 0;
}
.navbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.nav-pill {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Theme toggle ── */
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: var(--transition);
}
.theme-toggle:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
transform: rotate(15deg);
}
/* ── Hero section ── */
.hero {
position: relative;
z-index: 1;
text-align: center;
padding: 80px 24px 60px;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-radius: 100px;
background: var(--primary-glow);
border: 1px solid var(--border-hover);
font-size: 0.75rem;
font-weight: 600;
color: var(--primary);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 24px;
animation: fadeSlideDown 0.5s var(--ease) both;
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2.2rem, 5vw, 3.8rem);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: 20px;
animation: fadeSlideDown 0.5s var(--ease) 0.05s both;
}
.hero h1 .gradient-text {
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.1rem;
color: var(--text-secondary);
max-width: 560px;
margin: 0 auto 48px;
line-height: 1.7;
animation: fadeSlideDown 0.5s var(--ease) 0.1s both;
}
/* ── Stats bar ── */
.stats-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin-bottom: 64px;
animation: fadeSlideDown 0.5s var(--ease) 0.15s both;
flex-wrap: wrap;
}
.stat-item {
text-align: center;
}
.stat-value {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 4px;
}
.stat-divider {
width: 1px;
height: 40px;
background: var(--border);
}
/* ── Grid ── */
.grid-section {
position: relative;
z-index: 1;
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 0 24px 80px;
}
.section-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 20px;
}
/* ── Card ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 28px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-card);
transition: var(--transition);
position: relative;
overflow: hidden;
cursor: pointer;
}
.card::before {
content: '';
position: absolute;
inset: 0;
background: var(--shine);
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s;
}
.card:hover {
transform: translateY(-6px);
border-color: var(--border-hover);
box-shadow: var(--shadow-glow), var(--shadow-card);
background: var(--bg-card-hover);
}
.card:hover::before {
opacity: 1;
}
/* Number badge */
.card-num {
position: absolute;
top: 20px;
right: 20px;
font-family: var(--font-display);
font-size: 4rem;
font-weight: 900;
color: var(--border);
line-height: 1;
letter-spacing: -0.05em;
transition: var(--transition);
user-select: none;
}
.card:hover .card-num {
color: var(--primary-glow);
}
.card-icon-wrap {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
margin-bottom: 24px;
transition: var(--transition);
}
.card:hover .card-icon-wrap {
transform: scale(1.08);
}
/* Card themes */
.card[data-type="p01"] .card-icon-wrap {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
}
.card[data-type="p02"] .card-icon-wrap {
background: rgba(6, 182, 212, 0.15);
color: #22D3EE;
}
.card[data-type="p03"] .card-icon-wrap {
background: rgba(16, 185, 129, 0.15);
color: #34D399;
}
.card[data-type="uas"] .card-icon-wrap {
background: rgba(244, 63, 94, 0.15);
color: #FB7185;
}
.card-tag {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 8px;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 10px;
line-height: 1.3;
}
.card-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
flex-grow: 1;
}
.card-footer {
margin-top: 24px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
transition: var(--transition);
}
.card-footer .arrow {
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--primary-glow);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
transition: var(--transition);
}
.card:hover .card-footer {
color: var(--primary);
}
.card:hover .card-footer .arrow {
background: var(--primary);
color: white;
transform: translateX(3px);
}
/* ── Final (UAS) card — full-width special ── */
.card-uas {
grid-column: 1 / -1;
flex-direction: row;
align-items: center;
gap: 32px;
background: linear-gradient(135deg, var(--primary) 0%, #4338CA 100%);
border-color: transparent;
padding: 36px 40px;
}
.card-uas::before {
background: rgba(255, 255, 255, 0.05);
}
.card-uas:hover {
background: var(--bg-card-hover);
border-color: var(--border-hover);
}
.card-uas .card-icon-wrap {
background: rgba(255, 255, 255, 0.15);
color: white;
width: 64px;
height: 64px;
font-size: 1.8rem;
flex-shrink: 0;
transition: var(--transition);
}
.card-uas:hover .card-icon-wrap {
background: rgba(244, 63, 94, 0.15);
color: #FB7185;
}
.card-uas .card-tag {
color: rgba(255, 255, 255, 0.6);
transition: var(--transition);
}
.card-uas:hover .card-tag {
color: var(--text-muted);
}
.card-uas .card-title {
color: white;
font-size: 1.6rem;
transition: var(--transition);
}
.card-uas:hover .card-title {
color: var(--text-primary);
}
.card-uas .card-desc {
color: rgba(255, 255, 255, 0.8);
font-size: 1rem;
transition: var(--transition);
}
.card-uas:hover .card-desc {
color: var(--text-secondary);
}
.card-uas .card-footer {
color: white;
margin-top: 0;
transition: var(--transition);
}
.card-uas:hover .card-footer {
color: var(--primary);
}
.card-uas .card-num {
color: rgba(255, 255, 255, 0.08);
transition: var(--transition);
}
.card-uas:hover .card-num {
color: var(--primary-glow);
}
.card-uas .card-footer .arrow {
background: rgba(255, 255, 255, 0.2);
color: white;
transition: var(--transition);
}
.card-uas:hover .card-footer .arrow {
background: var(--primary);
color: white;
}
.card-uas-text {
flex: 1;
}
/* ── Footer ── */
.footer {
position: relative;
z-index: 1;
margin-top: auto;
padding: 24px 32px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
/* ══ ANIMATIONS ══ */
@keyframes fadeSlideDown {
from {
opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fadeSlideUp 0.5s var(--ease) both;
}
.card:nth-child(1) {
animation-delay: 0.15s;
}
.card:nth-child(2) {
animation-delay: 0.2s;
}
.card:nth-child(3) {
animation-delay: 0.25s;
}
.card:nth-child(4) {
animation-delay: 0.3s;
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ══ RESPONSIVE ══ */
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.card-uas {
flex-direction: column;
text-align: center;
}
.card-uas .card-footer {
justify-content: center;
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: 1fr;
}
.navbar {
padding: 0 16px;
}
.hero {
padding: 48px 16px 40px;
}
.stats-bar {
gap: 20px;
}
.stat-divider {
display: none;
}
}
</style>
</head>
<body>
<!-- Ambient orbs -->
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<!-- Navbar -->
<nav class="navbar">
<a href="#" class="navbar-brand">
<div class="brand-icon"><i class="fas fa-map-marked-alt"></i></div>
WebGIS Portal
</a>
<div class="navbar-right">
<span class="nav-pill">SIG 2025/2026</span>
<button class="theme-toggle" id="themeToggle" title="Toggle tema" aria-label="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="hero-badge">
<i class="fas fa-satellite-dish"></i>
Sistem Informasi Geografis
</div>
<h1>
Direktori Proyek<br>
<span class="gradient-text">WebGIS Interaktif</span>
</h1>
<p>Kumpulan modul pembelajaran dan proyek akhir mata kuliah Sistem Informasi Geografis. Pilih modul untuk
memulai eksplorasi.</p>
<div class="stats-bar">
<div class="stat-item">
<div class="stat-value">3</div>
<div class="stat-label">Modul Praktikum</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">1</div>
<div class="stat-label">Proyek Akhir</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">GIS</div>
<div class="stat-label">Teknologi</div>
</div>
</div>
</section>
<!-- Grid -->
<div class="grid-section">
<div class="section-label">Modul Pembelajaran</div>
<div class="grid">
<!-- Pertemuan 01 -->
<a href="01/frontend/" class="card" data-type="p01">
<span class="card-num">01</span>
<div class="card-icon-wrap">
<i class="fas fa-draw-polygon"></i>
</div>
<div class="card-tag">Pertemuan 01</div>
<h2 class="card-title">Geometri Dasar</h2>
<p class="card-desc">Menggambar dan mengelola geometri spasial: titik (SPBU), garis (Jalan), dan poligon
(Kavling) dengan operasi CRUD.</p>
<div class="card-footer">
Buka Modul
<div class="arrow"><i class="fas fa-arrow-right"></i></div>
</div>
</a>
<!-- Pertemuan 02 -->
<a href="02/frontend/" class="card" data-type="p02">
<span class="card-num">02</span>
<div class="card-icon-wrap">
<i class="fas fa-route"></i>
</div>
<div class="card-tag">Pertemuan 02</div>
<h2 class="card-title">Haversine &amp; Tematik</h2>
<p class="card-desc">Pemetaan sosial dengan analisis radius jarak Haversine dan visualisasi tematik data
kemiskinan penduduk.</p>
<div class="card-footer">
Buka Modul
<div class="arrow"><i class="fas fa-arrow-right"></i></div>
</div>
</a>
<!-- Pertemuan 03 -->
<a href="03/frontend/" class="card" data-type="p03">
<span class="card-num">03</span>
<div class="card-icon-wrap">
<i class="fas fa-layer-group"></i>
</div>
<div class="card-tag">Pertemuan 03</div>
<h2 class="card-title">Analisis Spasial</h2>
<p class="card-desc">Choropleth Map dinamis berbasis Point-in-Polygon — pewarnaan otomatis area kavling
oleh kepadatan warga miskin.</p>
<div class="card-footer">
Buka Modul
<div class="arrow"><i class="fas fa-arrow-right"></i></div>
</div>
</a>
<!-- Final UAS -->
<a href="webgis_app/" class="card card-uas" data-type="uas">
<span class="card-num">UAS</span>
<div class="card-icon-wrap">
<i class="fas fa-laptop-code"></i>
</div>
<div class="card-uas-text">
<div class="card-tag">Proyek Akhir — Final UAS</div>
<h2 class="card-title">Smart City WebGIS</h2>
<p class="card-desc">Sistem komprehensif manajemen kawasan kota dengan dashboard analitik,
autentikasi peran, dan visualisasi peta interaktif real-time.</p>
<div class="card-footer" style="margin-top:20px;">
Buka Aplikasi
<div class="arrow"><i class="fas fa-arrow-right"></i></div>
</div>
</div>
</a>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<span>D1041231071 — Naufal Zaky Ramadhan</span>
<span>Sistem Informasi Geografis &copy; 2025</span>
</footer>
<script>
// ── Theme toggle ──
const html = document.documentElement;
const btn = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
// Persist preference
const saved = localStorage.getItem('portal-theme') || 'dark';
setTheme(saved);
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('portal-theme', next);
});
function setTheme(t) {
html.setAttribute('data-theme', t);
icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
btn.title = t === 'dark' ? 'Beralih ke Mode Terang' : 'Beralih ke Mode Gelap';
}
</script>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* jalan.php * jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* kavling.php * kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon). * Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* rumah_ibadah.php * rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine. * Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once '../core_config/database.php'; require_once '../core_config/database.php';
require_once '../utils/response_helper.php'; require_once '../utils/response_helper.php';
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* statistik.php * statistik.php
* Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains). * Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains).
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* warga_miskin.php * warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin. * Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
+36 -17
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* Singleton PDO connection. * Singleton PDO connection.
* *
@@ -32,24 +32,43 @@ class Database {
$password = self::env('DB_PASSWORD', ''); $password = self::env('DB_PASSWORD', '');
} }
self::$conn = new PDO( try {
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", self::$conn = new PDO(
$username, "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$password $username,
); $password
self::$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); );
self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); self::$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty // Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); $stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) { if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../db_scripts/webgis_naufal_zaky.sql'; $sqlFile = __DIR__ . '/../../db_scripts/webgis_naufal_zaky.sql';
if (file_exists($sqlFile)) { if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile); $sql = file_get_contents($sqlFile);
self::$conn->exec($sql); self::$conn->exec($sql);
}
}
} catch (PDOException $e) {
$isApi = (defined('JSON_REQUEST') && JSON_REQUEST) ||
(strpos($_SERVER['REQUEST_URI'] ?? '', '/api/') !== false) ||
(strpos($_SERVER['REQUEST_URI'] ?? '', 'endpoints/') !== false) ||
(isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false);
if ($isApi) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Database Connection Failed: ' . $e->getMessage()
]);
exit();
} else {
die("Database Connection Failed: " . $e->getMessage());
} }
} }
} }
+1
View File
@@ -52,6 +52,7 @@ try {
'deskripsi' => $row['deskripsi'], 'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool) $row['buka_24_jam'], 'buka_24_jam' => (bool) $row['buka_24_jam'],
'jarak_meter' => round($row['jarak_meter'], 2), 'jarak_meter' => round($row['jarak_meter'], 2),
'jarak_km' => round($row['jarak_meter'] / 1000, 2),
'created_at' => $row['created_at'], 'created_at' => $row['created_at'],
], ],
]; ];
+8 -4
View File
@@ -27,14 +27,18 @@ if ($method !== 'GET') {
} }
try { try {
date_default_timezone_set('Asia/Jakarta');
$stmt = $pdo->query( $stmt = $pdo->query(
"SELECT "SELECT
COUNT(*) AS total, COUNT(*) AS total_spbu,
SUM(buka_24_jam = 1) AS buka_24_jam, COALESCE(SUM(buka_24_jam = 1), 0) AS spbu_buka,
SUM(buka_24_jam = 0) AS tidak_24_jam COALESCE(SUM(buka_24_jam = 0), 0) AS spbu_tutup
FROM spbu" FROM spbu"
); );
$summary = $stmt->fetch(); $summary = $stmt->fetch(PDO::FETCH_ASSOC);
if ($summary) {
$summary['server_time'] = date('Y-m-d H:i:s');
}
sendSuccess($summary, 'Status SPBU berhasil diambil'); sendSuccess($summary, 'Status SPBU berhasil diambil');
} catch (PDOException $e) { } catch (PDOException $e) {
sendError('Gagal mengambil status SPBU: ' . $e->getMessage(), 500); sendError('Gagal mengambil status SPBU: ' . $e->getMessage(), 500);
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/core_config/settings.php'; require_once __DIR__ . '/core_config/settings.php';
$host = getenv('DB_HOST') ?: 'localhost'; $host = getenv('DB_HOST') ?: 'localhost';
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/core_config/session_mgr.php'; require_once __DIR__ . '/core_config/session_mgr.php';
startAppSession(); startAppSession();
if (isset($_SESSION['user_id'])) { if (isset($_SESSION['user_id'])) {
+663 -77
View File
@@ -37,94 +37,680 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - WebGIS Smart City</title> <title>Login WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <meta name="description" content="Masuk ke sistem WebGIS Smart City Pontianak.">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="public_assets/css/global.css"> <style>
<link rel="stylesheet" href="public_assets/css/auth.css"> /* ══ DESIGN TOKENS ══ */
:root {
--font-sans: 'Inter', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--transition: all 0.25s var(--ease);
}
[data-theme="dark"] {
--bg-page: #0A0F1E;
--bg-card: #111827;
--bg-input: #1A2234;
--border: rgba(255,255,255,0.08);
--border-focus: #6366F1;
--text-primary: #F1F5F9;
--text-secondary:#94A3B8;
--text-muted: #64748B;
--primary: #6366F1;
--primary-dark: #4F46E5;
--primary-glow: rgba(99,102,241,0.25);
--accent: #06B6D4;
--error-bg: rgba(244,63,94,0.12);
--error-border: rgba(244,63,94,0.3);
--error-text: #FB7185;
--role-border: rgba(255,255,255,0.08);
--role-active-bg: rgba(99,102,241,0.15);
--role-active-border: #6366F1;
--shadow-card: 0 8px 48px rgba(0,0,0,0.5);
--dot-color: rgba(255,255,255,0.03);
}
[data-theme="light"] {
--bg-page: #EEF2FF;
--bg-card: #FFFFFF;
--bg-input: #F8FAFF;
--border: #E2E8F0;
--border-focus: #4F46E5;
--text-primary: #0F172A;
--text-secondary:#475569;
--text-muted: #94A3B8;
--primary: #4F46E5;
--primary-dark: #3730A3;
--primary-glow: rgba(79,70,229,0.12);
--accent: #0891B2;
--error-bg: #FFF1F2;
--error-border: #FECDD3;
--error-text: #BE123C;
--role-border: #E2E8F0;
--role-active-bg: #EEF2FF;
--role-active-border: #4F46E5;
--shadow-card: 0 8px 48px rgba(79,70,229,0.1);
--dot-color: rgba(79,70,229,0.04);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
transition: background 0.3s var(--ease), color 0.3s var(--ease);
}
/* Dot grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
z-index: 0;
}
/* Ambient glow */
.orb {
position: fixed;
border-radius: 50%;
filter: blur(100px);
pointer-events: none;
z-index: 0;
}
.orb-1 { width: 400px; height: 400px; background: var(--primary-glow); top: -80px; right: -80px; opacity: 0.7; }
.orb-2 { width: 300px; height: 300px; background: rgba(6,182,212,0.08); bottom: -60px; left: -60px; opacity: 0.6; }
/* ── Top Bar ── */
.topbar {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
background: transparent;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
}
.btn-back:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
transform: translateX(-2px);
}
.btn-back i { font-size: 0.75rem; }
.theme-toggle {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
transition: var(--transition);
}
.theme-toggle:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
}
/* ── Main Layout ── */
.login-page {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-container {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
max-width: 960px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow-card);
overflow: hidden;
animation: fadeIn 0.4s var(--ease) both;
}
/* ── Left panel (branding) ── */
.login-left {
background: linear-gradient(145deg, var(--primary) 0%, #4338CA 60%, #312E81 100%);
padding: 48px 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.login-left::before {
content: '';
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255,255,255,0.06);
bottom: -80px;
right: -80px;
}
.login-left::after {
content: '';
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(255,255,255,0.04);
top: -40px;
left: -40px;
}
.left-brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-icon {
width: 44px;
height: 44px;
background: rgba(255,255,255,0.15);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: white;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: white;
}
.left-body {
position: relative;
z-index: 1;
}
.left-title {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 700;
color: white;
line-height: 1.2;
margin-bottom: 16px;
}
.left-desc {
font-size: 0.9rem;
color: rgba(255,255,255,0.7);
line-height: 1.7;
margin-bottom: 32px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.85rem;
color: rgba(255,255,255,0.85);
}
.feature-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
flex-shrink: 0;
}
.left-footer {
position: relative;
z-index: 1;
font-size: 0.75rem;
color: rgba(255,255,255,0.4);
}
/* ── Right panel (form) ── */
.login-right {
padding: 48px 44px;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-header {
margin-bottom: 32px;
}
.form-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.form-subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* ── Role selector ── */
.role-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 28px;
}
.role-card { position: relative; cursor: pointer; }
.role-card input[type="radio"] {
position: absolute;
opacity: 0;
width: 0; height: 0;
}
.role-card-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 14px 10px;
border-radius: 12px;
border: 1.5px solid var(--role-border);
background: transparent;
cursor: pointer;
transition: var(--transition);
text-align: center;
}
.role-card input:checked + .role-card-label {
border-color: var(--role-active-border);
background: var(--role-active-bg);
}
.role-card-label:hover {
border-color: var(--border-focus);
background: var(--role-active-bg);
}
.role-card-icon {
font-size: 1.3rem;
line-height: 1;
}
.role-card input:checked + .role-card-label .role-card-icon {
color: var(--primary);
}
.role-card-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.role-card input:checked + .role-card-label .role-card-name {
color: var(--primary);
}
.role-card-desc {
font-size: 0.72rem;
color: var(--text-muted);
}
/* ── Error alert ── */
.alert-error {
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 10px;
padding: 12px 16px;
color: var(--error-text);
font-size: 0.875rem;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
animation: shake 0.4s var(--ease);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
/* ── Form fields ── */
.field-group {
margin-bottom: 18px;
}
.field-label {
display: block;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.field-wrap {
position: relative;
}
.field-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.9rem;
pointer-events: none;
transition: color 0.2s;
}
.field-control {
width: 100%;
padding: 11px 14px 11px 40px;
border-radius: 10px;
border: 1.5px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.9rem;
font-family: var(--font-sans);
outline: none;
transition: var(--transition);
}
.field-control:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--primary-glow);
}
.field-control:focus ~ .field-icon,
.field-wrap:focus-within .field-icon {
color: var(--primary);
}
.field-control::placeholder { color: var(--text-muted); }
.btn-eye {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 6px;
font-size: 0.9rem;
transition: color 0.2s;
}
.btn-eye:hover { color: var(--primary); }
/* ── Submit button ── */
.btn-login {
width: 100%;
padding: 13px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
font-size: 0.95rem;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: var(--transition);
box-shadow: 0 4px 12px var(--primary-glow);
margin-top: 8px;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--primary-glow);
filter: brightness(1.05);
}
.btn-login:active {
transform: translateY(0);
}
/* ── Animations ── */
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.97) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* ── Responsive ── */
@media (max-width: 768px) {
.login-container { grid-template-columns: 1fr; }
.login-left { display: none; }
.login-right { padding: 36px 28px; }
}
</style>
</head> </head>
<body class="auth-page"> <body>
<div class="auth-center"> <div class="orb orb-1"></div>
<div class="auth-form-wrap"> <div class="orb orb-2"></div>
<h2 class="auth-form-title">Selamat Datang</h2>
<p class="auth-form-subtitle">Masuk ke sistem WebGIS Smart City Pontianak.</p>
<div class="role-selector"> <!-- Top bar -->
<label class="role-card"> <div class="topbar">
<input type="radio" name="role_display" value="admin" checked> <a href="../portal.html" class="btn-back" id="backToPortal">
<div class="role-card-label"> <i class="fas fa-chevron-left"></i>
<div class="role-card-icon"><i class="fas fa-user-shield"></i></div> Kembali ke Portal
<div class="role-card-name">Admin</div> </a>
<div class="role-card-desc">Kelola semua data</div> <button class="theme-toggle" id="themeToggle" title="Toggle tema" aria-label="Toggle dark/light mode">
</div> <i class="fas fa-moon" id="themeIcon"></i>
</label> </button>
<label class="role-card">
<input type="radio" name="role_display" value="user">
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user"></i></div>
<div class="role-card-name">Pengguna</div>
<div class="role-card-desc">Lihat & analisis peta</div>
</div>
</label>
</div>
<?php if ($error): ?>
<div class="auth-error">
<i class="fas fa-exclamation-circle"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="POST" class="auth-form">
<div class="form-group">
<label class="form-label">Username</label>
<div class="input-with-icon">
<i class="fas fa-user input-icon"></i>
<input type="text" name="username" class="form-control"
placeholder="Masukkan username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required autocomplete="username">
</div>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<div class="input-with-icon">
<i class="fas fa-lock input-icon"></i>
<input type="password" name="password" class="form-control"
placeholder="Masukkan password"
required autocomplete="current-password"
id="passwordInput">
<button type="button" class="input-icon" style="right:12px;left:auto;cursor:pointer;background:none;border:none;color:var(--text-muted);" onclick="togglePass()">
<i class="fas fa-eye" id="eyeIcon"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-login">
<i class="fas fa-sign-in-alt"></i>
Masuk ke Sistem
</button>
</form>
</div>
</div> </div>
<!-- Login page -->
<main class="login-page">
<div class="login-container">
<!-- Left branding -->
<div class="login-left">
<div class="left-brand">
<div class="brand-icon"><i class="fas fa-map-marked-alt"></i></div>
<span class="brand-name">WebGIS Smart City</span>
</div>
<div class="left-body">
<h2 class="left-title">Selamat Datang di<br>Sistem Informasi Geografis</h2>
<p class="left-desc">Platform manajemen data spasial kawasan kota Pontianak dengan visualisasi peta interaktif real-time.</p>
<div class="feature-list">
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-map"></i></div>
Peta Interaktif Real-time
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-chart-bar"></i></div>
Dashboard Analitik
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-shield-alt"></i></div>
Autentikasi Multi-Peran
</div>
</div>
</div>
<div class="left-footer">
D1041231071 — Naufal Zaky Ramadhan &copy; 2026
</div>
</div>
<!-- Right form -->
<div class="login-right">
<div class="form-header">
<h1 class="form-title">Masuk ke Sistem</h1>
<p class="form-subtitle">Pilih peran dan masukkan kredensial Anda.</p>
</div>
<!-- Role selector -->
<div class="role-selector">
<label class="role-card">
<input type="radio" name="role_display" value="admin" checked>
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user-shield"></i></div>
<div class="role-card-name">Admin</div>
<div class="role-card-desc">Kelola semua data</div>
</div>
</label>
<label class="role-card">
<input type="radio" name="role_display" value="user">
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user"></i></div>
<div class="role-card-name">Pengguna</div>
<div class="role-card-desc">Lihat &amp; analisis peta</div>
</div>
</label>
</div>
<?php if ($error): ?>
<div class="alert-error">
<i class="fas fa-exclamation-circle"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="POST" autocomplete="on">
<div class="field-group">
<label class="field-label" for="usernameInput">Username</label>
<div class="field-wrap">
<i class="fas fa-user field-icon"></i>
<input type="text" name="username" id="usernameInput" class="field-control"
placeholder="Masukkan username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required autocomplete="username">
</div>
</div>
<div class="field-group">
<label class="field-label" for="passwordInput">Password</label>
<div class="field-wrap">
<i class="fas fa-lock field-icon"></i>
<input type="password" name="password" id="passwordInput" class="field-control"
placeholder="Masukkan password"
required autocomplete="current-password">
<button type="button" class="btn-eye" onclick="togglePass()" aria-label="Toggle password visibility">
<i class="fas fa-eye" id="eyeIcon"></i>
</button>
</div>
</div>
<button type="submit" class="btn-login" id="loginBtn">
<i class="fas fa-sign-in-alt"></i>
Masuk ke Sistem
</button>
</form>
</div>
</div>
</main>
<script> <script>
function togglePass() { // ── Password toggle ──
const inp = document.getElementById('passwordInput'); function togglePass() {
const ico = document.getElementById('eyeIcon'); const inp = document.getElementById('passwordInput');
if (inp.type === 'password') { const ico = document.getElementById('eyeIcon');
inp.type = 'text'; const isPass = inp.type === 'password';
ico.className = 'fas fa-eye-slash'; inp.type = isPass ? 'text' : 'password';
} else { ico.className = isPass ? 'fas fa-eye-slash' : 'fas fa-eye';
inp.type = 'password';
ico.className = 'fas fa-eye';
} }
}
// ── Theme toggle ──
const html = document.documentElement;
const btn = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
const saved = localStorage.getItem('portal-theme') || 'dark';
setTheme(saved);
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('portal-theme', next);
});
function setTheme(t) {
html.setAttribute('data-theme', t);
icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
// ── Loading state on submit ──
document.querySelector('form').addEventListener('submit', function() {
const loginBtn = document.getElementById('loginBtn');
loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Memproses...';
loginBtn.disabled = true;
});
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/core_config/session_mgr.php'; require_once __DIR__ . '/core_config/session_mgr.php';
startAppSession(); startAppSession();
session_destroy(); session_destroy();
+9 -9
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -32,7 +32,7 @@ $blankCount = $pdo->query("
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM rumah_ibadah ri FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= ri.radius_bantuan_meter WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
) )
")->fetch(); ")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0); $blankSpot = (int)($blankCount['cnt'] ?? 0);
@@ -47,11 +47,11 @@ require_once __DIR__ . '/partials/header.php';
?> ?>
<!-- ── Quick Access Map ── --> <!-- ── Quick Access Map ── -->
<div class="card" style="background: #EFF6FF; border: 1px solid #BFDBFE; margin-bottom: 24px;"> <div class="card" style="background: var(--primary-glow); border: 1px solid var(--primary-light); margin-bottom: 24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;"> <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div> <div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px; color:#1E3A8A;">🗺️ Buka Peta Interaktif</h3> <h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px; color:var(--text-primary);">🗺️ Buka Peta Interaktif</h3>
<p style="color:#3B82F6; font-size:0.88rem; max-width:500px;"> <p style="color:var(--text-secondary); font-size:0.88rem; max-width:500px;">
Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data. Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data.
</p> </p>
</div> </div>
@@ -127,9 +127,9 @@ require_once __DIR__ . '/partials/header.php';
<?php if ($recentSpbu): ?> <?php if ($recentSpbu): ?>
<div style="display:flex; flex-direction:column; gap:8px;"> <div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentSpbu as $s): ?> <?php foreach ($recentSpbu as $s): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;"> <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:var(--bg-elevated); border:1px solid var(--border-light); border-radius:8px;">
<div> <div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($s['nama']) ?></div> <div style="font-weight:600; font-size:0.88rem; color:var(--text-primary);"><?= htmlspecialchars($s['nama']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);"><?= date('d M Y', strtotime($s['created_at'])) ?></div> <div style="font-size:0.75rem; color:var(--text-muted);"><?= date('d M Y', strtotime($s['created_at'])) ?></div>
</div> </div>
<span class="badge <?= $s['buka_24_jam'] ? 'badge-success' : 'badge-danger' ?>"> <span class="badge <?= $s['buka_24_jam'] ? 'badge-success' : 'badge-danger' ?>">
@@ -152,9 +152,9 @@ require_once __DIR__ . '/partials/header.php';
<?php if ($recentWarga): ?> <?php if ($recentWarga): ?>
<div style="display:flex; flex-direction:column; gap:8px;"> <div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentWarga as $w): ?> <?php foreach ($recentWarga as $w): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;"> <div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:var(--bg-elevated); border:1px solid var(--border-light); border-radius:8px;">
<div> <div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($w['nama_kk']) ?></div> <div style="font-weight:600; font-size:0.88rem; color:var(--text-primary);"><?= htmlspecialchars($w['nama_kk']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);">Rp <?= number_format($w['penghasilan'],0,',','.') ?>/bln · <?= $w['jumlah_tanggungan'] ?> tanggungan</div> <div style="font-size:0.75rem; color:var(--text-muted);">Rp <?= number_format($w['penghasilan'],0,',','.') ?>/bln · <?= $w['jumlah_tanggungan'] ?> tanggungan</div>
</div> </div>
<span class="badge badge-warning"><?= $w['jumlah_tanggungan'] ?> org</span> <span class="badge badge-warning"><?= $w['jumlah_tanggungan'] ?> org</span>
+9 -5
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -64,10 +64,10 @@ foreach($rows as $r) {
</div> </div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" name="radius_bantuan_meter" class="form-control" min="100" max="10000" step="50" value="1000" required></div> <div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" name="radius_bantuan_meter" class="form-control" min="100" max="10000" step="50" value="1000" required></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta</label> <div class="form-group"><label class="form-label">Pilih Lokasi di Peta</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div> <div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid var(--border-light);margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" readonly style="background:#F9FAFB;"> <input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" readonly style="background:var(--bg-base); border-color:var(--border-light);">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" readonly style="background:#F9FAFB;"> <input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" readonly style="background:var(--bg-base); border-color:var(--border-light);">
</div> </div>
</div> </div>
</form> </form>
@@ -104,7 +104,11 @@ window.openModal = function(id) {
setTimeout(() => { setTimeout(() => {
if(!miniMap) { if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13); miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap); const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl).addTo(miniMap);
miniMap.on('click', function(e) { miniMap.on('click', function(e) {
const lat = e.latlng.lat; const lat = e.latlng.lat;
const lng = e.latlng.lng; const lng = e.latlng.lng;
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
+7 -3
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -113,7 +113,7 @@ function badgeStatus($status) {
<strong id="mapKategori"></strong><br> <strong id="mapKategori"></strong><br>
<span style="color: var(--text-secondary);" id="mapDeskripsi"></span> <span style="color: var(--text-secondary);" id="mapDeskripsi"></span>
</div> </div>
<div id="miniMapLihat" style="height:300px;border-radius:8px;border:1px solid #E5E7EB;"></div> <div id="miniMapLihat" style="height:300px;border-radius:8px;border:1px solid var(--border-light);"></div>
</div> </div>
</div> </div>
@@ -177,7 +177,11 @@ function lihatLokasi(r) {
setTimeout(() => { setTimeout(() => {
if(!mapInstance) { if(!mapInstance) {
mapInstance = L.map('miniMapLihat'); mapInstance = L.map('miniMapLihat');
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(mapInstance); const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl).addTo(mapInstance);
} }
if(mapMarker) mapInstance.removeLayer(mapMarker); if(mapMarker) mapInstance.removeLayer(mapMarker);
+9 -5
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -59,10 +59,10 @@ require_once __DIR__ . '/partials/header.php';
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" name="buka_24_jam" id="cb24jam" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div> <div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" name="buka_24_jam" id="cb24jam" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label> <label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div> <div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid var(--border-light);margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
</div> </div>
</div> </div>
</form> </form>
@@ -104,7 +104,11 @@ window.openModal = function(id) {
setTimeout(() => { setTimeout(() => {
if(!miniMap) { if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13); miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap); const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl).addTo(miniMap);
miniMap.on('click', function(e) { miniMap.on('click', function(e) {
const lat = e.latlng.lat; const lat = e.latlng.lat;
const lng = e.latlng.lng; const lng = e.latlng.lng;
+9 -5
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -45,10 +45,10 @@ require_once __DIR__ . '/partials/header.php';
<div class="form-group"><label class="form-label">Penghasilan per Bulan (Rp)</label><input type="number" name="penghasilan" class="form-control" placeholder="0" min="0"></div> <div class="form-group"><label class="form-label">Penghasilan per Bulan (Rp)</label><input type="number" name="penghasilan" class="form-control" placeholder="0" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan (jiwa)</label><input type="number" name="jumlah_tanggungan" class="form-control" placeholder="0" min="0" max="20"></div> <div class="form-group"><label class="form-label">Jumlah Tanggungan (jiwa)</label><input type="number" name="jumlah_tanggungan" class="form-control" placeholder="0" min="0" max="20"></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta *</label> <div class="form-group"><label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div> <div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid var(--border-light);margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
</div> </div>
</div> </div>
</form> </form>
@@ -77,7 +77,11 @@ window.openModal = function(id) {
setTimeout(() => { setTimeout(() => {
if(!miniMap) { if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13); miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap); const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl).addTo(miniMap);
miniMap.on('click', function(e) { miniMap.on('click', function(e) {
const lat = e.latlng.lat; const lat = e.latlng.lat;
const lng = e.latlng.lng; const lng = e.latlng.lng;
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin'); requireRole('admin');
@@ -5,6 +5,32 @@
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
// ── Theme toggle ──
(function() {
const html = document.documentElement;
const saved = localStorage.getItem('portal-theme') || 'dark';
setAdminTheme(saved);
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('adminThemeToggle');
const icon = document.getElementById('adminThemeIcon');
if (!btn) return;
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setAdminTheme(next);
localStorage.setItem('portal-theme', next);
});
function updateIcon() {
if (icon) icon.className = html.getAttribute('data-theme') === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
updateIcon();
});
function setAdminTheme(t) {
html.setAttribute('data-theme', t);
const icon = document.getElementById('adminThemeIcon');
if (icon) icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
})();
// ── Clock ── // ── Clock ──
function updateClock() { function updateClock() {
const now = new Date(); const now = new Date();
+8 -2
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* Admin Layout — sidebar + topbar * Admin Layout — sidebar + topbar
* Include di awal setiap halaman admin setelah auth check. * Include di awal setiap halaman admin setelah auth check.
@@ -8,7 +8,7 @@ $user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1)); $initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -105,6 +105,12 @@ $initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
<a href="<?= app_url('panel_admin/peta_admin.php') ?>" class="btn btn-primary btn-sm"> <a href="<?= app_url('panel_admin/peta_admin.php') ?>" class="btn btn-primary btn-sm">
<i class="fas fa-map"></i> Buka Peta <i class="fas fa-map"></i> Buka Peta
</a> </a>
<button class="btn btn-ghost btn-sm" id="adminThemeToggle" title="Toggle tema" aria-label="Toggle dark/light mode" style="padding:8px 10px;">
<i class="fas fa-moon" id="adminThemeIcon"></i>
</button>
<a href="<?= app_url('../portal.html') ?>" class="btn btn-ghost btn-sm" title="Kembali ke Portal WebGIS">
<i class="fas fa-home"></i> Portal
</a>
</div> </div>
</div> </div>
<div class="admin-content"> <div class="admin-content">
+4 -4
View File
@@ -30,7 +30,7 @@ require_once __DIR__ . '/partials/header.php';
<div class="layer-section-title">Infrastruktur</div> <div class="layer-section-title">Infrastruktur</div>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-spbu" checked> <input type="checkbox" id="ly-spbu">
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span> <span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">SPBU</span> <span class="layer-name">SPBU</span>
@@ -39,7 +39,7 @@ require_once __DIR__ . '/partials/header.php';
</label> </label>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-jalan" checked> <input type="checkbox" id="ly-jalan">
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span> <span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">Jalan</span> <span class="layer-name">Jalan</span>
@@ -48,7 +48,7 @@ require_once __DIR__ . '/partials/header.php';
</label> </label>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-kavling" checked> <input type="checkbox" id="ly-kavling">
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span> <span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">Kavling / Parsil</span> <span class="layer-name">Kavling / Parsil</span>
@@ -168,6 +168,6 @@ require_once __DIR__ . '/partials/header.php';
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script> <script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="<?= app_url('public_assets/js/peta-admin.js') ?>?v=20260612-fix"></script> <script src="<?= app_url('public_assets/js/peta-admin.js') ?>?v=<?= time() ?>"></script>
<?php $extraScript = ''; ?> <?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?> <?php require_once __DIR__ . '/partials/footer.php'; ?>
+9 -5
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('user'); requireRole('user');
@@ -107,10 +107,10 @@ function badgeStatus($status) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label> <label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div> <div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid var(--border-light);margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;"> <input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:var(--bg-base); border-color:var(--border-light);">
</div> </div>
</div> </div>
</form> </form>
@@ -134,7 +134,11 @@ window.openModal = function(id) {
setTimeout(() => { setTimeout(() => {
if(!miniMap) { if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13); miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap); const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl).addTo(miniMap);
miniMap.on('click', function(e) { miniMap.on('click', function(e) {
const lat = e.latlng.lat; const lat = e.latlng.lat;
const lng = e.latlng.lng; const lng = e.latlng.lng;
+5 -5
View File
@@ -1,4 +1,4 @@
<?php <?php
require_once __DIR__ . '/../core_config/database.php'; require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php'; require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('user'); requireRole('user');
@@ -29,7 +29,7 @@ $blankCount = $pdo->query("
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM rumah_ibadah ri FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= ri.radius_bantuan_meter WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
) )
")->fetch(); ")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0); $blankSpot = (int)($blankCount['cnt'] ?? 0);
@@ -39,7 +39,7 @@ $activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php'; require_once __DIR__ . '/partials/header.php';
?> ?>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);"> <div class="card" style="background: var(--bg-surface); border: 1px solid var(--border-light); margin-bottom: 24px;">
<h2 style="font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text-primary);">👋 Selamat Datang, <?= htmlspecialchars(currentUser()['nama_lengkap'] ?: currentUser()['username']) ?>!</h2> <h2 style="font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text-primary);">👋 Selamat Datang, <?= htmlspecialchars(currentUser()['nama_lengkap'] ?: currentUser()['username']) ?>!</h2>
<p style="color:var(--text-secondary);font-size:0.9rem;max-width:600px;"> <p style="color:var(--text-secondary);font-size:0.9rem;max-width:600px;">
Anda masuk sebagai <strong>Pengguna</strong>. Anda dapat melihat seluruh data spasial, analisis, dan berpartisipasi dengan mengirim Laporan Warga atau memberikan ulasan fasilitas. Anda masuk sebagai <strong>Pengguna</strong>. Anda dapat melihat seluruh data spasial, analisis, dan berpartisipasi dengan mengirim Laporan Warga atau memberikan ulasan fasilitas.
@@ -97,10 +97,10 @@ require_once __DIR__ . '/partials/header.php';
</div> </div>
</div> </div>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);"> <div class="card" style="background: var(--primary-glow); border: 1px solid var(--primary-light); margin-bottom: 24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;"> <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div> <div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px;">🗺️ Buka Peta Interaktif</h3> <h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px; color:var(--text-primary);">🗺️ Buka Peta Interaktif</h3>
<p style="color:var(--text-secondary); font-size:0.88rem; max-width:500px;"> <p style="color:var(--text-secondary); font-size:0.88rem; max-width:500px;">
Lihat semua layer data spasial: SPBU, jalan, kavling, rumah ibadah, warga miskin, kawasan kumuh, dan analisis spasial. Lihat semua layer data spasial: SPBU, jalan, kavling, rumah ibadah, warga miskin, kawasan kumuh, dan analisis spasial.
</p> </p>
+26
View File
@@ -5,6 +5,32 @@
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
// ── Theme toggle ──
(function() {
const html = document.documentElement;
const saved = localStorage.getItem('portal-theme') || 'dark';
setUserTheme(saved);
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('userThemeToggle');
const icon = document.getElementById('userThemeIcon');
if (!btn) return;
btn.addEventListener('click', () => {
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
setUserTheme(next);
localStorage.setItem('portal-theme', next);
});
function updateIcon() {
if (icon) icon.className = html.getAttribute('data-theme') === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
updateIcon();
});
function setUserTheme(t) {
html.setAttribute('data-theme', t);
const icon = document.getElementById('userThemeIcon');
if (icon) icon.className = t === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
})();
// ── Clock ── // ── Clock ──
function updateClock() { function updateClock() {
const now = new Date(); const now = new Date();
+8 -2
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* User Layout — sidebar + topbar (read-only mode, beda style dari admin) * User Layout — sidebar + topbar (read-only mode, beda style dari admin)
* Variables expected from parent: $pageTitle, $activeNav * Variables expected from parent: $pageTitle, $activeNav
@@ -7,7 +7,7 @@ $user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1)); $initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -80,6 +80,12 @@ $initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
<span class="badge badge-success" style="font-size:0.7rem;padding:5px 10px;"> <span class="badge badge-success" style="font-size:0.7rem;padding:5px 10px;">
<i class="fas fa-eye"></i> Mode Baca <i class="fas fa-eye"></i> Mode Baca
</span> </span>
<button class="btn btn-ghost btn-sm" id="userThemeToggle" title="Toggle tema" aria-label="Toggle dark/light mode" style="padding:8px 10px;">
<i class="fas fa-moon" id="userThemeIcon"></i>
</button>
<a href="<?= app_url('../portal.html') ?>" class="btn btn-ghost btn-sm" title="Kembali ke Portal WebGIS">
<i class="fas fa-home"></i> Portal
</a>
</div> </div>
</div> </div>
<div class="admin-content"> <div class="admin-content">
+4 -4
View File
@@ -33,7 +33,7 @@ require_once __DIR__ . '/partials/header.php';
<div class="layer-section-title">Infrastruktur</div> <div class="layer-section-title">Infrastruktur</div>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-spbu" checked> <input type="checkbox" id="ly-spbu">
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span> <span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">SPBU</span> <span class="layer-name">SPBU</span>
@@ -42,7 +42,7 @@ require_once __DIR__ . '/partials/header.php';
</label> </label>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-jalan" checked> <input type="checkbox" id="ly-jalan">
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span> <span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">Jalan</span> <span class="layer-name">Jalan</span>
@@ -51,7 +51,7 @@ require_once __DIR__ . '/partials/header.php';
</label> </label>
<label class="layer-item"> <label class="layer-item">
<input type="checkbox" id="ly-kavling" checked> <input type="checkbox" id="ly-kavling">
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span> <span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text"> <span class="layer-text">
<span class="layer-name">Kavling / Parsil</span> <span class="layer-name">Kavling / Parsil</span>
@@ -156,6 +156,6 @@ require_once __DIR__ . '/partials/header.php';
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="<?= app_url('public_assets/js/peta-user.js') ?>?v=20260612-fix"></script> <script src="<?= app_url('public_assets/js/peta-user.js') ?>?v=<?= time() ?>"></script>
<?php $extraScript = ''; ?> <?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?> <?php require_once __DIR__ . '/partials/footer.php'; ?>
+63 -25
View File
@@ -1,22 +1,24 @@
/* ========================================================= /* =========================================================
WEBGIS PROJECT FINAL — Design System (main.css) WEBGIS PROJECT FINAL — Design System (global.css)
========================================================= */ ========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root { /* ── Light Theme (default) ── */
/* Colors - Clean Light Theme */ :root, [data-theme="light"] {
--primary: #2563EB; /* Professional Blue */ /* Colors */
--primary: #2563EB;
--primary-light: #3B82F6; --primary-light: #3B82F6;
--primary-dark: #1D4ED8; --primary-dark: #1D4ED8;
--primary-glow: rgba(37, 99, 235, 0.08);
--accent: #10B981; --accent: #10B981;
--danger: #EF4444; --danger: #EF4444;
--warning: #F59E0B; --warning: #F59E0B;
--info: #0EA5E9; --info: #0EA5E9;
/* Backgrounds */ /* Backgrounds */
--bg-base: #F9FAFB; /* Very light gray */ --bg-base: #F9FAFB;
--bg-surface: #FFFFFF; /* Pure white */ --bg-surface: #FFFFFF;
--bg-elevated: #FFFFFF; --bg-elevated: #FFFFFF;
--bg-card: #FFFFFF; --bg-card: #FFFFFF;
@@ -25,9 +27,9 @@
--border-hover: #D1D5DB; --border-hover: #D1D5DB;
/* Text */ /* Text */
--text-primary: #111827; /* Near black */ --text-primary: #111827;
--text-secondary: #4B5563; /* Dark gray */ --text-secondary: #4B5563;
--text-muted: #6B7280; /* Medium gray */ --text-muted: #6B7280;
/* Sidebar */ /* Sidebar */
--sidebar-w: 260px; --sidebar-w: 260px;
@@ -42,7 +44,34 @@
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition: all 0.2s ease-in-out; --transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Dark Theme ── */
[data-theme="dark"] {
--primary: #818CF8;
--primary-light: #A5B4FC;
--primary-dark: #6366F1;
--primary-glow: rgba(129, 140, 248, 0.15);
--accent: #34D399;
--danger: #F87171;
--warning: #FCD34D;
--info: #38BDF8;
--bg-base: #0F172A;
--bg-surface: #1E293B;
--bg-elevated: #293548;
--bg-card: #1E293B;
--border-light: rgba(255,255,255,0.08);
--border-hover: rgba(129,140,248,0.45);
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--sidebar-bg: #0F172A;
--sidebar-border: rgba(255,255,255,0.07);
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -57,6 +86,7 @@ body {
line-height: 1.6; line-height: 1.6;
min-height: 100vh; min-height: 100vh;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
transition: background 0.25s ease, color 0.25s ease;
} }
a { color: var(--primary); text-decoration: none; transition: var(--transition); } a { color: var(--primary); text-decoration: none; transition: var(--transition); }
@@ -67,6 +97,7 @@ a:hover { color: var(--primary-dark); }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; } ::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; }
/* ── Buttons ── */ /* ── Buttons ── */
.btn { .btn {
@@ -83,7 +114,7 @@ a:hover { color: var(--primary-dark); }
.btn-danger { background: var(--danger); color: #fff; } .btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.95); } .btn-danger:hover { filter: brightness(0.95); }
.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); } .btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); }
.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); } .btn-ghost:hover { background: var(--bg-elevated); color: var(--text-primary); border-color: var(--border-hover); }
.btn-sm { padding: 6px 12px; font-size: 0.78rem; } .btn-sm { padding: 6px 12px; font-size: 0.78rem; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); } .btn-icon { padding: 8px; border-radius: var(--radius-sm); }
@@ -94,6 +125,7 @@ a:hover { color: var(--primary-dark); }
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 24px; padding: 24px;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: background 0.25s ease, border-color 0.25s ease;
} }
/* ── Form Elements ── */ /* ── Form Elements ── */
@@ -101,18 +133,18 @@ a:hover { color: var(--primary-dark); }
.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } .form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.form-control { .form-control {
width: 100%; padding: 10px 14px; border-radius: var(--radius-md); width: 100%; padding: 10px 14px; border-radius: var(--radius-md);
background: #FFFFFF; border: 1px solid var(--border-light); background: var(--bg-surface); border: 1px solid var(--border-light);
color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif; color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif;
outline: none; transition: var(--transition); box-shadow: var(--shadow-sm); outline: none; transition: var(--transition); box-shadow: var(--shadow-sm);
} }
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } .form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.form-control::placeholder { color: #9CA3AF; } .form-control::placeholder { color: var(--text-muted); }
select.form-control { select.form-control {
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 12px center; background-position: right 12px center;
padding-right: 36px; padding-right: 36px;
} }
textarea.form-control { resize: vertical; min-height: 80px; } textarea.form-control { resize: vertical; min-height: 80px; }
@@ -124,20 +156,26 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; } .badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; }
.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; } .badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; }
[data-theme="dark"] .badge-primary { background: rgba(129,140,248,0.15); color: #A5B4FC; border-color: rgba(129,140,248,0.3); }
[data-theme="dark"] .badge-success { background: rgba(52,211,153,0.15); color: #6EE7B7; border-color: rgba(52,211,153,0.3); }
[data-theme="dark"] .badge-danger { background: rgba(248,113,113,0.15); color: #FCA5A5; border-color: rgba(248,113,113,0.3); }
[data-theme="dark"] .badge-warning { background: rgba(252,211,77,0.15); color: #FDE68A; border-color: rgba(252,211,77,0.3); }
[data-theme="dark"] .badge-info { background: rgba(56,189,248,0.15); color: #7DD3FC; border-color: rgba(56,189,248,0.3); }
/* ── Tables ── */ /* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); } .table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); } .data-table thead th { background: var(--bg-elevated); padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); }
.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; } .data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; }
.data-table tbody tr:last-child { border-bottom: none; } .data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: #F3F4F6; } .data-table tbody tr:hover { background: var(--bg-elevated); }
.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; } .data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; }
.data-table .actions { display: flex; gap: 6px; } .data-table .actions { display: flex; gap: 6px; }
/* ── Modal ── */ /* ── Modal ── */
.modal-overlay { .modal-overlay {
display: none; position: fixed; inset: 0; z-index: 9999; display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px); background: rgba(17, 24, 39, 0.5); backdrop-filter: blur(4px);
align-items: center; justify-content: center; align-items: center; justify-content: center;
} }
.modal-overlay.active { display: flex; } .modal-overlay.active { display: flex; }
@@ -152,7 +190,7 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); } .modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; } .modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; }
.modal-close:hover { background: #F3F4F6; color: var(--text-primary); } .modal-close:hover { background: var(--bg-elevated); color: var(--text-primary); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); } .modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); }
/* ── Toast ── */ /* ── Toast ── */
@@ -176,7 +214,7 @@ textarea.form-control { resize: vertical; min-height: 80px; }
width: min(360px, calc(100vw - 32px)); width: min(360px, calc(100vw - 32px));
padding: 13px 14px; padding: 13px 14px;
color: var(--text-primary); color: var(--text-primary);
background: #FFFFFF; background: var(--bg-surface);
border: 1px solid #A7F3D0; border: 1px solid #A7F3D0;
border-left: 4px solid var(--accent); border-left: 4px solid var(--accent);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -229,7 +267,7 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; } .page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; } .page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; }
.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; } .page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; }
.page-header .breadcrumb .sep { color: #D1D5DB; } .page-header .breadcrumb .sep { color: var(--border-hover); }
/* ── Utilities ── */ /* ── Utilities ── */
.text-muted { color: var(--text-muted); } .text-muted { color: var(--text-muted); }
+117
View File
@@ -763,3 +763,120 @@
bottom: 66px; bottom: 66px;
} }
} }
/* ══════════════════════════════
DARK THEME OVERRIDES FOR MAPS
══════════════════════════════ */
[data-theme="dark"] {
.map-wrap,
#admin-map,
#user-map,
.leaflet-container {
background: #0f172a;
}
.leaflet-control-attribution {
background: rgba(15, 23, 42, 0.9) !important;
border-color: var(--border-light);
}
.leaflet-control-zoom a,
.leaflet-draw-toolbar a {
background: var(--bg-surface) !important;
color: var(--text-primary) !important;
border-color: var(--border-light) !important;
}
.leaflet-control-zoom a:hover,
.leaflet-draw-toolbar a:hover {
background: var(--bg-elevated) !important;
color: var(--primary-light) !important;
}
.map-sidebar {
background: rgba(30, 41, 59, 0.95);
border-color: var(--border-light);
}
.map-panel-icon {
background: rgba(99, 102, 241, 0.15);
color: var(--primary-light);
border-color: rgba(99, 102, 241, 0.3);
}
.layer-item:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--border-light);
}
.qs-item {
background: var(--bg-base);
border-color: var(--border-light);
}
.map-analysis {
background: rgba(30, 41, 59, 0.95);
border-color: var(--border-light);
}
.map-analysis button {
background: var(--bg-surface);
color: var(--text-secondary);
border-color: var(--border-light);
}
.map-analysis button:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.map-analysis button.active {
color: #FFFFFF;
background: var(--primary-dark);
border-color: var(--primary-dark);
}
.spbu-panel {
background: rgba(30, 41, 59, 0.95);
border-color: var(--border-light);
}
.spbu-result {
background: var(--bg-base);
border-color: var(--border-light);
}
.spbu-result-distance {
color: var(--primary-light);
}
.leaflet-popup-content-wrapper {
background: var(--bg-surface) !important;
border-color: var(--border-light);
}
.leaflet-popup-tip {
background: var(--bg-surface) !important;
}
.map-legend {
background: rgba(30, 41, 59, 0.95);
border-color: var(--border-light);
}
.legend-area {
background: rgba(255, 255, 255, 0.05);
}
.map-loading {
color: var(--text-primary);
background: rgba(15, 23, 42, 0.85);
}
.read-only-badge {
color: #6EE7B7;
background: rgba(52, 211, 153, 0.15);
border-color: rgba(52, 211, 153, 0.3);
}
}
+52
View File
@@ -362,3 +362,55 @@ body.admin-page {
.admin-main { margin-left: 0; } .admin-main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr; }
} }
/* ══════════════════════════════
DARK THEME OVERRIDES FOR PANEL
══════════════════════════════ */
[data-theme="dark"] {
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary-glow);
color: var(--primary-light);
}
.sidebar-footer {
background: #090d16;
}
.user-info {
background: #111827;
}
.user-avatar {
background: var(--primary-glow);
color: var(--primary-light);
}
.topbar-time {
background: var(--bg-elevated);
border-color: var(--border-light);
}
.search-box input {
background: var(--bg-surface);
border-color: var(--border-light);
}
.map-sidebar-floating {
background: var(--bg-surface);
border-color: var(--border-light);
}
/* Stat card styles for dark theme */
.stat-card.primary .stat-icon { background: rgba(37, 99, 235, 0.15); color: #818CF8; }
.stat-card.success .stat-icon { background: rgba(16, 185, 129, 0.15); color: #34D399; }
.stat-card.danger .stat-icon { background: rgba(239, 68, 68, 0.15); color: #F87171; }
.stat-card.warning .stat-icon { background: rgba(245, 158, 11, 0.15); color: #FCD34D; }
.stat-card.info .stat-icon { background: rgba(14, 165, 233, 0.15); color: #38BDF8; }
.stat-card.violet .stat-icon { background: rgba(124, 58, 237, 0.15); color: #A78BFA; }
}
+36 -15
View File
@@ -87,11 +87,26 @@ function initMap() {
L.control.zoom({ position: 'bottomright' }).addTo(state.map); L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
const tileLayer = L.tileLayer(tileUrl, {
attribution: '© OpenStreetMap, © CARTO', attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20 maxZoom: 20
}).addTo(state.map); }).addTo(state.map);
// Dynamic theme updater for tile layer
const observer = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
tileLayer.setUrl(dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
);
});
observer.observe(document.documentElement, { attributes: true });
} }
/* ── Fetch GeoJSON ── */ /* ── Fetch GeoJSON ── */
@@ -454,22 +469,28 @@ async function findNearestSpbu() {
try { try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`); const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json(); const j = await r.json();
if (j.status === 'success' && j.data) { if (j.status === 'success' && j.data && j.data.features && j.data.features.length > 0) {
const p = j.data.properties; const feature = j.data.features[0];
const coords = j.data.geometry.coordinates; if (feature && feature.geometry && feature.geometry.coordinates) {
const html = `<div class="spbu-result"> const p = feature.properties;
<div class="spbu-result-name">⛽ ${p.nama}</div> const coords = feature.geometry.coordinates;
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div> const jarakVal = p.jarak_km !== undefined ? p.jarak_km : (p.jarak_meter / 1000).toFixed(2);
<div class="spbu-result-distance">${p.jarak_km} km</div> const html = `<div class="spbu-result">
</div>`; <div class="spbu-result-name">⛽ ${p.nama}</div>
document.getElementById('spbuPanelResult').innerHTML = html; <div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
<div class="spbu-result-distance">${jarakVal} km</div>
</div>`;
document.getElementById('spbuPanelResult').innerHTML = html;
// Show marker // Show marker
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute); if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map) state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: p.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup(); .bindPopup(`<b>${p.nama}</b><br>${jarakVal} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Data geometri SPBU terdekat tidak valid.</div>`;
}
} else { } else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`; document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU dalam jangkauan'}</div>`;
} }
} catch (e) { } catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`; document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;
+34 -13
View File
@@ -81,11 +81,26 @@ function initMap() {
L.control.zoom({ position: 'bottomright' }).addTo(state.map); L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
const tileLayer = L.tileLayer(tileUrl, {
attribution: '© OpenStreetMap, © CARTO', attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20 maxZoom: 20
}).addTo(state.map); }).addTo(state.map);
// Dynamic theme updater for tile layer
const observer = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
tileLayer.setUrl(dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
);
});
observer.observe(document.documentElement, { attributes: true });
} }
async function fetchLayer(path) { async function fetchLayer(path) {
@@ -320,19 +335,25 @@ async function findNearestSpbu() {
try { try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`); const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json(); const j = await r.json();
if (j.status === 'success' && j.data) { if (j.status === 'success' && j.data && j.data.features && j.data.features.length > 0) {
const p = j.data.properties; const feature = j.data.features[0];
const coords = j.data.geometry.coordinates; if (feature && feature.geometry && feature.geometry.coordinates) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result"> const p = feature.properties;
<div class="spbu-result-name">⛽ ${p.nama}</div> const coords = feature.geometry.coordinates;
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div> const jarakVal = p.jarak_km !== undefined ? p.jarak_km : (p.jarak_meter / 1000).toFixed(2);
<div class="spbu-result-distance">${p.jarak_km} km</div> document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">
</div>`; <div class="spbu-result-name">⛽ ${p.nama}</div>
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute); <div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map) <div class="spbu-result-distance">${jarakVal} km</div>
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup(); </div>`;
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: p.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${jarakVal} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Data geometri SPBU terdekat tidak valid.</div>`;
}
} else { } else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`; document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU dalam jangkauan'}</div>`;
} }
} catch (e) { } catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`; document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;