/* ============================================================ 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 `
${formatTxDate(t.contractDate)} ${floorStr}${areaStr} ${fmtPrice(t.price)}
`; }).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 ? `
${more > 0 ? `+${more} more` : ''} View all ${n} in table ↓
` : ''; const popupHtml = `
${entity.label}
${d}${d ? ' · ' : ''}${n} transaction${n !== 1 ? 's' : ''}
${txRows}
${moreTag}
`; 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.

`; } 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)} `; }).join(''); const prevBtn = page > 0 ? `` : ''; const nextBtn = end < total ? `` : ''; return `
${total.toLocaleString()} landed transactions · all districts · showing ${start+1}–${end} Click any row to identify the exact property address →
${rows_html}
DateDistProjectAddress / Street TypeTenure Area (sqft) PSF (S$) Price
`; } 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.

`; } 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)} `; }).join(''); const prevBtn = page > 0 ? `` : ''; const nextBtn = end < total ? `` : ''; return `
${total.toLocaleString()} condo & apartment transactions · all districts · showing ${start+1}–${end} Click any row to view details →
${rows_html}
DateDistProjectStreet TypeTenureFloorUnit Size (sqft) PSF (S$) Price
`; } /** 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 = `
${t.project||t.street||'—'}
${t.street||'—'}
${t.propertyType}
${shortTenure(t.tenure)}
${floorStr}
${areaSqft.toLocaleString()} sqft
${fmtPrice(t.price)}
${formatTxDate(t.contractDate)}
`; } else { sg.innerHTML = `
${t.street}
${t.propertyType}
${shortTenure(t.tenure)}
${areaSqft.toLocaleString()} sqft
${fmtPrice(t.price)}
${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 = ``; } 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; } }); }