From a7cb1f2de07417a3fac9c8a93b859481add87a80 Mon Sep 17 00:00:00 2001 From: Ichsanix Date: Wed, 10 Jun 2026 22:46:10 +0700 Subject: [PATCH] first commit --- README.md | 290 ++++++++ api/bantuan.php | 80 +++ api/laporan.php | 135 ++++ api/parcels.php | 151 ++++ api/points.php | 220 ++++++ api/roads.php | 151 ++++ assets/css/style.css | 493 +++++++++++++ assets/js/app.js | 1557 +++++++++++++++++++++++++++++++++++++++++ config/db.php | 30 + config/helpers.php | 46 ++ dashboard.html | 305 ++++++++ database.sql | 65 ++ database/database.sql | 65 ++ docs/SRS.md | 325 +++++++++ index.php | 132 ++++ 15 files changed, 4045 insertions(+) create mode 100644 README.md create mode 100644 api/bantuan.php create mode 100644 api/laporan.php create mode 100644 api/parcels.php create mode 100644 api/points.php create mode 100644 api/roads.php create mode 100644 assets/css/style.css create mode 100644 assets/js/app.js create mode 100644 config/db.php create mode 100644 config/helpers.php create mode 100644 dashboard.html create mode 100644 database.sql create mode 100644 database/database.sql create mode 100644 docs/SRS.md create mode 100644 index.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fcf59f --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# WebGIS — Spatial Data Management System +## Setup & Installation Guide + +--- + +## Folder Structure + +``` +webgis/ +├── index.html ← Main frontend (single-page app) +├── database.sql ← Database schema + seed data +├── config/ +│ ├── db.php ← PDO database connection +│ └── helpers.php ← Shared utility functions +├── api/ +│ ├── points.php ← CRUD for point features +│ ├── roads.php ← CRUD for road polylines +│ └── parcels.php ← CRUD for land parcel polygons +├── assets/ +│ ├── css/ +│ │ └── style.css ← Application stylesheet +│ └── js/ +│ └── app.js ← Main application JavaScript +└── docs/ + └── SRS.md ← Software Requirements Specification +``` + +--- + +## Requirements + +- **PHP** 8.0 or higher (with PDO + PDO_MySQL extension) +- **MySQL** 8.0 or higher (or MariaDB 10.6+) +- **Web Server**: Apache (XAMPP/WAMP) or Nginx +- **Modern Browser**: Chrome 90+, Firefox 88+, Edge 90+ + +--- + +## Installation Steps + +### 1. Copy Files +Place the entire `webgis/` folder inside your web server's document root: +- XAMPP: `C:/xampp/htdocs/webgis/` +- WAMP: `C:/wamp64/www/webgis/` +- Linux: `/var/www/html/webgis/` + +### 2. Create the Database +Open phpMyAdmin or MySQL CLI and run: +```sql +source /path/to/webgis/database.sql; +``` +This creates the `webgis_db` database and all tables with sample data. + +### 3. Configure Database Connection +Edit `config/db.php`: +```php +define('DB_HOST', 'localhost'); +define('DB_NAME', 'webgis_db'); +define('DB_USER', 'root'); // your MySQL username +define('DB_PASS', ''); // your MySQL password +``` + +### 4. Start the Server +Start Apache and MySQL in XAMPP/WAMP, then visit: +``` +http://localhost/webgis/ +``` + +--- + +## Feature Usage + +### Adding a Point (SPBU / Mosque / Poor Population) +1. Click the **POINTS** tab in the sidebar +2. Click **"Add Point on Map"** +3. Click anywhere on the map +4. Fill in the modal form (name, category, etc.) +5. Click **"Add Point"** — marker appears on map + +### Drawing a Road +1. Click the **ROADS** tab +2. Click **"Draw Road on Map"** +3. Click multiple points on the map to draw the polyline +4. **Double-click** to finish drawing +5. Modal appears with **length auto-calculated** (read-only) +6. Enter road name and type, then save + +### Drawing a Land Parcel +1. Click the **PARCELS** tab +2. Click **"Draw Parcel on Map"** +3. Click to draw polygon vertices; **double-click** to close +4. Modal appears with **area auto-calculated** in m² +5. Enter owner name and ownership type, then save + +### Layer Visibility +- Use the **LAYERS** tab to toggle any layer on/off +- Each sub-layer (SPBU 24h, National Road, SHM parcels, etc.) is independently controllable + +### Edit / Delete +- Click **✏️** icon on any list item or in a popup to edit +- Click **🗑️** to delete (with confirmation) +- Press **Escape** to cancel any active drawing + +--- + +## API Endpoints + +All endpoints return JSON. CORS headers are set for local development. + +| Method | URL | Description | +|--------|-----|-------------| +| GET | `/api/points.php` | List all points as GeoJSON FeatureCollection | +| GET | `/api/points.php?id=N` | Get single point | +| GET | `/api/points.php?category=spbu` | Filter by category | +| POST | `/api/points.php` | Create a point | +| PUT | `/api/points.php?id=N` | Update a point | +| DELETE | `/api/points.php?id=N` | Delete a point | +| GET | `/api/roads.php` | List all roads | +| POST | `/api/roads.php` | Create a road | +| PUT | `/api/roads.php?id=N` | Update a road | +| DELETE | `/api/roads.php?id=N` | Delete a road | +| GET | `/api/parcels.php` | List all parcels | +| POST | `/api/parcels.php` | Create a parcel | +| PUT | `/api/parcels.php?id=N` | Update a parcel | +| DELETE | `/api/parcels.php?id=N` | Delete a parcel | + +--- + +## Libraries Used (CDN — no installation required) + +| Library | Version | Purpose | +|---------|---------|---------| +| Leaflet.js | 1.9.4 | Core mapping library | +| Leaflet.draw | 1.0.4 | Drawing tools (polyline, polygon) | +| leaflet-geometryutil | 0.10.3 | Geodesic area calculation | +| Google Fonts | — | Space Mono + DM Sans typography | + +--- +# WebGIS — Spatial Data Management System +## Setup & Installation Guide + +--- + +## Folder Structure + +``` +webgis/ +├── index.html ← Main frontend (single-page app) +├── database.sql ← Database schema + seed data +├── config/ +│ ├── db.php ← PDO database connection +│ └── helpers.php ← Shared utility functions +├── api/ +│ ├── points.php ← CRUD for point features +│ ├── roads.php ← CRUD for road polylines +│ └── parcels.php ← CRUD for land parcel polygons +├── assets/ +│ ├── css/ +│ │ └── style.css ← Application stylesheet +│ └── js/ +│ └── app.js ← Main application JavaScript +└── docs/ + └── SRS.md ← Software Requirements Specification +``` + +--- + +## Requirements + +- **PHP** 8.0 or higher (with PDO + PDO_MySQL extension) +- **MySQL** 8.0 or higher (or MariaDB 10.6+) +- **Web Server**: Apache (XAMPP/WAMP) or Nginx +- **Modern Browser**: Chrome 90+, Firefox 88+, Edge 90+ + +--- + +## Installation Steps + +### 1. Copy Files +Place the entire `webgis/` folder inside your web server's document root: +- XAMPP: `C:/xampp/htdocs/webgis/` +- WAMP: `C:/wamp64/www/webgis/` +- Linux: `/var/www/html/webgis/` + +### 2. Create the Database +Open phpMyAdmin or MySQL CLI and run: +```sql +source /path/to/webgis/database.sql; +``` +This creates the `webgis_db` database and all tables with sample data. + +### 3. Configure Database Connection +Edit `config/db.php`: +```php +define('DB_HOST', 'localhost'); +define('DB_NAME', 'webgis_db'); +define('DB_USER', 'root'); // your MySQL username +define('DB_PASS', ''); // your MySQL password +``` + +### 4. Start the Server +Start Apache and MySQL in XAMPP/WAMP, then visit: +``` +http://localhost/webgis/ +``` + +--- + +## Feature Usage + +### Adding a Point (SPBU / Mosque / Poor Population) +1. Click the **POINTS** tab in the sidebar +2. Click **"Add Point on Map"** +3. Click anywhere on the map +4. Fill in the modal form (name, category, etc.) +5. Click **"Add Point"** — marker appears on map + +### Drawing a Road +1. Click the **ROADS** tab +2. Click **"Draw Road on Map"** +3. Click multiple points on the map to draw the polyline +4. **Double-click** to finish drawing +5. Modal appears with **length auto-calculated** (read-only) +6. Enter road name and type, then save + +### Drawing a Land Parcel +1. Click the **PARCELS** tab +2. Click **"Draw Parcel on Map"** +3. Click to draw polygon vertices; **double-click** to close +4. Modal appears with **area auto-calculated** in m² +5. Enter owner name and ownership type, then save + +### Layer Visibility +- Use the **LAYERS** tab to toggle any layer on/off +- Each sub-layer (SPBU 24h, National Road, SHM parcels, etc.) is independently controllable + +### Edit / Delete +- Click **✏️** icon on any list item or in a popup to edit +- Click **🗑️** to delete (with confirmation) +- Press **Escape** to cancel any active drawing + +--- + +## API Endpoints + +All endpoints return JSON. CORS headers are set for local development. + +| Method | URL | Description | +|--------|-----|-------------| +| GET | `/api/points.php` | List all points as GeoJSON FeatureCollection | +| GET | `/api/points.php?id=N` | Get single point | +| GET | `/api/points.php?category=spbu` | Filter by category | +| POST | `/api/points.php` | Create a point | +| PUT | `/api/points.php?id=N` | Update a point | +| DELETE | `/api/points.php?id=N` | Delete a point | +| GET | `/api/roads.php` | List all roads | +| POST | `/api/roads.php` | Create a road | +| PUT | `/api/roads.php?id=N` | Update a road | +| DELETE | `/api/roads.php?id=N` | Delete a road | +| GET | `/api/parcels.php` | List all parcels | +| POST | `/api/parcels.php` | Create a parcel | +| PUT | `/api/parcels.php?id=N` | Update a parcel | +| DELETE | `/api/parcels.php?id=N` | Delete a parcel | + +--- + +## Libraries Used (CDN — no installation required) + +| Library | Version | Purpose | +|---------|---------|---------| +| Leaflet.js | 1.9.4 | Core mapping library | +| Leaflet.draw | 1.0.4 | Drawing tools (polyline, polygon) | +| leaflet-geometryutil | 0.10.3 | Geodesic area calculation | +| Google Fonts | — | Space Mono + DM Sans typography | + +--- + +## Notes +- This application is designed for academic/prototype use. For production, add: + - User authentication (sessions/JWT) + - Input rate limiting + - HTTPS enforcement + - Database backup strategy + +## Notes +- This application is designed for academic/prototype use. For production, add: + - User authentication (sessions/JWT) + - Input rate limiting + - HTTPS enforcement + - Database backup strategy diff --git a/api/bantuan.php b/api/bantuan.php new file mode 100644 index 0000000..48f0d22 --- /dev/null +++ b/api/bantuan.php @@ -0,0 +1,80 @@ +prepare( + "SELECT * + FROM bantuan + WHERE point_id=? + ORDER BY tanggal_bantuan DESC" + ); + + $stmt->execute([ + (int)$_GET['point_id'] + ]); + + jsonResponse( + $stmt->fetchAll() + ); + } + + break; + + case 'POST': + + $body = getRequestBody(); + + $stmt = $db->prepare( + "INSERT INTO bantuan + ( + point_id, + jenis_bantuan, + nominal, + tanggal_bantuan, + instansi_pemberi, + keterangan + ) + VALUES + (?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + + $body['point_id'], + + $body['jenis_bantuan'], + + $body['nominal'], + + $body['tanggal_bantuan'], + + $body['instansi_pemberi'], + + $body['keterangan'] + + ]); + + jsonResponse([ + 'message'=>'Bantuan berhasil ditambahkan' + ],201); + + break; + + default: + + jsonResponse([ + 'error'=>'Method not allowed' + ],405); +} \ No newline at end of file diff --git a/api/laporan.php b/api/laporan.php new file mode 100644 index 0000000..982ee26 --- /dev/null +++ b/api/laporan.php @@ -0,0 +1,135 @@ +prepare( + "SELECT * FROM laporan_masyarakat WHERE id=?" + ); + + $stmt->execute([(int)$_GET['id']]); + + $data = $stmt->fetch(); + + if (!$data) { + jsonResponse([ + 'error' => 'Laporan tidak ditemukan' + ], 404); + } + + jsonResponse($data); + } + + $stmt = $db->query( + "SELECT * FROM laporan_masyarakat + ORDER BY created_at DESC" + ); + + jsonResponse($stmt->fetchAll()); + + break; + + case 'POST': + + $body = getRequestBody(); + + $stmt = $db->prepare( + "INSERT INTO laporan_masyarakat + ( + nama_pelapor, + no_hp, + nama_warga, + alamat, + latitude, + longitude, + keterangan, + status + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + $body['nama_pelapor'] ?? '', + $body['no_hp'] ?? '', + $body['nama_warga'] ?? '', + $body['alamat'] ?? '', + $body['latitude'] ?? null, + $body['longitude'] ?? null, + $body['keterangan'] ?? '', + 'Menunggu Verifikasi' + ]); + + jsonResponse([ + 'message' => 'Laporan berhasil dikirim' + ], 201); + + break; + + case 'PUT': + + if (empty($_GET['id'])) { + jsonResponse([ + 'error' => 'id wajib' + ], 400); + } + + $body = getRequestBody(); + + $stmt = $db->prepare( + "UPDATE laporan_masyarakat + SET status=? + WHERE id=?" + ); + + $stmt->execute([ + $body['status'], + (int)$_GET['id'] + ]); + + jsonResponse([ + 'message' => 'Status laporan diperbarui' + ]); + + break; + + case 'DELETE': + + if (empty($_GET['id'])) { + jsonResponse([ + 'error' => 'id wajib' + ], 400); + } + + $stmt = $db->prepare( + "DELETE FROM laporan_masyarakat + WHERE id=?" + ); + + $stmt->execute([ + (int)$_GET['id'] + ]); + + jsonResponse([ + 'message' => 'Laporan dihapus' + ]); + + break; + + default: + + jsonResponse([ + 'error' => 'Method not allowed' + ], 405); +} \ No newline at end of file diff --git a/api/parcels.php b/api/parcels.php new file mode 100644 index 0000000..b4734b0 --- /dev/null +++ b/api/parcels.php @@ -0,0 +1,151 @@ +prepare('SELECT * FROM parcels WHERE id = ?'); + $stmt->execute([(int)$_GET['id']]); + $row = $stmt->fetch(); + if (!$row) jsonResponse(['error' => 'Parcel not found'], 404); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row); + } + + $where = ''; + $params = []; + if (!empty($_GET['type'])) { + $where = 'WHERE ownership_type = ?'; + $params = [$_GET['type']]; + } + + $stmt = $db->prepare("SELECT * FROM parcels $where ORDER BY created_at DESC"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + $features = []; + foreach ($rows as $row) { + $geo = json_decode($row['geojson'], true); + $features[] = [ + 'type' => 'Feature', + 'geometry' => $geo, + 'properties' => [ + 'id' => $row['id'], + 'owner_name' => $row['owner_name'], + 'ownership_type' => $row['ownership_type'], + 'area_m2' => (float)$row['area_m2'], + 'description' => $row['description'], + 'created_at' => $row['created_at'], + ], + ]; + } + jsonResponse(['type' => 'FeatureCollection', 'features' => $features]); + break; + + // ---------------------------------------------------------- + // POST /api/parcels.php + // Body: { owner_name, ownership_type, area_m2, geojson, description } + // ---------------------------------------------------------- + case 'POST': + $body = getRequestBody(); + + foreach (['owner_name', 'ownership_type', 'geojson'] as $f) { + if (empty($body[$f])) jsonResponse(['error' => "Field '$f' is required"], 422); + } + + $allowed = ['SHM', 'HGB', 'HGU', 'HP']; + if (!in_array($body['ownership_type'], $allowed, true)) { + jsonResponse(['error' => 'Invalid ownership_type'], 422); + } + + $geojson = is_array($body['geojson']) + ? json_encode($body['geojson']) + : $body['geojson']; + + $stmt = $db->prepare( + 'INSERT INTO parcels (owner_name, ownership_type, area_m2, geojson, description) + VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $body['owner_name'], + $body['ownership_type'], + (float)($body['area_m2'] ?? 0), + $geojson, + $body['description'] ?? null, + ]); + + $id = (int)$db->lastInsertId(); + $stmt2 = $db->prepare('SELECT * FROM parcels WHERE id = ?'); + $stmt2->execute([$id]); + $row = $stmt2->fetch(); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row, 201); + break; + + // ---------------------------------------------------------- + // PUT /api/parcels.php?id=N + // ---------------------------------------------------------- + case 'PUT': + if (empty($_GET['id'])) jsonResponse(['error' => 'id required'], 400); + $body = getRequestBody(); + $id = (int)$_GET['id']; + + $check = $db->prepare('SELECT id FROM parcels WHERE id = ?'); + $check->execute([$id]); + if (!$check->fetch()) jsonResponse(['error' => 'Parcel not found'], 404); + + $geojson = is_array($body['geojson'] ?? null) + ? json_encode($body['geojson']) + : ($body['geojson'] ?? '{}'); + + $stmt = $db->prepare( + 'UPDATE parcels SET owner_name=?, ownership_type=?, area_m2=?, geojson=?, description=? WHERE id=?' + ); + $stmt->execute([ + $body['owner_name'] ?? '', + $body['ownership_type'] ?? 'SHM', + (float)($body['area_m2'] ?? 0), + $geojson, + $body['description'] ?? null, + $id, + ]); + + $stmt2 = $db->prepare('SELECT * FROM parcels WHERE id = ?'); + $stmt2->execute([$id]); + $row = $stmt2->fetch(); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row); + break; + + // ---------------------------------------------------------- + // DELETE /api/parcels.php?id=N + // ---------------------------------------------------------- + case 'DELETE': + if (empty($_GET['id'])) jsonResponse(['error' => 'id required'], 400); + $id = (int)$_GET['id']; + $stmt = $db->prepare('DELETE FROM parcels WHERE id = ?'); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Parcel not found'], 404); + jsonResponse(['message' => 'Parcel deleted', 'id' => $id]); + break; + + default: + jsonResponse(['error' => 'Method not allowed'], 405); +} diff --git a/api/points.php b/api/points.php new file mode 100644 index 0000000..f04bbfc --- /dev/null +++ b/api/points.php @@ -0,0 +1,220 @@ +prepare('SELECT * FROM points WHERE id = ?'); + $stmt->execute([(int)$_GET['id']]); + $row = $stmt->fetch(); + if ($row) { + jsonResponse($row); + } else { + jsonResponse(['error' => 'Point not found'], 404); + } + } + + $where = ''; + $params = []; + if (!empty($_GET['category'])) { + $where = 'WHERE category = ?'; + $params = [$_GET['category']]; + } + + $stmt = $db->prepare("SELECT * FROM points $where ORDER BY created_at DESC"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + // Return as GeoJSON FeatureCollection + $features = []; + foreach ($rows as $row) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [(float)$row['longitude'], (float)$row['latitude']], + ], + 'properties' => [ + 'id' => $row['id'], + 'name' => $row['name'], + 'category' => $row['category'], + 'subtype' => $row['subtype'], + 'description' => $row['description'], + 'created_at' => $row['created_at'], + 'tanggal_lahir' => $row['tanggal_lahir'], + 'pendidikan' => $row['pendidikan'], + 'pekerjaan' => $row['pekerjaan'], + 'jumlah_tanggungan' => $row['jumlah_tanggungan'], + 'riwayat_penyakit' => $row['riwayat_penyakit'], + 'alamat' => $row['alamat'], + 'status_verifikasi' => $row['status_verifikasi'], + ], + ]; + } + jsonResponse([ + 'type' => 'FeatureCollection', + 'features' => $features, + ]); + break; + + // ---------------------------------------------------------- + // POST /api/points.php — create a point + // Body: { name, category, subtype, latitude, longitude, description } + // ---------------------------------------------------------- + case 'POST': + $body = getRequestBody(); + + $required = ['name', 'category', 'latitude', 'longitude']; + foreach ($required as $field) { + if (empty($body[$field]) && $body[$field] !== 0) { + jsonResponse(['error' => "Field '$field' is required"], 422); + } + } + + $allowed = ['spbu', 'mosque', 'poor']; + if (!in_array($body['category'], $allowed, true)) { + jsonResponse(['error' => 'Invalid category'], 422); + } + + $stmt = $db->prepare( + 'INSERT INTO points ( + name, + category, + subtype, + latitude, + longitude, + description, + tanggal_lahir, + pendidikan, + pekerjaan, + jumlah_tanggungan, + riwayat_penyakit, + alamat, + status_verifikasi + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $body['name'], + $body['category'], + $body['subtype'] ?? null, + (float)$body['latitude'], + (float)$body['longitude'], + $body['description'] ?? null, + + $body['tanggal_lahir'] ?? null, + $body['pendidikan'] ?? null, + $body['pekerjaan'] ?? null, + $body['jumlah_tanggungan'] ?? null, + $body['riwayat_penyakit'] ?? null, + $body['alamat'] ?? null, + + $body['status_verifikasi'] + ?? 'Menunggu Verifikasi' + ]); + + $id = (int)$db->lastInsertId(); + $stmt2 = $db->prepare('SELECT * FROM points WHERE id = ?'); + $stmt2->execute([$id]); + jsonResponse($stmt2->fetch(), 201); + break; + + // ---------------------------------------------------------- + // PUT /api/points.php?id=N — update a point + // Body: { name, category, subtype, latitude, longitude, description } + // ---------------------------------------------------------- + case 'PUT': + if (empty($_GET['id'])) { + jsonResponse(['error' => 'id is required'], 400); + } + $body = getRequestBody(); + $id = (int)$_GET['id']; + + // Check exists + $stmt = $db->prepare('SELECT id FROM points WHERE id = ?'); + $stmt->execute([$id]); + if (!$stmt->fetch()) { + jsonResponse(['error' => 'Point not found'], 404); + } + + $stmt = $db->prepare( + 'UPDATE points SET + name=?, + category=?, + subtype=?, + latitude=?, + longitude=?, + description=?, + tanggal_lahir=?, + pendidikan=?, + pekerjaan=?, + jumlah_tanggungan=?, + riwayat_penyakit=?, + alamat=?, + status_verifikasi=? + WHERE id=?' + ); + $stmt->execute([ + $body['name'] ?? '', + $body['category'] ?? 'spbu', + $body['subtype'] ?? null, + + (float)($body['latitude'] ?? 0), + (float)($body['longitude'] ?? 0), + + $body['description'] ?? null, + + $body['tanggal_lahir'] ?? null, + $body['pendidikan'] ?? null, + $body['pekerjaan'] ?? null, + $body['jumlah_tanggungan'] ?? null, + $body['riwayat_penyakit'] ?? null, + $body['alamat'] ?? null, + + $body['status_verifikasi'] + ?? 'Menunggu Verifikasi', + + $id + ]); + + $stmt2 = $db->prepare('SELECT * FROM points WHERE id = ?'); + $stmt2->execute([$id]); + jsonResponse($stmt2->fetch()); + break; + + // ---------------------------------------------------------- + // DELETE /api/points.php?id=N — delete a point + // ---------------------------------------------------------- + case 'DELETE': + if (empty($_GET['id'])) { + jsonResponse(['error' => 'id is required'], 400); + } + $id = (int)$_GET['id']; + $stmt = $db->prepare('DELETE FROM points WHERE id = ?'); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) { + jsonResponse(['error' => 'Point not found'], 404); + } + jsonResponse(['message' => 'Point deleted', 'id' => $id]); + break; + + default: + jsonResponse(['error' => 'Method not allowed'], 405); +} diff --git a/api/roads.php b/api/roads.php new file mode 100644 index 0000000..078f7ed --- /dev/null +++ b/api/roads.php @@ -0,0 +1,151 @@ +prepare('SELECT * FROM roads WHERE id = ?'); + $stmt->execute([(int)$_GET['id']]); + $row = $stmt->fetch(); + if (!$row) jsonResponse(['error' => 'Road not found'], 404); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row); + } + + $where = ''; + $params = []; + if (!empty($_GET['type'])) { + $where = 'WHERE road_type = ?'; + $params = [$_GET['type']]; + } + + $stmt = $db->prepare("SELECT * FROM roads $where ORDER BY created_at DESC"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + $features = []; + foreach ($rows as $row) { + $geo = json_decode($row['geojson'], true); + $features[] = [ + 'type' => 'Feature', + 'geometry' => $geo, + 'properties' => [ + 'id' => $row['id'], + 'name' => $row['name'], + 'road_type' => $row['road_type'], + 'length_m' => (float)$row['length_m'], + 'description' => $row['description'], + 'created_at' => $row['created_at'], + ], + ]; + } + jsonResponse(['type' => 'FeatureCollection', 'features' => $features]); + break; + + // ---------------------------------------------------------- + // POST /api/roads.php + // Body: { name, road_type, length_m, geojson, description } + // ---------------------------------------------------------- + case 'POST': + $body = getRequestBody(); + + foreach (['name', 'road_type', 'geojson'] as $f) { + if (empty($body[$f])) jsonResponse(['error' => "Field '$f' is required"], 422); + } + + $allowed = ['national', 'provincial', 'district']; + if (!in_array($body['road_type'], $allowed, true)) { + jsonResponse(['error' => 'Invalid road_type'], 422); + } + + $geojson = is_array($body['geojson']) + ? json_encode($body['geojson']) + : $body['geojson']; + + $stmt = $db->prepare( + 'INSERT INTO roads (name, road_type, length_m, geojson, description) + VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $body['name'], + $body['road_type'], + (float)($body['length_m'] ?? 0), + $geojson, + $body['description'] ?? null, + ]); + + $id = (int)$db->lastInsertId(); + $stmt2 = $db->prepare('SELECT * FROM roads WHERE id = ?'); + $stmt2->execute([$id]); + $row = $stmt2->fetch(); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row, 201); + break; + + // ---------------------------------------------------------- + // PUT /api/roads.php?id=N + // ---------------------------------------------------------- + case 'PUT': + if (empty($_GET['id'])) jsonResponse(['error' => 'id required'], 400); + $body = getRequestBody(); + $id = (int)$_GET['id']; + + $check = $db->prepare('SELECT id FROM roads WHERE id = ?'); + $check->execute([$id]); + if (!$check->fetch()) jsonResponse(['error' => 'Road not found'], 404); + + $geojson = is_array($body['geojson'] ?? null) + ? json_encode($body['geojson']) + : ($body['geojson'] ?? '{}'); + + $stmt = $db->prepare( + 'UPDATE roads SET name=?, road_type=?, length_m=?, geojson=?, description=? WHERE id=?' + ); + $stmt->execute([ + $body['name'] ?? '', + $body['road_type'] ?? 'national', + (float)($body['length_m'] ?? 0), + $geojson, + $body['description'] ?? null, + $id, + ]); + + $stmt2 = $db->prepare('SELECT * FROM roads WHERE id = ?'); + $stmt2->execute([$id]); + $row = $stmt2->fetch(); + $row['geojson'] = json_decode($row['geojson'], true); + jsonResponse($row); + break; + + // ---------------------------------------------------------- + // DELETE /api/roads.php?id=N + // ---------------------------------------------------------- + case 'DELETE': + if (empty($_GET['id'])) jsonResponse(['error' => 'id required'], 400); + $id = (int)$_GET['id']; + $stmt = $db->prepare('DELETE FROM roads WHERE id = ?'); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Road not found'], 404); + jsonResponse(['message' => 'Road deleted', 'id' => $id]); + break; + + default: + jsonResponse(['error' => 'Method not allowed'], 405); +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..6fbac07 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,493 @@ +/* ============================================================ + WebGIS — Main Stylesheet + Aesthetic: Industrial-cartographic, dark sidebar + bright map + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap'); + +/* ── CSS Variables ─────────────────────────────────────────── */ +:root { + --bg: #0f1117; + --sidebar: #161922; + --surface: #1e2130; + --surface2: #252a3a; + --border: #2d3348; + --accent: #4ade80; + --accent2: #38bdf8; + --warn: #fb923c; + --danger: #f87171; + --text: #e2e8f0; + --text-muted: #64748b; + --text-dim: #94a3b8; + + /* Road/feature colors */ + --national: #ef4444; + --provincial: #f97316; + --district: #3b82f6; + --spbu24: #22c55e; + --spbuNot: #eab308; + --mosque: #a78bfa; + --poor: #f43f5e; + + /* Ownership colors */ + --shm: #10b981; + --hgb: #6366f1; + --hgu: #f59e0b; + --hp: #ec4899; + + --sidebar-w: 340px; + --header-h: 52px; + --radius: 8px; + --font-mono: 'Space Mono', monospace; + --font-body: 'DM Sans', sans-serif; + --transition: 0.18s ease; +} + +/* ── Reset ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; +} + +/* ── Header ─────────────────────────────────────────────────── */ +#header { + height: var(--header-h); + background: var(--sidebar); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; + flex-shrink: 0; + z-index: 1000; +} +#header .logo { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.05em; + white-space: nowrap; +} +#header .logo span { color: var(--text-muted); font-weight: 400; } +#header .tagline { + font-size: 11px; + color: var(--text-muted); + border-left: 1px solid var(--border); + padding-left: 12px; +} +#header .spacer { flex: 1; } +#header .status-badge { + font-size: 11px; + font-family: var(--font-mono); + background: #0f2d1e; + color: var(--accent); + border: 1px solid #16532d; + border-radius: 4px; + padding: 2px 8px; +} + +/* ── Layout ─────────────────────────────────────────────────── */ +#app { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Sidebar ─────────────────────────────────────────────────── */ +#sidebar { + width: var(--sidebar-w); + background: var(--sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + flex-shrink: 0; +} + +#sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.tab-btn { + flex: 1; + padding: 10px 6px; + background: transparent; + border: none; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + cursor: pointer; + letter-spacing: 0.04em; + transition: var(--transition); + border-bottom: 2px solid transparent; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.tab-btn svg { width: 16px; height: 16px; } +.tab-btn:hover { color: var(--text); background: var(--surface); } +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(74,222,128,0.05); +} + +#sidebar-content { + flex: 1; + overflow-y: auto; + padding: 12px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +#sidebar-content::-webkit-scrollbar { width: 4px; } +#sidebar-content::-webkit-scrollbar-track { background: transparent; } +#sidebar-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* ── Panel (tab content) ─────────────────────────────────────── */ +.panel { display: none; } +.panel.active { display: block; } + +/* ── Section Headings ────────────────────────────────────────── */ +.section-title { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + color: var(--text-muted); + text-transform: uppercase; + margin: 12px 0 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +/* ── Forms ───────────────────────────────────────────────────── */ +.form-group { margin-bottom: 10px; } +.form-group label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--text-dim); + margin-bottom: 4px; + letter-spacing: 0.03em; +} +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font-body); + font-size: 13px; + padding: 7px 10px; + outline: none; + transition: border-color var(--transition); +} +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + border-color: var(--accent2); +} +.form-group input[readonly] { + color: var(--text-muted); + cursor: not-allowed; + background: var(--surface); +} +.form-group textarea { resize: vertical; min-height: 60px; } +.form-group select option { background: var(--surface2); } + +.coord-row { display: flex; gap: 8px; } +.coord-row .form-group { flex: 1; } + +/* ── Buttons ─────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius); + border: 1px solid transparent; + font-family: var(--font-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + white-space: nowrap; +} +.btn-primary { + background: var(--accent); + color: #0a1a0f; + border-color: var(--accent); +} +.btn-primary:hover { background: #22c55e; } +.btn-secondary { + background: var(--surface2); + color: var(--text); + border-color: var(--border); +} +.btn-secondary:hover { background: var(--surface); border-color: var(--text-muted); } +.btn-danger { + background: transparent; + color: var(--danger); + border-color: #7f1d1d; +} +.btn-danger:hover { background: rgba(248,113,113,0.1); } +.btn-warn { + background: transparent; + color: var(--warn); + border-color: #7c2d12; +} +.btn-warn:hover { background: rgba(251,146,60,0.1); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-full { width: 100%; justify-content: center; } +.btn-row { display: flex; gap: 8px; margin-top: 10px; } + +/* ── Info box ────────────────────────────────────────────────── */ +.info-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px; + font-size: 12px; + color: var(--text-dim); + margin-bottom: 10px; +} +.info-box.accent { border-color: var(--accent2); color: var(--accent2); background: rgba(56,189,248,0.05); } +.info-box.success { border-color: var(--accent); color: var(--accent); background: rgba(74,222,128,0.05); } +.info-box.warn { border-color: var(--warn); color: var(--warn); background: rgba(251,146,60,0.05); } + +/* ── Feature list ────────────────────────────────────────────── */ +.feature-list { display: flex; flex-direction: column; gap: 6px; } +.feature-item { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 10px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: var(--transition); + position: relative; +} +.feature-item:hover { border-color: var(--accent2); background: var(--surface2); } +.feature-item.selected { border-color: var(--accent); background: rgba(74,222,128,0.05); } +.feature-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.feature-dot.road { border-radius: 2px; height: 4px; width: 20px; } +.feature-dot.poly { border-radius: 2px; width: 14px; height: 14px; opacity: 0.7; } +.feature-info { flex: 1; min-width: 0; } +.feature-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.feature-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} +.feature-actions { display: flex; gap: 4px; opacity: 0; transition: var(--transition); } +.feature-item:hover .feature-actions { opacity: 1; } + +/* ── Layer control ───────────────────────────────────────────── */ +.layer-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + margin-bottom: 4px; +} +.layer-item:hover { background: var(--surface2); } +.layer-cb { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.layer-swatch { + width: 24px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} +.layer-swatch.circle { width: 14px; height: 14px; border-radius: 50%; } +.layer-label { font-size: 13px; color: var(--text-dim); flex: 1; } +.layer-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + background: var(--surface2); + padding: 1px 6px; + border-radius: 10px; +} + +/* ── Map area ────────────────────────────────────────────────── */ +#map-container { + flex: 1; + position: relative; + overflow: hidden; +} +#map { + width: 100%; + height: 100%; +} + +/* ── Map toolbar ─────────────────────────────────────────────── */ +#map-toolbar { + position: absolute; + top: 12px; + left: 12px; + z-index: 500; + display: flex; + flex-direction: column; + gap: 6px; +} +.map-tool-btn { + width: 36px; + height: 36px; + background: var(--sidebar); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-dim); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition); + font-size: 16px; +} +.map-tool-btn:hover { border-color: var(--accent2); color: var(--accent2); } +.map-tool-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(74,222,128,0.1); } + +/* ── Toast ───────────────────────────────────────────────────── */ +#toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; +} +.toast { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 14px; + font-size: 13px; + color: var(--text); + min-width: 220px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + animation: slideIn 0.2s ease; +} +.toast.success { border-color: var(--accent); } +.toast.error { border-color: var(--danger); } +.toast.warn { border-color: var(--warn); } +@keyframes slideIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ── Modal ───────────────────────────────────────────────────── */ +#modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + z-index: 8000; + align-items: center; + justify-content: center; +} +#modal-overlay.open { display: flex; } +#modal { + background: var(--sidebar); + border: 1px solid var(--border); + border-radius: 12px; + width: min(480px, 95vw); + max-height: 90vh; + overflow-y: auto; + padding: 24px; +} +#modal h3 { + font-family: var(--font-mono); + font-size: 14px; + color: var(--accent); + margin-bottom: 16px; +} + +/* ── Leaflet overrides ───────────────────────────────────────── */ +.leaflet-popup-content-wrapper { + background: var(--sidebar) !important; + color: var(--text) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius) !important; + box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important; +} +.leaflet-popup-tip { background: var(--sidebar) !important; } +.leaflet-popup-content { font-family: var(--font-body); font-size: 13px; } +.popup-title { font-weight: 600; margin-bottom: 6px; color: var(--accent2); } +.popup-row { display: flex; gap: 6px; margin-bottom: 3px; } +.popup-key { color: var(--text-muted); font-size: 11px; width: 90px; flex-shrink: 0; } +.popup-val { color: var(--text); font-size: 12px; } +.popup-actions { margin-top: 8px; display: flex; gap: 6px; } + +/* ── Draw toolbar overrides ──────────────────────────────────── */ +.leaflet-draw-toolbar a { + background-color: var(--sidebar) !important; + color: var(--text) !important; + border-color: var(--border) !important; +} +.leaflet-draw-toolbar a:hover { background-color: var(--surface) !important; } + +/* ── Coords display ──────────────────────────────────────────── */ +#coords-display { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 500; + background: rgba(15,17,23,0.85); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 10px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + pointer-events: none; +} + +/* ── Stats bar ───────────────────────────────────────────────── */ +#stats-bar { + background: var(--surface); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + padding: 4px 16px; + gap: 20px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; +} +.stat-item { display: flex; gap: 6px; } +.stat-label { color: var(--text-muted); } +.stat-val { color: var(--text-dim); } + +/* ── Scrollbar (global) ──────────────────────────────────────── */ +* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +*::-webkit-scrollbar { width: 4px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..d9577d4 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,1557 @@ +/* ============================================================ + WebGIS — Main Application JavaScript + Features: Points, Roads (Lines), Parcels (Polygons) + Leaflet.js + Leaflet.draw + Full CRUD via PHP API + ============================================================ */ + +/* ── API base path ─────────────────────────────────────────── */ +const API = { + points: 'api/points.php', + roads: 'api/roads.php', + parcels: 'api/parcels.php', + laporan: 'api/laporan.php', + bantuan:'api/bantuan.php' +}; + +/* ── Color/style maps ──────────────────────────────────────── */ +const ROAD_STYLES = { + national: { color: '#ef4444', weight: 4, dashArray: null }, + provincial: { color: '#f97316', weight: 3, dashArray: '6,4' }, + district: { color: '#3b82f6', weight: 2, dashArray: '4,3' }, +}; + +const PARCEL_STYLES = { + SHM: { color: '#10b981', fillColor: '#10b981', fillOpacity: 0.25, weight: 2 }, + HGB: { color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.25, weight: 2 }, + HGU: { color: '#f59e0b', fillColor: '#f59e0b', fillOpacity: 0.25, weight: 2 }, + HP: { color: '#ec4899', fillColor: '#ec4899', fillOpacity: 0.25, weight: 2 }, +}; + +const POINT_COLORS = { + 'spbu-24hours': '#22c55e', + 'spbu-not24hours':'#eab308', + mosque: '#a78bfa', + poor: '#f43f5e', +}; + + /* ===== RADIUS ANALYSIS ===== */ + let poorMarkers = []; + let activeCircle = null; + let selectedCenter = null; + let radius = 500; + +/* ── State ─────────────────────────────────────────────────── */ +const state = { + currentTab: 'layers', + drawMode: null, // 'point', 'road', 'parcel' + editingId: null, + pendingLatLng: null, + counts: { points: 0, roads: 0, parcels: 0 }, + +}; + +/* ── Layer Groups ──────────────────────────────────────────── */ +const layers = { + spbu24: L.layerGroup(), + spbuNot: L.layerGroup(), + mosque: L.layerGroup(), + poor: L.layerGroup(), + national: L.layerGroup(), + provincial: L.layerGroup(), + district: L.layerGroup(), + shm: L.layerGroup(), + hgb: L.layerGroup(), + hgu: L.layerGroup(), + hp: L.layerGroup(), +}; + +/* Store references: id → Leaflet layer */ +const layerRefs = { + points: {}, + roads: {}, + parcels: {}, +}; + +/* ── Map Init ──────────────────────────────────────────────── */ +const map = L.map('map', { + center: [-0.0457391, 109.3394621], + zoom: 13, + zoomControl: false, +}); + +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, +}).addTo(map); + +// Add all layer groups to map +Object.values(layers).forEach(lg => lg.addTo(map)); + +// Zoom control bottom-right +L.control.zoom({ position: 'bottomright' }).addTo(map); + +/* ── Leaflet Draw Setup ─────────────────────────────────────── */ +const drawnItems = new L.FeatureGroup(); +map.addLayer(drawnItems); + +// We create draw controls dynamically to use only what's needed +let activeDrawHandler = null; + +function startDraw(type) { + // Cancel any active draw + if (activeDrawHandler) { + activeDrawHandler.disable(); + activeDrawHandler = null; + } + + if (type === 'road') { + activeDrawHandler = new L.Draw.Polyline(map, { + shapeOptions: { color: '#4ade80', weight: 3 }, + }); + } else if (type === 'parcel') { + activeDrawHandler = new L.Draw.Polygon(map, { + shapeOptions: { color: '#38bdf8', weight: 2, fillOpacity: 0.2 }, + }); + } + + if (activeDrawHandler) activeDrawHandler.enable(); +} + +function stopDraw() { + if (activeDrawHandler) { + activeDrawHandler.disable(); + activeDrawHandler = null; + } + state.drawMode = null; + updateDrawModeUI(); +} + +/* ── Draw event: when user finishes drawing ─────────────────── */ +map.on(L.Draw.Event.CREATED, function (e) { + const layer = e.layer; + const latlngs = layer.getLatLngs(); + + if (state.drawMode === 'road') { + // Calculate length automatically + const flat = latlngs.flat ? latlngs.flat(Infinity) : latlngs; + const length = calculatePolylineLength(flat); + openRoadModal(null, flat, length); + } else if (state.drawMode === 'parcel') { + // Calculate area automatically (latlngs is array of arrays for polygon) + const ring = latlngs[0] || latlngs; + const area = L.GeometryUtil.geodesicArea(ring); + openParcelModal(null, ring, area); + } + + stopDraw(); +}); + +/* ── Haversine / Leaflet length calculation ─────────────────── */ +function calculatePolylineLength(latlngs) { + let total = 0; + for (let i = 0; i < latlngs.length - 1; i++) { + total += latlngs[i].distanceTo(latlngs[i + 1]); + } + return Math.round(total * 100) / 100; // meters, 2 decimals +} + +/* ── Custom point marker ────────────────────────────────────── */ +function makeIcon(category, subtype) { + const key = category === 'spbu' ? `spbu-${subtype}` : category; + const color = POINT_COLORS[key] || '#aaa'; + + const icons = { + spbu: '⛽', + mosque: '🕌', + poor: '🏘️', + }; + const emoji = icons[category] || '📍'; + + return L.divIcon({ + html: `
${emoji}
`, + className: '', + iconSize: [30, 30], + iconAnchor: [15, 30], + popupAnchor:[0, -32], + }); +} + +/* ============================================================ + POINTS — Add / Load / Edit / Delete + ============================================================ */ + +/* Click on map to add point */ +let pointClickHandler = null; +let laporanClickHandler = null; + +function enablePointAdd() { + state.drawMode = 'point'; + updateDrawModeUI(); + toast('Click on the map to place a point', 'info'); + + pointClickHandler = function(e) { + state.pendingLatLng = e.latlng; + openPointModal(null, e.latlng); + disablePointAdd(); + }; + map.once('click', pointClickHandler); +} + +function openLaporanModal(latlng) { + showModal( + 'laporan', + null, + latlng + ); +} + +function enableLaporanAdd() { + + toast( + 'Klik lokasi warga yang ingin dilaporkan', + 'info' + ); + + laporanClickHandler = function(e) { + + openLaporanModal(e.latlng); + + map.off('click', laporanClickHandler); + }; + + map.once('click', laporanClickHandler); +} + +function disablePointAdd() { + if (pointClickHandler) { + map.off('click', pointClickHandler); + pointClickHandler = null; + } + state.drawMode = null; + updateDrawModeUI(); +} + +/* Load all points from API */ +async function loadPoints() { + const res = await fetch(API.points); + const fc = await res.json(); + clearLayerGroup('points'); + + fc.features.forEach(f => addPointToMap(f.properties, f.geometry.coordinates)); + updateCounts(); + refreshPointList(); +} + +/* Add a point feature to the correct layer group */ +function addPointToMap(props, coords) { + // coords = [lng, lat] + const latlng = L.latLng(coords[1], coords[0]); + const marker = L.marker(latlng, { icon: makeIcon(props.category, props.subtype) }); + if (props.category === 'poor') { + poorMarkers.push(marker); + } + + if (props.category === 'mosque') { + marker.on('click', function(e) { + selectedCenter = e.latlng; + + // hapus circle lama + if (activeCircle) { + map.removeLayer(activeCircle); + } + + // buat circle baru + activeCircle = L.circle(selectedCenter, { + radius: radius, + color: 'blue', + fillOpacity: 0.2 + }).addTo(map); + + updateMarkerColors(); + }); + } + + marker.on('click', async function () { + + const html = + await buildPointPopup(props); + + marker.bindPopup(html) + .openPopup(); + + }); + // Put in correct layer group + const lg = getPointLayer(props.category, props.subtype); + if (lg) lg.addLayer(marker); + + layerRefs.points[props.id] = marker; + return marker; +} + +function getPointLayer(category, subtype) { + if (category === 'spbu') return subtype === '24hours' ? layers.spbu24 : layers.spbuNot; + if (category === 'mosque') return layers.mosque; + if (category === 'poor') return layers.poor; + return null; +} +async function buildPointPopup(p) { + + const bantuan = + p.category === 'poor' + ? await getRiwayatBantuan(p.id) + : []; + + const subtypeStr = p.category === 'spbu' + ? `` + : ''; + + const bantuanBtn = + p.category === 'poor' + ? ` + + ` + : ''; + + const bantuanHistory = + bantuan.length + ? ` +
+ +
+ Riwayat Bantuan +
+ + ${bantuan.map(b => ` +
+ • ${b.jenis_bantuan} + (Rp ${Number(b.nominal).toLocaleString('id-ID')}) +
+ `).join('')} + ` + : ''; + + return ` + + + + + ${subtypeStr} + + ${p.description + ? ` + + ` + : '' + } + + ${bantuanHistory} + + + `; +} + +/* Open modal for adding/editing a point */ +async function openPointModal(id, latlng) { + let point = null; + if (id) { + const res = await fetch(`${API.points}?id=${id}`); + point = await res.json(); + latlng = L.latLng(point.latitude, point.longitude); + } + + showModal('point', point, latlng); +} + +function openBantuanModal(pointId) { + + showModal( + 'bantuan', + null, + pointId + ); + +} + +function showModal(type, data, extra) { + const overlay = document.getElementById('modal-overlay'); + const modal = document.getElementById('modal'); + + if (type === 'point') { + const latlng = extra; + modal.innerHTML = ` +

${data ? '✏️ Edit Point' : '📍 Add Point'}

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
`; + } + + else if (type === 'laporan') { + + const latlng = extra; + + modal.innerHTML = ` + +

📢 Laporan Masyarakat

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ `; +} +else if(type === 'bantuan') { + + const pointId = extra; + + modal.innerHTML = ` + +

🎁 Tambah Bantuan

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + `; +} + else if (type === 'road') { + const { latlngs, length } = extra; + const d = data; + modal.innerHTML = ` +

${d ? '✏️ Edit Road' : '🛣️ Add Road'}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + // Store pending latlngs in state + state._pendingRoadLatLngs = latlngs; + } else if (type === 'parcel') { + const { ring, area } = extra; + const d = data; + modal.innerHTML = ` +

${d ? '✏️ Edit Parcel' : '🗺️ Add Land Parcel'}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + state._pendingParcelRing = ring; + } + + overlay.classList.add('open'); +} + +function closeModal() { + document.getElementById('modal-overlay').classList.remove('open'); + state._pendingRoadLatLngs = null; + state._pendingParcelRing = null; +} + +function toggleSubtype() { + const cat = document.getElementById('f-cat').value; + + document.getElementById('subtype-group').style.display = + cat === 'spbu' ? '' : 'none'; + + const poorFields = document.getElementById('poor-fields'); + + if (poorFields) { + poorFields.style.display = + cat === 'poor' ? '' : 'none'; + } +} + +/* Save point (create or update) */ +async function savePoint(id, lat, lng) { + const name = document.getElementById('f-name').value.trim(); + const category = document.getElementById('f-cat').value; + const subtype = category === 'spbu' ? document.getElementById('f-subtype').value : null; + const desc = document.getElementById('f-desc').value.trim(); + + if (!name) { toast('Name is required', 'error'); return; } + + const body = { + name, + category, + subtype, + latitude: lat, + longitude: lng, + description: desc, + + tanggal_lahir: + document.getElementById('f-tanggal-lahir')?.value || null, + + pendidikan: + document.getElementById('f-pendidikan')?.value || null, + + pekerjaan: + document.getElementById('f-pekerjaan')?.value || null, + + jumlah_tanggungan: + document.getElementById('f-tanggungan')?.value || null, + + riwayat_penyakit: + document.getElementById('f-penyakit')?.value || null, + + alamat: + document.getElementById('f-alamat')?.value || null, + + status_verifikasi: + 'Menunggu Verifikasi' + }; + try { + let res; + if (id) { + res = await fetch(`${API.points}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) }); + } else { + res = await fetch(API.points, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) }); + } + const data = await res.json(); + if (!res.ok) { toast(data.error || 'Error saving point', 'error'); return; } + + closeModal(); + toast(id ? 'Point updated ✓' : 'Point added ✓', 'success'); + await loadPoints(); + switchTab('points'); + } catch (e) { + toast('Network error', 'error'); + } +} + +async function deletePoint(id) { + if (!confirm('Delete this point?')) return; + const res = await fetch(`${API.points}?id=${id}`, { method: 'DELETE' }); + if (res.ok) { toast('Point deleted', 'success'); await loadPoints(); } + else toast('Delete failed', 'error'); +} + +/* ============================================================ + ROADS — Add / Load / Edit / Delete + ============================================================ */ + +function startAddRoad() { + state.drawMode = 'road'; + updateDrawModeUI(); + toast('Draw a polyline on the map. Double-click to finish.', 'info'); + startDraw('road'); +} + +function openRoadModal(id, latlngs, length) { + if (id) { + fetch(`${API.roads}?id=${id}`).then(r => r.json()).then(d => { + showModal('road', d, { latlngs: null, length: d.length_m }); + }); + } else { + showModal('road', null, { latlngs, length }); + } +} + +async function saveRoad(id) { + const name = document.getElementById('f-name').value.trim(); + const road_type = document.getElementById('f-type').value; + const length_m = parseFloat(document.getElementById('f-length').value) || 0; + const desc = document.getElementById('f-desc').value.trim(); + + if (!name) { toast('Road name is required', 'error'); return; } + + let geojson; + if (id) { + // editing: re-use existing geometry from API + const res = await fetch(`${API.roads}?id=${id}`); + const d = await res.json(); + geojson = d.geojson; + } else { + const latlngs = state._pendingRoadLatLngs; + if (!latlngs || latlngs.length < 2) { toast('No road geometry', 'error'); return; } + geojson = { + type: 'LineString', + coordinates: latlngs.map(ll => [ll.lng, ll.lat]), + }; + } + + const body = { name, road_type, length_m, geojson, description: desc }; + + try { + let res; + if (id) { + res = await fetch(`${API.roads}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) }); + } else { + res = await fetch(API.roads, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) }); + } + const data = await res.json(); + if (!res.ok) { toast(data.error || 'Error saving road', 'error'); return; } + + closeModal(); + toast(id ? 'Road updated ✓' : 'Road added ✓', 'success'); + await loadRoads(); + switchTab('roads'); + } catch (e) { + toast('Network error', 'error'); + } +} + +async function loadRoads() { + const res = await fetch(API.roads); + const fc = await res.json(); + clearLayerGroup('roads'); + + fc.features.forEach(f => addRoadToMap(f.properties, f.geometry)); + updateCounts(); + refreshRoadList(); +} + +function addRoadToMap(props, geometry) { + const coords = geometry.coordinates.map(c => [c[1], c[0]]); + const style = ROAD_STYLES[props.road_type] || ROAD_STYLES.national; + const polyline = L.polyline(coords, style); + polyline.bindPopup(buildRoadPopup(props)); + + const lg = layers[props.road_type]; + if (lg) lg.addLayer(polyline); + + layerRefs.roads[props.id] = polyline; + return polyline; +} + +function buildRoadPopup(p) { + return ` + + + + ${p.description ? `` : ''} + `; +} + +async function deleteRoad(id) { + if (!confirm('Delete this road?')) return; + const res = await fetch(`${API.roads}?id=${id}`, { method: 'DELETE' }); + if (res.ok) { toast('Road deleted', 'success'); await loadRoads(); } + else toast('Delete failed', 'error'); +} + +/* ============================================================ + PARCELS — Add / Load / Edit / Delete + ============================================================ */ + +function startAddParcel() { + state.drawMode = 'parcel'; + updateDrawModeUI(); + toast('Draw a polygon on the map. Double-click to close.', 'info'); + startDraw('parcel'); +} + +function openParcelModal(id, ring, area) { + if (id) { + fetch(`${API.parcels}?id=${id}`).then(r => r.json()).then(d => { + showModal('parcel', d, { ring: null, area: d.area_m2 }); + }); + } else { + showModal('parcel', null, { ring, area }); + } +} + +async function saveParcel(id) { + const owner_name = document.getElementById('f-owner').value.trim(); + const ownership_type = document.getElementById('f-own-type').value; + const area_m2 = parseFloat(document.getElementById('f-area').value) || 0; + const desc = document.getElementById('f-desc').value.trim(); + + if (!owner_name) { toast('Owner name is required', 'error'); return; } + + let geojson; + if (id) { + const res = await fetch(`${API.parcels}?id=${id}`); + const d = await res.json(); + geojson = d.geojson; + } else { + const ring = state._pendingParcelRing; + if (!ring || ring.length < 3) { toast('No polygon geometry', 'error'); return; } + const coords = ring.map(ll => [ll.lng, ll.lat]); + coords.push(coords[0]); // close ring + geojson = { type: 'Polygon', coordinates: [coords] }; + } + + const body = { owner_name, ownership_type, area_m2, geojson, description: desc }; + + try { + let res; + if (id) { + res = await fetch(`${API.parcels}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) }); + } else { + res = await fetch(API.parcels, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) }); + } + const data = await res.json(); + if (!res.ok) { toast(data.error || 'Error saving parcel', 'error'); return; } + + closeModal(); + toast(id ? 'Parcel updated ✓' : 'Parcel added ✓', 'success'); + await loadParcels(); + switchTab('parcels'); + } catch (e) { + toast('Network error', 'error'); + } +} + +async function loadParcels() { + const res = await fetch(API.parcels); + const fc = await res.json(); + clearLayerGroup('parcels'); + + fc.features.forEach(f => addParcelToMap(f.properties, f.geometry)); + updateCounts(); + refreshParcelList(); +} + +function addParcelToMap(props, geometry) { + const coords = geometry.coordinates[0].map(c => [c[1], c[0]]); + const style = PARCEL_STYLES[props.ownership_type] || PARCEL_STYLES.SHM; + const polygon = L.polygon(coords, style); + polygon.bindPopup(buildParcelPopup(props)); + + const lgKey = props.ownership_type.toLowerCase(); + const lg = layers[lgKey]; + if (lg) lg.addLayer(polygon); + + layerRefs.parcels[props.id] = polygon; + return polygon; +} + +function buildParcelPopup(p) { + return ` + + + + ${p.description ? `` : ''} + `; +} + +async function deleteParcel(id) { + if (!confirm('Delete this parcel?')) return; + const res = await fetch(`${API.parcels}?id=${id}`, { method: 'DELETE' }); + if (res.ok) { toast('Parcel deleted', 'success'); await loadParcels(); } + else toast('Delete failed', 'error'); +} + +/* ============================================================ + Layer Control — checkboxes in sidebar + ============================================================ */ + +function toggleLayer(layerKey, show) { + const lg = layers[layerKey]; + if (!lg) return; + if (show) { map.addLayer(lg); } + else { map.removeLayer(lg); } +} + +function initLayerCheckboxes() { + document.querySelectorAll('[data-layer]').forEach(cb => { + cb.addEventListener('change', function() { + toggleLayer(this.dataset.layer, this.checked); + }); + }); +} + +/* ============================================================ + Sidebar tabs + ============================================================ */ + +function switchTab(tab) { + state.currentTab = tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === `panel-${tab}`)); +} + +/* ============================================================ + List renderers (sidebar panels) + ============================================================ */ + +async function refreshPointList() { + const res = await fetch(API.points); + const fc = await res.json(); + const el = document.getElementById('point-list'); + if (!el) return; + + if (!fc.features.length) { + el.innerHTML = '
No points yet. Click the map to add.
'; + return; + } + + el.innerHTML = fc.features.map(f => { + const p = f.properties; + const key = p.category === 'spbu' ? `spbu-${p.subtype}` : p.category; + const col = POINT_COLORS[key] || '#aaa'; + return `
+
+
+
${escHtml(p.name)}
+
${catLabel(p.category)}${p.subtype ? ' · ' + p.subtype : ''}
+
+
+ + +
+
`; + }).join(''); +} + +async function refreshRoadList() { + const res = await fetch(API.roads); + const fc = await res.json(); + const el = document.getElementById('road-list'); + if (!el) return; + + if (!fc.features.length) { + el.innerHTML = '
No roads yet. Use the draw tool to add.
'; + return; + } + + el.innerHTML = fc.features.map(f => { + const p = f.properties; + const col = ROAD_STYLES[p.road_type]?.color || '#aaa'; + return `
+
+
+
${escHtml(p.name)}
+
${roadTypeLabel(p.road_type)} · ${fmtLength(p.length_m)}
+
+
+ + +
+
`; + }).join(''); +} + +async function saveLaporan(lat,lng) { + + const body = { + + nama_pelapor: + document.getElementById('lp-nama').value, + + no_hp: + document.getElementById('lp-hp').value, + + nama_warga: + document.getElementById('lp-warga').value, + + alamat: + document.getElementById('lp-alamat').value, + + keterangan: + document.getElementById('lp-ket').value, + + latitude: lat, + longitude: lng + + }; + + const res = await fetch( + API.laporan, + { + method:'POST', + headers:jsonHeader(), + body:JSON.stringify(body) + } + ); + + if(res.ok){ + + toast( + 'Laporan berhasil dikirim', + 'success' + ); + + closeModal(); + + refreshLaporanList(); + + } else { + + toast( + 'Gagal mengirim laporan', + 'error' + ); + + } +} + +async function saveBantuan(pointId){ + + const body = { + + point_id: pointId, + + jenis_bantuan: + document.getElementById('b-jenis').value, + + nominal: + document.getElementById('b-nominal').value, + + tanggal_bantuan: + document.getElementById('b-tanggal').value, + + instansi_pemberi: + document.getElementById('b-instansi').value, + + keterangan: + document.getElementById('b-ket').value + + }; + + const res = await fetch( + API.bantuan, + { + method:'POST', + headers:jsonHeader(), + body:JSON.stringify(body) + } + ); + + if(res.ok){ + + toast( + 'Bantuan berhasil ditambahkan', + 'success' + ); + + closeModal(); + + } else { + + toast( + 'Gagal menambahkan bantuan', + 'error' + ); + + } +} + +async function getRiwayatBantuan(pointId) { + + const res = await fetch( + `${API.bantuan}?point_id=${pointId}` + ); + + return await res.json(); +} + +async function refreshParcelList() { + const res = await fetch(API.parcels); + const fc = await res.json(); + const el = document.getElementById('parcel-list'); + if (!el) return; + + if (!fc.features.length) { + el.innerHTML = '
No parcels yet. Use the polygon tool to add.
'; + return; + } + + el.innerHTML = fc.features.map(f => { + const p = f.properties; + const col = PARCEL_STYLES[p.ownership_type]?.color || '#aaa'; + return `
+
+
+
${escHtml(p.owner_name)}
+
${p.ownership_type} · ${fmtArea(p.area_m2)}
+
+
+ + +
+
`; + }).join(''); +} + +/* Zoom-to helpers */ +function zoomToPoint(id) { + const m = layerRefs.points[id]; + if (m) { map.setView(m.getLatLng(), 17); m.openPopup(); } +} +function zoomToRoad(id) { + const l = layerRefs.roads[id]; + if (l) { map.fitBounds(l.getBounds()); l.openPopup(l.getBounds().getCenter()); } +} +function zoomToParcel(id) { + const l = layerRefs.parcels[id]; + if (l) { map.fitBounds(l.getBounds()); l.openPopup(l.getBounds().getCenter()); } +} + +/* ============================================================ + Layer-group management + ============================================================ */ + +function clearLayerGroup(type) { + if (type === 'points') { + ['spbu24','spbuNot','mosque','poor'].forEach(k => layers[k].clearLayers()); + layerRefs.points = {}; + } else if (type === 'roads') { + ['national','provincial','district'].forEach(k => layers[k].clearLayers()); + layerRefs.roads = {}; + } else if (type === 'parcels') { + ['shm','hgb','hgu','hp'].forEach(k => layers[k].clearLayers()); + layerRefs.parcels = {}; + } + if (type === 'points') { + poorMarkers = []; // reset biar ga numpuk + } +} + +/* ============================================================ + Stats, counts, UI helpers + ============================================================ */ + +function updateCounts() { + const pc = Object.keys(layerRefs.points).length; + const rc = Object.keys(layerRefs.roads).length; + const lc = Object.keys(layerRefs.parcels).length; + + document.getElementById('stat-points').textContent = pc; + document.getElementById('stat-roads').textContent = rc; + document.getElementById('stat-parcels').textContent= lc; + + // Update layer count badges + document.querySelectorAll('[data-count-layer]').forEach(el => { + const key = el.dataset.countLayer; + const lg = layers[key]; + if (lg) el.textContent = lg.getLayers().length; + }); +} + +function updateDrawModeUI() { + document.querySelectorAll('.map-tool-btn').forEach(b => { + b.classList.toggle('active', b.dataset.mode === state.drawMode); + }); +} + +/* ── Coordinates tracker ───────────────────────────────────── */ +map.on('mousemove', function(e) { + const el = document.getElementById('coords-display'); + if (el) el.textContent = `${e.latlng.lat.toFixed(6)}, ${e.latlng.lng.toFixed(6)}`; +}); + +/* ── Toast notifications ───────────────────────────────────── */ +function toast(msg, type = 'info') { + const container = document.getElementById('toast-container'); + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.textContent = msg; + container.appendChild(el); + setTimeout(() => el.remove(), 3500); +} + +/* ── Format helpers ─────────────────────────────────────────── */ +function fmtLength(m) { + m = parseFloat(m) || 0; + return m >= 1000 ? `${(m/1000).toFixed(2)} km` : `${m.toFixed(1)} m`; +} + +function fmtArea(m2) { + m2 = parseFloat(m2) || 0; + return m2 >= 10000 ? `${(m2/10000).toFixed(4)} ha` : `${m2.toFixed(2)} m²`; +} + +function catLabel(cat) { + return { spbu: 'SPBU', mosque: 'Mosque', poor: 'Poor Population' }[cat] || cat; +} + +function roadTypeLabel(t) { + return { national: 'National', provincial: 'Provincial', district: 'District' }[t] || t; +} + +function escHtml(str) { + return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} +function escAttr(str) { return escHtml(str); } + +function jsonHeader() { + return { 'Content-Type': 'application/json' }; +} + +/* ============================================================ + Keyboard shortcut: Escape to cancel draw + ============================================================ */ +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + stopDraw(); + disablePointAdd(); + closeModal(); + } +}); + + +function updateMarkerColors() { + if (!selectedCenter) return; + + let inside = 0; + let outside = 0; + + poorMarkers.forEach(marker => { + let distance = map.distance(selectedCenter, marker.getLatLng()); + + if (distance <= radius) { + marker.getElement().style.filter = "hue-rotate(0deg)"; + inside++; + } else { + marker.getElement().style.filter = "hue-rotate(120deg)"; + outside++; + } + }); + + console.log("Inside:", inside, "Outside:", outside); +} + +/* ============================================================ + Bootstrap — load all data on page ready + ============================================================ */ +document.addEventListener('DOMContentLoaded', async function() { + // Tab switching + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => switchTab(btn.dataset.tab)); + }); + + // Layer checkboxes + initLayerCheckboxes(); + + // Load all data + await Promise.all([loadPoints(), loadRoads(), loadParcels(), refreshLaporanList()]); + + toast('WebGIS loaded ✓', 'success'); +}); + +async function rejectLaporan(id){ + + const res = await fetch( + `${API.laporan}?id=${id}`, + { + method:'PUT', + headers:jsonHeader(), + body:JSON.stringify({ + status:'Ditolak' + }) + } + ); + + if(res.ok){ + + toast( + 'Laporan ditolak', + 'success' + ); + + refreshLaporanList(); + } +} +async function approveLaporan(id){ + + const lapRes = + await fetch(`${API.laporan}?id=${id}`); + + const laporan = + await lapRes.json(); + + const pointBody = { + + name: + laporan.nama_warga, + + category: + 'poor', + + latitude: + laporan.latitude, + + longitude: + laporan.longitude, + + description: + laporan.keterangan, + + alamat: + laporan.alamat, + + status_verifikasi: + 'Layak' + + }; + + const resPoint = + await fetch( + API.points, + { + method:'POST', + headers:jsonHeader(), + body:JSON.stringify(pointBody) + } + ); + + if(!resPoint.ok){ + + toast( + 'Gagal membuat data warga', + 'error' + ); + + return; + } + + await fetch( + `${API.laporan}?id=${id}`, + { + method:'PUT', + headers:jsonHeader(), + body:JSON.stringify({ + status:'Disetujui' + }) + } + ); + + toast( + 'Laporan disetujui', + 'success' + ); + + await loadPoints(); + + await refreshLaporanList(); +} + +async function refreshLaporanList() { + + const res = await fetch(API.laporan); + const data = await res.json(); + + const el = document.getElementById('laporan-list'); + + if (!el) return; + + if (!data.length) { + el.innerHTML = + '
Belum ada laporan.
'; + return; + } + +el.innerHTML = data.map(l => ` + +
+ +
+ +
+ ${l.nama_warga} +
+ +
+ ${l.status} +
+ +
+ ${l.alamat || '-'} +
+ +
+ +
+ + ${ + l.status === 'Menunggu Verifikasi' + ? ` + + + + ` + : '' + } + +
+ +
+ +`).join(''); +} + +document.addEventListener('DOMContentLoaded', function() { + const slider = document.getElementById("radiusSlider"); + + if (slider) { + slider.addEventListener("input", function () { + radius = parseInt(this.value); + document.getElementById("radiusValue").innerText = radius; + + if (activeCircle) { + activeCircle.setRadius(radius); + updateMarkerColors(); + } + }); + } +}); diff --git a/config/db.php b/config/db.php new file mode 100644 index 0000000..a1d1c23 --- /dev/null +++ b/config/db.php @@ -0,0 +1,30 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + } catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]); + exit; + } + } + return $pdo; +} diff --git a/config/helpers.php b/config/helpers.php new file mode 100644 index 0000000..0ae6f43 --- /dev/null +++ b/config/helpers.php @@ -0,0 +1,46 @@ + + + + + + WebGIS — Spatial Data Management System + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ Radius: 500 m
+ +
+ +
+ + +
0.000000, 0.000000
+ + +
Press ESC to cancel
+
+ +
+ + + + + + + + +
+ + + + + + + + + + + + diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..daf985f --- /dev/null +++ b/database.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- WebGIS Database Schema +-- Supports: Points (SPBU, Mosque, Poor Population), +-- Lines (Roads), Polygons (Land Parcels) +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS webgis_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE webgis_db; + +-- ------------------------------------------------------------ +-- TABLE: points +-- Stores all point-type features (SPBU, Mosque, Poor Population) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS points ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + category ENUM('spbu','mosque','poor') NOT NULL COMMENT 'spbu=Gas Station, mosque=Mosque, poor=Poor Population', + subtype VARCHAR(100) DEFAULT NULL COMMENT 'For SPBU: 24hours or not24hours', + latitude DECIMAL(10,8) NOT NULL, + longitude DECIMAL(11,8) NOT NULL, + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- TABLE: roads +-- Stores road polylines with GeoJSON geometry +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS roads ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + road_type ENUM('national','provincial','district') NOT NULL, + length_m DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 'Length in meters, auto-calculated by frontend', + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON LineString geometry', + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- TABLE: parcels +-- Stores land parcel polygons with GeoJSON geometry +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS parcels ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_name VARCHAR(255) NOT NULL, + ownership_type ENUM('SHM','HGB','HGU','HP') NOT NULL, + area_m2 DECIMAL(15,4) NOT NULL DEFAULT 0 COMMENT 'Area in square meters, auto-calculated by frontend', + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON Polygon geometry', + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- Sample seed data (optional) +-- ------------------------------------------------------------ +INSERT INTO points (name, category, subtype, latitude, longitude, description) VALUES +('SPBU Jl. Sudirman', 'spbu', '24hours', -6.9175, 107.6191, 'SPBU di pusat kota'), +('SPBU Jl. Asia Afrika', 'spbu', 'not24hours', -6.9214, 107.6062, 'SPBU selatan kota'), +('Masjid Agung', 'mosque', NULL, -6.9200, 107.6100, 'Masjid Agung Bandung'), +('Masjid Al-Ikhlas', 'mosque', NULL, -6.9150, 107.6250, 'Masjid Kelurahan'), +('Lokasi Kemiskinan A', 'poor', NULL, -6.9300, 107.6050, 'Kawasan padat penduduk'), +('Lokasi Kemiskinan B', 'poor', NULL, -6.9350, 107.6150, 'RW 07 Kebon Jeruk'); diff --git a/database/database.sql b/database/database.sql new file mode 100644 index 0000000..daf985f --- /dev/null +++ b/database/database.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- WebGIS Database Schema +-- Supports: Points (SPBU, Mosque, Poor Population), +-- Lines (Roads), Polygons (Land Parcels) +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS webgis_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE webgis_db; + +-- ------------------------------------------------------------ +-- TABLE: points +-- Stores all point-type features (SPBU, Mosque, Poor Population) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS points ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + category ENUM('spbu','mosque','poor') NOT NULL COMMENT 'spbu=Gas Station, mosque=Mosque, poor=Poor Population', + subtype VARCHAR(100) DEFAULT NULL COMMENT 'For SPBU: 24hours or not24hours', + latitude DECIMAL(10,8) NOT NULL, + longitude DECIMAL(11,8) NOT NULL, + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- TABLE: roads +-- Stores road polylines with GeoJSON geometry +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS roads ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + road_type ENUM('national','provincial','district') NOT NULL, + length_m DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 'Length in meters, auto-calculated by frontend', + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON LineString geometry', + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- TABLE: parcels +-- Stores land parcel polygons with GeoJSON geometry +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS parcels ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_name VARCHAR(255) NOT NULL, + ownership_type ENUM('SHM','HGB','HGU','HP') NOT NULL, + area_m2 DECIMAL(15,4) NOT NULL DEFAULT 0 COMMENT 'Area in square meters, auto-calculated by frontend', + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON Polygon geometry', + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ +-- Sample seed data (optional) +-- ------------------------------------------------------------ +INSERT INTO points (name, category, subtype, latitude, longitude, description) VALUES +('SPBU Jl. Sudirman', 'spbu', '24hours', -6.9175, 107.6191, 'SPBU di pusat kota'), +('SPBU Jl. Asia Afrika', 'spbu', 'not24hours', -6.9214, 107.6062, 'SPBU selatan kota'), +('Masjid Agung', 'mosque', NULL, -6.9200, 107.6100, 'Masjid Agung Bandung'), +('Masjid Al-Ikhlas', 'mosque', NULL, -6.9150, 107.6250, 'Masjid Kelurahan'), +('Lokasi Kemiskinan A', 'poor', NULL, -6.9300, 107.6050, 'Kawasan padat penduduk'), +('Lokasi Kemiskinan B', 'poor', NULL, -6.9350, 107.6150, 'RW 07 Kebon Jeruk'); diff --git a/docs/SRS.md b/docs/SRS.md new file mode 100644 index 0000000..a5d3441 --- /dev/null +++ b/docs/SRS.md @@ -0,0 +1,325 @@ +# WebGIS — Software Requirements Specification (SRS) +## Spatial Data Management System for Poverty Reduction Support + +--- + +## 1. INTRODUCTION + +### 1.1 Purpose +This document defines the functional and non-functional requirements for a **WebGIS (Web-based Geographic Information System)** application designed to manage spatial data — including point features, road networks, and land parcels — in support of local government decision-making with a focus on poverty reduction programs. + +### 1.2 Scope +The system is named **WebGIS-SPD** (Spatial Poverty Data). It allows government officers, planners, and community stakeholders to: +- Register, visualize, update, and delete spatial features (points, lines, polygons) +- Identify the spatial distribution of poor population areas +- Overlay infrastructure data (roads, SPBU) with poverty indicators +- Support location-based decision-making for resource allocation + +### 1.3 Intended Audience +- Academic reviewers and instructors +- Local government (dinas/pemda) administrators +- GIS officers and data entry staff +- NGO field coordinators + +### 1.4 Definitions +| Term | Meaning | +|------|---------| +| GIS | Geographic Information System | +| SPBU | Stasiun Pengisian Bahan Bakar Umum (Gas Station) | +| SHM | Sertifikat Hak Milik (Freehold Title) | +| HGB | Hak Guna Bangunan (Building Rights) | +| HGU | Hak Guna Usaha (Business Rights) | +| HP | Hak Pakai (Usage Rights) | +| CRUD | Create, Read, Update, Delete | +| GeoJSON | Geographic JSON — standard geospatial format | + +--- + +## 2. SYSTEM OVERVIEW + +WebGIS-SPD is a web application built with: +- **Frontend**: HTML5, CSS3, Vanilla JavaScript, Leaflet.js +- **Backend**: PHP (procedural, no framework) +- **Database**: MySQL 8+ +- **Map Tiles**: OpenStreetMap (free, community-maintained) +- **Drawing Tools**: Leaflet.draw plugin + +The system follows a **client-server architecture**. All spatial data is stored in MySQL as GeoJSON text and served via RESTful PHP endpoints. + +--- + +## 3. FUNCTIONAL REQUIREMENTS + +### FR-01: Point Data Management (SPBU, Mosque, Poor Population) +| ID | Requirement | +|----|-------------| +| FR-01.1 | User can add a point by clicking on the map | +| FR-01.2 | Coordinates are captured automatically from click event | +| FR-01.3 | Each point has: name, category, optional subtype | +| FR-01.4 | SPBU points have a subtype: "24 Hours" or "Not 24 Hours" | +| FR-01.5 | User can edit point attributes | +| FR-01.6 | User can delete a point | +| FR-01.7 | Points display custom icons by category | +| FR-01.8 | Clicking a marker shows a popup with attributes and action buttons | + +### FR-02: Road Polyline Management +| ID | Requirement | +|----|-------------| +| FR-02.1 | User draws roads using Leaflet.draw polyline tool | +| FR-02.2 | Road length is calculated automatically in meters | +| FR-02.3 | Length is NOT manually editable | +| FR-02.4 | Road types: National (red), Provincial (orange), District (blue) | +| FR-02.5 | Each road stores: name, type, length, GeoJSON geometry | +| FR-02.6 | Roads are styled by type using distinct colors | +| FR-02.7 | User can edit road name/type | +| FR-02.8 | User can delete a road | + +### FR-03: Land Parcel Polygon Management +| ID | Requirement | +|----|-------------| +| FR-03.1 | User draws parcels using Leaflet.draw polygon tool | +| FR-03.2 | Parcel area is calculated automatically in m² using geodesic formula | +| FR-03.3 | Area is NOT manually editable | +| FR-03.4 | Ownership types: SHM, HGB, HGU, HP | +| FR-03.5 | Each parcel stores: owner name, ownership type, area, GeoJSON geometry | +| FR-03.6 | Parcels are styled by ownership type | +| FR-03.7 | User can edit parcel attributes | +| FR-03.8 | User can delete a parcel | + +### FR-04: Layer Control +| ID | Requirement | +|----|-------------| +| FR-04.1 | Each feature category has an independent toggle checkbox | +| FR-04.2 | SPBU is split into two toggleable sub-layers | +| FR-04.3 | Roads are split by type into three sub-layers | +| FR-04.4 | Parcels are split by ownership into four sub-layers | +| FR-04.5 | Layer counts are displayed next to each toggle | + +### FR-05: Data Listing and Navigation +| ID | Requirement | +|----|-------------| +| FR-05.1 | Sidebar shows a scrollable list of all features per type | +| FR-05.2 | Clicking a list item zooms the map to that feature | +| FR-05.3 | List items have inline Edit and Delete actions | + +--- + +## 4. NON-FUNCTIONAL REQUIREMENTS + +| ID | Category | Requirement | +|----|----------|-------------| +| NFR-01 | Performance | Map renders within 2 seconds on standard broadband | +| NFR-02 | Usability | All map interactions require no more than 3 clicks | +| NFR-03 | Reliability | API returns structured JSON for all success/error states | +| NFR-04 | Security | All inputs are sanitized via PDO prepared statements | +| NFR-05 | Scalability | Database schema supports >10,000 features per table | +| NFR-06 | Compatibility | Works in modern browsers (Chrome, Firefox, Edge) | +| NFR-07 | Maintainability | Code is modular, commented, and follows single responsibility | +| NFR-08 | Openness | Uses open-source libraries (Leaflet, OSM) — no licensing cost | + +--- + +## 5. USER ROLES + +| Role | Permissions | +|------|-------------| +| **GIS Admin** | Full CRUD on all layers; manage system configuration | +| **Data Entry Officer** | Add and edit features; cannot delete | +| **Viewer** | Read-only; can pan/zoom map and view popups | +| **Community Supervisor** | Add poor population points; view mosques and infrastructure | + +--- + +## 6. USE CASE DESCRIPTIONS + +### UC-01: Add Gas Station (SPBU) +- **Actor**: GIS Admin / Data Entry Officer +- **Precondition**: User is on the Points tab +- **Flow**: + 1. User clicks "Add Point on Map" + 2. User clicks a location on the map + 3. Modal opens with coordinates pre-filled + 4. User selects category = SPBU, subtype = 24 Hours / Not 24 Hours + 5. User enters name and optionally description + 6. User clicks "Add Point" + 7. Marker appears on map; entry added to list +- **Postcondition**: Point stored in database; shown on correct sub-layer + +### UC-02: Draw and Save a Road +- **Actor**: GIS Admin +- **Precondition**: User is on the Roads tab +- **Flow**: + 1. User clicks "Draw Road on Map" + 2. User draws polyline by clicking multiple points; double-clicks to finish + 3. Modal opens with length auto-filled (read-only) + 4. User enters road name and type + 5. User saves; road appears on map with correct color +- **Postcondition**: Road stored with GeoJSON + calculated length + +### UC-03: Draw and Save a Land Parcel +- **Actor**: GIS Admin +- **Flow**: Similar to UC-02 but with polygon; area auto-calculated + +### UC-04: Toggle Layer Visibility +- **Actor**: Any user +- **Flow**: User clicks checkbox next to a layer name; features appear/disappear + +### UC-05: Edit a Feature +- **Actor**: GIS Admin / Data Entry +- **Flow**: User clicks "✏️" on a list item or in popup → modal opens pre-filled → saves updated data + +### UC-06: Delete a Feature +- **Actor**: GIS Admin +- **Flow**: User clicks "🗑️" → confirmation dialog → feature removed from map and database + +--- + +## 7. BUSINESS PROCESS ANALYSIS + +### Current Process (Without System) +1. Field officers collect data on paper forms +2. Data is manually entered into Excel spreadsheets +3. Reports are produced monthly; no spatial context +4. Decision-makers have no map-based view of poverty distribution +5. Infrastructure gaps (missing roads, no SPBU access) are not correlated with poverty + +### Improved Process (With WebGIS-SPD) +1. Field officers directly enter poor population locations on the web map +2. Data is instantly visible to supervisors and planners +3. GIS admin overlays road network + poverty zones to identify accessibility gaps +4. Land parcel ownership data helps identify available land for social housing +5. Mosque locations help identify community focal points for program delivery + +--- + +## 8. POVERTY REDUCTION SYSTEM DESIGN + +### 8.1 Actors Involved + +| Actor | Role | Data They Contribute | +|-------|------|----------------------| +| **Local Government (Dinas Sosial)** | Decision makers, program funders | Program areas, budget allocation zones | +| **GIS Officers (BPS/Bappeda)** | Data managers, analysts | Road networks, land parcels, administrative boundaries | +| **Community (RW/RT Leaders)** | Ground truth validators | Poor household locations, family counts | +| **Mosques / Religious Institutions** | Community focal points | Zakat distribution data, beneficiary lists | +| **NGOs / Social Organizations** | Program implementors | Intervention areas, beneficiary tracking | +| **Health Centers (Puskesmas)** | Health services data | Malnourishment points, access to healthcare | + +### 8.2 Required Data Attributes Per Actor + +**Poor Population Points** (contributed by Community + Dinas Sosial): +- Head of household name +- Number of family members +- Monthly income (IDR) +- Access to clean water: yes/no +- Distance to nearest road (m) — calculated from road layer +- Distance to nearest SPBU — calculated from point layer +- Receiving social assistance: yes/no +- Assistance program type (PKH, BPNT, etc.) + +**Road Network** (contributed by GIS Officers): +- Road name, type, surface condition +- Last maintained date +- Accessibility rating (1–5) + +**Land Parcels** (contributed by BPN / GIS Officers): +- Ownership type (SHM/HGB/HGU/HP) +- Current usage (residential, agricultural, vacant) +- Available for social housing: yes/no + +**Mosque Data** (contributed by Religious Affairs): +- Name, capacity +- Zakat collection amount (IDR/year) +- Zakat distribution radius (m) + +### 8.3 Example Use Case: Targeting Poverty Aid + +**Scenario**: Government wants to identify poor households with the worst infrastructure access. + +**System Query**: +1. Display all "Poor Population" points +2. Toggle on "District Roads" layer +3. Visually identify poor zones more than 500m from any road +4. Overlay land parcel layer → identify HGU/HP parcels near these zones +5. Overlay mosque layer → find nearest mosque to use as aid distribution point + +**Result**: Decision maker can now select 50 priority households based on spatial proximity to infrastructure gaps, identify community delivery channels (mosques), and plan land use for social housing — all from a single map interface. + +### 8.4 How the System Contributes to Poverty Reduction + +| Contribution | Mechanism | +|-------------|-----------| +| **Targeted Aid Delivery** | Spatial query identifies worst-off zones first | +| **Infrastructure Planning** | Road gaps near poor zones inform public works budgets | +| **Land Use Planning** | Vacant HP/HGU parcels near poor zones identified for social housing | +| **Community Empowerment** | Mosques mapped as distribution hubs for zakat + government aid | +| **Evidence-Based Policy** | Spatial statistics replace anecdotal reports | +| **Monitoring** | Change detection over time shows if interventions are working | +| **Transparency** | Web-based, shareable map increases government accountability | + +--- + +## 9. SYSTEM ARCHITECTURE DIAGRAM + +``` +┌──────────────────────────────────────────────────────────────┐ +│ CLIENT (Browser) │ +│ ┌────────────┐ ┌─────────────┐ ┌────────────────────┐ │ +│ │ Leaflet.js │ │ Leaflet │ │ Vanilla JS App │ │ +│ │ Base Map │ │ Draw Tools │ │ (app.js) │ │ +│ └─────┬──────┘ └──────┬──────┘ └────────┬───────────┘ │ +│ └────────────────┴──────────────────┘ │ +│ │ fetch() HTTP │ +└───────────────────────────┼──────────────────────────────────┘ + │ +┌───────────────────────────┼──────────────────────────────────┐ +│ SERVER (PHP) │ │ +│ ┌──────────────────────────────────┐ │ │ +│ │ api/points.php │ │ │ +│ │ api/roads.php │◄──┘ │ +│ │ api/parcels.php │ │ +│ └───────────────┬──────────────────┘ │ +│ │ PDO │ +│ ┌───────────────▼──────────────────┐ │ +│ │ MySQL Database │ │ +│ │ tables: points, roads, parcels │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 10. DATABASE ENTITY RELATIONSHIP + +``` +POINTS +├── id (PK) +├── name +├── category [spbu|mosque|poor] +├── subtype [24hours|not24hours|null] +├── latitude, longitude +├── description +└── timestamps + +ROADS +├── id (PK) +├── name +├── road_type [national|provincial|district] +├── length_m (auto-calculated) +├── geojson (LineString) +├── description +└── timestamps + +PARCELS +├── id (PK) +├── owner_name +├── ownership_type [SHM|HGB|HGU|HP] +├── area_m2 (auto-calculated) +├── geojson (Polygon) +├── description +└── timestamps +``` + +--- + +*Document Version: 1.0 | Prepared for Academic Submission | WebGIS-SPD* diff --git a/index.php b/index.php new file mode 100644 index 0000000..1c37c65 --- /dev/null +++ b/index.php @@ -0,0 +1,132 @@ + + + + + + + SinergiSpasial + + + + + +
+ + + +
+ +
+ +
+ +

+ Sistem Informasi Geografis + Berbasis Web untuk Analisis + Sebaran Kemiskinan +

+ +

+ Membantu identifikasi wilayah bantuan + berdasarkan jangkauan masjid + di Kota Pontianak. +

+ + + Masuk ke Sistem + + +
+ +
+ +
+ +

Tentang Sistem

+ +

+ SinergiSpasial merupakan WebGIS yang + dirancang untuk membantu visualisasi + dan analisis sebaran penduduk miskin + menggunakan pendekatan geospasial + berbasis radius jangkauan masjid. +

+ +
+ +
+ +

Fitur Unggulan

+ +
+ +
+

🕌 Data Masjid

+

Manajemen lokasi masjid sebagai pusat bantuan.

+
+ +
+

👨‍👩‍👧‍👦 Data Penduduk

+

Pemetaan penduduk miskin berbasis lokasi.

+
+ +
+

📍 Analisis Radius

+

Menentukan jangkauan bantuan secara otomatis.

+
+ +
+ +
+ +
+ +

Preview Sistem

+ + Preview Dashboard + +
+ +
+ +

Alur Analisis

+ +
+ +
Pilih Masjid
+
+ +
Tentukan Radius
+
+ +
Hitung Jarak
+
+ +
Identifikasi Penduduk
+
+ +
Rekomendasi Bantuan
+ +
+ +
+ + + + + \ No newline at end of file