commit cd376094df08e5771817190d2921e396631ead1e Author: Tazyeuu <126654209+Tazyeuu@users.noreply.github.com> Date: Wed Jun 10 22:07:47 2026 +0700 pertama dan terakhir diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe45183 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50448fa --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4896d54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# 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/ diff --git a/01/backend/api/jalan.php b/01/backend/api/jalan.php new file mode 100644 index 0000000..7264a11 --- /dev/null +++ b/01/backend/api/jalan.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/01/backend/api/kavling.php b/01/backend/api/kavling.php new file mode 100644 index 0000000..ed862a5 --- /dev/null +++ b/01/backend/api/kavling.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/01/backend/api/spbu.php b/01/backend/api/spbu.php new file mode 100644 index 0000000..7529ba1 --- /dev/null +++ b/01/backend/api/spbu.php @@ -0,0 +1,59 @@ +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; +} +?> diff --git a/01/backend/config/db.php b/01/backend/config/db.php new file mode 100644 index 0000000..287e5da --- /dev/null +++ b/01/backend/config/db.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/01/backend/utils/response_helper.php b/01/backend/utils/response_helper.php new file mode 100644 index 0000000..01e7070 --- /dev/null +++ b/01/backend/utils/response_helper.php @@ -0,0 +1,51 @@ + '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(); +} +?> diff --git a/01/frontend/css/base.css b/01/frontend/css/base.css new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/01/frontend/css/base.css @@ -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; +} diff --git a/01/frontend/css/form.css b/01/frontend/css/form.css new file mode 100644 index 0000000..be53320 --- /dev/null +++ b/01/frontend/css/form.css @@ -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); } +} diff --git a/01/frontend/css/main.css b/01/frontend/css/main.css new file mode 100644 index 0000000..14cadd4 --- /dev/null +++ b/01/frontend/css/main.css @@ -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; } } diff --git a/01/frontend/css/map.css b/01/frontend/css/map.css new file mode 100644 index 0000000..5cbc999 --- /dev/null +++ b/01/frontend/css/map.css @@ -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; +} diff --git a/01/frontend/css/sidebar.css b/01/frontend/css/sidebar.css new file mode 100644 index 0000000..5ce4b23 --- /dev/null +++ b/01/frontend/css/sidebar.css @@ -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; + } +} diff --git a/01/frontend/index.html b/01/frontend/index.html new file mode 100644 index 0000000..a851e3b --- /dev/null +++ b/01/frontend/index.html @@ -0,0 +1,56 @@ + + + + + + WebGIS - Pertemuan 01 + + + + + + + + +
+ + +
+
+

P01: Geometri

+

Sistem Informasi Geografis dengan arsitektur modern.

+
+ + + + + + +
+ + +
+ + + + + + + diff --git a/01/frontend/js/app.js b/01/frontend/js/app.js new file mode 100644 index 0000000..073b818 --- /dev/null +++ b/01/frontend/js/app.js @@ -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: `
+ ${svgPath} +
`, + iconSize: [34, 34], + iconAnchor: [17, 34], + popupAnchor: [0, -34] + }); +}; + +export const iconSPBU = (is24h) => createIcon('', 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 ? `

Buka 24 Jam: ${props.buka_24_jam ? 'Ya' : 'Tidak'}

` : ''; + layer.bindPopup(` + + `); +}; + +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); + }); +}; diff --git a/01/frontend/js/config.js b/01/frontend/js/config.js new file mode 100644 index 0000000..452904b --- /dev/null +++ b/01/frontend/js/config.js @@ -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' + } +}; diff --git a/01/frontend/js/modules/draw.js b/01/frontend/js/modules/draw.js new file mode 100644 index 0000000..40628f4 --- /dev/null +++ b/01/frontend/js/modules/draw.js @@ -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; +}; diff --git a/01/frontend/js/modules/form.js b/01/frontend/js/modules/form.js new file mode 100644 index 0000000..3959e52 --- /dev/null +++ b/01/frontend/js/modules/form.js @@ -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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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 = ''; + }); +}; diff --git a/01/frontend/js/modules/map.js b/01/frontend/js/modules/map.js new file mode 100644 index 0000000..fdecef9 --- /dev/null +++ b/01/frontend/js/modules/map.js @@ -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: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); + + return map; +}; diff --git a/01/frontend/js/services/api.service.js b/01/frontend/js/services/api.service.js new file mode 100644 index 0000000..36e7e5f --- /dev/null +++ b/01/frontend/js/services/api.service.js @@ -0,0 +1,41 @@ +export const BASE_URL = ''; + +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; + } +}; diff --git a/01/frontend/js/services/jalan.service.js b/01/frontend/js/services/jalan.service.js new file mode 100644 index 0000000..b83d1d4 --- /dev/null +++ b/01/frontend/js/services/jalan.service.js @@ -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(); + } +}; diff --git a/01/frontend/js/services/kavling.service.js b/01/frontend/js/services/kavling.service.js new file mode 100644 index 0000000..eb25543 --- /dev/null +++ b/01/frontend/js/services/kavling.service.js @@ -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(); + } +}; diff --git a/01/frontend/js/services/spbu.service.js b/01/frontend/js/services/spbu.service.js new file mode 100644 index 0000000..31c6b8d --- /dev/null +++ b/01/frontend/js/services/spbu.service.js @@ -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(); + } +}; diff --git a/01/frontend/js/utils/style.util.js b/01/frontend/js/utils/style.util.js new file mode 100644 index 0000000..565c238 --- /dev/null +++ b/01/frontend/js/utils/style.util.js @@ -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 {}; +}; diff --git a/01/frontend/js/utils/ui.util.js b/01/frontend/js/utils/ui.util.js new file mode 100644 index 0000000..a462ff5 --- /dev/null +++ b/01/frontend/js/utils/ui.util.js @@ -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'; + } +}; diff --git a/02/backend/api/jalan.php b/02/backend/api/jalan.php new file mode 100644 index 0000000..7264a11 --- /dev/null +++ b/02/backend/api/jalan.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/02/backend/api/kavling.php b/02/backend/api/kavling.php new file mode 100644 index 0000000..ed862a5 --- /dev/null +++ b/02/backend/api/kavling.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/02/backend/api/rumah_ibadah.php b/02/backend/api/rumah_ibadah.php new file mode 100644 index 0000000..1de6d5d --- /dev/null +++ b/02/backend/api/rumah_ibadah.php @@ -0,0 +1,89 @@ +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; +} +?> diff --git a/02/backend/api/spbu.php b/02/backend/api/spbu.php new file mode 100644 index 0000000..7529ba1 --- /dev/null +++ b/02/backend/api/spbu.php @@ -0,0 +1,59 @@ +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; +} +?> diff --git a/02/backend/api/warga_miskin.php b/02/backend/api/warga_miskin.php new file mode 100644 index 0000000..dca0aff --- /dev/null +++ b/02/backend/api/warga_miskin.php @@ -0,0 +1,67 @@ +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; +} +?> diff --git a/02/backend/config/db.php b/02/backend/config/db.php new file mode 100644 index 0000000..287e5da --- /dev/null +++ b/02/backend/config/db.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/02/backend/utils/geo_helper.php b/02/backend/utils/geo_helper.php new file mode 100644 index 0000000..4d0a44f --- /dev/null +++ b/02/backend/utils/geo_helper.php @@ -0,0 +1,66 @@ +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; + } +} +?> diff --git a/02/backend/utils/response_helper.php b/02/backend/utils/response_helper.php new file mode 100644 index 0000000..cd7968c --- /dev/null +++ b/02/backend/utils/response_helper.php @@ -0,0 +1,29 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} + +function sendError($message = 'Error', $statusCode = 400) { + http_response_code($statusCode); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} +?> diff --git a/02/frontend/css/base.css b/02/frontend/css/base.css new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/02/frontend/css/base.css @@ -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; +} diff --git a/02/frontend/css/form.css b/02/frontend/css/form.css new file mode 100644 index 0000000..be53320 --- /dev/null +++ b/02/frontend/css/form.css @@ -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); } +} diff --git a/02/frontend/css/main.css b/02/frontend/css/main.css new file mode 100644 index 0000000..14cadd4 --- /dev/null +++ b/02/frontend/css/main.css @@ -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; } } diff --git a/02/frontend/css/map.css b/02/frontend/css/map.css new file mode 100644 index 0000000..0656663 --- /dev/null +++ b/02/frontend/css/map.css @@ -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; +} diff --git a/02/frontend/css/sidebar.css b/02/frontend/css/sidebar.css new file mode 100644 index 0000000..5ce4b23 --- /dev/null +++ b/02/frontend/css/sidebar.css @@ -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; + } +} diff --git a/02/frontend/index.html b/02/frontend/index.html new file mode 100644 index 0000000..359e23e --- /dev/null +++ b/02/frontend/index.html @@ -0,0 +1,50 @@ + + + + + + WebGIS - Pertemuan 02 + + + + + + + +
+
+
+

P02: Tematik & Jarak

+

Kumulatif P01 + Analisis radius jarak Haversine.

+
+ + + + + +
+

Analisis Jarak

+

Klik [Cek Radius] di popup Rumah Ibadah mana pun.

+
+
+ + +
+
+ + + + + diff --git a/02/frontend/js/app.js b/02/frontend/js/app.js new file mode 100644 index 0000000..3485d03 --- /dev/null +++ b/02/frontend/js/app.js @@ -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: `
+ ${svgPath} +
`, + iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34] + }); +}; + +const iconSPBU = (is24h) => createIcon('', is24h ? '#10B981' : '#EF4444'); +const iconRumah = createIcon('', '#8B5CF6'); +const iconWarga = createIcon('', '#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 = `

24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}

`; + if(type==='rumah_ibadah' && feature) ext = ``; + + layer.bindPopup(` + + `); +}; + +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 ? '' : '

Tidak ada warga miskin.

'; + + res.features.forEach(f => { + const d = parseFloat(f.properties.jarak_km).toFixed(2); + container.innerHTML += `
${f.properties.nama}${d} km
`; + }); + + 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)); +}; diff --git a/02/frontend/js/config.js b/02/frontend/js/config.js new file mode 100644 index 0000000..8988fae --- /dev/null +++ b/02/frontend/js/config.js @@ -0,0 +1,4 @@ +export const CONFIG = { + BASE_URL: '/02/backend/api', + RADIUS_KM: 1.0 // Radius default 1 km +}; diff --git a/02/frontend/js/modules/draw.js b/02/frontend/js/modules/draw.js new file mode 100644 index 0000000..40628f4 --- /dev/null +++ b/02/frontend/js/modules/draw.js @@ -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; +}; diff --git a/02/frontend/js/modules/form.js b/02/frontend/js/modules/form.js new file mode 100644 index 0000000..3959e52 --- /dev/null +++ b/02/frontend/js/modules/form.js @@ -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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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 = ''; + }); +}; diff --git a/02/frontend/js/modules/map.js b/02/frontend/js/modules/map.js new file mode 100644 index 0000000..fdecef9 --- /dev/null +++ b/02/frontend/js/modules/map.js @@ -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: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); + + return map; +}; diff --git a/02/frontend/js/services/api.service.js b/02/frontend/js/services/api.service.js new file mode 100644 index 0000000..36e7e5f --- /dev/null +++ b/02/frontend/js/services/api.service.js @@ -0,0 +1,41 @@ +export const BASE_URL = ''; + +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; + } +}; diff --git a/02/frontend/js/services/rumah_ibadah.service.js b/02/frontend/js/services/rumah_ibadah.service.js new file mode 100644 index 0000000..dc20231 --- /dev/null +++ b/02/frontend/js/services/rumah_ibadah.service.js @@ -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(); + } +}; diff --git a/02/frontend/js/services/warga_miskin.service.js b/02/frontend/js/services/warga_miskin.service.js new file mode 100644 index 0000000..d9cee43 --- /dev/null +++ b/02/frontend/js/services/warga_miskin.service.js @@ -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(); + } +}; diff --git a/02/frontend/js/utils/ui.util.js b/02/frontend/js/utils/ui.util.js new file mode 100644 index 0000000..6ed9116 --- /dev/null +++ b/02/frontend/js/utils/ui.util.js @@ -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
dalam radius ${radiusKm} km`; + + if (wargaList.length === 0) { + list.innerHTML = `

Tidak ada data dalam radius ini.

`; + return; + } + + list.innerHTML = wargaList.map(w => ` +
+
+ ${w.nama_kk} + ${w.jarak_km} km +
+
Rp ${w.penghasilan.toLocaleString('id-ID')} | ${w.jumlah_tanggungan} orang
+
+ `).join(''); +}; + +export const hideResultPanel = () => { + document.getElementById('result-panel').style.display = 'none'; +}; diff --git a/03/backend/api/jalan.php b/03/backend/api/jalan.php new file mode 100644 index 0000000..7264a11 --- /dev/null +++ b/03/backend/api/jalan.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/03/backend/api/kavling.php b/03/backend/api/kavling.php new file mode 100644 index 0000000..ed862a5 --- /dev/null +++ b/03/backend/api/kavling.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/03/backend/api/rumah_ibadah.php b/03/backend/api/rumah_ibadah.php new file mode 100644 index 0000000..1de6d5d --- /dev/null +++ b/03/backend/api/rumah_ibadah.php @@ -0,0 +1,89 @@ +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; +} +?> diff --git a/03/backend/api/spbu.php b/03/backend/api/spbu.php new file mode 100644 index 0000000..7529ba1 --- /dev/null +++ b/03/backend/api/spbu.php @@ -0,0 +1,59 @@ +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; +} +?> diff --git a/03/backend/api/statistik.php b/03/backend/api/statistik.php new file mode 100644 index 0000000..b5712c6 --- /dev/null +++ b/03/backend/api/statistik.php @@ -0,0 +1,71 @@ +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); +} +?> diff --git a/03/backend/api/warga_miskin.php b/03/backend/api/warga_miskin.php new file mode 100644 index 0000000..dca0aff --- /dev/null +++ b/03/backend/api/warga_miskin.php @@ -0,0 +1,67 @@ +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; +} +?> diff --git a/03/backend/config/db.php b/03/backend/config/db.php new file mode 100644 index 0000000..287e5da --- /dev/null +++ b/03/backend/config/db.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/03/backend/utils/response_helper.php b/03/backend/utils/response_helper.php new file mode 100644 index 0000000..cd7968c --- /dev/null +++ b/03/backend/utils/response_helper.php @@ -0,0 +1,29 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} + +function sendError($message = 'Error', $statusCode = 400) { + http_response_code($statusCode); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} +?> diff --git a/03/frontend/base.css b/03/frontend/base.css new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/03/frontend/base.css @@ -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; +} diff --git a/03/frontend/css/main.css b/03/frontend/css/main.css new file mode 100644 index 0000000..14cadd4 --- /dev/null +++ b/03/frontend/css/main.css @@ -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; } } diff --git a/03/frontend/form.css b/03/frontend/form.css new file mode 100644 index 0000000..be53320 --- /dev/null +++ b/03/frontend/form.css @@ -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); } +} diff --git a/03/frontend/index.html b/03/frontend/index.html new file mode 100644 index 0000000..968c82c --- /dev/null +++ b/03/frontend/index.html @@ -0,0 +1,53 @@ + + + + + + WebGIS - Pertemuan 03 + + + + + + + +
+
+
+

P03: Analisis Spasial

+

Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).

+
+ + + +
+

Statistik Kavling (Choropleth)

+

Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.

+
+ Total Penduduk Miskin: + 0 +
+
+ Kavling Padat (>2 warga): + 0 +
+
+ + +
+
+ + + + + diff --git a/03/frontend/js/app.js b/03/frontend/js/app.js new file mode 100644 index 0000000..3941765 --- /dev/null +++ b/03/frontend/js/app.js @@ -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: `
${svgPath}
`, + iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34] +}); + +const iconSPBU = (is24h) => createIcon('', is24h ? '#10B981' : '#EF4444'); +const iconRumah = createIcon('', '#8B5CF6'); +const iconWarga = createIcon('', '#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 = `

24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}

`; + if(type==='kavling') ext = `

Jml Warga Miskin: ${props.jumlah_warga_miskin||0} Jiwa

`; + + layer.bindPopup(` + + `); +}; + +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)); +}; diff --git a/03/frontend/js/modules/draw.js b/03/frontend/js/modules/draw.js new file mode 100644 index 0000000..40628f4 --- /dev/null +++ b/03/frontend/js/modules/draw.js @@ -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; +}; diff --git a/03/frontend/js/modules/form.js b/03/frontend/js/modules/form.js new file mode 100644 index 0000000..3959e52 --- /dev/null +++ b/03/frontend/js/modules/form.js @@ -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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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 = ''; + }); +}; diff --git a/03/frontend/js/modules/map.js b/03/frontend/js/modules/map.js new file mode 100644 index 0000000..fdecef9 --- /dev/null +++ b/03/frontend/js/modules/map.js @@ -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: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); + + return map; +}; diff --git a/03/frontend/js/services/api.service.js b/03/frontend/js/services/api.service.js new file mode 100644 index 0000000..36e7e5f --- /dev/null +++ b/03/frontend/js/services/api.service.js @@ -0,0 +1,41 @@ +export const BASE_URL = ''; + +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; + } +}; diff --git a/03/frontend/js/services/rumah_ibadah.service.js b/03/frontend/js/services/rumah_ibadah.service.js new file mode 100644 index 0000000..dc20231 --- /dev/null +++ b/03/frontend/js/services/rumah_ibadah.service.js @@ -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(); + } +}; diff --git a/03/frontend/js/services/statistik.service.js b/03/frontend/js/services/statistik.service.js new file mode 100644 index 0000000..d0afd0d --- /dev/null +++ b/03/frontend/js/services/statistik.service.js @@ -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(); + } +}; diff --git a/03/frontend/js/services/warga_miskin.service.js b/03/frontend/js/services/warga_miskin.service.js new file mode 100644 index 0000000..d9cee43 --- /dev/null +++ b/03/frontend/js/services/warga_miskin.service.js @@ -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(); + } +}; diff --git a/03/frontend/map.css b/03/frontend/map.css new file mode 100644 index 0000000..0656663 --- /dev/null +++ b/03/frontend/map.css @@ -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; +} diff --git a/03/frontend/sidebar.css b/03/frontend/sidebar.css new file mode 100644 index 0000000..5ce4b23 --- /dev/null +++ b/03/frontend/sidebar.css @@ -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; + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f07a0b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +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/ + +RUN chown -R www-data:www-data /var/www/html + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f22db8 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# WebGIS Poverty Map + +Aplikasi WebGIS PHP + MySQL untuk pemetaan data kemiskinan, fasilitas, laporan warga, dan analisis blank spot bantuan. + +## Stack + +- PHP 8.2 Apache +- MySQL/MariaDB +- Leaflet +- PDO MySQL + +## Deploy Coolify + +1. Buat database MySQL/MariaDB di Coolify. +2. Import `database/webgis_db.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 +``` + +Root aplikasi menampilkan halaman pemilih progres dari `index.html`. Versi final berjalan di `/project_final`, jadi gunakan `APP_BASE_PATH=/project_final` untuk deploy Coolify ini. + +`CORS_ALLOWED_ORIGIN` boleh dikosongkan untuk same-origin deployment. Isi dengan domain aplikasi jika API memang perlu diakses dari origin lain. + +## Akun Awal + +Setelah import SQL: + +- Admin: `admin / admin123` +- Pengguna: `pengguna / user123` + +Ganti password akun demo setelah deploy jika aplikasi dibuka publik. + +## Struktur Penting + +- `index.html` - halaman awal untuk memilih progres. +- `01/`, `02/`, `03/`, `final/` - progres pertemuan. +- `project_final/` - aplikasi final yang berjalan di `/project_final`. +- `database/webgis_db.sql` - struktur dan seed database lengkap. +- `Dockerfile` - image PHP Apache untuk Coolify. +- `.env.example` - contoh environment variable deploy. + +## Local XAMPP + +Untuk menjalankan dari XAMPP seperti struktur lama, set base path: + +```env +APP_BASE_PATH=/project/project_final +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=webgis_db +DB_USERNAME=root +DB_PASSWORD= +``` + +Import `database/webgis_db.sql`, lalu buka `/project/project_final/login.php`. diff --git a/database/create_tables.sql b/database/create_tables.sql new file mode 100644 index 0000000..f7dfc13 --- /dev/null +++ b/database/create_tables.sql @@ -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; diff --git a/database/seed_pontianak.sql b/database/seed_pontianak.sql new file mode 100644 index 0000000..d0d3b70 --- /dev/null +++ b/database/seed_pontianak.sql @@ -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)') + ); diff --git a/database/seed_users.sql b/database/seed_users.sql new file mode 100644 index 0000000..32d5b0a --- /dev/null +++ b/database/seed_users.sql @@ -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); diff --git a/database/webgis_db.sql b/database/webgis_db.sql new file mode 100644 index 0000000..f05a77e --- /dev/null +++ b/database/webgis_db.sql @@ -0,0 +1,282 @@ +-- ============================================================ +-- WebGIS Pontianak - Database Lengkap +-- ============================================================ +-- Import file ini jika ingin membuat ulang database dari awal. +-- Perhatian: tabel lama dengan nama yang sama akan dihapus. + +CREATE DATABASE IF NOT EXISTS webgis_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE webgis_db; + +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; diff --git a/final/backend/api/jalan.php b/final/backend/api/jalan.php new file mode 100644 index 0000000..7264a11 --- /dev/null +++ b/final/backend/api/jalan.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/final/backend/api/kavling.php b/final/backend/api/kavling.php new file mode 100644 index 0000000..ed862a5 --- /dev/null +++ b/final/backend/api/kavling.php @@ -0,0 +1,75 @@ +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; +} +?> diff --git a/final/backend/api/rumah_ibadah.php b/final/backend/api/rumah_ibadah.php new file mode 100644 index 0000000..1de6d5d --- /dev/null +++ b/final/backend/api/rumah_ibadah.php @@ -0,0 +1,89 @@ +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; +} +?> diff --git a/final/backend/api/spbu.php b/final/backend/api/spbu.php new file mode 100644 index 0000000..7529ba1 --- /dev/null +++ b/final/backend/api/spbu.php @@ -0,0 +1,59 @@ +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; +} +?> diff --git a/final/backend/api/statistik.php b/final/backend/api/statistik.php new file mode 100644 index 0000000..b5712c6 --- /dev/null +++ b/final/backend/api/statistik.php @@ -0,0 +1,71 @@ +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); +} +?> diff --git a/final/backend/api/warga_miskin.php b/final/backend/api/warga_miskin.php new file mode 100644 index 0000000..dca0aff --- /dev/null +++ b/final/backend/api/warga_miskin.php @@ -0,0 +1,67 @@ +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; +} +?> diff --git a/final/backend/config/db.php b/final/backend/config/db.php new file mode 100644 index 0000000..287e5da --- /dev/null +++ b/final/backend/config/db.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/final/backend/utils/response_helper.php b/final/backend/utils/response_helper.php new file mode 100644 index 0000000..cd7968c --- /dev/null +++ b/final/backend/utils/response_helper.php @@ -0,0 +1,29 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} + +function sendError($message = 'Error', $statusCode = 400) { + http_response_code($statusCode); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} +?> diff --git a/final/frontend/base.css b/final/frontend/base.css new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/final/frontend/base.css @@ -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; +} diff --git a/final/frontend/css/main.css b/final/frontend/css/main.css new file mode 100644 index 0000000..14cadd4 --- /dev/null +++ b/final/frontend/css/main.css @@ -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; } } diff --git a/final/frontend/form.css b/final/frontend/form.css new file mode 100644 index 0000000..be53320 --- /dev/null +++ b/final/frontend/form.css @@ -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); } +} diff --git a/final/frontend/index.html b/final/frontend/index.html new file mode 100644 index 0000000..968c82c --- /dev/null +++ b/final/frontend/index.html @@ -0,0 +1,53 @@ + + + + + + WebGIS - Pertemuan 03 + + + + + + + +
+
+
+

P03: Analisis Spasial

+

Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).

+
+ + + +
+

Statistik Kavling (Choropleth)

+

Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.

+
+ Total Penduduk Miskin: + 0 +
+
+ Kavling Padat (>2 warga): + 0 +
+
+ + +
+
+ + + + + diff --git a/final/frontend/js/app.js b/final/frontend/js/app.js new file mode 100644 index 0000000..3941765 --- /dev/null +++ b/final/frontend/js/app.js @@ -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: `
${svgPath}
`, + iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34] +}); + +const iconSPBU = (is24h) => createIcon('', is24h ? '#10B981' : '#EF4444'); +const iconRumah = createIcon('', '#8B5CF6'); +const iconWarga = createIcon('', '#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 = `

24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}

`; + if(type==='kavling') ext = `

Jml Warga Miskin: ${props.jumlah_warga_miskin||0} Jiwa

`; + + layer.bindPopup(` + + `); +}; + +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)); +}; diff --git a/final/frontend/js/modules/draw.js b/final/frontend/js/modules/draw.js new file mode 100644 index 0000000..40628f4 --- /dev/null +++ b/final/frontend/js/modules/draw.js @@ -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; +}; diff --git a/final/frontend/js/modules/form.js b/final/frontend/js/modules/form.js new file mode 100644 index 0000000..3959e52 --- /dev/null +++ b/final/frontend/js/modules/form.js @@ -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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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 = ''; + }); +}; diff --git a/final/frontend/js/modules/map.js b/final/frontend/js/modules/map.js new file mode 100644 index 0000000..fdecef9 --- /dev/null +++ b/final/frontend/js/modules/map.js @@ -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: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); + + return map; +}; diff --git a/final/frontend/js/services/api.service.js b/final/frontend/js/services/api.service.js new file mode 100644 index 0000000..36e7e5f --- /dev/null +++ b/final/frontend/js/services/api.service.js @@ -0,0 +1,41 @@ +export const BASE_URL = ''; + +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; + } +}; diff --git a/final/frontend/js/services/rumah_ibadah.service.js b/final/frontend/js/services/rumah_ibadah.service.js new file mode 100644 index 0000000..dc20231 --- /dev/null +++ b/final/frontend/js/services/rumah_ibadah.service.js @@ -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(); + } +}; diff --git a/final/frontend/js/services/statistik.service.js b/final/frontend/js/services/statistik.service.js new file mode 100644 index 0000000..c41a736 --- /dev/null +++ b/final/frontend/js/services/statistik.service.js @@ -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(); + } +}; diff --git a/final/frontend/js/services/warga_miskin.service.js b/final/frontend/js/services/warga_miskin.service.js new file mode 100644 index 0000000..d9cee43 --- /dev/null +++ b/final/frontend/js/services/warga_miskin.service.js @@ -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(); + } +}; diff --git a/final/frontend/map.css b/final/frontend/map.css new file mode 100644 index 0000000..0656663 --- /dev/null +++ b/final/frontend/map.css @@ -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; +} diff --git a/final/frontend/sidebar.css b/final/frontend/sidebar.css new file mode 100644 index 0000000..5ce4b23 --- /dev/null +++ b/final/frontend/sidebar.css @@ -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; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..1ba1c19 --- /dev/null +++ b/index.html @@ -0,0 +1,320 @@ + + + + + + Portal WebGIS - Sistem Informasi Geografis + + + + + + + + +
+
+ +
+

Sistem Informasi Geografis

+

Direktori tugas dan proyek akhir mata kuliah Sistem Informasi Geografis. Pilih modul pembelajaran di bawah ini untuk memulai.

+
+ +
+ + + +
+ +
+
Pertemuan 01
+

Geometri Dasar

+

Fitur menggambar peta dasar dan CRUD spasial.

+ +
+ + + +
+ +
+
Pertemuan 02
+

Haversine & Tematik

+

Pemetaan sosial dan query pencarian radius jarak.

+ +
+ + + +
+ +
+
Pertemuan 03
+

Analisis Spasial

+

Visualisasi Peta Choropleth dinamis (Point-in-Polygon).

+ +
+ + + +
+ +
+
Proyek Akhir
+

Final UAS

+

Sistem komprehensif untuk evaluasi akhir semester.

+ +
+ +
+ + + diff --git a/project_final/admin/data_jalan.php b/project_final/admin/data_jalan.php new file mode 100644 index 0000000..ae32088 --- /dev/null +++ b/project_final/admin/data_jalan.php @@ -0,0 +1,79 @@ +query("SELECT id,nama,jenis_jalan,created_at FROM jalan ORDER BY id DESC")->fetchAll(); +$pageTitle='Data Jalan'; $activeNav='jalan'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+ + +
+
+ + + + $r): ?> + + + + + + + + + + +
#Nama JalanJenisDitambahkanAksi
+ + +
Belum ada data jalan.
+ + + + + + +const API=APP_BASE + '/api/jalan.php'; +function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editJenis').value=r.jenis_jalan||'Jalan Lokal'; openModal('modalEdit'); } +async function simpan(){ const f=document.getElementById('formTambah'); const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,jenis_jalan:f.jenis_jalan.value,geometry:{type:'LineString',coordinates:[]}})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } +async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,jenis_jalan:document.getElementById('editJenis').value})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } +async function hapus(id,nama){ if(!confirm(`Hapus jalan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Jalan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } + +JS; ?> + diff --git a/project_final/admin/data_kavling.php b/project_final/admin/data_kavling.php new file mode 100644 index 0000000..0de57bd --- /dev/null +++ b/project_final/admin/data_kavling.php @@ -0,0 +1,82 @@ +query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at FROM kavling ORDER BY id DESC")->fetchAll(); +$pageTitle='Data Kavling'; $activeNav='kavling'; +require_once __DIR__ . '/partials/header.php'; +$statusColors = ['SHM'=>'badge-success','HGB'=>'badge-info','HGU'=>'badge-warning','HP'=>'badge-primary']; +$statusFull = ['SHM'=>'Sertifikat Hak Milik','HGB'=>'Hak Guna Bangunan','HGU'=>'Hak Guna Usaha','HP'=>'Hak Pakai']; +?> + +
+ + +
+
+ + + + $r): $sk=$r['status_kepemilikan']; ?> + + + + + + + + + + + +
#Nama PemilikStatus KepemilikanLuas (m²)DitambahkanAksi
+ + +
Belum ada data kavling.
+ + + + + + +const API=APP_BASE + '/api/kavling.php'; +function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_pemilik; document.getElementById('editStatus').value=r.status_kepemilikan; document.getElementById('editLuas').value=r.luas||0; openModal('modalEdit'); } +async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_pemilik.value){showToast('Nama pemilik wajib diisi!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:f.nama_pemilik.value,status_kepemilikan:f.status_kepemilikan.value,luas:parseFloat(f.luas.value)||0,geometry:{type:'Polygon',coordinates:[[]]}})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } +async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:document.getElementById('editNama').value,status_kepemilikan:document.getElementById('editStatus').value,luas:parseFloat(document.getElementById('editLuas').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } +async function hapus(id,nama){ if(!confirm(`Hapus kavling milik "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kavling dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } + +JS; ?> + diff --git a/project_final/admin/data_kawasan.php b/project_final/admin/data_kawasan.php new file mode 100644 index 0000000..c044ed9 --- /dev/null +++ b/project_final/admin/data_kawasan.php @@ -0,0 +1,57 @@ +query("SELECT k.id, k.nama_kawasan, k.created_at, COUNT(w.id) as jumlah_warga + FROM kawasan_kumuh k LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom) + GROUP BY k.id ORDER BY k.id DESC")->fetchAll(); +$pageTitle='Kawasan Kumuh'; $activeNav='kawasan'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+ + Gambar di Peta +
+
+ + + + $r): $cnt=(int)$r['jumlah_warga']; $status=$cnt>3?'Rawan Kumuh':($cnt>0?'Perlu Perhatian':'Aman'); $badge=$cnt>3?'badge-danger':($cnt>0?'badge-warning':'badge-success'); ?> + + + + + + + + + + + +
#Nama KawasanWarga TerdampakStatusDitambahkanAksi
+ + +
Belum ada data kawasan. Gambar polygon di peta untuk menambahkan.
+ + + + +const API=APP_BASE + '/api/kawasan_kumuh.php'; +function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kawasan; openModal('modalEdit'); } +async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kawasan:document.getElementById('editNama').value})}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } +async function hapus(id,nama){ if(!confirm(`Hapus kawasan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); } + +JS; ?> + diff --git a/project_final/admin/data_laporan.php b/project_final/admin/data_laporan.php new file mode 100644 index 0000000..47582fc --- /dev/null +++ b/project_final/admin/data_laporan.php @@ -0,0 +1,191 @@ +query($sql)->fetchAll(); + +$pageTitle = 'Laporan Warga'; +$activeNav = 'laporan'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; + +function badgeStatus($status) { + if($status === 'menunggu') return 'badge-warning'; + if($status === 'diproses') return 'badge-info'; + if($status === 'selesai') return 'badge-success'; + if($status === 'ditolak') return 'badge-danger'; + return 'badge-primary'; +} +?> + + + +
+ +
+ +
+ + + + + + + + + + + + + + $r): ?> + + + + + + + + + + + + + + +
#PelaporKategoriDeskripsiStatusTanggalAksi
+ + +
+ + + +
+
Belum ada laporan dari warga.
+
+ + + + + + + + + +JS; ?> + diff --git a/project_final/admin/data_rumah.php b/project_final/admin/data_rumah.php new file mode 100644 index 0000000..ec8dcc0 --- /dev/null +++ b/project_final/admin/data_rumah.php @@ -0,0 +1,140 @@ +query("SELECT id,nama,agama,radius_bantuan_meter,created_at FROM rumah_ibadah ORDER BY id DESC")->fetchAll(); +$pageTitle='Rumah Ibadah'; $activeNav='rumah'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; +$agamaIcon = ['Islam'=>'🕌','Kristen'=>'⛪','Katolik'=>'⛪','Hindu'=>'🛕','Buddha'=>'☸️','Konghucu'=>'🏯']; +$agamaBadge = ['Islam'=>'badge-success','Kristen'=>'badge-info','Katolik'=>'badge-primary','Hindu'=>'badge-warning','Buddha'=>'badge-danger','Konghucu'=>'badge-warning']; + +$grouped = []; +foreach($agamaIcon as $ag => $ic) { $grouped[$ag] = []; } +foreach($rows as $r) { + if(!isset($grouped[$r['agama']])) $grouped[$r['agama']] = []; + $grouped[$r['agama']][] = $r; +} +?> + +
+ + +
+ $list): ?> +

+ +

+
+ + + + $r): ?> + + + + + + + + + + +
#NamaRadius BantuanDitambahkanAksi
m
+ + +
Belum ada data .
+ + + + + + + + +JS; ?> + diff --git a/project_final/admin/data_spbu.php b/project_final/admin/data_spbu.php new file mode 100644 index 0000000..a9cb598 --- /dev/null +++ b/project_final/admin/data_spbu.php @@ -0,0 +1,164 @@ +query("SELECT id, nama, deskripsi, buka_24_jam, created_at FROM spbu ORDER BY id DESC")->fetchAll(); +$pageTitle = 'Data SPBU'; $activeNav = 'spbu'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; +?> + +
+ + +
+
+ + + + + + $r): ?> + + + + + + + + + + + +
#Nama SPBUDeskripsiStatusDitambahkanAksi
+ + +
Belum ada data SPBU. Tambah sekarang
+
+ + + + + + + + + +JS; ?> + diff --git a/project_final/admin/data_warga.php b/project_final/admin/data_warga.php new file mode 100644 index 0000000..4877948 --- /dev/null +++ b/project_final/admin/data_warga.php @@ -0,0 +1,101 @@ +query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at FROM warga_miskin ORDER BY id DESC")->fetchAll(); +$pageTitle='Warga Miskin'; $activeNav='warga'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; +?> + +
+ + +
+
+ + + + $r): ?> + + + + + + + + + + + +
#Nama KKPenghasilan/BlnTanggunganDitambahkanAksi
Rp orang
+ + +
Belum ada data.
+ + + + + + + +JS; ?> + diff --git a/project_final/admin/index.php b/project_final/admin/index.php new file mode 100644 index 0000000..6f6dc51 --- /dev/null +++ b/project_final/admin/index.php @@ -0,0 +1,172 @@ +query("SELECT + (SELECT COUNT(*) FROM spbu) as spbu, + (SELECT COUNT(*) FROM jalan) as jalan, + (SELECT COUNT(*) FROM kavling) as kavling, + (SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah, + (SELECT COUNT(*) FROM warga_miskin) as warga_miskin, + (SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh, + (SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam, + (SELECT COUNT(*) FROM users) as total_users +")->fetch(); + +// Kawasan merah +$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM ( + SELECT k.id FROM kawasan_kumuh k + LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom) + GROUP BY k.id HAVING COUNT(w.id) > 3 +) sub")->fetch(); +$kawasanMerah = (int)($merahResult['cnt'] ?? 0); + +// Warga miskin di luar semua radius bantuan rumah ibadah +$blankCount = $pdo->query(" + SELECT COUNT(*) as cnt + FROM warga_miskin w + WHERE NOT EXISTS ( + SELECT 1 + FROM rumah_ibadah ri + WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter + ) +")->fetch(); +$blankSpot = (int)($blankCount['cnt'] ?? 0); + +// Recent data +$recentSpbu = $pdo->query("SELECT nama, buka_24_jam, created_at FROM spbu ORDER BY created_at DESC LIMIT 5")->fetchAll(); +$recentWarga = $pdo->query("SELECT nama_kk, penghasilan, jumlah_tanggungan, created_at FROM warga_miskin ORDER BY created_at DESC LIMIT 5")->fetchAll(); + +$pageTitle = 'Dashboard'; +$activeNav = 'dashboard'; +require_once __DIR__ . '/partials/header.php'; +?> + + +
+
+
+

🗺️ Buka Peta Interaktif

+

+ Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data. +

+
+
+ + Buka Peta Admin + +
+
+
+ + +
+
+
+
+
Total SPBU
+
+
buka 24 jam
+
+
+
+
+
+
Ruas Jalan
+
+
Segmen terpetakan
+
+
+
+
+
+
Rumah Ibadah
+
+
5 agama tercakup
+
+
+
+
+
+
Warga Miskin
+
+
blank spot
+
+
+
+
+
+
Kawasan Rawan
+
+
dari kawasan
+
+
+
+
+
+
Kavling / Parsil
+
+
Bidang tanah tercatat
+
+
+
+ + +
+ + +
+
+

⛽ SPBU Terbaru

+ Lihat Semua +
+ +
+ +
+
+
+
+
+ + + +
+ +
+ +

Belum ada data SPBU

+ +
+ + +
+
+

👥 Warga Terdaftar

+ Lihat Semua +
+ +
+ +
+
+
+
Rp /bln · tanggungan
+
+ org +
+ +
+ +

Belum ada data warga

+ +
+
+ + + + diff --git a/project_final/admin/partials/footer.php b/project_final/admin/partials/footer.php new file mode 100644 index 0000000..a2474e0 --- /dev/null +++ b/project_final/admin/partials/footer.php @@ -0,0 +1,119 @@ + + + + +
+ + + + + diff --git a/project_final/admin/partials/header.php b/project_final/admin/partials/header.php new file mode 100644 index 0000000..1c27689 --- /dev/null +++ b/project_final/admin/partials/header.php @@ -0,0 +1,110 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Admin Panel') ?> — WebGIS Smart City + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Buka Peta + +
+
+
diff --git a/project_final/admin/peta.php b/project_final/admin/peta.php new file mode 100644 index 0000000..99762be --- /dev/null +++ b/project_final/admin/peta.php @@ -0,0 +1,173 @@ + + + +'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+
+ + + +
+ + + + + + + +
+ + + +
+
Legenda
+
+
SPBU 24 Jam
+
SPBU Terbatas
+
Jalan
+
Kavling
+
Rumah Ibadah
+
Warga Miskin
+
Kawasan Kumuh
+
Blank Spot
+
+
+ + + +
+
+ +
+
+
+
+ + + + + + diff --git a/project_final/admin/users.php b/project_final/admin/users.php new file mode 100644 index 0000000..26c3619 --- /dev/null +++ b/project_final/admin/users.php @@ -0,0 +1,179 @@ +query("SELECT id, username, role, nama_lengkap, created_at FROM users ORDER BY id ASC")->fetchAll(); +$pageTitle = 'Kelola Users'; +$activeNav = 'users'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+ + +
+
+ + + + + + $r): ?> + + + + + + + + + + +
#UsernameNama LengkapRoleDibuatAksi
+ + 👨‍💼 Admin + + 👤 Pengguna + +
+ + +
+
+ + + + + + + + +const API = APP_BASE + '/api/users.php'; + +function editRow(r) { + document.getElementById('editId').value = r.id; + document.getElementById('editUsername').value = r.username; + document.getElementById('editNama').value = r.nama_lengkap || ''; + document.getElementById('editRole').value = r.role; + document.getElementById('editPassword').value = ''; + openModal('modalEdit'); +} + +async function simpan() { + const u = document.getElementById('tUsername').value.trim(); + const p = document.getElementById('tPassword').value; + if (!u) return showToast('Username wajib!', 'error'); + if (p.length < 6) return showToast('Password minimal 6 karakter!', 'error'); + + const res = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: u, + nama_lengkap: document.getElementById('tNama').value, + role: document.getElementById('tRole').value, + password: p + }) + }); + const d = await res.json(); + if (d.status === 'success') { showToast('User berhasil ditambahkan!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +async function update() { + const id = document.getElementById('editId').value; + const u = document.getElementById('editUsername').value.trim(); + const p = document.getElementById('editPassword').value; + if (!u) return showToast('Username wajib!', 'error'); + if (p && p.length < 6) return showToast('Password minimal 6 karakter!', 'error'); + + const body = { + username: u, + nama_lengkap: document.getElementById('editNama').value, + role: document.getElementById('editRole').value + }; + if (p) body.password = p; + + const res = await fetch(API + '?id=' + id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const d = await res.json(); + if (d.status === 'success') { showToast('User diperbarui!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +async function hapus(id, username) { + if (id == 1) return showToast('Admin utama (ID=1) tidak dapat dihapus!', 'error'); + if (!confirm(`Hapus user "${username}"?`)) return; + const res = await fetch(API + '?id=' + id, { method: 'DELETE' }); + const d = await res.json(); + if (d.status === 'success') { showToast('User dihapus!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +JS; ?> + diff --git a/project_final/api/blank_spot.php b/project_final/api/blank_spot.php new file mode 100644 index 0000000..81b7d44 --- /dev/null +++ b/project_final/api/blank_spot.php @@ -0,0 +1,66 @@ +query($sql); +$features = []; + +while ($r = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], + 'nama_kk' => $r['nama_kk'], + 'jarak_km' => round(((float)$r['jarak_meter']) / 1000, 2), + 'radius_km' => round(((int)$r['radius_bantuan_meter']) / 1000, 2), + 'selisih_km' => round(((float)$r['selisih_meter']) / 1000, 2), + 'rumah_ibadah_terdekat' => $r['rumah_ibadah_terdekat'] + ] + ]; +} + +sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Blank Spot'); diff --git a/project_final/api/helpers.php b/project_final/api/helpers.php new file mode 100644 index 0000000..16a36bd --- /dev/null +++ b/project_final/api/helpers.php @@ -0,0 +1,52 @@ + 'success', 'message' => $message, 'data' => $data]); + exit; +} + +function sendError(string $message = 'Bad Request', int $code = 400): void { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message, 'data' => null]); + exit; +} + +function getInput(): array { + return json_decode(file_get_contents('php://input'), true) ?? []; +} + +function requireApiRole(string $role): void { + if (session_status() === PHP_SESSION_NONE) { + startAppSession(); + } + + if (!isset($_SESSION['user_id'], $_SESSION['role'])) { + sendError('Unauthorized', 401); + } + + if ($_SESSION['role'] !== $role) { + sendError('Forbidden', 403); + } +} + +function requireAdminForMutation(): void { + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + requireApiRole('admin'); + } +} diff --git a/project_final/api/jalan.php b/project_final/api/jalan.php new file mode 100644 index 0000000..3c21ed8 --- /dev/null +++ b/project_final/api/jalan.php @@ -0,0 +1,22 @@ +query("SELECT id,nama,jenis_jalan,created_at,ST_AsGeoJSON(geom) as geojson FROM jalan ORDER BY id DESC"); + $features=[]; + while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama'=>$r['nama'],'jenis_jalan'=>$r['jenis_jalan'],'created_at'=>$r['created_at']]]; + sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Jalan'); break; + case 'POST': + $d=getInput(); if(empty($d['nama'])||empty($d['geometry'])) sendError('Nama & geometri wajib'); + $pdo->prepare("INSERT INTO jalan(nama,jenis_jalan,geom) VALUES(?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama'],$d['jenis_jalan']??'Lokal',json_encode($d['geometry'])]); + sendSuccess(['id'=>$pdo->lastInsertId()],'Jalan disimpan',201); break; + case 'PUT': + $d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("UPDATE jalan SET nama=?,jenis_jalan=? WHERE id=?")->execute([$d['nama'],$d['jenis_jalan'],$id]); + sendSuccess(null,'Jalan diperbarui'); break; + case 'DELETE': + $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM jalan WHERE id=?")->execute([$id]); sendSuccess(null,'Jalan dihapus'); break; + default: sendError('Method not allowed',405); +} diff --git a/project_final/api/kavling.php b/project_final/api/kavling.php new file mode 100644 index 0000000..0c5fca3 --- /dev/null +++ b/project_final/api/kavling.php @@ -0,0 +1,26 @@ +query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at,ST_AsGeoJSON(geom) as geojson FROM kavling ORDER BY id DESC"); + $features=[]; + while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_pemilik'=>$r['nama_pemilik'],'status_kepemilikan'=>$r['status_kepemilikan'],'luas'=>$r['luas'],'created_at'=>$r['created_at']]]; + sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Kavling'); break; + case 'POST': + $d=getInput(); if(empty($d['nama_pemilik'])||empty($d['status_kepemilikan'])||empty($d['geometry'])) sendError('Data tidak lengkap'); + $pdo->prepare("INSERT INTO kavling(nama_pemilik,status_kepemilikan,luas,geom) VALUES(?,?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_pemilik'],$d['status_kepemilikan'],$d['luas']??0,json_encode($d['geometry'])]); + sendSuccess(['id'=>$pdo->lastInsertId()],'Kavling disimpan',201); break; + case 'PUT': + $d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + if(!empty($d['geometry'])){ + $pdo->prepare("UPDATE kavling SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']),$id]); + } else { + $pdo->prepare("UPDATE kavling SET nama_pemilik=?,status_kepemilikan=?,luas=? WHERE id=?")->execute([$d['nama_pemilik'],$d['status_kepemilikan'],$d['luas']??0,$id]); + } + sendSuccess(null,'Kavling diperbarui'); break; + case 'DELETE': + $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM kavling WHERE id=?")->execute([$id]); sendSuccess(null,'Kavling dihapus'); break; + default: sendError('Method not allowed',405); +} diff --git a/project_final/api/kawasan_kumuh.php b/project_final/api/kawasan_kumuh.php new file mode 100644 index 0000000..397507b --- /dev/null +++ b/project_final/api/kawasan_kumuh.php @@ -0,0 +1,22 @@ +query("SELECT id,nama_kawasan,created_at,ST_AsGeoJSON(geom) as geojson FROM kawasan_kumuh ORDER BY id DESC"); + $features=[]; + while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_kawasan'=>$r['nama_kawasan'],'created_at'=>$r['created_at']]]; + sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Kawasan Kumuh'); break; + case 'POST': + $d=getInput(); if(empty($d['nama_kawasan'])||empty($d['geometry'])) sendError('Data tidak lengkap'); + $pdo->prepare("INSERT INTO kawasan_kumuh(nama_kawasan,geom) VALUES(?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_kawasan'],json_encode($d['geometry'])]); + sendSuccess(['id'=>$pdo->lastInsertId()],'Kawasan disimpan',201); break; + case 'PUT': + $d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("UPDATE kawasan_kumuh SET nama_kawasan=? WHERE id=?")->execute([$d['nama_kawasan'],$id]); + sendSuccess(null,'Kawasan diperbarui'); break; + case 'DELETE': + $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM kawasan_kumuh WHERE id=?")->execute([$id]); sendSuccess(null,'Kawasan dihapus'); break; + default: sendError('Method not allowed',405); +} diff --git a/project_final/api/laporan.php b/project_final/api/laporan.php new file mode 100644 index 0000000..07212a6 --- /dev/null +++ b/project_final/api/laporan.php @@ -0,0 +1,104 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +try { + if ($method === 'GET') { + // Jika parameter my_reports=1, hanya laporan milik user yang login + // Jika tidak dan role=admin, ambil semua. + $my_reports = $_GET['my_reports'] ?? 0; + + $sql = "SELECT l.*, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat, u.nama_lengkap, u.username + FROM laporan_warga l + JOIN users u ON l.user_id = u.id "; + + if ($my_reports || $_SESSION['role'] !== 'admin') { + // User biasa hanya boleh melihat laporannya sendiri di tabel dashboard, + // tapi mungkin butuh lihat semua laporan di peta publik? + // Untuk peta public (semua laporan disetujui atau diproses), tambahkan parameter public=1 + if (isset($_GET['public']) && $_GET['public'] == 1) { + $sql .= " WHERE l.status IN ('diproses', 'selesai') "; + } else { + $sql .= " WHERE l.user_id = " . (int)$_SESSION['user_id']; + } + } + $sql .= " ORDER BY l.id DESC"; + + $rows = $pdo->query($sql)->fetchAll(); + + $features = []; + foreach ($rows as $row) { + $features[] = [ + 'type' => 'Feature', + 'properties' => [ + 'id' => $row['id'], + 'user_id' => $row['user_id'], + 'pelapor' => $row['nama_lengkap'] ?: $row['username'], + 'kategori' => $row['kategori'], + 'deskripsi' => $row['deskripsi'], + 'status' => $row['status'], + 'created_at' => $row['created_at'] + ], + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [(float)$row['lng'], (float)$row['lat']] + ] + ]; + } + echo json_encode([ + 'type' => 'FeatureCollection', + 'features' => $features + ]); + } + elseif ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['kategori'], $input['deskripsi'], $input['geometry'])) { + throw new Exception("Data tidak lengkap."); + } + $coords = $input['geometry']['coordinates']; + $stmt = $pdo->prepare("INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry) VALUES (?, ?, ?, ST_GeomFromText(?))"); + $stmt->execute([ + $_SESSION['user_id'], + $input['kategori'], + $input['deskripsi'], + "POINT({$coords[0]} {$coords[1]})" + ]); + echo json_encode(['status' => 'success', 'message' => 'Laporan berhasil dibuat.']); + } + elseif ($method === 'PUT') { + if ($_SESSION['role'] !== 'admin') throw new Exception("Unauthorized"); + $id = $_GET['id'] ?? 0; + $input = json_decode(file_get_contents('php://input'), true); + $status = $input['status'] ?? 'menunggu'; + $stmt = $pdo->prepare("UPDATE laporan_warga SET status = ? WHERE id = ?"); + $stmt->execute([$status, $id]); + echo json_encode(['status' => 'success', 'message' => 'Status laporan diperbarui.']); + } + elseif ($method === 'DELETE') { + $id = $_GET['id'] ?? 0; + // User bisa hapus kalau status menunggu. Admin bisa hapus semua. + if ($_SESSION['role'] === 'admin') { + $stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?"); + $stmt->execute([$id]); + } else { + $stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ? AND user_id = ? AND status = 'menunggu'"); + $stmt->execute([$id, $_SESSION['user_id']]); + } + echo json_encode(['status' => 'success', 'message' => 'Laporan dihapus.']); + } +} catch (Exception $e) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/project_final/api/rumah_ibadah.php b/project_final/api/rumah_ibadah.php new file mode 100644 index 0000000..28c73bd --- /dev/null +++ b/project_final/api/rumah_ibadah.php @@ -0,0 +1,102 @@ + 10000) return 10000; + return $radius; +} + +switch ($method) { + case 'GET': + $stmt = $pdo->query(" + SELECT + r.id, + r.nama, + r.agama, + r.radius_bantuan_meter, + r.created_at, + ST_AsGeoJSON(r.geom) AS geojson, + IFNULL(AVG(u.rating), 0) AS avg_rating, + COUNT(u.id) AS total_ulasan + FROM rumah_ibadah r + LEFT JOIN ulasan_fasilitas u + ON u.fasilitas_id = r.id + AND u.fasilitas_tipe = 'rumah_ibadah' + GROUP BY r.id + ORDER BY r.id DESC + "); + + $features = []; + while ($r = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], + 'nama' => $r['nama'], + 'agama' => $r['agama'], + 'radius_bantuan_meter' => (int)$r['radius_bantuan_meter'], + 'radius_bantuan_km' => round(((int)$r['radius_bantuan_meter']) / 1000, 2), + 'created_at' => $r['created_at'], + 'avg_rating' => round((float)$r['avg_rating'], 1), + 'total_ulasan' => (int)$r['total_ulasan'] + ] + ]; + } + + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah'); + break; + + case 'POST': + $d = getInput(); + if (empty($d['nama']) || empty($d['agama']) || empty($d['geometry'])) { + sendError('Data tidak lengkap'); + } + + $radius = normalizeRadius($d['radius_bantuan_meter'] ?? null); + $pdo->prepare(" + INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) + VALUES (?, ?, ?, ST_GeomFromGeoJSON(?)) + ")->execute([$d['nama'], $d['agama'], $radius, json_encode($d['geometry'])]); + + sendSuccess(['id' => $pdo->lastInsertId()], 'Rumah Ibadah disimpan', 201); + break; + + case 'PUT': + $d = getInput(); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + + if (!empty($d['geometry'])) { + $pdo->prepare("UPDATE rumah_ibadah SET geom = ST_GeomFromGeoJSON(?) WHERE id = ?") + ->execute([json_encode($d['geometry']), $id]); + } else { + $radius = normalizeRadius($d['radius_bantuan_meter'] ?? null); + $pdo->prepare(" + UPDATE rumah_ibadah + SET nama = ?, agama = ?, radius_bantuan_meter = ? + WHERE id = ? + ")->execute([$d['nama'], $d['agama'], $radius, $id]); + } + + sendSuccess(null, 'Rumah Ibadah diperbarui'); + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + + $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?")->execute([$id]); + sendSuccess(null, 'Rumah Ibadah dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/spbu.php b/project_final/api/spbu.php new file mode 100644 index 0000000..bd703e3 --- /dev/null +++ b/project_final/api/spbu.php @@ -0,0 +1,57 @@ +query("SELECT s.id, s.nama, s.deskripsi, s.buka_24_jam, s.created_at, ST_AsGeoJSON(s.geom) as geojson, + IFNULL(AVG(u.rating), 0) as avg_rating, COUNT(u.id) as total_ulasan + FROM spbu s + LEFT JOIN ulasan_fasilitas u ON u.fasilitas_id = s.id AND u.fasilitas_tipe = 'spbu' + GROUP BY s.id + ORDER BY s.id DESC"); + $features = []; + while ($r = $stmt->fetch()) { + $features[] = [ + 'type'=>'Feature', + 'geometry'=>json_decode($r['geojson']), + 'properties'=>[ + 'id'=>$r['id'], + 'nama'=>$r['nama'], + 'deskripsi'=>$r['deskripsi'], + 'buka_24_jam'=>(int)$r['buka_24_jam'], + 'created_at'=>$r['created_at'], + 'avg_rating'=>round($r['avg_rating'], 1), + 'total_ulasan'=>$r['total_ulasan'] + ] + ]; + } + sendSuccess(['type'=>'FeatureCollection','features'=>$features], 'Data SPBU'); + break; + case 'POST': + $d = getInput(); + if (empty($d['nama']) || empty($d['geometry'])) sendError('Nama dan geometri wajib diisi'); + $stmt = $pdo->prepare("INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (?,?,?,ST_GeomFromGeoJSON(?))"); + $stmt->execute([$d['nama'], $d['deskripsi']??'', (int)($d['buka_24_jam']??0), json_encode($d['geometry'])]); + sendSuccess(['id'=>$pdo->lastInsertId()], 'SPBU disimpan', 201); + break; + case 'PUT': + $d = getInput(); $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib disertakan'); + if (!empty($d['geometry'])) { + $stmt = $pdo->prepare("UPDATE spbu SET geom=ST_GeomFromGeoJSON(?) WHERE id=?"); + $stmt->execute([json_encode($d['geometry']), $id]); + } else { + $stmt = $pdo->prepare("UPDATE spbu SET nama=?, deskripsi=?, buka_24_jam=? WHERE id=?"); + $stmt->execute([$d['nama'], $d['deskripsi']??'', (int)($d['buka_24_jam']??0), $id]); + } + sendSuccess(null, 'SPBU diperbarui'); + break; + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib disertakan'); + $pdo->prepare("DELETE FROM spbu WHERE id=?")->execute([$id]); + sendSuccess(null, 'SPBU dihapus'); + break; + default: sendError('Method not allowed', 405); +} diff --git a/project_final/api/spbu_status.php b/project_final/api/spbu_status.php new file mode 100644 index 0000000..1df037d --- /dev/null +++ b/project_final/api/spbu_status.php @@ -0,0 +1,29 @@ +query(" + SELECT + COUNT(*) AS total_spbu, + SUM(CASE WHEN buka_24_jam = 1 THEN 1 ELSE 0 END) AS spbu_buka + FROM spbu +")->fetch(); + +$total = (int)($row['total_spbu'] ?? 0); +$open = (int)($row['spbu_buka'] ?? 0); + +sendSuccess([ + 'total_spbu' => $total, + 'spbu_buka' => $open, + 'spbu_tutup' => max(0, $total - $open), + 'basis_perhitungan' => 'buka_24_jam', + 'server_time' => date('Y-m-d H:i:s'), + 'timezone' => date_default_timezone_get() +], 'Status SPBU'); diff --git a/project_final/api/spbu_terdekat.php b/project_final/api/spbu_terdekat.php new file mode 100644 index 0000000..c8b5699 --- /dev/null +++ b/project_final/api/spbu_terdekat.php @@ -0,0 +1,13 @@ +prepare("SELECT id, nama, buka_24_jam, ST_AsGeoJSON(geom) as geojson, + ST_Distance_Sphere(geom, ST_GeomFromText(?)) / 1000 as jarak_km + FROM spbu ORDER BY jarak_km ASC LIMIT 1"); +$stmt->execute([$wkt]); +$r = $stmt->fetch(); +if (!$r) sendError('Tidak ada SPBU di database', 404); +sendSuccess(['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama'=>$r['nama'],'buka_24_jam'=>(int)$r['buka_24_jam'],'jarak_km'=>round($r['jarak_km'],2)]], 'SPBU terdekat'); diff --git a/project_final/api/statistik.php b/project_final/api/statistik.php new file mode 100644 index 0000000..90132fb --- /dev/null +++ b/project_final/api/statistik.php @@ -0,0 +1,53 @@ +query("SELECT + (SELECT COUNT(*) FROM spbu) as total_spbu, + (SELECT COUNT(*) FROM jalan) as total_jalan, + (SELECT COUNT(*) FROM kavling) as total_kavling, + (SELECT COUNT(*) FROM rumah_ibadah) as total_rumah_ibadah, + (SELECT COUNT(*) FROM warga_miskin) as total_warga_miskin, + (SELECT COUNT(*) FROM kawasan_kumuh) as total_kawasan_kumuh, + (SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam +")->fetch(); + +// Choropleth: kawasan kumuh + jumlah warga di dalamnya +$sql = "SELECT k.id, k.nama_kawasan, ST_AsGeoJSON(k.geom) as geojson, + COUNT(w.id) as jumlah_warga, + COALESCE(SUM(w.jumlah_tanggungan),0) as total_tanggungan + FROM kawasan_kumuh k + LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom) + GROUP BY k.id"; +$stmt = $pdo->query($sql); +$features = []; +$merahCount = 0; +while ($r = $stmt->fetch()) { + $jumlah = (int)$r['jumlah_warga']; + if ($jumlah > 3) $merahCount++; + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => $r['id'], + 'nama_kawasan' => $r['nama_kawasan'], + 'jumlah_warga' => $jumlah, + 'total_tanggungan' => (int)$r['total_tanggungan'], + ] + ]; +} + +// Rumah ibadah by agama +$byAgama = $pdo->query("SELECT agama, COUNT(*) as total FROM rumah_ibadah GROUP BY agama")->fetchAll(); + +// Kavling by status +$byStatus = $pdo->query("SELECT status_kepemilikan, COUNT(*) as total FROM kavling GROUP BY status_kepemilikan")->fetchAll(); + +sendSuccess([ + 'counts' => $counts, + 'kawasan_merah' => $merahCount, + 'choropleth' => ['type' => 'FeatureCollection', 'features' => $features], + 'rumah_by_agama' => $byAgama, + 'kavling_by_status' => $byStatus, +], 'Statistik OK'); diff --git a/project_final/api/ulasan.php b/project_final/api/ulasan.php new file mode 100644 index 0000000..b7528cb --- /dev/null +++ b/project_final/api/ulasan.php @@ -0,0 +1,50 @@ + 'error', 'message' => 'Hanya pengguna terdaftar yang dapat memberikan ulasan']); + exit; +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +try { + if ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['fasilitas_tipe'], $input['fasilitas_id'], $input['rating'])) { + throw new Exception("Data ulasan tidak lengkap."); + } + + $tipe = $input['fasilitas_tipe']; + if (!in_array($tipe, ['spbu', 'rumah_ibadah'])) throw new Exception("Tipe fasilitas tidak valid"); + + $fasilitas_id = (int)$input['fasilitas_id']; + $rating = (int)$input['rating']; + $komentar = trim($input['komentar'] ?? ''); + + if ($rating < 1 || $rating > 5) throw new Exception("Rating harus antara 1 sampai 5"); + + // Cek apakah sudah pernah mengulas + $check = $pdo->prepare("SELECT id FROM ulasan_fasilitas WHERE user_id = ? AND fasilitas_tipe = ? AND fasilitas_id = ?"); + $check->execute([$_SESSION['user_id'], $tipe, $fasilitas_id]); + if ($check->fetch()) { + throw new Exception("Anda sudah memberikan ulasan untuk fasilitas ini."); + } + + $stmt = $pdo->prepare("INSERT INTO ulasan_fasilitas (user_id, fasilitas_tipe, fasilitas_id, rating, komentar) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$_SESSION['user_id'], $tipe, $fasilitas_id, $rating, $komentar]); + + echo json_encode(['status' => 'success', 'message' => 'Ulasan berhasil disimpan! Terima kasih atas partisipasi Anda.']); + } else { + throw new Exception("Method not allowed"); + } +} catch (Exception $e) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/project_final/api/users.php b/project_final/api/users.php new file mode 100644 index 0000000..1c27305 --- /dev/null +++ b/project_final/api/users.php @@ -0,0 +1,31 @@ +query("SELECT id,username,role,nama_lengkap,created_at FROM users ORDER BY id ASC"); + sendSuccess($stmt->fetchAll(), 'Data Users'); break; + case 'POST': + $d=getInput(); + if(empty($d['username'])||empty($d['password'])||empty($d['role'])) sendError('Data tidak lengkap'); + $hash = password_hash($d['password'], PASSWORD_BCRYPT); + try { + $pdo->prepare("INSERT INTO users(username,password,role,nama_lengkap) VALUES(?,?,?,?)")->execute([$d['username'],$hash,$d['role'],$d['nama_lengkap']??'']); + sendSuccess(['id'=>$pdo->lastInsertId()],'User disimpan',201); + } catch(Exception $e) { sendError('Username sudah digunakan'); } + break; + case 'PUT': + $d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + if(!empty($d['password'])) { + $hash=password_hash($d['password'],PASSWORD_BCRYPT); + $pdo->prepare("UPDATE users SET username=?,password=?,role=?,nama_lengkap=? WHERE id=?")->execute([$d['username'],$hash,$d['role'],$d['nama_lengkap']??'',$id]); + } else { + $pdo->prepare("UPDATE users SET username=?,role=?,nama_lengkap=? WHERE id=?")->execute([$d['username'],$d['role'],$d['nama_lengkap']??'',$id]); + } + sendSuccess(null,'User diperbarui'); break; + case 'DELETE': + $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + if($id==1) sendError('Admin utama tidak dapat dihapus'); + $pdo->prepare("DELETE FROM users WHERE id=?")->execute([$id]); sendSuccess(null,'User dihapus'); break; + default: sendError('Method not allowed',405); +} diff --git a/project_final/api/warga_miskin.php b/project_final/api/warga_miskin.php new file mode 100644 index 0000000..0d0b647 --- /dev/null +++ b/project_final/api/warga_miskin.php @@ -0,0 +1,26 @@ +query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at,ST_AsGeoJSON(geom) as geojson FROM warga_miskin ORDER BY id DESC"); + $features=[]; + while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_kk'=>$r['nama_kk'],'penghasilan'=>$r['penghasilan'],'jumlah_tanggungan'=>$r['jumlah_tanggungan'],'created_at'=>$r['created_at']]]; + sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Warga Miskin'); break; + case 'POST': + $d=getInput(); if(empty($d['nama_kk'])||empty($d['geometry'])) sendError('Data tidak lengkap'); + $pdo->prepare("INSERT INTO warga_miskin(nama_kk,penghasilan,jumlah_tanggungan,geom) VALUES(?,?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_kk'],$d['penghasilan']??0,$d['jumlah_tanggungan']??0,json_encode($d['geometry'])]); + sendSuccess(['id'=>$pdo->lastInsertId()],'Warga disimpan',201); break; + case 'PUT': + $d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + if(!empty($d['geometry'])){ + $pdo->prepare("UPDATE warga_miskin SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']),$id]); + } else { + $pdo->prepare("UPDATE warga_miskin SET nama_kk=?,penghasilan=?,jumlah_tanggungan=? WHERE id=?")->execute([$d['nama_kk'],$d['penghasilan']??0,$d['jumlah_tanggungan']??0,$id]); + } + sendSuccess(null,'Warga diperbarui'); break; + case 'DELETE': + $id=$_GET['id']??null; if(!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM warga_miskin WHERE id=?")->execute([$id]); sendSuccess(null,'Warga dihapus'); break; + default: sendError('Method not allowed',405); +} diff --git a/project_final/assets/css/admin.css b/project_final/assets/css/admin.css new file mode 100644 index 0000000..aeec233 --- /dev/null +++ b/project_final/assets/css/admin.css @@ -0,0 +1,364 @@ +/* ========================================================= + ADMIN LAYOUT STYLES — sidebar, topbar, main content + ========================================================= */ + +body.admin-page { + display: flex; + min-height: 100vh; + background: var(--bg-base); +} + +/* ══════════════════════════════ + SIDEBAR + ══════════════════════════════ */ +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); + display: flex; + flex-direction: column; + height: 100vh; + position: fixed; + top: 0; left: 0; + z-index: 100; + overflow-y: auto; + transition: var(--transition); +} + +.sidebar-brand { + padding: 24px 20px; + border-bottom: 1px solid var(--sidebar-border); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.brand-icon { + width: 40px; height: 40px; + background: var(--primary); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 1.1rem; + color: white; + flex-shrink: 0; + box-shadow: var(--shadow-sm); +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.brand-name { + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.brand-sub { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +.sidebar-nav { + padding: 16px 0; + flex: 1; +} + +.nav-section-title { + padding: 16px 20px 8px; + font-size: 0.7rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + border-radius: 0; + transition: var(--transition); + position: relative; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: #F3F4F6; + color: var(--text-primary); +} + +.nav-item.active { + background: #EFF6FF; + color: var(--primary-dark); +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 4px; + background: var(--primary); + border-radius: 0 2px 2px 0; +} + +.nav-item .nav-icon { + width: 20px; + text-align: center; + font-size: 0.95rem; + flex-shrink: 0; + color: inherit; +} + +.nav-item .nav-badge { + margin-left: auto; + background: var(--danger); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; +} + +.sidebar-footer { + border-top: 1px solid var(--sidebar-border); + padding: 16px; + flex-shrink: 0; + background: #FAFAFA; +} + +.user-info { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: var(--radius-md); + background: #FFFFFF; + border: 1px solid var(--sidebar-border); + box-shadow: var(--shadow-sm); +} + +.user-avatar { + width: 36px; height: 36px; + border-radius: 50%; + background: #E0E7FF; + display: flex; align-items: center; justify-content: center; + font-size: 0.9rem; + font-weight: 700; + color: var(--primary-dark); + flex-shrink: 0; +} + +.user-details { flex: 1; overflow: hidden; } +.user-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.user-role { font-size: 0.75rem; color: var(--text-muted); text-transform: capitalize; } + +.user-logout { + background: none; border: none; color: var(--text-muted); + cursor: pointer; font-size: 1.1rem; padding: 6px; + transition: all 0.2s; + border-radius: var(--radius-sm); +} +.user-logout:hover { color: var(--danger); background: #FEF2F2; } + +/* ══════════════════════════════ + MAIN CONTENT AREA + ══════════════════════════════ */ +.admin-main { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; + min-width: 0; +} + +/* ── Topbar ── */ +.topbar { + height: 64px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + padding: 0 32px; + gap: 16px; + position: sticky; + top: 0; + z-index: 50; +} + +.topbar-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-time { + font-size: 0.82rem; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +/* ── Content ── */ +.admin-content { + flex: 1; + padding: 32px; + overflow-y: auto; +} + +/* ══════════════════════════════ + DASHBOARD STATS CARDS + ══════════════════════════════ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.stat-card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 24px; + display: flex; + align-items: flex-start; + gap: 16px; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-hover); } + +.stat-icon { + width: 48px; height: 48px; + border-radius: var(--radius-lg); + display: flex; align-items: center; justify-content: center; + font-size: 1.3rem; + flex-shrink: 0; +} + +.stat-body { flex: 1; min-width: 0; } +.stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; } +.stat-value { font-size: 1.9rem; font-weight: 800; color: var(--text-primary); line-height: 1; letter-spacing: -0.02em; } +.stat-change { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; font-weight: 500; } + +/* Card color variants (Flat UI style) */ +.stat-card.primary .stat-icon { background: #EFF6FF; color: #2563EB; } +.stat-card.success .stat-icon { background: #ECFDF5; color: #059669; } +.stat-card.danger .stat-icon { background: #FEF2F2; color: #DC2626; } +.stat-card.warning .stat-icon { background: #FFFBEB; color: #D97706; } +.stat-card.info .stat-icon { background: #F0F9FF; color: #0284C7; } +.stat-card.violet .stat-icon { background: #F5F3FF; color: #7C3AED; } + +/* ══════════════════════════════ + DATA TABLE PAGE LAYOUT + ══════════════════════════════ */ +.page-toolbar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.page-toolbar .search-box { + flex: 1; + min-width: 250px; + max-width: 360px; + position: relative; +} + +.search-box .search-icon { + position: absolute; + left: 14px; top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 0.9rem; +} + +.search-box input { + width: 100%; + padding: 10px 14px 10px 38px; + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.9rem; + outline: none; + transition: var(--transition); + font-family: 'Inter', sans-serif; + box-shadow: var(--shadow-sm); +} + +.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } +.search-box input::placeholder { color: #9CA3AF; } + +/* ══════════════════════════════ + MAP PAGE IN ADMIN + ══════════════════════════════ */ +.admin-map-page .admin-content { + padding: 0; + display: flex; + flex-direction: column; +} + +.admin-map-page .topbar { + z-index: 500; +} + +#admin-map { + flex: 1; + min-height: calc(100vh - 64px); +} + +.map-sidebar-floating { + position: absolute; + top: 16px; left: 16px; + z-index: 400; + width: 280px; + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: var(--shadow-md); +} + +/* ══════════════════════════════ + RESPONSIVE + ══════════════════════════════ */ +@media (max-width: 768px) { + .sidebar { transform: translateX(-100%); } + .sidebar.open { transform: translateX(0); box-shadow: var(--shadow-lg); } + .admin-main { margin-left: 0; } + .stats-grid { grid-template-columns: 1fr; } +} diff --git a/project_final/assets/css/auth.css b/project_final/assets/css/auth.css new file mode 100644 index 0000000..5b92140 --- /dev/null +++ b/project_final/assets/css/auth.css @@ -0,0 +1,215 @@ +/* ========================================================= + AUTH PAGE STYLES — login.php + ========================================================= */ + +body.auth-page { + background: #F9FAFB; + min-height: 100vh; + padding: 20px; + overflow-y: auto; +} + +/* ── Center Panel (Form) ── */ +.auth-center { + width: 100%; + max-width: 440px; + margin: 40px auto; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + background: #FFFFFF; + border-radius: 16px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01); + border: 1px solid #E5E7EB; +} + +.auth-form-wrap { + width: 100%; +} + +.auth-form-title { + font-size: 1.8rem; + font-weight: 800; + color: #111827; + margin-bottom: 8px; + letter-spacing: -0.03em; +} + +.auth-form-subtitle { + color: #6B7280; + font-size: 0.95rem; + margin-bottom: 32px; +} + +/* Role selector */ +.role-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 32px; +} + +.role-card { + position: relative; + cursor: pointer; +} + +.role-card input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; height: 0; +} + +.role-card-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 12px; + border-radius: 12px; + border: 2px solid #E5E7EB; + background: #FFFFFF; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.role-card input:checked + .role-card-label { + border-color: #2563EB; + background: #EFF6FF; +} + +.role-card-label:hover { + border-color: #D1D5DB; + background: #F9FAFB; +} + +.role-card input:checked + .role-card-label:hover { + border-color: #2563EB; +} + +.role-card-icon { + font-size: 1.5rem; + line-height: 1; +} + +.role-card-name { + font-size: 0.9rem; + font-weight: 600; + color: #111827; +} + +.role-card input:checked + .role-card-label .role-card-name { + color: #1D4ED8; +} + +.role-card-desc { + font-size: 0.75rem; + color: #6B7280; +} + +/* Login form */ +.auth-form .form-group { margin-bottom: 20px; } + +.auth-form .input-with-icon { + position: relative; +} + +.auth-form .input-with-icon .input-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: #9CA3AF; + font-size: 1rem; + pointer-events: none; +} + +.auth-form .input-with-icon .form-control { + padding-left: 44px; + height: 46px; + border-radius: 8px; +} + +.auth-form .btn-login { + width: 100%; + padding: 14px; + font-size: 1rem; + justify-content: center; + background: #2563EB; + color: #FFFFFF; + border-radius: 8px; + margin-top: 12px; + font-weight: 600; + box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2); +} + +.auth-form .btn-login:hover { + background: #1D4ED8; + box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3); +} + +.auth-error { + background: #FEF2F2; + border: 1px solid #FECACA; + border-radius: 8px; + padding: 12px 16px; + color: #B91C1C; + font-size: 0.9rem; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; +} + +.auth-hint { + margin-top: 36px; + padding-top: 24px; + border-top: 1px solid #E5E7EB; +} + +.auth-hint-title { + font-size: 0.8rem; + font-weight: 700; + color: #6B7280; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 12px; +} + +.auth-hint-creds { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-hint-item { + display: flex; + justify-content: space-between; + align-items: center; + background: #F9FAFB; + border: 1px solid #E5E7EB; + border-radius: 8px; + padding: 10px 14px; + font-size: 0.85rem; +} + +.auth-hint-role { + font-weight: 600; + color: #374151; +} + +.auth-hint-pass { + font-family: monospace; + color: #4B5563; + background: #E5E7EB; + padding: 4px 10px; + border-radius: 6px; + font-weight: 500; +} + +/* Responsive */ +@media (max-width: 900px) { + .auth-center { width: 100%; border-left: none; } +} diff --git a/project_final/assets/css/main.css b/project_final/assets/css/main.css new file mode 100644 index 0000000..df0c78a --- /dev/null +++ b/project_final/assets/css/main.css @@ -0,0 +1,248 @@ +/* ========================================================= + WEBGIS PROJECT FINAL — Design System (main.css) + ========================================================= */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + +:root { + /* Colors - Clean Light Theme */ + --primary: #2563EB; /* Professional Blue */ + --primary-light: #3B82F6; + --primary-dark: #1D4ED8; + --accent: #10B981; + --danger: #EF4444; + --warning: #F59E0B; + --info: #0EA5E9; + + /* Backgrounds */ + --bg-base: #F9FAFB; /* Very light gray */ + --bg-surface: #FFFFFF; /* Pure white */ + --bg-elevated: #FFFFFF; + --bg-card: #FFFFFF; + + /* Borders */ + --border-light: #E5E7EB; + --border-hover: #D1D5DB; + + /* Text */ + --text-primary: #111827; /* Near black */ + --text-secondary: #4B5563; /* Dark gray */ + --text-muted: #6B7280; /* Medium gray */ + + /* Sidebar */ + --sidebar-w: 260px; + --sidebar-bg: #FFFFFF; + --sidebar-border: #E5E7EB; + + /* Misc */ + --radius-sm: 4px; + --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; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + font-family: 'Inter', sans-serif; + background: var(--bg-base); + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--primary); text-decoration: none; transition: var(--transition); } +a:hover { color: var(--primary-dark); } + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #94A3B8; } + +/* ── Buttons ── */ +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 16px; border-radius: var(--radius-md); border: 1px solid transparent; + font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif; + cursor: pointer; transition: var(--transition); white-space: nowrap; + outline: none; +} +.btn-primary { background: var(--primary); color: #fff; box-shadow: var(--shadow-sm); } +.btn-primary:hover { background: var(--primary-dark); } +.btn-success { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); } +.btn-success:hover { filter: brightness(0.95); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { filter: brightness(0.95); } +.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); } +.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); } +.btn-sm { padding: 6px 12px; font-size: 0.78rem; } +.btn-icon { padding: 8px; border-radius: var(--radius-sm); } + +/* ── Cards ── */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 24px; + box-shadow: var(--shadow-sm); +} + +/* ── Form Elements ── */ +.form-group { margin-bottom: 16px; } +.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } +.form-control { + width: 100%; padding: 10px 14px; border-radius: var(--radius-md); + background: #FFFFFF; border: 1px solid var(--border-light); + color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif; + outline: none; transition: var(--transition); box-shadow: var(--shadow-sm); +} +.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } +.form-control::placeholder { color: #9CA3AF; } +select.form-control { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} +textarea.form-control { resize: vertical; min-height: 80px; } + +/* ── Badges ── */ +.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: var(--radius-xl); font-size: 0.75rem; font-weight: 600; } +.badge-primary { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; } +.badge-success { background: #ECFDF5; color: #047857; border: 1px solid #A7F3D0; } +.badge-danger { background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; } +.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; } +.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; } + +/* ── Tables ── */ +.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); } +.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); } +.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; } +.data-table tbody tr:last-child { border-bottom: none; } +.data-table tbody tr:hover { background: #F3F4F6; } +.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; } +.data-table .actions { display: flex; gap: 6px; } + +/* ── Modal ── */ +.modal-overlay { + display: none; position: fixed; inset: 0; z-index: 9999; + background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px); + align-items: center; justify-content: center; +} +.modal-overlay.active { display: flex; } +.modal { + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); padding: 32px; + width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto; + box-shadow: var(--shadow-lg); + animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } +.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); } +.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; } +.modal-close:hover { background: #F3F4F6; color: var(--text-primary); } +.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); } + +/* ── Toast ── */ +.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; } +.toast { padding: 14px 20px; border-radius: var(--radius-md); font-size: 0.9rem; font-weight: 500; color: #fff; box-shadow: var(--shadow-md); animation: toastIn 0.3s ease; transition: opacity 0.3s; } +.toast.success { background: #10B981; } +.toast.error { background: #EF4444; } +.toast.info { background: #3B82F6; } +@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } } + +/* SPBU realtime snackbar */ +.spbu-snackbar { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 99998; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: min(360px, calc(100vw - 32px)); + padding: 13px 14px; + color: var(--text-primary); + background: #FFFFFF; + border: 1px solid #A7F3D0; + border-left: 4px solid var(--accent); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + opacity: 0; + pointer-events: none; + transform: translateY(18px); + transition: opacity 0.25s ease, transform 0.25s ease; +} +.spbu-snackbar.show { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} +.spbu-snackbar-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); +} +.spbu-snackbar-title { + font-size: 0.84rem; + font-weight: 800; + line-height: 1.2; +} +.spbu-snackbar-meta { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.3; +} +.spbu-snackbar-count { + min-width: 44px; + padding: 5px 8px; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); + text-align: center; + font-size: 1.05rem; + font-weight: 800; +} + +/* ── Page header ── */ +.page-header { margin-bottom: 28px; } +.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; } +.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; } +.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; } +.page-header .breadcrumb .sep { color: #D1D5DB; } + +/* ── Utilities ── */ +.text-muted { color: var(--text-muted); } +.text-secondary { color: var(--text-secondary); } +.text-success { color: var(--accent); } +.text-danger { color: var(--danger); } +.text-warning { color: var(--warning); } +.flex { display: flex; } +.flex-center { display: flex; align-items: center; justify-content: center; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.mt-1 { margin-top: 8px; } +.mt-2 { margin-top: 16px; } +.mb-1 { margin-bottom: 8px; } +.mb-2 { margin-bottom: 16px; } +.w-full { width: 100%; } diff --git a/project_final/assets/css/map.css b/project_final/assets/css/map.css new file mode 100644 index 0000000..af8f700 --- /dev/null +++ b/project_final/assets/css/map.css @@ -0,0 +1,765 @@ +/* ========================================================= + MAP STYLES - interactive map panels and Leaflet overrides + ========================================================= */ + +.map-page { + height: 100vh; + overflow: hidden; + background: var(--bg-base); +} + +.map-wrap { + position: relative; + width: 100%; + height: calc(100vh - 64px); + background: #E5E7EB; +} + +#admin-map, +#user-map { + width: 100%; + height: 100%; + background: #E5E7EB; +} + +.leaflet-container { + font-family: 'Inter', sans-serif; + background: #E5E7EB; +} + +.leaflet-control-attribution { + background: rgba(255, 255, 255, 0.92) !important; + color: var(--text-muted) !important; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + padding: 3px 8px; + font-size: 0.68rem; +} + +.leaflet-control-attribution a { + color: var(--primary-dark) !important; +} + +.leaflet-bar { + border: 1px solid var(--border-light) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--shadow-md) !important; + overflow: hidden; +} + +.leaflet-control-zoom a, +.leaflet-draw-toolbar a { + width: 34px !important; + height: 34px !important; + line-height: 34px !important; + background: #FFFFFF !important; + color: var(--text-primary) !important; + border: 0 !important; + border-bottom: 1px solid var(--border-light) !important; +} + +.leaflet-control-zoom a:last-child, +.leaflet-draw-toolbar a:last-child { + border-bottom: 0 !important; +} + +.leaflet-control-zoom a:hover, +.leaflet-draw-toolbar a:hover { + background: #F3F4F6 !important; + color: var(--primary-dark) !important; +} + +/* Layer panel */ +.map-sidebar { + position: absolute; + top: 16px; + left: 16px; + z-index: 500; + width: 316px; + max-height: calc(100vh - 112px); + overflow-y: auto; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-panel-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border-light); +} + +.map-panel-icon { + width: 40px; + height: 40px; + display: grid; + place-items: center; + flex-shrink: 0; + border-radius: var(--radius-md); + background: #EFF6FF; + color: var(--primary-dark); + border: 1px solid #BFDBFE; +} + +.map-panel-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 0.98rem; + line-height: 1.2; + font-weight: 700; +} + +.map-panel-header p { + margin: 3px 0 0; + color: var(--text-muted); + font-size: 0.76rem; + line-height: 1.35; +} + +.layer-section { + margin-bottom: 16px; +} + +.layer-section:last-of-type { + margin-bottom: 12px; +} + +.layer-section-title { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.layer-item { + display: grid; + grid-template-columns: 18px 30px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 48px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + user-select: none; + transition: var(--transition); +} + +.layer-item + .layer-item { + margin-top: 4px; +} + +.layer-item:hover { + background: #F9FAFB; + border-color: var(--border-light); + color: var(--text-primary); +} + +.layer-item input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--primary); + cursor: pointer; +} + +.layer-color { + --layer-color: var(--primary); + width: 30px; + height: 30px; + display: grid; + place-items: center; + flex-shrink: 0; + color: #FFFFFF; + background: var(--layer-color); + border: 1px solid color-mix(in srgb, var(--layer-color) 72%, #000 28%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28); +} + +.layer-color i { + font-size: 0.76rem; +} + +.layer-point { + border-radius: 50%; +} + +.layer-line { + height: 4px; + border-radius: 999px; + align-self: center; + box-shadow: none; +} + +.layer-area { + border-radius: 6px; + background: color-mix(in srgb, var(--layer-color) 22%, #FFFFFF 78%); + border-color: var(--layer-color); +} + +.layer-hatch { + background: + repeating-linear-gradient(45deg, color-mix(in srgb, var(--layer-color) 78%, #FFFFFF 22%) 0 4px, transparent 4px 8px), + color-mix(in srgb, var(--layer-color) 12%, #FFFFFF 88%); +} + +.layer-gradient { + border-radius: 6px; + background: linear-gradient(90deg, #10B981 0%, #F59E0B 50%, #EF4444 100%); + border-color: #D1D5DB; +} + +.layer-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} + +.layer-name { + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.layer-meta { + margin-top: 2px; + color: var(--text-muted); + font-size: 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.map-quickstats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding-top: 14px; + border-top: 1px solid var(--border-light); +} + +.qs-item { + min-width: 0; + padding: 9px 6px; + text-align: center; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.qs-value { + font-size: 1.08rem; + line-height: 1; + font-weight: 800; +} + +.qs-label { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.62rem; + font-weight: 700; +} + +/* Toolbar */ +.map-analysis { + position: absolute; + top: 16px; + left: 50%; + z-index: 500; + display: flex; + align-items: center; + gap: 6px; + max-width: calc(100% - 380px); + padding: 8px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transform: translateX(-50%); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-analysis button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 7px 12px; + color: var(--text-secondary); + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + font-family: 'Inter', sans-serif; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} + +.map-analysis button:hover { + background: #F3F4F6; + color: var(--text-primary); + border-color: var(--border-hover); +} + +.map-analysis button.active { + color: #FFFFFF; + background: var(--primary); + border-color: var(--primary); +} + +.map-analysis .map-tool-accent { + color: #047857; + background: #ECFDF5; + border-color: #A7F3D0; +} + +.map-analysis .map-tool-accent:hover { + color: #065F46; + background: #D1FAE5; + border-color: #6EE7B7; +} + +/* Nearest SPBU panel */ +.spbu-panel { + position: absolute; + top: 16px; + right: 16px; + z-index: 500; + width: 310px; + padding: 16px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.spbu-panel-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: var(--text-primary); + font-size: 0.94rem; + font-weight: 800; +} + +.spbu-result { + margin-top: 10px; + padding: 12px; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.spbu-result-name { + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 800; +} + +.spbu-result-meta { + margin-top: 4px; + color: var(--text-muted); + font-size: 0.75rem; +} + +.spbu-result-distance { + margin-top: 8px; + color: var(--primary-dark); + font-size: 1.35rem; + line-height: 1; + font-weight: 800; +} + +/* Markers */ +.marker-pin { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: #FFFFFF; + background: var(--primary); + border: 2px solid #FFFFFF; + border-radius: 50% 50% 50% 0; + box-shadow: 0 6px 12px rgba(17, 24, 39, 0.28); + font-size: 0.9rem; + transform: rotate(-45deg); +} + +.marker-pin > * { + transform: rotate(45deg); +} + +.div-icon-wrap { + background: transparent; + border: none; +} + +/* Popups */ +.leaflet-popup-content-wrapper { + color: var(--text-primary) !important; + background: #FFFFFF !important; + border: 1px solid var(--border-light); + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-lg); +} + +.leaflet-popup-tip { + background: #FFFFFF !important; +} + +.leaflet-popup-content { + margin: 13px 16px !important; + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + line-height: 1.5; +} + +.popup-title { + margin-bottom: 6px; + color: var(--text-primary); + font-size: 0.98rem; + font-weight: 800; +} + +.popup-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 5px; + color: var(--text-secondary); +} + +.popup-row strong { + color: var(--text-primary); +} + +.popup-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.popup-actions .btn { + padding: 5px 10px; + font-size: 0.75rem; +} + +/* Legend */ +.map-legend { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 500; + min-width: 230px; + padding: 14px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-legend-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + color: var(--text-primary); + font-size: 0.82rem; + font-weight: 800; +} + +.legend-grid { + display: grid; + gap: 7px; +} + +.legend-item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 22px; + color: var(--text-secondary); + font-size: 0.76rem; + font-weight: 600; +} + +.legend-symbol { + --legend-color: var(--primary); + justify-self: center; + background: var(--legend-color); +} + +.legend-dot { + width: 12px; + height: 12px; + border: 2px solid #FFFFFF; + border-radius: 50%; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--legend-color) 70%, #000 30%); +} + +.legend-line { + width: 22px; + height: 4px; + border-radius: 999px; +} + +.legend-area { + width: 16px; + height: 16px; + border: 1px solid var(--legend-color); + border-radius: 4px; + background: color-mix(in srgb, var(--legend-color) 18%, #FFFFFF 82%); +} + +.legend-hatch { + width: 16px; + height: 16px; + border: 1px solid var(--legend-color); + border-radius: 4px; + background: + repeating-linear-gradient(45deg, color-mix(in srgb, var(--legend-color) 76%, #FFFFFF 24%) 0 3px, transparent 3px 6px), + color-mix(in srgb, var(--legend-color) 12%, #FFFFFF 88%); +} + +/* Modal */ +.map-modal { + position: fixed; + inset: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.45); + backdrop-filter: blur(2px); +} + +.map-modal.active { + display: flex; +} + +.map-modal-content { + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + padding: 28px 32px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Loading and toast */ +.map-loading { + position: absolute; + inset: 0; + z-index: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-primary); + background: rgba(249, 250, 251, 0.72); + backdrop-filter: blur(3px); + font-size: 0.95rem; + font-weight: 700; +} + +.spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(37, 99, 235, 0.16); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.toast-container { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 99999; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + max-width: 320px; + padding: 12px 20px; + color: #FFFFFF; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.88rem; + font-weight: 600; + animation: toastIn 0.3s ease; + transition: opacity 0.3s; +} + +.toast.success { background: #10B981; } +.toast.error { background: #EF4444; } +.toast.info { background: #3B82F6; } + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.read-only-badge { + position: absolute; + top: 16px; + right: 16px; + z-index: 500; + display: flex; + align-items: center; + gap: 7px; + padding: 8px 13px; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.78rem; + font-weight: 800; +} + +/* Responsive */ +@media (max-width: 1180px) { + .map-analysis { + left: auto; + right: 16px; + max-width: calc(100% - 364px); + overflow-x: auto; + transform: none; + } +} + +@media (max-width: 900px) { + .map-sidebar { + width: 300px; + } + + .map-analysis { + top: auto; + right: 16px; + bottom: 16px; + left: 16px; + max-width: none; + justify-content: flex-start; + } + + .map-legend { + right: 16px; + bottom: 78px; + } +} + +@media (max-width: 768px) { + .map-wrap { + height: calc(100vh - 64px); + } + + .map-sidebar { + top: 8px; + left: 8px; + width: calc(100% - 16px); + max-height: 46vh; + padding: 12px; + } + + .map-panel-header { + margin-bottom: 10px; + padding-bottom: 10px; + } + + .layer-item { + min-height: 44px; + } + + .map-quickstats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .map-analysis { + right: 8px; + bottom: 8px; + left: 8px; + padding: 6px; + overflow-x: auto; + } + + .map-analysis button { + min-height: 32px; + padding: 6px 10px; + font-size: 0.74rem; + } + + .spbu-panel { + display: none; + } + + .map-legend { + right: 8px; + bottom: 66px; + min-width: 210px; + max-width: calc(100% - 16px); + } + + .read-only-badge { + top: auto; + right: 8px; + bottom: 66px; + } +} diff --git a/project_final/assets/js/admin-map.js b/project_final/assets/js/admin-map.js new file mode 100644 index 0000000..0fefc7a --- /dev/null +++ b/project_final/assets/js/admin-map.js @@ -0,0 +1,491 @@ +/* ========================================================= + admin-map.js — Full WebGIS Map (admin mode: edit & draw) + ========================================================= */ + +const API = APP_BASE + '/api'; +const STAT_API = APP_BASE + '/api/statistik.php'; + +const state = { + map: null, + layers: { + spbu: null, jalan: null, kavling: null, + kawasan: null, rumah: null, warga: null, + choropleth: null, blankSpot: null, spbuRoute: null + }, + activeDraw: null, // 'spbu' | 'jalan' | 'kavling' | 'kawasan' | 'rumah' | 'warga' + drawHandlers: {}, + pendingGeometry: null +}; + +const COLORS = { + spbu: '#F59E0B', + spbu24: '#10B981', + jalan: '#3B82F6', + kavling: '#8B5CF6', + kawasan: '#EF4444', + rumah: '#A855F7', + warga: '#EF4444', + blankSpot: '#F97316' +}; + +/* ── Icon factory ── */ +function makeIcon(emoji, bgColor, size = 34) { + return L.divIcon({ + className: 'div-icon-wrap', + html: `
${emoji}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size], + popupAnchor: [0, -size] + }); +} + +const ICONS = { + spbu24: makeIcon('⛽', COLORS.spbu24), + spbu: makeIcon('⛽', COLORS.spbu), + rumah: makeIcon('🕌', COLORS.rumah), + warga: makeIcon('👤', COLORS.warga), + blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36) +}; + +/* ── Toast ── */ +function toast(msg, type = 'success') { + const c = document.getElementById('toastContainer'); + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.innerHTML = `${msg}`; + c.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000); +} + +/* ── Initialize map ── */ +function initMap() { + state.map = L.map('admin-map', { zoomControl: false }) + .setView([-0.0583, 109.3448], 13); + + L.control.zoom({ position: 'bottomright' }).addTo(state.map); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap, © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(state.map); +} + +/* ── Fetch GeoJSON ── */ +async function fetchLayer(path) { + const r = await fetch(`${API}/${path}`); + const j = await r.json(); + return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] }; +} + +function formatKm(value) { + return Number(value || 0).toLocaleString('id-ID', { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }); +} + +/* ── Render: SPBU (Point) ── */ +function renderSpbu(data) { + if (state.layers.spbu) state.map.removeLayer(state.layers.spbu); + state.layers.spbu = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }), + onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) + }); + if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map); +} + +/* ── Render: Jalan (LineString) ── */ +function renderJalan(data) { + if (state.layers.jalan) state.map.removeLayer(state.layers.jalan); + state.layers.jalan = L.geoJSON(data, { + style: { color: COLORS.jalan, weight: 5, opacity: 0.85 }, + onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) + }); + if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map); +} + +/* ── Render: Kavling (Polygon) ── */ +function renderKavling(data) { + if (state.layers.kavling) state.map.removeLayer(state.layers.kavling); + state.layers.kavling = L.geoJSON(data, { + style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 }, + onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) + }); + if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map); +} + +/* ── Render: Kawasan Kumuh (Polygon) ── */ +function renderKawasan(data) { + if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan); + state.layers.kawasan = L.geoJSON(data, { + style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' }, + onEachFeature: (f, l) => bindPopup(l, 'kawasan', f.properties) + }); + if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map); +} + +/* ── Render: Rumah Ibadah (Point) ── */ +function renderRumah(data) { + if (state.layers.rumah) state.map.removeLayer(state.layers.rumah); + state.layers.rumah = L.featureGroup(); + (data.features || []).forEach((f) => { + if (!f.geometry || f.geometry.type !== 'Point') return; + const props = f.properties || {}; + const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]); + const circle = L.circle(ll, { + radius: Number(props.radius_bantuan_meter || 1000), + color: COLORS.rumah, + fillColor: COLORS.rumah, + fillOpacity: 0.08, + opacity: 0.55, + weight: 2 + }); + const marker = L.marker(ll, { icon: ICONS.rumah }); + bindPopup(circle, 'rumah', props); + bindPopup(marker, 'rumah', props); + state.layers.rumah.addLayer(circle); + state.layers.rumah.addLayer(marker); + }); + if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map); +} + +/* ── Render: Warga Miskin (Point) ── */ +function renderWarga(data) { + if (state.layers.warga) state.map.removeLayer(state.layers.warga); + state.layers.warga = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }), + onEachFeature: (f, l) => bindPopup(l, 'warga', f.properties) + }); + if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map); +} + +/* ── Render: Choropleth (kawasan + count warga) ── */ +function renderChoropleth(data) { + if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth); + state.layers.choropleth = L.geoJSON(data, { + style: (f) => { + const c = f.properties.jumlah_warga; + let color = '#10B981'; + if (c > 0) color = '#F59E0B'; + if (c > 3) color = '#EF4444'; + return { color, fillColor: color, fillOpacity: 0.35, weight: 3 }; + }, + onEachFeature: (f, l) => { + const c = f.properties.jumlah_warga; + l.bindPopup(` + + `); + } + }); + if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map); +} + +/* ── Render: Blank Spot (warga yang jauh dari rumah ibadah) ── */ +function renderBlankSpot(data) { + if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot); + state.layers.blankSpot = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }), + onEachFeature: (f, l) => { + l.bindPopup(` + + + + + `); + } + }); + if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map); +} + +/* ── Popup binding helper ── */ +function bindPopup(layer, type, props) { + let html = ``; + if (type === 'spbu') { + html += ``; + if (props.deskripsi) html += ``; + } else if (type === 'jalan') { + html += ``; + } else if (type === 'kavling') { + html += ``; + html += ``; + } else if (type === 'rumah') { + html += ``; + html += ``; + } else if (type === 'warga') { + html += ``; + html += ``; + } + html += ``; + layer.bindPopup(html); +} + +/* ── Delete from map ── */ +window.deleteFeature = async function (type, id) { + if (!confirm('Yakin hapus data ini?')) return; + const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type]; + if (!ep) return; + const r = await fetch(`${API}/${ep}?id=${id}`, { method: 'DELETE' }); + const j = await r.json(); + if (j.status === 'success') { toast('Data dihapus!'); loadAll(); } + else toast(j.message, 'error'); +}; + +/* ── Layer toggling ── */ +function bindLayerToggles() { + const map = { + 'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling', + 'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga', + 'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot' + }; + Object.entries(map).forEach(([id, key]) => { + document.getElementById(id)?.addEventListener('change', (e) => { + const layer = state.layers[key]; + if (!layer) return; + if (e.target.checked) state.map.addLayer(layer); + else state.map.removeLayer(layer); + }); + }); +} + +/* ── Drawing tool activation ── */ +function activateDraw(type) { + if (state.activeDraw) deactivateDraw(); + state.activeDraw = type; + + // Update UI button state + document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active')); + document.querySelector(`[data-draw="${type}"]`)?.classList.add('active'); + + // Create appropriate L.Draw handler + let handler = null; + if (type === 'spbu' || type === 'rumah' || type === 'warga') { + handler = new L.Draw.Marker(state.map); + } else if (type === 'jalan') { + handler = new L.Draw.Polyline(state.map, { shapeOptions: { color: COLORS.jalan, weight: 5 } }); + } else if (type === 'kavling' || type === 'kawasan') { + handler = new L.Draw.Polygon(state.map, { shapeOptions: { color: type === 'kavling' ? COLORS.kavling : COLORS.kawasan } }); + } + state.drawHandlers[type] = handler; + handler.enable(); +} + +/* ── Deactivate drawing ── */ +function deactivateDraw() { + if (state.activeDraw && state.drawHandlers[state.activeDraw]) { + state.drawHandlers[state.activeDraw].disable(); + } + state.activeDraw = null; + document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active')); +} + +/* ── Draw event listener ── */ +function bindDrawEvent() { + state.map.on(L.Draw.Event.CREATED, (e) => { + const geom = e.layer.toGeoJSON().geometry; + state.pendingGeometry = geom; + openFormModal(state.activeDraw, geom); + }); + + state.map.on(L.Draw.Event.DRAWSTOP, () => { + // User cancelled drawing + document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active')); + state.activeDraw = null; + }); +} + +/* ── Form modal untuk input data baru ── */ +function openFormModal(type, geometry) { + const modal = document.getElementById('formModal'); + const title = document.getElementById('formModalTitle'); + const body = document.getElementById('formModalBody'); + + const titles = { spbu: '⛽ Tambah SPBU', jalan: '🛣️ Tambah Jalan', kavling: '🏘️ Tambah Kavling', + kawasan: '⚠️ Tambah Kawasan Kumuh', rumah: '🕌 Tambah Rumah Ibadah', warga: '👤 Tambah Warga Miskin' }; + title.textContent = titles[type] || 'Tambah Data'; + + let formHtml = ''; + if (type === 'spbu') { + formHtml = ` +
+
+
`; + } else if (type === 'jalan') { + formHtml = ` +
+
+
`; + } else if (type === 'kavling') { + formHtml = ` +
+
+
+
`; + } else if (type === 'kawasan') { + formHtml = ` +
`; + } else if (type === 'rumah') { + formHtml = ` +
+
+
+
`; + } else if (type === 'warga') { + formHtml = ` +
+
+
`; + } + + body.innerHTML = formHtml + ` + `; + + modal.classList.add('active'); +} + +window.closeFormModal = function () { + document.getElementById('formModal').classList.remove('active'); + state.pendingGeometry = null; + deactivateDraw(); +}; + +/* ── Save feature to backend ── */ +window.saveFeature = async function (type) { + const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type]; + if (!ep) return; + + let payload = { geometry: state.pendingGeometry }; + const v = (id) => document.getElementById(id)?.value; + + if (type === 'spbu') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama: v('f-nama'), deskripsi: v('f-deskripsi') || '', buka_24_jam: document.getElementById('f-24').checked ? 1 : 0 }; + } else if (type === 'jalan') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama: v('f-nama'), jenis_jalan: v('f-jenis') }; + } else if (type === 'kavling') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama_pemilik: v('f-nama'), status_kepemilikan: v('f-status'), luas: parseFloat(v('f-luas')) || 0 }; + } else if (type === 'kawasan') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama_kawasan: v('f-nama') }; + } else if (type === 'rumah') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama: v('f-nama'), agama: v('f-agama'), radius_bantuan_meter: Math.max(100, Math.min(10000, parseInt(v('f-radius'), 10) || 1000)) }; + } else if (type === 'warga') { + if (!v('f-nama')) return toast('Nama wajib!', 'error'); + payload = { ...payload, nama_kk: v('f-nama'), penghasilan: parseFloat(v('f-penghasilan')) || 0, jumlah_tanggungan: parseInt(v('f-tanggungan')) || 0 }; + } + + try { + const r = await fetch(`${API}/${ep}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const j = await r.json(); + if (j.status === 'success') { toast('Data berhasil disimpan!'); closeFormModal(); loadAll(); } + else toast(j.message || 'Gagal menyimpan', 'error'); + } catch (e) { toast('Error: ' + e.message, 'error'); } +}; + +/* ── Analysis: SPBU Terdekat ── */ +async function findNearestSpbu() { + const center = state.map.getCenter(); + document.getElementById('spbuPanelResult').innerHTML = '
Mencari...'; + document.getElementById('spbuPanel').style.display = 'block'; + + try { + const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`); + const j = await r.json(); + if (j.status === 'success' && j.data) { + const p = j.data.properties; + const coords = j.data.geometry.coordinates; + const html = `
+
⛽ ${p.nama}
+
${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}
+
${p.jarak_km} km
+
`; + document.getElementById('spbuPanelResult').innerHTML = html; + + // Show marker + if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute); + state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map) + .bindPopup(`${p.nama}
${p.jarak_km} km dari pusat peta`).openPopup(); + } else { + document.getElementById('spbuPanelResult').innerHTML = `
${j.message || 'Tidak ada SPBU'}
`; + } + } catch (e) { + document.getElementById('spbuPanelResult').innerHTML = `
Error: ${e.message}
`; + } +} + +/* ── Load all layers ── */ +async function loadAll() { + document.getElementById('loadingOverlay').style.display = 'flex'; + try { + const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([ + fetchLayer('spbu.php'), + fetchLayer('jalan.php'), + fetchLayer('kavling.php'), + fetchLayer('kawasan_kumuh.php'), + fetchLayer('rumah_ibadah.php'), + fetchLayer('warga_miskin.php'), + fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null), + fetchLayer('blank_spot.php') + ]); + renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling); + renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga); + if (stat && stat.choropleth) renderChoropleth(stat.choropleth); + if (blank) renderBlankSpot(blank); + + // Update quick stats + if (stat && stat.counts) { + document.getElementById('qs-spbu').textContent = stat.counts.total_spbu; + document.getElementById('qs-jalan').textContent = stat.counts.total_jalan; + document.getElementById('qs-kavling').textContent = stat.counts.total_kavling; + document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin; + } + } catch (e) { + toast('Gagal load data: ' + e.message, 'error'); + } finally { + document.getElementById('loadingOverlay').style.display = 'none'; + } +} + +/* ── Init ── */ +document.addEventListener('DOMContentLoaded', () => { + initMap(); + loadAll(); + bindLayerToggles(); + bindDrawEvent(); + + document.querySelectorAll('[data-draw]').forEach(btn => { + btn.addEventListener('click', () => { + const type = btn.dataset.draw; + if (state.activeDraw === type) deactivateDraw(); + else activateDraw(type); + }); + }); + + document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu); + document.getElementById('btn-close-spbu')?.addEventListener('click', () => { + document.getElementById('spbuPanel').style.display = 'none'; + if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; } + }); +}); diff --git a/project_final/assets/js/user-map.js b/project_final/assets/js/user-map.js new file mode 100644 index 0000000..4f06119 --- /dev/null +++ b/project_final/assets/js/user-map.js @@ -0,0 +1,358 @@ +/* ========================================================= + user-map.js — Read-only WebGIS Map (user mode) + ========================================================= */ + +const API = APP_BASE + '/api'; +const STAT_API = APP_BASE + '/api/statistik.php'; + +const state = { + map: null, + layers: { + spbu: null, jalan: null, kavling: null, + kawasan: null, rumah: null, warga: null, + choropleth: null, blankSpot: null, spbuRoute: null + } +}; + +const COLORS = { + spbu: '#F59E0B', + spbu24: '#10B981', + jalan: '#3B82F6', + kavling: '#8B5CF6', + kawasan: '#EF4444', + rumah: '#A855F7', + warga: '#EF4444', + blankSpot: '#F97316' +}; + +function makeIcon(emoji, bgColor, size = 34) { + return L.divIcon({ + className: 'div-icon-wrap', + html: `
${emoji}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size], + popupAnchor: [0, -size] + }); +} + +const ICONS = { + spbu24: makeIcon('⛽', COLORS.spbu24), + spbu: makeIcon('⛽', COLORS.spbu), + rumah: makeIcon('🕌', COLORS.rumah), + warga: makeIcon('👤', COLORS.warga), + blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36) +}; + +function toast(msg, type = 'success') { + const c = document.getElementById('toastContainer'); + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.innerHTML = `${msg}`; + c.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000); +} + +function initMap() { + state.map = L.map('user-map', { zoomControl: false }) + .setView([-0.0583, 109.3448], 13); + + L.control.zoom({ position: 'bottomright' }).addTo(state.map); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap, © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(state.map); +} + +async function fetchLayer(path) { + const r = await fetch(`${API}/${path}`); + const j = await r.json(); + return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] }; +} + +function formatKm(value) { + return Number(value || 0).toLocaleString('id-ID', { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }); +} + +function bindReadOnlyPopup(layer, type, props) { + let html = ``; + if (type === 'spbu') { + html += ``; + if (props.deskripsi) html += ``; + html += ``; + html += ``; + } else if (type === 'jalan') { + html += ``; + } else if (type === 'kavling') { + html += ``; + html += ``; + } else if (type === 'rumah') { + html += ``; + html += ``; + html += ``; + html += ``; + } else if (type === 'warga') { + html += ``; + html += ``; + } + layer.bindPopup(html); +} + +// Ulasan Modal Logic +window.bukaReview = function(tipe, id, nama) { + let m = document.getElementById('modalReview'); + if(!m) { + m = document.createElement('div'); + m.id = 'modalReview'; + m.className = 'modal-overlay'; + m.innerHTML = ` + `; + document.body.appendChild(m); + } + + document.getElementById('revTipe').value = tipe; + document.getElementById('revId').value = id; + document.getElementById('revNama').innerText = "Mengulas: " + nama; + document.getElementById('revRating').value = "5"; + document.getElementById('revKomen').value = ""; + m.style.display = 'flex'; +}; + +window.submitReview = async function() { + const tipe = document.getElementById('revTipe').value; + const id = document.getElementById('revId').value; + const rating = document.getElementById('revRating').value; + const komen = document.getElementById('revKomen').value; + + const res = await fetch(APP_BASE + '/api/ulasan.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fasilitas_tipe: tipe, fasilitas_id: id, rating: rating, komentar: komen }) + }); + const j = await res.json(); + if(j.status === 'success') { + toast('Ulasan berhasil dikirim!'); + document.getElementById('modalReview').style.display = 'none'; + loadAll(); // reload data + } else { + toast(j.message, 'error'); + } +}; + +function renderSpbu(data) { + if (state.layers.spbu) state.map.removeLayer(state.layers.spbu); + state.layers.spbu = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }), + onEachFeature: (f, l) => bindReadOnlyPopup(l, 'spbu', f.properties) + }); + if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map); +} + +function renderJalan(data) { + if (state.layers.jalan) state.map.removeLayer(state.layers.jalan); + state.layers.jalan = L.geoJSON(data, { + style: { color: COLORS.jalan, weight: 5, opacity: 0.85 }, + onEachFeature: (f, l) => bindReadOnlyPopup(l, 'jalan', f.properties) + }); + if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map); +} + +function renderKavling(data) { + if (state.layers.kavling) state.map.removeLayer(state.layers.kavling); + state.layers.kavling = L.geoJSON(data, { + style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 }, + onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kavling', f.properties) + }); + if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map); +} + +function renderKawasan(data) { + if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan); + state.layers.kawasan = L.geoJSON(data, { + style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' }, + onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kawasan', f.properties) + }); + if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map); +} + +function renderRumah(data) { + if (state.layers.rumah) state.map.removeLayer(state.layers.rumah); + state.layers.rumah = L.featureGroup(); + (data.features || []).forEach((f) => { + if (!f.geometry || f.geometry.type !== 'Point') return; + const props = f.properties || {}; + const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]); + const circle = L.circle(ll, { + radius: Number(props.radius_bantuan_meter || 1000), + color: COLORS.rumah, + fillColor: COLORS.rumah, + fillOpacity: 0.08, + opacity: 0.55, + weight: 2 + }); + const marker = L.marker(ll, { icon: ICONS.rumah }); + bindReadOnlyPopup(circle, 'rumah', props); + bindReadOnlyPopup(marker, 'rumah', props); + state.layers.rumah.addLayer(circle); + state.layers.rumah.addLayer(marker); + }); + if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map); +} + +function renderWarga(data) { + if (state.layers.warga) state.map.removeLayer(state.layers.warga); + state.layers.warga = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }), + onEachFeature: (f, l) => bindReadOnlyPopup(l, 'warga', f.properties) + }); + if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map); +} + +function renderChoropleth(data) { + if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth); + state.layers.choropleth = L.geoJSON(data, { + style: (f) => { + const c = f.properties.jumlah_warga; + let color = '#10B981'; + if (c > 0) color = '#F59E0B'; + if (c > 3) color = '#EF4444'; + return { color, fillColor: color, fillOpacity: 0.35, weight: 3 }; + }, + onEachFeature: (f, l) => { + l.bindPopup(` + + `); + } + }); + if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map); +} + +function renderBlankSpot(data) { + if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot); + state.layers.blankSpot = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }), + onEachFeature: (f, l) => { + l.bindPopup(` + + + + + `); + } + }); + if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map); +} + +function bindLayerToggles() { + const map = { + 'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling', + 'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga', + 'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot' + }; + Object.entries(map).forEach(([id, key]) => { + document.getElementById(id)?.addEventListener('change', (e) => { + const layer = state.layers[key]; + if (!layer) return; + if (e.target.checked) state.map.addLayer(layer); + else state.map.removeLayer(layer); + }); + }); +} + +async function findNearestSpbu() { + const center = state.map.getCenter(); + document.getElementById('spbuPanelResult').innerHTML = '
Mencari...'; + document.getElementById('spbuPanel').style.display = 'block'; + + try { + const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`); + const j = await r.json(); + if (j.status === 'success' && j.data) { + const p = j.data.properties; + const coords = j.data.geometry.coordinates; + document.getElementById('spbuPanelResult').innerHTML = `
+
⛽ ${p.nama}
+
${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}
+
${p.jarak_km} km
+
`; + if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute); + state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map) + .bindPopup(`${p.nama}
${p.jarak_km} km dari pusat peta`).openPopup(); + } else { + document.getElementById('spbuPanelResult').innerHTML = `
${j.message || 'Tidak ada SPBU'}
`; + } + } catch (e) { + document.getElementById('spbuPanelResult').innerHTML = `
Error: ${e.message}
`; + } +} + +async function loadAll() { + document.getElementById('loadingOverlay').style.display = 'flex'; + try { + const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([ + fetchLayer('spbu.php'), + fetchLayer('jalan.php'), + fetchLayer('kavling.php'), + fetchLayer('kawasan_kumuh.php'), + fetchLayer('rumah_ibadah.php'), + fetchLayer('warga_miskin.php'), + fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null), + fetchLayer('blank_spot.php') + ]); + renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling); + renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga); + if (stat && stat.choropleth) renderChoropleth(stat.choropleth); + if (blank) renderBlankSpot(blank); + + if (stat && stat.counts) { + document.getElementById('qs-spbu').textContent = stat.counts.total_spbu; + document.getElementById('qs-jalan').textContent = stat.counts.total_jalan; + document.getElementById('qs-kavling').textContent = stat.counts.total_kavling; + document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin; + } + } catch (e) { + toast('Gagal load data: ' + e.message, 'error'); + } finally { + document.getElementById('loadingOverlay').style.display = 'none'; + } +} + +document.addEventListener('DOMContentLoaded', () => { + initMap(); + loadAll(); + bindLayerToggles(); + + document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu); + document.getElementById('btn-close-spbu')?.addEventListener('click', () => { + document.getElementById('spbuPanel').style.display = 'none'; + if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; } + }); +}); diff --git a/project_final/config/app.php b/project_final/config/app.php new file mode 100644 index 0000000..d17c0af --- /dev/null +++ b/project_final/config/app.php @@ -0,0 +1,25 @@ + $_SESSION['user_id'] ?? null, + 'username' => $_SESSION['username'] ?? '', + 'role' => $_SESSION['role'] ?? '', + 'nama_lengkap'=> $_SESSION['nama_lengkap']?? '', + ]; +} diff --git a/project_final/config/db.php b/project_final/config/db.php new file mode 100644 index 0000000..dd90d3a --- /dev/null +++ b/project_final/config/db.php @@ -0,0 +1,34 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + } + return self::$conn; + } +} diff --git a/project_final/config/session.php b/project_final/config/session.php new file mode 100644 index 0000000..14a6858 --- /dev/null +++ b/project_final/config/session.php @@ -0,0 +1,24 @@ + 0, + 'path' => app_base_path() ?: '/', + 'secure' => $isHttps, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); +} diff --git a/project_final/index.php b/project_final/index.php new file mode 100644 index 0000000..20ff4bc --- /dev/null +++ b/project_final/index.php @@ -0,0 +1,13 @@ +prepare("SELECT * FROM users WHERE username = ? LIMIT 1"); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + $_SESSION['nama_lengkap'] = $user['nama_lengkap']; + header($user['role'] === 'admin' ? 'Location: admin/index.php' : 'Location: user/index.php'); + exit; + } + + $error = 'Username atau password salah. Silakan coba lagi.'; + } else { + $error = 'Username dan password wajib diisi.'; + } +} +?> + + + + + + Login - WebGIS Smart City + + + + + + + +
+
+

Selamat Datang

+

Masuk ke sistem WebGIS Smart City Pontianak.

+ +
+ + +
+ + +
+ + +
+ + +
+ Akun Testing:
+ Admin: admin / admin123
+ User: pengguna / user123 +
+ +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+
+
+ + + + diff --git a/project_final/logout.php b/project_final/logout.php new file mode 100644 index 0000000..455d8f6 --- /dev/null +++ b/project_final/logout.php @@ -0,0 +1,6 @@ +query("SELECT + (SELECT COUNT(*) FROM spbu) as spbu, + (SELECT COUNT(*) FROM jalan) as jalan, + (SELECT COUNT(*) FROM kavling) as kavling, + (SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah, + (SELECT COUNT(*) FROM warga_miskin) as warga_miskin, + (SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh, + (SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam +")->fetch(); + +// Hitung kawasan merah (rawan) +$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM ( + SELECT k.id FROM kawasan_kumuh k + LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom) + GROUP BY k.id HAVING COUNT(w.id) > 3 +) sub")->fetch(); +$kawasanMerah = (int)($merahResult['cnt'] ?? 0); + +// Hitung blank spot +$blankCount = $pdo->query(" + SELECT COUNT(*) as cnt + FROM warga_miskin w + WHERE NOT EXISTS ( + SELECT 1 + FROM rumah_ibadah ri + WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter + ) +")->fetch(); +$blankSpot = (int)($blankCount['cnt'] ?? 0); + +$pageTitle = 'Beranda'; +$activeNav = 'dashboard'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+

👋 Selamat Datang, !

+

+ Anda masuk sebagai Pengguna. Anda dapat melihat seluruh data spasial, analisis, dan berpartisipasi dengan mengirim Laporan Warga atau memberikan ulasan fasilitas. +

+
+ +
+
+
+
+
Total SPBU
+
+
buka 24 jam
+
+
+
+
+
+
Ruas Jalan
+
+
Segmen terpetakan
+
+
+
+
+
+
Rumah Ibadah
+
+
Lokasi tercatat
+
+
+
+
+
+
Warga Miskin
+
+
blank spot
+
+
+
+
+
+
Kawasan Rawan
+
+
dari kawasan
+
+
+
+
+
+
Kavling / Parsil
+
+
Bidang tanah
+
+
+
+ +
+
+
+

🗺️ Buka Peta Interaktif

+

+ Lihat semua layer data spasial: SPBU, jalan, kavling, rumah ibadah, warga miskin, kawasan kumuh, dan analisis spasial. +

+
+
+ + Buka Peta + +
+
+
+ + diff --git a/project_final/user/laporan.php b/project_final/user/laporan.php new file mode 100644 index 0000000..49fc7d5 --- /dev/null +++ b/project_final/user/laporan.php @@ -0,0 +1,191 @@ +prepare("SELECT id, kategori, deskripsi, status, created_at FROM laporan_warga WHERE user_id = ? ORDER BY id DESC"); +$stmt->execute([$_SESSION['user_id']]); +$rows = $stmt->fetchAll(); + +$pageTitle = 'Laporan Warga'; +$activeNav = 'laporan'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; + +function badgeStatus($status) { + if($status === 'menunggu') return 'badge-warning'; + if($status === 'diproses') return 'badge-info'; + if($status === 'selesai') return 'badge-success'; + if($status === 'ditolak') return 'badge-danger'; + return 'badge-primary'; +} +?> + + + +
+ + +
+ +
+ + + + + + + + + + + + + $r): ?> + + + + + + + + + + + + + + + +
#KategoriDeskripsi SingkatStatusTanggalAksi
+ + +
+ + + + Dikunci + +
+
+ Anda belum pernah membuat laporan. +
+
+ + + + + + +JS; ?> + diff --git a/project_final/user/partials/footer.php b/project_final/user/partials/footer.php new file mode 100644 index 0000000..a2474e0 --- /dev/null +++ b/project_final/user/partials/footer.php @@ -0,0 +1,119 @@ +
+
+ + +
+ + + + + diff --git a/project_final/user/partials/header.php b/project_final/user/partials/header.php new file mode 100644 index 0000000..4da42b8 --- /dev/null +++ b/project_final/user/partials/header.php @@ -0,0 +1,85 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'User Portal') ?> — WebGIS Smart City + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Mode Baca + +
+
+
diff --git a/project_final/user/peta.php b/project_final/user/peta.php new file mode 100644 index 0000000..03b0d6d --- /dev/null +++ b/project_final/user/peta.php @@ -0,0 +1,161 @@ + + +'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+
+ +
+ Mode Baca +
+ + + +
+ +
+ + + +
+
Legenda
+
+
SPBU 24 Jam
+
SPBU Terbatas
+
Jalan
+
Kavling
+
Rumah Ibadah
+
Warga Miskin
+
Kawasan Kumuh
+
Blank Spot
+
+
+ + +
+ + + + +