/* eslint-disable no-undef */
/* ValuationDashboard.jsx — the Valuation tab's result panel.
Shown inside the map's bottom sheet once the user has chosen a Scheme/Area.
Estimates the property's market value (switchable between three real,
API-served models — Random Forest, XGBoost, FT-Transformer) and surfaces the
essential market data for the chosen region, mirroring the real NAPIC
Open Transaction schema: average price by property type, median price,
price per m² (built-up), median built-up / land area (sq.m), tenure mix,
and the price + volume trend across 2021–2026. All figures derive from the
same calibrated transaction layer the map uses, so the dashboard always
agrees with the underlying records. */
const { useState, useMemo, useEffect, useRef } = React;
const STRATA_TYPES = new Set(['Condominium/Apartment', 'Flat', 'Low-Cost Flat', 'Town House']);
const VAL_AVG_GUARD_MIN_TXNS = 3;
const VAL_AVG_GUARD_MAX_DELTA = 0.50;
/* ---- stats helpers ---------------------------------------------------- */
const valMean = (a) => a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
const valMedian = (a) => {
if (!a.length) return 0;
const s = [...a].sort((x, y) => x - y);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const rmCompact = (n) => {
if (!n) return '—';
if (n >= 1e6) return 'RM ' + (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return 'RM ' + Math.round(n / 1e3) + 'k';
return formatRM(n);
};
const SHORT_TYPE = {
'1 - 1 1/2 Storey Terraced': '1–1½ Terraced', '2 - 2 1/2 Storey Terraced': '2–2½ Terraced',
'Condominium/Apartment': 'Condo / Apt', '1 - 1 1/2 Storey Semi-Detached': '1–1½ Semi-D',
'Low-Cost House': 'Low-Cost House', 'Detached': 'Detached',
'2 - 2 1/2 Storey Semi-Detached': '2–2½ Semi-D', 'Flat': 'Flat',
'Low-Cost Flat': 'Low-Cost Flat', 'Cluster House': 'Cluster House', 'Town House': 'Town House',
};
const shortType = (t) => SHORT_TYPE[t] || t;
const buildAvgGuard = (point, rows, selectedType) => {
if (!point) return { blocked: false, avg: null, n: 0, delta: 0, scope: 'none' };
const valid = (rows || []).filter((r) => Number(r.Price) > 0);
const exact = selectedType ? valid.filter((r) => (r['Property Type'] || '') === selectedType) : [];
const use = exact.length >= VAL_AVG_GUARD_MIN_TXNS ? exact : valid;
const prices = use.map((r) => Number(r.Price));
if (prices.length < VAL_AVG_GUARD_MIN_TXNS) {
return { blocked: true, avg: null, n: prices.length, delta: 0, scope: exact.length ? 'type' : 'similar' };
}
const avg = valMean(prices);
const delta = avg ? Math.abs(point - avg) / avg : 0;
return {
blocked: avg > 0 && delta > VAL_AVG_GUARD_MAX_DELTA,
avg,
n: prices.length,
delta,
scope: use === exact ? 'type' : 'similar',
};
};
/* Recent real transactions (NAPIC Open Transaction Data) scoped to the
selection — rendered as a scrollable table, one row per record. */
const VAL_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmtTxnMonth = (iso) => {
if (!iso) return '—';
const s = String(iso);
return (VAL_MONTHS[+s.slice(5, 7) - 1] || '') + ' ' + s.slice(0, 4);
};
const RecentTh = ({ children, right }) => (
{children}
);
const recentCell = {
padding: '7px 10px', fontFamily: "'DM Sans',sans-serif", fontSize: 11.5,
color: C.mid, borderBottom: `1px solid ${C.border}80`, verticalAlign: 'top',
};
const recentNum = { ...recentCell, textAlign: 'right', whiteSpace: 'nowrap', fontFamily: "'JetBrains Mono',monospace" };
const RecentTxnRow = ({ r, i }) => {
const price = Number(r.Price || 0);
const built = r.Area == null ? null : Number(r.Area);
const land = r.Land == null ? null : Number(r.Land);
const size = built || land;
const place = r['Road Name'] || r['Scheme Name/Area'] || r.Mukim || '—';
return (
{fmtTxnMonth(r['Transaction Date'])}
{shortType(r['Property Type'] || '—')}
{place}
{size ? Math.round(size).toLocaleString('en-MY') : '—'}
{rmCompact(price)}
);
};
/* ECharts line chart of the yearly average price — the price-growth trajectory
across the years, in the life-expectancy example's style: a smooth animated
draw, an end label with the latest price, and focus-on-hover. Green when the
latest year sits above the first (prices grew), red when below. Keeps the
YoY % pill labels + year labels, and adds a per-year hover (avg price, volume,
YoY %). Sits to the right of the year bar chart in the trend card. */
const YearLineChart = ({ rows }) => {
const elRef = React.useRef(null);
const chartRef = React.useRef(null);
React.useEffect(() => {
if (!window.echarts || !elRef.current) return undefined;
const chart = window.echarts.init(elRef.current, null, { renderer: 'canvas' });
chartRef.current = chart;
const ro = new ResizeObserver(() => chart.resize());
ro.observe(elRef.current);
const raf = requestAnimationFrame(() => chart.resize());
const t = setTimeout(() => chart.resize(), 300);
return () => { cancelAnimationFrame(raf); clearTimeout(t); ro.disconnect(); chart.dispose(); chartRef.current = null; };
}, []);
React.useEffect(() => {
const chart = chartRef.current;
if (!chart || !window.echarts) return;
if (!rows || rows.length === 0) { chart.clear(); return; }
const n = rows.length;
const up = rows[n - 1].avg >= rows[0].avg;
const color = up ? C.up : C.down;
const grad = new window.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: up ? 'rgba(45,122,79,0.22)' : 'rgba(166,50,40,0.22)' },
{ offset: 1, color: up ? 'rgba(45,122,79,0)' : 'rgba(166,50,40,0)' },
]);
const data = rows.map((r, i) => ({
value: Math.round(r.avg), year: r.y, n: r.n,
pct: i ? ((r.avg - rows[i - 1].avg) / (rows[i - 1].avg || 1)) * 100 : null,
}));
// colour each YoY % pill by its own direction (green up / red down), like the
// old chart; the first year has no prior year, so it shows no pill.
data.forEach((d, i) => {
if (i === 0) { d.label = { show: false }; return; }
const c = d.pct >= 0 ? C.up : C.down;
d.label = { color: c, borderColor: c };
});
chart.setOption({
animationDuration: 2000, animationEasing: 'cubicOut',
backgroundColor: 'transparent',
grid: { left: 6, right: 58, top: 30, bottom: 6, containLabel: true },
tooltip: {
trigger: 'axis', backgroundColor: C.deep, borderColor: C.deep, padding: [8, 10],
textStyle: { color: C.cream, fontFamily: "'DM Sans',sans-serif", fontSize: 12 },
axisPointer: { type: 'line', lineStyle: { color: C.earth, width: 1, type: [3, 4] } },
formatter: (ps) => {
const d = ps[0].data; const p = d.pct;
return `${d.year}
` +
`Avg price: ${rmCompact(d.value)}
` +
`${d.n} transaction${d.n === 1 ? '' : 's'}
` +
(p != null ? `YoY ${p >= 0 ? '+' : ''}${p.toFixed(1)}%
` : '');
},
},
xAxis: {
type: 'category', data: rows.map(r => r.y), boundaryGap: false,
axisLine: { lineStyle: { color: C.border } }, axisTick: { show: false },
axisLabel: { color: C.mid, fontFamily: "'DM Sans',sans-serif", fontSize: 11 },
},
yAxis: { type: 'value', scale: true, show: false },
series: [{
type: 'line', name: 'Average price', smooth: true, data,
showSymbol: true, symbol: 'circle', symbolSize: 7,
lineStyle: { color, width: 2.4 },
itemStyle: { color: C.raised, borderColor: color, borderWidth: 2 },
areaStyle: { color: grad },
emphasis: { focus: 'series' },
// YoY % change pills at each point (the old segment labels, preserved)
label: {
show: true, position: 'top', distance: 7,
formatter: (p) => (p.data.pct != null ? (p.data.pct >= 0 ? '+' : '') + p.data.pct.toFixed(1) + '%' : ''),
color, fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, fontWeight: 700,
backgroundColor: C.raised, borderColor: color, borderWidth: 1, borderRadius: 8, padding: [2, 5],
},
// latest average price labelled at the line end (life-expectancy style)
endLabel: {
show: true, distance: 6,
formatter: (p) => rmCompact(p.data.value),
color, fontFamily: "'JetBrains Mono',monospace", fontSize: 11, fontWeight: 700,
},
labelLayout: { moveOverlap: 'shiftY' },
}],
}, true);
}, [rows]);
return
;
};
/* ---- region aggregation (area-level sample) --------------------------- */
function valComputeRegion(sel) {
const { state, district, mukim, area, propertyType } = sel || {};
let rows = [];
try { rows = getTransactionsForScope({ state, district, mukim, area }) || []; } catch (e) {}
if (rows.length < 6) { try { rows = rows.concat(getTransactions({ state, district, mukim, area, road: '' }) || []); } catch (e) {} }
const typeRows = propertyType ? rows.filter(r => r.type === propertyType) : [];
const valuationRows = typeRows.length ? typeRows : rows;
const median = valMedian(valuationRows.map(r => r.price));
const ppsmArr = valuationRows.map(r => r.ppsm).filter(Boolean);
const ppsm = ppsmArr.length ? Math.round(valMedian(ppsmArr)) : null;
const floorArr = valuationRows.map(r => r.built).filter(Boolean);
const medFloor = floorArr.length ? Math.round(valMedian(floorArr)) : null;
const landArr = valuationRows.map(r => r.land).filter(Boolean);
const medLand = landArr.length ? Math.round(valMedian(landArr)) : null;
const fhPct = Math.round(100 * valuationRows.filter(r => r.tenure === 'Freehold').length / (valuationRows.length || 1));
const map = {};
rows.forEach(r => { (map[r.type] = map[r.type] || []).push(r); });
const byType = Object.entries(map)
.map(([type, arr]) => ({ type, avg: valMean(arr.map(r => r.price)), n: arr.length }))
.sort((a, b) => b.avg - a.avg);
const dominant = byType.reduce((a, b) => (a && a.n >= b.n ? a : b), null);
const byYear = {};
rows.forEach(r => { (byYear[r.year] = byYear[r.year] || []).push(r.price); });
const yearAvg = Object.keys(byYear).map(Number).sort()
.map(y => ({ y, avg: valMean(byYear[y]), n: byYear[y].length }));
let trendTotal = null;
if (yearAvg.length >= 2) {
const f = yearAvg[0], l = yearAvg[yearAvg.length - 1];
trendTotal = (l.avg / f.avg - 1) * 100;
}
return {
rows,
valuationRows,
count: valuationRows.length || rows.length,
median,
ppsm,
medFloor,
medLand,
fhPct,
byType,
dominant,
selectedType: propertyType || (dominant && dominant.type),
yearAvg,
trendTotal,
baseVal: median || 400000,
};
}
/* The three valuation models served by the backend (/valuation/predict?model=).
`key` is the API selector; order here = left-to-right button order. All three
are real, trained models — no illustrative placeholders. */
const MODEL_DEFS = [
{ key: 'rf', label: 'Random Forest', short: 'Forest',
note: 'Tuned ensemble of decision trees — robust to outliers and non-linear price drivers.' },
{ key: 'xgboost', label: 'XGBoost', short: 'XGBoost',
note: 'Gradient-boosted trees with conformal prediction bands — the primary AVM model.' },
{ key: 'ft', label: 'FT-Transformer', short: 'FT-Tx',
note: 'Feature-Tokenizer + Transformer — a tabular deep-learning model that turns every feature into a token and attends across them.' },
];
/* ---- small presentational bits --------------------------------------- */
const StatTile = ({ label, value, sub, accent }) => (
{label}
{value}
{sub && {sub} }
);
const ValuationDashboard = ({ sel, loading, fullpage, onExportRoi }) => {
const [modelIdx, setModelIdx] = useState(1); // XGBoost default (primary model)
const [apiResults, setApiResults] = useState({}); // { rf, xgboost, ft } live predictions
const [apiSig, setApiSig] = useState(null);
const [apiError, setApiError] = useState(null);
const [apiLoading, setApiLoading] = useState(false);
const [recentTxns, setRecentTxns] = useState(null); // real Open Transaction Data rows
const [recentLoading, setRecentLoading] = useState(false);
const [recentScope, setRecentScope] = useState('exact'); // 'exact' | 'family' | 'area'
const [recentTotal, setRecentTotal] = useState(0); // total matched (may exceed the 1000 shown)
const [byTypeReal, setByTypeReal] = useState(null); // real avg price per property type
const [yearReal, setYearReal] = useState(null); // real yearly avg price for the selected type
const [priceReal, setPriceReal] = useState(null); // real server-side price stats (median, count) for the scope
const data = useMemo(() => valComputeRegion(sel),
[sel.state, sel.district, sel.mukim, sel.area, sel.propertyType]);
/* Real region stats from the NAPIC rows in the "Recent transactions" table
(not the synthetic map layer): median plot/built-up size (used to size the
model), median RM/m² (built-up), and freehold share. Prefer rows of the
selected property type; if the recent list fell back to a wider family/area
scope, use whatever it has. Fields are null when the API gave nothing — the
callers then fall back to the calibrated layer's medians. */
const realSizes = useMemo(() => {
const rows = recentTxns || [];
const exact = sel.propertyType
? rows.filter((r) => (r['Property Type'] || '') === sel.propertyType)
: [];
const use = exact.length ? exact : rows;
const medOf = (xs) => {
const s = xs.filter((v) => v != null && Number(v) > 0).map(Number).sort((a, b) => a - b);
if (!s.length) return null;
const k = Math.floor(s.length / 2);
return s.length % 2 ? s[k] : (s[k - 1] + s[k]) / 2;
};
const med = (key) => medOf(use.map((r) => r[key]));
const ppsm = medOf(use.map((r) => (Number(r.Area) > 0 ? Number(r.Price) / Number(r.Area) : null)));
const fhPct = use.length
? Math.round((100 * use.filter((r) => r.Tenure === 'Freehold').length) / use.length)
: null;
return {
n: use.length,
land: med('Land'),
area: med('Area'),
ppsm: ppsm != null ? Math.round(ppsm) : null,
fhPct,
};
}, [recentTxns, sel.propertyType]);
/* Latest actual transactions for the chosen area, straight from the cleaned
NAPIC Open Transaction Data (/data/query => transactions.parquet), newest
first. Property type leads the priority:
1) sales of the exact selected type in the area;
2) else the most similar type - same landed / non-landed family;
3) else whatever transacted in the area.
Road is NOT used to scope this list: names in the source are inconsistently
abbreviated (LRG / JLN / JALAN / LORONG ...), which would wrongly empty it. */
useEffect(() => {
if (!sel.district) { setRecentTxns(null); setRecentScope('exact'); return; }
let cancelled = false;
setRecentLoading(true);
setRecentTxns(null);
setRecentTotal(0);
setRecentScope('exact');
const area = {
district: sel.district, mukim: sel.mukim, scheme: sel.area,
sort_by: 'Transaction Date', order: 'desc',
};
const sameFamily = (t) => STRATA_TYPES.has(t) === STRATA_TYPES.has(sel.propertyType);
const done = (rows, scope, total) => {
if (cancelled) return;
setRecentTxns(rows); setRecentScope(scope); setRecentTotal(total || 0); setRecentLoading(false);
};
// 1) exact selected type — ALL matching records in the area (backend caps 1000).
const exactReq = sel.propertyType
? window.API.dataQuery({ ...area, property_type: sel.propertyType, limit: 1000 })
: Promise.resolve({ rows: [] });
exactReq
.then((d) => {
if (cancelled) return;
if (sel.propertyType && d.rows && d.rows.length) { done(d.rows, 'exact', d.total_matched); return; }
// 2/3) widen to the whole area, then prefer the same landed/non-landed family.
window.API.dataQuery({ ...area, limit: 1000 })
.then((all) => {
if (cancelled) return;
const rows = all.rows || [];
if (!rows.length) { done([], 'exact', 0); return; }
const fam = sel.propertyType ? rows.filter((r) => sameFamily(r['Property Type'])) : [];
if (fam.length) done(fam, 'family', fam.length);
else done(rows, 'area', all.total_matched);
})
.catch(() => done([], 'exact', 0));
})
.catch(() => done([], 'exact', 0));
return () => { cancelled = true; };
}, [sel.district, sel.mukim, sel.area, sel.propertyType]);
/* Real average price per property type for the area, computed server-side over
every matching record (not the paginated page). `limit: 1` keeps the payload
tiny — we only want stats.by_type. Not filtered by type: the card shows all
types side by side. Falls back to the calibrated layer if the API is offline. */
useEffect(() => {
if (!sel.district) { setByTypeReal(null); return; }
let cancelled = false;
window.API.dataQuery({
district: sel.district, mukim: sel.mukim, scheme: sel.area, limit: 1,
})
.then((d) => { if (!cancelled) setByTypeReal((d.stats && d.stats.by_type) || []); })
.catch(() => { if (!cancelled) setByTypeReal(null); });
return () => { cancelled = true; };
}, [sel.district, sel.mukim, sel.area]);
/* Real yearly average price + price stats for the SELECTED property type in the
area (server-side over ALL matching rows, not just the page). If that type has
no sales here, fall back to the area's all-type stats so nothing goes blank.
The single response feeds both the yearly trend chart (stats.yearly) and the
KPI tiles / "vs median" (stats.price). Calibrated layer is the offline fallback. */
useEffect(() => {
if (!sel.district) { setYearReal(null); setPriceReal(null); return; }
let cancelled = false;
const area = { district: sel.district, mukim: sel.mukim, scheme: sel.area };
const toYears = (yearly) => (yearly || []).map(y => ({ y: y.year, avg: y.mean, n: y.count }));
window.API.dataQuery({ ...area, property_type: sel.propertyType, limit: 1 })
.then((d) => {
if (cancelled) return;
const yearly = (d.stats && d.stats.yearly) || [];
if (sel.propertyType && yearly.length) {
setYearReal({ rows: toYears(yearly), scope: 'type' });
setPriceReal((d.stats && d.stats.price) || null);
return;
}
window.API.dataQuery({ ...area, limit: 1 })
.then((a) => {
if (cancelled) return;
setYearReal({ rows: toYears(a.stats && a.stats.yearly), scope: 'area' });
setPriceReal((a.stats && a.stats.price) || null);
})
.catch(() => { if (!cancelled) { setYearReal(null); setPriceReal(null); } });
})
.catch(() => { if (!cancelled) { setYearReal(null); setPriceReal(null); } });
return () => { cancelled = true; };
}, [sel.district, sel.mukim, sel.area, sel.propertyType]);
/* Lazy per-model valuation. The VM only holds one (memory-heavy) model in RAM
at a time, so the dashboard computes ONLY the selected model and fetches the
others when the user switches tabs. Results are cached per search via a
signature — switching back is instant, and a property/location/size change
clears the cache and recomputes just the selected model. */
const payloadBase = useMemo(() => {
if (!data.selectedType || !sel.district) return null;
const isStrata = STRATA_TYPES.has(data.selectedType);
const modelLand = isStrata
? (realSizes.area || realSizes.land || data.medFloor || data.medLand || 1)
: (realSizes.land || realSizes.area || data.medLand || data.medFloor || 1);
const modelArea = isStrata ? null : (realSizes.area || data.medFloor || null);
const fhShare = realSizes.fhPct != null ? realSizes.fhPct : data.fhPct;
return {
property_type: data.selectedType,
district: sel.district,
mukim: sel.mukim || sel.district,
scheme: sel.area || sel.mukim || sel.district,
tenure: (fhShare >= 50 ? 'Freehold' : 'Leasehold'),
land: Math.max(1, Math.round(modelLand)),
area: modelArea ? Math.round(modelArea) : null,
};
}, [sel.district, sel.mukim, sel.area, data.selectedType, data.fhPct,
data.medLand, data.medFloor, realSizes.land, realSizes.area, realSizes.fhPct]);
const payloadSig = useMemo(() => payloadBase ? JSON.stringify(payloadBase) : null, [payloadBase]);
const resultsRef = useRef({});
const sigRef = useRef(null);
useEffect(() => { resultsRef.current = apiResults; }, [apiResults]);
useEffect(() => {
if (!payloadBase || !payloadSig) {
setApiResults({});
setApiSig(null);
sigRef.current = null;
setApiLoading(false);
return;
}
const sigChanged = sigRef.current !== payloadSig;
if (sigChanged) {
sigRef.current = payloadSig;
setApiResults({});
setApiSig(null);
} // new search -> drop stale results
const key = MODEL_DEFS[modelIdx].key;
if (!sigChanged && apiSig === payloadSig && resultsRef.current[key]) { setApiLoading(false); return; } // cached
setApiLoading(true); setApiError(null);
let cancelled = false;
window.API.valuationPredict({ ...payloadBase, model: key })
.then((r) => {
if (cancelled) return;
setApiResults((prev) => ({ ...(sigRef.current === payloadSig ? prev : {}), [key]: r }));
setApiSig(payloadSig);
})
.catch((e) => { if (!cancelled) setApiError({ sig: payloadSig, message: e.message }); })
.finally(() => { if (!cancelled) setApiLoading(false); });
return () => { cancelled = true; };
}, [payloadBase, payloadSig, modelIdx, apiSig]);
/* Build the three cards from live predictions only. Missing model results
stay non-live so the estimate panel can show loading instead of fallback
values while the server is responding. */
const validApiResults = apiSig === payloadSig ? apiResults : {};
const models = useMemo(() => MODEL_DEFS.map((def) => {
const r = validApiResults[def.key];
if (r && r.predicted_price) {
const pt = r.predicted_price;
const band = Math.max(0.02, (r.price_high - r.price_low) / (2 * pt));
return {
...def,
point: pt,
band,
conf: r.confidence === 'high' ? 93 : r.confidence === 'medium' ? 88 : 78,
mae: r.val_mape != null ? (r.val_mape * 100).toFixed(1) + '%' : '—',
live: true,
note: def.note + ' ' + (r.comparables?.length || 0) + ' NAPIC comparables anchored.',
};
}
return { ...def, point: data.baseVal, band: 0.1, conf: null, mae: '—', live: false };
}), [validApiResults, data.baseVal]);
const m = models[modelIdx];
const hasLiveEstimate = m.live;
const activeApiError = apiError && apiError.sig === payloadSig ? apiError.message : null;
const avgGuard = useMemo(() => (
hasLiveEstimate && !recentLoading
? buildAvgGuard(m.point, recentTxns, sel.propertyType)
: { blocked: false, avg: null, n: 0, delta: 0, scope: 'none' }
), [hasLiveEstimate, recentLoading, recentTxns, m.point, sel.propertyType]);
const guardChecking = hasLiveEstimate && recentLoading;
const avgGuardBlocked = hasLiveEstimate && !recentLoading && avgGuard.blocked;
const hasDisplayableEstimate = hasLiveEstimate && !guardChecking && !avgGuardBlocked;
const estimateLoading = (!hasLiveEstimate && !activeApiError && (loading || apiLoading || !!payloadSig)) || guardChecking;
const estimateUnavailable = (!hasLiveEstimate && !!activeApiError) || avgGuardBlocked;
const unavailableMessage = avgGuardBlocked
? (avgGuard.avg
? `Data is not available: model estimate differs by ${(avgGuard.delta * 100).toFixed(0)}% from the recent ${avgGuard.scope === 'type' ? shortType(sel.propertyType) : 'similar-property'} average (${rmCompact(avgGuard.avg)}, ${avgGuard.n} transactions).`
: `Data is not available: only ${avgGuard.n} recent comparable transaction${avgGuard.n === 1 ? '' : 's'} found for this selection.`)
: `API error: ${activeApiError}`;
const low = m.point * (1 - m.band);
const high = m.point * (1 + m.band);
// Scale the comparison band over the models that have actually been computed
// (lazy loading means non-selected models may not be fetched yet).
const bandModels = models.filter(x => x.live || x === m);
const dLow = Math.min(...bandModels.map(x => x.point * (1 - x.band)));
const dHigh = Math.max(...bandModels.map(x => x.point * (1 + x.band)));
const pct = (v) => Math.max(0, Math.min(100, ((v - dLow) / (dHigh - dLow)) * 100));
// Region KPIs from REAL NAPIC data (server-side price stats + recent-txn
// medians), with the calibrated layer only as an offline fallback. These
// replace the synthetic `data.*` figures in the tiles and the "vs median".
const regMedian = (priceReal && priceReal.median) || data.median;
const regCount = (priceReal && priceReal.count) || realSizes.n || data.count;
const regPpsm = realSizes.ppsm != null ? realSizes.ppsm : data.ppsm;
const regFloor = realSizes.area != null ? Math.round(realSizes.area) : data.medFloor;
const regLand = realSizes.land != null ? Math.round(realSizes.land) : data.medLand;
const regFhPct = realSizes.fhPct != null ? realSizes.fhPct : data.fhPct;
const valPerSqm = regFloor ? Math.round(m.point / regFloor) : null;
// Average price by property type — real aggregates from the Open Transaction
// Data (server-computed over all matching rows); calibrated layer is the
// offline fallback. Normalised to {type, avg, n} so the card stays unchanged.
const byType = (byTypeReal && byTypeReal.length)
? byTypeReal.map(t => ({ type: t.type, avg: t.mean, n: t.count }))
: (byTypeReal ? [] : data.byType);
const dominantType = byType.reduce((a, b) => (a && a.n >= b.n ? a : b), null);
const maxTypeAvg = Math.max(...byType.map(t => t.avg), 1);
// Average transacted price by year — real yearly trend for the selected type
// in the area (calibrated layer is the offline fallback). Scope flag tells the
// header whether it narrowed to the type or widened to the whole area.
const yearAvg = (yearReal && yearReal.rows && yearReal.rows.length)
? yearReal.rows
: (yearReal ? [] : data.yearAvg);
const yearScope = yearReal ? yearReal.scope : null;
const maxYear = Math.max(...yearAvg.map(y => y.avg), 1);
const minYear = Math.min(...yearAvg.map(y => y.avg), maxYear);
const maxVol = Math.max(...yearAvg.map(y => y.n), 1);
// Net price growth across the visible years (first → last) — drives the line
// chart's colour, the growth badge, and the "Trend" KPI tile.
const yGrowth = (yearAvg.length > 1 && yearAvg[0].avg)
? ((yearAvg[yearAvg.length - 1].avg - yearAvg[0].avg) / yearAvg[0].avg) * 100
: 0;
const yUp = yGrowth >= 0;
const recentArea = sel.area || sel.mukim || sel.district;
const recentFamily = STRATA_TYPES.has(sel.propertyType) ? 'non-landed' : 'landed';
const recentNote = recentScope === 'family'
? `No ${shortType(sel.propertyType)} sales in ${recentArea} — showing the most similar ${recentFamily} sales.`
: recentScope === 'area'
? `No ${shortType(sel.propertyType)} or similar ${recentFamily} sales — showing all recent sales in ${recentArea}.`
: null;
const displayedPoint = Math.round(m.point / 1000) * 1000;
const exportRoi = () => {
if (!hasDisplayableEstimate || !onExportRoi) return;
onExportRoi({
propertyPrice: displayedPoint,
locationLabel: [sel.area || sel.mukim || sel.district, sel.district, sel.state].filter(Boolean).join(', '),
propertyType: data.selectedType || sel.propertyType || '',
sourceModel: m.label,
rangeLow: low,
rangeHigh: high,
mukim: sel.mukim || null,
scheme: sel.area || null,
district: sel.district || null,
state: sel.state || null,
});
};
return (
{/* header */}
Automated Valuation
{sel.area || sel.district}
{[sel.district, sel.state].filter(Boolean).join(', ')} · {regCount.toLocaleString('en-MY')} comparable transactions · NAPIC 2021–2026
{/* body */}
{/* ===== LEFT — estimate + model switcher ===== */}
Valuation model
{models.map((x, i) => {
const on = i === modelIdx;
return (
setModelIdx(i)} style={{
padding: '9px 4px', borderRadius: 8, cursor: 'pointer',
border: `1px solid ${on ? C.deep : C.border}`,
background: on ? C.deep : C.cream, color: on ? C.cream : C.mid,
fontFamily: "'DM Sans',sans-serif", fontSize: 12, fontWeight: 600,
transition: 'all .18s',
}}>{x.short}
);
})}
Estimated Market Value · {m.label}
{hasDisplayableEstimate && (
LIVE
)}
{avgGuardBlocked && (
DATA UNAVAILABLE
)}
{estimateLoading && (
FETCHING…
)}
{estimateLoading && (
{guardChecking ? 'Checking recent transaction average...' : 'Loading valuation from server...'}
)}
{estimateUnavailable && (
{unavailableMessage}
)}
{formatRM(displayedPoint)}
Likely range {rmCompact(low)} – {rmCompact(high)}
{valPerSqm && ≈ RM {valPerSqm.toLocaleString()}/m² }
{/* confidence band across all models */}
{models.map((x, i) => i !== modelIdx && x.live && (
))}
{rmCompact(dLow)}
{rmCompact(dHigh)}
Confidence
{m.conf != null ? m.conf + '%' : '—'}
MdAPE
{m.mae}
vs median
= regMedian ? C.up : C.down}>
{(m.point >= regMedian ? '+' : '') + ((m.point / regMedian - 1) * 100).toFixed(1)}%
{m.note}
{hasDisplayableEstimate && onExportRoi && (
Export to ROI Calculator
)}
{/* ===== RIGHT — region data ===== */}
{/* recent transactions — real NAPIC Open Transaction Data, scoped to the
selected fields. Sits above the averages so the latest actual sales
lead the area read-out. */}
Recent transactions
{recentTxns && recentTxns.length
? (recentTotal > recentTxns.length
? `${recentTxns.length.toLocaleString('en-MY')} of ${recentTotal.toLocaleString('en-MY')} records`
: `${recentTxns.length.toLocaleString('en-MY')} record${recentTxns.length > 1 ? 's' : ''}`)
: 'matching records'}
{!recentLoading && recentTxns && recentTxns.length > 0 && recentNote && (
{recentNote}
)}
{recentLoading ? (
Loading recent transactions…
) : (recentTxns && recentTxns.length) ? (
Date
Type
Road / Area
m²
Price
{recentTxns.map((r, i) => )}
) : (
No transaction records found for this selection.
)}
{/* avg price by property type — real averages from the Open Transaction Data */}
Average price by property type
avg · count
{byType.length === 0 ? (
No transaction records found for this selection.
) : (
{byType.map(t => {
const dom = dominantType && t.type === dominantType.type;
return (
{shortType(t.type)}
{rmCompact(t.avg)}
· {t.n}
);
})}
)}
{/* price trend by year — taller bar chart (left half) paired with a
price-growth line chart (right half). Split so the sparse bars read
clearly and the up/down trajectory gets its own panel. */}
Average transacted price by year
{yearScope === 'type' && sel.propertyType
? shortType(sel.propertyType)
: yearScope === 'area' ? 'all types' : '2021–2026'}
{yearAvg.length === 0 ? (
No transaction records found for this selection.
) : (
{/* left — average price (bars) + volume (dots) */}
bar = avg price · ● = volume
{yearAvg.map(y => {
const h = 26 + ((y.avg - minYear) / (maxYear - minYear || 1)) * 120;
return (
{rmCompact(y.avg)}
{y.n}
{y.y}
);
})}
{/* right — price-growth trajectory (line) */}
price growth
{yearAvg.length > 1 && (
P/L {(yUp ? '+' : '') + yGrowth.toFixed(1) + '%'}
)}
{yearAvg.length > 1 && (
Overall P/L ({yearAvg[0].y}-{yearAvg[yearAvg.length - 1].y})
{(yUp ? '+' : '') + yGrowth.toFixed(1) + '%'}
)}
)}
Indicative AVM estimate based on NAPIC 2021–2026 comparable transactions. Areas in sq.m. Not a formal valuation.
{loading && (
Computing valuation…
)}
);
};
Object.assign(window, { ValuationDashboard });