/* eslint-disable no-undef */ /* TransactionMapPage.jsx — "Malaysia Property Transaction Map" tab. Layout: interactive map + cascading location search panel on top, filtered transaction table below. The search is a strict cascade: State → District → Mukim → Scheme/Area → Road → Transactions, and the map's state selection stays two-way synced with the State dropdown. */ const { useState, useEffect, useMemo, useRef } = React; /* ---- small shared bits ------------------------------------------------ */ const Spinner = ({ label }) => (
{label}
); const apiTxnToRow = (r, state) => { const land = r.Land == null ? null : Number(r.Land); const built = r.Area == null ? null : Number(r.Area); const price = Number(r.Price || 0); const basis = built || land; const date = (r['Transaction Date'] || '').slice(0, 10); return { state, district: r.District || '', mukim: r.Mukim || '', area: r['Scheme Name/Area'] || '', road: r['Road Name'] || 'Not recorded', type: r['Property Type'] || '', year: Number(r.Year || (date ? date.slice(0, 4) : 0)), date, monthYear: date, price, land, built, tenure: r.Tenure || '', lot: r['Unit Level'] || '', level: r['Unit Level'] || null, ppsf: basis ? Math.round(price / basis) : null, ppsm: basis ? Math.round(price / basis) : null, }; }; const StepSelect = ({ label, value, placeholder, options, onChange, disabled, loading, loadingLabel, onClear }) => (
{label} {onClear && value && !loading && ( )}
{loading ? : ( )}
); /* ---- main page -------------------------------------------------------- */ const TransactionMapPage = ({ onEngage, navOpen, variant, onExportRoi }) => { const isVal = variant === 'valuation'; // Valuation tab swaps the table for the AVM dashboard const [geo, setGeo] = useState(null); const [geoErr, setGeoErr] = useState(false); const [sel, setSel] = useState({ state: '', district: '', propertyType: '', mukim: '', area: '', road: '' }); const [load, setLoad] = useState({ d: false, m: false, a: false, r: false, t: false }); const [txns, setTxns] = useState(null); // null = not loaded yet const [region, setRegion] = useState('west'); const [panelOpen, setPanelOpen] = useState(false); // starts minimized; auto-expands after State + District const [sheetOpen, setSheetOpen] = useState(true); const [sheetMax, setSheetMax] = useState(false); // table expanded to full page const [searched, setSearched] = useState(null); // snapshot of the selection when Search was pressed const [roadSearchOpen, setRoadSearchOpen] = useState(false); // "can't find mukim/scheme? search by road" disclosure const [roadAutoFill, setRoadAutoFill] = useState(null); // {road, mukim, area} when mukim/scheme were back-filled from a road // filters const [types, setTypes] = useState([]); // selected property types const [yearMode, setYearMode] = useState('range'); const [yr, setYr] = useState({ single: '', from: '', to: '' }); const [price, setPrice] = useState({ min: '', max: '' }); const timers = useRef([]); const bodyRef = useRef(null); const delay = (fn, ms) => { const id = setTimeout(fn, ms); timers.current.push(id); }; useEffect(() => () => timers.current.forEach(clearTimeout), []); // when a new cascade step is revealed, scroll the panel body to show it useEffect(() => { const el = bodyRef.current; if (el) requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); }, [sel.district, sel.mukim, sel.area, sel.road, load.m, load.a, load.r, roadSearchOpen]); useEffect(() => { loadMalaysiaGeo().then(setGeo).catch(() => setGeoErr(true)); }, []); /* Live mukim + scheme lists from the backend, scoped to the user's selection. The frontend's mock generator is kept as a fallback in case the API is offline or the chosen mock district/mukim doesn't exist in the dataset. */ const [apiMukims, setApiMukims] = useState(null); const [apiAreas, setApiAreas] = useState(null); const [apiRoads, setApiRoads] = useState(null); // real road names for the current scope useEffect(() => { if (!sel.district) { setApiMukims(null); setApiAreas(null); return; } let cancelled = false; const q = { district: sel.district }; if (sel.mukim) q.mukim = sel.mukim; window.API.valuationOptions(q) .then(opts => { if (cancelled) return; setApiMukims((opts.mukim || []).map(o => o.value)); setApiAreas((opts.scheme || []).map(o => o.value)); }) .catch(() => { if (cancelled) return; setApiMukims(null); setApiAreas(null); }); return () => { cancelled = true; }; }, [sel.district, sel.mukim]); /* Real road names for whichever road picker is on screen — the scheme-scoped ④ field (when a scheme is chosen) or the district-level "search by road" disclosure. Fetched on demand so the big district list isn't pulled unless needed; falls back to the mock generator if the API is offline. */ useEffect(() => { const wantRoads = !!sel.district && (!!sel.area || roadSearchOpen); if (!wantRoads) { setApiRoads(null); return; } let cancelled = false; window.API.valuationRoads({ district: sel.district, mukim: sel.mukim, scheme: sel.area }) .then(r => { if (!cancelled) setApiRoads(r.roads || []); }) .catch(() => { if (!cancelled) setApiRoads(null); }); return () => { cancelled = true; }; }, [sel.district, sel.mukim, sel.area, roadSearchOpen]); // option lists (derived strictly from the current selection) // keep the current selection in its own list — a mukim/scheme back-filled from // a road may not exist in the live API list, but must stay selectable. const withSelected = (list, val) => (val && !list.includes(val) ? [val, ...list] : list); const districts = geo && sel.state ? geo.byName[sel.state].districts.map(d => d.name) : []; const mockMukims = sel.district ? getMukims(sel.state, sel.district) : []; const mukims = withSelected((apiMukims && apiMukims.length) ? apiMukims : mockMukims, sel.mukim); // Scheme/Area can be browsed at the mukim level (if a mukim is chosen) OR // directly at the district level (Mukim is optional). const mockAreas = sel.mukim ? getAreas(sel.state, sel.district, sel.mukim) : (sel.district ? getDistrictAreas(sel.state, sel.district) : []); const areas = withSelected((apiAreas && apiAreas.length) ? apiAreas : mockAreas, sel.area); // Scheme-scoped road list for the ④ field — real names from the API, mock as // fallback. Keep the chosen road selectable even if the API list capped it. const mockRoads = sel.area ? getRoads(sel.state, sel.district, sel.mukim, sel.area) : []; const roads = sel.area ? withSelected((apiRoads && apiRoads.length) ? apiRoads : mockRoads, sel.road) : []; // District-wide road list — the fallback when the user can't find their // mukim/scheme. Real names from the API; the mock walk is memoised as fallback. const mockDistrictRoads = useMemo( () => (sel.district ? getDistrictRoads(sel.state, sel.district) : []), [sel.state, sel.district], ); const districtRoads = (apiRoads && apiRoads.length) ? apiRoads : mockDistrictRoads; /* cascade handlers — each resets every level below it and clears prior results. Selecting the blank option (or pressing Clear) deselects that level. */ const selectState = (state) => { // propertyType is orthogonal to the geographic cascade — keep it so the user // doesn't lose their pick (and re-forget it) when they change location. setSel(s => ({ state: state || '', district: '', propertyType: s.propertyType, mukim: '', area: '', road: '' })); setSearched(null); setTxns(null); setRoadAutoFill(null); if (state) { if (onEngage) onEngage(); // reveal the dashboard chrome on first engagement if (geo) setRegion(geo.regionOf(state)); setLoad(l => ({ ...l, d: true })); delay(() => setLoad(l => ({ ...l, d: false })), 450); } }; const selectDistrict = (district) => { setSel(s => ({ ...s, district: district || '', mukim: '', area: '', road: '' })); setSearched(null); setTxns(null); setRoadAutoFill(null); if (district) { setPanelOpen(true); // surface the panel so the user can refine setLoad(l => ({ ...l, m: true, a: true })); delay(() => setLoad(l => ({ ...l, m: false, a: false })), 450); } }; const selectMukim = (mukim) => { setSel(s => ({ ...s, mukim: mukim || '', area: '', road: '' })); setSearched(null); setTxns(null); setRoadAutoFill(null); if (mukim) { setLoad(l => ({ ...l, a: true })); delay(() => setLoad(l => ({ ...l, a: false })), 450); } }; const selectArea = (area) => { // Shortcut: a scheme can be picked directly under a district (mukim skipped). // Keep any mukim the user already chose; if they skipped it, auto back-fill // the REAL parent mukim from the backend (mock lookup is the offline fallback). setSel(s => ({ ...s, area: area || '', road: '' })); setSearched(null); setTxns(null); setRoadAutoFill(null); if (!area) return; setLoad(l => ({ ...l, r: true })); delay(() => setLoad(l => ({ ...l, r: false })), 450); if (sel.district && !sel.mukim) { window.API.valuationOptions({ district: sel.district, scheme: area }) .then(opts => { const top = (opts.mukim || [])[0]; if (!top) throw new Error('no parent mukim'); setSel(s => (s.area === area && !s.mukim ? { ...s, mukim: top.value } : s)); }) .catch(() => { const mk = getAreaMukim(sel.state, sel.district, area); if (mk) setSel(s => (s.area === area && !s.mukim ? { ...s, mukim: mk } : s)); }); } }; const selectRoad = (road) => { setSel(s => ({ ...s, road: road || '' })); setSearched(null); setTxns(null); setRoadAutoFill(null); }; /* Pick a road directly under the district (skipping mukim + scheme). People often know their road but not their mukim/scheme, so we ask the backend which mukim + scheme own this real road and back-fill them — the cascade above auto-populates and the area-scoped Road field takes over. Mock lookup is the offline fallback. */ const selectRoadDirect = (road) => { setSearched(null); setTxns(null); if (!road) { setSel(s => ({ ...s, road: '' })); setRoadAutoFill(null); return; } setSel(s => ({ ...s, road })); // reflect the road immediately window.API.valuationOptions({ district: sel.district, road }) .then(opts => { const mk = (opts.mukim || [])[0]; const sc = (opts.scheme || [])[0]; if (!sc) throw new Error('no owner'); setSel(s => (s.road === road ? { ...s, mukim: mk ? mk.value : s.mukim, area: sc.value } : s)); setRoadAutoFill({ road, mukim: mk ? mk.value : '', area: sc.value }); }) .catch(() => { const path = getRoadPath(sel.state, sel.district, road) || {}; setSel(s => (s.road === road ? { ...s, mukim: path.mukim || s.mukim, area: path.area || s.area } : s)); setRoadAutoFill(path.area ? { road, mukim: path.mukim || '', area: path.area } : null); }); }; const selectPropertyType = (propertyType) => { setSel(s => ({ ...s, propertyType: propertyType || '' })); setSearched(null); setTxns(null); }; /* explicit Search — gathers results for whatever level is filled. Road is optional; a mukim- or district-level search is allowed. */ const canSearch = !!sel.district && !!sel.propertyType; const runSearch = () => { if (!sel.district) return; const snap = { ...sel }; setSearched(snap); setSheetOpen(true); if (onEngage) onEngage(); if (isVal) setPanelOpen(false); // minimise Location Search so it doesn't cover the dashboard setLoad(l => ({ ...l, t: true })); if (!isVal) { window.API.dataQuery({ district: snap.district, mukim: snap.mukim, scheme: snap.area, road: snap.road, property_type: snap.propertyType, limit: 600, }) .then(data => { const rows = (data.rows || []).map(r => apiTxnToRow(r, snap.state)); setTxns(rows); setTypes(snap.propertyType ? [snap.propertyType] : [...new Set(rows.map(r => r.type))]); setYearMode('range'); setYr({ single: '', from: '', to: '' }); setPrice({ min: '', max: '' }); }) .catch(() => { const rows = getTransactionsForScope(snap); setTxns(rows); setTypes(snap.propertyType ? [snap.propertyType] : [...new Set(rows.map(r => r.type))]); setYearMode('range'); setYr({ single: '', from: '', to: '' }); setPrice({ min: '', max: '' }); }) .finally(() => setLoad(l => ({ ...l, t: false }))); } else { delay(() => setLoad(l => ({ ...l, t: false })), 600); } }; const availTypes = useMemo(() => txns ? [...new Set(txns.map(r => r.type))].sort() : [], [txns]); const filtered = useMemo(() => { if (!txns) return []; return txns.filter(r => { if (types.length && !types.includes(r.type)) return false; if (yearMode === 'single' && yr.single && r.year !== +yr.single) return false; if (yearMode === 'range') { if (yr.from && r.year < +yr.from) return false; if (yr.to && r.year > +yr.to) return false; } if (price.min && r.price < +price.min) return false; if (price.max && r.price > +price.max) return false; return true; }); }, [txns, types, yearMode, yr, price]); const medianPrice = useMemo(() => { if (!filtered.length) return null; const s = filtered.map(r => r.price).sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; }, [filtered]); /* status hint */ let hint; if (!sel.state) hint = isVal ? 'Select a state to begin a valuation.' : 'Select a state to explore property transactions.'; else if (!sel.district) hint = `${sel.state}. Choose a district to continue.`; else if (!sel.propertyType) hint = `${sel.district}, ${sel.state}. Pick a property type below (optionally refine by mukim / scheme / road first).`; else if (searched) hint = isVal ? `Valuation ready for ${searched.area || searched.mukim || searched.district}.` : `Showing ${searched.road || searched.area || searched.mukim || searched.district}.`; else hint = `${sel.district}, ${sel.state}. Refine by mukim / scheme / road (all optional), then press Search.`; if (geoErr) return (
Unable to load the Malaysia map data. Please check your connection and reload.
); const YEARS = [2021, 2022, 2023, 2024, 2025, 2026]; const sheetVisible = !!searched || sheetMax; // results appear once Search runs (kept while maximized) // Slide the Location Search clear of the floating nav while it's open. const searchLeft = navOpen ? 252 : 16; return (
{/* ===== FULL-BLEED MAP ===== */} {geo ? { if (!sel.state) setRegion(r); }} onSelectState={selectState} onSelectDistrict={selectDistrict}/> :
Loading Malaysia map…
} {/* ===== FLOATING SEARCH PANEL ===== Hidden while the table is maximized to full page — the search fields live in TxnFullPage's header instead, so they don't overlap. */} {!sheetMax && (panelOpen ? (
Explore Location Search
{hint}
selectState('')}/> selectDistrict('')}/> {sel.district && (
③ Narrow down — select a mukim and / or a scheme / area (optional)
{roadAutoFill && (
Auto-filled from {roadAutoFill.road} — Mukim{' '} {roadAutoFill.mukim || '—'}, Scheme / Area{' '} {roadAutoFill.area}. Adjust below if needed.
)}
Mukim {sel.mukim && !load.m && ( )}
{load.m ? : }
Scheme / Area {sel.area && !load.a && ( )}
{load.a ? : }
)} {/* Escape hatch: can't find the mukim/scheme? Search a road directly under the district — picking one back-fills its mukim & scheme. */} {sel.district && !sel.area && (
{roadSearchOpen && (
Road Name
Pick a road and we'll fill in its mukim & scheme for you.
)}
)} {sel.area && ( selectRoad('')}/> )} {/* Property Type sits at the bottom, just above Search, and always renders regardless of the optional cascade steps above it — so it stays in view (and gets nudged down as mukim/area/road reveal) and users stop skipping it. Highlighted until it's chosen. */}
selectPropertyType('')}/>
{(sel.state || sel.district) && ( )}
) : ( ))} {/* ===== BOTTOM SHEET: TRANSACTION TABLE ===== */} {sheetVisible && (
{!sheetOpen && !sheetMax && ( )} {(sheetOpen || sheetMax) && ( {!sheetMax && (
)} {isVal ? ( sheetMax ? ( { setSheetMax(false); setPanelOpen(false); }} districts={districts} mukims={mukims} areas={areas} roads={roads} selectState={selectState} stateNames={geo ? geo.stateNames : []} selectDistrict={selectDistrict} selectMukim={selectMukim} selectArea={selectArea} selectRoad={selectRoad} selectPropertyType={selectPropertyType} clearAll={() => { selectState(''); setSheetMax(false); setPanelOpen(true); }} load={load} txns={txns} filtered={filtered} availTypes={availTypes} types={types} setTypes={setTypes} yr={yr} setYr={setYr} price={price} setPrice={setPrice} years={YEARS} onExportRoi={onExportRoi}/> ) : ( ) ) : sheetMax ? ( { setSheetMax(false); setPanelOpen(true); }} districts={districts} mukims={mukims} areas={areas} roads={roads} selectState={selectState} stateNames={geo ? geo.stateNames : []} selectDistrict={selectDistrict} selectMukim={selectMukim} selectArea={selectArea} selectRoad={selectRoad} selectPropertyType={selectPropertyType} clearAll={() => { selectState(''); setSheetMax(false); setPanelOpen(true); }} load={load} txns={txns} filtered={filtered} availTypes={availTypes} types={types} setTypes={setTypes} yr={yr} setYr={setYr} price={price} setPrice={setPrice} years={YEARS}/> ) : ( )}
)}
)}
); }; const iconBtn = { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, background: C.cream, border: `1px solid ${C.border}`, color: C.mid, cursor: 'pointer', flexShrink: 0, }; const clearLink = { border: 0, background: 'transparent', color: C.muted, cursor: 'pointer', fontFamily: "'DM Sans',sans-serif", fontSize: 11, fontWeight: 600, padding: 0, display: 'flex', alignItems: 'center', gap: 3, }; const disclosureBtn = { width: '100%', display: 'flex', alignItems: 'center', gap: 7, textAlign: 'left', background: 'transparent', border: `1px dashed ${C.earth}55`, borderRadius: 8, color: C.earth, cursor: 'pointer', padding: '9px 11px', fontFamily: "'DM Sans',sans-serif", fontSize: 12.5, fontWeight: 600, }; Object.assign(window, { TransactionMapPage });