/* 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 ( ); })}
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 && ( )}
{/* ===== 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) ? (
DateTypeRoad / AreaPrice {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 });