/* ============================================================
resolver.js — Property Detail panel, AI ranking, SLA cadastral
============================================================ */
/* ── URL helpers ─────────────────────────────────────────────────────────── */
function clearResolver(){
['rawAddress','propType','tenure','area','psf','price','txDate'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('output').innerHTML = '';
document.getElementById('tx-summary').style.display = 'none';
// Restore the landed manual-entry form if it was hidden for a condo transaction
const formCard = document.getElementById('manual-entry-card');
if(formCard) formCard.style.display = '';
// Tear down all satellite map instances so they re-initialise cleanly next time
Object.values(_satMaps).forEach(m => { try{ m.remove(); }catch(e){} });
Object.keys(_satMaps).forEach(k => delete _satMaps[k]);
Object.keys(_satMapEls).forEach(k => delete _satMapEls[k]);
window._lotPolys = {};
}
function parseStreet(raw){ return raw.replace(/^XX\s+/i,'').trim().toUpperCase(); }
function gmapsUrl(addr, postal){
// Postal code alone is the most precise query for Singapore addresses
const query = (postal && /^\d{6}$/.test(String(postal)))
? `Singapore ${postal}`
: addr;
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
function streetViewUrl(lat, lng){
return `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`;
}
function pgUrl(addr){
return `https://www.propertyguru.com.sg/property-for-sale?market=residential&freetext=${encodeURIComponent(addr)}`;
}
function epUrl(addr){
return `https://www.edgeprop.sg/property-search?q=${encodeURIComponent(addr)}`;
}
function uraMapUrl(postal){
return `https://www.ura.gov.sg/maps/?service=mp&postal=${postal}`;
}
/* ── Main resolve function ───────────────────────────────────────────────── */
async function resolve(){
const btn = document.getElementById('resolveBtn');
const out = document.getElementById('output');
// openaiKey is '__server__' if configured, '' if not
const openaiKey = App.openaiKey || '';
btn.disabled = true;
btn.innerHTML = ' Searching…';
const rawAddress = document.getElementById('rawAddress').value.trim();
const type = document.getElementById('propType').value.trim();
const tenure = document.getElementById('tenure').value.trim();
const date = document.getElementById('txDate').value.trim();
const area = document.getElementById('area').value.trim();
const psf = document.getElementById('psf').value.trim();
const price = document.getElementById('price').value.trim();
const street = parseStreet(rawAddress);
function setStatus(msg){
out.innerHTML = `
${msg}
`;
}
try {
setStatus(`Looking up ${street}…`);
const [addresses, sgIndex] = await Promise.all([
getAllAddresses(street),
loadSgIndex()
]);
const tx = { street, type, tenure, area, psf, price, date };
let exactStreet = addresses.filter(c => {
const addr = (c.ADDRESS || '').toUpperCase();
const postal = (c.POSTAL || '');
return /^\d{6}$/.test(postal) && addr.includes(street);
});
if(exactStreet.length === 0){
setStatus(`Identifying properties on ${street}…`);
exactStreet = await bruteForceHouseNumbers(street, () => {});
}
if(exactStreet.length === 0){
out.innerHTML = `
No registered addresses found on ${street} .
Check spelling — use "PALM ROAD" not "PALM RD". You can also try a keyword, e.g. "CHARTWELL".
`;
btn.disabled = false;
btn.innerHTML = ` Identify Address`;
return;
}
const txAreaSqft = parseFloat(area) || 0;
const slaResult = sgIndex
? enrichWithSlaAreas(exactStreet, sgIndex, txAreaSqft)
: { candidates: exactStreet, calibFactor: 1.0, calibNote: null };
const enriched = slaResult.candidates;
const calibNote = slaResult.calibNote;
const areaHits = enriched.filter(c => c.sla_area_sqft).length;
if(!openaiKey){
out.innerHTML = renderNoKeyResult(enriched, street, calibNote, areaHits);
} else {
setStatus(`Identifying property…`);
const result = await gptRank(openaiKey, enriched, tx, calibNote);
out.innerHTML = renderResults(result, enriched, calibNote);
}
// Auto-initialise the satellite map for the best match card (idx 0)
setTimeout(() => toggleSatMap(0), 50);
} catch(e){
out.innerHTML = `⚠ ${e.message}
`;
}
btn.disabled = false;
btn.innerHTML = ` Identify Address`;
}
/* ── Results rendering ───────────────────────────────────────────────────── */
function confBadge(score){
const [bg, fg, label] = score >= 70
? ['rgba(16,185,129,.15)','#10b981','High Match']
: score >= 45
? ['rgba(245,158,11,.15)','#f59e0b','Possible Match']
: ['rgba(239,68,68,.15)','#ef4444','Low Match'];
return `${label} `;
}
/* ── Land plot geometry helpers ──────────────────────────────────────────── */
/**
* Compute approximate lot dimensions (frontage, depth, orientation) from a
* flat polygon array [lng0,lat0, lng1,lat1, ...].
* Uses the minimum bounding rectangle along the polygon's longest edge axis.
*/
function computeLotDimensions(flatPoly){
const n = flatPoly.length / 2;
if(n < 3) return null;
const lat0 = flatPoly[1];
const mPerLng = 111320 * Math.cos(lat0 * Math.PI / 180);
const mPerLat = 110540;
// Vertices in metres
const verts = [];
for(let i = 0; i < n; i++)
verts.push([flatPoly[i*2] * mPerLng, flatPoly[i*2+1] * mPerLat]);
// Find the longest edge — this defines the primary axis
let bestLen = 0, bestAngle = 0;
for(let i = 0; i < n; i++){
const j = (i + 1) % n;
const dx = verts[j][0] - verts[i][0];
const dy = verts[j][1] - verts[i][1];
const len = Math.sqrt(dx*dx + dy*dy);
if(len > bestLen){ bestLen = len; bestAngle = Math.atan2(dy, dx); }
}
// Project all vertices onto [main axis, perpendicular]
const cosA = Math.cos(bestAngle), sinA = Math.sin(bestAngle);
let minMain = Infinity, maxMain = -Infinity;
let minPerp = Infinity, maxPerp = -Infinity;
for(const [x, y] of verts){
const m = x*cosA + y*sinA;
const p = -x*sinA + y*cosA;
if(m < minMain) minMain = m;
if(m > maxMain) maxMain = m;
if(p < minPerp) minPerp = p;
if(p > maxPerp) maxPerp = p;
}
const extMain = maxMain - minMain;
const extPerp = maxPerp - minPerp;
const depthFt = Math.round(Math.max(extMain, extPerp) * 3.28084);
const frontageFt = Math.round(Math.min(extMain, extPerp) * 3.28084);
// Orientation of the longer axis expressed as a compass pair
const longerAngle = extMain >= extPerp ? bestAngle : bestAngle + Math.PI / 2;
const deg = ((longerAngle * 180 / Math.PI) % 180 + 180) % 180;
const orientation = deg < 22.5 || deg >= 157.5 ? 'E–W'
: deg < 67.5 ? 'NE–SW'
: deg < 112.5 ? 'N–S'
: 'NW–SE';
return { frontageFt, depthFt, orientation };
}
/**
* Render a compact land-plot panel: SVG polygon shape + key lot stats.
* flatPoly = [lng0,lat0, ...], areaSqft = calibrated land area.
*/
function renderPolygonWidget(flatPoly, areaSqft, idx = 0){
if(!flatPoly || flatPoly.length < 6) return '';
const n = flatPoly.length / 2;
const lngs = [], lats = [];
for(let i = 0; i < n; i++){ lngs.push(flatPoly[i*2]); lats.push(flatPoly[i*2+1]); }
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const cLat = (minLat + maxLat) / 2;
const cosLat = Math.cos(cLat * Math.PI / 180);
// Scale maintaining true aspect ratio (convert degrees → metres)
const dLngM = (maxLng - minLng) * cosLat * 111320 || 1e-6;
const dLatM = (maxLat - minLat) * 110540 || 1e-6;
const W = 140, H = 140, PAD = 14;
const scale = Math.min((W - 2*PAD) / dLngM, (H - 2*PAD) / dLatM);
const usedW = dLngM * scale, usedH = dLatM * scale;
const offX = (W - usedW) / 2, offY = (H - usedH) / 2;
// SVG points — north up (lat increases upward → y flipped)
const pts = [];
for(let i = 0; i < n; i++){
const x = offX + (lngs[i] - minLng) * cosLat * 111320 * scale;
const y = H - offY - (lats[i] - minLat) * 110540 * scale;
pts.push(`${x.toFixed(1)},${y.toFixed(1)}`);
}
const dims = computeLotDimensions(flatPoly);
const cx = W - 19, cy = 19; // compass centre (top-right)
// Store polygon per-card for the satellite map
window._lotPolys = window._lotPolys || {};
window._lotPolys[idx] = flatPoly;
// Best match (idx 0) shows the map open by default; others are collapsed
const mapDisplay = idx === 0 ? 'block' : 'none';
const btnLabel = idx === 0 ? '▲ Hide Map' : '🛰️ View Satellite + Lot Outline';
return ``;
}
function extLink(href, cls, label){
return `${label} `;
}
function renderResults(result, rawCandidates, calibNote){
let html = '';
if(result.analysis){
html += `
Analysis Summary
${result.analysis}
${result.land_area_note
? `
${result.land_area_note}
`
: ''}
`;
}
html += `Candidate Addresses
`;
(result.candidates||[]).forEach((c, i) => {
const isTop = i === 0;
const raw = rawCandidates.find(r => r.POSTAL === (c.postal||''));
const areaRow = raw?.sla_area_sqft
? `
Land Area
${raw.sla_area_sqft.toLocaleString()} sqft
`
: '';
const factors = [
areaRow,
c.land_area_fit ? `
Area Match ${c.land_area_fit}
` : '',
c.tenure_fit ? `
Tenure ${c.tenure_fit}
` : '',
c.type_fit ? `
Property Type ${c.type_fit}
` : '',
].filter(Boolean).join('');
const polyWidget = renderPolygonWidget(raw?.plot_polygon, raw?.sla_area_sqft, i);
html += `
${isTop ? '
Best Match
' : ''}
${c.address}
Postal Code: ${c.postal||'—'}
${confBadge(c.confidence)}
${polyWidget}
${factors ? `
${factors}
` : ''}
${c.reason ? `
${c.reason}
` : ''}
${extLink(gmapsUrl(c.address,c.postal), 'ext-maps', 'Map')}
${c.lat&&c.lng ? extLink(streetViewUrl(c.lat,c.lng), 'ext-sv', 'Street View') : ''}
${extLink(pgUrl(c.address), 'ext-pg', 'PropertyGuru')}
${extLink(epUrl(c.address), 'ext-ep', 'EdgeProp')}
`;
});
html += '
';
const rawHtml = rawCandidates.map(c =>
`
${c.ADDRESS}
${c.POSTAL||'—'}
Map
`
).join('');
html += ``;
return html;
}
/**
* Score a single candidate against the URA transaction area.
* Uses a Gaussian decay on the calibrated area % deviation.
* Returns score 0–95 and a human-readable area match note.
*/
function scoreCandidate(c, txAreaSqft){
if(!c.sla_area_sqft || txAreaSqft <= 0)
return { score: 18, areaNote: 'No SLA area data — cannot assess area match' };
const pct = Math.abs(c.sla_area_sqft - txAreaSqft) / txAreaSqft;
const score = Math.max(5, Math.round(95 * Math.exp(-8 * pct * pct)));
const diffSqft = Math.round(c.sla_area_sqft - txAreaSqft);
const sign = diffSqft >= 0 ? '+' : '';
const pctLabel = Math.round(pct * 100);
const areaNote = pct <= 0.03
? `Near-exact match (${sign}${diffSqft.toLocaleString()} sqft)`
: pct <= 0.12
? `Close match (${sign}${diffSqft.toLocaleString()} sqft, ${pctLabel}% off)`
: pct <= 0.25
? `Moderate match (${sign}${diffSqft.toLocaleString()} sqft, ${pctLabel}% off)`
: `Poor area match (${sign}${diffSqft.toLocaleString()} sqft, ${pctLabel}% off)`;
return { score, areaNote };
}
/** Rank candidates by calibrated SLA area proximity to the URA transaction area. */
function rankCandidatesLocally(candidates, txAreaSqft){
return candidates
.map(c => {
const { score, areaNote } = scoreCandidate(c, txAreaSqft);
return { ...c, _score: score, _areaNote: areaNote };
})
.sort((a, b) => b._score - a._score);
}
function renderNoKeyResult(candidates, street, calibNote, areaHits){
const txAreaSqft = parseFloat(document.getElementById('area').value) || 0;
const ranked = rankCandidatesLocally(candidates, txAreaSqft);
// Detect ties at the top — multiple candidates with the same (high) score
const topScore = ranked[0]?._score ?? 0;
const tied = ranked.filter(c => c._score === topScore && topScore >= 60).length > 1;
let html = '';
if(areaHits === 0){
html += `
No land area data available for addresses on ${street}.
Candidates are listed in street order.
`;
} else {
html += `
Results ordered by match confidence.
${tied ? ' Multiple candidates are equally matched — use Map or Street View to verify.' : ''}
`;
}
html += ``;
ranked.forEach((c, i) => {
const isTop = i === 0 && topScore >= 40;
const postal = (c.POSTAL && c.POSTAL !== 'NIL') ? c.POSTAL : null;
const sv = c.LATITUDE && c.LONGITUDE ? streetViewUrl(c.LATITUDE, c.LONGITUDE) : null;
const areaMatchLine = c.sla_area_sqft
? `
Area Match
${c._areaNote}
`
: '';
const polyWidget = renderPolygonWidget(c.plot_polygon, c.sla_area_sqft, i);
html += `
${isTop && !tied ? '
Best Match
' : ''}
${isTop && tied ? '
Tied Match
' : ''}
${c.ADDRESS}
Postal Code: ${postal || '—'}
${confBadge(c._score)}
${polyWidget}
${areaMatchLine ? `
${areaMatchLine}
` : ''}
${extLink(gmapsUrl(c.ADDRESS, c.POSTAL), 'ext-maps', 'Map')}
${sv ? extLink(sv, 'ext-sv', 'Street View') : ''}
`;
});
html += '
';
return html;
}
let _rawVisible = false;
function toggleRaw(){
_rawVisible = !_rawVisible;
document.getElementById('rawSection').style.display = _rawVisible ? 'block' : 'none';
document.getElementById('rawBtn').textContent = _rawVisible ? 'Hide all candidates' : 'Show all candidates';
}
/* ── SLA Cadastral Index ─────────────────────────────────────────────────── */
async function loadSgIndex(){
if(App.sgIndexState === 'loaded') return App.sgIndex;
if(App.sgIndexState === 'unavailable') return null;
if(App.sgIndexState === 'loading'){
while(App.sgIndexState === 'loading') await new Promise(r => setTimeout(r, 100));
return App.sgIndex;
}
App.sgIndexState = 'loading';
try {
const r = await fetch('./sg_cadastral_index.json');
if(!r.ok) throw new Error(`HTTP ${r.status}`);
App.sgIndex = await r.json();
App.sgIndexState = 'loaded';
console.log(`SLA cadastral index loaded: ${Object.keys(App.sgIndex.cells||{}).length.toLocaleString()} grid cells`);
} catch(e){
App.sgIndex = null;
App.sgIndexState = 'unavailable';
console.info('SLA cadastral index not available:', e.message);
}
return App.sgIndex;
}
/** Ray-casting point-in-polygon — flatPoly = [lng0,lat0,lng1,lat1,...] */
function pointInPoly(lat, lng, flatPoly){
let inside = false;
const n = flatPoly.length / 2;
let j = n - 1;
for(let i = 0; i < n; i++){
const xi = flatPoly[i*2], yi = flatPoly[i*2+1];
const xj = flatPoly[j*2], yj = flatPoly[j*2+1];
if(((yi>lat) !== (yj>lat)) && (lng < (xj-xi)*(lat-yi)/(yj-yi)+xi)) inside = !inside;
j = i;
}
return inside;
}
function lookupAreaFromIndex(lat, lng, index){
if(!index?.cells) return null;
const res = index.meta?.grid_res || 0.001;
const row = Math.floor(lat / res);
const col = Math.floor(lng / res);
let containing = [], nearby = [];
for(let dr = -1; dr <= 1; dr++){
for(let dc = -1; dc <= 1; dc++){
const parcels = index.cells[`${row+dr}_${col+dc}`];
if(!parcels) continue;
for(const parcel of parcels){
(pointInPoly(lat, lng, parcel.p) ? containing : nearby).push(parcel);
}
}
}
if(containing.length > 0){
const best = containing.reduce((a,b) => a.a > b.a ? a : b);
return { area_sqft: best.a, area_sqm: +(best.a*0.092903).toFixed(1), polygon: best.p };
}
const plausible = nearby.filter(p => p.a > 500);
if(plausible.length > 0){
const best = plausible.reduce((a,b) => a.a > b.a ? a : b);
return { area_sqft: best.a, area_sqm: +(best.a*0.092903).toFixed(1), polygon: best.p };
}
return null;
}
function enrichWithSlaAreas(candidates, index, txAreaSqft){
// Step 1: raw SLA lookup
const raw = candidates.map(c => {
const lat = parseFloat(c.LATITUDE || c.lat);
const lng = parseFloat(c.LONGITUDE || c.lng);
if(!lat || !lng) return c;
const d = lookupAreaFromIndex(lat, lng, index);
if(d) return { ...c, sla_area_sqft: d.area_sqft, sla_area_sqm: d.area_sqm, plot_polygon: d.polygon };
return c;
});
// Step 2: street-level calibration
const slaSamples = raw.filter(c => c.sla_area_sqft).map(c => c.sla_area_sqft);
let calibFactor = 1.0, calibNote = null;
if(slaSamples.length >= 3 && txAreaSqft > 0){
const sorted = [...slaSamples].sort((a,b) => a-b);
const medianSla = sorted[Math.floor(sorted.length/2)];
const ratio = txAreaSqft / medianSla;
if(ratio > 0.08 && ratio < 3.0 && Math.abs(ratio-1.0) > 0.08){
calibFactor = ratio;
const pct = Math.round(Math.abs(1-ratio)*100);
const dir = ratio < 1 ? 'larger' : 'smaller';
calibNote = `SLA land areas on this street are systematically ${pct}% ${dir} than the URA transaction area. `
+ (ratio < 1
? `Likely cause: SLA cadastral polygons include road/drain reserve strips deducted from URA records.`
: `Likely cause: geocoding offset where address coordinates land on a small adjacent parcel.`);
}
}
// Step 3: apply calibration (polygon kept at raw geo coords — no scaling needed)
const calibrated = raw.map(c => {
if(!c.sla_area_sqft) return c;
const cal = +(c.sla_area_sqft * calibFactor).toFixed(1);
return {
...c,
sla_area_sqft: cal,
sla_area_sqm: +(cal * 0.092903).toFixed(1),
};
});
// calibNote is kept for internal debugging but not surfaced in the UI
return { candidates: calibrated, calibFactor, calibNote };
}
/* ============================================================
Satellite lot-outline map (Leaflet + Esri World Imagery)
============================================================ */
const _satMaps = {}; // Leaflet map instances keyed by card index
const _satMapEls = {}; // corresponding DOM elements
/**
* Toggle the satellite map for card at position `idx`.
* Uses Esri World Imagery (free, no API key) + labels overlay.
* Polygon data is read from window._lotPolys[idx].
*/
function toggleSatMap(idx){
const container = document.getElementById(`lot-sat-map-${idx}`);
const btn = document.getElementById(`sat-map-btn-${idx}`);
if(!container) return;
if(typeof L === 'undefined'){
alert('Map library not loaded — please check your internet connection and reload.');
return;
}
const isHidden = container.style.display === 'none' || !container.style.display;
if(isHidden){
container.style.display = 'block';
if(btn) btn.textContent = '▲ Hide Map';
if(_satMaps[idx] && _satMapEls[idx] === container){
_satMaps[idx].invalidateSize();
} else {
if(_satMaps[idx]){ try{ _satMaps[idx].remove(); }catch(e){} }
_satMapEls[idx] = container;
const map = L.map(container, {
zoomControl: true,
scrollWheelZoom: false,
attributionControl: false, // attribution removed per user request
});
_satMaps[idx] = map;
// ── Base: Esri World Imagery ─────────────────────────────────────────
L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 21, maxNativeZoom: 19 }
).addTo(map);
// ── Labels: road names + building names on top of satellite ──────────
L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 21, maxNativeZoom: 19, pane: 'overlayPane', opacity: 0.85 }
).addTo(map);
// ── Cadastral polygon ─────────────────────────────────────────────────
const flatPoly = (window._lotPolys || {})[idx];
if(flatPoly && flatPoly.length >= 6){
const latlngs = [];
for(let i = 0; i < flatPoly.length; i += 2){
latlngs.push([flatPoly[i + 1], flatPoly[i]]);
}
const poly = L.polygon(latlngs, {
color: '#2563eb',
weight: 2.5,
opacity: 0.95,
fillColor: '#3b82f6',
fillOpacity: 0.15,
}).addTo(map);
map.fitBounds(poly.getBounds(), { padding: [28, 28] });
}
}
} else {
container.style.display = 'none';
if(btn) btn.textContent = '🛰️ View Satellite + Lot Outline';
}
}
/* ============================================================
Condo / Non-landed — Property Detail with Price Trend
============================================================ */
const UNIT_COLORS = {
'Studio': '#64748b',
'1BR': '#059669',
'2BR': '#2563eb',
'3BR': '#7c3aed',
'4BR': '#d97706',
'5BR': '#dc2626',
'PH': '#0891b2',
};
/** Convert URA contract date string (MMYY) to a numeric year value for charting */
function txDateToNum(cd){
if(!cd || cd.length < 4) return 0;
const mm = parseInt(cd.substring(0,2), 10);
const yy = parseInt(cd.substring(2,4), 10);
const year = yy + (yy < 50 ? 2000 : 1900);
return year + (mm - 1) / 12;
}
/** Median of an array of numbers */
function _median(arr){
if(!arr.length) return 0;
const s = [...arr].sort((a,b) => a-b);
const m = Math.floor(s.length / 2);
return s.length % 2 === 0 ? (s[m-1] + s[m]) / 2 : s[m];
}
/* ── Interactive price trend chart ─────────────────────────────────────────
State is kept at module level so the toggle callbacks (chartRange,
chartToggleType, chartDots) can redraw without full-card re-render.
──────────────────────────────────────────────────────────────────────── */
let _chart = { tx: [], cur: null, hidden: new Set(), range: 0, dots: false };
/** Entry point: initialise state and return full chart HTML. */
function renderPriceTrendChart(projectTx, currentTx){
_chart.tx = projectTx || [];
_chart.cur = currentTx;
_chart.hidden = new Set();
_chart.range = 0;
_chart.dots = false;
return `${_chartBuild()}
`;
}
/** Public callbacks wired to buttons/legend. */
function chartRange(n) { _chart.range = n; _chartRedraw(); }
function chartDots() { _chart.dots = !_chart.dots; _chartRedraw(); }
function chartToggleType(type) {
if(_chart.hidden.has(type)) _chart.hidden.delete(type);
else {
// Keep at least one type visible
const allTypes = [...new Set(_chart.tx.map(t => inferUnitType(sqmToSqft(parseFloat(t.area)||0))))];
if(_chart.hidden.size < allTypes.length - 1) _chart.hidden.add(type);
}
_chartRedraw();
}
function _chartRedraw(){
const el = document.getElementById('tc-container');
if(el) el.innerHTML = _chartBuild();
}
/** Build the full chart HTML (toolbar + SVG + legend + note). */
function _chartBuild(){
const { range, dots, tx } = _chart;
const RANGES = [[0,'All'],[3,'3Y'],[2,'2Y'],[1,'1Y']];
const rangeBtns = RANGES.map(([n, lbl]) =>
`${lbl} `
).join('');
const dotBtn = `Per Sale `;
return `
${_chartSvg()}
${_chartLegend()}
${tx.length.toLocaleString()} transactions total · Lines = quarterly median PSF · Click legend items to toggle unit types · ★ = selected transaction
`;
}
/** Generate the legend as interactive toggle buttons. */
function _chartLegend(){
const { tx, cur, hidden } = _chart;
const curType = cur ? inferUnitType(sqmToSqft(parseFloat(cur.area)||0)) : null;
const allTypes = [...new Set(tx.map(t => inferUnitType(sqmToSqft(parseFloat(t.area)||0))))].sort();
return allTypes.map(ut => {
const col = UNIT_COLORS[ut] || '#64748b';
const isOff = hidden.has(ut);
const isCur = ut === curType;
const label = ut === '1BR' ? '1-Bed' : ut === '2BR' ? '2-Bed' : ut === '3BR' ? '3-Bed' :
ut === '4BR' ? '4-Bed' : ut === '5BR' ? '5-Bed' : ut === 'PH' ? 'Penthouse' : ut;
return `
${label}
`;
}).join('');
}
/** Generate the SVG chart from current `_chart` state. */
function _chartSvg(){
const { tx, cur, hidden, range, dots } = _chart;
// Apply year-range filter
let filtered = tx;
if(range > 0){
const maxDt = Math.max(...tx.map(t => txDateToNum(t.contractDate)));
const cutoff = maxDt - range;
filtered = tx.filter(t => txDateToNum(t.contractDate) >= cutoff);
}
// Build enriched point list, skip hidden unit types
const pts = filtered.map(t => {
const sqft = sqmToSqft(parseFloat(t.area) || 0);
const price = parseInt(t.price) || 0;
const psf = sqft > 0 ? Math.round(price / sqft) : 0;
const dt = txDateToNum(t.contractDate);
const mm = parseInt((t.contractDate||'00').substring(0,2), 10) || 1;
const yy = parseInt((t.contractDate||'0000').substring(2,4), 10);
const year = yy + (yy < 50 ? 2000 : 1900);
const q = Math.ceil(mm / 3) || 1;
const qNum = year + (q - 1) * 0.25;
const ut = inferUnitType(sqft);
return { psf, dt, qNum, unitType: ut, sqft, t, isCurrent: t === cur, xJitter: 0 };
}).filter(p => p.psf > 0 && p.dt > 0 && !hidden.has(p.unitType));
if(!pts.length){
return 'No data for the selected filters.
';
}
const W = 600, H = 280;
const L = 62, R = 18, T = 22, B = 40;
const cW = W - L - R, cH = H - T - B;
const rawMinDt = Math.min(...pts.map(p => p.dt));
const rawMaxDt = Math.max(...pts.map(p => p.dt));
const rawRange = Math.max(rawMaxDt - rawMinDt, 0.5);
const xPad = rawRange * 0.05 + 0.06;
const xMin = rawMinDt - xPad;
const xMax = rawMaxDt + xPad;
const xRange = xMax - xMin;
const minPsf = Math.min(...pts.map(p => p.psf));
const maxPsf = Math.max(...pts.map(p => p.psf));
const psfSpan = Math.max(maxPsf - minPsf, 200);
const psfLow = Math.max(0, minPsf - psfSpan * 0.12);
const psfHigh = maxPsf + psfSpan * 0.12;
const xScale = d => L + (d - xMin) / xRange * cW;
const yScale = p => T + cH - (p - psfLow) / (psfHigh - psfLow) * cH;
// ── Grid lines ─────────────────────────────────────────────────────────────
const psfStep = Math.ceil((psfHigh - psfLow) / 5 / 100) * 100 || 100;
const psfStart = Math.ceil(psfLow / psfStep) * psfStep;
let grid = '';
for(let v = psfStart; v <= psfHigh + 1; v += psfStep){
const y = yScale(v);
if(y < T - 2 || y > T + cH + 2) continue;
const lbl = v >= 1000 ? `${(v/1000).toFixed(1)}k` : v;
grid += ` `;
grid += `${lbl} `;
}
// ── X-axis tick labels ──────────────────────────────────────────────────────
let xLabels = '';
const showQuarters = rawRange < 2.5;
if(showQuarters){
const qNums = [...new Set(pts.map(p => p.qNum))].sort();
for(const qn of qNums){
const x = xScale(qn);
if(x < L + 5 || x > W - R - 5) continue;
const yr = Math.floor(qn);
const q = Math.round((qn - yr) / 0.25) + 1;
xLabels += ` `;
xLabels += `${yr} Q${q} `;
}
} else {
for(let yr = Math.ceil(xMin); yr <= Math.floor(xMax); yr++){
const x = xScale(yr);
if(x < L + 5 || x > W - R - 5) continue;
xLabels += ` `;
xLabels += `${yr} `;
}
}
const curUnitType = cur ? inferUnitType(sqmToSqft(parseFloat(cur.area)||0)) : null;
// ── Jitter for scatter dots ────────────────────────────────────────────────
if(dots){
const qBuckets = {};
pts.forEach((p, i) => {
if(!qBuckets[p.qNum]) qBuckets[p.qNum] = [];
qBuckets[p.qNum].push(i);
});
const budget = Math.min((cW * 0.25 / xRange) * 0.55, 16);
for(const idxs of Object.values(qBuckets)){
const n = idxs.length;
idxs.forEach((idx, rank) => {
pts[idx].xJitter = n === 1 ? 0 : (rank / (n - 1) - 0.5) * 2 * budget;
});
}
}
// ── Quarterly median trend lines ───────────────────────────────────────────
const byType = {};
for(const p of pts){
if(!byType[p.unitType]) byType[p.unitType] = {};
if(!byType[p.unitType][p.qNum]) byType[p.unitType][p.qNum] = [];
byType[p.unitType][p.qNum].push(p.psf);
}
let lines = '';
for(const [ut, quarters] of Object.entries(byType)){
const col = UNIT_COLORS[ut] || '#64748b';
const isCurUt = ut === curUnitType;
const medians = Object.entries(quarters)
.map(([qNum, psfs]) => ({ x: xScale(+qNum), y: yScale(_median(psfs)) }))
.sort((a, b) => a.x - b.x);
if(medians.length >= 2){
const poly = medians.map(m => `${m.x.toFixed(1)},${m.y.toFixed(1)}`).join(' ');
lines += ` `;
}
// Dots at each quarterly median
for(const m of medians){
lines += ` `;
}
}
// ── Scatter dots (optional) ────────────────────────────────────────────────
let scatter = '';
if(dots){
const sortedPts = [...pts].sort((a, b) => (a.isCurrent ? 1 : -1));
for(const p of sortedPts){
if(p.isCurrent) continue; // drawn separately below
const x = (xScale(p.dt) + p.xJitter).toFixed(1);
const y = yScale(p.psf).toFixed(1);
const col = UNIT_COLORS[p.unitType] || '#64748b';
const tip = `${p.unitType} · S$${p.psf.toLocaleString()}/sqft · ${p.sqft.toLocaleString()} sqft · ${formatTxDate(p.t.contractDate)}`;
scatter += `${tip} `;
}
}
// ── Current transaction marker ─────────────────────────────────────────────
let curMark = '';
if(cur){
const curPt = pts.find(p => p.isCurrent);
if(curPt && !hidden.has(curPt.unitType)){
const cx = xScale(curPt.dt).toFixed(1);
const cy = yScale(curPt.psf).toFixed(1);
const col = UNIT_COLORS[curPt.unitType] || '#64748b';
const tip = `★ This transaction · ${curPt.unitType} · S$${curPt.psf.toLocaleString()}/sqft · ${curPt.sqft.toLocaleString()} sqft`;
// Pulsing gold ring + white-centred dot
curMark += ` `;
curMark += `${tip} `;
curMark += `★ `;
}
}
// ── Y-axis label ───────────────────────────────────────────────────────────
const mid = (T + cH / 2).toFixed(1);
const yAxisLbl = `PSF (S$) `;
return `
${grid}
${xLabels}
${yAxisLbl}
${lines}
${scatter}
${curMark}
`;
}
/**
* @deprecated — superseded by renderPriceTrendChart
* Kept as an alias so any stale references don't crash.
*/
function renderPriceTrendSVG(projectTx, currentTx){
if(!projectTx || projectTx.length === 0){
return 'No transactions found for this project.
';
}
const W = 560, H = 270;
const L = 56, R = 18, T = 16, B = 36;
const cW = W - L - R, cH = H - T - B;
// Build enriched point list
const pts = projectTx.map(t => {
const sqft = sqmToSqft(parseFloat(t.area) || 0);
const price = parseInt(t.price) || 0;
const psf = sqft > 0 ? Math.round(price / sqft) : 0;
const dt = txDateToNum(t.contractDate);
const mm = parseInt((t.contractDate||'00').substring(0,2), 10) || 1;
const yy = parseInt((t.contractDate||'0000').substring(2,4), 10);
const year = yy + (yy < 50 ? 2000 : 1900);
const q = Math.ceil(mm / 3) || 1;
const qNum = year + (q - 1) * 0.25;
return { psf, dt, qNum, unitType: inferUnitType(sqft), sqft, t, isCurrent: t === currentTx, xJitter: 0 };
}).filter(p => p.psf > 0 && p.dt > 0);
if(!pts.length) return 'No valid data.
';
// ── X range with padding so dots aren't clipped at edges ──────────────────
const rawMinDt = Math.min(...pts.map(p => p.dt));
const rawMaxDt = Math.max(...pts.map(p => p.dt));
const rawRange = Math.max(rawMaxDt - rawMinDt, 0.5);
const xPad = rawRange * 0.06 + 0.08;
const xMin = rawMinDt - xPad;
const xMax = rawMaxDt + xPad;
const xRange = xMax - xMin;
const minPsf = Math.min(...pts.map(p => p.psf));
const maxPsf = Math.max(...pts.map(p => p.psf));
const psfRange = Math.max(maxPsf - minPsf, 200);
const psfLow = Math.max(0, minPsf - psfRange * 0.12);
const psfHigh = maxPsf + psfRange * 0.12;
const xScale = d => L + (d - xMin) / xRange * cW;
const yScale = p => T + cH - (p - psfLow) / (psfHigh - psfLow) * cH;
// ── Horizontal jitter: spread same-quarter dots so they don't stack ────────
// Each quarter gets a fixed pixel budget; dots spaced evenly within it.
const qBuckets = {};
pts.forEach((p, i) => {
if(!qBuckets[p.qNum]) qBuckets[p.qNum] = [];
qBuckets[p.qNum].push(i);
});
const jitterBudget = Math.min((cW * 0.25 / xRange) * 0.55, 18); // px
for(const idxs of Object.values(qBuckets)){
const n = idxs.length;
idxs.forEach((ptIdx, rank) => {
pts[ptIdx].xJitter = n === 1 ? 0 : (rank / (n - 1) - 0.5) * 2 * jitterBudget;
});
}
// ── Y-axis grid lines ──────────────────────────────────────────────────────
const psfStep = Math.ceil((psfHigh - psfLow) / 5 / 100) * 100 || 100;
const psfStart = Math.ceil(psfLow / psfStep) * psfStep;
let gridLines = '';
for(let v = psfStart; v <= psfHigh + 1; v += psfStep){
const y = yScale(v);
if(y < T - 2 || y > T + cH + 2) continue;
const label = v >= 1000 ? `${(v/1000).toFixed(1)}k` : v;
gridLines += ` `;
gridLines += `${label} `;
}
// ── X-axis date labels (quarters or years depending on span) ───────────────
let xLabels = '';
const showQuarters = rawRange < 2.5; // show quarter labels for short-span projects
if(showQuarters){
// Label every quarter that has data
const qNums = [...new Set(pts.map(p => p.qNum))].sort();
for(const qn of qNums){
const x = xScale(qn);
if(x < L + 5 || x > W - R - 5) continue;
const yr = Math.floor(qn);
const q = Math.round((qn - yr) / 0.25) + 1;
xLabels += ` `;
xLabels += `${yr} Q${q} `;
}
} else {
for(let yr = Math.ceil(xMin); yr <= Math.floor(xMax); yr++){
const x = xScale(yr);
if(x < L + 5 || x > W - R - 5) continue;
xLabels += ` `;
xLabels += `${yr} `;
}
}
const curUnitType = currentTx ? inferUnitType(sqmToSqft(parseFloat(currentTx.area) || 0)) : null;
// ── Quarterly median trend lines per unit type ─────────────────────────────
const byType = {};
for(const p of pts){
if(!byType[p.unitType]) byType[p.unitType] = {};
if(!byType[p.unitType][p.qNum]) byType[p.unitType][p.qNum] = [];
byType[p.unitType][p.qNum].push(p.psf);
}
let trendLines = '';
for(const [ut, quarters] of Object.entries(byType)){
const col = UNIT_COLORS[ut] || '#64748b';
const isHighlight = ut === curUnitType;
const medians = Object.entries(quarters)
.map(([qNum, psfs]) => ({ x: xScale(+qNum), y: yScale(_median(psfs)) }))
.sort((a, b) => a.x - b.x);
if(medians.length >= 2){
const polyPts = medians.map(m => `${m.x.toFixed(1)},${m.y.toFixed(1)}`).join(' ');
// Wider glow behind highlighted line for visibility
if(isHighlight){
trendLines += ` `;
}
trendLines += ` `;
}
// Median marker dots on the line
for(const m of medians){
trendLines += ` `;
}
}
// ── Jittered scatter dots (background context) ─────────────────────────────
const sortedPts = [...pts].sort((a, b) => (a.isCurrent ? 1 : -1));
let dots = '';
for(const p of sortedPts){
const x = (xScale(p.dt) + p.xJitter).toFixed(1);
const y = yScale(p.psf).toFixed(1);
const col = UNIT_COLORS[p.unitType] || '#64748b';
const dimmed = !p.isCurrent && curUnitType && p.unitType !== curUnitType;
const tip = `${p.unitType} · S$${p.psf.toLocaleString()}/sqft · ${p.sqft.toLocaleString()} sqft · ${formatTxDate(p.t.contractDate)}`;
if(p.isCurrent){
// Large ring marker for the selected transaction
dots += `${tip} ★ This Transaction `;
dots += ` `;
} else {
const op = dimmed ? 0.1 : 0.6;
dots += `${tip} `;
}
}
// ── Axis label ─────────────────────────────────────────────────────────────
const mid = (T + cH / 2).toFixed(1);
const yAxisLabel = `PSF (S$) `;
// ── Legend ─────────────────────────────────────────────────────────────────
const legendTypes = [...new Set(pts.map(p => p.unitType))].sort();
const legend = legendTypes.map(ut => {
const col = UNIT_COLORS[ut] || '#64748b';
const bold = ut === curUnitType ? 'font-weight:700;color:var(--text)' : 'color:var(--text2)';
const op = ut === curUnitType ? 1 : 0.55;
return `
${ut} `;
}).join('');
return `
${gridLines}
${xLabels}
${yAxisLabel}
${trendLines}
${dots}
${legend}
${projectTx.length.toLocaleString()} transactions · Lines show quarterly median PSF · Unit type estimated from floor area · ★ = current transaction
`;
}
/**
* Render the condo / non-landed property detail card into the output div.
* Called from selectTransaction() in browser.js when a non-landed row is clicked.
*/
function showCondoDetail(t){
// Hide the landed-only manual entry form
const formCard = document.getElementById('manual-entry-card');
if(formCard) formCard.style.display = 'none';
const areaSqft = sqmToSqft(parseFloat(t.area) || 0);
const price = parseInt(t.price) || 0;
const psf = areaSqft > 0 ? Math.round(price / areaSqft) : 0;
const project = t.project || t.street || '';
const floorStr = t.floorRange ? `Level ${t.floorRange}` : '—';
const saleTypeMap = { '1': 'New Sale', '2': 'Sub Sale', '3': 'Resale' };
const saleType = saleTypeMap[t.typeOfSale] || '—';
// Use the full 5-year dataset for price trends if available;
// fall back to the search-scoped set (may be year-filtered) if not yet loaded.
const allForTrend = App.allNonLandedTxFull || App.allNonLandedTx || [];
const projectTx = allForTrend.filter(tx => tx.project && tx.project === project);
// External links
const mapLink = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(project + ' Singapore')}`;
document.getElementById('output').innerHTML = `
${project}
${t.street||''} · District ${t.district||'—'} · ${t.marketSegment||''}
📍 Map
Floor ${floorStr}
Floor Area ${areaSqft.toLocaleString()} sqft
Tenure ${shortTenure(t.tenure)}
Type ${t.propertyType}
Sale Type ${saleType}
PSF S$${psf.toLocaleString()}/sqft
Price ${fmtPrice(t.price)}
Date ${formatTxDate(t.contractDate)}
Price Trend — ${project}
${renderPriceTrendChart(projectTx, t)}
`;
// Scroll the detail into view on mobile (it appears below the summary card)
setTimeout(() => {
const el = document.getElementById('output');
if(el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 80);
}