← back to Commercialrealestate
public/sfv-pool.html
332 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SFV Homes $700k–$1.2M · Pool Filter</title>
<style>
:root { --cols: 3; }
* { box-sizing: border-box; }
body { margin:0; background:#0e1116; color:#e6edf3;
font-family:-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif; padding:20px; }
.wrap { max-width:1400px; margin:0 auto; }
h1 { font-size:20px; margin:0 0 3px; }
.sub { color:#8b949e; font-size:12px; margin-bottom:16px; }
.sub a { color:#58a6ff; }
/* headline stats */
.stats { display:flex; gap:8px; flex-wrap:wrap; margin:0 0 16px; }
.stat { flex:1 1 120px; padding:12px 14px; background:#161b22; border:1px solid #2a313c; border-radius:10px; text-align:center; }
.stat .n { font-size:24px; font-weight:700; }
.stat .l { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.5px; margin-top:2px; }
.stat.pool .n { color:#3fb950; } .stat.nopool .n { color:#58a6ff; } .stat.pending .n { color:#8b949e; }
/* controls */
.controls { display:flex; gap:14px; flex-wrap:wrap; align-items:center; background:#161b22;
border:1px solid #2a313c; border-radius:10px; padding:12px 14px; margin-bottom:16px; }
.controls label { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.5px; margin-right:6px; }
.seg { display:inline-flex; border:1px solid #2a313c; border-radius:8px; overflow:hidden; }
.seg button { background:#0e1116; color:#c9d1d9; border:0; padding:7px 12px; font-size:13px; cursor:pointer; }
.seg button.on { background:#238636; color:#fff; }
.seg button.on[data-pool="false"] { background:#1f6feb; }
.seg button.on[data-pool="null"] { background:#57606a; }
select, input[type=range] { background:#0e1116; color:#e6edf3; border:1px solid #2a313c; border-radius:8px; padding:6px 8px; font-size:13px; }
input[type=range] { padding:0; vertical-align:middle; }
.grid { display:grid; grid-template-columns:repeat(var(--cols), minmax(0,1fr)); gap:12px; }
.card { background:#161b22; border:1px solid #2a313c; border-radius:10px; padding:12px 13px; display:flex; flex-direction:column; gap:5px; }
.photo { width:100%; aspect-ratio:16/10; object-fit:cover; border-radius:8px; display:block; margin-bottom:4px; background:#20262f; }
.photo.ph { display:flex; } .photo.ph::after { content:'no photo'; margin:auto; color:#57606a; font-size:11px; }
.card .addr { font-size:14px; font-weight:600; }
.card .addr a { color:#58a6ff; text-decoration:none; }
.card .price { font-size:18px; font-weight:700; color:#e6edf3; }
.card .meta { font-size:12px; color:#8b949e; }
.badge { display:inline-block; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.4px;
padding:2px 7px; border-radius:20px; }
.badge.pool { background:rgba(63,185,80,.15); color:#3fb950; border:1px solid rgba(63,185,80,.4); }
.badge.nopool { background:rgba(88,166,255,.12); color:#58a6ff; border:1px solid rgba(88,166,255,.35); }
.badge.pending { background:rgba(139,148,158,.12); color:#8b949e; border:1px solid rgba(139,148,158,.35); }
.feat { font-size:11px; color:#7a828c; }
.empty { color:#8b949e; padding:30px; text-align:center; }
.foot { color:#6e7681; font-size:11px; margin-top:20px; border-top:1px solid #2a313c; padding-top:10px; }
.card { cursor:pointer; transition:border-color .12s; }
.card:hover { border-color:#3d444d; }
/* detail modal */
.backdrop { position:fixed; inset:0; background:rgba(1,4,9,.72); display:none; align-items:flex-start; justify-content:center; padding:40px 16px; overflow:auto; z-index:50; }
.backdrop.open { display:flex; }
.modal { background:#161b22; border:1px solid #2a313c; border-radius:14px; max-width:720px; width:100%; overflow:hidden; }
.modal .hero { width:100%; height:340px; object-fit:cover; display:block; background:#20262f; }
.modal .body { padding:18px 20px 22px; }
.modal .top { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.modal h2 { font-size:20px; margin:0 0 2px; }
.modal .csub { color:#8b949e; font-size:13px; }
.modal .mprice { font-size:26px; font-weight:700; text-align:right; white-space:nowrap; }
.modal .mprice small { display:block; font-size:12px; color:#8b949e; font-weight:400; }
.kv { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; margin:18px 0 6px; }
.kv .cell { background:#0e1116; border:1px solid #232a33; border-radius:8px; padding:9px 11px; }
.kv .k { font-size:10px; text-transform:uppercase; letter-spacing:.5px; color:#8b949e; }
.kv .v { font-size:15px; font-weight:600; margin-top:2px; }
.modal .actions { display:flex; gap:10px; margin-top:16px; }
.btn { display:inline-block; text-decoration:none; font-size:13px; font-weight:600; padding:9px 16px; border-radius:8px; border:0; cursor:pointer; }
.btn.primary { background:#238636; color:#fff; }
.btn.ghost { background:#21262d; color:#c9d1d9; border:1px solid #2a313c; }
.x { background:none; border:0; color:#8b949e; font-size:22px; cursor:pointer; line-height:1; }
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<style>
#map { height:70vh; min-height:460px; border:1px solid #2a313c; border-radius:10px; display:none; }
body.mapview #grid, body.mapview #foot { display:none; }
body.mapview #map { display:block; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background:#161b22; color:#e6edf3; }
.leaflet-popup-content { margin:10px 12px; font-size:13px; }
.mpop img { width:100%; height:120px; object-fit:cover; border-radius:6px; margin-bottom:6px; }
.mpop .p { font-size:16px; font-weight:700; }
.mpop a { color:#58a6ff; }
.viewseg button.on[data-view="map"] { background:#1f6feb; }
/* Zillow/Redfin-style price pins */
.pricepin { display:inline-block; padding:3px 8px; border-radius:13px; font-size:12px; font-weight:700;
color:#fff; white-space:nowrap; box-shadow:0 1px 5px rgba(0,0,0,.55); border:1.5px solid rgba(255,255,255,.35); cursor:pointer; }
.pricepin.pool { background:#2f9e44; } .pricepin.nopool { background:#1971c2; } .pricepin.pending { background:#5c636a; }
.pricepin:hover { filter:brightness(1.12); z-index:1000; }
.maplegend { position:absolute; bottom:14px; left:14px; z-index:500; background:rgba(22,27,34,.92);
border:1px solid #2a313c; border-radius:8px; padding:8px 10px; font-size:11px; color:#c9d1d9; }
.maplegend b { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:5px; vertical-align:middle; }
</style>
</head>
<body>
<div class="wrap">
<h1>🏊 San Fernando Valley Homes · $700k – $1.2M</h1>
<div class="sub">Active single-family listings, split by swimming pool<br>
<a href="/mls.html?shortlist=sfv-pool">open these on CRCP (checked off) →</a> ·
<a href="/mls.html">all LA listings →</a></div>
<div class="stats" id="stats"></div>
<div class="controls">
<div><label>View</label>
<span class="seg viewseg" id="viewSeg">
<button data-view="cards" class="on">Cards</button>
<button data-view="map">🗺️ Map</button>
</span>
</div>
<div><label>Pool</label>
<span class="seg" id="poolSeg">
<button data-pool="all" class="on">All</button>
<button data-pool="true">🏊 With Pool</button>
<button data-pool="false">No Pool</button>
<button data-pool="null">Pending</button>
</span>
</div>
<div><label>City</label>
<select id="city"><option value="">All SFV</option></select>
</div>
<div><label>Sort</label>
<select id="sort">
<option value="price_desc">Price ↓</option>
<option value="price_asc">Price ↑</option>
<option value="dom_asc">Newest (DOM)</option>
<option value="ppsf_asc">$/sqft ↑</option>
<option value="sqft_desc">Sqft ↓</option>
<option value="city_az">City A→Z</option>
</select>
</div>
<div><label>Density</label>
<input type="range" id="density" min="2" max="6" step="1" value="3">
</div>
</div>
<div class="grid" id="grid"><div class="empty">Loading…</div></div>
<div id="map"></div>
<div class="foot" id="foot"></div>
</div>
<div class="backdrop" id="backdrop">
<div class="modal" id="modal" role="dialog" aria-modal="true"></div>
</div>
<script>
const KEY = 'sfvpool';
const st = { pool:'all', city:'', sort: localStorage.getItem(KEY+':sort')||'price_desc',
cols:+(localStorage.getItem(KEY+':cols')||3), view: localStorage.getItem(KEY+':view')||'cards' };
let DATA = { withPool:[], withoutPool:[], unknown:[] };
const $ = s => document.querySelector(s);
const money = n => n>=1000 ? '$'+(+n).toLocaleString() : '—';
function allRows() {
const tag = (arr,t)=>arr.map(r=>({...r, _pool:t}));
return [...tag(DATA.withPool,'true'), ...tag(DATA.withoutPool,'false'), ...tag(DATA.unknown,'null')];
}
function poolBadge(t){ return t==='true' ? '<span class="badge pool">🏊 Pool</span>'
: t==='false' ? '<span class="badge nopool">No Pool</span>'
: '<span class="badge pending">Pool: pending</span>'; }
function render() {
let rows = allRows();
if (st.pool !== 'all') rows = rows.filter(r => r._pool === st.pool);
if (st.city) rows = rows.filter(r => r.city === st.city);
const ppsf = r => (r.sqft>0 ? r.price/r.sqft : Infinity);
const S = st.sort;
rows.sort((a,b) =>
S==='price_asc' ? a.price-b.price :
S==='dom_asc' ? (a.days_on_market||999)-(b.days_on_market||999) :
S==='ppsf_asc' ? ppsf(a)-ppsf(b) :
S==='sqft_desc' ? (b.sqft||0)-(a.sqft||0) :
S==='city_az' ? (a.city||'').localeCompare(b.city||'') || b.price-a.price :
b.price-a.price);
document.documentElement.style.setProperty('--cols', st.cols);
const g = $('#grid');
if (!rows.length) { g.innerHTML = '<div class="empty">No listings match this filter.</div>'; }
else g.innerHTML = rows.map(r => {
const pp = r.ppsf ? '$'+(+r.ppsf).toLocaleString()+'/sqft' : (r.sqft>0 ? '$'+Math.round(r.price/r.sqft).toLocaleString()+'/sqft' : '');
const lot = r.lot_size ? String(r.lot_size).replace(/ square feet/i,' sf').replace(/ Sq\.? ?Ft\.?/i,' sf') : '';
const feat = r._pool==='true' && r.pool_features ? `<div class="feat">${r.pool_features}</div>` : '';
const photo = r.photo_url
? `<img class="photo" src="${r.photo_url}" alt="" loading="lazy">`
: `<div class="photo ph"></div>`;
const poolTxt = r._pool==='true'?'Pool':r._pool==='false'?'No Pool':'Pending';
// data-field tags give the Nav Agent a clean per-field model (Fields toggle / sort-by-any / group-by)
return `<div class="card" data-id="${r.id}">
${photo}
<div class="addr" data-field="Address">${r.address||'—'}</div>
<div class="price"><span data-field="Price">${money(r.price)}</span> <span data-field="$/sqft" style="font-size:12px;color:#8b949e;font-weight:400">${pp?'· '+pp:''}</span></div>
<div class="meta"><span data-field="City">${r.city}</span> · <span data-field="Beds">${r.beds||'?'}</span> bd / <span data-field="Baths">${r.baths||'?'}</span> ba · <span data-field="Sqft">${r.sqft?(+r.sqft).toLocaleString():'—'}</span> sqft${lot?' · lot <span data-field="Lot">'+lot+'</span>':''} · <span data-field="DOM">${r.days_on_market!=null?r.days_on_market+'d':''}</span></div>
<div><span data-field="Pool" class="badge ${r._pool==='true'?'pool':r._pool==='false'?'nopool':'pending'}">${r._pool==='true'?'🏊 Pool':r._pool==='false'?'No Pool':'Pending'}</span></div>${feat}
</div>`;
}).join('');
$('#foot').textContent = `${rows.length} shown · pool status enriched from Redfin listing detail (structured POOL_FEATURES). "Pending" = detail not yet checked or field absent.`;
if (st.view === 'map' && typeof renderMap === 'function') renderMap();
}
// current filtered set (shared by cards + map)
function currentRows() {
let rows = allRows();
if (st.pool !== 'all') rows = rows.filter(r => r._pool === st.pool);
if (st.city) rows = rows.filter(r => r.city === st.city);
return rows;
}
function paintStats() {
const c = { p: DATA.withPool.length, n: DATA.withoutPool.length, u: DATA.unknown.length };
const t = c.p + c.n + c.u;
$('#stats').innerHTML =
`<div class="stat"><div class="n">${t.toLocaleString()}</div><div class="l">Total $700k–1.2M</div></div>
<div class="stat pool"><div class="n">${c.p.toLocaleString()}</div><div class="l">🏊 With Pool</div></div>
<div class="stat nopool"><div class="n">${c.n.toLocaleString()}</div><div class="l">No Pool</div></div>
<div class="stat pending"><div class="n">${c.u.toLocaleString()}</div><div class="l">Pool Pending</div></div>`;
}
const byId = new Map();
async function load() {
const r = await fetch('/api/sfv-pool');
const j = await r.json();
DATA = { withPool:j.withPool||[], withoutPool:j.withoutPool||[], unknown:j.unknown||[] };
allRows().forEach(x => byId.set(x.id, x));
const _src = $('#src'); if (_src) _src.textContent = 'source: ' + (j.source||'?');
// populate city dropdown
const cities = [...new Set(allRows().map(r=>r.city))].sort();
$('#city').insertAdjacentHTML('beforeend', cities.map(c=>`<option value="${c}">${c}</option>`).join(''));
paintStats(); render();
}
// wire controls
$('#poolSeg').addEventListener('click', e => {
if (e.target.tagName!=='BUTTON') return;
st.pool = e.target.dataset.pool;
[...$('#poolSeg').children].forEach(b=>b.classList.toggle('on', b===e.target));
render();
});
$('#city').addEventListener('change', e => { st.city = e.target.value; render(); });
$('#sort').addEventListener('change', e => { st.sort = e.target.value; localStorage.setItem(KEY+':sort', st.sort); render(); });
$('#density').addEventListener('input', e => { st.cols = +e.target.value; localStorage.setItem(KEY+':cols', st.cols); render(); });
$('#sort').value = st.sort; $('#density').value = st.cols;
// ---- detail modal: click any card to see all info on-page ----
function cell(k, v) { return v==null||v===''||v==='—' ? '' : `<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>`; }
function openModal(id) {
const r = byId.get(id); if (!r) return;
const pp = r.ppsf ? '$'+(+r.ppsf).toLocaleString() : (r.sqft>0 ? '$'+Math.round(r.price/r.sqft).toLocaleString() : '—');
const lot = r.lot_size ? String(r.lot_size).replace(/ square feet/i,' sq ft').replace(/ Sq\.? ?Ft\.?/i,' sq ft') : '—';
const poolTxt = r._pool==='true' ? ('🏊 Yes' + (r.pool_features?' — '+r.pool_features:'')) : r._pool==='false' ? 'No' : 'Pending';
const hero = r.photo_url ? `<img class="hero" src="${r.photo_url}" alt="">` : `<div class="hero"></div>`;
$('#modal').innerHTML = `${hero}
<div class="body">
<div class="top">
<div><h2>${r.address||'—'}</h2><div class="csub">${r.city}${r.zip?', '+r.zip:''}</div></div>
<div><div class="mprice">${money(r.price)}<small>${pp}/sqft</small></div></div>
<button class="x" aria-label="close" onclick="closeModal()">×</button>
</div>
<div class="kv">
${cell('Beds', r.beds)}
${cell('Baths', r.baths)}
${cell('Interior', r.sqft ? (+r.sqft).toLocaleString()+' sqft' : '—')}
${cell('Price / sqft', pp+'/sqft')}
${cell('Lot size', lot)}
${cell('Year built', r.year_built)}
${cell('Days on market', r.days_on_market!=null ? r.days_on_market+'d' : '—')}
${cell('Pool', poolTxt)}
</div>
<div class="actions">
<a class="btn primary" href="${r.source||'#'}" target="_blank" rel="noopener noreferrer">View on Redfin →</a>
<button class="btn ghost" onclick="closeModal()">Close</button>
</div>
</div>`;
$('#backdrop').classList.add('open');
}
function closeModal(){ $('#backdrop').classList.remove('open'); }
window.closeModal = closeModal;
$('#grid').addEventListener('click', e => { const c = e.target.closest('.card'); if (c) openModal(c.dataset.id); });
$('#backdrop').addEventListener('click', e => { if (e.target === $('#backdrop')) closeModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ---- map view (Leaflet + markercluster, matches CRCP's CARTO tiles) ----
let _map=null, _cluster=null;
function renderMap() {
if (!window.L) return;
if (!_map) {
_map = L.map('map', { scrollWheelZoom:true }).setView([34.19,-118.44], 11); // SFV center
// Esri dark-gray canvas — free, NO API KEY required (attribution only)
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
{ attribution:'Tiles © Esri', maxZoom:16 }).addTo(_map);
_cluster = L.layerGroup(); _map.addLayer(_cluster); // no clustering — show every price pin (Zillow-style)
const lg = L.control({ position:'bottomleft' });
lg.onAdd = () => { const d = L.DomUtil.create('div','maplegend');
d.innerHTML = '<b style="background:#2f9e44"></b>Pool <b style="background:#1971c2"></b>No pool <b style="background:#5c636a"></b>Pending'; return d; };
lg.addTo(_map);
}
_cluster.clearLayers();
const cls = t => t==='true' ? 'pool' : t==='false' ? 'nopool' : 'pending';
const priceTag = n => n>=1e6 ? '$'+(n/1e6).toFixed(2).replace(/\.?0+$/,'')+'M' : '$'+Math.round(n/1000)+'K';
const pts = [];
currentRows().filter(r=>r.lat && r.lng).forEach(r => {
const icon = L.divIcon({ className:'pricepin-wrap',
html:`<span class="pricepin ${cls(r._pool)}">${priceTag(r.price)}</span>`,
iconSize:null, iconAnchor:[26,12] });
const m = L.marker([r.lat, r.lng], { icon });
const pp = r.ppsf ? '$'+(+r.ppsf).toLocaleString()+'/sqft' : '';
const lot = r.lot_size ? String(r.lot_size).replace(/ square feet/i,' sf') : '';
const img = r.photo_url ? `<img src="${r.photo_url}">` : '';
m.bindPopup(`<div class="mpop">${img}<div class="p">${money(r.price)} ${pp?'<span style="font-size:12px;color:#8b949e">· '+pp+'</span>':''}</div>`
+ `<div>${r.address||''}, ${r.city}</div>`
+ `<div style="color:#8b949e;font-size:12px">${r.beds||'?'}bd/${r.baths||'?'}ba · ${r.sqft?(+r.sqft).toLocaleString()+' sqft':''}${lot?' · lot '+lot:''}</div>`
+ `<div style="margin:4px 0">${poolBadge(r._pool)}</div>`
+ `<a href="${r.source||'#'}" target="_blank" rel="noopener noreferrer">View on Redfin →</a></div>`);
_cluster.addLayer(m); pts.push([r.lat, r.lng]);
});
if (pts.length) _map.fitBounds(pts, { padding:[30,30], maxZoom:14 });
setTimeout(()=>_map.invalidateSize(), 60); // tiles render correctly after the div un-hides
}
function setView(v) {
st.view = v; localStorage.setItem(KEY+':view', v);
document.body.classList.toggle('mapview', v==='map');
[...$('#viewSeg').children].forEach(b=>b.classList.toggle('on', b.dataset.view===v));
if (v==='map') renderMap();
}
$('#viewSeg').addEventListener('click', e => { if (e.target.tagName==='BUTTON') setView(e.target.dataset.view); });
load().then(()=>{ if (st.view==='map') setView('map'); });
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>