← 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> &nbsp;·&nbsp;
    <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 &nbsp; <b style="background:#1971c2"></b>No pool &nbsp; <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>