/* ============================================================ utils.js — Shared helpers (date, area, price, tenure) ============================================================ */ /** Parse URA contractDate "MMYY" → comparable number YYYYMM */ function parseTxDate(mmyy){ if(!mmyy || mmyy.length < 4) return 0; const mm = parseInt(mmyy.slice(0,2), 10); const yy = parseInt(mmyy.slice(2,4), 10); return (2000 + yy) * 100 + mm; } /** Parse 4-digit year from contractDate "MMYY" */ function parseTxYear(mmyy){ if(!mmyy || mmyy.length < 4) return 0; return 2000 + parseInt(mmyy.slice(2,4), 10); } /** Format "MMYY" → "Jan 2025" */ function formatTxDate(mmyy){ if(!mmyy || mmyy.length < 4) return '—'; const months = ['Jan','Feb','Mar','Apr','May','Jun', 'Jul','Aug','Sep','Oct','Nov','Dec']; const mm = parseInt(mmyy.slice(0,2), 10) - 1; const yy = parseInt(mmyy.slice(2,4), 10); return `${months[mm] || '?'} ${2000 + yy}`; } /** URA area is in sqm — convert to sqft */ function sqmToSqft(sqm){ return Math.round(parseFloat(sqm || 0) * 10.7639); } function sqftToSqm(sqft){ return (parseFloat(sqft) * 0.092903).toFixed(1); } /** Format price: 4200000 → "S$4.20M" */ function fmtPrice(p){ const n = parseFloat(p); if(!n) return '—'; if(n >= 1e6) return `S$${(n/1e6).toFixed(2)}M`; return `S$${Math.round(n/1000)}K`; } /** Format PSF: price / area_sqft */ function fmtPsf(price, areaSqft){ if(!price || !areaSqft) return '—'; return `S$${Math.round(parseFloat(price)/areaSqft).toLocaleString()} psf`; } /** Shorten property type label (maps URA raw values to display labels) */ function shortType(t){ switch(t){ // Landed case 'Terrace': return 'Terrace'; case 'Semi-detached': return 'Semi-D'; case 'Detached': return 'Detached'; case 'Strata Terrace': return 'Strata Terrace'; case 'Strata Semi-detached': return 'Strata Semi-D'; case 'Strata Detached': return 'Strata Detached'; // Non-landed case 'Apartment': return 'Apt'; case 'Condominium': return 'Condo'; case 'Executive Condominium': return 'EC'; default: return t || ''; } } /** Shorten tenure to badge text. * Raw format: "NNN yrs lease commencing from YYYY" | "Freehold" | "999 years leasehold" * Renders as: "NNNyr (YYYY)" — caller should set title= to the full raw string. */ function shortTenure(t){ if(!t) return '—'; if(/freehold/i.test(t)) return 'Freehold'; const durMatch = t.match(/^(\d+)\s*yr/i); const yrMatch = t.match(/(\d{4})/); const dur = durMatch ? durMatch[1] : ''; const yr = yrMatch ? yrMatch[1] : ''; if(dur) return yr ? `${dur}yr (${yr})` : `${dur}yr`; return t; } /** * Infer unit type from floor area (sqft). * URA transaction data has no bedroom field — area is the standard proxy. * Returns short label e.g. "Studio", "1BR", "2BR"... */ function inferUnitType(areaSqft){ if(!areaSqft || areaSqft <= 0) return '—'; if(areaSqft < 430) return 'Studio'; if(areaSqft < 700) return '1BR'; if(areaSqft < 980) return '2BR'; if(areaSqft < 1350) return '3BR'; if(areaSqft < 2000) return '4BR'; if(areaSqft < 3000) return '5BR'; return 'PH'; // Penthouse / large unit } /** CSS class for tenure badge */ function tenureTagClass(t){ if(!t) return 'tag-99'; if(/freehold/i.test(t)) return 'tag-fh'; if(/999/.test(t)) return 'tag-999'; return 'tag-99'; } /** * Convert SVY21 (Singapore local grid) to WGS84 lat/lng. * URA transaction data stores coordinates in SVY21: x = Easting, y = Northing. * Accuracy: ±5 m — sufficient for map markers. * * SVY21 parameters (SLA): * Origin 1°22'02.9154"N, 103°49'31.9752"E * False E 28001.642 m | False N 38744.572 m */ function svy21ToLatLng(northing, easting){ const oLat = 1.3674876666667; // origin latitude (degrees) const oLon = 103.82554916667; // origin longitude (degrees) const FN = 38744.572; // false northing (metres) const FE = 28001.642; // false easting (metres) // metres-per-degree at Singapore's latitude const mPerDegLat = 110540; const lat = oLat + (northing - FN) / mPerDegLat; const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180); const lng = oLon + (easting - FE) / mPerDegLon; return [lat, lng]; }