Compare commits

..

12 Commits

164 changed files with 13006 additions and 19 deletions
+22
View File
@@ -0,0 +1,22 @@
.git/
.gitignore
.env
.env.*
!.env.example
MANUAL_BOOK.docx
PRESENTASI.pptx
NEXT_WORK_SUMMARY.md
PROJECT_PROGRESS_SUMMARY.md
node_modules/
vendor/
uploads/
project_final/uploads/
*.log
*.tmp
database/backups/
database/*backup*.sql
database/*_backup.sql
*.sql.gz
*.dump
+9
View File
@@ -0,0 +1,9 @@
APP_BASE_PATH=/project_final
SESSION_SECURE=true
CORS_ALLOWED_ORIGIN=
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=webgis_db
DB_USERNAME=webgis_user
DB_PASSWORD=change_me
+55
View File
@@ -0,0 +1,55 @@
# OS / editor
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
# Local deliverables / work notes
MANUAL_BOOK.docx
PRESENTASI.pptx
NEXT_WORK_SUMMARY.md
PROJECT_PROGRESS_SUMMARY.md
# Environment / local config
.env
.env.*
!.env.example
project_final/config/db.local.php
project_final/config/*.local.php
# PHP / Composer
vendor/
# Node / frontend packages
node_modules/
# Logs / cache / temp
*.log
logs/
cache/
tmp/
temp/
*.tmp
*.bak
*.swp
# Runtime uploads
uploads/*
project_final/uploads/*
!uploads/.gitkeep
!project_final/uploads/.gitkeep
# Generated backups / exports
database/backups/
database/*backup*.sql
database/*_backup.sql
*.sql.gz
*.dump
# Test / build output
coverage/
dist/
build/
akun_testing.txt
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'jenis_jalan' => $row['jenis_jalan'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Jalan wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Jalan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
sendError('Nama pemilik dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_pemilik' => $input['nama_pemilik'],
':luas' => $input['luas'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Kavling wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kavling berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool)$row['buka_24_jam'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':deskripsi' => $input['deskripsi'] ?? '',
':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Singleton PDO connection.
*
* Coolify: set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, and DB_PASSWORD
* from the database service credentials.
*/
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
$dbUrl = self::env('DATABASE_URL');
if ($dbUrl) {
$parsed = parse_url($dbUrl);
$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? '3306';
$username = $parsed['user'] ?? 'root';
$password = $parsed['pass'] ?? '';
$dbName = ltrim($parsed['path'], '/');
} else {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
}
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
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
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../../database/webgis_db.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
self::$conn->exec($sql);
}
}
}
return self::$conn;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* response_helper.php
* Tanggung Jawab: Standarisasi format response JSON untuk semua API endpoint.
* Mencegah inkonsistensi struktur data yang dikembalikan ke frontend.
*/
header('Content-Type: application/json; charset=utf-8');
// Izinkan CORS (opsional, jika frontend dan backend di port yang beda)
$allowedOrigin = getenv('CORS_ALLOWED_ORIGIN');
if ($allowedOrigin !== false && $allowedOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit(0);
}
/**
* Mengirim response sukses
* @param mixed $data Data yang ingin dikirim
* @param string $message Pesan sukses (opsional)
* @param int $statusCode HTTP Status Code (default: 200)
*/
function sendSuccess($data = null, $message = 'Success', $statusCode = 200) {
http_response_code($statusCode);
echo json_encode([
'status' => 'success',
'message' => $message,
'data' => $data
]);
exit();
}
/**
* Mengirim response error
* @param string $message Pesan error
* @param int $statusCode HTTP Status Code (default: 400)
*/
function sendError($message = 'Error', $statusCode = 400) {
http_response_code($statusCode);
echo json_encode([
'status' => 'error',
'message' => $message
]);
exit();
}
?>
+61
View File
@@ -0,0 +1,61 @@
/* base.css
* Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
/* Color Palette - Minimalist & Elegant */
--primary: #4F46E5;
--primary-hover: #4338CA;
--secondary: #10B981;
--background: #F3F4F6;
--surface: #FFFFFF;
--surface-glass: rgba(255, 255, 255, 0.85);
--text-main: #111827;
--text-muted: #6B7280;
--border: #E5E7EB;
--danger: #EF4444;
/* Shadows & Effects */
--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);
--radius-md: 12px;
--radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
}
h1, h2, h3, h4 {
font-weight: 600;
color: var(--text-main);
}
/* Scrollbar minimalis */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
+92
View File
@@ -0,0 +1,92 @@
/* form.css
* Tanggung Jawab: Styling komponen form (input, button, select).
*/
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-main);
}
.form-control {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.95rem;
transition: var(--transition);
background: var(--surface);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
font-family: inherit;
font-weight: 600;
font-size: 0.9rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
gap: 8px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--danger);
color: white;
width: auto;
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-danger:hover {
background: #DC2626;
}
/* Micro-animation for active state */
.btn:active {
transform: translateY(1px);
}
/* Form Container Animation */
#form-container {
background: var(--surface);
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
margin-bottom: 20px;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
+205
View File
@@ -0,0 +1,205 @@
:root {
--primary: #6366F1;
--primary-hover: #4F46E5;
--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;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
background: var(--bg-dark);
color: var(--text-light);
overflow: hidden;
}
#map {
width: 100vw;
height: 100vh;
z-index: 1;
}
/* Floating Dock */
.floating-dock {
position: absolute;
top: 24px;
left: 24px;
z-index: 1000;
background: var(--bg-glass);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass);
border-radius: var(--radius);
padding: 24px;
width: 340px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
/* Custom Scrollbar for Dock */
.floating-dock::-webkit-scrollbar { width: 6px; }
.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; }
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
/* Buttons */
.menu-group { display: flex; flex-direction: column; gap: 10px; }
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
.btn-action {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
color: var(--text-light);
padding: 14px 16px;
border-radius: 12px;
cursor: pointer;
font-weight: 500;
font-family: var(--font);
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
text-align: left;
}
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
.btn-action:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.btn-action.active {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
}
/* Button Variants */
.btn-spbu svg { color: #10B981; }
.btn-jalan svg { color: #F59E0B; }
.btn-kavling svg { color: #3B82F6; }
.btn-rumah svg { color: #8B5CF6; }
.btn-warga svg { color: #EF4444; }
/* Form Panel */
.form-panel {
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border-glass);
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 0.8rem; color: var(--text-muted); }
.form-control {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
padding: 10px 12px;
border-radius: 8px;
color: white;
font-family: var(--font);
font-size: 0.9rem;
outline: none;
transition: border 0.2s;
}
.form-control:focus { border-color: var(--primary); }
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
.btn-submit {
background: var(--primary);
color: white;
border: none;
padding: 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: var(--primary-hover); }
/* Leaflet Customizations */
.leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a {
background: var(--bg-glass) !important;
color: var(--text-light) !important;
border-color: var(--border-glass) !important;
backdrop-filter: blur(10px);
}
.leaflet-control-zoom a:hover { background: var(--primary) !important; }
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper {
background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important;
border-radius: 16px !important;
box-shadow: var(--shadow) !important;
}
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
.leaflet-popup-content { margin: 16px !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 p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
margin-top: 12px;
width: 100%;
transition: all 0.2s;
}
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */
.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 {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+73
View File
@@ -0,0 +1,73 @@
/* map.css
* Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
*/
.map-container {
flex: 1;
position: relative;
background: #E2E8F0;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* Customizing Leaflet Controls */
.leaflet-control-zoom {
border: none !important;
box-shadow: var(--shadow-md) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a {
color: var(--text-main) !important;
background: var(--surface-glass) !important;
backdrop-filter: blur(8px);
transition: var(--transition);
}
.leaflet-control-zoom a:hover {
background: var(--surface) !important;
color: var(--primary) !important;
}
/* Sembunyikan Toolbar Default Leaflet Draw karena kita pakai Menu Kiri */
.leaflet-draw-toolbar {
display: none !important;
}
/* Custom Popup Modern */
.leaflet-popup-content-wrapper {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 4px;
}
.leaflet-popup-content {
margin: 14px;
font-family: 'Inter', sans-serif;
}
.popup-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 6px;
border-bottom: 2px solid var(--primary);
padding-bottom: 4px;
display: inline-block;
}
.popup-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.popup-action {
display: flex;
justify-content: flex-end;
}
+78
View File
@@ -0,0 +1,78 @@
/* sidebar.css
* Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
*/
.sidebar {
width: 350px;
background: var(--surface-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 1000;
box-shadow: var(--shadow-lg);
transition: var(--transition);
}
.sidebar-header {
padding: 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-title {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.sidebar-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Toast Notification */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--surface);
color: var(--text-main);
padding: 12px 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.9rem;
font-weight: 500;
transform: translateY(20px);
opacity: 0;
animation: slideIn 0.3s forwards ease-out;
border-left: 4px solid var(--primary);
}
.toast.error {
border-left-color: var(--danger);
}
@keyframes slideIn {
to {
transform: translateY(0);
opacity: 1;
}
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 01</title>
<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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<!-- Peta -->
<div id="map"></div>
<!-- Floating Dock -->
<div class="floating-dock">
<div class="header">
<h2>P01: Geometri</h2>
<p>Sistem Informasi Geografis dengan arsitektur modern.</p>
</div>
<div class="menu-group">
<div class="menu-title">Alat Spasial</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-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>
<!-- Dynamic Form Container -->
<div id="form-container" style="display: none;"></div>
</div>
<!-- Toast Notification Container -->
<div id="toast-container" class="toast-container"></div>
<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 type="module" src="js/app.js"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
import { initMap } from './modules/map.js';
import { setupDrawControls } from './modules/draw.js';
import { renderForm } from './modules/form.js';
import { spbuService, jalanService, kavlingService } from './services/api.service.js';
let appMap;
let drawControl;
// Custom SVG Icons Generator
export const createIcon = (svgPath, color) => {
return L.divIcon({
className: 'custom-icon',
html: `<div style="background-color: ${color}; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); color: white;">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">${svgPath}</svg>
</div>`,
iconSize: [34, 34],
iconAnchor: [17, 34],
popupAnchor: [0, -34]
});
};
export const iconSPBU = (is24h) => createIcon('<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>', is24h ? '#10B981' : '#EF4444');
window.showToast = (msg, type='success') => {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
};
document.addEventListener('DOMContentLoaded', async () => {
appMap = initMap('map');
drawControl = setupDrawControls(appMap, handleGeometryCreated);
await loadAllData();
});
const loadAllData = async () => {
try {
const spbu = await spbuService.getAll();
L.geoJSON(spbu, {
pointToLayer: (f, latlng) => L.marker(latlng, { icon: iconSPBU(f.properties.buka_24_jam) }),
onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties)
}).addTo(appMap);
const jalan = await jalanService.getAll();
L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).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) {
window.showToast("Gagal meload data: "+e.message, 'error');
}
};
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>` : '';
layer.bindPopup(`
<div class="popup-custom">
<h3>${props.nama}</h3>
<p>${props.deskripsi || ''}</p>
${ext}
<button class="btn-delete" onclick="window.deleteData('${type}', ${props.id})">Hapus Data</button>
</div>
`);
};
window.deleteData = async (type, id) => {
if(!confirm('Yakin ingin menghapus?')) return;
try {
if(type==='spbu') await spbuService.delete(id);
if(type==='jalan') await jalanService.delete(id);
if(type==='kavling') await kavlingService.delete(id);
window.showToast('Data dihapus');
setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal hapus', 'error'); }
};
const handleGeometryCreated = (type, geometry, layer) => {
const tempLayer = L.geoJSON(geometry).addTo(appMap);
renderForm(type, geometry, async (payload) => {
try {
if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload);
window.showToast('Berhasil disimpan');
setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => {
appMap.removeLayer(tempLayer);
});
};
+15
View File
@@ -0,0 +1,15 @@
/**
* config.js
* Tanggung Jawab: Menyimpan konstanta dan konfigurasi global aplikasi.
*/
export const CONFIG = {
BASE_URL: '/01/backend/api',
// Konstanta tipe fitur
FEATURE_TYPES: {
SPBU: 'Point',
JALAN: 'LineString',
KAVLING: 'Polygon'
}
};
+31
View File
@@ -0,0 +1,31 @@
export const setupDrawControls = (map, onGeometryCreated) => {
const drawControl = new L.Control.Draw({
draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
const type = event.layerType;
// Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
let customType = type;
if(type === 'marker') customType = 'spbu';
if(type === 'polyline') customType = 'jalan';
if(type === 'polygon') customType = 'kavling';
if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
});
// Custom Menu Handlers
const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
return drawControl;
};
+50
View File
@@ -0,0 +1,50 @@
export const renderForm = (type, geometry, onSubmit, onCancel) => {
const container = document.getElementById('form-container');
container.style.display = 'flex';
const extraFields = type === 'spbu' ? `
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="input-24jam">
<span>Buka 24 Jam</span>
</label>
</div>
` : '';
container.innerHTML = `
<div class="form-panel">
<h3 style="margin:0 0 10px 0; font-size:1rem; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px;">Simpan Data (${type.toUpperCase()})</h3>
<div class="form-group">
<input type="text" id="input-nama" class="form-control" placeholder="Nama / Label" required>
</div>
<div class="form-group">
<input type="text" id="input-deskripsi" class="form-control" placeholder="Deskripsi Singkat">
</div>
${extraFields}
<div style="display:flex; gap:10px; margin-top:8px;">
<button id="btn-save" class="btn-submit" style="flex:1;">Simpan</button>
<button id="btn-cancel" class="btn-submit" style="background:rgba(255,255,255,0.1); color:var(--text-light); flex:1;">Batal</button>
</div>
</div>
`;
document.getElementById('btn-save').addEventListener('click', () => {
const payload = {
type,
geometry,
nama: document.getElementById('input-nama').value,
deskripsi: document.getElementById('input-deskripsi').value,
};
if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
onSubmit(payload);
container.style.display = 'none';
container.innerHTML = '';
});
document.getElementById('btn-cancel').addEventListener('click', () => {
if(onCancel) onCancel();
container.style.display = 'none';
container.innerHTML = '';
});
};
+25
View File
@@ -0,0 +1,25 @@
/**
* map.js
* Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
*/
export const initMap = (containerId) => {
// Pusat peta default (Universitas Tanjungpura, Pontianak)
const map = L.map(containerId, {
zoomControl: false
}).setView([-0.0583, 109.3448], 15);
// Pindahkan zoom control ke kanan bawah
L.control.zoom({
position: 'bottomright'
}).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
return map;
};
+49
View File
@@ -0,0 +1,49 @@
const getBaseUrl = () => {
const segments = window.location.pathname.split('/');
const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg));
if (targetIndex !== -1) {
return segments.slice(0, targetIndex).join('/');
}
return '';
};
export const BASE_URL = getBaseUrl();
const createService = (endpoint) => ({
getAll: async () => {
const res = await fetch(`${BASE_URL}/${endpoint}`);
const json = await res.json();
return json.data;
},
create: async (data) => {
const res = await fetch(`${BASE_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
return json.data;
},
delete: async (id) => {
const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
method: 'DELETE'
});
const json = await res.json();
return json.data;
}
});
// P01
export const spbuService = createService('01/backend/api/spbu.php');
export const jalanService = createService('01/backend/api/jalan.php');
export const kavlingService = createService('01/backend/api/kavling.php');
// P02
export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = {
getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
const json = await res.json();
return json.data;
}
};
+28
View File
@@ -0,0 +1,28 @@
/**
* jalan.service.js
* Tanggung Jawab: Komunikasi HTTP ke endpoint API jalan.php
*/
import { CONFIG } from '../config.js';
export const jalanService = {
getAll: async () => {
const response = await fetch(`${CONFIG.BASE_URL}/jalan.php`);
return await response.json();
},
save: async (data) => {
const response = await fetch(`${CONFIG.BASE_URL}/jalan.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return await response.json();
},
delete: async (id) => {
const response = await fetch(`${CONFIG.BASE_URL}/jalan.php?id=${id}`, {
method: 'DELETE'
});
return await response.json();
}
};
@@ -0,0 +1,28 @@
/**
* kavling.service.js
* Tanggung Jawab: Komunikasi HTTP ke endpoint API kavling.php
*/
import { CONFIG } from '../config.js';
export const kavlingService = {
getAll: async () => {
const response = await fetch(`${CONFIG.BASE_URL}/kavling.php`);
return await response.json();
},
save: async (data) => {
const response = await fetch(`${CONFIG.BASE_URL}/kavling.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return await response.json();
},
delete: async (id) => {
const response = await fetch(`${CONFIG.BASE_URL}/kavling.php?id=${id}`, {
method: 'DELETE'
});
return await response.json();
}
};
+28
View File
@@ -0,0 +1,28 @@
/**
* spbu.service.js
* Tanggung Jawab: Komunikasi HTTP ke endpoint API spbu.php
*/
import { CONFIG } from '../config.js';
export const spbuService = {
getAll: async () => {
const response = await fetch(`${CONFIG.BASE_URL}/spbu.php`);
return await response.json();
},
save: async (data) => {
const response = await fetch(`${CONFIG.BASE_URL}/spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return await response.json();
},
delete: async (id) => {
const response = await fetch(`${CONFIG.BASE_URL}/spbu.php?id=${id}`, {
method: 'DELETE'
});
return await response.json();
}
};
+34
View File
@@ -0,0 +1,34 @@
/**
* style.util.js
* Tanggung Jawab: Mendefinisikan style untuk GeoJSON berdasarkan properti.
*/
export const getGeoJsonStyle = (feature) => {
// Style default untuk Kavling (Polygon)
if (feature.geometry.type === 'Polygon') {
return {
color: '#10B981', // Secondary color
weight: 2,
opacity: 0.8,
fillColor: '#34D399',
fillOpacity: 0.4
};
}
// Style default untuk Jalan (LineString)
if (feature.geometry.type === 'LineString') {
const type = feature.properties.jenis_jalan;
let color = '#4F46E5'; // Primary color
if (type === 'Arteri') color = '#EF4444'; // Danger color
else if (type === 'Kolektor') color = '#F59E0B'; // Warning color
return {
color: color,
weight: 4,
opacity: 0.9
};
}
return {};
};
+32
View File
@@ -0,0 +1,32 @@
/**
* ui.util.js
* Tanggung Jawab: Manipulasi DOM umum seperti Toast notification.
*/
export const showToast = (message, type = 'success') => {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
// Hapus toast setelah 3 detik
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
};
export const clearFormContainer = () => {
const container = document.getElementById('form-container');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
};
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'jenis_jalan' => $row['jenis_jalan'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Jalan wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Jalan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
sendError('Nama pemilik dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_pemilik' => $input['nama_pemilik'],
':luas' => $input['luas'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Kavling wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kavling berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
require_once '../utils/geo_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
if (isset($_GET['action']) && $_GET['action'] === 'jangkauan' && isset($_GET['id'])) {
// Analisis Jangkauan Haversine
$id = $_GET['id'];
$radius = isset($_GET['radius']) ? (float) $_GET['radius'] : 1.0; // default 1 km
// Dapatkan koordinat rumah ibadah ini
$stmt = $pdo->prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
$rumah_ibadah = $stmt->fetch();
if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
$geom = json_decode($rumah_ibadah['geojson'], true);
$pusatLon = $geom['coordinates'][0];
$pusatLat = $geom['coordinates'][1];
// Panggil geo_helper untuk mendapatkan warga miskin
$warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
sendSuccess($warga, 'Data jangkauan berhasil dihitung');
} else {
// GET Semua Rumah Ibadah (GeoJSON)
try {
$stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'agama' => $row['agama']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':agama' => $input['agama'] ?? 'Islam',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool)$row['buka_24_jam'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':deskripsi' => $input['deskripsi'] ?? '',
':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_kk' => $row['nama_kk'],
'penghasilan' => $row['penghasilan'],
'jumlah_tanggungan' => $row['jumlah_tanggungan']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_kk' => $input['nama_kk'],
':penghasilan' => $input['penghasilan'] ?? 0,
':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Singleton PDO connection.
*
* Coolify: set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, and DB_PASSWORD
* from the database service credentials.
*/
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
$dbUrl = self::env('DATABASE_URL');
if ($dbUrl) {
$parsed = parse_url($dbUrl);
$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? '3306';
$username = $parsed['user'] ?? 'root';
$password = $parsed['pass'] ?? '';
$dbName = ltrim($parsed['path'], '/');
} else {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
}
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
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
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../../database/webgis_db.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
self::$conn->exec($sql);
}
}
}
return self::$conn;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* geo_helper.php
* Tanggung Jawab: Menyediakan fungsi matematika spasial manual (seperti Haversine).
*/
class GeoHelper {
/**
* Menghitung jarak antara dua koordinat geografis menggunakan Haversine Formula.
* Mengembalikan jarak dalam satuan Kilometer.
*/
public static function haversineDistance($lat1, $lon1, $lat2, $lon2) {
$earthRadius = 6371; // Jari-jari bumi dalam kilometer
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLon / 2) * sin($dLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
$distance = $earthRadius * $c;
return $distance;
}
/**
* Mencari Warga Miskin dalam radius tertentu dari suatu titik (Rumah Ibadah).
*/
public static function getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radiusKm = 1.0) {
// Ambil semua data warga miskin
$stmt = $pdo->query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
$wargaDalamRadius = [];
while ($row = $stmt->fetch()) {
$geom = json_decode($row['geojson'], true);
// GeoJSON Point format: [Longitude, Latitude]
$wargaLon = $geom['coordinates'][0];
$wargaLat = $geom['coordinates'][1];
// Hitung jarak dengan Haversine
$jarak = self::haversineDistance($pusatLat, $pusatLon, $wargaLat, $wargaLon);
if ($jarak <= $radiusKm) {
$wargaDalamRadius[] = [
'id' => $row['id'],
'nama_kk' => $row['nama_kk'],
'penghasilan' => $row['penghasilan'],
'jumlah_tanggungan' => $row['jumlah_tanggungan'],
'jarak_km' => round($jarak, 2),
'geometry' => $geom
];
}
}
// Urutkan berdasarkan jarak terdekat
usort($wargaDalamRadius, function($a, $b) {
return $a['jarak_km'] <=> $b['jarak_km'];
});
return $wargaDalamRadius;
}
}
?>
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* response_helper.php
* Tanggung Jawab: Standarisasi format response JSON.
*/
header('Content-Type: application/json; charset=utf-8');
$allowedOrigin = getenv('CORS_ALLOWED_ORIGIN');
if ($allowedOrigin !== false && $allowedOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
function sendSuccess($data = null, $message = 'Success', $statusCode = 200) {
http_response_code($statusCode);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $statusCode = 400) {
http_response_code($statusCode);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
?>
+61
View File
@@ -0,0 +1,61 @@
/* base.css
* Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
/* Color Palette - Minimalist & Elegant */
--primary: #4F46E5;
--primary-hover: #4338CA;
--secondary: #10B981;
--background: #F3F4F6;
--surface: #FFFFFF;
--surface-glass: rgba(255, 255, 255, 0.85);
--text-main: #111827;
--text-muted: #6B7280;
--border: #E5E7EB;
--danger: #EF4444;
/* Shadows & Effects */
--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);
--radius-md: 12px;
--radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
}
h1, h2, h3, h4 {
font-weight: 600;
color: var(--text-main);
}
/* Scrollbar minimalis */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
+92
View File
@@ -0,0 +1,92 @@
/* form.css
* Tanggung Jawab: Styling komponen form (input, button, select).
*/
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-main);
}
.form-control {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.95rem;
transition: var(--transition);
background: var(--surface);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
font-family: inherit;
font-weight: 600;
font-size: 0.9rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
gap: 8px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--danger);
color: white;
width: auto;
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-danger:hover {
background: #DC2626;
}
/* Micro-animation for active state */
.btn:active {
transform: translateY(1px);
}
/* Form Container Animation */
#form-container {
background: var(--surface);
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
margin-bottom: 20px;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
+205
View File
@@ -0,0 +1,205 @@
:root {
--primary: #6366F1;
--primary-hover: #4F46E5;
--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;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
background: var(--bg-dark);
color: var(--text-light);
overflow: hidden;
}
#map {
width: 100vw;
height: 100vh;
z-index: 1;
}
/* Floating Dock */
.floating-dock {
position: absolute;
top: 24px;
left: 24px;
z-index: 1000;
background: var(--bg-glass);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass);
border-radius: var(--radius);
padding: 24px;
width: 340px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
/* Custom Scrollbar for Dock */
.floating-dock::-webkit-scrollbar { width: 6px; }
.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; }
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
/* Buttons */
.menu-group { display: flex; flex-direction: column; gap: 10px; }
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
.btn-action {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
color: var(--text-light);
padding: 14px 16px;
border-radius: 12px;
cursor: pointer;
font-weight: 500;
font-family: var(--font);
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
text-align: left;
}
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
.btn-action:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.btn-action.active {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
}
/* Button Variants */
.btn-spbu svg { color: #10B981; }
.btn-jalan svg { color: #F59E0B; }
.btn-kavling svg { color: #3B82F6; }
.btn-rumah svg { color: #8B5CF6; }
.btn-warga svg { color: #EF4444; }
/* Form Panel */
.form-panel {
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border-glass);
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 0.8rem; color: var(--text-muted); }
.form-control {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
padding: 10px 12px;
border-radius: 8px;
color: white;
font-family: var(--font);
font-size: 0.9rem;
outline: none;
transition: border 0.2s;
}
.form-control:focus { border-color: var(--primary); }
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
.btn-submit {
background: var(--primary);
color: white;
border: none;
padding: 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: var(--primary-hover); }
/* Leaflet Customizations */
.leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a {
background: var(--bg-glass) !important;
color: var(--text-light) !important;
border-color: var(--border-glass) !important;
backdrop-filter: blur(10px);
}
.leaflet-control-zoom a:hover { background: var(--primary) !important; }
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper {
background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important;
border-radius: 16px !important;
box-shadow: var(--shadow) !important;
}
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
.leaflet-popup-content { margin: 16px !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 p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
margin-top: 12px;
width: 100%;
transition: all 0.2s;
}
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */
.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 {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+68
View File
@@ -0,0 +1,68 @@
/* map.css
* Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
*/
.map-container {
flex: 1;
position: relative;
background: #E2E8F0;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* Customizing Leaflet Controls */
.leaflet-control-zoom {
border: none !important;
box-shadow: var(--shadow-md) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a {
color: var(--text-main) !important;
background: var(--surface-glass) !important;
backdrop-filter: blur(8px);
transition: var(--transition);
}
.leaflet-control-zoom a:hover {
background: var(--surface) !important;
color: var(--primary) !important;
}
/* Custom Popup Modern */
.leaflet-popup-content-wrapper {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 4px;
}
.leaflet-popup-content {
margin: 14px;
font-family: 'Inter', sans-serif;
}
.popup-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 6px;
border-bottom: 2px solid var(--primary);
padding-bottom: 4px;
display: inline-block;
}
.popup-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.popup-action {
display: flex;
justify-content: flex-end;
}
+78
View File
@@ -0,0 +1,78 @@
/* sidebar.css
* Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
*/
.sidebar {
width: 350px;
background: var(--surface-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 1000;
box-shadow: var(--shadow-lg);
transition: var(--transition);
}
.sidebar-header {
padding: 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-title {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.sidebar-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Toast Notification */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--surface);
color: var(--text-main);
padding: 12px 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.9rem;
font-weight: 500;
transform: translateY(20px);
opacity: 0;
animation: slideIn 0.3s forwards ease-out;
border-left: 4px solid var(--primary);
}
.toast.error {
border-left-color: var(--danger);
}
@keyframes slideIn {
to {
transform: translateY(0);
opacity: 1;
}
}
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 02</title>
<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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="css/main.css">
<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; }
.badge { background: var(--primary); padding: 2px 6px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: white; }
</style>
</head>
<body>
<div id="map"></div>
<div class="floating-dock">
<div class="header">
<h2>P02: Tematik & Jarak</h2>
<p>Kumulatif P01 + Analisis radius jarak Haversine.</p>
</div>
<div class="menu-group">
<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-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 class="menu-group" style="margin-top: 5px;">
<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-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 id="instruction-panel" class="form-panel" style="margin-top: 5px;">
<h3 style="margin:0 0 5px 0; font-size:0.9rem; color:var(--text-main);">Analisis Jarak</h3>
<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>
<div id="radius-results" style="max-height:150px; overflow-y:auto; display:flex; flex-direction:column; gap:8px;"></div>
</div>
<div id="form-container" style="display: none;"></div>
</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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
import { initMap } from './modules/map.js';
import { setupDrawControls } from './modules/draw.js';
import { renderForm } from './modules/form.js';
import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, haversineService } from './services/api.service.js';
let appMap;
let drawControl;
let currentRadiusCircle = null;
let currentHighlightLayer = null;
export const createIcon = (svgPath, color) => {
return L.divIcon({
className: 'custom-icon',
html: `<div style="background-color: ${color}; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); color: white;">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">${svgPath}</svg>
</div>`,
iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
});
};
const iconSPBU = (is24h) => createIcon('<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>', is24h ? '#10B981' : '#EF4444');
const iconRumah = createIcon('<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>', '#8B5CF6');
const iconWarga = createIcon('<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>', '#EF4444');
window.showToast = (msg, type='success') => {
const t = document.createElement('div');
t.className = `toast ${type}`; t.innerHTML = msg;
document.getElementById('toast-container').appendChild(t);
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 () => {
try {
const [spbu, jalan, kavling, rumah, warga] = await Promise.all([
spbuService.getAll(), jalanService.getAll(), kavlingService.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);
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);
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);
} catch (e) {
window.showToast("Gagal meload data: "+e.message, 'error');
}
};
const bindPopup = (layer, type, props, feature=null) => {
let ext = '';
if(type==='spbu') ext = `<p>24 Jam: <strong style="color:${props.buka_24_jam?'#10B981':'#EF4444'}">${props.buka_24_jam?'Ya':'Tidak'}</strong></p>`;
if(type==='rumah_ibadah' && feature) ext = `<button class="btn-submit" style="width:100%; margin-top:8px;" onclick="window.checkRadius(${props.id}, ${feature.geometry.coordinates[1]}, ${feature.geometry.coordinates[0]})">Cek Radius 1km</button>`;
layer.bindPopup(`
<div class="popup-custom">
<h3>${props.nama}</h3>
<p>${props.deskripsi || ''}</p>
${ext}
<button class="btn-delete" onclick="window.deleteData('${type}', ${props.id})">Hapus</button>
</div>
`);
};
window.checkRadius = async (rumahId, lat, lng) => {
if (currentRadiusCircle) appMap.removeLayer(currentRadiusCircle);
if (currentHighlightLayer) appMap.removeLayer(currentHighlightLayer);
currentRadiusCircle = L.circle([lat, lng], { radius: 1000, color: '#8B5CF6', fillColor: '#8B5CF6', fillOpacity: 0.15, weight: 2 }).addTo(appMap);
appMap.flyTo([lat, lng], 15);
window.showToast('Menghitung jarak...');
try {
const res = await haversineService.getDalamRadius(rumahId, 1);
const container = document.getElementById('radius-results');
container.innerHTML = res.features.length ? '' : '<p>Tidak ada warga miskin.</p>';
res.features.forEach(f => {
const d = parseFloat(f.properties.jarak_km).toFixed(2);
container.innerHTML += `<div class="result-item"><span>${f.properties.nama}</span><span class="badge">${d} km</span></div>`;
});
currentHighlightLayer = L.geoJSON(res, {
pointToLayer: (f, ll) => L.circleMarker(ll, { radius:8, fillColor:'#EF4444', color:'#fff', weight:2, fillOpacity:1 })
}).addTo(appMap);
} catch(e) { window.showToast('Gagal kalkulasi', 'error'); }
};
window.deleteData = async (type, id) => {
if(!confirm('Yakin menghapus?')) return;
try {
if(type==='spbu') await spbuService.delete(id);
if(type==='jalan') await jalanService.delete(id);
if(type==='kavling') await kavlingService.delete(id);
if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
if(type==='warga_miskin') await wargaMiskinService.delete(id);
window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal hapus', 'error'); }
};
const handleGeometryCreated = (type, geometry, layer) => {
const tempLayer = L.geoJSON(geometry).addTo(appMap);
renderForm(type, geometry, async (payload) => {
try {
if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload);
if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
if(type==='warga_miskin') await wargaMiskinService.create(payload);
window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => appMap.removeLayer(tempLayer));
};
+4
View File
@@ -0,0 +1,4 @@
export const CONFIG = {
BASE_URL: '/02/backend/api',
RADIUS_KM: 1.0 // Radius default 1 km
};
+31
View File
@@ -0,0 +1,31 @@
export const setupDrawControls = (map, onGeometryCreated) => {
const drawControl = new L.Control.Draw({
draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
const type = event.layerType;
// Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
let customType = type;
if(type === 'marker') customType = 'spbu';
if(type === 'polyline') customType = 'jalan';
if(type === 'polygon') customType = 'kavling';
if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
});
// Custom Menu Handlers
const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
return drawControl;
};
+50
View File
@@ -0,0 +1,50 @@
export const renderForm = (type, geometry, onSubmit, onCancel) => {
const container = document.getElementById('form-container');
container.style.display = 'flex';
const extraFields = type === 'spbu' ? `
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="input-24jam">
<span>Buka 24 Jam</span>
</label>
</div>
` : '';
container.innerHTML = `
<div class="form-panel">
<h3 style="margin:0 0 10px 0; font-size:1rem; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px;">Simpan Data (${type.toUpperCase()})</h3>
<div class="form-group">
<input type="text" id="input-nama" class="form-control" placeholder="Nama / Label" required>
</div>
<div class="form-group">
<input type="text" id="input-deskripsi" class="form-control" placeholder="Deskripsi Singkat">
</div>
${extraFields}
<div style="display:flex; gap:10px; margin-top:8px;">
<button id="btn-save" class="btn-submit" style="flex:1;">Simpan</button>
<button id="btn-cancel" class="btn-submit" style="background:rgba(255,255,255,0.1); color:var(--text-light); flex:1;">Batal</button>
</div>
</div>
`;
document.getElementById('btn-save').addEventListener('click', () => {
const payload = {
type,
geometry,
nama: document.getElementById('input-nama').value,
deskripsi: document.getElementById('input-deskripsi').value,
};
if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
onSubmit(payload);
container.style.display = 'none';
container.innerHTML = '';
});
document.getElementById('btn-cancel').addEventListener('click', () => {
if(onCancel) onCancel();
container.style.display = 'none';
container.innerHTML = '';
});
};
+25
View File
@@ -0,0 +1,25 @@
/**
* map.js
* Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
*/
export const initMap = (containerId) => {
// Pusat peta default (Universitas Tanjungpura, Pontianak)
const map = L.map(containerId, {
zoomControl: false
}).setView([-0.0583, 109.3448], 15);
// Pindahkan zoom control ke kanan bawah
L.control.zoom({
position: 'bottomright'
}).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
return map;
};
+49
View File
@@ -0,0 +1,49 @@
const getBaseUrl = () => {
const segments = window.location.pathname.split('/');
const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg));
if (targetIndex !== -1) {
return segments.slice(0, targetIndex).join('/');
}
return '';
};
export const BASE_URL = getBaseUrl();
const createService = (endpoint) => ({
getAll: async () => {
const res = await fetch(`${BASE_URL}/${endpoint}`);
const json = await res.json();
return json.data;
},
create: async (data) => {
const res = await fetch(`${BASE_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
return json.data;
},
delete: async (id) => {
const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
method: 'DELETE'
});
const json = await res.json();
return json.data;
}
});
// P01
export const spbuService = createService('01/backend/api/spbu.php');
export const jalanService = createService('01/backend/api/jalan.php');
export const kavlingService = createService('01/backend/api/kavling.php');
// P02
export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = {
getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
const json = await res.json();
return json.data;
}
};
@@ -0,0 +1,22 @@
import { CONFIG } from '../config.js';
export const rumahIbadahService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
return await res.json();
},
getJangkauan: async (id, radius = 1) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
return await res.json();
}
};
@@ -0,0 +1,18 @@
import { CONFIG } from '../config.js';
export const wargaMiskinService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
return await res.json();
}
};
+50
View File
@@ -0,0 +1,50 @@
export const showToast = (message, type = 'success') => {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
};
export const clearFormContainer = () => {
const container = document.getElementById('form-container');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
};
export const renderResultPanel = (wargaList, radiusKm) => {
const panel = document.getElementById('result-panel');
const list = document.getElementById('result-list');
const title = document.getElementById('result-title');
panel.style.display = 'block';
title.innerHTML = `Warga Miskin <br><span style="font-size:0.8rem;color:var(--text-muted);font-weight:normal;">dalam radius ${radiusKm} km</span>`;
if (wargaList.length === 0) {
list.innerHTML = `<p style="font-size:0.85rem;color:var(--text-muted);">Tidak ada data dalam radius ini.</p>`;
return;
}
list.innerHTML = wargaList.map(w => `
<div class="warga-item">
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
<strong>${w.nama_kk}</strong>
<span class="badge-distance">${w.jarak_km} km</span>
</div>
<div style="color:var(--text-muted);">Rp ${w.penghasilan.toLocaleString('id-ID')} | ${w.jumlah_tanggungan} orang</div>
</div>
`).join('');
};
export const hideResultPanel = () => {
document.getElementById('result-panel').style.display = 'none';
};
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'jenis_jalan' => $row['jenis_jalan'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Jalan wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Jalan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
sendError('Nama pemilik dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_pemilik' => $input['nama_pemilik'],
':luas' => $input['luas'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Kavling wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kavling berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
require_once '../utils/geo_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
if (isset($_GET['action']) && $_GET['action'] === 'jangkauan' && isset($_GET['id'])) {
// Analisis Jangkauan Haversine
$id = $_GET['id'];
$radius = isset($_GET['radius']) ? (float) $_GET['radius'] : 1.0; // default 1 km
// Dapatkan koordinat rumah ibadah ini
$stmt = $pdo->prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
$rumah_ibadah = $stmt->fetch();
if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
$geom = json_decode($rumah_ibadah['geojson'], true);
$pusatLon = $geom['coordinates'][0];
$pusatLat = $geom['coordinates'][1];
// Panggil geo_helper untuk mendapatkan warga miskin
$warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
sendSuccess($warga, 'Data jangkauan berhasil dihitung');
} else {
// GET Semua Rumah Ibadah (GeoJSON)
try {
$stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'agama' => $row['agama']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':agama' => $input['agama'] ?? 'Islam',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool)$row['buka_24_jam'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':deskripsi' => $input['deskripsi'] ?? '',
':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* statistik.php
* Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains).
* Menghitung kepadatan warga miskin dalam setiap kavling.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
try {
// Query Spatial Point-in-Polygon
// Mengambil Kavling dan menghitung jumlah warga miskin (Point) yang ada di dalamnya
$sql = "
SELECT
k.id,
k.nama_pemilik,
k.luas,
ST_AsGeoJSON(k.geom) as geojson,
COUNT(w.id) as jumlah_warga,
COALESCE(SUM(w.jumlah_tanggungan), 0) as total_tanggungan
FROM kavling k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id
";
$stmt = $pdo->query($sql);
$features = [];
$total_warga = 0;
while ($row = $stmt->fetch()) {
$jumlah = (int) $row['jumlah_warga'];
$total_warga += $jumlah;
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'jumlah_warga' => $jumlah,
'total_tanggungan' => (int) $row['total_tanggungan']
]
];
}
$response = [
'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) {
sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500);
}
} else {
sendError('Method not allowed', 405);
}
?>
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_kk' => $row['nama_kk'],
'penghasilan' => $row['penghasilan'],
'jumlah_tanggungan' => $row['jumlah_tanggungan']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_kk' => $input['nama_kk'],
':penghasilan' => $input['penghasilan'] ?? 0,
':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Singleton PDO connection.
*
* Coolify: set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, and DB_PASSWORD
* from the database service credentials.
*/
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
$dbUrl = self::env('DATABASE_URL');
if ($dbUrl) {
$parsed = parse_url($dbUrl);
$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? '3306';
$username = $parsed['user'] ?? 'root';
$password = $parsed['pass'] ?? '';
$dbName = ltrim($parsed['path'], '/');
} else {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
}
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
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
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../../database/webgis_db.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
self::$conn->exec($sql);
}
}
}
return self::$conn;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* response_helper.php
* Tanggung Jawab: Standarisasi format response JSON.
*/
header('Content-Type: application/json; charset=utf-8');
$allowedOrigin = getenv('CORS_ALLOWED_ORIGIN');
if ($allowedOrigin !== false && $allowedOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
function sendSuccess($data = null, $message = 'Success', $statusCode = 200) {
http_response_code($statusCode);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $statusCode = 400) {
http_response_code($statusCode);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
?>
+61
View File
@@ -0,0 +1,61 @@
/* base.css
* Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
/* Color Palette - Minimalist & Elegant */
--primary: #4F46E5;
--primary-hover: #4338CA;
--secondary: #10B981;
--background: #F3F4F6;
--surface: #FFFFFF;
--surface-glass: rgba(255, 255, 255, 0.85);
--text-main: #111827;
--text-muted: #6B7280;
--border: #E5E7EB;
--danger: #EF4444;
/* Shadows & Effects */
--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);
--radius-md: 12px;
--radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
}
h1, h2, h3, h4 {
font-weight: 600;
color: var(--text-main);
}
/* Scrollbar minimalis */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
+205
View File
@@ -0,0 +1,205 @@
:root {
--primary: #6366F1;
--primary-hover: #4F46E5;
--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;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
background: var(--bg-dark);
color: var(--text-light);
overflow: hidden;
}
#map {
width: 100vw;
height: 100vh;
z-index: 1;
}
/* Floating Dock */
.floating-dock {
position: absolute;
top: 24px;
left: 24px;
z-index: 1000;
background: var(--bg-glass);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass);
border-radius: var(--radius);
padding: 24px;
width: 340px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
/* Custom Scrollbar for Dock */
.floating-dock::-webkit-scrollbar { width: 6px; }
.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; }
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
/* Buttons */
.menu-group { display: flex; flex-direction: column; gap: 10px; }
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
.btn-action {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
color: var(--text-light);
padding: 14px 16px;
border-radius: 12px;
cursor: pointer;
font-weight: 500;
font-family: var(--font);
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
text-align: left;
}
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
.btn-action:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.btn-action.active {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
}
/* Button Variants */
.btn-spbu svg { color: #10B981; }
.btn-jalan svg { color: #F59E0B; }
.btn-kavling svg { color: #3B82F6; }
.btn-rumah svg { color: #8B5CF6; }
.btn-warga svg { color: #EF4444; }
/* Form Panel */
.form-panel {
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border-glass);
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 0.8rem; color: var(--text-muted); }
.form-control {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
padding: 10px 12px;
border-radius: 8px;
color: white;
font-family: var(--font);
font-size: 0.9rem;
outline: none;
transition: border 0.2s;
}
.form-control:focus { border-color: var(--primary); }
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
.btn-submit {
background: var(--primary);
color: white;
border: none;
padding: 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: var(--primary-hover); }
/* Leaflet Customizations */
.leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a {
background: var(--bg-glass) !important;
color: var(--text-light) !important;
border-color: var(--border-glass) !important;
backdrop-filter: blur(10px);
}
.leaflet-control-zoom a:hover { background: var(--primary) !important; }
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper {
background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important;
border-radius: 16px !important;
box-shadow: var(--shadow) !important;
}
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
.leaflet-popup-content { margin: 16px !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 p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
margin-top: 12px;
width: 100%;
transition: all 0.2s;
}
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */
.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 {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+92
View File
@@ -0,0 +1,92 @@
/* form.css
* Tanggung Jawab: Styling komponen form (input, button, select).
*/
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-main);
}
.form-control {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.95rem;
transition: var(--transition);
background: var(--surface);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
font-family: inherit;
font-weight: 600;
font-size: 0.9rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
gap: 8px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--danger);
color: white;
width: auto;
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-danger:hover {
background: #DC2626;
}
/* Micro-animation for active state */
.btn:active {
transform: translateY(1px);
}
/* Form Container Animation */
#form-container {
background: var(--surface);
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
margin-bottom: 20px;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 03</title>
<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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="css/main.css">
<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; }
.badge { background: var(--primary); padding: 2px 6px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: white; }
</style>
</head>
<body>
<div id="map"></div>
<div class="floating-dock">
<div class="header">
<h2>P03: Analisis Spasial</h2>
<p>Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).</p>
</div>
<div class="menu-group">
<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-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 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);">
<h3 style="margin:0 0 5px 0; font-size:0.9rem; color:#10B981;">Statistik Kavling (Choropleth)</h3>
<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>
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<span style="font-size:0.8rem;">Total Penduduk Miskin:</span>
<strong id="stat-total-warga" style="color:white;">0</strong>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:0.8rem;">Kavling Padat (>2 warga):</span>
<strong id="stat-kavling-merah" style="color:#EF4444;">0</strong>
</div>
</div>
<div id="form-container" style="display: none;"></div>
</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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
import { initMap } from './modules/map.js';
import { setupDrawControls } from './modules/draw.js';
import { renderForm } from './modules/form.js';
import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, BASE_URL } from './services/api.service.js';
let appMap;
let drawControl;
const statistikService = {
getChoropleth: async () => {
const res = await fetch(`${BASE_URL}/03/backend/api/statistik.php`);
const json = await res.json();
return json.data;
}
};
export const createIcon = (svgPath, color) => L.divIcon({
className: 'custom-icon',
html: `<div style="background-color: ${color}; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); color: white;"><svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">${svgPath}</svg></div>`,
iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
});
const iconSPBU = (is24h) => createIcon('<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>', is24h ? '#10B981' : '#EF4444');
const iconRumah = createIcon('<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>', '#8B5CF6');
const iconWarga = createIcon('<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>', '#EF4444');
window.showToast = (msg, type='success') => {
const t = document.createElement('div'); t.className = `toast ${type}`; t.innerHTML = msg;
document.getElementById('toast-container').appendChild(t);
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 () => {
try {
const [spbu, jalan, choro, rumah, warga] = await Promise.all([
spbuService.getAll(), jalanService.getAll(), statistikService.getChoropleth(),
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);
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);
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;
L.geoJSON(choro, {
style: (f) => {
const count = f.properties.jumlah_warga_miskin;
let c = '#10B981'; // Hijau (0)
if(count > 0) c = '#F59E0B'; // Kuning (1-2)
if(count > 2) { c = '#EF4444'; merahCount++; } // Merah (>2)
return { color: c, fillColor: c, fillOpacity: 0.4, weight: 2 };
},
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
}).addTo(appMap);
document.getElementById('stat-total-warga').innerText = warga.features.length;
document.getElementById('stat-kavling-merah').innerText = merahCount;
} catch (e) { window.showToast("Gagal meload data: "+e.message, 'error'); }
};
const bindPopup = (layer, type, props) => {
let ext = '';
if(type==='spbu') ext = `<p>24 Jam: <strong style="color:${props.buka_24_jam?'#10B981':'#EF4444'}">${props.buka_24_jam?'Ya':'Tidak'}</strong></p>`;
if(type==='kavling') ext = `<p>Jml Warga Miskin: <strong>${props.jumlah_warga_miskin||0} Jiwa</strong></p>`;
layer.bindPopup(`
<div class="popup-custom">
<h3>${props.nama}</h3>
<p>${props.deskripsi || ''}</p>
${ext}
<button class="btn-delete" onclick="window.deleteData('${type}', ${props.id})">Hapus</button>
</div>
`);
};
window.deleteData = async (type, id) => {
if(!confirm('Yakin menghapus?')) return;
try {
if(type==='spbu') await spbuService.delete(id);
if(type==='jalan') await jalanService.delete(id);
if(type==='kavling') await kavlingService.delete(id);
if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
if(type==='warga_miskin') await wargaMiskinService.delete(id);
window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal hapus', 'error'); }
};
const handleGeometryCreated = (type, geometry, layer) => {
const tempLayer = L.geoJSON(geometry).addTo(appMap);
renderForm(type, geometry, async (payload) => {
try {
if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload);
if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
if(type==='warga_miskin') await wargaMiskinService.create(payload);
window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => appMap.removeLayer(tempLayer));
};
+31
View File
@@ -0,0 +1,31 @@
export const setupDrawControls = (map, onGeometryCreated) => {
const drawControl = new L.Control.Draw({
draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
const type = event.layerType;
// Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
let customType = type;
if(type === 'marker') customType = 'spbu';
if(type === 'polyline') customType = 'jalan';
if(type === 'polygon') customType = 'kavling';
if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
});
// Custom Menu Handlers
const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
return drawControl;
};
+50
View File
@@ -0,0 +1,50 @@
export const renderForm = (type, geometry, onSubmit, onCancel) => {
const container = document.getElementById('form-container');
container.style.display = 'flex';
const extraFields = type === 'spbu' ? `
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="input-24jam">
<span>Buka 24 Jam</span>
</label>
</div>
` : '';
container.innerHTML = `
<div class="form-panel">
<h3 style="margin:0 0 10px 0; font-size:1rem; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px;">Simpan Data (${type.toUpperCase()})</h3>
<div class="form-group">
<input type="text" id="input-nama" class="form-control" placeholder="Nama / Label" required>
</div>
<div class="form-group">
<input type="text" id="input-deskripsi" class="form-control" placeholder="Deskripsi Singkat">
</div>
${extraFields}
<div style="display:flex; gap:10px; margin-top:8px;">
<button id="btn-save" class="btn-submit" style="flex:1;">Simpan</button>
<button id="btn-cancel" class="btn-submit" style="background:rgba(255,255,255,0.1); color:var(--text-light); flex:1;">Batal</button>
</div>
</div>
`;
document.getElementById('btn-save').addEventListener('click', () => {
const payload = {
type,
geometry,
nama: document.getElementById('input-nama').value,
deskripsi: document.getElementById('input-deskripsi').value,
};
if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
onSubmit(payload);
container.style.display = 'none';
container.innerHTML = '';
});
document.getElementById('btn-cancel').addEventListener('click', () => {
if(onCancel) onCancel();
container.style.display = 'none';
container.innerHTML = '';
});
};
+25
View File
@@ -0,0 +1,25 @@
/**
* map.js
* Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
*/
export const initMap = (containerId) => {
// Pusat peta default (Universitas Tanjungpura, Pontianak)
const map = L.map(containerId, {
zoomControl: false
}).setView([-0.0583, 109.3448], 15);
// Pindahkan zoom control ke kanan bawah
L.control.zoom({
position: 'bottomright'
}).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
return map;
};
+49
View File
@@ -0,0 +1,49 @@
const getBaseUrl = () => {
const segments = window.location.pathname.split('/');
const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg));
if (targetIndex !== -1) {
return segments.slice(0, targetIndex).join('/');
}
return '';
};
export const BASE_URL = getBaseUrl();
const createService = (endpoint) => ({
getAll: async () => {
const res = await fetch(`${BASE_URL}/${endpoint}`);
const json = await res.json();
return json.data;
},
create: async (data) => {
const res = await fetch(`${BASE_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
return json.data;
},
delete: async (id) => {
const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
method: 'DELETE'
});
const json = await res.json();
return json.data;
}
});
// P01
export const spbuService = createService('01/backend/api/spbu.php');
export const jalanService = createService('01/backend/api/jalan.php');
export const kavlingService = createService('01/backend/api/kavling.php');
// P02
export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = {
getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
const json = await res.json();
return json.data;
}
};
@@ -0,0 +1,22 @@
import { CONFIG } from '../config.js';
export const rumahIbadahService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
return await res.json();
},
getJangkauan: async (id, radius = 1) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
return await res.json();
}
};
@@ -0,0 +1,10 @@
export const CONFIG = {
BASE_URL: '/03/backend/api'
};
export const statistikService = {
getKepadatan: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/statistik.php`);
return await res.json();
}
};
@@ -0,0 +1,18 @@
import { CONFIG } from '../config.js';
export const wargaMiskinService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
return await res.json();
}
};
+68
View File
@@ -0,0 +1,68 @@
/* map.css
* Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
*/
.map-container {
flex: 1;
position: relative;
background: #E2E8F0;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* Customizing Leaflet Controls */
.leaflet-control-zoom {
border: none !important;
box-shadow: var(--shadow-md) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a {
color: var(--text-main) !important;
background: var(--surface-glass) !important;
backdrop-filter: blur(8px);
transition: var(--transition);
}
.leaflet-control-zoom a:hover {
background: var(--surface) !important;
color: var(--primary) !important;
}
/* Custom Popup Modern */
.leaflet-popup-content-wrapper {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 4px;
}
.leaflet-popup-content {
margin: 14px;
font-family: 'Inter', sans-serif;
}
.popup-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 6px;
border-bottom: 2px solid var(--primary);
padding-bottom: 4px;
display: inline-block;
}
.popup-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.popup-action {
display: flex;
justify-content: flex-end;
}
+78
View File
@@ -0,0 +1,78 @@
/* sidebar.css
* Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
*/
.sidebar {
width: 350px;
background: var(--surface-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 1000;
box-shadow: var(--shadow-lg);
transition: var(--transition);
}
.sidebar-header {
padding: 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-title {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.sidebar-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Toast Notification */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--surface);
color: var(--text-main);
padding: 12px 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.9rem;
font-weight: 500;
transform: translateY(20px);
opacity: 0;
animation: slideIn 0.3s forwards ease-out;
border-left: 4px solid var(--primary);
}
.toast.error {
border-left-color: var(--danger);
}
@keyframes slideIn {
to {
transform: translateY(0);
opacity: 1;
}
}
+16
View File
@@ -0,0 +1,16 @@
FROM php:8.2-apache
RUN docker-php-ext-install pdo_mysql mysqli \
&& a2enmod rewrite headers
COPY index.html /var/www/html/index.html
COPY 01/ /var/www/html/01/
COPY 02/ /var/www/html/02/
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
EXPOSE 80
-18
View File
@@ -1,18 +0,0 @@
MIT License
Copyright (c) 2026 naufalzakyramadhan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+80 -1
View File
@@ -1,2 +1,81 @@
# D1041231071_UAS-Tugas_WebGISProject
# D1041231071 — WebGIS Smart City Project
**Naufal Zaky R | UAS WebGIS**
Aplikasi WebGIS berbasis PHP + MySQL untuk pemetaan data kemiskinan, fasilitas umum, laporan warga, dan analisis blank spot bantuan sosial di Kota Pontianak.
## Stack Teknologi
- PHP 8.2 Apache
- MySQL / MariaDB + PostGIS
- Leaflet.js (peta interaktif)
- PDO MySQL (koneksi database)
## Cara Deploy (Coolify)
1. Buat database MySQL/MariaDB di Coolify.
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
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/
├── beranda.html ← Halaman pemilih progres
├── webgis_app/ ← Aplikasi final (main app)
│ ├── panel_admin/ ← Halaman & fitur admin
│ ├── panel_user/ ← Halaman & fitur pengguna
│ ├── endpoints/ ← API endpoint PHP
│ ├── core_config/ ← Konfigurasi (DB, session, auth)
│ └── public_assets/ ← CSS & JS (global, panel, map)
├── db_scripts/ ← SQL scripts database
│ ├── buat_tabel.sql
│ ├── data_awal_wilayah.sql
│ ├── data_awal_pengguna.sql
│ └── webgis_naufal_zaky.sql
├── version_01/ ← Progres pertemuan 1
├── version_02/ ← Progres pertemuan 2
├── version_03/ ← Progres pertemuan 3
├── legacy_version/ ← Versi lama (referensi)
├── Dockerfile ← Docker image untuk Coolify
└── .env ← Konfigurasi environment
```
## Jalankan di XAMPP Lokal
Set konfigurasi di `.env`:
```env
APP_BASE_PATH=/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=webgis_db
DB_USERNAME=root
DB_PASSWORD=
```
Import `db_scripts/webgis_naufal_zaky.sql` ke phpMyAdmin, lalu buka:
`http://localhost/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app/login.php`
+116
View File
@@ -0,0 +1,116 @@
-- ============================================================
-- WebGIS Pontianak - Struktur Database
-- ============================================================
-- Jalankan file ini terlebih dahulu sebelum menjalankan seed data.
CREATE DATABASE IF NOT EXISTS webgis_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE webgis_db;
-- ============================================================
-- 1. Users
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
nama_lengkap VARCHAR(100) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 2. Data Spasial
-- ============================================================
CREATE TABLE IF NOT EXISTS spbu (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
deskripsi TEXT,
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_spbu_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
agama VARCHAR(50) NOT NULL,
radius_bantuan_meter INT NOT NULL DEFAULT 1000,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_rumah_ibadah_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
jenis_jalan VARCHAR(50) DEFAULT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_jalan_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kavling (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pemilik VARCHAR(100) NOT NULL,
status_kepemilikan VARCHAR(50) NOT NULL,
luas DECIMAL(10,2) DEFAULT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_kavling_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kawasan_kumuh (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_kawasan VARCHAR(100) NOT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_kawasan_kumuh_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS warga_miskin (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_kk VARCHAR(100) NOT NULL,
penghasilan DECIMAL(15,2) NOT NULL,
jumlah_tanggungan INT NOT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_warga_miskin_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 3. Data Interaksi Pengguna
-- ============================================================
CREATE TABLE IF NOT EXISTS laporan_warga (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
kategori VARCHAR(50) NOT NULL,
deskripsi TEXT NOT NULL,
geometry POINT NOT NULL,
status ENUM('menunggu', 'diproses', 'selesai', 'ditolak') NOT NULL DEFAULT 'menunggu',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_laporan_user_id (user_id),
SPATIAL INDEX idx_laporan_geometry (geometry),
CONSTRAINT fk_laporan_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS ulasan_fasilitas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
fasilitas_tipe ENUM('spbu', 'rumah_ibadah') NOT NULL,
fasilitas_id INT NOT NULL,
rating TINYINT NOT NULL,
komentar TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ulasan_user_id (user_id),
INDEX idx_ulasan_fasilitas (fasilitas_tipe, fasilitas_id),
CONSTRAINT fk_ulasan_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT chk_ulasan_rating CHECK (rating BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+26
View File
@@ -0,0 +1,26 @@
-- ============================================================
-- WebGIS Pontianak - Seed Users
-- ============================================================
-- Akun demo:
-- admin / admin123
-- pengguna / user123
USE webgis_db;
INSERT INTO users (username, password, role, nama_lengkap) VALUES
(
'admin',
'$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62',
'admin',
'Administrator WebGIS'
),
(
'pengguna',
'$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW',
'user',
'Pengguna Publik'
)
ON DUPLICATE KEY UPDATE
password = VALUES(password),
role = VALUES(role),
nama_lengkap = VALUES(nama_lengkap);
+172
View File
@@ -0,0 +1,172 @@
-- ============================================================
-- WebGIS Pontianak - Seed Data Spasial
-- ============================================================
-- Koordinat menggunakan urutan longitude, latitude.
USE webgis_db;
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE spbu;
TRUNCATE TABLE rumah_ibadah;
TRUNCATE TABLE jalan;
TRUNCATE TABLE kavling;
TRUNCATE TABLE kawasan_kumuh;
TRUNCATE TABLE warga_miskin;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- 1. SPBU
-- ============================================================
INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
(
'SPBU Bundaran Digulis Untan',
'SPBU terdekat dari kampus',
1,
ST_GeomFromText('POINT(109.3440 -0.0565)')
),
(
'SPBU Ahmad Yani',
'Dekat Mega Mall (24 Jam)',
1,
ST_GeomFromText('POINT(109.3360 -0.0450)')
),
(
'SPBU Imam Bonjol',
'SPBU pinggir kota',
0,
ST_GeomFromText('POINT(109.3410 -0.0380)')
),
(
'SPBU Kota Baru',
'Jl. Sultan Abdurrahman',
0,
ST_GeomFromText('POINT(109.3245 -0.0487)')
);
-- ============================================================
-- 2. Rumah Ibadah
-- ============================================================
INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
(
'Masjid Raya Mujahidin',
'Islam',
1000,
ST_GeomFromText('POINT(109.3356 -0.0454)')
),
(
'Gereja Katedral St. Yosef',
'Katolik',
1000,
ST_GeomFromText('POINT(109.3368 -0.0298)')
),
(
'Vihara Bodhisatva Karaniya Metta',
'Buddha',
1000,
ST_GeomFromText('POINT(109.3468 -0.0233)')
),
(
'Masjid Jami Keraton Kadriyah',
'Islam',
1000,
ST_GeomFromText('POINT(109.3523 -0.0229)')
);
-- ============================================================
-- 3. Jalan
-- ============================================================
INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
(
'Jalan Ahmad Yani',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)')
),
(
'Jalan Gajah Mada',
'Kolektor',
ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)')
),
(
'Jalan Imam Bonjol',
'Lokal',
ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)')
);
-- ============================================================
-- 4. Kavling
-- ============================================================
INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) VALUES
(
'PT. Mega Karya',
'HGB',
25000.00,
ST_GeomFromText('POLYGON((109.333 -0.048, 109.338 -0.048, 109.338 -0.053, 109.333 -0.053, 109.333 -0.048))')
),
(
'Bapak Sudirman',
'SHM',
1500.00,
ST_GeomFromText('POLYGON((109.340 -0.035, 109.341 -0.035, 109.341 -0.036, 109.340 -0.036, 109.340 -0.035))')
);
-- ============================================================
-- 5. Kawasan Kumuh
-- ============================================================
INSERT INTO kawasan_kumuh (nama_kawasan, geom) VALUES
(
'Kawasan Rawan Bantaran Sungai',
ST_GeomFromText('POLYGON((109.360 -0.050, 109.365 -0.050, 109.365 -0.055, 109.360 -0.055, 109.360 -0.050))')
),
(
'Kawasan Padat Parit Tokaya',
ST_GeomFromText('POLYGON((109.342 -0.038, 109.346 -0.038, 109.346 -0.042, 109.342 -0.042, 109.342 -0.038))')
);
-- ============================================================
-- 6. Warga Miskin
-- ============================================================
INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES
(
'Bpk. Budi (Terisolasi)',
800000.00,
4,
ST_GeomFromText('POINT(109.361 -0.051)')
),
(
'Ibu Siti (Terisolasi)',
600000.00,
2,
ST_GeomFromText('POINT(109.362 -0.052)')
),
(
'Bpk. Joko (Terisolasi)',
1000000.00,
5,
ST_GeomFromText('POINT(109.363 -0.053)')
),
(
'Ibu Ani (Terisolasi)',
700000.00,
3,
ST_GeomFromText('POINT(109.364 -0.054)')
),
(
'Bpk. Hasan',
1200000.00,
3,
ST_GeomFromText('POINT(109.334 -0.050)')
),
(
'Mbah Warno',
400000.00,
1,
ST_GeomFromText('POINT(109.336 -0.046)')
),
(
'Pak Junaidi',
900000.00,
4,
ST_GeomFromText('POINT(109.344 -0.040)')
);
+278
View File
@@ -0,0 +1,278 @@
-- ============================================================
-- WebGIS Pontianak - Database Lengkap
-- ============================================================
-- Import file ini jika ingin membuat ulang database dari awal.
-- Perhatian: tabel lama dengan nama yang sama akan dihapus.
-- Database selected via connection string
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS ulasan_fasilitas;
DROP TABLE IF EXISTS laporan_warga;
DROP TABLE IF EXISTS warga_miskin;
DROP TABLE IF EXISTS kawasan_kumuh;
DROP TABLE IF EXISTS kavling;
DROP TABLE IF EXISTS jalan;
DROP TABLE IF EXISTS rumah_ibadah;
DROP TABLE IF EXISTS spbu;
DROP TABLE IF EXISTS users;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- 1. Users
-- ============================================================
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
nama_lengkap VARCHAR(100) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (username, password, role, nama_lengkap) VALUES
(
'admin',
'$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62',
'admin',
'Administrator WebGIS'
),
(
'pengguna',
'$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW',
'user',
'Pengguna Publik'
);
-- ============================================================
-- 2. Data Spasial
-- ============================================================
CREATE TABLE spbu (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
deskripsi TEXT,
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_spbu_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
(
'SPBU Bundaran Digulis Untan',
'SPBU terdekat dari kampus',
1,
ST_GeomFromText('POINT(109.3440 -0.0565)')
),
(
'SPBU Ahmad Yani',
'Dekat Mega Mall (24 Jam)',
1,
ST_GeomFromText('POINT(109.3360 -0.0450)')
),
(
'SPBU Imam Bonjol',
'SPBU pinggir kota',
0,
ST_GeomFromText('POINT(109.3410 -0.0380)')
),
(
'SPBU Kota Baru',
'Jl. Sultan Abdurrahman',
0,
ST_GeomFromText('POINT(109.3245 -0.0487)')
);
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
agama VARCHAR(50) NOT NULL,
radius_bantuan_meter INT NOT NULL DEFAULT 1000,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_rumah_ibadah_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
(
'Masjid Raya Mujahidin',
'Islam',
1000,
ST_GeomFromText('POINT(109.3356 -0.0454)')
),
(
'Gereja Katedral St. Yosef',
'Katolik',
1000,
ST_GeomFromText('POINT(109.3368 -0.0298)')
),
(
'Vihara Bodhisatva Karaniya Metta',
'Buddha',
1000,
ST_GeomFromText('POINT(109.3468 -0.0233)')
),
(
'Masjid Jami Keraton Kadriyah',
'Islam',
1000,
ST_GeomFromText('POINT(109.3523 -0.0229)')
);
CREATE TABLE jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
jenis_jalan VARCHAR(50) DEFAULT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_jalan_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
(
'Jalan Ahmad Yani',
'Arteri Primer',
ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)')
),
(
'Jalan Gajah Mada',
'Kolektor',
ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)')
),
(
'Jalan Imam Bonjol',
'Lokal',
ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)')
);
CREATE TABLE kavling (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pemilik VARCHAR(100) NOT NULL,
status_kepemilikan VARCHAR(50) NOT NULL,
luas DECIMAL(10,2) DEFAULT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_kavling_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) VALUES
(
'PT. Mega Karya',
'HGB',
25000.00,
ST_GeomFromText('POLYGON((109.333 -0.048, 109.338 -0.048, 109.338 -0.053, 109.333 -0.053, 109.333 -0.048))')
),
(
'Bapak Sudirman',
'SHM',
1500.00,
ST_GeomFromText('POLYGON((109.340 -0.035, 109.341 -0.035, 109.341 -0.036, 109.340 -0.036, 109.340 -0.035))')
);
CREATE TABLE kawasan_kumuh (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_kawasan VARCHAR(100) NOT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_kawasan_kumuh_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO kawasan_kumuh (nama_kawasan, geom) VALUES
(
'Kawasan Rawan Bantaran Sungai',
ST_GeomFromText('POLYGON((109.360 -0.050, 109.365 -0.050, 109.365 -0.055, 109.360 -0.055, 109.360 -0.050))')
),
(
'Kawasan Padat Parit Tokaya',
ST_GeomFromText('POLYGON((109.342 -0.038, 109.346 -0.038, 109.346 -0.042, 109.342 -0.042, 109.342 -0.038))')
);
CREATE TABLE warga_miskin (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_kk VARCHAR(100) NOT NULL,
penghasilan DECIMAL(15,2) NOT NULL,
jumlah_tanggungan INT NOT NULL,
geom GEOMETRY NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX idx_warga_miskin_geom (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES
(
'Bpk. Budi (Terisolasi)',
800000.00,
4,
ST_GeomFromText('POINT(109.361 -0.051)')
),
(
'Ibu Siti (Terisolasi)',
600000.00,
2,
ST_GeomFromText('POINT(109.362 -0.052)')
),
(
'Bpk. Joko (Terisolasi)',
1000000.00,
5,
ST_GeomFromText('POINT(109.363 -0.053)')
),
(
'Ibu Ani (Terisolasi)',
700000.00,
3,
ST_GeomFromText('POINT(109.364 -0.054)')
),
(
'Bpk. Hasan',
1200000.00,
3,
ST_GeomFromText('POINT(109.334 -0.050)')
),
(
'Mbah Warno',
400000.00,
1,
ST_GeomFromText('POINT(109.336 -0.046)')
),
(
'Pak Junaidi',
900000.00,
4,
ST_GeomFromText('POINT(109.344 -0.040)')
);
-- ============================================================
-- 3. Data Interaksi Pengguna
-- ============================================================
CREATE TABLE laporan_warga (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
kategori VARCHAR(50) NOT NULL,
deskripsi TEXT NOT NULL,
geometry POINT NOT NULL,
status ENUM('menunggu', 'diproses', 'selesai', 'ditolak') NOT NULL DEFAULT 'menunggu',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_laporan_user_id (user_id),
SPATIAL INDEX idx_laporan_geometry (geometry),
CONSTRAINT fk_laporan_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE ulasan_fasilitas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
fasilitas_tipe ENUM('spbu', 'rumah_ibadah') NOT NULL,
fasilitas_id INT NOT NULL,
rating TINYINT NOT NULL,
komentar TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ulasan_user_id (user_id),
INDEX idx_ulasan_fasilitas (fasilitas_tipe, fasilitas_id),
CONSTRAINT fk_ulasan_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT chk_ulasan_rating CHECK (rating BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+320
View File
@@ -0,0 +1,320 @@
<!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>
+24
View File
@@ -0,0 +1,24 @@
<?php
$host = '203.24.51.230';
$port = 33060;
$user = 'root';
$pass = 'oyMK5S18DMjHtOSVxoQWy2n0YASA5QJuyko00udOuneeijdiNFENgVG2ZMXFL36H';
$conn = mysqli_connect($host, $user, $pass, '', $port);
if (!$conn) {
die("Connection failed: " . mysqli_connect_error() . "\n");
}
$sql = file_get_contents(__DIR__ . '/database/webgis_db.sql');
if (mysqli_multi_query($conn, $sql)) {
do {
if ($res = mysqli_store_result($conn)) {
mysqli_free_result($res);
}
} while (mysqli_more_results($conn) && mysqli_next_result($conn));
echo "Database imported successfully!\n";
} else {
echo "Error importing: " . mysqli_error($conn) . "\n";
}
mysqli_close($conn);
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* jalan.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Jalan (LineString).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'jenis_jalan' => $row['jenis_jalan'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Jalan wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Jalan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* kavling.php
* Tanggung Jawab: Menangani operasi CRUD untuk entitas Kavling (Polygon).
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
sendError('Nama pemilik dan geometri wajib diisi');
}
try {
$sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_pemilik' => $input['nama_pemilik'],
':luas' => $input['luas'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) {
sendError('ID Kavling wajib disertakan');
}
try {
$stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kavling berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* rumah_ibadah.php
* Tanggung Jawab: Operasi CRUD Rumah Ibadah + Endpoint analisis jangkauan Haversine.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
require_once '../utils/geo_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
if (isset($_GET['action']) && $_GET['action'] === 'jangkauan' && isset($_GET['id'])) {
// Analisis Jangkauan Haversine
$id = $_GET['id'];
$radius = isset($_GET['radius']) ? (float) $_GET['radius'] : 1.0; // default 1 km
// Dapatkan koordinat rumah ibadah ini
$stmt = $pdo->prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
$rumah_ibadah = $stmt->fetch();
if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
$geom = json_decode($rumah_ibadah['geojson'], true);
$pusatLon = $geom['coordinates'][0];
$pusatLat = $geom['coordinates'][1];
// Panggil geo_helper untuk mendapatkan warga miskin
$warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
sendSuccess($warga, 'Data jangkauan berhasil dihitung');
} else {
// GET Semua Rumah Ibadah (GeoJSON)
try {
$stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'agama' => $row['agama']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':agama' => $input['agama'] ?? 'Islam',
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool)$row['buka_24_jam'],
'created_at' => $row['created_at']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama' => $input['nama'],
':deskripsi' => $input['deskripsi'] ?? '',
':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* statistik.php
* Tanggung Jawab: Menyediakan data analisis spasial menggunakan Point in Polygon (ST_Contains).
* Menghitung kepadatan warga miskin dalam setiap kavling.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
try {
// Query Spatial Point-in-Polygon
// Mengambil Kavling dan menghitung jumlah warga miskin (Point) yang ada di dalamnya
$sql = "
SELECT
k.id,
k.nama_pemilik,
k.luas,
ST_AsGeoJSON(k.geom) as geojson,
COUNT(w.id) as jumlah_warga,
COALESCE(SUM(w.jumlah_tanggungan), 0) as total_tanggungan
FROM kavling k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id
";
$stmt = $pdo->query($sql);
$features = [];
$total_warga = 0;
while ($row = $stmt->fetch()) {
$jumlah = (int) $row['jumlah_warga'];
$total_warga += $jumlah;
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'luas' => $row['luas'],
'jumlah_warga' => $jumlah,
'total_tanggungan' => (int) $row['total_tanggungan']
]
];
}
$response = [
'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) {
sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500);
}
} else {
sendError('Method not allowed', 405);
}
?>
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* warga_miskin.php
* Tanggung Jawab: Operasi CRUD untuk Warga Miskin.
*/
require_once '../core_config/database.php';
require_once '../utils/response_helper.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_kk' => $row['nama_kk'],
'penghasilan' => $row['penghasilan'],
'jumlah_tanggungan' => $row['jumlah_tanggungan']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
try {
$sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_kk' => $input['nama_kk'],
':penghasilan' => $input['penghasilan'] ?? 0,
':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
':geometry' => json_encode($input['geometry'])
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID dibutuhkan');
$stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
$stmt->execute([$id]);
sendSuccess(null, 'Dihapus');
break;
default:
sendError('Method not allowed', 405);
break;
}
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
try {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
self::$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $exception) {
echo "Connection error: " . $exception->getMessage();
}
}
return self::$conn;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* response_helper.php
* Tanggung Jawab: Standarisasi format response JSON.
*/
header('Content-Type: application/json; charset=utf-8');
$allowedOrigin = getenv('CORS_ALLOWED_ORIGIN');
if ($allowedOrigin !== false && $allowedOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
function sendSuccess($data = null, $message = 'Success', $statusCode = 200) {
http_response_code($statusCode);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $statusCode = 400) {
http_response_code($statusCode);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
?>
+61
View File
@@ -0,0 +1,61 @@
/* base.css
* Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
/* Color Palette - Minimalist & Elegant */
--primary: #4F46E5;
--primary-hover: #4338CA;
--secondary: #10B981;
--background: #F3F4F6;
--surface: #FFFFFF;
--surface-glass: rgba(255, 255, 255, 0.85);
--text-main: #111827;
--text-muted: #6B7280;
--border: #E5E7EB;
--danger: #EF4444;
/* Shadows & Effects */
--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);
--radius-md: 12px;
--radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
}
h1, h2, h3, h4 {
font-weight: 600;
color: var(--text-main);
}
/* Scrollbar minimalis */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
+205
View File
@@ -0,0 +1,205 @@
:root {
--primary: #6366F1;
--primary-hover: #4F46E5;
--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;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
background: var(--bg-dark);
color: var(--text-light);
overflow: hidden;
}
#map {
width: 100vw;
height: 100vh;
z-index: 1;
}
/* Floating Dock */
.floating-dock {
position: absolute;
top: 24px;
left: 24px;
z-index: 1000;
background: var(--bg-glass);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glass);
border-radius: var(--radius);
padding: 24px;
width: 340px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
/* Custom Scrollbar for Dock */
.floating-dock::-webkit-scrollbar { width: 6px; }
.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; }
.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
/* Buttons */
.menu-group { display: flex; flex-direction: column; gap: 10px; }
.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
.btn-action {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
color: var(--text-light);
padding: 14px 16px;
border-radius: 12px;
cursor: pointer;
font-weight: 500;
font-family: var(--font);
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
text-align: left;
}
.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
.btn-action:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.btn-action.active {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
}
/* Button Variants */
.btn-spbu svg { color: #10B981; }
.btn-jalan svg { color: #F59E0B; }
.btn-kavling svg { color: #3B82F6; }
.btn-rumah svg { color: #8B5CF6; }
.btn-warga svg { color: #EF4444; }
/* Form Panel */
.form-panel {
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border-glass);
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 0.8rem; color: var(--text-muted); }
.form-control {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
padding: 10px 12px;
border-radius: 8px;
color: white;
font-family: var(--font);
font-size: 0.9rem;
outline: none;
transition: border 0.2s;
}
.form-control:focus { border-color: var(--primary); }
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
.btn-submit {
background: var(--primary);
color: white;
border: none;
padding: 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: var(--primary-hover); }
/* Leaflet Customizations */
.leaflet-draw-toolbar { display: none !important; }
.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
.leaflet-control-zoom a {
background: var(--bg-glass) !important;
color: var(--text-light) !important;
border-color: var(--border-glass) !important;
backdrop-filter: blur(10px);
}
.leaflet-control-zoom a:hover { background: var(--primary) !important; }
/* Leaflet Popup Premium */
.leaflet-popup-content-wrapper {
background: var(--bg-glass) !important;
backdrop-filter: blur(16px) !important;
border: 1px solid var(--border-glass) !important;
color: var(--text-light) !important;
border-radius: 16px !important;
box-shadow: var(--shadow) !important;
}
.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
.leaflet-popup-content { margin: 16px !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 p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
.leaflet-popup-content .btn-delete {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
margin-top: 12px;
width: 100%;
transition: all 0.2s;
}
.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
/* Custom Map Marker Icon Animation */
.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 {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
background: var(--bg-glass); backdrop-filter: blur(16px);
border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error { border-left-color: #EF4444; }
.toast.success { border-left-color: #10B981; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+92
View File
@@ -0,0 +1,92 @@
/* form.css
* Tanggung Jawab: Styling komponen form (input, button, select).
*/
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-main);
}
.form-control {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.95rem;
transition: var(--transition);
background: var(--surface);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
font-family: inherit;
font-weight: 600;
font-size: 0.9rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
gap: 8px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--danger);
color: white;
width: auto;
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-danger:hover {
background: #DC2626;
}
/* Micro-animation for active state */
.btn:active {
transform: translateY(1px);
}
/* Form Container Animation */
#form-container {
background: var(--surface);
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
margin-bottom: 20px;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS - Pertemuan 03</title>
<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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<link rel="stylesheet" href="css/main.css">
<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; }
.badge { background: var(--primary); padding: 2px 6px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: white; }
</style>
</head>
<body>
<div id="map"></div>
<div class="floating-dock">
<div class="header">
<h2>P03: Analisis Spasial</h2>
<p>Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).</p>
</div>
<div class="menu-group">
<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-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 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);">
<h3 style="margin:0 0 5px 0; font-size:0.9rem; color:#10B981;">Statistik Kavling (Choropleth)</h3>
<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>
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<span style="font-size:0.8rem;">Total Penduduk Miskin:</span>
<strong id="stat-total-warga" style="color:white;">0</strong>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:0.8rem;">Kavling Padat (>2 warga):</span>
<strong id="stat-kavling-merah" style="color:#EF4444;">0</strong>
</div>
</div>
<div id="form-container" style="display: none;"></div>
</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://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
import { initMap } from './modules/map.js';
import { setupDrawControls } from './modules/draw.js';
import { renderForm } from './modules/form.js';
import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, BASE_URL } from './services/api.service.js';
let appMap;
let drawControl;
const statistikService = {
getChoropleth: async () => {
const res = await fetch(`${BASE_URL}/03/backend/api/statistik.php`);
const json = await res.json();
return json.data;
}
};
export const createIcon = (svgPath, color) => L.divIcon({
className: 'custom-icon',
html: `<div style="background-color: ${color}; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); color: white;"><svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">${svgPath}</svg></div>`,
iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
});
const iconSPBU = (is24h) => createIcon('<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>', is24h ? '#10B981' : '#EF4444');
const iconRumah = createIcon('<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>', '#8B5CF6');
const iconWarga = createIcon('<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>', '#EF4444');
window.showToast = (msg, type='success') => {
const t = document.createElement('div'); t.className = `toast ${type}`; t.innerHTML = msg;
document.getElementById('toast-container').appendChild(t);
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 () => {
try {
const [spbu, jalan, choro, rumah, warga] = await Promise.all([
spbuService.getAll(), jalanService.getAll(), statistikService.getChoropleth(),
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);
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);
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;
L.geoJSON(choro, {
style: (f) => {
const count = f.properties.jumlah_warga_miskin;
let c = '#10B981'; // Hijau (0)
if(count > 0) c = '#F59E0B'; // Kuning (1-2)
if(count > 2) { c = '#EF4444'; merahCount++; } // Merah (>2)
return { color: c, fillColor: c, fillOpacity: 0.4, weight: 2 };
},
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
}).addTo(appMap);
document.getElementById('stat-total-warga').innerText = warga.features.length;
document.getElementById('stat-kavling-merah').innerText = merahCount;
} catch (e) { window.showToast("Gagal meload data: "+e.message, 'error'); }
};
const bindPopup = (layer, type, props) => {
let ext = '';
if(type==='spbu') ext = `<p>24 Jam: <strong style="color:${props.buka_24_jam?'#10B981':'#EF4444'}">${props.buka_24_jam?'Ya':'Tidak'}</strong></p>`;
if(type==='kavling') ext = `<p>Jml Warga Miskin: <strong>${props.jumlah_warga_miskin||0} Jiwa</strong></p>`;
layer.bindPopup(`
<div class="popup-custom">
<h3>${props.nama}</h3>
<p>${props.deskripsi || ''}</p>
${ext}
<button class="btn-delete" onclick="window.deleteData('${type}', ${props.id})">Hapus</button>
</div>
`);
};
window.deleteData = async (type, id) => {
if(!confirm('Yakin menghapus?')) return;
try {
if(type==='spbu') await spbuService.delete(id);
if(type==='jalan') await jalanService.delete(id);
if(type==='kavling') await kavlingService.delete(id);
if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
if(type==='warga_miskin') await wargaMiskinService.delete(id);
window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal hapus', 'error'); }
};
const handleGeometryCreated = (type, geometry, layer) => {
const tempLayer = L.geoJSON(geometry).addTo(appMap);
renderForm(type, geometry, async (payload) => {
try {
if(type==='spbu') await spbuService.create(payload);
if(type==='jalan') await jalanService.create(payload);
if(type==='kavling') await kavlingService.create(payload);
if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
if(type==='warga_miskin') await wargaMiskinService.create(payload);
window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
} catch(e) { window.showToast('Gagal simpan', 'error'); }
}, () => appMap.removeLayer(tempLayer));
};
+31
View File
@@ -0,0 +1,31 @@
export const setupDrawControls = (map, onGeometryCreated) => {
const drawControl = new L.Control.Draw({
draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
const type = event.layerType;
// Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
let customType = type;
if(type === 'marker') customType = 'spbu';
if(type === 'polyline') customType = 'jalan';
if(type === 'polygon') customType = 'kavling';
if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
});
// Custom Menu Handlers
const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
return drawControl;
};
+50
View File
@@ -0,0 +1,50 @@
export const renderForm = (type, geometry, onSubmit, onCancel) => {
const container = document.getElementById('form-container');
container.style.display = 'flex';
const extraFields = type === 'spbu' ? `
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="input-24jam">
<span>Buka 24 Jam</span>
</label>
</div>
` : '';
container.innerHTML = `
<div class="form-panel">
<h3 style="margin:0 0 10px 0; font-size:1rem; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px;">Simpan Data (${type.toUpperCase()})</h3>
<div class="form-group">
<input type="text" id="input-nama" class="form-control" placeholder="Nama / Label" required>
</div>
<div class="form-group">
<input type="text" id="input-deskripsi" class="form-control" placeholder="Deskripsi Singkat">
</div>
${extraFields}
<div style="display:flex; gap:10px; margin-top:8px;">
<button id="btn-save" class="btn-submit" style="flex:1;">Simpan</button>
<button id="btn-cancel" class="btn-submit" style="background:rgba(255,255,255,0.1); color:var(--text-light); flex:1;">Batal</button>
</div>
</div>
`;
document.getElementById('btn-save').addEventListener('click', () => {
const payload = {
type,
geometry,
nama: document.getElementById('input-nama').value,
deskripsi: document.getElementById('input-deskripsi').value,
};
if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
onSubmit(payload);
container.style.display = 'none';
container.innerHTML = '';
});
document.getElementById('btn-cancel').addEventListener('click', () => {
if(onCancel) onCancel();
container.style.display = 'none';
container.innerHTML = '';
});
};
+25
View File
@@ -0,0 +1,25 @@
/**
* map.js
* Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
*/
export const initMap = (containerId) => {
// Pusat peta default (Universitas Tanjungpura, Pontianak)
const map = L.map(containerId, {
zoomControl: false
}).setView([-0.0583, 109.3448], 15);
// Pindahkan zoom control ke kanan bawah
L.control.zoom({
position: 'bottomright'
}).addTo(map);
// Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
return map;
};
+49
View File
@@ -0,0 +1,49 @@
const getBaseUrl = () => {
const segments = window.location.pathname.split('/');
const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg));
if (targetIndex !== -1) {
return segments.slice(0, targetIndex).join('/');
}
return '';
};
export const BASE_URL = getBaseUrl();
const createService = (endpoint) => ({
getAll: async () => {
const res = await fetch(`${BASE_URL}/${endpoint}`);
const json = await res.json();
return json.data;
},
create: async (data) => {
const res = await fetch(`${BASE_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
return json.data;
},
delete: async (id) => {
const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
method: 'DELETE'
});
const json = await res.json();
return json.data;
}
});
// P01
export const spbuService = createService('01/backend/api/spbu.php');
export const jalanService = createService('01/backend/api/jalan.php');
export const kavlingService = createService('01/backend/api/kavling.php');
// P02
export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
export const haversineService = {
getDalamRadius: async (id, radius) => {
const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
const json = await res.json();
return json.data;
}
};
@@ -0,0 +1,22 @@
import { CONFIG } from '../config.js';
export const rumahIbadahService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
return await res.json();
},
getJangkauan: async (id, radius = 1) => {
const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
return await res.json();
}
};
@@ -0,0 +1,10 @@
export const CONFIG = {
BASE_URL: '/final/backend/api'
};
export const statistikService = {
getKepadatan: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/statistik.php`);
return await res.json();
}
};
@@ -0,0 +1,18 @@
import { CONFIG } from '../config.js';
export const wargaMiskinService = {
getAll: async () => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
return await res.json();
},
save: async (data) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
return await res.json();
},
delete: async (id) => {
const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
return await res.json();
}
};
+68
View File
@@ -0,0 +1,68 @@
/* map.css
* Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
*/
.map-container {
flex: 1;
position: relative;
background: #E2E8F0;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* Customizing Leaflet Controls */
.leaflet-control-zoom {
border: none !important;
box-shadow: var(--shadow-md) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a {
color: var(--text-main) !important;
background: var(--surface-glass) !important;
backdrop-filter: blur(8px);
transition: var(--transition);
}
.leaflet-control-zoom a:hover {
background: var(--surface) !important;
color: var(--primary) !important;
}
/* Custom Popup Modern */
.leaflet-popup-content-wrapper {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 4px;
}
.leaflet-popup-content {
margin: 14px;
font-family: 'Inter', sans-serif;
}
.popup-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 6px;
border-bottom: 2px solid var(--primary);
padding-bottom: 4px;
display: inline-block;
}
.popup-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.popup-action {
display: flex;
justify-content: flex-end;
}

Some files were not shown because too many files have changed in this diff Show More