const { useState, useEffect, useRef, useMemo } = React; dayjs.extend(window.dayjs_plugin_relativeTime); const CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other']; const SEVERITIES = ['high','medium','low']; const STATUSES = ['submitted','in_progress','fixed']; const SEVERITY_COLOR = { high:'#D32F2F', medium:'#F57C00', low:'#388E3C' }; const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' }; function fetchJSON(path){ return fetch(path).then(r=>r.json()); } function useI18n(initialLang='en'){ const [lang,setLang] = useState(localStorage.getItem('lang') || initialLang); const [map,setMap] = useState({en:null,ms:null}); useEffect(()=>{ Promise.all([fetchJSON('./i18n/en.json'), fetchJSON('./i18n/ms.json')]) .then(([en,ms])=> setMap({en,ms})) .catch(err=> console.error('i18n load',err)); },[]); const t = (key)=> (map[lang] && map[lang][key]) || (map['en'] && map['en'][key]) || key; useEffect(()=> localStorage.setItem('lang',lang),[lang]); return {lang,setLang,t,i18nMap:map}; } function App(){ const {lang,setLang,t,i18nMap} = useI18n(); const [rawData,setRawData] = useState([]); const [loading,setLoading] = useState(true); const defaultFrom = dayjs().subtract(30,'day').format('YYYY-MM-DD'); const defaultTo = dayjs().format('YYYY-MM-DD'); // form state const [formCategories,setFormCategories] = useState(new Set(CATEGORY_LIST)); const [formSeverities,setFormSeverities] = useState(new Set(SEVERITIES)); const [formStatuses,setFormStatuses] = useState(new Set(STATUSES)); const [formFrom,setFormFrom] = useState(defaultFrom); const [formTo,setFormTo] = useState(defaultTo); // applied filters const [appliedFilters,setAppliedFilters] = useState({ categories:new Set(CATEGORY_LIST), severities:new Set(SEVERITIES), statuses:new Set(STATUSES), from:defaultFrom, to:defaultTo }); const [filtered,setFiltered] = useState([]); const [selected,setSelected] = useState(null); const mapRef = useRef(null); const markersRef = useRef(null); const heatRef = useRef(null); const mapContainerRef = useRef(null); const [heatEnabled,setHeatEnabled] = useState(false); useEffect(()=>{ fetchJSON('./data/demo-reports.json').then(data=>{ setRawData(data); setLoading(false); }).catch(err=>{ console.error(err); setLoading(false); }); },[]); useEffect(()=>{ // init map once const map = L.map('map', { center:[3.1390,101.6869], zoom:12, preferCanvas:true }); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map); mapRef.current = map; markersRef.current = L.markerClusterGroup(); map.addLayer(markersRef.current); return ()=> { map.remove(); mapRef.current=null; markersRef.current=null; }; },[]); // compute filtered when rawData or appliedFilters change useEffect(()=>{ if(!rawData) return; const from = dayjs(appliedFilters.from).startOf('day'); const to = dayjs(appliedFilters.to).endOf('day'); const out = rawData.filter(r=>{ if(!r || !r.createdAt) return false; const created = dayjs(r.createdAt); if(created.isBefore(from) || created.isAfter(to)) return false; if(!appliedFilters.categories.has(r.category)) return false; if(!appliedFilters.severities.has(r.severity)) return false; if(!appliedFilters.statuses.has(r.status)) return false; return true; }); setFiltered(out); },[rawData, appliedFilters]); // update markers and heatmap when filtered changes useEffect(()=>{ const map = mapRef.current; const markersLayer = markersRef.current; if(!map || !markersLayer) return; markersLayer.clearLayers(); if(filtered.length === 0){ // remove heat if present if(heatRef.current){ heatRef.current.remove(); heatRef.current = null; } map.setView([3.1390,101.6869],12); const container = document.querySelector('.map-panel'); if(container) container.classList.add('no-reports'); return; } else { const container = document.querySelector('.map-panel'); if(container) container.classList.remove('no-reports'); } const bounds = []; filtered.forEach(r=>{ if(!r.location) return; const lat = r.location.lat; const lng = r.location.lng; bounds.push([lat,lng]); const color = SEVERITY_COLOR[r.severity] || '#333'; const icon = L.divIcon({ className: 'custom-marker', html: `
`, iconSize:[22,22], iconAnchor:[11,11] }); const marker = L.marker([lat,lng], { icon }); marker.on('click', ()=> { setSelected(r); }); marker.bindPopup(`${r.category}{t('drawer.details') || 'Details'}
{selected.notes ?{selected.notes}
:{t('drawer.noNotes') || 'No additional notes'}
}{t('label.location') || 'Location'}: {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}
{t('label.createdAt') || 'Created'}: {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}