/* ============================================================
browser.js — Transaction browser: search, filter, table, map
============================================================ */
/* ── Browser hero-map state ─────────────────────────────────────────────── */
let _bMap = null; // Leaflet map instance
let _bLandedGrp = null; // MarkerClusterGroup for landed streets
let _bCondoGrp = null; // MarkerClusterGroup for condo projects
const _bLayerOn = { landed: true, condo: true };
let _bFirstLoad = true; // auto-fit bounds on first data load
let _bAllRows = []; // flat transaction array (all types) for _mapInspect
let _bMapStyle = 'satellite';
let _bSatLayers = [];
let _bStreetLayer = null;
/* ── Measure tool state ──────────────────────────────────────────────────── */
let _measureActive = false;
let _measurePts = [];
let _measureLine = null;
let _measureLabel = null;
/* Geocoded coordinates for Singapore streets and condo projects.
Loaded once from /static/geo_lookup.json at startup.
Populated by running: python scripts/build_geo_lookup.py */
let _geoLookup = { streets: {}, projects: {} };
/** Load geo_lookup.json — called once during app init. */
async function loadGeoLookup(){
try {
const res = await fetch('/static/geo_lookup.json');
if(res.ok) _geoLookup = await res.json();
} catch(e){
console.warn('[Map] geo_lookup.json not available — using district centroids as fallback');
}
}
/* District centroids (WGS84) — reliable fallback when a street/project
is not yet in geo_lookup.json. Run the geocoding script to fill the lookup. */
const DISTRICT_CENTROIDS = {
'01':[1.2802,103.8510], '02':[1.2758,103.8422], '03':[1.2885,103.8185],
'04':[1.2662,103.8205], '05':[1.3002,103.7755], '06':[1.2968,103.8533],
'07':[1.3012,103.8582], '08':[1.3117,103.8620], '09':[1.2950,103.8255],
'10':[1.3110,103.8025], '11':[1.3270,103.8315], '12':[1.3365,103.8498],
'13':[1.3340,103.8755], '14':[1.3172,103.8930], '15':[1.3078,103.9068],
'16':[1.3235,103.9405], '17':[1.3765,103.9680], '18':[1.3558,103.9420],
'19':[1.3705,103.8875], '20':[1.3658,103.8430], '21':[1.3285,103.7680],
'22':[1.3472,103.7068], '23':[1.3835,103.7488], '24':[1.4092,103.7178],
'25':[1.4368,103.7912], '26':[1.4012,103.8198], '27':[1.4285,103.8338],
'28':[1.4082,103.8698],
};
/** Simple deterministic hash for per-district jitter (fallback only). */
function _hashStr(s){
let h = 0;
for(let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return h;
}
/** Fallback: district centroid + deterministic jitter when no geocoded coord. */
function _centroidLatLng(district, seedStr){
const c = DISTRICT_CENTROIDS[String(district || '').padStart(2, '0')];
if(!c) return null;
const seed = _hashStr(seedStr || '');
const angle = ((seed & 0xFFFF) / 65536) * 2 * Math.PI;
const r = 250 + ((seed >>> 16 & 0x7FFF) / 32767) * 350;
return [ c[0] + r * Math.cos(angle) / 110540, c[1] + r * Math.sin(angle) / 111300 ];
}
/**
* Resolve a lat/lng for a map entity.
* For landed: look up the street name in geo_lookup.streets.
* For non-landed: look up the project name in geo_lookup.projects,
* falling back to the street name in geo_lookup.streets.
* Falls back to district centroid + jitter if nothing is found.
*/
function _entityLatLng(type, key, street, district){
if(type === 'landed'){
const coords = (_geoLookup.streets || {})[key];
if(coords) return coords;
// street key with alternate casing
const alt = (_geoLookup.streets || {})[key.toUpperCase()];
if(alt) return alt;
} else {
const proj = (_geoLookup.projects || {})[key];
if(proj) return proj;
const projAlt = (_geoLookup.projects || {})[key.toUpperCase()];
if(projAlt) return projAlt;
// fall back to street lookup
if(street){
const st = (_geoLookup.streets || {})[street] ||
(_geoLookup.streets || {})[street.toUpperCase()];
if(st) return st;
}
}
return _centroidLatLng(district, key);
}
/**
* Group a flat transaction array into per-street (landed) and per-project
* (non-landed) entities. Each entity holds all its transactions sorted
* newest-first.
*
* Returns an array of entity objects:
* { type, key, label, district, latlng, txs }
*/
function buildMapEntities(allRows){
const landedMap = new Map();
const condoMap = new Map();
for(const t of allRows){
const isNL = NONLANDED_TYPES.has(t.propertyType);
if(isNL){
const key = (t.project || t.street || '').toUpperCase();
if(!condoMap.has(key)){
condoMap.set(key, {
type: 'condo', key,
label: t.project || t.street || key,
district: t.district,
street: (t.street || '').toUpperCase(),
txs: [],
});
}
condoMap.get(key).txs.push(t);
} else {
const key = (t.street || '').toUpperCase();
if(!landedMap.has(key)){
landedMap.set(key, {
type: 'landed', key,
label: t.street || key,
district: t.district,
street: key,
txs: [],
});
}
landedMap.get(key).txs.push(t);
}
}
// Sort each entity's transactions newest-first
const sort = txs => txs.sort((a, b) => parseTxDate(b.contractDate) - parseTxDate(a.contractDate));
for(const e of landedMap.values()) sort(e.txs);
for(const e of condoMap.values()) sort(e.txs);
return [...landedMap.values(), ...condoMap.values()];
}
/** Initialise the hero map (called lazily on first updateBrowserMap). */
function initBrowserMap(){
if(_bMap) return;
const container = document.getElementById('browser-map');
if(!container || typeof L === 'undefined') return;
_bMap = L.map('browser-map', {
zoomControl: false,
scrollWheelZoom: false,
attributionControl: false,
});
// Zoom control — bottom-left so it doesn't clash with layer toggles (top-right)
L.control.zoom({ position: 'bottomleft' }).addTo(_bMap);
// ── Satellite base: Esri World Imagery (same as the property detail map) ──
const esriBase = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, minZoom: 10, maxNativeZoom: 19 }
);
// ── Satellite labels: road + place names over imagery ─────────────────────
const esriLabels = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, minZoom: 10, maxNativeZoom: 19, pane: 'overlayPane', opacity: 0.85 }
);
// ── Street fallback: OneMap Default ───────────────────────────────────────
_bStreetLayer = L.tileLayer(
'https://www.onemap.gov.sg/maps/tiles/Default/{z}/{x}/{y}.png',
{ maxZoom: 19, minZoom: 10 }
);
_bSatLayers = [esriBase, esriLabels];
esriBase.addTo(_bMap);
esriLabels.addTo(_bMap); // satellite is default
// Default view: whole Singapore
_bMap.setView([1.3521, 103.8198], 11);
// Enable scroll-wheel zoom only while pointer is inside the map
_bMap.on('mouseover', () => _bMap.scrollWheelZoom.enable());
_bMap.on('mouseout', () => _bMap.scrollWheelZoom.disable());
// Build cluster groups with colour-matched icons
function makeClusterGroup(colorClass){
return L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
iconCreateFunction(cluster){
const n = cluster.getChildCount();
return L.divIcon({
html: `
${n > 99 ? '99+' : n}
`,
iconSize: [34, 34],
className: '',
});
},
});
}
_bLandedGrp = makeClusterGroup('map-cluster-landed');
_bCondoGrp = makeClusterGroup('map-cluster-condo');
_bMap.addLayer(_bLandedGrp);
_bMap.addLayer(_bCondoGrp);
// ── Prevent popup closing on mobile ──────────────────────────────────────
// On touch devices, taps inside a tall popup can leak through to the map
// and trigger Leaflet's built-in "closePopupOnClick" behaviour, instantly
// closing the popup before the user can interact with it.
// Calling disableClickPropagation on the popup container on every open
// stops the touch event from reaching the map layer underneath.
_bMap.on('popupopen', function(ev){
const el = ev.popup.getElement();
if(el){
L.DomEvent.disableClickPropagation(el);
L.DomEvent.disableScrollPropagation(el);
}
});
// ── Scale bar (metric) ────────────────────────────────────────────────────
L.control.scale({ metric: true, imperial: false, position: 'bottomright' }).addTo(_bMap);
// ── North compass (redesigned: clean rose, 42 px, red-north convention) ────
const Compass = L.Control.extend({
options: { position: 'bottomright' },
onAdd() {
const d = L.DomUtil.create('div', 'map-compass');
d.title = 'North is up';
d.innerHTML = `
N
`;
L.DomEvent.disableClickPropagation(d);
return d;
},
});
new Compass().addTo(_bMap);
// ── Measure tool — click handler ──────────────────────────────────────────
_bMap.on('click', function(e){
if(!_measureActive) return;
_measurePts.push(e.latlng);
// Redraw polyline
if(_measureLine){ _bMap.removeLayer(_measureLine); _measureLine = null; }
if(_measureLabel){ _bMap.removeLayer(_measureLabel); _measureLabel = null; }
if(_measurePts.length >= 2){
_measureLine = L.polyline(_measurePts, {
color: '#2563eb', weight: 2.5, dashArray: '8 5', opacity: 0.9,
}).addTo(_bMap);
let totalM = 0;
for(let i = 1; i < _measurePts.length; i++)
totalM += _measurePts[i-1].distanceTo(_measurePts[i]);
const dist = totalM < 1000
? `${Math.round(totalM)} m`
: `${(totalM / 1000).toFixed(2)} km`;
_measureLabel = L.marker(_measurePts[_measurePts.length - 1], {
icon: L.divIcon({
html: `${dist}
`,
className: '',
iconAnchor: [-6, 16],
}),
zIndexOffset: 1000,
interactive: false,
}).addTo(_bMap);
}
});
}
/**
* Repopulate the hero map from the full all-types transaction array.
*
* One Leaflet marker is placed per unique street (landed) or project (non-landed).
* Coordinates come from geo_lookup.json (accurate OneMap geocoding); when a key
* is not yet geocoded we fall back to the district centroid.
*
* Each popup lists the most recent transactions for that street/project with an
* individual "→" Inspect button that links to the Property Detail panel.
*/
function updateBrowserMap(rows){
initBrowserMap();
if(!_bMap) return;
_bAllRows = rows;
_bLandedGrp.clearLayers();
_bCondoGrp.clearLayers();
const entities = buildMapEntities(rows);
const allBounds = [];
entities.forEach(entity => {
const latlng = _entityLatLng(entity.type, entity.key, entity.street, entity.district);
if(!latlng) return;
const isNL = entity.type === 'condo';
const color = isNL ? '#4f46e5' : '#d97706';
const border = isNL ? '#3730a3' : '#92400e';
const group = isNL ? _bCondoGrp : _bLandedGrp;
// Build popup rows for up to 5 most recent transactions
const recent = entity.txs.slice(0, 5);
const txRows = recent.map(t => {
const idx = _bAllRows.indexOf(t);
const areaSqft = sqmToSqft(parseFloat(t.area) || 0);
const areaStr = areaSqft ? areaSqft.toLocaleString() + ' sqft' : '';
const floorStr = t.floorRange ? `Lvl ${t.floorRange} ` : '';
return ``;
}).join('');
const n = entity.txs.length;
const d = entity.district ? `D${entity.district}` : '';
const more = n - recent.length;
// Always render a "View all in table" link so any entity is one click away
// from the full search results, even when all ≤5 transactions are visible.
const encLabel = encodeURIComponent(entity.label);
const distArg = entity.district || 0;
const moreTag = n > 0
? ``
: '';
const popupHtml = ``;
const marker = L.circleMarker(latlng, {
radius: 7, fillColor: color, color: border,
weight: 1.5, opacity: 0.9, fillOpacity: 0.82,
});
marker.bindPopup(popupHtml, {
maxWidth: 310, minWidth: 240, closeButton: true,
// autoPan would move the map to fit a tall popup, triggering
// MarkerCluster re-clustering which destroys the popup on mobile.
// The tx list is scrollable so the popup is always compact.
autoPan: false,
});
group.addLayer(marker);
allBounds.push(latlng);
});
// Status pill
const statusEl = document.getElementById('map-status');
if(statusEl){
const lCount = entities.filter(e => e.type === 'landed').length;
const cCount = entities.filter(e => e.type === 'condo').length;
statusEl.textContent = `${lCount.toLocaleString()} streets · `
+ `${cCount.toLocaleString()} projects · `
+ `${rows.length.toLocaleString()} transactions`;
}
if(_bFirstLoad && allBounds.length){
_bFirstLoad = false;
_bMap.fitBounds(allBounds, { padding: [30, 30], maxZoom: 13 });
}
}
/**
* Called from map popup Inspect button.
* idx references _bAllRows (the full cross-type dataset), NOT App.filtered.
* We temporarily expose just this transaction so selectTransaction(0) works.
*/
function _mapInspect(idx){
_bMap.closePopup();
const t = _bAllRows[idx];
if(!t) return;
App.filtered = [t];
App.currentTx = t;
selectTransaction(0);
}
/**
* Called from a map popup "View all N in table" link.
* Switches the table to the correct mode, pre-fills the search fields with
* the entity's name + the map's current year, then runs a search and scrolls.
*
* We do the tab/UI switch manually (rather than via switchSearchMode) so we
* can set the street input BEFORE triggering the search — switchSearchMode
* clears the input and auto-searches, which would override our values.
*/
function _mapViewAll(entityType, district, encodedLabel){
_bMap.closePopup();
// If the map is in fullscreen, exit it first so the page can scroll to the table.
if(_mapFs) toggleMapFullscreen();
const label = decodeURIComponent(encodedLabel);
const year = (document.getElementById('map-year-select') || {}).value || '';
const isLanded = entityType !== 'condo';
const mode = isLanded ? 'landed' : 'nonlanded';
// ── Switch tab UI (without triggering switchSearchMode's auto-search) ──
App.searchMode = mode;
document.getElementById('tab-landed').classList.toggle('mode-tab-active', isLanded);
document.getElementById('tab-nonlanded').classList.toggle('mode-tab-active', !isLanded);
document.getElementById('type-wrap-landed').style.display = isLanded ? '' : 'none';
document.getElementById('type-wrap-nonlanded').style.display = isLanded ? 'none' : '';
const streetInput = document.getElementById('searchStreet');
document.getElementById('streetLabel').firstChild.textContent =
isLanded ? 'Street Name ' : 'Project / Street ';
streetInput.placeholder = isLanded
? 'e.g. PALM ROAD, CHARTWELL DRIVE — leave blank for all streets'
: 'e.g. MARINA BAY, PINNACLE, SOMMERVILLE — leave blank for all';
// ── Apply filter values ───────────────────────────────────────────────
const distSel = document.getElementById('searchDistrict');
if(distSel) distSel.value = district ? String(district) : '';
const yearSel = document.getElementById('searchYear');
if(yearSel) yearSel.value = year;
streetInput.value = label;
// ── Scroll to the search bar, then run search ─────────────────────────
const anchor = document.querySelector('.mode-tabs');
if(anchor) anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(searchTransactions, 350);
}
/** Toggle landed / condo layer visibility from the floating buttons. */
function toggleMapLayer(type){
_bLayerOn[type] = !_bLayerOn[type];
const group = type === 'landed' ? _bLandedGrp : _bCondoGrp;
if(!group) return;
if(_bLayerOn[type]) _bMap.addLayer(group);
else _bMap.removeLayer(group);
const btn = document.getElementById(`map-toggle-${type}`);
if(btn) btn.classList.toggle('map-layer-active', _bLayerOn[type]);
}
/** Toggle between satellite imagery and OneMap street tiles. */
function toggleMapStyle(){
if(!_bMap) return;
const btn = document.getElementById('map-toggle-style');
if(_bMapStyle === 'satellite'){
// Switch to street map
_bSatLayers.forEach(l => _bMap.removeLayer(l));
_bStreetLayer.addTo(_bMap);
_bMapStyle = 'street';
if(btn){ btn.textContent = '🛰 Satellite'; btn.title = 'Switch to satellite view'; }
} else {
// Switch back to satellite
_bMap.removeLayer(_bStreetLayer);
_bSatLayers.forEach(l => l.addTo(_bMap));
_bMapStyle = 'satellite';
if(btn){ btn.textContent = '🗺 Street'; btn.title = 'Switch to street map'; }
}
}
/**
* Toggle the distance-measure mode.
* First click activates (cursor → crosshair, click map to add waypoints).
* Second click clears all waypoints and deactivates.
*/
function toggleMeasure(){
_measureActive = !_measureActive;
const btn = document.getElementById('map-measure-btn');
if(!_measureActive){
if(_measureLine) { _bMap.removeLayer(_measureLine); _measureLine = null; }
if(_measureLabel) { _bMap.removeLayer(_measureLabel); _measureLabel = null; }
_measurePts = [];
if(btn) btn.classList.remove('map-layer-active');
if(_bMap) _bMap.getContainer().style.cursor = '';
} else {
if(btn) btn.classList.add('map-layer-active');
if(_bMap) _bMap.getContainer().style.cursor = 'crosshair';
}
}
/**
* Fetch all transaction types for the year selected in the map year dropdown
* and repopulate the hero map independently of the table search.
*/
async function refreshMapData(){
if(!App.serverReady) return;
const year = (document.getElementById('map-year-select') || {}).value || '';
const statusEl = document.getElementById('map-status');
if(statusEl) statusEl.textContent = 'Refreshing map…';
try {
const rawData = await fetchAllBatches(year);
updateBrowserMap(flattenUraTransactions(rawData));
} catch(e){
console.error('[Map] refresh failed:', e);
if(statusEl) statusEl.textContent = 'Map refresh failed — check server';
}
}
/* ═══════════════════════════════════════════════════════════════════════════
MAP GEOCODER SEARCH
─────────────────────────────────────────────────────────────────────────── */
let _mapSearchMarker = null; // temporary highlight ring after a fly-to
let _mapSearchTimer = null; // debounce handle for autocomplete
let _mapSearchCursor = -1; // keyboard arrow-key cursor in the dropdown
/**
* Return up to 7 autocomplete suggestions from the local geo_lookup dictionaries.
* Streets are listed before projects; both filtered by substring match.
*/
function _mapSuggest(raw){
if(!raw || raw.length < 2) return [];
const q = raw.toUpperCase();
const streets = Object.keys(_geoLookup.streets || {}).filter(k => k.includes(q)).slice(0, 4);
const projects = Object.keys(_geoLookup.projects || {}).filter(k => k.includes(q)).slice(0, 4);
return [
...streets.map(l => ({ label: l, kind: 'street' })),
...projects.map(l => ({ label: l, kind: 'project' })),
].slice(0, 7);
}
/** Rebuild and show/hide the autocomplete dropdown (debounced 150 ms). */
function _mapSearchInput(){
clearTimeout(_mapSearchTimer);
const input = document.getElementById('map-search-input');
const drop = document.getElementById('map-search-drop');
if(!input || !drop) return;
input.classList.remove('map-search-notfound');
const q = input.value.trim();
if(q.length < 2){ drop.style.display = 'none'; return; }
_mapSearchTimer = setTimeout(() => {
const hits = _mapSuggest(q);
if(!hits.length){ drop.style.display = 'none'; return; }
drop.innerHTML = hits.map(h => {
const icon = h.kind === 'street' ? '🏠' : '🏢';
const badge = h.kind === 'street' ? 'Street' : 'Condo';
const safe = h.label.replace(/"/g, '"');
return `
${icon}
${h.label}
${badge}
`;
}).join('');
drop.style.display = 'block';
_mapSearchCursor = -1;
}, 150);
}
/** Keyboard handling: Escape, Enter, ↑/↓ arrow navigation. */
function _mapSearchKey(e){
const drop = document.getElementById('map-search-drop');
const items = drop ? Array.from(drop.querySelectorAll('.msd-item')) : [];
if(e.key === 'Escape'){
if(drop) drop.style.display = 'none';
return;
}
if(e.key === 'ArrowDown'){
e.preventDefault();
_mapSearchCursor = Math.min(_mapSearchCursor + 1, items.length - 1);
items.forEach((el, i) => el.classList.toggle('msd-item-active', i === _mapSearchCursor));
return;
}
if(e.key === 'ArrowUp'){
e.preventDefault();
_mapSearchCursor = Math.max(_mapSearchCursor - 1, 0);
items.forEach((el, i) => el.classList.toggle('msd-item-active', i === _mapSearchCursor));
return;
}
if(e.key === 'Enter'){
if(_mapSearchCursor >= 0 && items[_mapSearchCursor]){
items[_mapSearchCursor].click();
} else {
mapSearch();
}
}
}
/** User clicked a dropdown suggestion — fly to it and close the dropdown. */
function _mapSearchCommit(label, kind){
const input = document.getElementById('map-search-input');
const drop = document.getElementById('map-search-drop');
if(input) input.value = label;
if(drop) drop.style.display = 'none';
const coords = kind === 'street'
? (_geoLookup.streets || {})[label]
: (_geoLookup.projects || {})[label];
if(coords) _mapFlyTo(coords);
}
/**
* Main search entry point (Enter key or "Go" button).
* Priority: exact local match → partial local match → OneMap geocode API.
*/
async function mapSearch(){
const input = document.getElementById('map-search-input');
const drop = document.getElementById('map-search-drop');
if(!input || !_bMap) return;
if(drop) drop.style.display = 'none';
const raw = input.value.trim();
if(!raw) return;
const q = raw.toUpperCase();
// 1. Exact match — streets
const st = (_geoLookup.streets || {})[q];
if(st){ _mapFlyTo(st); return; }
// 2. Exact match — projects
const pr = (_geoLookup.projects || {})[q];
if(pr){ _mapFlyTo(pr); return; }
// 3. Partial match (first autocomplete hit)
const hits = _mapSuggest(raw);
if(hits.length){ _mapSearchCommit(hits[0].label, hits[0].kind); return; }
// 4. OneMap geocode — handles postal codes, full addresses, landmarks
try {
const url = 'https://www.onemap.gov.sg/api/common/elastic/search'
+ '?searchVal=' + encodeURIComponent(raw)
+ '&returnGeom=Y&getAddrDetails=Y&pageNum=1';
const resp = await fetch(url);
const data = await resp.json();
const r = (data.results || [])[0];
if(r){
_mapFlyTo([parseFloat(r.LATITUDE), parseFloat(r.LONGITUDE)]);
return;
}
} catch(e){
console.warn('[Map search] OneMap error:', e);
}
// 5. Not found — briefly flash the input border red
if(input){
input.classList.add('map-search-notfound');
setTimeout(() => input.classList.remove('map-search-notfound'), 1400);
}
}
/**
* Fly to a coordinate pair and show a fading indigo highlight ring
* so the user can see exactly where the map jumped to.
*/
function _mapFlyTo(latlng, zoom = 15){
if(!_bMap) return;
_bMap.flyTo(latlng, zoom, { duration: 1.2 });
if(_mapSearchMarker){ _bMap.removeLayer(_mapSearchMarker); _mapSearchMarker = null; }
_mapSearchMarker = L.circleMarker(latlng, {
radius: 20, color: '#4f46e5', weight: 2.5, opacity: 0.85,
fillColor: '#4f46e5', fillOpacity: 0.12,
interactive: false,
}).addTo(_bMap);
setTimeout(() => {
if(_mapSearchMarker){ _bMap.removeLayer(_mapSearchMarker); _mapSearchMarker = null; }
}, 2500);
}
/** Dismiss the one-time map hint bar and remember in localStorage. */
function dismissMapHint(){
const bar = document.getElementById('map-hint-bar');
if(bar) bar.style.display = 'none';
try { localStorage.setItem('mh1', '1'); } catch(e){}
}
/* ── Fullscreen map ──────────────────────────────────────────────────────────
Toggles the map container between its normal inline position and a
viewport-covering fixed overlay. Works on desktop and mobile.
─────────────────────────────────────────────────────────────────────────── */
let _mapFs = false;
function toggleMapFullscreen(){
_mapFs = !_mapFs;
const wrap = document.getElementById('browser-map').parentElement; // .browser-map-wrap
const btn = document.getElementById('map-fs-btn');
if(_mapFs){
wrap.classList.add('map-fullscreen');
document.body.style.overflow = 'hidden';
if(btn){
btn.classList.add('map-layer-active', 'map-fs-active');
btn.title = 'Exit full-screen (Esc)';
btn.innerHTML = `
Exit`;
}
} else {
wrap.classList.remove('map-fullscreen');
document.body.style.overflow = '';
if(btn){
btn.classList.remove('map-layer-active', 'map-fs-active');
btn.title = 'Full-screen map (Esc to exit)';
btn.innerHTML = `
Full`;
}
}
// Give the browser one frame to apply the CSS change, then tell Leaflet
setTimeout(() => { if(_bMap) _bMap.invalidateSize(); }, 60);
}
// Escape key exits fullscreen
document.addEventListener('keydown', function(e){
if(e.key === 'Escape' && _mapFs) toggleMapFullscreen();
});
// Close the autocomplete dropdown when the user clicks outside the search box
document.addEventListener('click', function(e){
if(!e.target.closest('#map-search-wrap')){
const drop = document.getElementById('map-search-drop');
if(drop) drop.style.display = 'none';
}
});
/** Switch between Landed and Non-Landed (condo/apt) modes */
function switchSearchMode(mode){
App.searchMode = mode;
const isLanded = mode === 'landed';
// Update tab active styling
document.getElementById('tab-landed').classList.toggle('mode-tab-active', isLanded);
document.getElementById('tab-nonlanded').classList.toggle('mode-tab-active', !isLanded);
// Show the correct Property Type dropdown
document.getElementById('type-wrap-landed').style.display = isLanded ? '' : 'none';
document.getElementById('type-wrap-nonlanded').style.display = isLanded ? 'none' : '';
// Update street/project label and placeholder
const streetInput = document.getElementById('searchStreet');
document.getElementById('streetLabel').firstChild.textContent =
isLanded ? 'Street Name ' : 'Project / Street ';
streetInput.placeholder = isLanded
? 'e.g. PALM ROAD, CHARTWELL DRIVE — leave blank for all streets'
: 'e.g. MARINA BAY, PINNACLE, SOMMERVILLE — leave blank for all';
// Clear results and re-search with the new mode
streetInput.value = '';
if(isLanded){
document.getElementById('searchType').value = '';
} else {
document.getElementById('searchTypeNL').value = '';
}
searchTransactions();
}
/** Common filter values shared by both modes */
function _getCommonFilters(){
return {
streetFilter: document.getElementById('searchStreet').value.trim().toUpperCase(),
saleTypeFilter: document.getElementById('searchSaleType').value,
districtFilter: document.getElementById('searchDistrict').value,
yearFilter: document.getElementById('searchYear').value,
priceMinM: parseFloat(document.getElementById('searchPriceMin').value) || 0,
priceMaxM: parseFloat(document.getElementById('searchPriceMax').value) || 0,
};
}
/** Main search entry point — called by Search button and on page load */
async function searchTransactions(){
const btn = document.getElementById('searchBtn');
const out = document.getElementById('browser-output');
const mode = App.searchMode || 'landed';
btn.disabled = true;
btn.innerHTML = ' Loading…';
const filters = _getCommonFilters();
const yearNote = filters.yearFilter ? filters.yearFilter : 'all years';
out.innerHTML = ` Loading ${yearNote} data…
`;
try {
// Year is filtered server-side — only the requested year's data is transferred
const rawData = await fetchAllBatches(filters.yearFilter);
const all = flattenUraTransactions(rawData);
// Always cache the non-landed set from this search (may be year-filtered).
const allNL = all.filter(t => NONLANDED_TYPES.has(t.propertyType));
App.allNonLandedTx = allNL;
// Price trend charts need the full 5-year dataset regardless of the year filter.
// If this was a full (unfiltered) fetch, use it directly.
// If a year filter was active, kick off a one-time background fetch of all years.
if (!filters.yearFilter) {
App.allNonLandedTxFull = allNL;
} else if (!App.allNonLandedTxFull) {
fetchAllBatches('').then(rawAll => {
App.allNonLandedTxFull = flattenUraTransactions(rawAll)
.filter(t => NONLANDED_TYPES.has(t.propertyType));
console.info('[PriceTrend] Full 5-year dataset cached:',
App.allNonLandedTxFull.length, 'non-landed records');
}).catch(err => {
console.warn('[PriceTrend] Background full-data fetch failed:', err);
});
}
// Map always shows ALL types — independent of tab / filter selections.
// Update map first (non-blocking) then render the table.
updateBrowserMap(all);
if(mode === 'nonlanded'){
await _searchNonLanded(all, filters, out);
} else {
await _searchLanded(all, filters, out);
}
} catch(e){
out.innerHTML = renderApiError(e);
}
btn.disabled = false;
btn.innerHTML = ` Search`;
}
async function _searchLanded(all, filters, out){
const { streetFilter, saleTypeFilter, districtFilter, yearFilter, priceMinM, priceMaxM } = filters;
const typeFilter = document.getElementById('searchType').value;
let rows = all.filter(t => LANDED_TYPES.has(t.propertyType));
if(streetFilter) rows = rows.filter(t => (t.street||'').toUpperCase().includes(streetFilter));
if(typeFilter) rows = rows.filter(t => t.propertyType === typeFilter);
if(saleTypeFilter) rows = rows.filter(t => (t.typeOfSale||'') === saleTypeFilter);
if(districtFilter) rows = rows.filter(t => parseInt(t.district,10) === parseInt(districtFilter,10));
// yearFilter already applied server-side; no client re-filter needed
if(priceMinM > 0) rows = rows.filter(t => parseFloat(t.price||0) >= priceMinM * 1_000_000);
if(priceMaxM > 0) rows = rows.filter(t => parseFloat(t.price||0) <= priceMaxM * 1_000_000);
rows.sort((a,b) => parseTxDate(b.contractDate) - parseTxDate(a.contractDate));
App.transactions = rows;
App.filtered = rows;
App.page = 0;
renderFilterChips({ streetFilter, typeFilter, saleTypeFilter, districtFilter, yearFilter, priceMinM, priceMaxM });
out.innerHTML = renderTransactionTable(rows, 0);
_attachResizers('landed');
}
async function _searchNonLanded(all, filters, out){
const { streetFilter, saleTypeFilter, districtFilter, yearFilter, priceMinM, priceMaxM } = filters;
const typeFilter = document.getElementById('searchTypeNL').value;
const allNL = App.allNonLandedTx; // already set in searchTransactions()
let rows = allNL.slice();
// For non-landed, search both project name and street
if(streetFilter) rows = rows.filter(t =>
(t.street||'').toUpperCase().includes(streetFilter) ||
(t.project||'').toUpperCase().includes(streetFilter));
if(typeFilter) rows = rows.filter(t => t.propertyType === typeFilter);
if(saleTypeFilter) rows = rows.filter(t => (t.typeOfSale||'') === saleTypeFilter);
if(districtFilter) rows = rows.filter(t => parseInt(t.district,10) === parseInt(districtFilter,10));
// yearFilter already applied server-side; no client re-filter needed
if(priceMinM > 0) rows = rows.filter(t => parseFloat(t.price||0) >= priceMinM * 1_000_000);
if(priceMaxM > 0) rows = rows.filter(t => parseFloat(t.price||0) <= priceMaxM * 1_000_000);
rows.sort((a,b) => parseTxDate(b.contractDate) - parseTxDate(a.contractDate));
App.transactions = rows;
App.filtered = rows;
App.page = 0;
renderFilterChips({ streetFilter, typeFilter: typeFilter, saleTypeFilter, districtFilter, yearFilter, priceMinM, priceMaxM });
out.innerHTML = renderNonLandedTable(rows, 0);
_attachResizers('nonlanded');
}
/** Reset all filter fields and re-run search */
function resetFilters(){
['searchStreet','searchType','searchTypeNL','searchSaleType','searchDistrict',
'searchPriceMin','searchPriceMax'].forEach(id => {
document.getElementById(id).value = '';
});
// Reset year to the default (first option = current year, not "All Years")
const yearEl = document.getElementById('searchYear');
if(yearEl) yearEl.selectedIndex = 0;
const fc = document.getElementById('filterChips');
fc.style.display = 'none';
fc.innerHTML = '';
searchTransactions();
}
/** Render active filter chips */
function renderFilterChips({ streetFilter, typeFilter, saleTypeFilter, districtFilter, yearFilter, priceMinM, priceMaxM }){
const chips = [];
if(districtFilter) chips.push(`D${String(districtFilter).padStart(2,'0')}`);
if(yearFilter) chips.push(yearFilter);
if(typeFilter) chips.push(shortType(typeFilter));
if(saleTypeFilter) chips.push(saleTypeFilter);
if(streetFilter) chips.push(`Street: ${streetFilter}`);
if(priceMinM > 0) chips.push(`≥ S$${priceMinM}M`);
if(priceMaxM > 0) chips.push(`≤ S$${priceMaxM}M`);
const el = document.getElementById('filterChips');
if(!chips.length){ el.style.display='none'; el.innerHTML=''; return; }
el.style.display = 'flex';
el.innerHTML = 'Active filters: '
+ chips.map(c =>
`${c} ✕ `
).join('');
}
/** Render the transactions table with pagination */
function renderTransactionTable(rows, page){
const total = rows.length;
const start = page * App.PAGE_SIZE;
const end = Math.min(start + App.PAGE_SIZE, total);
const slice = rows.slice(start, end);
if(total === 0){
return `
No landed transactions match the selected filters.
Try a different district, year, or clear filters.
✕ Clear Filters
`;
}
const rows_html = slice.map((t, idx) => {
const areaSqft = sqmToSqft(t.area);
const psfNum = areaSqft ? Math.round(parseFloat(t.price||0) / areaSqft) : 0;
const psfStr = psfNum ? `S$${psfNum.toLocaleString()}` : '—';
const typeCls = tenureTagClass(t.tenure);
const distStr = t.district ? 'D'+String(t.district).padStart(2,'0') : '—';
const proj = (t.project && t.project.toUpperCase() !== (t.street||'').toUpperCase()) ? t.project : '';
return `
${formatTxDate(t.contractDate)}
${distStr}
${proj}
${t.street||'—'}
${shortType(t.propertyType)}
${shortTenure(t.tenure)}
${areaSqft.toLocaleString()}
${psfStr}
${fmtPrice(t.price)}
Inspect →
`;
}).join('');
const prevBtn = page > 0
? `← Prev ` : '';
const nextBtn = end < total
? `Next → ` : '';
return `
${total.toLocaleString()} landed transactions · all districts
· showing ${start+1}–${end}
Click any row to identify the exact property address →
Date Dist Project Address / Street
Type Tenure
Area (sqft)
PSF (S$)
Price
${rows_html}
`;
}
function goPage(page){
App.page = page;
const html = App.searchMode === 'nonlanded'
? renderNonLandedTable(App.filtered, page)
: renderTransactionTable(App.filtered, page);
document.getElementById('browser-output').innerHTML = html;
_attachResizers(App.searchMode === 'nonlanded' ? 'nonlanded' : 'landed');
}
/** ── Non-Landed (Condo / Apt) table ─────────────────────────────────────── */
function renderNonLandedTable(rows, page){
const total = rows.length;
const start = page * App.PAGE_SIZE;
const end = Math.min(start + App.PAGE_SIZE, total);
const slice = rows.slice(start, end);
if(total === 0){
return `
No transactions match the selected filters.
Try a different district, year, or project name.
✕ Clear Filters
`;
}
const rows_html = slice.map((t, idx) => {
const areaSqft = sqmToSqft(t.area);
const psfNum = areaSqft ? Math.round(parseFloat(t.price||0) / areaSqft) : 0;
const psfStr = psfNum ? `S$${psfNum.toLocaleString()}` : '—';
const typeCls = tenureTagClass(t.tenure);
const distStr = t.district ? 'D'+String(t.district).padStart(2,'0') : '—';
const proj = t.project || '—';
const floor = t.floorRange ? `L${t.floorRange.replace(/^0+/,'').replace('-0','-')}` : '—';
const unitType = inferUnitType(areaSqft);
return `
${formatTxDate(t.contractDate)}
${distStr}
${proj}
${t.street||'—'}
${shortType(t.propertyType)}
${shortTenure(t.tenure)}
${floor}
${unitType}
${areaSqft.toLocaleString()}
${psfStr}
${fmtPrice(t.price)}
Inspect →
`;
}).join('');
const prevBtn = page > 0
? `← Prev ` : '';
const nextBtn = end < total
? `Next → ` : '';
return `
${total.toLocaleString()} condo & apartment transactions · all districts
· showing ${start+1}–${end}
Click any row to view details →
Date Dist Project Street
Type Tenure Floor Unit
Size (sqft)
PSF (S$)
Price
${rows_html}
`;
}
/** When user clicks a transaction row — pre-fill resolver and switch panel */
function selectTransaction(idx){
const t = App.filtered[idx];
if(!t) return;
App.currentTx = t;
// Safety net: exiting fullscreen via any navigation path should restore normal scroll.
if(_mapFs) toggleMapFullscreen();
const areaSqft = sqmToSqft(t.area);
const price = parseInt(t.price) || 0;
const psf = areaSqft ? Math.round(price / areaSqft) : 0;
const isNL = NONLANDED_TYPES.has(t.propertyType);
/* ── Summary card ── */
const sg = document.getElementById('tx-summary-grid');
if(isNL){
const floorStr = t.floorRange ? `Level ${t.floorRange}` : '—';
sg.innerHTML = `
Project ${t.project||t.street||'—'}
Street ${t.street||'—'}
Type ${t.propertyType}
Tenure ${shortTenure(t.tenure)}
Floor ${floorStr}
Floor Area ${areaSqft.toLocaleString()} sqft
Price ${fmtPrice(t.price)}
Date ${formatTxDate(t.contractDate)}
`;
} else {
sg.innerHTML = `
Street ${t.street}
Type ${t.propertyType}
Tenure ${shortTenure(t.tenure)}
Land Area ${areaSqft.toLocaleString()} sqft
Price ${fmtPrice(t.price)}
Date ${formatTxDate(t.contractDate)}
`;
}
document.getElementById('tx-summary').style.display = '';
document.getElementById('output').innerHTML = '';
// For condos: add a "Check Price Trend" action button inside the summary card
const actionRow = document.getElementById('tx-summary-action');
if(actionRow){
if(isNL){
actionRow.innerHTML = `
Check Price Trend `;
} else {
actionRow.innerHTML = '';
}
}
const formCard = document.getElementById('manual-entry-card');
showPanel('resolver');
/* ── Condo path: hide the landed form, wait for button click ── */
if(isNL){
if(formCard) formCard.style.display = 'none';
return;
}
/* ── Landed path: always ensure form is visible ── */
if(formCard) formCard.style.display = '';
document.getElementById('rawAddress').value = t.street ? `XX ${t.street}` : '';
document.getElementById('propType').value = shortType(t.propertyType);
document.getElementById('tenure').value = t.tenure || '';
document.getElementById('area').value = areaSqft;
document.getElementById('psf').value = psf;
document.getElementById('price').value = price;
document.getElementById('txDate').value = formatTxDate(t.contractDate);
// Auto-resolve if AI is configured
if(App.openaiKey){
resolve();
}
}
/** API error with helpful context */
function renderApiError(err){
const isProxyDown = /Failed to fetch|NetworkError|fetch/i.test(err.message);
const isKeyErr = /access key|invalid/i.test(err.message);
const isTokenErr = /token/i.test(err.message);
let extra = '';
if(isProxyDown){
extra = `
Is server.py running?
Run: python server.py
then open http://localhost:8080
`;
} else if(isKeyErr || isTokenErr){
extra = `
Check your URA Access Key in config.json
`;
}
return `
Could not load transaction data ${err.message}
${extra}
`;
}
/* ── Column resize ───────────────────────────────────────────────────────── */
const _colWidths = {}; // persists widths across pagination: {landed:{0:120,...}, nonlanded:{...}}
function _attachResizers(tableKey){
const tbl = document.querySelector('#browser-output table');
if(!tbl) return;
const saved = _colWidths[tableKey] || {};
tbl.querySelectorAll('thead th').forEach((th, i) => {
// Restore saved width from previous drag
if(saved[i]){
th.style.width = saved[i] + 'px';
th.style.minWidth = saved[i] + 'px';
}
// Skip columns that already got a resizer (shouldn't happen, but guard)
if(th.querySelector('.col-resizer')) return;
const rz = document.createElement('div');
rz.className = 'col-resizer';
rz.title = 'Drag to resize column';
th.appendChild(rz);
rz.addEventListener('mousedown', e => {
const startX = e.clientX;
const startW = th.offsetWidth;
rz.classList.add('dragging');
const onMove = ev => {
const w = Math.max(50, startW + (ev.clientX - startX));
th.style.width = w + 'px';
th.style.minWidth = w + 'px';
if(!_colWidths[tableKey]) _colWidths[tableKey] = {};
_colWidths[tableKey][i] = w;
};
const onUp = () => {
rz.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
});
// Add hover tooltips to any truncated body cells lacking a title
tbl.querySelectorAll('tbody td').forEach(td => {
if(!td.title && td.firstChild && !td.firstChild.tagName){
const txt = td.textContent.trim();
if(txt) td.title = txt;
}
});
}