initial commit

This commit is contained in:
cindy
2026-06-10 19:20:46 +07:00
commit ec94359f9a
21 changed files with 23023 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<?php
require_once __DIR__ . '/../koneksi.php';
$result = $conn->query("SELECT * FROM jalan ORDER BY created_at DESC");
$data = array();
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
echo json_encode($data);
$conn->close();
?>
+29
View File
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
exit;
}
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if ($id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
exit;
}
$sql = "DELETE FROM jalan WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request']);
exit;
}
$nama = trim($_POST['nama'] ?? '');
$status = trim($_POST['status'] ?? '');
$geojson = $_POST['geojson'] ?? '';
$panjang = floatval($_POST['panjang'] ?? 0);
if (empty($nama) || empty($status) || empty($geojson) || $panjang <= 0) {
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$sql = "INSERT INTO jalan (nama, status, panjang, geojson) VALUES (?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ssds", $nama, $status, $panjang, $geojson);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data berhasil disimpan']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+28
View File
@@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
$id = intval($_POST['id'] ?? 0);
$nama = trim($_POST['nama'] ?? '');
$status = trim($_POST['status'] ?? '');
// Jika ada geojson, update geometri juga
if (isset($_POST['geojson'])) {
$geojson = $_POST['geojson'];
$panjang = floatval($_POST['panjang'] ?? 0);
$sql = "UPDATE jalan SET nama = ?, status = ?, geojson = ?, panjang = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("sssdi", $nama, $status, $geojson, $panjang, $id);
} else {
// Hanya update info
$sql = "UPDATE jalan SET nama = ?, status = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ssi", $nama, $status, $id);
}
if ($stmt->execute()) {
echo json_encode(['status' => 'success']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
?>
+13
View File
@@ -0,0 +1,13 @@
<?php
require_once __DIR__ . '/../koneksi.php';
$result = $conn->query("SELECT * FROM parsil_tanah ORDER BY created_at DESC");
$data = array();
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
echo json_encode($data);
$conn->close();
?>
+29
View File
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
exit;
}
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if ($id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
exit;
}
$sql = "DELETE FROM parsil_tanah WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request']);
exit;
}
$nama = trim($_POST['nama'] ?? '');
$status = trim($_POST['status'] ?? '');
$geojson = $_POST['geojson'] ?? '';
$luas = floatval($_POST['luas'] ?? 0);
if (empty($nama) || empty($status) || empty($geojson) || $luas <= 0) {
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$sql = "INSERT INTO parsil_tanah (nama, status, luas, geojson) VALUES (?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ssds", $nama, $status, $luas, $geojson);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data berhasil disimpan']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+28
View File
@@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
$id = intval($_POST['id'] ?? 0);
$nama = trim($_POST['nama'] ?? '');
$status = trim($_POST['status'] ?? '');
// Jika ada geojson, update geometri juga
if (isset($_POST['geojson'])) {
$geojson = $_POST['geojson'];
$luas = floatval($_POST['luas'] ?? 0);
$sql = "UPDATE parsil_tanah SET nama = ?, status = ?, geojson = ?, luas = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("sssdi", $nama, $status, $geojson, $luas, $id);
} else {
// Hanya update info
$sql = "UPDATE parsil_tanah SET nama = ?, status = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ssi", $nama, $status, $id);
}
if ($stmt->execute()) {
echo json_encode(['status' => 'success']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
?>
+280
View File
@@ -0,0 +1,280 @@
# 🗺️ WebGIS - Sistem Informasi SPBU Kota Pontianak
[![PHP Version](https://img.shields.io/badge/PHP-7.4+-blue.svg)](https://php.net)
[![MySQL](https://img.shields.io/badge/MySQL-5.7+-orange.svg)](https://mysql.com)
[![Leaflet](https://img.shields.io/badge/Leaflet-1.9.4-green.svg)](https://leafletjs.com)
[![License](https://img.shields.io/badge/License-MIT-red.svg)](LICENSE)
Aplikasi WebGIS untuk mengelola dan memvisualisasikan data **SPBU**, **Jaringan Jalan**, dan **Parsil Tanah** di Kota Pontianak secara interaktif.
---
## ✨ Fitur Utama
### 📍 Manajemen SPBU
* Tambah, edit, hapus data SPBU
* Drag & drop marker untuk update posisi
* Kategorisasi (24 jam / Tidak 24 jam)
* Informasi kontak WhatsApp
### 🛣️ Manajemen Jalan
* Gambar polyline langsung di peta
* Klasifikasi status jalan (Nasional/Provinsi/Kabupaten)
* Hitung panjang jalan otomatis
* Edit dan hapus data jalan
### 🏠 Manajemen Parsil Tanah
* Gambar polygon untuk bidang tanah
* Klasifikasi status sertifikat (SHM/HGB/HGU/HP)
* Hitung luas tanah otomatis
* Edit dan hapus data parsil
### 🗺️ Fitur Peta
* Peta interaktif dengan Leaflet.js
* Multiple layer control
* Legend interaktif
* Search dan filter data
* Zoom to feature
### ⚙️ Layer Control & Tampilan
* **Layer Control Terpisah** - SPBU 24 jam dan Non 24 jam beda layer
* **Tampilan Modern** - UI lebih elegan dengan font DM Sans
* **Popup Koordinat** - Menampilkan latitude/longitude lengkap
* **Loading State** - Animasi loading yang halus
---
## 🛠️ Teknologi yang Digunakan
| Teknologi | Kegunaan |
| --------------------- | ------------------------------ |
| PHP 7.4+ | Backend API & koneksi database |
| MySQL | Database penyimpanan data |
| Leaflet.js | Library peta interaktif |
| Turf.js | Kalkulasi geometri |
| Leaflet Draw | Drawing tools |
| Leaflet Extra Markers | Marker kustom |
| HTML5/CSS3/JS | Frontend |
---
## 📁 Struktur Folder
```
```
---
## 🚀 Instalasi
### Prasyarat
* XAMPP / Laragon / MAMP
* PHP ≥ 7.4
* MySQL ≥ 5.7
* Browser modern
### Langkah Instalasi
#### 1. Clone Repository
```bash
git clone https://github.com/username/SPBU.git
```
#### 2. Pindahkan ke htdocs
```bash
C:/xampp/htdocs/webgis/SPBU/
```
#### 3. Import Database
**phpMyAdmin**
* http://localhost/phpmyadmin
* Buat database: `webgis`
* Import `webgis.sql`
**Command Line**
```bash
mysql -u root -p
CREATE DATABASE webgis;
USE webgis;
SOURCE webgis.sql;
```
#### 4. Konfigurasi Database
```php
<?php
$host = 'localhost';
$user = 'root';
$password = '';
$database = 'webgis';
$conn = new mysqli($host, $user, $password, $database);
if ($conn->connect_error) {
die(json_encode(['error' => true, 'message' => $conn->connect_error]));
}
?>
```
#### 5. Jalankan Aplikasi
```
http://localhost/webgis/01SPBU/
```
---
## 📖 Cara Penggunaan
### Mode Navigasi
* Klik objek untuk melihat detail
* Klik list untuk zoom lokasi
### Tambah SPBU
1. Klik tombol SPBU
2. Klik peta
3. Isi form
4. Simpan
### Tambah Jalan
1. Klik Jalan
2. Klik titik di peta
3. Double click selesai
4. Simpan
### Tambah Parsil
1. Klik Parsil
2. Klik titik
3. Selesaikan polygon
4. Simpan
### Edit & Hapus
* Edit: tombol ✏️
* Hapus: tombol 🗑️
---
## 🔧 API Endpoints
### SPBU
| Endpoint | Method |
| -------------------------- | ------ |
| /SPBU/ambil.php | GET |
| /SPBU/simpan.php | POST |
| /SPBU/hapus.php | POST |
| /SPBU/update_lokasi.php | POST |
| /SPBU/update_spbu_info.php | POST |
### Jalan
| Endpoint | Method |
| ----------------------- | ------ |
| /Jalan/ambil_jalan.php | GET |
| /Jalan/simpan_jalan.php | POST |
| /Jalan/hapus_jalan.php | POST |
### Parsil
| Endpoint | Method |
| ------------------------- | ------ |
| /Parsil/ambil_parsil.php | GET |
| /Parsil/simpan_parsil.php | POST |
| /Parsil/hapus_parsil.php | POST |
---
## 📊 Database Structure
### spbu
| Field | Type |
| --------- | ------- |
| id | INT |
| nama | VARCHAR |
| nomor | VARCHAR |
| kategori | ENUM |
| latitude | DECIMAL |
| longitude | DECIMAL |
### jalan
| Field | Type |
| ------- | -------- |
| id | INT |
| nama | VARCHAR |
| status | VARCHAR |
| panjang | DOUBLE |
| geojson | LONGTEXT |
### parsil_tanah
| Field | Type |
| ------- | -------- |
| id | INT |
| nama | VARCHAR |
| status | VARCHAR |
| luas | DOUBLE |
| geojson | LONGTEXT |
---
## 🐛 Troubleshooting
**Data tidak muncul**
```php
require_once __DIR__ . '/../koneksi.php';
```
**Path error**
* Gunakan `__DIR__`
**Peta tidak muncul**
* Cek internet & console
**Data gagal simpan**
* Cek DB & permission
---
## 👥 Kontributor
* Cindy Dyra Islamay — Fullstack Developer
---
## 📝 Lisensi
MIT License
---
## 📞 Kontak
* Email: [d1041231045@student.untan.ac.id]
---
⭐ Jika project ini membantu, beri bintang di repository.
+35
View File
@@ -0,0 +1,35 @@
<?php
header('Content-Type: application/json');
try {
require_once __DIR__ . '/../koneksi.php';
// Cek koneksi
if (!isset($conn) || $conn->connect_error) {
throw new Exception("Database connection failed");
}
$result = $conn->query("SELECT * FROM spbu ORDER BY created_at DESC");
if (!$result) {
throw new Exception("Query failed: " . $conn->error);
}
$data = array();
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
echo json_encode($data);
} catch (Exception $e) {
echo json_encode([
'error' => true,
'message' => $e->getMessage()
]);
}
if (isset($conn)) {
$conn->close();
}
?>
+28
View File
@@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
exit;
}
$id = intval($_POST['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
exit;
}
$sql = "DELETE FROM spbu WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data SPBU berhasil dihapus']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+52
View File
@@ -0,0 +1,52 @@
<?php
header('Content-Type: application/json');
try {
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception('Invalid request method');
}
$nama = isset($_POST['nama']) ? trim($_POST['nama']) : '';
$nomor = isset($_POST['nomor']) ? trim($_POST['nomor']) : '';
$kategori = isset($_POST['kategori']) ? trim($_POST['kategori']) : '';
$lat = isset($_POST['latitude']) ? floatval($_POST['latitude']) : null;
$lng = isset($_POST['longitude']) ? floatval($_POST['longitude']) : null;
if (empty($nama) || empty($nomor) || empty($kategori)) {
throw new Exception('Semua field harus diisi');
}
$sql = "INSERT INTO spbu (nama, nomor, kategori, latitude, longitude) VALUES (?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $conn->error);
}
$stmt->bind_param("sssdd", $nama, $nomor, $kategori, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'Data berhasil disimpan',
'id' => $conn->insert_id
]);
} else {
throw new Exception('Execute failed: ' . $stmt->error);
}
$stmt->close();
} catch (Exception $e) {
echo json_encode([
'status' => 'error',
'message' => $e->getMessage()
]);
}
if (isset($conn)) {
$conn->close();
}
?>
+37
View File
@@ -0,0 +1,37 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
exit;
}
$id = intval($_POST['id'] ?? 0);
$latitude = floatval($_POST['latitude'] ?? 0);
$longitude = floatval($_POST['longitude'] ?? 0);
if ($id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
exit;
}
$sql = "UPDATE spbu SET latitude = ?, longitude = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
echo json_encode(['status' => 'error', 'message' => $conn->error]);
exit;
}
$stmt->bind_param("ddi", $latitude, $longitude, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Lokasi diupdate']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../koneksi.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['status' => 'error', 'message' => 'Invalid request']);
exit;
}
$id = intval($_POST['id'] ?? 0);
$nama = trim($_POST['nama'] ?? '');
$nomor = trim($_POST['nomor'] ?? '');
$kategori = trim($_POST['kategori'] ?? '');
if ($id <= 0 || empty($nama) || empty($nomor) || empty($kategori)) {
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$sql = "UPDATE spbu SET nama = ?, nomor = ?, kategori = ? WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("sssi", $nama, $nomor, $kategori, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data SPBU diupdate']);
} else {
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
?>
+198
View File
@@ -0,0 +1,198 @@
-- phpMyAdmin SQL Dump
-- version 5.2.1
-- https://www.phpmyadmin.net/
--
-- Host: 127.0.0.1
-- Generation Time: Jun 07, 2026 at 07:10 PM
-- Server version: 10.4.32-MariaDB
-- PHP Version: 8.2.12
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `webgis`
--
-- --------------------------------------------------------
--
-- Table structure for table `jalan`
--
CREATE TABLE `jalan` (
`id` int(11) NOT NULL,
`nama` varchar(255) NOT NULL COMMENT 'Nama ruas jalan',
`status` varchar(100) NOT NULL COMMENT 'Status jalan (e.g. Nasional, Provinsi, Kabupaten, Desa)',
`panjang` double NOT NULL DEFAULT 0 COMMENT 'Panjang jalan dalam meter',
`geojson` longtext NOT NULL COMMENT 'Geometri jalan dalam format GeoJSON',
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Data segmen jalan (polyline) dalam format GeoJSON';
--
-- Dumping data for table `jalan`
--
INSERT INTO `jalan` (`id`, `nama`, `status`, `panjang`, `geojson`, `created_at`) VALUES
(1, 'Jalan Sepakat 2', 'Kabupaten', 1784.791473379715, '{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":[[109.35178399086,-0.0583629161931721],[109.35063600540163,-0.05999276433692213],[109.34970259666444,-0.06140815873824388],[109.3490481376648,-0.062394645723090975],[109.34783577919008,-0.06381004006026729],[109.34582948684694,-0.06598674491163233],[109.3437159061432,-0.06820159083180335],[109.34262156486513,-0.06938108597800444],[109.34150576591493,-0.07064203975056207],[109.34150576591493,-0.07064203975056207],[109.34150576591493,-0.07064203975056207]]},\"properties\":{}}', '2026-06-07 16:09:11');
-- --------------------------------------------------------
--
-- Table structure for table `parsil_tanah`
--
CREATE TABLE `parsil_tanah` (
`id` int(11) NOT NULL,
`nama` varchar(255) NOT NULL COMMENT 'Nama parsil / bidang tanah',
`status` varchar(50) NOT NULL COMMENT 'Status hak (SHM | HGB | HGU | HP)',
`luas` double NOT NULL DEFAULT 0 COMMENT 'Luas area dalam meter persegi',
`geojson` longtext NOT NULL COMMENT 'Geometri parsil dalam format GeoJSON',
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Data parsil tanah (polygon) dalam format GeoJSON';
--
-- Dumping data for table `parsil_tanah`
--
INSERT INTO `parsil_tanah` (`id`, `nama`, `status`, `luas`, `geojson`, `created_at`) VALUES
(1, 'Pengadilan Tinggi Negeri', 'HGB', 1312.2666173966293, '{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[109.35532987117769,-0.06302106891085976],[109.35539424419404,-0.06308540501714478],[109.35543715953828,-0.06303715293742466],[109.35552835464479,-0.06313365709682675],[109.35549080371857,-0.06317654783426951],[109.35555517673494,-0.06327841333559274],[109.35537278652193,-0.06346069896896436],[109.35512065887453,-0.06321407722949855],[109.3551367521286,-0.06319263186079624],[109.3551367521286,-0.06319263186079624],[109.35532987117769,-0.06302106891085976]]]},\"properties\":{}}', '2026-06-07 16:02:58'),
(2, 'Hutan Sylva UNTAN', 'HP', 31066.903716160716, '{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[109.34868872165681,-0.05657792397283722],[109.34927880764009,-0.05576299985790386],[109.3494075536728,-0.055827335972649154],[109.34962749481203,-0.055784445229494106],[109.35099005699159,-0.0574571841892975],[109.35042142868042,-0.05825066290960069],[109.34964895248415,-0.05740893210459027],[109.34938609600069,-0.057092612881665034],[109.34924125671388,-0.05700147005438797],[109.34868872165681,-0.05657792397283722]]]},\"properties\":{}}', '2026-06-07 17:07:13');
-- --------------------------------------------------------
--
-- Table structure for table `polygons`
--
CREATE TABLE `polygons` (
`id` int(11) NOT NULL,
`nama` varchar(255) NOT NULL COMMENT 'Nama polygon',
`coordinates` longtext NOT NULL COMMENT 'Koordinat polygon (JSON string)',
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Layer polygon generik (koordinat sebagai teks JSON)';
-- --------------------------------------------------------
--
-- Table structure for table `polylines`
--
CREATE TABLE `polylines` (
`id` int(11) NOT NULL,
`nama` varchar(255) NOT NULL COMMENT 'Nama polyline',
`coordinates` longtext NOT NULL COMMENT 'Koordinat polyline (JSON string)',
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Layer polyline generik (koordinat sebagai teks JSON)';
-- --------------------------------------------------------
--
-- Table structure for table `spbu`
--
CREATE TABLE `spbu` (
`id` int(11) NOT NULL,
`nama` varchar(255) NOT NULL COMMENT 'Nama SPBU',
`nomor` varchar(100) NOT NULL COMMENT 'Nomor WhatsApp / kontak',
`kategori` varchar(50) NOT NULL COMMENT '24_jam | tidak_24_jam',
`latitude` decimal(10,8) DEFAULT NULL COMMENT 'Koordinat lintang',
`longitude` decimal(11,8) DEFAULT NULL COMMENT 'Koordinat bujur',
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Data titik lokasi SPBU';
--
-- Dumping data for table `spbu`
--
INSERT INTO `spbu` (`id`, `nama`, `nomor`, `kategori`, `latitude`, `longitude`, `created_at`) VALUES
(1, 'SPBU Parit Husein', '0824245345', 'tidak', -0.06431884, 109.35623646, '2026-06-07 16:02:20'),
(2, 'SPBU Kota Baru', '085154545', '24_jam', -0.04773790, 109.31863725, '2026-06-07 17:10:26');
--
-- Indexes for dumped tables
--
--
-- Indexes for table `jalan`
--
ALTER TABLE `jalan`
ADD PRIMARY KEY (`id`),
ADD KEY `idx_jalan_status` (`status`),
ADD KEY `idx_jalan_created_at` (`created_at`);
--
-- Indexes for table `parsil_tanah`
--
ALTER TABLE `parsil_tanah`
ADD PRIMARY KEY (`id`),
ADD KEY `idx_parsil_status` (`status`),
ADD KEY `idx_parsil_created_at` (`created_at`);
--
-- Indexes for table `polygons`
--
ALTER TABLE `polygons`
ADD PRIMARY KEY (`id`),
ADD KEY `idx_polygons_created_at` (`created_at`);
--
-- Indexes for table `polylines`
--
ALTER TABLE `polylines`
ADD PRIMARY KEY (`id`),
ADD KEY `idx_polylines_created_at` (`created_at`);
--
-- Indexes for table `spbu`
--
ALTER TABLE `spbu`
ADD PRIMARY KEY (`id`),
ADD KEY `idx_spbu_kategori` (`kategori`),
ADD KEY `idx_spbu_created_at` (`created_at`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `jalan`
--
ALTER TABLE `jalan`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
--
-- AUTO_INCREMENT for table `parsil_tanah`
--
ALTER TABLE `parsil_tanah`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
--
-- AUTO_INCREMENT for table `polygons`
--
ALTER TABLE `polygons`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `polylines`
--
ALTER TABLE `polylines`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `spbu`
--
ALTER TABLE `spbu`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+1793
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<?php
// Set header JSON untuk semua response
header('Content-Type: application/json');
$host = 'localhost';
$user = 'root';
$password = ''; // Pastikan password MySQL Anda
$database = 'webgis';
try {
// Buat koneksi
$conn = new mysqli($host, $user, $password, $database);
// Cek koneksi
if ($conn->connect_error) {
throw new Exception("Koneksi gagal: " . $conn->connect_error);
}
// Set charset
$conn->set_charset("utf8");
} catch (Exception $e) {
// Jika error, tetap kembalikan JSON
echo json_encode([
'status' => 'error',
'message' => $e->getMessage()
]);
exit;
}
?>
+432
View File
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choropleth Peta Kepadatan Penduduk - Kota Pontianak</title>
<!-- Leaflet CSS & JS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
width: 100%;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden; /* Mencegah scroll */
}
/* Peta full screen */
#map {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* Info panel tetap di atas peta */
.info {
padding: 8px 12px;
font: 15px/1.4 'Segoe UI', 'Arial', sans-serif;
background: white;
background: rgba(255,255,255,0.95);
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
border-radius: 8px;
border-left: 4px solid #2c7da0;
min-width: 200px;
z-index: 1000;
}
.legend {
text-align: left;
line-height: 20px;
color: #2c3e2f;
background: rgba(255,255,255,0.92);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
font-size: 13px;
font-weight: 500;
z-index: 1000;
}
.legend i {
width: 24px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.85;
border-radius: 3px;
border: 0.5px solid rgba(0,0,0,0.1);
}
.legend strong {
font-size: 13px;
display: block;
margin-bottom: 5px;
color: #1f3b2c;
text-align: center;
border-bottom: 1px dashed #aaa;
}
/* Title note floating di atas peta */
.title-note {
position: fixed;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(0,0,0,0.75);
backdrop-filter: blur(8px);
padding: 8px 20px;
border-radius: 40px;
font-size: 13px;
font-weight: 500;
color: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
pointer-events: none;
white-space: nowrap;
font-family: 'Segoe UI', sans-serif;
}
@media (max-width: 700px) {
.info { font-size: 12px; min-width: 160px; }
.legend { font-size: 11px; }
.title-note { font-size: 11px; padding: 5px 12px; white-space: nowrap; }
}
</style>
</head>
<body>
<div id="map-container">
<div id="map"></div>
</div>
<!-- Data GeoJSON terpisah: pontianak.js -->
<script type="text/javascript" src="pontianak.js"></script>
<script type="text/javascript">
// ==============================
// 1. INISIALISASI PETA
// ==============================
// Koordinat pusat Kota Pontianak (sekitar -0.020, 109.342)
const pontianakCenter = [-0.020, 109.342];
const initialZoom = 12;
const map = L.map('map').setView(pontianakCenter, initialZoom);
// Base map tiles (OpenStreetMap)
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// ==============================
// 2. INFO CONTROL (hover / klik)
// ==============================
const info = L.control();
info.onAdd = function () {
this._div = L.DomUtil.create('div', 'info');
this.update();
return this._div;
};
// Fungsi update info panel (menampilkan data kecamatan & jumlah penduduk)
info.update = function (props) {
let content = '';
if (props) {
// Menggunakan properti "Ket" untuk nama kecamatan, "Penduduk" untuk jumlah penduduk
const kecamatan = props.Ket || props.name || 'Tidak diketahui';
const penduduk = props.Penduduk !== undefined && props.Penduduk !== null ? props.Penduduk.toLocaleString('id-ID') : 'Data tidak tersedia';
content = `<b>🏙️ ${kecamatan}</b><br/>👥 Jumlah Penduduk: <strong>${penduduk}</strong> jiwa`;
} else {
content = 'Arahkan kursor ke wilayah kecamatan';
}
this._div.innerHTML = `<h4>📊 Kependudukan Kota Pontianak</h4>${content}`;
};
info.addTo(map);
// ==============================
// 3. FUNGSI WARNA BERDASARKAN JUMLAH PENDUDUK
// ==============================
// Menentukan warna berdasarkan kelas populasi (disesuaikan dengan data Pontianak)
// Data contoh: Pontianak Timur ~112.440, kita buat rentang sampai >150rb (jika ada)
function getColor(population) {
if (population === undefined || population === null) return '#cccccc'; // abu-abu untuk data kosong
if (population > 150000) return '#4d004b'; // ungu tua
if (population > 100000) return '#810f7c'; // ungu sedang
if (population > 70000) return '#c51b8a'; // magenta
if (population > 50000) return '#de77ae'; // pink
if (population > 30000) return '#f1b6da'; // pink muda
if (population > 10000) return '#fde0ef'; // sangat muda
return '#fff7fb'; // putih kebiruan
}
// Style untuk setiap feature (polygon)
function style(feature) {
const pop = feature?.properties?.Penduduk;
return {
weight: 1.8,
opacity: 0.9,
color: '#2c3e50',
dashArray: null,
fillOpacity: 0.75,
fillColor: getColor(pop)
};
}
// ==============================
// 4. INTERAKSI (HOVER, KLIK ZOOM)
// ==============================
let currentHighlightedLayer = null;
function highlightFeature(e) {
const layer = e.target;
// Reset style layer sebelumnya jika ada (opsional, tapi biar rapi)
if (currentHighlightedLayer && currentHighlightedLayer !== layer) {
geojsonLayer.resetStyle(currentHighlightedLayer);
}
layer.setStyle({
weight: 3.5,
color: '#ffaa33',
dashArray: '',
fillOpacity: 0.85,
fillColor: layer.feature.properties.Penduduk ? getColor(layer.feature.properties.Penduduk) : '#aaa'
});
layer.bringToFront();
// Update info panel dengan properti dari feature
info.update(layer.feature.properties);
currentHighlightedLayer = layer;
}
function resetHighlight(e) {
const layer = e.target;
geojsonLayer.resetStyle(layer);
// Jangan langsung hapus info, tapi update ke default tanpa properti
// Agar tidak menghilang saat mouse out, kita update info ke teks default.
// (opsi: biarkan info terakhir agar tidak kosong, namun lebih baik tampilkan petunjuk)
info.update(null);
currentHighlightedLayer = null;
}
function zoomToFeature(e) {
const layer = e.target;
map.fitBounds(layer.getBounds());
// setelah zoom, tetap tampilkan info
info.update(layer.feature.properties);
}
function onEachFeature(feature, layer) {
// pastikan properti yang diperlukan ada (fallback)
if (!feature.properties) feature.properties = {};
if (!feature.properties.Ket && feature.properties.Plan_Area) {
// alternatif jika nama kecamatan tidak pakai "Ket", bisa pakai "Plan_Area"? Tidak. Tapi kita pastikan
feature.properties.Ket = feature.properties.Ket || 'Kecamatan';
}
layer.on({
mouseover: highlightFeature,
mouseout: resetHighlight,
click: zoomToFeature
});
// Tambahkan tooltip sederhana (opsional) saat hover tanpa harus selalu pakai info
layer.bindTooltip(`${feature.properties.Ket || 'Kecamatan'} : ${(feature.properties.Penduduk || 0).toLocaleString('id-ID')} jiwa`, {
sticky: true,
className: 'custom-tooltip',
offset: [0, -5]
});
}
// ==============================
// 5. LOAD DATA GEOJSON (PONTIANAK.JS)
// ==============================
// Variabel global dari file pontianak.js harus bernama "pontianakData" atau "statesData"?
// Pada contoh dari user, file pontianak.js berisi object JSON langsung (tidak dibungkus var?)
// Tapi agar aman: kita akan deteksi apakah ada variabel global bernama "pontianakData" atau "statesData"
// Berdasarkan instruksi: file pontianak.js memiliki struktur seperti contoh (FeatureCollection) dengan properti Penduduk, Ket.
// Agar kode fleksibel, kita akan menggunakan data dari window jika tersedia.
// Namun user hanya menyebut "pontianak.js ada di file terpisah dengan index.html" dan isinya diawali dengan { ... }
// Biasanya file js berisi: var pontianakGeoJSON = { ... }; atau langsung window.data = ...
// Agar kompatibel, kita baca dari global scope: cek apakah ada variable "pontianakData", "statesData", atau "geoJsonPontianak".
// Kita juga bisa fallback jika file js langsung mengekspos window.pontianakData.
// Karena tidak dipastikan nama variabelnya, kita akan cari object FeatureCollection yang terdaftar.
let geoJsonData = null;
// Cek beberapa kemungkinan nama variabel global dari pontianak.js
if (typeof pontianakData !== 'undefined') {
geoJsonData = pontianakData;
} else if (typeof statesData !== 'undefined') {
// Hati-hati: statesData mungkin dari contoh US, tapi jika user mengganti isi pontianak.js dengan statesData, kita gunakan.
// Namun pastikan propertinya sesuai: cek apakah fiturnya punya "Penduduk"
if (statesData && statesData.features && statesData.features[0] && statesData.features[0].properties &&
(statesData.features[0].properties.Penduduk !== undefined || statesData.features[0].properties.Ket)) {
geoJsonData = statesData;
} else {
console.warn("Variabel statesData tersedia tapi bukan data Pontianak (tidak memiliki Penduduk).");
}
} else if (typeof pontianakGeoJSON !== 'undefined') {
geoJsonData = pontianakGeoJSON;
} else if (typeof dataPontianak !== 'undefined') {
geoJsonData = dataPontianak;
} else {
// Cek apakah ada variabel global lain yang mungkin berisi FeatureCollection dengan properti Penduduk?
// Kita loop properti window untuk debugging, tapi hati-hati performa.
for (let key in window) {
if (window.hasOwnProperty(key) && window[key] && window[key].type === 'FeatureCollection' && window[key].features) {
// cek sampel feature apakah mengandung Penduduk
const sample = window[key].features[0];
if (sample && sample.properties && (sample.properties.Penduduk !== undefined || sample.properties.Ket)) {
geoJsonData = window[key];
console.log(`Data GeoJSON ditemukan melalui variabel global: ${key}`);
break;
}
}
}
}
// Jika masih null, beri pesan error dan tampilkan peta kosong dengan notifikasi
if (!geoJsonData) {
console.error("Gagal memuat data GeoJSON. Pastikan file pontianak.js memiliki variabel global seperti 'pontianakData', 'statesData', atau langsung mengekspos FeatureCollection.");
// Tampilkan overlay peringatan di peta
const errorMsg = L.control({ position: 'bottomleft' });
errorMsg.onAdd = function() {
const div = L.DomUtil.create('div', 'info');
div.style.background = '#d9534f';
div.style.color = 'white';
div.style.fontWeight = 'bold';
div.style.padding = '8px 12px';
div.style.borderRadius = '6px';
div.innerHTML = '⚠️ Data GeoJSON (pontianak.js) tidak ditemukan. Pastikan file tersedia dan berisi variabel global dengan format FeatureCollection.';
return div;
};
errorMsg.addTo(map);
// Tambahkan layer kosong agar peta tetap interaktif
var geojsonLayer = L.geoJson(null).addTo(map);
window.geojsonLayer = geojsonLayer;
} else {
// Validasi dan tambahkan ke peta
try {
// Pastikan koordinat polygon sesuai (EPSG:4326)
const geojsonLayer = L.geoJson(geoJsonData, {
style: style,
onEachFeature: onEachFeature
}).addTo(map);
// Simpan layer ke variabel global untuk reset style
window.geojsonLayer = geojsonLayer;
// Optional: sesuaikan zoom agar pas dengan bounding box seluruh kecamatan Pontianak
if (geojsonLayer.getBounds().isValid()) {
map.fitBounds(geojsonLayer.getBounds());
// sedikit zoom out agar tidak terlalu ketat (opsi)
map.setZoom(map.getZoom() - 0.3);
}
// Tambahkan atribusi sumber data (sesuai kebutuhan)
map.attributionControl.addAttribution('Data Kependudukan & GeoJSON: Pemerintah Kota Pontianak (simulasi)');
} catch (err) {
console.error("Error saat memproses GeoJSON:", err);
const errorControl = L.control({ position: 'bottomleft' });
errorControl.onAdd = function() {
const div = L.DomUtil.create('div', 'info');
div.style.background = '#f0ad4e';
div.innerHTML = '⚠️ Format GeoJSON tidak valid. Periksa struktur pontianak.js.';
return div;
};
errorControl.addTo(map);
}
}
// ==============================
// 6. LEGEND (Berdasarkan Jumlah Penduduk)
// ==============================
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'info legend');
// Rentang populasi sesuai data Pontianak (disesuaikan)
const populationRanges = [
{ min: 0, max: 10000, label: '< 10.000', color: '#fff7fb' },
{ min: 10000, max: 30000, label: '10.000 - 30.000', color: '#fde0ef' },
{ min: 30000, max: 50000, label: '30.000 - 50.000', color: '#f1b6da' },
{ min: 50000, max: 70000, label: '50.000 - 70.000', color: '#de77ae' },
{ min: 70000, max: 100000, label: '70.000 - 100.000', color: '#c51b8a' },
{ min: 100000, max: 150000, label: '100.000 - 150.000', color: '#810f7c' },
{ min: 150000, max: Infinity, label: '> 150.000', color: '#4d004b' }
];
div.innerHTML = '<strong>👥 JUMLAH PENDUDUK</strong><br>';
for (let range of populationRanges) {
const color = range.color;
const label = range.label;
div.innerHTML +=
'<i style="background:' + color + '; width: 24px; height: 18px; display: inline-block; margin-right: 8px; border: 0.5px solid #777;"></i> ' +
label + '<br>';
}
div.innerHTML += '<br><small style="font-size:10px;">Hover/klik polygon untuk detail</small>';
return div;
};
legend.addTo(map);
// Tambahkan kontrol skala (optional)
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
// ==============================
// 7. ADDITIONAL HANDLE: Jika data tidak punya properti 'Ket', coba gunakan 'Plan_Area' atau 'Id'
// ==============================
// Fungsi tambahan: pastikan info bisa membaca properti apapun yang relevan
// Override sedikit agar saat hover info properti bekerja
// sudah dilakukan di onEachFeature dan info.update, tapi kita patch info.update agar lebih fleksibel
const originalUpdate = info.update;
info.update = function(props) {
if (props) {
let name = props.Ket || props.NAMA_KEC || props.name || props.kecamatan || 'Kecamatan Pontianak';
let population = props.Penduduk !== undefined ? props.Penduduk : (props.JUMLAH_PENDUDUK || props.populasi || 0);
if (typeof population === 'string') population = parseInt(population, 10);
if (isNaN(population)) population = 0;
const formattedPop = population.toLocaleString('id-ID');
this._div.innerHTML = `<h4>📊 Data Kependudukan</h4><b>🏘️ ${name}</b><br/>👥 Jumlah Penduduk: <strong>${formattedPop}</strong> jiwa`;
} else {
this._div.innerHTML = `<h4>📊 Kependudukan Kota Pontianak</h4>Arahkan kursor ke wilayah kecamatan`;
}
};
// Jika data belum dimuat karena tidak ada global, kita bisa coba fetch manual? Tidak diperlukan karena menggunakan script tag.
// Tambahkan log sukses jika geojsonLayer berhasil.
setTimeout(() => {
if (window.geojsonLayer && window.geojsonLayer.getLayers().length > 0) {
console.log(`✅ Choropleth Pontianak siap. Jumlah kecamatan yang ditampilkan: ${window.geojsonLayer.getLayers().length}`);
} else if (!geoJsonData) {
console.warn("⚠️ Periksa kembali file pontianak.js. Pastikan file memiliki variabel global seperti: var pontianakData = { type: 'FeatureCollection', features: [...] }");
}
}, 300);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1
Submodule 03PovertyMapping added at 7f555a2494
+494
View File
@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portfolio WebGIS Cindy Dyra Islamay</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--mint:#2ECC8E;--mint-soft:#E6F9F2;--mint-mid:#A8EDD0;
--sky:#4FAAED;--sky-soft:#EAF4FD;
--coral:#FF7E6B;--coral-soft:#FFF0EE;
--amber:#F5A623;--amber-soft:#FEF5E7;
--white:#FFFFFF;--surface:#F7FAFA;--border:#E4EEF0;
--text-primary:#1A2B38;--text-secondary:#4A6274;--text-muted:#8AACBA;
--r-sm:10px;--r-md:16px;--r-lg:24px;--r-xl:32px
}
html{scroll-behavior:smooth}
body{font-family:'Plus Jakarta Sans',sans-serif;background:var(--white);color:var(--text-primary);overflow-x:hidden;line-height:1.6}
/* ── NAV ── */
nav{position:sticky;top:0;z-index:100;background:rgba(255,255,255,0.88);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--border);padding:0 clamp(20px,5vw,80px)}
.nav-inner{max-width:1120px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:60px}
.nav-brand{font-size:15px;font-weight:700;color:var(--text-primary);text-decoration:none;display:flex;align-items:center;gap:8px}
.nav-dot{width:8px;height:8px;border-radius:50%;background:var(--mint)}
.nav-badge{font-size:12px;font-weight:600;color:var(--mint);background:var(--mint-soft);padding:4px 12px;border-radius:100px;border:1px solid var(--mint-mid)}
/* ── HERO ── */
.hero{background:var(--white);padding:80px clamp(20px,5vw,80px) 0;position:relative;overflow:hidden}
.hero::after{content:'';position:absolute;bottom:0;left:0;right:0;height:120px;background:linear-gradient(to bottom,transparent,var(--surface))}
.hero-bg-circle{position:absolute;border-radius:50%;pointer-events:none}
.hero-bg-circle.c1{width:480px;height:480px;background:radial-gradient(circle,#D4F5E8 0%,transparent 70%);top:-80px;right:-60px}
.hero-bg-circle.c2{width:320px;height:320px;background:radial-gradient(circle,#DFF0FC 0%,transparent 70%);bottom:40px;left:-80px}
.hero-inner{max-width:1120px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:center;position:relative;z-index:1}
.hero-text{}
.hero-pill{display:inline-flex;align-items:center;gap:8px;background:var(--mint-soft);color:#1A7A52;font-size:12px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;padding:6px 14px;border-radius:100px;border:1px solid var(--mint-mid);margin-bottom:24px}
.hero-pill-dot{width:6px;height:6px;border-radius:50%;background:var(--mint);animation:blink 2s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.hero-title{font-size:clamp(2rem,4.5vw,3.2rem);font-weight:800;line-height:1.1;color:var(--text-primary);margin-bottom:20px}
.hero-title em{font-style:normal;color:var(--mint)}
.hero-subtitle{font-size:1.05rem;color:var(--text-secondary);line-height:1.75;margin-bottom:36px;max-width:460px}
.hero-identity{display:flex;align-items:center;gap:14px}
.hero-avatar{width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,var(--mint),var(--sky));display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0}
.hero-name{font-size:.95rem;font-weight:700;color:var(--text-primary)}
.hero-nim{font-size:.82rem;color:var(--text-muted);margin-top:2px}
.hero-visual{display:flex;justify-content:center;align-items:center;padding-bottom:40px}
.hero-globe-wrap{position:relative;width:360px;height:360px}
.globe-float{animation:float 6s ease-in-out infinite}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-16px)}}
.globe-ring{position:absolute;border-radius:50%;border:1.5px dashed}
.globe-ring.r1{width:420px;height:420px;top:50%;left:50%;transform:translate(-50%,-50%);border-color:rgba(46,204,142,.15);animation:spin 24s linear infinite}
.globe-ring.r2{width:500px;height:500px;top:50%;left:50%;transform:translate(-50%,-50%);border-color:rgba(79,170,237,.1);animation:spin 36s linear infinite reverse}
@keyframes spin{from{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(360deg)}}
/* ── SECTION BRIDGE ── */
.section-bridge{background:var(--surface);padding:48px clamp(20px,5vw,80px)}
.bridge-inner{max-width:1120px;margin:0 auto;display:flex;gap:24px;flex-wrap:wrap}
.bridge-card{flex:1;min-width:200px;background:var(--white);border-radius:var(--r-md);border:1px solid var(--border);padding:24px;display:flex;align-items:flex-start;gap:16px;transition:transform .25s,box-shadow .25s}
.bridge-card:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(0,80,60,.07)}
.bridge-icon{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.bridge-icon svg{width:22px;height:22px}
.bridge-label{font-size:.8rem;color:var(--text-muted);font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin-bottom:4px}
.bridge-value{font-size:1.1rem;font-weight:700;color:var(--text-primary)}
/* ── PROJECTS SECTION ── */
.projects{background:var(--white);padding:80px clamp(20px,5vw,80px)}
.section-head{text-align:center;margin-bottom:60px}
.section-eyebrow{font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:12px}
.section-title{font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;color:var(--text-primary);line-height:1.2}
.section-desc{font-size:.98rem;color:var(--text-secondary);margin-top:12px;max-width:480px;margin-left:auto;margin-right:auto;line-height:1.7}
/* ── CARDS ── */
.cards{max-width:1120px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:28px}
.card{background:var(--white);border:1px solid var(--border);border-radius:var(--r-xl);overflow:hidden;display:flex;flex-direction:column;transition:transform .3s cubic-bezier(.25,.8,.25,1),box-shadow .3s;opacity:0;transform:translateY(32px)}
.card.in{opacity:1;transform:translateY(0);transition:opacity .55s ease,transform .55s ease,box-shadow .3s,border-color .3s}
.card:hover{transform:translateY(-8px)!important;box-shadow:0 24px 56px rgba(0,0,0,.1)}
.card-thumb{height:180px;position:relative;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.card-thumb-dots{position:absolute;inset:0;background-image:radial-gradient(circle,rgba(255,255,255,.5) 1px,transparent 1px);background-size:20px 20px}
.card-number{position:absolute;top:16px;left:16px;width:32px;height:32px;border-radius:10px;background:rgba(255,255,255,.9);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:800;color:var(--text-primary)}
.card-icon-wrap{width:80px;height:80px;border-radius:24px;background:rgba(255,255,255,.9);display:flex;align-items:center;justify-content:center;position:relative;z-index:1;transition:transform .3s}
.card:hover .card-icon-wrap{transform:scale(1.08) rotate(-3deg)}
.card-icon-wrap svg{width:40px;height:40px}
.card-body{padding:28px;flex:1;display:flex;flex-direction:column}
.card-tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:4px 11px;border-radius:100px;margin-bottom:14px}
.card-title{font-size:1.15rem;font-weight:800;color:var(--text-primary);line-height:1.3;margin-bottom:10px}
.card-desc{font-size:.9rem;color:var(--text-secondary);line-height:1.75;flex:1;margin-bottom:24px}
.card-btn{display:inline-flex;align-items:center;gap:8px;font-size:.88rem;font-weight:700;text-decoration:none;padding:12px 22px;border-radius:100px;transition:gap .2s,opacity .2s;align-self:flex-start}
.card-btn:hover{gap:14px;opacity:.88}
.card-btn svg{width:15px;height:15px;transition:transform .2s}
.card-btn:hover svg{transform:translateX(3px)}
/* card color themes */
.card.theme-green .card-thumb{background:linear-gradient(135deg,#D0F5E6,#A8EDD6)}
.card.theme-green .card-tag{background:#D4F5E8;color:#0E6640}
.card.theme-green .card-btn{background:#2ECC8E;color:#fff}
.card.theme-blue .card-thumb{background:linear-gradient(135deg,#D6EEFF,#B8DAFF)}
.card.theme-blue .card-tag{background:#DFF0FC;color:#1155A0}
.card.theme-blue .card-btn{background:#4FAAED;color:#fff}
.card.theme-coral .card-thumb{background:linear-gradient(135deg,#FFE8E4,#FFCFC8)}
.card.theme-coral .card-tag{background:#FFF0EE;color:#B33922}
.card.theme-coral .card-btn{background:#FF7E6B;color:#fff}
/* ── FEATURES STRIP ── */
.features{background:var(--surface);padding:72px clamp(20px,5vw,80px)}
.features-inner{max-width:1120px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:center}
.features-text .section-eyebrow{text-align:left}
.features-text .section-title{text-align:left}
.features-list{margin-top:32px;display:flex;flex-direction:column;gap:20px}
.feature-item{display:flex;align-items:flex-start;gap:16px}
.feature-check{width:36px;height:36px;border-radius:10px;background:var(--mint-soft);border:1px solid var(--mint-mid);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.feature-check svg{width:18px;height:18px;color:var(--mint)}
.feature-title{font-size:.95rem;font-weight:700;color:var(--text-primary);margin-bottom:3px}
.feature-desc{font-size:.86rem;color:var(--text-secondary);line-height:1.65}
.features-visual{background:var(--white);border-radius:var(--r-xl);border:1px solid var(--border);padding:32px;display:flex;flex-direction:column;gap:16px}
.feat-bar-label{display:flex;justify-content:space-between;align-items:center;font-size:.82rem}
.feat-bar-name{color:var(--text-secondary);font-weight:600}
.feat-bar-val{color:var(--text-primary);font-weight:700}
.feat-bar-track{height:8px;background:#EDF2F5;border-radius:100px;overflow:hidden}
.feat-bar-fill{height:100%;border-radius:100px;transform:scaleX(0);transform-origin:left;transition:transform 1.2s cubic-bezier(.4,0,.2,1)}
.feat-bar-fill.go{transform:scaleX(1)}
.fv-projects{display:flex;flex-direction:column;gap:0}
.fv-row{padding:18px 0}
.fv-divider{height:1px;background:var(--border)}
.fv-row-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.fv-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
.fv-row-name{font-size:.9rem;font-weight:700;color:var(--text-primary)}
.fv-tags{display:flex;flex-wrap:wrap;gap:8px}
.fv-tag{font-size:.78rem;font-weight:600;padding:4px 12px;border-radius:100px;letter-spacing:.02em}
/* ── FOOTER ── */
footer{background:#0f6543;padding:48px clamp(20px,5vw,80px);text-align:center}
.footer-logo{font-size:1.1rem;font-weight:800;color:var(--white);margin-bottom:8px}
.footer-logo span{color:var(--white)}
.footer-sub{font-size:.85rem;color:#ffffff;margin-top:4px}
.footer-line{width:40px;height:2px;background:var(--white);border-radius:2px;margin:20px auto}
/* ── RESPONSIVE ── */
@media(max-width:900px){
.hero-inner{grid-template-columns:1fr;text-align:center;gap:40px}
.hero-identity{justify-content:center}
.hero-subtitle{margin-left:auto;margin-right:auto}
.hero-visual{padding-bottom:0}
.hero-globe-wrap{width:260px;height:260px}
.features-inner{grid-template-columns:1fr}
}
@media(max-width:600px){
.hero{padding-top:52px}
.projects{padding:52px 16px}
.features{padding:52px 16px}
.section-bridge{padding:32px 16px}
.cards{grid-template-columns:1fr;gap:20px}
.bridge-card{min-width:150px}
footer{padding:40px 16px}
}
@media(prefers-reduced-motion:reduce){
.globe-float,.globe-ring,.hero-bg-circle,.feat-bar-fill,.card,.blink{animation:none;transition:none}
.card{opacity:1;transform:none}
}
</style>
</head>
<body>
<!-- NAV -->
<nav>
<div class="nav-inner">
<a class="nav-brand" href="#">
<span class="nav-dot"></span>
WebGIS Portfolio
</a>
<span class="nav-badge">SIG · 2026</span>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div class="hero-bg-circle c1" aria-hidden="true"></div>
<div class="hero-bg-circle c2" aria-hidden="true"></div>
<div class="hero-inner">
<div class="hero-text">
<div class="hero-pill">
<span class="hero-pill-dot"></span>
Sistem Informasi Geografis
</div>
<h1 class="hero-title">Portfolio Proyek<br><em>WebGIS</em></h1>
<p class="hero-subtitle">
Kumpulan aplikasi pemetaan berbasis web yang dikembangkan untuk
memvisualisasikan dan menganalisis data geografis menggunakan
teknologi WebGIS modern.
</p>
<div class="hero-identity">
<div class="hero-avatar">CD</div>
<div>
<div class="hero-name">Cindy Dyra Islamay</div>
<div class="hero-nim">NIM: D1041231045</div>
</div>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="hero-globe-wrap">
<div class="globe-ring r1"></div>
<div class="globe-ring r2"></div>
<svg class="globe-float" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- soft background disc -->
<circle cx="180" cy="180" r="160" fill="#EDF8F3"/>
<circle cx="180" cy="180" r="140" fill="#F5FBF8"/>
<!-- grid lines -->
<ellipse cx="180" cy="180" rx="140" ry="56" stroke="#B0E8CF" stroke-width="1.5" fill="none"/>
<ellipse cx="180" cy="180" rx="140" ry="100" stroke="#B0E8CF" stroke-width="1" fill="none"/>
<line x1="180" y1="40" x2="180" y2="320" stroke="#B0E8CF" stroke-width="1" stroke-dasharray="6,5"/>
<line x1="40" y1="180" x2="320" y2="180" stroke="#B0E8CF" stroke-width="1"/>
<line x1="68" y1="100" x2="292" y2="100" stroke="#C8EEE0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="68" y1="260" x2="292" y2="260" stroke="#C8EEE0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- landmass blobs -->
<path d="M120,140 Q140,120 170,128 Q195,120 205,135 Q220,130 228,148 Q240,155 232,170 Q240,185 225,192 Q215,210 200,202 Q185,215 168,208 Q148,212 138,198 Q118,195 112,180 Q100,165 120,140Z" fill="#A8DDBB" opacity=".9"/>
<path d="M200,150 Q215,144 226,152 Q238,148 242,162 Q252,170 244,180 Q248,192 236,196 Q226,206 216,198 Q204,202 198,190 Q188,182 196,170 Q188,158 200,150Z" fill="#90D5AC" opacity=".8"/>
<path d="M130,200 Q144,190 156,196 Q166,188 175,198 Q185,196 188,208 Q194,218 184,224 Q178,234 166,228 Q154,234 144,224 Q132,222 128,212 Q122,204 130,200Z" fill="#A8DDBB" opacity=".75"/>
<path d="M105,156 Q112,148 122,154 Q130,148 134,160 Q140,168 132,174 Q134,184 124,186 Q114,190 106,182 Q98,176 100,166 Q96,158 105,156Z" fill="#90D5AC" opacity=".7"/>
<!-- pin markers -->
<g filter="url(#pinShadow)">
<circle cx="180" cy="165" r="10" fill="#2ECC8E"/>
<circle cx="180" cy="165" r="5" fill="white"/>
<circle cx="180" cy="165" r="18" fill="#2ECC8E" opacity=".2"/>
</g>
<g>
<circle cx="145" cy="175" r="7" fill="#4FAAED"/>
<circle cx="145" cy="175" r="3.5" fill="white"/>
</g>
<g>
<circle cx="218" cy="170" r="7" fill="#FF7E6B"/>
<circle cx="218" cy="170" r="3.5" fill="white"/>
</g>
<!-- outer border -->
<circle cx="180" cy="180" r="140" stroke="#7DD8B0" stroke-width="2" fill="none"/>
<defs>
<filter id="pinShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#2ECC8E" flood-opacity=".35"/>
</filter>
</defs>
</svg>
</div>
</div>
</div>
</section>
<!-- BRIDGE STRIP -->
<div class="section-bridge">
<div class="bridge-inner">
<div class="bridge-card">
<div class="bridge-icon" style="background:#E6F9F2">
<svg viewBox="0 0 24 24" fill="none" stroke="#2ECC8E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<div>
<div class="bridge-label">Total Proyek</div>
<div class="bridge-value">3 Aplikasi WebGIS</div>
</div>
</div>
<div class="bridge-card">
<div class="bridge-icon" style="background:#EAF4FD">
<svg viewBox="0 0 24 24" fill="none" stroke="#4FAAED" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<div>
<div class="bridge-label">Wilayah Studi</div>
<div class="bridge-value">Kota Pontianak</div>
</div>
</div>
<div class="bridge-card">
<div class="bridge-icon" style="background:#FEF5E7">
<svg viewBox="0 0 24 24" fill="none" stroke="#F5A623" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<div>
<div class="bridge-label">Teknologi</div>
<div class="bridge-value">Leaflet.js · GeoJSON</div>
</div>
</div>
<div class="bridge-card">
<div class="bridge-icon" style="background:#FFF0EE">
<svg viewBox="0 0 24 24" fill="none" stroke="#FF7E6B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div>
<div class="bridge-label">Mata Kuliah</div>
<div class="bridge-value">Sistem Informasi Geografis C</div>
</div>
</div>
</div>
</div>
<!-- PROJECTS -->
<section class="projects">
<div class="section-head">
<div class="section-eyebrow">Koleksi Karya</div>
<h2 class="section-title">Proyek yang Dikembangkan</h2>
<p class="section-desc">Setiap aplikasi dirancang untuk menjawab kebutuhan analisis spasial yang nyata di wilayah Kota Pontianak.</p>
</div>
<div class="cards">
<!-- Card 1 -->
<article class="card theme-green" data-delay="0">
<div class="card-thumb">
<div class="card-thumb-dots"></div>
<span class="card-number">01</span>
<div class="card-icon-wrap">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="#D0F5E6"/>
<path d="M24 12C18.48 12 14 16.48 14 22c0 7.5 10 18 10 18s10-10.5 10-18c0-5.52-4.48-10-10-10zm0 13a3 3 0 1 1 0-6 3 3 0 0 1 0 6z" fill="#2ECC8E"/>
<circle cx="33" cy="14" r="4" fill="#A8EDD0" stroke="#2ECC8E" stroke-width="1.5"/>
<circle cx="15" cy="32" r="3" fill="#A8EDD0" stroke="#2ECC8E" stroke-width="1.5"/>
</svg>
</div>
</div>
<div class="card-body">
<span class="card-tag">Pemetaan Fasilitas</span>
<h3 class="card-title">Mapping SPBU</h3>
<p class="card-desc">Aplikasi WebGIS untuk pemetaan lokasi stasiun pengisian bahan bakar umum (SPBU) dengan fitur visualisasi interaktif dan manajemen data geografis.</p>
<a href="http://localhost/webgis/01SPBU/index.html" class="card-btn" target="_blank" rel="noopener">
Buka Proyek
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
</article>
<!-- Card 2 -->
<article class="card theme-blue" data-delay="120">
<div class="card-thumb">
<div class="card-thumb-dots"></div>
<span class="card-number">02</span>
<div class="card-icon-wrap">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="#D6EEFF"/>
<rect x="10" y="14" width="12" height="10" rx="2" fill="#B8DAFF"/>
<rect x="26" y="10" width="12" height="14" rx="2" fill="#4FAAED"/>
<rect x="10" y="28" width="12" height="10" rx="2" fill="#4FAAED" opacity=".6"/>
<rect x="26" y="28" width="12" height="10" rx="2" fill="#B8DAFF"/>
<line x1="10" y1="38" x2="38" y2="38" stroke="#4FAAED" stroke-width="1.5"/>
</svg>
</div>
</div>
<div class="card-body">
<span class="card-tag">Demografi</span>
<h3 class="card-title">Choropleth Map Kepadatan Penduduk Kota Pontianak</h3>
<p class="card-desc">Visualisasi peta choropleth yang menampilkan distribusi kepadatan penduduk di Kota Pontianak menggunakan gradasi warna untuk analisis demografis.</p>
<a href="http://localhost/webgis/02Cloropeth/index.html" class="card-btn" target="_blank" rel="noopener">
Buka Proyek
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
</article>
<!-- Card 3 -->
<article class="card theme-coral" data-delay="240">
<div class="card-thumb">
<div class="card-thumb-dots"></div>
<span class="card-number">03</span>
<div class="card-icon-wrap">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="#FFE8E4"/>
<circle cx="16" cy="20" r="5" fill="#FF7E6B" opacity=".7"/>
<circle cx="30" cy="16" r="4" fill="#FFCFC8" stroke="#FF7E6B" stroke-width="1.5"/>
<circle cx="26" cy="30" r="4" fill="#FFCFC8" stroke="#FF7E6B" stroke-width="1.5"/>
<line x1="16" y1="20" x2="30" y2="16" stroke="#FF7E6B" stroke-width="1.5" stroke-dasharray="4,3"/>
<line x1="30" y1="16" x2="26" y2="30" stroke="#FF7E6B" stroke-width="1.5" stroke-dasharray="4,3"/>
<line x1="16" y1="20" x2="26" y2="30" stroke="#FF7E6B" stroke-width="1.5" stroke-dasharray="4,3"/>
</svg>
</div>
</div>
<div class="card-body">
<span class="card-tag">Analisis Sosial</span>
<h3 class="card-title">WebGIS Pemetaan Kemiskinan</h3>
<p class="card-desc">Aplikasi WebGIS yang memetakan distribusi spasial rumah tangga berpenghasilan rendah dan menghubungkannya dengan tempat ibadah sebagai pusat distribusi bantuan.</p>
<a href="http://localhost/webgis/03PovertyMapping/login.html" class="card-btn" target="_blank" rel="noopener">
Buka Proyek
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
</article>
</div>
</section>
<!-- FEATURES STRIP -->
<section class="features">
<div class="features-inner">
<div class="features-text">
<div class="section-eyebrow">Kemampuan Teknis</div>
<h2 class="section-title">Dibangun dengan Teknologi WebGIS Modern</h2>
<div class="features-list">
<div class="feature-item">
<div class="feature-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<div class="feature-title">Peta Interaktif Berbasis Web</div>
<div class="feature-desc">Visualisasi data geografis yang dapat dijelajahi langsung di browser tanpa instalasi tambahan.</div>
</div>
</div>
<div class="feature-item">
<div class="feature-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<div class="feature-title">Analisis Data Spasial</div>
<div class="feature-desc">Pengolahan dan interpretasi data geografis untuk menghasilkan wawasan berbasis lokasi.</div>
</div>
</div>
<div class="feature-item">
<div class="feature-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<div class="feature-title">Desain Responsif &amp; Aksesibel</div>
<div class="feature-desc">Antarmuka yang menyesuaikan tampilan di berbagai ukuran layar dari desktop hingga ponsel.</div>
</div>
</div>
</div>
</div>
<div class="features-visual">
<p style="font-size:.82rem;font-weight:700;color:var(--text-muted);letter-spacing:.06em;text-transform:uppercase;margin-bottom:20px">Stack Teknologi per Proyek</p>
<div class="fv-projects">
<div class="fv-row">
<div class="fv-row-header">
<span class="fv-dot" style="background:#2ECC8E"></span>
<span class="fv-row-name">Mapping SPBU</span>
</div>
<div class="fv-tags">
<span class="fv-tag" style="background:#E6F9F2;color:#0E6640">Leaflet.js</span>
<span class="fv-tag" style="background:#E6F9F2;color:#0E6640">GeoJSON</span>
<span class="fv-tag" style="background:#E6F9F2;color:#0E6640">Layer Control</span>
</div>
</div>
<div class="fv-divider"></div>
<div class="fv-row">
<div class="fv-row-header">
<span class="fv-dot" style="background:#4FAAED"></span>
<span class="fv-row-name">Choropleth Kepadatan</span>
</div>
<div class="fv-tags">
<span class="fv-tag" style="background:#EAF4FD;color:#1155A0">Leaflet.js</span>
<span class="fv-tag" style="background:#EAF4FD;color:#1155A0">Choropleth</span>
<span class="fv-tag" style="background:#EAF4FD;color:#1155A0">GeoJSON</span>
</div>
</div>
<div class="fv-divider"></div>
<div class="fv-row">
<div class="fv-row-header">
<span class="fv-dot" style="background:#FF7E6B"></span>
<span class="fv-row-name">Pemetaan Kemiskinan</span>
</div>
<div class="fv-tags">
<span class="fv-tag" style="background:#FFF0EE;color:#B33922">Leaflet.js</span>
<span class="fv-tag" style="background:#FFF0EE;color:#B33922">Chart.js</span>
<span class="fv-tag" style="background:#FFF0EE;color:#B33922">GeoJSON</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FOOTER -->
<footer>
<div class="footer-logo">WebGIS<span>.</span></div>
<div class="footer-sub">Cindy Dyra Islamay · NIM D1041231045</div>
<div class="footer-line"></div>
<div style="font-size:.82rem;color:#ffffff">© 2026 Portfolio Proyek Sistem Informasi Geografis</div>
</footer>
<script>
(function(){
// card entrance
var cards=document.querySelectorAll('.card');
var io=new IntersectionObserver(function(entries){
entries.forEach(function(e){
if(!e.isIntersecting)return;
var d=parseInt(e.target.dataset.delay||0);
setTimeout(function(){e.target.classList.add('in')},d);
io.unobserve(e.target);
});
},{threshold:0.1});
cards.forEach(function(c){io.observe(c)});
// nav shadow on scroll
var nav=document.querySelector('nav');
window.addEventListener('scroll',function(){
nav.style.boxShadow=window.scrollY>10?'0 2px 16px rgba(0,60,40,.07)':'none';
},{passive:true});
})();
</script>
</body>
</html>