/* 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 && (
onClear()} style={clearLink}>
Clear
)}
{loading
?
: (
onChange(e.target.value)}
style={{
width: '100%', background: disabled ? C.cream : C.cream,
border: `1px solid ${disabled ? C.border : C.earth + '55'}`,
color: disabled ? C.muted : C.deep,
fontFamily: "'DM Sans', sans-serif", fontSize: 14,
borderRadius: 8, padding: '10px 12px', boxSizing: 'border-box',
appearance: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.65 : 1,
backgroundImage: `url("data:image/svg+xml;utf8, ")`,
backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center',
}}>
{placeholder}
{options.map(o => {o} )}
)}
);
/* ---- 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
setPanelOpen(false)} title="Collapse"
style={iconBtn}>
{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 && (
selectMukim('')} style={clearLink}>
Clear
)}
{load.m
?
:
}
Scheme / Area
{sel.area && !load.a && (
selectArea('')} style={clearLink}>
Clear
)}
{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 && (
setRoadSearchOpen(o => !o)} style={disclosureBtn}>
Can't find your mukim or scheme? Search by road name
{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('')}/>
{searched ? 'Update search' : (isVal ? 'Value this area' : 'Search')}
{(sel.state || sel.district) && (
selectState('')} style={{
background: 'transparent', border: `1px solid ${C.border}`, color: C.mid, borderRadius: 9,
padding: '12px 14px', fontFamily: "'DM Sans',sans-serif", fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>Clear
)}
) : (
setPanelOpen(true)} style={{
position: 'absolute', top: 16, left: searchLeft, zIndex: 20,
display: 'flex', alignItems: 'center', gap: 9,
background: C.deep, color: C.cream, border: 0, borderRadius: 9999,
padding: '11px 18px', fontFamily: "'DM Sans',sans-serif", fontSize: 13.5,
fontWeight: 600, cursor: 'pointer', boxShadow: '0 6px 20px rgba(44,57,48,.24)',
transition: 'left .5s cubic-bezier(.16,1,.3,1)',
}}>
Location Search
{searched && !isVal && txns && {filtered.length} }
))}
{/* ===== BOTTOM SHEET: TRANSACTION TABLE ===== */}
{sheetVisible && (
{!sheetOpen && !sheetMax && (
setSheetOpen(true)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 20px', background: 'transparent', border: 0, cursor: 'pointer',
}}>
{isVal
? `Estimated Valuation · ${(searched && (searched.area || searched.mukim || searched.district)) || sel.area || sel.district}`
: `Property Transactions${txns ? ` · ${filtered.length} of ${txns.length}` : ''}`}
)}
{(sheetOpen || sheetMax) && (
{!sheetMax && (
{ setYearMode('range'); setSheetMax(true); }} title="Expand to full page"
style={iconBtn}>
setSheetOpen(false)} title="Collapse" style={iconBtn}>
)}
{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 });