fitur pencarian
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+552
-12
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sistem GIS Manajemen SPBU</title>
|
||||
<title>Sistem GIS</title>
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
@@ -38,6 +38,11 @@
|
||||
.icon-btn { width: 2.75rem; height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 0.9rem; border: 1px solid rgba(203,213,225,.9); background: rgba(255,255,255,.92); backdrop-filter: blur(14px); transition: .18s ease; }
|
||||
.icon-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
|
||||
.icon-btn.active { background: #4f46e5; color: white; border-color: #4f46e5; }
|
||||
.registry-card { box-shadow: 0 18px 45px rgba(15, 23, 42, 0.14); }
|
||||
.registry-list { max-height: min(56vh, 520px); overflow-y: auto; }
|
||||
.registry-item { transition: .18s ease; }
|
||||
.registry-item:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1); }
|
||||
.registry-chip { display: inline-flex; align-items: center; gap: .35rem; border-radius: 9999px; padding: .25rem .6rem; font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 font-sans">
|
||||
@@ -48,12 +53,17 @@
|
||||
<div class="absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] w-[300px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-2xl backdrop-blur-md md:w-[340px]">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-black tracking-tight text-indigo-700">GIS SPBU</h1>
|
||||
<h1 class="text-lg font-black tracking-tight text-indigo-700">GIS</h1>
|
||||
<p id="modeHint" class="mt-1 text-xs text-slate-500">Mode default hanya menampilkan peta dan atribut data yang sudah dibuat.</p>
|
||||
</div>
|
||||
<button onclick="loadMarkers({ force: true }); loadFeatures(true);" class="icon-btn text-indigo-600" title="Refresh data">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21h5v-5"/></svg>
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleInputRegistry()" class="icon-btn text-slate-700" title="Buka registry input">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M8 8h11"/><path d="M8 12h11"/><path d="M8 16h7"/></svg>
|
||||
</button>
|
||||
<button onclick="loadMarkers({ force: true }); loadFeatures(true);" class="icon-btn text-indigo-600" title="Refresh data">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21h5v-5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="connectionStatus" class="mt-2 text-[10px] font-bold uppercase tracking-wider text-amber-500">Mengecek koneksi ke database...</div>
|
||||
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
@@ -73,6 +83,9 @@
|
||||
<button class="icon-btn" id="modePolygonBtn" title="Tambah polygon parsil">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 5v10l-8 5-8-5V7z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="modeChoroplethBtn" title="Lihat peta choropleth kecamatan">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2z"/><path d="M9 4v14"/><path d="M15 6v14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="spatialControls" class="mt-3 hidden rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<div id="statusControlWrap">
|
||||
@@ -88,9 +101,83 @@
|
||||
<button id="clearDraftBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Bersih</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500">
|
||||
<span class="rounded-full bg-green-50 px-3 py-1 text-green-700">Buka 24 Jam</span>
|
||||
<span class="rounded-full bg-red-50 px-3 py-1 text-red-700">Jam Terbatas</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="inputRegistryPanel" class="absolute right-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] hidden w-[320px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md registry-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-black tracking-tight text-slate-800">Registry Input</h2>
|
||||
<p class="mt-1 text-[11px] text-slate-500">Daftar input yang bisa difilter.</p>
|
||||
</div>
|
||||
<button id="closeInputRegistryBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup registry">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2">
|
||||
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500">Tercatat</span>
|
||||
<span id="inputRegistryCount" class="text-sm font-black text-indigo-600">0</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Nama / Atribut</label>
|
||||
<input id="inputSearchQuery" type="text" placeholder="Cari nama, kategori, nomor..." class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis</label>
|
||||
<select id="inputTypeFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="all">Semua</option>
|
||||
<option value="point">Poin</option>
|
||||
<option value="polyline">Polyline</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Dari</label>
|
||||
<input id="inputDateFrom" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Sampai</label>
|
||||
<input id="inputDateTo" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button id="applyInputFilterBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Terapkan</button>
|
||||
<button id="clearInputFilterBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Reset</button>
|
||||
</div>
|
||||
|
||||
<div id="inputRegistryList" class="registry-list mt-3 space-y-2 pr-1">
|
||||
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||||
Memuat data input...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="choroplethPanel" class="absolute right-6 bottom-6 z-[2000] hidden w-[320px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-black tracking-tight text-slate-800">Choropleth Kecamatan Pontianak</h3>
|
||||
<p class="mt-1 text-[11px] text-slate-500">Klik kecamatan untuk melihat jumlah penduduk.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-indigo-100 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-indigo-700">Interaktif</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div id="choroplethName" class="text-xs font-bold uppercase tracking-wider text-slate-500">Belum dipilih</div>
|
||||
<div id="choroplethPopulation" class="mt-1 text-xl font-black text-slate-800">-</div>
|
||||
<div id="choroplethDensity" class="mt-1 text-xs text-slate-500">Klik salah satu kecamatan di peta.</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-600">
|
||||
<div class="rounded-lg bg-[#fee2e2] px-2 py-1">< 80.000 jiwa</div>
|
||||
<div class="rounded-lg bg-[#fecaca] px-2 py-1">80.000 - 100.000</div>
|
||||
<div class="rounded-lg bg-[#fca5a5] px-2 py-1">100.000 - 120.000</div>
|
||||
<div class="rounded-lg bg-[#f87171] px-2 py-1">> 120.000 jiwa</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,9 +265,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Area Peta -->
|
||||
<div id="map" class="shadow-lg border border-slate-200 bg-slate-200"></div>
|
||||
|
||||
<select id="featureType" class="hidden">
|
||||
<option value="polyline">polyline</option>
|
||||
<option value="polygon">polygon</option>
|
||||
@@ -210,7 +294,11 @@
|
||||
let pendingShape = null;
|
||||
let pendingNewFeatureLayer = null;
|
||||
let featureLayerMap = {};
|
||||
let locationLayerMap = {};
|
||||
let locationRecords = [];
|
||||
let featureRecords = [];
|
||||
let editingFeatureId = null;
|
||||
let choroplethLayer = null;
|
||||
|
||||
const FEATURE_STATUS_OPTIONS = {
|
||||
polyline: [
|
||||
@@ -226,6 +314,186 @@
|
||||
]
|
||||
};
|
||||
|
||||
const CHOROPLETH_GEOJSON_PATH = 'data/pontianak-kecamatan.geojson';
|
||||
let choroplethGeoJsonData = null;
|
||||
|
||||
function getPopulationFill(population) {
|
||||
if (population > 120000) return '#ef4444';
|
||||
if (population > 100000) return '#f87171';
|
||||
if (population > 80000) return '#fca5a5';
|
||||
return '#fecaca';
|
||||
}
|
||||
|
||||
function getChoroplethValue(properties) {
|
||||
if (properties.population != null) {
|
||||
return { key: 'population', label: 'Populasi', value: Number(properties.population || 0), unit: 'jiwa' };
|
||||
}
|
||||
|
||||
if (properties.density != null) {
|
||||
return { key: 'density', label: 'Kepadatan', value: Number(properties.density || 0), unit: 'jiwa/km²' };
|
||||
}
|
||||
|
||||
return { key: 'none', label: 'Nilai', value: 0, unit: '' };
|
||||
}
|
||||
|
||||
function resetChoroplethPanel() {
|
||||
document.getElementById('choroplethName').innerText = 'Belum dipilih';
|
||||
document.getElementById('choroplethPopulation').innerText = '-';
|
||||
document.getElementById('choroplethDensity').innerText = 'Klik salah satu kecamatan di peta.';
|
||||
}
|
||||
|
||||
function updateChoroplethPanel(properties) {
|
||||
const mainValue = getChoroplethValue(properties);
|
||||
const population = Number(properties.population || 0);
|
||||
const area = Number(properties.area_km2 || 0);
|
||||
const density = area > 0 ? population / area : 0;
|
||||
|
||||
document.getElementById('choroplethName').innerText = properties.name || properties.NAME || 'Tanpa nama';
|
||||
document.getElementById('choroplethPopulation').innerText = `${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}`;
|
||||
|
||||
if (area > 0 && population > 0) {
|
||||
document.getElementById('choroplethDensity').innerText = `Kepadatan: ${density.toLocaleString('id-ID', { maximumFractionDigits: 0 })} jiwa/km²`;
|
||||
} else {
|
||||
document.getElementById('choroplethDensity').innerText = `${mainValue.label} ditampilkan dari properti GeoJSON.`;
|
||||
}
|
||||
}
|
||||
|
||||
function clearChoroplethLayer() {
|
||||
if (choroplethLayer) {
|
||||
map.removeLayer(choroplethLayer);
|
||||
choroplethLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRingArea(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let area = 0;
|
||||
for (let index = 0; index < ring.length; index += 1) {
|
||||
const [x1, y1] = ring[index];
|
||||
const [x2, y2] = ring[(index + 1) % ring.length];
|
||||
area += (x1 * y2) - (x2 * y1);
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
function getPolygonPartArea(part) {
|
||||
const outerRing = Array.isArray(part) ? part[0] : null;
|
||||
return getRingArea(outerRing);
|
||||
}
|
||||
|
||||
function sanitizeChoroplethFeature(feature) {
|
||||
const geometry = feature.geometry || {};
|
||||
|
||||
if (geometry.type !== 'MultiPolygon' || !Array.isArray(geometry.coordinates)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
const partsWithArea = geometry.coordinates
|
||||
.map(part => ({ part, area: getPolygonPartArea(part) }))
|
||||
.filter(entry => entry.area > 0)
|
||||
.sort((left, right) => right.area - left.area);
|
||||
|
||||
if (partsWithArea.length <= 1) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
const largestArea = partsWithArea[0].area;
|
||||
const filteredParts = partsWithArea
|
||||
.filter(entry => entry.area >= largestArea * 0.1)
|
||||
.map(entry => entry.part);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
geometry: {
|
||||
...geometry,
|
||||
coordinates: filteredParts.length > 0 ? filteredParts : [partsWithArea[0].part]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeChoroplethGeoJson(data) {
|
||||
return {
|
||||
...data,
|
||||
features: data.features.map(sanitizeChoroplethFeature)
|
||||
};
|
||||
}
|
||||
|
||||
async function loadChoroplethGeoJson() {
|
||||
if (choroplethGeoJsonData) {
|
||||
return choroplethGeoJsonData;
|
||||
}
|
||||
|
||||
const response = await fetch(CHOROPLETH_GEOJSON_PATH, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gagal memuat ${CHOROPLETH_GEOJSON_PATH} (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
|
||||
throw new Error('Format GeoJSON tidak valid, harus FeatureCollection.');
|
||||
}
|
||||
|
||||
choroplethGeoJsonData = sanitizeChoroplethGeoJson(data);
|
||||
return choroplethGeoJsonData;
|
||||
}
|
||||
|
||||
async function buildChoroplethLayer() {
|
||||
clearChoroplethLayer();
|
||||
|
||||
const geoJsonData = await loadChoroplethGeoJson();
|
||||
|
||||
choroplethLayer = L.geoJSON(geoJsonData, {
|
||||
style: feature => ({
|
||||
color: '#ffffff',
|
||||
weight: 2,
|
||||
fillColor: getPopulationFill(Number(getChoroplethValue(feature.properties || {}).value || 0)),
|
||||
fillOpacity: 0.72
|
||||
}),
|
||||
onEachFeature: (feature, layer) => {
|
||||
const props = feature.properties || {};
|
||||
const districtName = props.name || props.NAME || 'Tanpa nama';
|
||||
|
||||
layer.bindTooltip(districtName, { sticky: true });
|
||||
layer.on('mouseover', () => {
|
||||
layer.setStyle({ weight: 3, fillOpacity: 0.86, color: '#1f2937' });
|
||||
});
|
||||
layer.on('mouseout', () => {
|
||||
choroplethLayer.resetStyle(layer);
|
||||
});
|
||||
layer.on('click', () => {
|
||||
const mainValue = getChoroplethValue(props);
|
||||
|
||||
updateChoroplethPanel(props);
|
||||
layer.bindPopup(`<div class="p-1"><strong>${districtName}</strong><br>${mainValue.label}: ${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}</div>`).openPopup();
|
||||
});
|
||||
}
|
||||
}).addTo(map);
|
||||
|
||||
map.fitBounds(choroplethLayer.getBounds(), { padding: [20, 20] });
|
||||
resetChoroplethPanel();
|
||||
}
|
||||
|
||||
async function toggleChoroplethMode(enabled) {
|
||||
const panel = document.getElementById('choroplethPanel');
|
||||
|
||||
panel.classList.toggle('hidden', !enabled);
|
||||
if (enabled) {
|
||||
try {
|
||||
await buildChoroplethLayer();
|
||||
} catch (error) {
|
||||
showMessage(error.message || 'Gagal memuat peta choropleth', 'bg-red-600 text-white');
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearChoroplethLayer();
|
||||
}
|
||||
|
||||
function getFeatureStatusColor(type, status) {
|
||||
const options = FEATURE_STATUS_OPTIONS[type] || [];
|
||||
const found = options.find(item => item.value === status);
|
||||
@@ -240,6 +508,220 @@
|
||||
return `${value.toFixed(2)} m²`;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
if (!value) return null;
|
||||
const normalized = String(value).replace(' ', 'T');
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatInputDate(value) {
|
||||
const date = parseDateOnly(value);
|
||||
if (!date) return 'Tanggal tidak tersedia';
|
||||
|
||||
return new Intl.DateTimeFormat('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getRecordDateKey(record) {
|
||||
const date = parseDateOnly(record && record.created_at);
|
||||
if (!date) return null;
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function enforceRegistryDateRange(showFeedback = false) {
|
||||
const fromEl = document.getElementById('inputDateFrom');
|
||||
const toEl = document.getElementById('inputDateTo');
|
||||
const from = fromEl.value;
|
||||
const to = toEl.value;
|
||||
|
||||
// Batasi tanggal akhir agar tidak boleh sebelum tanggal awal.
|
||||
toEl.min = from || '';
|
||||
|
||||
if (from && to && to < from) {
|
||||
toEl.value = from;
|
||||
if (showFeedback) {
|
||||
showMessage('Tanggal akhir disesuaikan ke tanggal awal', 'bg-slate-700 text-white');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRegistryFilters() {
|
||||
return {
|
||||
search: document.getElementById('inputSearchQuery').value.toLowerCase().trim(),
|
||||
type: document.getElementById('inputTypeFilter').value,
|
||||
from: document.getElementById('inputDateFrom').value,
|
||||
to: document.getElementById('inputDateTo').value
|
||||
};
|
||||
}
|
||||
|
||||
function recordMatchesFilters(record, filters) {
|
||||
// Filter berdasarkan pencarian
|
||||
if (filters.search) {
|
||||
const searchQuery = filters.search;
|
||||
const matchesSearch =
|
||||
(record.title || '').toLowerCase().includes(searchQuery) ||
|
||||
(record.subtitle || '').toLowerCase().includes(searchQuery) ||
|
||||
(record.status || '').toLowerCase().includes(searchQuery) ||
|
||||
String(record.id).includes(searchQuery);
|
||||
|
||||
if (!matchesSearch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.type !== 'all' && record.type !== filters.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dateKey = getRecordDateKey(record);
|
||||
if (!filters.from && !filters.to) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!dateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.from && dateKey < filters.from) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.to && dateKey > filters.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRegistryRecords() {
|
||||
const pointRecords = locationRecords.map(loc => ({
|
||||
id: Number(loc.id),
|
||||
type: 'point',
|
||||
title: loc.nama || 'Tanpa nama',
|
||||
subtitle: loc.nomor || '-',
|
||||
status: loc.buka_24jam || '-',
|
||||
created_at: loc.created_at || null
|
||||
}));
|
||||
|
||||
const spatialRecords = featureRecords.map(feature => ({
|
||||
id: Number(feature.id),
|
||||
type: feature.type,
|
||||
title: feature.nama_objek || 'Tanpa nama',
|
||||
subtitle: feature.kategori || '-',
|
||||
status: feature.status_objek || '-',
|
||||
created_at: feature.created_at || null
|
||||
}));
|
||||
|
||||
return [...pointRecords, ...spatialRecords].sort((left, right) => {
|
||||
const leftDate = parseDateOnly(left.created_at);
|
||||
const rightDate = parseDateOnly(right.created_at);
|
||||
|
||||
if (!leftDate && !rightDate) return right.id - left.id;
|
||||
if (!leftDate) return 1;
|
||||
if (!rightDate) return -1;
|
||||
return rightDate - leftDate;
|
||||
});
|
||||
}
|
||||
|
||||
function getRegistryTypeLabel(type) {
|
||||
if (type === 'point') return 'Poin';
|
||||
if (type === 'polyline') return 'Polyline';
|
||||
if (type === 'polygon') return 'Polygon';
|
||||
return 'Data';
|
||||
}
|
||||
|
||||
function getRegistryTypeStyle(type) {
|
||||
if (type === 'point') return 'bg-emerald-100 text-emerald-700';
|
||||
if (type === 'polyline') return 'bg-indigo-100 text-indigo-700';
|
||||
if (type === 'polygon') return 'bg-amber-100 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
|
||||
function focusRegistryItem(type, id) {
|
||||
if (type === 'point') {
|
||||
const marker = locationLayerMap[id];
|
||||
if (!marker) return;
|
||||
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
|
||||
marker.openPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const layer = featureLayerMap[id];
|
||||
if (!layer) return;
|
||||
if (layer.getBounds) {
|
||||
map.fitBounds(layer.getBounds(), { padding: [40, 40] });
|
||||
}
|
||||
layer.openPopup();
|
||||
}
|
||||
|
||||
function renderInputRegistry() {
|
||||
const listEl = document.getElementById('inputRegistryList');
|
||||
const countEl = document.getElementById('inputRegistryCount');
|
||||
const filters = getRegistryFilters();
|
||||
const records = buildRegistryRecords().filter(record => recordMatchesFilters(record, filters));
|
||||
|
||||
countEl.innerText = records.length;
|
||||
|
||||
if (!records.length) {
|
||||
listEl.innerHTML = `
|
||||
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||||
Tidak ada input yang sesuai dengan filter.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = records.map(record => {
|
||||
const typeLabel = getRegistryTypeLabel(record.type);
|
||||
const typeClass = getRegistryTypeStyle(record.type);
|
||||
const dateLabel = formatInputDate(record.created_at);
|
||||
const measure = record.type === 'point'
|
||||
? `Telp: ${record.subtitle}`
|
||||
: `Kategori: ${record.subtitle}`;
|
||||
const statusLabel = record.type === 'point'
|
||||
? `24 Jam: ${record.status}`
|
||||
: `Status: ${record.status}`;
|
||||
|
||||
return `
|
||||
<button type="button" onclick="focusRegistryItem('${record.type}', ${record.id})" class="registry-item w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-left shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="registry-chip ${typeClass}">${typeLabel}</span>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-slate-400">ID ${record.id}</span>
|
||||
</div>
|
||||
<h3 class="mt-1 truncate text-sm font-semibold text-slate-800">${record.title}</h3>
|
||||
<p class="mt-1 text-[11px] text-slate-500">${measure}</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-500">${statusLabel}</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
<div>Tanggal</div>
|
||||
<div class="mt-1 max-w-[110px] normal-case tracking-normal text-slate-600">${dateLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleInputRegistry(forceState = null) {
|
||||
const panel = document.getElementById('inputRegistryPanel');
|
||||
const shouldOpen = forceState === null ? panel.classList.contains('hidden') : !!forceState;
|
||||
|
||||
panel.classList.toggle('hidden', !shouldOpen);
|
||||
if (shouldOpen) {
|
||||
renderInputRegistry();
|
||||
}
|
||||
}
|
||||
|
||||
function getFeatureOptions(type) {
|
||||
return FEATURE_STATUS_OPTIONS[type] || [];
|
||||
}
|
||||
@@ -282,7 +764,8 @@
|
||||
view: document.getElementById('modeViewBtn'),
|
||||
point: document.getElementById('modePointBtn'),
|
||||
polyline: document.getElementById('modePolylineBtn'),
|
||||
polygon: document.getElementById('modePolygonBtn')
|
||||
polygon: document.getElementById('modePolygonBtn'),
|
||||
choropleth: document.getElementById('modeChoroplethBtn')
|
||||
};
|
||||
|
||||
Object.values(modeButtons).forEach(button => button.classList.remove('active'));
|
||||
@@ -290,6 +773,8 @@
|
||||
modeButtons[mode].classList.add('active');
|
||||
}
|
||||
|
||||
toggleChoroplethMode(mode === 'choropleth');
|
||||
|
||||
if (mode === 'view') {
|
||||
hint.innerText = 'Mode default: hanya melihat peta dan atribut data yang sudah dibuat.';
|
||||
spatialControls.classList.add('hidden');
|
||||
@@ -301,6 +786,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'choropleth') {
|
||||
hint.innerText = 'Mode choropleth aktif: klik kecamatan untuk melihat populasi.';
|
||||
spatialControls.classList.add('hidden');
|
||||
runActionBtn.innerText = 'Choropleth Aktif';
|
||||
runActionBtn.disabled = true;
|
||||
clearDraftBtn.disabled = true;
|
||||
updateDrawControl(null);
|
||||
document.getElementById('modeMeasure').value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
runActionBtn.disabled = false;
|
||||
clearDraftBtn.disabled = false;
|
||||
|
||||
@@ -529,6 +1025,7 @@
|
||||
throw new Error('Format data fitur tidak valid');
|
||||
}
|
||||
|
||||
featureRecords = data;
|
||||
featureLayer.clearLayers();
|
||||
featureLayerMap = {};
|
||||
|
||||
@@ -552,6 +1049,7 @@
|
||||
layer.bindPopup(getFeaturePopup(feature));
|
||||
}
|
||||
});
|
||||
renderInputRegistry();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Gagal memuat fitur spasial:', err);
|
||||
@@ -810,6 +1308,8 @@
|
||||
if (data.status === 'success') {
|
||||
if (layer) featureLayer.removeLayer(layer);
|
||||
delete featureLayerMap[id];
|
||||
featureRecords = featureRecords.filter(feature => Number(feature.id) !== Number(id));
|
||||
renderInputRegistry();
|
||||
if (editingFeatureId === id) editingFeatureId = null;
|
||||
closeFeatureEditor();
|
||||
showMessage('🗑️ Fitur berhasil dihapus', 'bg-orange-600 text-white');
|
||||
@@ -931,6 +1431,7 @@
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) map.removeLayer(layer);
|
||||
});
|
||||
locationLayerMap = {};
|
||||
}
|
||||
|
||||
function createSnapshotHash(locations) {
|
||||
@@ -975,6 +1476,7 @@
|
||||
throw new Error('Format data bukan array');
|
||||
}
|
||||
|
||||
locationRecords = data;
|
||||
const currentHash = createSnapshotHash(data);
|
||||
if (!force && currentHash === latestSnapshotHash) {
|
||||
if (!silent) {
|
||||
@@ -1000,6 +1502,8 @@
|
||||
icon: createColoredIcon(color),
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
locationLayerMap[loc.id] = marker;
|
||||
marker.locationData = loc;
|
||||
|
||||
// Event Drag & Drop untuk Update Lokasi
|
||||
marker.on('dragend', function(event) {
|
||||
@@ -1010,6 +1514,8 @@
|
||||
marker.bindPopup(getPopupContent(loc));
|
||||
});
|
||||
|
||||
renderInputRegistry();
|
||||
|
||||
setConnectionStatus("✅ Terhubung ke database", "text-green-600");
|
||||
} catch (e) {
|
||||
console.error("JSON Parse Error:", e, "Raw text:", text);
|
||||
@@ -1237,6 +1743,7 @@
|
||||
|
||||
window.onload = function () {
|
||||
setDataMode('view');
|
||||
enforceRegistryDateRange(false);
|
||||
loadMarkers({ force: true });
|
||||
loadFeatures(true);
|
||||
startAutoRefresh();
|
||||
@@ -1246,6 +1753,7 @@
|
||||
document.getElementById('modePointBtn').addEventListener('click', () => setDataMode('point'));
|
||||
document.getElementById('modePolylineBtn').addEventListener('click', () => setDataMode('polyline'));
|
||||
document.getElementById('modePolygonBtn').addEventListener('click', () => setDataMode('polygon'));
|
||||
document.getElementById('modeChoroplethBtn').addEventListener('click', () => setDataMode('choropleth'));
|
||||
document.getElementById('drawStatus').addEventListener('change', () => {
|
||||
const type = document.getElementById('featureType').value;
|
||||
document.getElementById('featureStatus').value = document.getElementById('drawStatus').value;
|
||||
@@ -1282,6 +1790,38 @@
|
||||
const id = Number(document.getElementById('editFeatureId').value);
|
||||
deleteFeature(id);
|
||||
});
|
||||
document.getElementById('closeInputRegistryBtn').addEventListener('click', () => toggleInputRegistry(false));
|
||||
document.getElementById('inputTypeFilter').addEventListener('change', renderInputRegistry);
|
||||
document.getElementById('inputDateFrom').addEventListener('change', () => {
|
||||
enforceRegistryDateRange(true);
|
||||
renderInputRegistry();
|
||||
});
|
||||
document.getElementById('inputDateTo').addEventListener('change', () => {
|
||||
enforceRegistryDateRange(true);
|
||||
renderInputRegistry();
|
||||
});
|
||||
document.getElementById('applyInputFilterBtn').addEventListener('click', () => {
|
||||
enforceRegistryDateRange(true);
|
||||
renderInputRegistry();
|
||||
});
|
||||
document.getElementById('clearInputFilterBtn').addEventListener('click', () => {
|
||||
document.getElementById('inputSearchQuery').value = '';
|
||||
document.getElementById('inputTypeFilter').value = 'all';
|
||||
document.getElementById('inputDateFrom').value = '';
|
||||
document.getElementById('inputDateTo').value = '';
|
||||
enforceRegistryDateRange(false);
|
||||
renderInputRegistry();
|
||||
});
|
||||
|
||||
// Event listener untuk pencarian real-time
|
||||
document.getElementById('inputSearchQuery').addEventListener('input', () => {
|
||||
renderInputRegistry();
|
||||
});
|
||||
|
||||
// Event listener untuk filter tipe (instant filtering)
|
||||
document.getElementById('inputTypeFilter').addEventListener('change', () => {
|
||||
renderInputRegistry();
|
||||
});
|
||||
document.getElementById('featureModal').addEventListener('click', (event) => {
|
||||
if (event.target.id === 'featureModal') closeFeatureEditor();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user