From 2c123f5af222b0582d26250ba0b1f19b0384b47d Mon Sep 17 00:00:00 2001 From: naukyy Date: Sat, 13 Jun 2026 00:00:33 +0700 Subject: [PATCH] feat: Initial commit - WebGIS Smart City Project by Naufal Zaky Ramadhan (D1041231071) --- .dockerignore | 22 + .env.example | 9 + .gitignore | 55 ++ 01/backend/api/jalan.php | 75 ++ 01/backend/api/kavling.php | 75 ++ 01/backend/api/spbu.php | 59 ++ 01/backend/config/db.php | 57 ++ 01/backend/utils/response_helper.php | 51 ++ 01/frontend/css/base.css | 61 ++ 01/frontend/css/form.css | 92 +++ 01/frontend/css/main.css | 205 +++++ 01/frontend/css/map.css | 73 ++ 01/frontend/css/sidebar.css | 78 ++ 01/frontend/index.html | 56 ++ 01/frontend/js/app.js | 92 +++ 01/frontend/js/config.js | 15 + 01/frontend/js/modules/draw.js | 31 + 01/frontend/js/modules/form.js | 50 ++ 01/frontend/js/modules/map.js | 25 + 01/frontend/js/services/api.service.js | 49 ++ 01/frontend/js/services/jalan.service.js | 28 + 01/frontend/js/services/kavling.service.js | 28 + 01/frontend/js/services/spbu.service.js | 28 + 01/frontend/js/utils/style.util.js | 34 + 01/frontend/js/utils/ui.util.js | 32 + 02/backend/api/jalan.php | 75 ++ 02/backend/api/kavling.php | 75 ++ 02/backend/api/rumah_ibadah.php | 89 ++ 02/backend/api/spbu.php | 59 ++ 02/backend/api/warga_miskin.php | 67 ++ 02/backend/config/db.php | 57 ++ 02/backend/utils/geo_helper.php | 66 ++ 02/backend/utils/response_helper.php | 29 + 02/frontend/css/base.css | 61 ++ 02/frontend/css/form.css | 92 +++ 02/frontend/css/main.css | 205 +++++ 02/frontend/css/map.css | 68 ++ 02/frontend/css/sidebar.css | 78 ++ 02/frontend/index.html | 50 ++ 02/frontend/js/app.js | 124 +++ 02/frontend/js/config.js | 4 + 02/frontend/js/modules/draw.js | 31 + 02/frontend/js/modules/form.js | 50 ++ 02/frontend/js/modules/map.js | 25 + 02/frontend/js/services/api.service.js | 49 ++ .../js/services/rumah_ibadah.service.js | 22 + .../js/services/warga_miskin.service.js | 18 + 02/frontend/js/utils/ui.util.js | 50 ++ 03/backend/api/jalan.php | 75 ++ 03/backend/api/kavling.php | 75 ++ 03/backend/api/rumah_ibadah.php | 89 ++ 03/backend/api/spbu.php | 59 ++ 03/backend/api/statistik.php | 71 ++ 03/backend/api/warga_miskin.php | 67 ++ 03/backend/config/db.php | 57 ++ 03/backend/utils/response_helper.php | 29 + 03/frontend/base.css | 61 ++ 03/frontend/css/main.css | 205 +++++ 03/frontend/form.css | 92 +++ 03/frontend/index.html | 53 ++ 03/frontend/js/app.js | 110 +++ 03/frontend/js/modules/draw.js | 31 + 03/frontend/js/modules/form.js | 50 ++ 03/frontend/js/modules/map.js | 25 + 03/frontend/js/services/api.service.js | 49 ++ .../js/services/rumah_ibadah.service.js | 22 + 03/frontend/js/services/statistik.service.js | 10 + .../js/services/warga_miskin.service.js | 18 + 03/frontend/map.css | 68 ++ 03/frontend/sidebar.css | 78 ++ Dockerfile | 16 + README.md | 81 ++ db_scripts/buat_tabel.sql | 116 +++ db_scripts/data_awal_pengguna.sql | 26 + db_scripts/data_awal_wilayah.sql | 172 ++++ db_scripts/webgis_naufal_zaky.sql | 278 +++++++ index.html | 320 ++++++++ setup_database.php | 24 + versi/backend/api/jalan.php | 75 ++ versi/backend/api/kavling.php | 75 ++ versi/backend/api/rumah_ibadah.php | 89 ++ versi/backend/api/spbu.php | 59 ++ versi/backend/api/statistik.php | 71 ++ versi/backend/api/warga_miskin.php | 67 ++ versi/backend/config/db.php | 32 + versi/backend/utils/response_helper.php | 29 + versi/frontend/base.css | 61 ++ versi/frontend/css/main.css | 205 +++++ versi/frontend/form.css | 92 +++ versi/frontend/index.html | 53 ++ versi/frontend/js/app.js | 110 +++ versi/frontend/js/modules/draw.js | 31 + versi/frontend/js/modules/form.js | 50 ++ versi/frontend/js/modules/map.js | 25 + versi/frontend/js/services/api.service.js | 49 ++ .../js/services/rumah_ibadah.service.js | 22 + .../frontend/js/services/statistik.service.js | 10 + .../js/services/warga_miskin.service.js | 18 + versi/frontend/map.css | 68 ++ versi/frontend/sidebar.css | 78 ++ webgis_app/.htaccess | 13 + webgis_app/api/blank_spot.php | 2 + webgis_app/api/check_srid.php | 2 + webgis_app/api/fix_srid.php | 2 + webgis_app/api/jalan.php | 2 + webgis_app/api/kavling.php | 2 + webgis_app/api/kawasan_kumuh.php | 2 + webgis_app/api/laporan.php | 2 + webgis_app/api/rumah_ibadah.php | 2 + webgis_app/api/spbu.php | 2 + webgis_app/api/spbu_status.php | 2 + webgis_app/api/spbu_terdekat.php | 2 + webgis_app/api/statistik.php | 2 + webgis_app/api/ulasan.php | 2 + webgis_app/api/users.php | 2 + webgis_app/api/warga_miskin.php | 2 + webgis_app/core_config/database.php | 58 ++ webgis_app/core_config/env_loader.php | 56 ++ webgis_app/core_config/middleware_auth.php | 66 ++ webgis_app/core_config/session_mgr.php | 24 + webgis_app/core_config/settings.php | 29 + webgis_app/endpoints/api_fasilitas.php | 113 +++ webgis_app/endpoints/api_jalan.php | 88 ++ webgis_app/endpoints/api_kavling.php | 91 +++ webgis_app/endpoints/api_kawasan.php | 86 ++ webgis_app/endpoints/api_laporan.php | 117 +++ webgis_app/endpoints/api_pengguna.php | 77 ++ webgis_app/endpoints/api_spbu.php | 91 +++ webgis_app/endpoints/api_spbu_nearby.php | 62 ++ webgis_app/endpoints/api_spbu_status.php | 41 + webgis_app/endpoints/api_statistik.php | 65 ++ webgis_app/endpoints/api_ulasan.php | 100 +++ webgis_app/endpoints/api_warga_miskin.php | 91 +++ webgis_app/endpoints/area_blank.php | 98 +++ webgis_app/endpoints/cek_srid.php | 35 + webgis_app/endpoints/perbaiki_srid.php | 33 + webgis_app/import.php | 28 + webgis_app/index.php | 13 + webgis_app/login.php | 130 +++ webgis_app/logout.php | 6 + webgis_app/panel_admin/index.php | 172 ++++ webgis_app/panel_admin/kelola_fasilitas.php | 140 ++++ webgis_app/panel_admin/kelola_jalan.php | 79 ++ webgis_app/panel_admin/kelola_kavling.php | 82 ++ webgis_app/panel_admin/kelola_kawasan.php | 57 ++ webgis_app/panel_admin/kelola_laporan.php | 191 +++++ webgis_app/panel_admin/kelola_spbu.php | 164 ++++ webgis_app/panel_admin/kelola_warga.php | 101 +++ webgis_app/panel_admin/manajemen_pengguna.php | 179 ++++ webgis_app/panel_admin/partials/footer.php | 119 +++ webgis_app/panel_admin/partials/header.php | 110 +++ webgis_app/panel_admin/peta_admin.php | 173 ++++ webgis_app/panel_user/buat_laporan.php | 191 +++++ webgis_app/panel_user/index.php | 116 +++ webgis_app/panel_user/partials/footer.php | 119 +++ webgis_app/panel_user/partials/header.php | 85 ++ webgis_app/panel_user/peta_user.php | 161 ++++ webgis_app/public_assets/css/auth.css | 215 +++++ webgis_app/public_assets/css/global.css | 248 ++++++ webgis_app/public_assets/css/map.css | 765 ++++++++++++++++++ webgis_app/public_assets/css/panel.css | 364 +++++++++ webgis_app/public_assets/js/peta-admin.js | 532 ++++++++++++ webgis_app/public_assets/js/peta-user.js | 383 +++++++++ 163 files changed, 13007 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 01/backend/api/jalan.php create mode 100644 01/backend/api/kavling.php create mode 100644 01/backend/api/spbu.php create mode 100644 01/backend/config/db.php create mode 100644 01/backend/utils/response_helper.php create mode 100644 01/frontend/css/base.css create mode 100644 01/frontend/css/form.css create mode 100644 01/frontend/css/main.css create mode 100644 01/frontend/css/map.css create mode 100644 01/frontend/css/sidebar.css create mode 100644 01/frontend/index.html create mode 100644 01/frontend/js/app.js create mode 100644 01/frontend/js/config.js create mode 100644 01/frontend/js/modules/draw.js create mode 100644 01/frontend/js/modules/form.js create mode 100644 01/frontend/js/modules/map.js create mode 100644 01/frontend/js/services/api.service.js create mode 100644 01/frontend/js/services/jalan.service.js create mode 100644 01/frontend/js/services/kavling.service.js create mode 100644 01/frontend/js/services/spbu.service.js create mode 100644 01/frontend/js/utils/style.util.js create mode 100644 01/frontend/js/utils/ui.util.js create mode 100644 02/backend/api/jalan.php create mode 100644 02/backend/api/kavling.php create mode 100644 02/backend/api/rumah_ibadah.php create mode 100644 02/backend/api/spbu.php create mode 100644 02/backend/api/warga_miskin.php create mode 100644 02/backend/config/db.php create mode 100644 02/backend/utils/geo_helper.php create mode 100644 02/backend/utils/response_helper.php create mode 100644 02/frontend/css/base.css create mode 100644 02/frontend/css/form.css create mode 100644 02/frontend/css/main.css create mode 100644 02/frontend/css/map.css create mode 100644 02/frontend/css/sidebar.css create mode 100644 02/frontend/index.html create mode 100644 02/frontend/js/app.js create mode 100644 02/frontend/js/config.js create mode 100644 02/frontend/js/modules/draw.js create mode 100644 02/frontend/js/modules/form.js create mode 100644 02/frontend/js/modules/map.js create mode 100644 02/frontend/js/services/api.service.js create mode 100644 02/frontend/js/services/rumah_ibadah.service.js create mode 100644 02/frontend/js/services/warga_miskin.service.js create mode 100644 02/frontend/js/utils/ui.util.js create mode 100644 03/backend/api/jalan.php create mode 100644 03/backend/api/kavling.php create mode 100644 03/backend/api/rumah_ibadah.php create mode 100644 03/backend/api/spbu.php create mode 100644 03/backend/api/statistik.php create mode 100644 03/backend/api/warga_miskin.php create mode 100644 03/backend/config/db.php create mode 100644 03/backend/utils/response_helper.php create mode 100644 03/frontend/base.css create mode 100644 03/frontend/css/main.css create mode 100644 03/frontend/form.css create mode 100644 03/frontend/index.html create mode 100644 03/frontend/js/app.js create mode 100644 03/frontend/js/modules/draw.js create mode 100644 03/frontend/js/modules/form.js create mode 100644 03/frontend/js/modules/map.js create mode 100644 03/frontend/js/services/api.service.js create mode 100644 03/frontend/js/services/rumah_ibadah.service.js create mode 100644 03/frontend/js/services/statistik.service.js create mode 100644 03/frontend/js/services/warga_miskin.service.js create mode 100644 03/frontend/map.css create mode 100644 03/frontend/sidebar.css create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 db_scripts/buat_tabel.sql create mode 100644 db_scripts/data_awal_pengguna.sql create mode 100644 db_scripts/data_awal_wilayah.sql create mode 100644 db_scripts/webgis_naufal_zaky.sql create mode 100644 index.html create mode 100644 setup_database.php create mode 100644 versi/backend/api/jalan.php create mode 100644 versi/backend/api/kavling.php create mode 100644 versi/backend/api/rumah_ibadah.php create mode 100644 versi/backend/api/spbu.php create mode 100644 versi/backend/api/statistik.php create mode 100644 versi/backend/api/warga_miskin.php create mode 100644 versi/backend/config/db.php create mode 100644 versi/backend/utils/response_helper.php create mode 100644 versi/frontend/base.css create mode 100644 versi/frontend/css/main.css create mode 100644 versi/frontend/form.css create mode 100644 versi/frontend/index.html create mode 100644 versi/frontend/js/app.js create mode 100644 versi/frontend/js/modules/draw.js create mode 100644 versi/frontend/js/modules/form.js create mode 100644 versi/frontend/js/modules/map.js create mode 100644 versi/frontend/js/services/api.service.js create mode 100644 versi/frontend/js/services/rumah_ibadah.service.js create mode 100644 versi/frontend/js/services/statistik.service.js create mode 100644 versi/frontend/js/services/warga_miskin.service.js create mode 100644 versi/frontend/map.css create mode 100644 versi/frontend/sidebar.css create mode 100644 webgis_app/.htaccess create mode 100644 webgis_app/api/blank_spot.php create mode 100644 webgis_app/api/check_srid.php create mode 100644 webgis_app/api/fix_srid.php create mode 100644 webgis_app/api/jalan.php create mode 100644 webgis_app/api/kavling.php create mode 100644 webgis_app/api/kawasan_kumuh.php create mode 100644 webgis_app/api/laporan.php create mode 100644 webgis_app/api/rumah_ibadah.php create mode 100644 webgis_app/api/spbu.php create mode 100644 webgis_app/api/spbu_status.php create mode 100644 webgis_app/api/spbu_terdekat.php create mode 100644 webgis_app/api/statistik.php create mode 100644 webgis_app/api/ulasan.php create mode 100644 webgis_app/api/users.php create mode 100644 webgis_app/api/warga_miskin.php create mode 100644 webgis_app/core_config/database.php create mode 100644 webgis_app/core_config/env_loader.php create mode 100644 webgis_app/core_config/middleware_auth.php create mode 100644 webgis_app/core_config/session_mgr.php create mode 100644 webgis_app/core_config/settings.php create mode 100644 webgis_app/endpoints/api_fasilitas.php create mode 100644 webgis_app/endpoints/api_jalan.php create mode 100644 webgis_app/endpoints/api_kavling.php create mode 100644 webgis_app/endpoints/api_kawasan.php create mode 100644 webgis_app/endpoints/api_laporan.php create mode 100644 webgis_app/endpoints/api_pengguna.php create mode 100644 webgis_app/endpoints/api_spbu.php create mode 100644 webgis_app/endpoints/api_spbu_nearby.php create mode 100644 webgis_app/endpoints/api_spbu_status.php create mode 100644 webgis_app/endpoints/api_statistik.php create mode 100644 webgis_app/endpoints/api_ulasan.php create mode 100644 webgis_app/endpoints/api_warga_miskin.php create mode 100644 webgis_app/endpoints/area_blank.php create mode 100644 webgis_app/endpoints/cek_srid.php create mode 100644 webgis_app/endpoints/perbaiki_srid.php create mode 100644 webgis_app/import.php create mode 100644 webgis_app/index.php create mode 100644 webgis_app/login.php create mode 100644 webgis_app/logout.php create mode 100644 webgis_app/panel_admin/index.php create mode 100644 webgis_app/panel_admin/kelola_fasilitas.php create mode 100644 webgis_app/panel_admin/kelola_jalan.php create mode 100644 webgis_app/panel_admin/kelola_kavling.php create mode 100644 webgis_app/panel_admin/kelola_kawasan.php create mode 100644 webgis_app/panel_admin/kelola_laporan.php create mode 100644 webgis_app/panel_admin/kelola_spbu.php create mode 100644 webgis_app/panel_admin/kelola_warga.php create mode 100644 webgis_app/panel_admin/manajemen_pengguna.php create mode 100644 webgis_app/panel_admin/partials/footer.php create mode 100644 webgis_app/panel_admin/partials/header.php create mode 100644 webgis_app/panel_admin/peta_admin.php create mode 100644 webgis_app/panel_user/buat_laporan.php create mode 100644 webgis_app/panel_user/index.php create mode 100644 webgis_app/panel_user/partials/footer.php create mode 100644 webgis_app/panel_user/partials/header.php create mode 100644 webgis_app/panel_user/peta_user.php create mode 100644 webgis_app/public_assets/css/auth.css create mode 100644 webgis_app/public_assets/css/global.css create mode 100644 webgis_app/public_assets/css/map.css create mode 100644 webgis_app/public_assets/css/panel.css create mode 100644 webgis_app/public_assets/js/peta-admin.js create mode 100644 webgis_app/public_assets/js/peta-user.js 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..cf2d2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# OS / editor +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ + +# Local deliverables / work notes +MANUAL_BOOK.docx +PRESENTASI.pptx +NEXT_WORK_SUMMARY.md +PROJECT_PROGRESS_SUMMARY.md + +# Environment / local config +.env +.env.* +!.env.example +project_final/config/db.local.php +project_final/config/*.local.php + +# PHP / Composer +vendor/ + +# Node / frontend packages +node_modules/ + +# Logs / cache / temp +*.log +logs/ +cache/ +tmp/ +temp/ +*.tmp +*.bak +*.swp + +# Runtime uploads +uploads/* +project_final/uploads/* +!uploads/.gitkeep +!project_final/uploads/.gitkeep + +# Generated backups / exports +database/backups/ +database/*backup*.sql +database/*_backup.sql +*.sql.gz +*.dump + +# Test / build output +coverage/ +dist/ +build/ + +akun_testing.txt diff --git a/01/backend/api/jalan.php b/01/backend/api/jalan.php new file mode 100644 index 0000000..886efd9 --- /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..4d51ee2 --- /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..6acaf74 --- /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..67677f8 --- /dev/null +++ b/01/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + return self::$conn; + } +} 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..8339ec3 --- /dev/null +++ b/01/frontend/js/services/api.service.js @@ -0,0 +1,49 @@ +const getBaseUrl = () => { + const segments = window.location.pathname.split('/'); + const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg)); + if (targetIndex !== -1) { + return segments.slice(0, targetIndex).join('/'); + } + return ''; +}; +export const BASE_URL = getBaseUrl(); + +const createService = (endpoint) => ({ + getAll: async () => { + const res = await fetch(`${BASE_URL}/${endpoint}`); + const json = await res.json(); + return json.data; + }, + create: async (data) => { + const res = await fetch(`${BASE_URL}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const json = await res.json(); + return json.data; + }, + delete: async (id) => { + const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, { + method: 'DELETE' + }); + const json = await res.json(); + return json.data; + } +}); + +// P01 +export const spbuService = createService('01/backend/api/spbu.php'); +export const jalanService = createService('01/backend/api/jalan.php'); +export const kavlingService = createService('01/backend/api/kavling.php'); + +// P02 +export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php'); +export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); +export const haversineService = { + getDalamRadius: async (id, radius) => { + const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); + const json = await res.json(); + return json.data; + } +}; 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..886efd9 --- /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..4d51ee2 --- /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..b1172c4 --- /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..6acaf74 --- /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..2c48a44 --- /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..67677f8 --- /dev/null +++ b/02/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + return self::$conn; + } +} 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..8339ec3 --- /dev/null +++ b/02/frontend/js/services/api.service.js @@ -0,0 +1,49 @@ +const getBaseUrl = () => { + const segments = window.location.pathname.split('/'); + const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg)); + if (targetIndex !== -1) { + return segments.slice(0, targetIndex).join('/'); + } + return ''; +}; +export const BASE_URL = getBaseUrl(); + +const createService = (endpoint) => ({ + getAll: async () => { + const res = await fetch(`${BASE_URL}/${endpoint}`); + const json = await res.json(); + return json.data; + }, + create: async (data) => { + const res = await fetch(`${BASE_URL}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const json = await res.json(); + return json.data; + }, + delete: async (id) => { + const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, { + method: 'DELETE' + }); + const json = await res.json(); + return json.data; + } +}); + +// P01 +export const spbuService = createService('01/backend/api/spbu.php'); +export const jalanService = createService('01/backend/api/jalan.php'); +export const kavlingService = createService('01/backend/api/kavling.php'); + +// P02 +export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php'); +export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); +export const haversineService = { + getDalamRadius: async (id, radius) => { + const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); + const json = await res.json(); + return json.data; + } +}; 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..886efd9 --- /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..4d51ee2 --- /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..b1172c4 --- /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..6acaf74 --- /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..0208fa0 --- /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..2c48a44 --- /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..67677f8 --- /dev/null +++ b/03/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + return self::$conn; + } +} 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..8339ec3 --- /dev/null +++ b/03/frontend/js/services/api.service.js @@ -0,0 +1,49 @@ +const getBaseUrl = () => { + const segments = window.location.pathname.split('/'); + const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg)); + if (targetIndex !== -1) { + return segments.slice(0, targetIndex).join('/'); + } + return ''; +}; +export const BASE_URL = getBaseUrl(); + +const createService = (endpoint) => ({ + getAll: async () => { + const res = await fetch(`${BASE_URL}/${endpoint}`); + const json = await res.json(); + return json.data; + }, + create: async (data) => { + const res = await fetch(`${BASE_URL}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const json = await res.json(); + return json.data; + }, + delete: async (id) => { + const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, { + method: 'DELETE' + }); + const json = await res.json(); + return json.data; + } +}); + +// P01 +export const spbuService = createService('01/backend/api/spbu.php'); +export const jalanService = createService('01/backend/api/jalan.php'); +export const kavlingService = createService('01/backend/api/kavling.php'); + +// P02 +export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php'); +export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); +export const haversineService = { + getDalamRadius: async (id, radius) => { + const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); + const json = await res.json(); + return json.data; + } +}; 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..9f3f94e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM php:8.2-apache + +RUN docker-php-ext-install pdo_mysql mysqli \ + && a2enmod rewrite headers + +COPY index.html /var/www/html/index.html +COPY 01/ /var/www/html/01/ +COPY 02/ /var/www/html/02/ +COPY 03/ /var/www/html/03/ +COPY final/ /var/www/html/final/ +COPY project_final/ /var/www/html/project_final/ +COPY database/ /var/www/html/database/ + +RUN chown -R www-data:www-data /var/www/html + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d80a113 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# D1041231071 — WebGIS Smart City Project +**Naufal Zaky R | UAS WebGIS** + +Aplikasi WebGIS berbasis PHP + MySQL untuk pemetaan data kemiskinan, fasilitas umum, laporan warga, dan analisis blank spot bantuan sosial di Kota Pontianak. + +## Stack Teknologi + +- PHP 8.2 Apache +- MySQL / MariaDB + PostGIS +- Leaflet.js (peta interaktif) +- PDO MySQL (koneksi database) + +## Cara Deploy (Coolify) + +1. Buat database MySQL/MariaDB di Coolify. +2. Import `db_scripts/webgis_naufal_zaky.sql` ke database tersebut. +3. Buat aplikasi baru dari repo ini. +4. Pilih build menggunakan `Dockerfile` di root repository. +5. Set environment variable: + +```env +APP_BASE_PATH= +SESSION_SECURE=true +CORS_ALLOWED_ORIGIN= +DB_HOST=nama-service-database +DB_PORT=3306 +DB_DATABASE=webgis_db +DB_USERNAME=user_database +DB_PASSWORD=password_database +``` + +Aplikasi final berjalan di `/webgis_app`. Gunakan `APP_BASE_PATH=/webgis_app` untuk deploy. + +## Akun Awal + +Setelah import SQL: + +- Admin: `admin / admin123` +- Pengguna: `pengguna / user123` + +> Ganti password akun demo setelah deploy jika aplikasi dibuka publik. + +## Struktur Project + +``` +D1041231071_NaufalZakyR_UAS_WebGISProject/ +├── beranda.html ← Halaman pemilih progres +├── webgis_app/ ← Aplikasi final (main app) +│ ├── panel_admin/ ← Halaman & fitur admin +│ ├── panel_user/ ← Halaman & fitur pengguna +│ ├── endpoints/ ← API endpoint PHP +│ ├── core_config/ ← Konfigurasi (DB, session, auth) +│ └── public_assets/ ← CSS & JS (global, panel, map) +├── db_scripts/ ← SQL scripts database +│ ├── buat_tabel.sql +│ ├── data_awal_wilayah.sql +│ ├── data_awal_pengguna.sql +│ └── webgis_naufal_zaky.sql +├── version_01/ ← Progres pertemuan 1 +├── version_02/ ← Progres pertemuan 2 +├── version_03/ ← Progres pertemuan 3 +├── legacy_version/ ← Versi lama (referensi) +├── Dockerfile ← Docker image untuk Coolify +└── .env ← Konfigurasi environment +``` + +## Jalankan di XAMPP Lokal + +Set konfigurasi di `.env`: + +```env +APP_BASE_PATH=/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=webgis_db +DB_USERNAME=root +DB_PASSWORD= +``` + +Import `db_scripts/webgis_naufal_zaky.sql` ke phpMyAdmin, lalu buka: +`http://localhost/D1041231071_NaufalZakyR_UAS_WebGISProject/webgis_app/login.php` diff --git a/db_scripts/buat_tabel.sql b/db_scripts/buat_tabel.sql new file mode 100644 index 0000000..f7dfc13 --- /dev/null +++ b/db_scripts/buat_tabel.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/db_scripts/data_awal_pengguna.sql b/db_scripts/data_awal_pengguna.sql new file mode 100644 index 0000000..32d5b0a --- /dev/null +++ b/db_scripts/data_awal_pengguna.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/db_scripts/data_awal_wilayah.sql b/db_scripts/data_awal_wilayah.sql new file mode 100644 index 0000000..d0d3b70 --- /dev/null +++ b/db_scripts/data_awal_wilayah.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/db_scripts/webgis_naufal_zaky.sql b/db_scripts/webgis_naufal_zaky.sql new file mode 100644 index 0000000..7a77b37 --- /dev/null +++ b/db_scripts/webgis_naufal_zaky.sql @@ -0,0 +1,278 @@ +-- ============================================================ +-- WebGIS Pontianak - Database Lengkap +-- ============================================================ +-- Import file ini jika ingin membuat ulang database dari awal. +-- Perhatian: tabel lama dengan nama yang sama akan dihapus. + +-- Database selected via connection string + +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS ulasan_fasilitas; +DROP TABLE IF EXISTS laporan_warga; +DROP TABLE IF EXISTS warga_miskin; +DROP TABLE IF EXISTS kawasan_kumuh; +DROP TABLE IF EXISTS kavling; +DROP TABLE IF EXISTS jalan; +DROP TABLE IF EXISTS rumah_ibadah; +DROP TABLE IF EXISTS spbu; +DROP TABLE IF EXISTS users; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 1. Users +-- ============================================================ +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') NOT NULL DEFAULT 'user', + nama_lengkap VARCHAR(100) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO users (username, password, role, nama_lengkap) VALUES + ( + 'admin', + '$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62', + 'admin', + 'Administrator WebGIS' + ), + ( + 'pengguna', + '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW', + 'user', + 'Pengguna Publik' + ); + +-- ============================================================ +-- 2. Data Spasial +-- ============================================================ +CREATE TABLE spbu ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(100) NOT NULL, + deskripsi TEXT, + buka_24_jam TINYINT(1) NOT NULL DEFAULT 0, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_spbu_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES + ( + 'SPBU Bundaran Digulis Untan', + 'SPBU terdekat dari kampus', + 1, + ST_GeomFromText('POINT(109.3440 -0.0565)') + ), + ( + 'SPBU Ahmad Yani', + 'Dekat Mega Mall (24 Jam)', + 1, + ST_GeomFromText('POINT(109.3360 -0.0450)') + ), + ( + 'SPBU Imam Bonjol', + 'SPBU pinggir kota', + 0, + ST_GeomFromText('POINT(109.3410 -0.0380)') + ), + ( + 'SPBU Kota Baru', + 'Jl. Sultan Abdurrahman', + 0, + ST_GeomFromText('POINT(109.3245 -0.0487)') + ); + +CREATE TABLE rumah_ibadah ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(100) NOT NULL, + agama VARCHAR(50) NOT NULL, + radius_bantuan_meter INT NOT NULL DEFAULT 1000, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_rumah_ibadah_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES + ( + 'Masjid Raya Mujahidin', + 'Islam', + 1000, + ST_GeomFromText('POINT(109.3356 -0.0454)') + ), + ( + 'Gereja Katedral St. Yosef', + 'Katolik', + 1000, + ST_GeomFromText('POINT(109.3368 -0.0298)') + ), + ( + 'Vihara Bodhisatva Karaniya Metta', + 'Buddha', + 1000, + ST_GeomFromText('POINT(109.3468 -0.0233)') + ), + ( + 'Masjid Jami Keraton Kadriyah', + 'Islam', + 1000, + ST_GeomFromText('POINT(109.3523 -0.0229)') + ); + +CREATE TABLE jalan ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(100) NOT NULL, + jenis_jalan VARCHAR(50) DEFAULT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_jalan_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO jalan (nama, jenis_jalan, geom) VALUES + ( + 'Jalan Ahmad Yani', + 'Arteri Primer', + ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)') + ), + ( + 'Jalan Gajah Mada', + 'Kolektor', + ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)') + ), + ( + 'Jalan Imam Bonjol', + 'Lokal', + ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)') + ); + +CREATE TABLE kavling ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_pemilik VARCHAR(100) NOT NULL, + status_kepemilikan VARCHAR(50) NOT NULL, + luas DECIMAL(10,2) DEFAULT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_kavling_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) VALUES + ( + 'PT. Mega Karya', + 'HGB', + 25000.00, + ST_GeomFromText('POLYGON((109.333 -0.048, 109.338 -0.048, 109.338 -0.053, 109.333 -0.053, 109.333 -0.048))') + ), + ( + 'Bapak Sudirman', + 'SHM', + 1500.00, + ST_GeomFromText('POLYGON((109.340 -0.035, 109.341 -0.035, 109.341 -0.036, 109.340 -0.036, 109.340 -0.035))') + ); + +CREATE TABLE kawasan_kumuh ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_kawasan VARCHAR(100) NOT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_kawasan_kumuh_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO kawasan_kumuh (nama_kawasan, geom) VALUES + ( + 'Kawasan Rawan Bantaran Sungai', + ST_GeomFromText('POLYGON((109.360 -0.050, 109.365 -0.050, 109.365 -0.055, 109.360 -0.055, 109.360 -0.050))') + ), + ( + 'Kawasan Padat Parit Tokaya', + ST_GeomFromText('POLYGON((109.342 -0.038, 109.346 -0.038, 109.346 -0.042, 109.342 -0.042, 109.342 -0.038))') + ); + +CREATE TABLE warga_miskin ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_kk VARCHAR(100) NOT NULL, + penghasilan DECIMAL(15,2) NOT NULL, + jumlah_tanggungan INT NOT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_warga_miskin_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES + ( + 'Bpk. Budi (Terisolasi)', + 800000.00, + 4, + ST_GeomFromText('POINT(109.361 -0.051)') + ), + ( + 'Ibu Siti (Terisolasi)', + 600000.00, + 2, + ST_GeomFromText('POINT(109.362 -0.052)') + ), + ( + 'Bpk. Joko (Terisolasi)', + 1000000.00, + 5, + ST_GeomFromText('POINT(109.363 -0.053)') + ), + ( + 'Ibu Ani (Terisolasi)', + 700000.00, + 3, + ST_GeomFromText('POINT(109.364 -0.054)') + ), + ( + 'Bpk. Hasan', + 1200000.00, + 3, + ST_GeomFromText('POINT(109.334 -0.050)') + ), + ( + 'Mbah Warno', + 400000.00, + 1, + ST_GeomFromText('POINT(109.336 -0.046)') + ), + ( + 'Pak Junaidi', + 900000.00, + 4, + ST_GeomFromText('POINT(109.344 -0.040)') + ); + +-- ============================================================ +-- 3. Data Interaksi Pengguna +-- ============================================================ +CREATE TABLE laporan_warga ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + kategori VARCHAR(50) NOT NULL, + deskripsi TEXT NOT NULL, + geometry POINT NOT NULL, + status ENUM('menunggu', 'diproses', 'selesai', 'ditolak') NOT NULL DEFAULT 'menunggu', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_laporan_user_id (user_id), + SPATIAL INDEX idx_laporan_geometry (geometry), + CONSTRAINT fk_laporan_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE ulasan_fasilitas ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + fasilitas_tipe ENUM('spbu', 'rumah_ibadah') NOT NULL, + fasilitas_id INT NOT NULL, + rating TINYINT NOT NULL, + komentar TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_ulasan_user_id (user_id), + INDEX idx_ulasan_fasilitas (fasilitas_tipe, fasilitas_id), + CONSTRAINT fk_ulasan_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT chk_ulasan_rating CHECK (rating BETWEEN 1 AND 5) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/index.html b/index.html new file mode 100644 index 0000000..0c9ad80 --- /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/setup_database.php b/setup_database.php new file mode 100644 index 0000000..5ebe69e --- /dev/null +++ b/setup_database.php @@ -0,0 +1,24 @@ +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/versi/backend/api/kavling.php b/versi/backend/api/kavling.php new file mode 100644 index 0000000..4d51ee2 --- /dev/null +++ b/versi/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/versi/backend/api/rumah_ibadah.php b/versi/backend/api/rumah_ibadah.php new file mode 100644 index 0000000..b1172c4 --- /dev/null +++ b/versi/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/versi/backend/api/spbu.php b/versi/backend/api/spbu.php new file mode 100644 index 0000000..6acaf74 --- /dev/null +++ b/versi/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/versi/backend/api/statistik.php b/versi/backend/api/statistik.php new file mode 100644 index 0000000..0208fa0 --- /dev/null +++ b/versi/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/versi/backend/api/warga_miskin.php b/versi/backend/api/warga_miskin.php new file mode 100644 index 0000000..2c48a44 --- /dev/null +++ b/versi/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/versi/backend/config/db.php b/versi/backend/config/db.php new file mode 100644 index 0000000..287e5da --- /dev/null +++ b/versi/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/versi/backend/utils/response_helper.php b/versi/backend/utils/response_helper.php new file mode 100644 index 0000000..cd7968c --- /dev/null +++ b/versi/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/versi/frontend/base.css b/versi/frontend/base.css new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/versi/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/versi/frontend/css/main.css b/versi/frontend/css/main.css new file mode 100644 index 0000000..14cadd4 --- /dev/null +++ b/versi/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/versi/frontend/form.css b/versi/frontend/form.css new file mode 100644 index 0000000..be53320 --- /dev/null +++ b/versi/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/versi/frontend/index.html b/versi/frontend/index.html new file mode 100644 index 0000000..968c82c --- /dev/null +++ b/versi/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/versi/frontend/js/app.js b/versi/frontend/js/app.js new file mode 100644 index 0000000..3941765 --- /dev/null +++ b/versi/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/versi/frontend/js/modules/draw.js b/versi/frontend/js/modules/draw.js new file mode 100644 index 0000000..40628f4 --- /dev/null +++ b/versi/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/versi/frontend/js/modules/form.js b/versi/frontend/js/modules/form.js new file mode 100644 index 0000000..3959e52 --- /dev/null +++ b/versi/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/versi/frontend/js/modules/map.js b/versi/frontend/js/modules/map.js new file mode 100644 index 0000000..fdecef9 --- /dev/null +++ b/versi/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/versi/frontend/js/services/api.service.js b/versi/frontend/js/services/api.service.js new file mode 100644 index 0000000..8339ec3 --- /dev/null +++ b/versi/frontend/js/services/api.service.js @@ -0,0 +1,49 @@ +const getBaseUrl = () => { + const segments = window.location.pathname.split('/'); + const targetIndex = segments.findIndex(seg => ['01', '02', '03', 'versi'].includes(seg)); + if (targetIndex !== -1) { + return segments.slice(0, targetIndex).join('/'); + } + return ''; +}; +export const BASE_URL = getBaseUrl(); + +const createService = (endpoint) => ({ + getAll: async () => { + const res = await fetch(`${BASE_URL}/${endpoint}`); + const json = await res.json(); + return json.data; + }, + create: async (data) => { + const res = await fetch(`${BASE_URL}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const json = await res.json(); + return json.data; + }, + delete: async (id) => { + const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, { + method: 'DELETE' + }); + const json = await res.json(); + return json.data; + } +}); + +// P01 +export const spbuService = createService('01/backend/api/spbu.php'); +export const jalanService = createService('01/backend/api/jalan.php'); +export const kavlingService = createService('01/backend/api/kavling.php'); + +// P02 +export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php'); +export const wargaMiskinService = createService('02/backend/api/warga_miskin.php'); +export const haversineService = { + getDalamRadius: async (id, radius) => { + const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`); + const json = await res.json(); + return json.data; + } +}; diff --git a/versi/frontend/js/services/rumah_ibadah.service.js b/versi/frontend/js/services/rumah_ibadah.service.js new file mode 100644 index 0000000..dc20231 --- /dev/null +++ b/versi/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/versi/frontend/js/services/statistik.service.js b/versi/frontend/js/services/statistik.service.js new file mode 100644 index 0000000..c41a736 --- /dev/null +++ b/versi/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/versi/frontend/js/services/warga_miskin.service.js b/versi/frontend/js/services/warga_miskin.service.js new file mode 100644 index 0000000..d9cee43 --- /dev/null +++ b/versi/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/versi/frontend/map.css b/versi/frontend/map.css new file mode 100644 index 0000000..0656663 --- /dev/null +++ b/versi/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/versi/frontend/sidebar.css b/versi/frontend/sidebar.css new file mode 100644 index 0000000..5ce4b23 --- /dev/null +++ b/versi/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/webgis_app/.htaccess b/webgis_app/.htaccess new file mode 100644 index 0000000..26fefea --- /dev/null +++ b/webgis_app/.htaccess @@ -0,0 +1,13 @@ +RewriteEngine On + +# Specific file mappings from old API names to new refactored endpoint names +RewriteRule ^api/kawasan_kumuh\.php$ endpoints/api_kawasan.php [L,QSA] +RewriteRule ^api/rumah_ibadah\.php$ endpoints/api_fasilitas.php [L,QSA] +RewriteRule ^api/users\.php$ endpoints/api_pengguna.php [L,QSA] +RewriteRule ^api/spbu_terdekat\.php$ endpoints/api_spbu_nearby.php [L,QSA] +RewriteRule ^api/blank_spot\.php$ endpoints/area_blank.php [L,QSA] +RewriteRule ^api/check_srid\.php$ endpoints/cek_srid.php [L,QSA] +RewriteRule ^api/fix_srid\.php$ endpoints/perbaiki_srid.php [L,QSA] + +# General pattern mapping: api/filename.php -> endpoints/api_filename.php +RewriteRule ^api/([a-zA-Z0-9_]+)\.php$ endpoints/api_$1.php [L,QSA] diff --git a/webgis_app/api/blank_spot.php b/webgis_app/api/blank_spot.php new file mode 100644 index 0000000..6b9c84b --- /dev/null +++ b/webgis_app/api/blank_spot.php @@ -0,0 +1,2 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../db_scripts/webgis_naufal_zaky.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + return self::$conn; + } +} diff --git a/webgis_app/core_config/env_loader.php b/webgis_app/core_config/env_loader.php new file mode 100644 index 0000000..b7bdd1e --- /dev/null +++ b/webgis_app/core_config/env_loader.php @@ -0,0 +1,56 @@ += 2 && + (($value[0] === '"' && $value[-1] === '"') || + ($value[0] === "'" && $value[-1] === "'"))) { + $value = substr($value, 1, -1); + } + + // Only set if not already defined in the environment (server env wins) + if (getenv($key) === false) { + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + + $loaded = true; +} + +// Auto-load on include +loadEnv(); diff --git a/webgis_app/core_config/middleware_auth.php b/webgis_app/core_config/middleware_auth.php new file mode 100644 index 0000000..a012fed --- /dev/null +++ b/webgis_app/core_config/middleware_auth.php @@ -0,0 +1,66 @@ + $_SESSION['user_id'] ?? null, + 'username' => $_SESSION['username'] ?? '', + 'role' => $_SESSION['role'] ?? '', + 'nama_lengkap'=> $_SESSION['nama_lengkap']?? '', + ]; +} + +/** + * API-safe auth helpers: return JSON 401/403 instead of redirecting. + * Use these in endpoints called via fetch/AJAX. + */ +function requireApiLogin(): void { + if (!isLoggedIn()) { + http_response_code(401); + echo json_encode(['status' => 'error', 'message' => 'Autentikasi diperlukan']); + exit; + } +} + +function requireApiRole(string $role): void { + requireApiLogin(); + if ($_SESSION['role'] !== $role) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak: role tidak sesuai']); + exit; + } +} diff --git a/webgis_app/core_config/session_mgr.php b/webgis_app/core_config/session_mgr.php new file mode 100644 index 0000000..7280851 --- /dev/null +++ b/webgis_app/core_config/session_mgr.php @@ -0,0 +1,24 @@ + 0, + 'path' => app_base_path() ?: '/', + 'secure' => $isHttps, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); +} diff --git a/webgis_app/core_config/settings.php b/webgis_app/core_config/settings.php new file mode 100644 index 0000000..d2a4468 --- /dev/null +++ b/webgis_app/core_config/settings.php @@ -0,0 +1,29 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama, agama, radius_bantuan_meter, created_at, + 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'], + 'radius_bantuan_meter' => $row['radius_bantuan_meter'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data Rumah Ibadah: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama']) || !isset($input['agama']) || !isset($input['geometry'])) { + sendError('Nama, agama, dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) + VALUES (:nama, :agama, :radius_bantuan_meter, ST_GeomFromGeoJSON(:geometry))" + ); + $stmt->execute([ + ':nama' => $input['nama'], + ':agama' => $input['agama'], + ':radius_bantuan_meter' => $input['radius_bantuan_meter'] ?? 1000, + ':geometry' => json_encode($input['geometry']), + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Data Rumah Ibadah berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan Rumah Ibadah: ' . $e->getMessage(), 500); + } + break; + + case 'PUT': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID Rumah Ibadah wajib disertakan'); + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) sendError('Data tidak valid'); + try { + $stmt = $pdo->prepare( + "UPDATE rumah_ibadah SET nama = :nama, agama = :agama, radius_bantuan_meter = :radius_bantuan_meter WHERE id = :id" + ); + $stmt->execute([ + ':nama' => $input['nama'], + ':agama' => $input['agama'], + ':radius_bantuan_meter' => $input['radius_bantuan_meter'] ?? 1000, + ':id' => $id, + ]); + sendSuccess(null, 'Data Rumah Ibadah berhasil diperbarui'); + } catch (PDOException $e) { + sendError('Gagal memperbarui Rumah Ibadah: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID Rumah Ibadah wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Data Rumah Ibadah berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus Rumah Ibadah: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_jalan.php b/webgis_app/endpoints/api_jalan.php new file mode 100644 index 0000000..288f860 --- /dev/null +++ b/webgis_app/endpoints/api_jalan.php @@ -0,0 +1,88 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) AS geojson FROM jalan" + ); + $features = []; + while ($row = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($row['geojson']), + 'properties' => [ + 'id' => $row['id'], + 'nama' => $row['nama'], + 'jenis_jalan' => $row['jenis_jalan'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama']) || !isset($input['geometry'])) { + sendError('Nama dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO jalan (nama, jenis_jalan, geom) + VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))" + ); + $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': + requireApiRole('admin'); + $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); +} diff --git a/webgis_app/endpoints/api_kavling.php b/webgis_app/endpoints/api_kavling.php new file mode 100644 index 0000000..cbdb76c --- /dev/null +++ b/webgis_app/endpoints/api_kavling.php @@ -0,0 +1,91 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama_pemilik, status_kepemilikan, 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'], + 'status_kepemilikan' => $row['status_kepemilikan'], + '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': + requireApiRole('admin'); + $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 { + $stmt = $pdo->prepare( + "INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) + VALUES (:nama_pemilik, :status_kepemilikan, :luas, ST_GeomFromGeoJSON(:geometry))" + ); + $stmt->execute([ + ':nama_pemilik' => $input['nama_pemilik'], + ':status_kepemilikan' => $input['status_kepemilikan'] ?? 'SHM', + ':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': + requireApiRole('admin'); + $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); +} diff --git a/webgis_app/endpoints/api_kawasan.php b/webgis_app/endpoints/api_kawasan.php new file mode 100644 index 0000000..bc867ab --- /dev/null +++ b/webgis_app/endpoints/api_kawasan.php @@ -0,0 +1,86 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama_kawasan, created_at, ST_AsGeoJSON(geom) AS geojson FROM kawasan_kumuh" + ); + $features = []; + while ($row = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($row['geojson']), + 'properties' => [ + 'id' => $row['id'], + 'nama_kawasan' => $row['nama_kawasan'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kawasan Kumuh berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data Kawasan Kumuh: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama_kawasan']) || !isset($input['geometry'])) { + sendError('Nama kawasan dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO kawasan_kumuh (nama_kawasan, geom) + VALUES (:nama_kawasan, ST_GeomFromGeoJSON(:geometry))" + ); + $stmt->execute([ + ':nama_kawasan' => $input['nama_kawasan'], + ':geometry' => json_encode($input['geometry']), + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kawasan Kumuh berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan Kawasan Kumuh: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID Kawasan Kumuh wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM kawasan_kumuh WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Data Kawasan Kumuh berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus Kawasan Kumuh: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_laporan.php b/webgis_app/endpoints/api_laporan.php new file mode 100644 index 0000000..f9a1108 --- /dev/null +++ b/webgis_app/endpoints/api_laporan.php @@ -0,0 +1,117 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +startAppSession(); +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + requireApiLogin(); + try { + $userId = $_SESSION['user_id'] ?? null; + $role = $_SESSION['role'] ?? 'user'; + + if ($role === 'admin') { + $stmt = $pdo->query( + "SELECT lw.id, lw.user_id, u.username, lw.kategori, lw.deskripsi, + lw.status, lw.created_at, ST_AsGeoJSON(lw.geometry) AS geojson + FROM laporan_warga lw + JOIN users u ON u.id = lw.user_id + ORDER BY lw.created_at DESC" + ); + } else { + $stmt = $pdo->prepare( + "SELECT id, user_id, kategori, deskripsi, status, created_at, + ST_AsGeoJSON(geometry) AS geojson + FROM laporan_warga WHERE user_id = :uid ORDER BY created_at DESC" + ); + $stmt->execute([':uid' => $userId]); + } + + $rows = $stmt->fetchAll(); + foreach ($rows as &$r) { + $r['geojson'] = json_decode($r['geojson']); + } + sendSuccess($rows, 'Data Laporan berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil laporan: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiLogin(); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['kategori']) || !isset($input['deskripsi']) || !isset($input['geometry'])) { + sendError('Kategori, deskripsi, dan geometri wajib diisi'); + } + $userId = $_SESSION['user_id']; + try { + $stmt = $pdo->prepare( + "INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry) + VALUES (:user_id, :kategori, :deskripsi, ST_GeomFromGeoJSON(:geometry))" + ); + $stmt->execute([ + ':user_id' => $userId, + ':kategori' => $input['kategori'], + ':deskripsi'=> $input['deskripsi'], + ':geometry' => json_encode($input['geometry']), + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Laporan berhasil dikirim', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan laporan: ' . $e->getMessage(), 500); + } + break; + + case 'PUT': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + $id = $_GET['id'] ?? null; + if (!$id || !isset($input['status'])) sendError('ID dan status wajib disertakan'); + $allowed = ['menunggu', 'diproses', 'selesai', 'ditolak']; + if (!in_array($input['status'], $allowed)) sendError('Status tidak valid'); + try { + $stmt = $pdo->prepare("UPDATE laporan_warga SET status = :status WHERE id = :id"); + $stmt->execute([':status' => $input['status'], ':id' => $id]); + sendSuccess(null, 'Status laporan berhasil diperbarui'); + } catch (PDOException $e) { + sendError('Gagal memperbarui laporan: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID laporan wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Laporan berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus laporan: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_pengguna.php b/webgis_app/endpoints/api_pengguna.php new file mode 100644 index 0000000..b25d731 --- /dev/null +++ b/webgis_app/endpoints/api_pengguna.php @@ -0,0 +1,77 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +requireApiRole('admin'); + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, username, role, nama_lengkap, created_at FROM users ORDER BY created_at DESC" + ); + sendSuccess($stmt->fetchAll(), 'Data Pengguna berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data pengguna: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['username'], $input['password'])) { + sendError('Username dan password wajib diisi'); + } + try { + $hash = password_hash($input['password'], PASSWORD_BCRYPT); + $stmt = $pdo->prepare( + "INSERT INTO users (username, password, role, nama_lengkap) + VALUES (:username, :password, :role, :nama_lengkap)" + ); + $stmt->execute([ + ':username' => $input['username'], + ':password' => $hash, + ':role' => $input['role'] ?? 'user', + ':nama_lengkap'=> $input['nama_lengkap'] ?? null, + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Pengguna berhasil dibuat', 201); + } catch (PDOException $e) { + sendError('Gagal membuat pengguna: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID pengguna wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM users WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Pengguna berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus pengguna: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_spbu.php b/webgis_app/endpoints/api_spbu.php new file mode 100644 index 0000000..fdc1bd5 --- /dev/null +++ b/webgis_app/endpoints/api_spbu.php @@ -0,0 +1,91 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama, deskripsi, buka_24_jam, created_at, + ST_AsGeoJSON(geom) AS geojson FROM spbu" + ); + $features = []; + while ($row = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($row['geojson']), + 'properties' => [ + 'id' => $row['id'], + 'nama' => $row['nama'], + 'deskripsi' => $row['deskripsi'], + 'buka_24_jam' => (bool) $row['buka_24_jam'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data SPBU: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama']) || !isset($input['geometry'])) { + sendError('Nama dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) + VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))" + ); + $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()], 'Data SPBU berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan SPBU: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID SPBU wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM spbu WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Data SPBU berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus SPBU: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_spbu_nearby.php b/webgis_app/endpoints/api_spbu_nearby.php new file mode 100644 index 0000000..e6d705f --- /dev/null +++ b/webgis_app/endpoints/api_spbu_nearby.php @@ -0,0 +1,62 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +if ($method !== 'GET') { sendError('Method not allowed', 405); } + +$lat = isset($_GET['lat']) ? (float) $_GET['lat'] : null; +$lng = isset($_GET['lng']) ? (float) $_GET['lng'] : null; +$radius = isset($_GET['radius']) ? (int) $_GET['radius'] : 5000; + +if ($lat === null || $lng === null) { sendError('Parameter lat dan lng wajib disertakan'); } + +try { + $stmt = $pdo->prepare( + "SELECT id, nama, deskripsi, buka_24_jam, created_at, + ST_AsGeoJSON(geom) AS geojson, + ST_Distance_Sphere(geom, ST_GeomFromText(:point)) AS jarak_meter + FROM spbu + WHERE ST_Distance_Sphere(geom, ST_GeomFromText(:point)) <= :radius + ORDER BY jarak_meter ASC" + ); + $stmt->execute([':point' => "POINT({$lng} {$lat})", ':radius' => $radius]); + $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'], + 'jarak_meter' => round($row['jarak_meter'], 2), + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'SPBU Terdekat berhasil diambil'); +} catch (PDOException $e) { + sendError('Gagal mengambil SPBU Terdekat: ' . $e->getMessage(), 500); +} diff --git a/webgis_app/endpoints/api_spbu_status.php b/webgis_app/endpoints/api_spbu_status.php new file mode 100644 index 0000000..877dd78 --- /dev/null +++ b/webgis_app/endpoints/api_spbu_status.php @@ -0,0 +1,41 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +if ($method !== 'GET') { + sendError('Method not allowed', 405); +} + +try { + $stmt = $pdo->query( + "SELECT + COUNT(*) AS total, + SUM(buka_24_jam = 1) AS buka_24_jam, + SUM(buka_24_jam = 0) AS tidak_24_jam + FROM spbu" + ); + $summary = $stmt->fetch(); + sendSuccess($summary, 'Status SPBU berhasil diambil'); +} catch (PDOException $e) { + sendError('Gagal mengambil status SPBU: ' . $e->getMessage(), 500); +} diff --git a/webgis_app/endpoints/api_statistik.php b/webgis_app/endpoints/api_statistik.php new file mode 100644 index 0000000..5ac87e8 --- /dev/null +++ b/webgis_app/endpoints/api_statistik.php @@ -0,0 +1,65 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +if ($method !== 'GET') { sendError('Method not allowed', 405); } + +try { + $counts = []; + + $tables = [ + 'spbu' => 'total_spbu', + 'rumah_ibadah' => 'total_rumah_ibadah', + 'jalan' => 'total_jalan', + 'kavling' => 'total_kavling', + 'kawasan_kumuh' => 'total_kawasan_kumuh', + 'warga_miskin' => 'total_warga_miskin', + 'laporan_warga' => 'total_laporan', + 'users' => 'total_users', + ]; + + foreach ($tables as $table => $key) { + $row = $pdo->query("SELECT COUNT(*) AS cnt FROM `{$table}`")->fetch(); + $counts[$key] = (int) $row['cnt']; + } + + // Additional SPBU breakdown + $spbuStatus = $pdo->query( + "SELECT SUM(buka_24_jam=1) AS buka_24_jam, SUM(buka_24_jam=0) AS tidak_24_jam FROM spbu" + )->fetch(); + $counts['spbu_buka_24_jam'] = (int) $spbuStatus['buka_24_jam']; + $counts['spbu_tidak_24_jam'] = (int) $spbuStatus['tidak_24_jam']; + + // Laporan by status + $laporanStatus = $pdo->query( + "SELECT status, COUNT(*) AS cnt FROM laporan_warga GROUP BY status" + )->fetchAll(); + $counts['laporan_by_status'] = []; + foreach ($laporanStatus as $row) { + $counts['laporan_by_status'][$row['status']] = (int) $row['cnt']; + } + + sendSuccess($counts, 'Statistik berhasil diambil'); +} catch (PDOException $e) { + sendError('Gagal mengambil statistik: ' . $e->getMessage(), 500); +} diff --git a/webgis_app/endpoints/api_ulasan.php b/webgis_app/endpoints/api_ulasan.php new file mode 100644 index 0000000..7b1262a --- /dev/null +++ b/webgis_app/endpoints/api_ulasan.php @@ -0,0 +1,100 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +startAppSession(); +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + $tipe = $_GET['tipe'] ?? null; + $fid = $_GET['fasilitas_id'] ?? null; + try { + if ($tipe && $fid) { + $stmt = $pdo->prepare( + "SELECT uf.id, uf.user_id, u.username, uf.fasilitas_tipe, + uf.fasilitas_id, uf.rating, uf.komentar, uf.created_at + FROM ulasan_fasilitas uf + JOIN users u ON u.id = uf.user_id + WHERE uf.fasilitas_tipe = :tipe AND uf.fasilitas_id = :fid + ORDER BY uf.created_at DESC" + ); + $stmt->execute([':tipe' => $tipe, ':fid' => $fid]); + } else { + $stmt = $pdo->query( + "SELECT uf.id, uf.user_id, u.username, uf.fasilitas_tipe, + uf.fasilitas_id, uf.rating, uf.komentar, uf.created_at + FROM ulasan_fasilitas uf + JOIN users u ON u.id = uf.user_id + ORDER BY uf.created_at DESC" + ); + } + sendSuccess($stmt->fetchAll(), 'Data Ulasan berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil ulasan: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiLogin(); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['fasilitas_tipe'], $input['fasilitas_id'], $input['rating'])) { + sendError('fasilitas_tipe, fasilitas_id, dan rating wajib diisi'); + } + $allowed = ['spbu', 'rumah_ibadah']; + if (!in_array($input['fasilitas_tipe'], $allowed)) sendError('fasilitas_tipe tidak valid'); + if ($input['rating'] < 1 || $input['rating'] > 5) sendError('Rating harus antara 1-5'); + try { + $stmt = $pdo->prepare( + "INSERT INTO ulasan_fasilitas (user_id, fasilitas_tipe, fasilitas_id, rating, komentar) + VALUES (:user_id, :fasilitas_tipe, :fasilitas_id, :rating, :komentar)" + ); + $stmt->execute([ + ':user_id' => $_SESSION['user_id'], + ':fasilitas_tipe'=> $input['fasilitas_tipe'], + ':fasilitas_id' => $input['fasilitas_id'], + ':rating' => $input['rating'], + ':komentar' => $input['komentar'] ?? null, + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Ulasan berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan ulasan: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID ulasan wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM ulasan_fasilitas WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Ulasan berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus ulasan: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/api_warga_miskin.php b/webgis_app/endpoints/api_warga_miskin.php new file mode 100644 index 0000000..f7829a7 --- /dev/null +++ b/webgis_app/endpoints/api_warga_miskin.php @@ -0,0 +1,91 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + $stmt = $pdo->query( + "SELECT id, nama_kk, penghasilan, jumlah_tanggungan, created_at, + 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'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data Warga Miskin: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama_kk']) || !isset($input['geometry'])) { + sendError('Nama KK dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) + VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))" + ); + $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()], 'Data Warga Miskin berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan Warga Miskin: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID Warga Miskin wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Data Warga Miskin berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus Warga Miskin: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/area_blank.php b/webgis_app/endpoints/area_blank.php new file mode 100644 index 0000000..7b61a4e --- /dev/null +++ b/webgis_app/endpoints/area_blank.php @@ -0,0 +1,98 @@ + 'success', 'message' => $message, 'data' => $data]); + exit(); +} +function sendError($message = 'Error', $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message]); + exit(); +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + try { + // Check if blank_spot table exists, otherwise return empty collection + $check = $pdo->query("SHOW TABLES LIKE 'blank_spot'")->rowCount(); + if ($check === 0) { + sendSuccess( + ['type' => 'FeatureCollection', 'features' => []], + 'Tabel blank_spot belum tersedia' + ); + } + $stmt = $pdo->query( + "SELECT id, nama, keterangan, created_at, ST_AsGeoJSON(geom) AS geojson FROM blank_spot" + ); + $features = []; + while ($row = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($row['geojson']), + 'properties' => [ + 'id' => $row['id'], + 'nama' => $row['nama'], + 'keterangan' => $row['keterangan'], + 'created_at' => $row['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Blank Spot berhasil diambil'); + } catch (PDOException $e) { + sendError('Gagal mengambil data Blank Spot: ' . $e->getMessage(), 500); + } + break; + + case 'POST': + requireApiRole('admin'); + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['nama']) || !isset($input['geometry'])) { + sendError('Nama dan geometri wajib diisi'); + } + try { + $stmt = $pdo->prepare( + "INSERT INTO blank_spot (nama, keterangan, geom) + VALUES (:nama, :keterangan, ST_GeomFromGeoJSON(:geometry))" + ); + $stmt->execute([ + ':nama' => $input['nama'], + ':keterangan' => $input['keterangan'] ?? '', + ':geometry' => json_encode($input['geometry']), + ]); + sendSuccess(['id' => $pdo->lastInsertId()], 'Data Blank Spot berhasil disimpan', 201); + } catch (PDOException $e) { + sendError('Gagal menyimpan Blank Spot: ' . $e->getMessage(), 500); + } + break; + + case 'DELETE': + requireApiRole('admin'); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID Blank Spot wajib disertakan'); + try { + $stmt = $pdo->prepare("DELETE FROM blank_spot WHERE id = :id"); + $stmt->execute([':id' => $id]); + sendSuccess(null, 'Data Blank Spot berhasil dihapus'); + } catch (PDOException $e) { + sendError('Gagal menghapus Blank Spot: ' . $e->getMessage(), 500); + } + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/webgis_app/endpoints/cek_srid.php b/webgis_app/endpoints/cek_srid.php new file mode 100644 index 0000000..cbee85c --- /dev/null +++ b/webgis_app/endpoints/cek_srid.php @@ -0,0 +1,35 @@ +query( + "SELECT ST_SRID(geom) AS srid, COUNT(*) AS jumlah + FROM `{$table}` + GROUP BY ST_SRID(geom)" + ); + $results[$table] = $stmt->fetchAll(); + } + + http_response_code(200); + echo json_encode(['status' => 'success', 'data' => $results]); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/webgis_app/endpoints/perbaiki_srid.php b/webgis_app/endpoints/perbaiki_srid.php new file mode 100644 index 0000000..a9858fb --- /dev/null +++ b/webgis_app/endpoints/perbaiki_srid.php @@ -0,0 +1,33 @@ +exec( + "UPDATE `{$table}` SET geom = ST_GeomFromWKB(ST_AsWKB(geom), 0) WHERE ST_SRID(geom) != 0" + ); + $updated[$table] = ['rows_updated' => $affected]; + } + + http_response_code(200); + echo json_encode(['status' => 'success', 'message' => 'SRID diperbaiki', 'data' => $updated]); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/webgis_app/import.php b/webgis_app/import.php new file mode 100644 index 0000000..e8b5d60 --- /dev/null +++ b/webgis_app/import.php @@ -0,0 +1,28 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Read SQL file + $sqlFile = __DIR__ . '/../database/webgis_db.sql'; + if (!file_exists($sqlFile)) { + die("SQL file not found at: $sqlFile"); + } + + $sql = file_get_contents($sqlFile); + + // Split into statements and execute + $pdo->exec($sql); + + echo "Database imported successfully to $db at $host!"; +} catch (PDOException $e) { + echo "Connection failed: " . $e->getMessage() . "\n"; +} diff --git a/webgis_app/index.php b/webgis_app/index.php new file mode 100644 index 0000000..09c45d2 --- /dev/null +++ b/webgis_app/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: panel_admin/index.php' : 'Location: panel_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.

+ +
+ + +
+ + +
+ + +
+ + + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+
+
+ + + + diff --git a/webgis_app/logout.php b/webgis_app/logout.php new file mode 100644 index 0000000..64e6652 --- /dev/null +++ b/webgis_app/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, + (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(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= 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/webgis_app/panel_admin/kelola_fasilitas.php b/webgis_app/panel_admin/kelola_fasilitas.php new file mode 100644 index 0000000..b82e0a9 --- /dev/null +++ b/webgis_app/panel_admin/kelola_fasilitas.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/webgis_app/panel_admin/kelola_jalan.php b/webgis_app/panel_admin/kelola_jalan.php new file mode 100644 index 0000000..293b51a --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/kelola_kavling.php b/webgis_app/panel_admin/kelola_kavling.php new file mode 100644 index 0000000..c6b0569 --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/kelola_kawasan.php b/webgis_app/panel_admin/kelola_kawasan.php new file mode 100644 index 0000000..cbcd9ae --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/kelola_laporan.php b/webgis_app/panel_admin/kelola_laporan.php new file mode 100644 index 0000000..7aa795c --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/kelola_spbu.php b/webgis_app/panel_admin/kelola_spbu.php new file mode 100644 index 0000000..f9bb7e1 --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/kelola_warga.php b/webgis_app/panel_admin/kelola_warga.php new file mode 100644 index 0000000..9653642 --- /dev/null +++ b/webgis_app/panel_admin/kelola_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/webgis_app/panel_admin/manajemen_pengguna.php b/webgis_app/panel_admin/manajemen_pengguna.php new file mode 100644 index 0000000..60378e3 --- /dev/null +++ b/webgis_app/panel_admin/manajemen_pengguna.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/webgis_app/panel_admin/partials/footer.php b/webgis_app/panel_admin/partials/footer.php new file mode 100644 index 0000000..a2474e0 --- /dev/null +++ b/webgis_app/panel_admin/partials/footer.php @@ -0,0 +1,119 @@ + + + + +
+ + + + + diff --git a/webgis_app/panel_admin/partials/header.php b/webgis_app/panel_admin/partials/header.php new file mode 100644 index 0000000..08ae1c9 --- /dev/null +++ b/webgis_app/panel_admin/partials/header.php @@ -0,0 +1,110 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Admin Panel') ?> — WebGIS Smart City + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Buka Peta + +
+
+
diff --git a/webgis_app/panel_admin/peta_admin.php b/webgis_app/panel_admin/peta_admin.php new file mode 100644 index 0000000..4658c71 --- /dev/null +++ b/webgis_app/panel_admin/peta_admin.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/webgis_app/panel_user/buat_laporan.php b/webgis_app/panel_user/buat_laporan.php new file mode 100644 index 0000000..d11958c --- /dev/null +++ b/webgis_app/panel_user/buat_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/webgis_app/panel_user/index.php b/webgis_app/panel_user/index.php new file mode 100644 index 0000000..c22e4e4 --- /dev/null +++ b/webgis_app/panel_user/index.php @@ -0,0 +1,116 @@ +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(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= 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. +

+
+ +
+
+ + diff --git a/webgis_app/panel_user/partials/footer.php b/webgis_app/panel_user/partials/footer.php new file mode 100644 index 0000000..a2474e0 --- /dev/null +++ b/webgis_app/panel_user/partials/footer.php @@ -0,0 +1,119 @@ +
+
+ + +
+ + + + + diff --git a/webgis_app/panel_user/partials/header.php b/webgis_app/panel_user/partials/header.php new file mode 100644 index 0000000..5016214 --- /dev/null +++ b/webgis_app/panel_user/partials/header.php @@ -0,0 +1,85 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'User Portal') ?> — WebGIS Smart City + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Mode Baca + +
+
+
diff --git a/webgis_app/panel_user/peta_user.php b/webgis_app/panel_user/peta_user.php new file mode 100644 index 0000000..805a165 --- /dev/null +++ b/webgis_app/panel_user/peta_user.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
+
+
+ + +
+ + + + + diff --git a/webgis_app/public_assets/css/auth.css b/webgis_app/public_assets/css/auth.css new file mode 100644 index 0000000..5b92140 --- /dev/null +++ b/webgis_app/public_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/webgis_app/public_assets/css/global.css b/webgis_app/public_assets/css/global.css new file mode 100644 index 0000000..df0c78a --- /dev/null +++ b/webgis_app/public_assets/css/global.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/webgis_app/public_assets/css/map.css b/webgis_app/public_assets/css/map.css new file mode 100644 index 0000000..af8f700 --- /dev/null +++ b/webgis_app/public_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/webgis_app/public_assets/css/panel.css b/webgis_app/public_assets/css/panel.css new file mode 100644 index 0000000..aeec233 --- /dev/null +++ b/webgis_app/public_assets/css/panel.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/webgis_app/public_assets/js/peta-admin.js b/webgis_app/public_assets/js/peta-admin.js new file mode 100644 index 0000000..317decd --- /dev/null +++ b/webgis_app/public_assets/js/peta-admin.js @@ -0,0 +1,532 @@ +/* ========================================================= + 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), // default (Islam) + warga: makeIcon('👤', COLORS.warga), + blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36) +}; + +/* ── Per-religion icon colors & emojis ── */ +const AGAMA_EMOJI = { + 'Islam': '🕌', + 'Kristen': '⛪', + 'Katolik': '⛪', + 'Hindu': '🛕', + 'Buddha': '☯️', + 'Konghucu': '🏯' +}; +const AGAMA_COLOR = { + 'Islam': '#6D28D9', + 'Kristen': '#2563EB', + 'Katolik': '#1D4ED8', + 'Hindu': '#D97706', + 'Buddha': '#F59E0B', + 'Konghucu': '#DC2626' +}; +function getRumahIcon(agama) { + const emoji = AGAMA_EMOJI[agama] || '🛐'; + const color = AGAMA_COLOR[agama] || COLORS.rumah; + return makeIcon(emoji, color); +} + +/* ── 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) — icon berubah sesuai agama ── */ +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 agama = props.agama || 'Islam'; + const agamaColor = AGAMA_COLOR[agama] || COLORS.rumah; + 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: agamaColor, + fillColor: agamaColor, + fillOpacity: 0.10, + opacity: 0.60, + weight: 2 + }); + const marker = L.marker(ll, { icon: getRumahIcon(agama) }); + 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 += ``; + } + // Map type to the correct admin manage page + const kelolaMap = { + spbu: 'kelola_spbu.php', + jalan: 'kelola_jalan.php', + kavling: 'kelola_kavling.php', + kawasan: 'kelola_kawasan.php', + rumah: 'kelola_fasilitas.php', + warga: 'kelola_warga.php' + }; + const kelolaPage = kelolaMap[type] || ''; + 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(); +}; + +/* ── Update draw modal title when agama changes ── */ +window.updateRumahModalTitle = function(agama) { + const emoji = AGAMA_EMOJI[agama] || '🛐'; + document.getElementById('formModalTitle').textContent = `${emoji} Tambah Rumah Ibadah`; +}; + +/* ── 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/webgis_app/public_assets/js/peta-user.js b/webgis_app/public_assets/js/peta-user.js new file mode 100644 index 0000000..ea78f21 --- /dev/null +++ b/webgis_app/public_assets/js/peta-user.js @@ -0,0 +1,383 @@ +/* ========================================================= + 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), // default (Islam) + warga: makeIcon('👤', COLORS.warga), + blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36) +}; + +/* ── Per-religion icon colors & emojis ── */ +const AGAMA_EMOJI = { + 'Islam': '🕌', + 'Kristen': '⛪', + 'Katolik': '⛪', + 'Hindu': '🛕', + 'Buddha': '☯️', + 'Konghucu': '🏯' +}; +const AGAMA_COLOR = { + 'Islam': '#6D28D9', + 'Kristen': '#2563EB', + 'Katolik': '#1D4ED8', + 'Hindu': '#D97706', + 'Buddha': '#F59E0B', + 'Konghucu': '#DC2626' +}; +function getRumahIcon(agama) { + const emoji = AGAMA_EMOJI[agama] || '🛐'; + const color = AGAMA_COLOR[agama] || COLORS.rumah; + return makeIcon(emoji, color); +} + +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 agama = props.agama || 'Islam'; + const agamaColor = AGAMA_COLOR[agama] || COLORS.rumah; + 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: agamaColor, + fillColor: agamaColor, + fillOpacity: 0.10, + opacity: 0.60, + weight: 2 + }); + const marker = L.marker(ll, { icon: getRumahIcon(agama) }); + 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; } + }); +});