/* ============================================================ 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 `
N
Land Plot
${areaSqft ? `
Area${Math.round(areaSqft).toLocaleString()} sqft
` : ''} ${dims ? `
Frontage~${dims.frontageFt} ft
` : ''} ${dims ? `
Depth~${dims.depthFt} ft
` : ''} ${dims ? `
Orientation${dims.orientation}
` : ''}
`; } 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}
` : ''}
`; }); 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}
` : ''}
`; }); 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]) => `` ).join(''); const dotBtn = ``; return `
Range
${rangeBtns}
${dotBtn}
${_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 ``; }).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
${floorStr}
${areaSqft.toLocaleString()} sqft
${shortTenure(t.tenure)}
${t.propertyType}
${saleType}
S$${psf.toLocaleString()}/sqft
${fmtPrice(t.price)}
${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); }