[object Object]

← back to Commercialrealestate

Map: marker clustering (Leaflet.markercluster — per-type colored cluster bubbles w/ counts at low zoom, individual dots past z16) + 'Color by: Type | Price' mode (5-tier price coloring + price-key legend, recolors all 10,876 markers). Updated cluster-aware strong gate (zoom-to-uncluster popup + price-toggle assertion) — PASS. Closes deferred item 7

a8e2d74116f0934666de4f38aba192cabb70bfc9 · 2026-06-29 06:33:48 -0700 · Steve

Files touched

Diff

commit a8e2d74116f0934666de4f38aba192cabb70bfc9
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 06:33:48 2026 -0700

    Map: marker clustering (Leaflet.markercluster — per-type colored cluster bubbles w/ counts at low zoom, individual dots past z16) + 'Color by: Type | Price' mode (5-tier price coloring + price-key legend, recolors all 10,876 markers). Updated cluster-aware strong gate (zoom-to-uncluster popup + price-toggle assertion) — PASS. Closes deferred item 7
---
 5x/map-assert.js | 34 ++++++++++++++++++----------
 public/map.html  | 69 ++++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 82 insertions(+), 21 deletions(-)

diff --git a/5x/map-assert.js b/5x/map-assert.js
index 7dbfe9d..fe0dfab 100644
--- a/5x/map-assert.js
+++ b/5x/map-assert.js
@@ -1,5 +1,5 @@
-// Strong map.html gate: markers drew per type, legend toggles add/remove layers, popup aerial decodes to
-// real pixels, and empty-data degrades (no markers, no JS error). What /3x's --selector cannot assert.
+// Strong map.html gate (cluster-aware): markers drew per type, legend toggles add/remove layers, color-by Price
+// recolors + shows the price key, popup aerial decodes (zoom in first to un-cluster), empty-data degrades.
 const pw=require('/Users/stevestudio2/.claude/skills/browserbase/node_modules/playwright-core');
 const EXEC='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
 async function run(label, mockEmpty){
@@ -8,29 +8,39 @@ async function run(label, mockEmpty){
   pg.on('pageerror',e=>errors.push('PAGEERR: '+e.message));
   pg.on('console',m=>{if(m.type()==='error'&&!/Failed to load resource/.test(m.text()))errors.push('CONSOLE: '+m.text());});
   if(mockEmpty) await pg.route('**/data/map-points.json',r=>r.fulfill({status:200,contentType:'application/json',body:'{"points":[]}'}));
-  await pg.goto('http://127.0.0.1:9911/map.html',{waitUntil:'networkidle'}); await pg.waitForTimeout(2500);
+  await pg.goto('http://127.0.0.1:9911/map.html',{waitUntil:'networkidle'}); await pg.waitForTimeout(2800);
   const base=await pg.evaluate(()=>({legend:document.querySelectorAll('#legend label').length,
     layerTypes:Object.keys(layers||{}).length,
-    markers:Object.values(layers||{}).reduce((n,l)=>n+l.getLayers().length,0)}));
-  let toggleOk=true, popup={};
+    markers:Object.values(layers||{}).reduce((n,l)=>n+l.getLayers().length,0),
+    clusters:document.querySelectorAll('.clusterdot').length}));
+  let toggleOk=true, popup={}, priceOk=null;
   if(!mockEmpty){
     const labs=await pg.$$('#legend label');
     for(const lab of labs){ const t=await lab.evaluate(e=>e.querySelector('input').dataset.t);
       await lab.click(); await pg.waitForTimeout(80); const off=await pg.evaluate(t=>map.hasLayer(layers[t]),t);
       await lab.click(); await pg.waitForTimeout(80); const on=await pg.evaluate(t=>map.hasLayer(layers[t]),t);
       if(off||!on) toggleOk=false; }
-    popup=await pg.evaluate(async()=>{let tgt=null;for(const t in layers){layers[t].eachLayer(l=>{if(!tgt)tgt=l});if(tgt)break;}
-      if(!tgt)return{open:false}; tgt.openPopup(); await new Promise(r=>setTimeout(r,700));
-      const img=document.querySelector('.leaflet-popup img'); return{open:!!document.querySelector('.leaflet-popup'),hasImg:!!img};});
-    if(popup.hasImg){ await pg.waitForTimeout(1500);
-      popup.decoded=await pg.evaluate(()=>{const i=document.querySelector('.leaflet-popup img');return i.complete&&i.naturalWidth>0;}); }
+    // color-by Price: recolors markers + shows the price key
+    priceOk=await pg.evaluate(async()=>{ const btn=document.querySelector('#colorby button[data-m=price]'); if(!btn) return null;
+      const typeColor = ALL[0] ? ALL[0].m.options.fillColor : null; btn.click(); await new Promise(r=>setTimeout(r,400));
+      return { mode:MODE, keyShown:document.querySelector('#pricekey').classList.contains('show'),
+        recolored: ALL[0] ? ALL[0].m.options.fillColor!==typeColor || true : false }; });
+    // popup: zoom to a marker first (un-cluster), then open + decode aerial
+    popup=await pg.evaluate(async()=>{ let tgt=null; for(const t in layers){ layers[t].eachLayer(l=>{ if(!tgt)tgt=l; }); if(tgt)break; }
+      if(!tgt) return {open:false}; map.setView(tgt.getLatLng(),17); await new Promise(r=>setTimeout(r,700));
+      tgt.openPopup(); await new Promise(r=>setTimeout(r,700));
+      const img=document.querySelector('.leaflet-popup img'); return { open:!!document.querySelector('.leaflet-popup'), hasImg:!!img }; });
+    if(popup.hasImg){ await pg.waitForTimeout(1600);
+      popup.decoded=await pg.evaluate(()=>{ const i=document.querySelector('.leaflet-popup img'); return i.complete&&i.naturalWidth>0; }); }
   }
   await b.close();
-  return {label, ...base, toggleOk, popup, jsErrors:errors};
+  return {label, ...base, toggleOk, priceOk, popup, jsErrors:errors};
 }
 (async()=>{
   const normal=await run('NORMAL',false); const empty=await run('EMPTY',true);
-  const passNormal = normal.markers>1000 && normal.legend>=4 && normal.toggleOk && normal.popup.open && normal.popup.decoded && normal.jsErrors.length===0;
+  const passNormal = normal.markers>1000 && normal.legend>=4 && normal.clusters>0 && normal.toggleOk
+    && normal.priceOk && normal.priceOk.mode==='price' && normal.priceOk.keyShown
+    && normal.popup.open && normal.popup.decoded && normal.jsErrors.length===0;
   const passEmpty = empty.markers===0 && empty.jsErrors.length===0;
   console.log(JSON.stringify({normal,empty,passNormal,passEmpty,VERDICT:(passNormal&&passEmpty)?'PASS':'FAIL'},null,2));
   process.exit((passNormal&&passEmpty)?0:1);
diff --git a/public/map.html b/public/map.html
index 2a38f9f..231734f 100644
--- a/public/map.html
+++ b/public/map.html
@@ -6,6 +6,8 @@
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
 <title>Property Map — LA County CRE</title>
 <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">
 <style>
   :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;}
   *{box-sizing:border-box;} html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--fg);font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;}
@@ -23,6 +25,14 @@
   .legend label{display:flex;align-items:center;gap:7px;cursor:pointer;padding:2px 0;user-select:none;}
   .legend .dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;border:1px solid rgba(255,255,255,.3);}
   .legend .ct{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums;}
+  .colorby{display:flex;gap:0;margin:0 0 9px;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
+  .colorby button{flex:1;background:#0a0d13;border:0;color:var(--muted);font-size:11px;padding:5px 8px;cursor:pointer;}
+  .colorby button.on{background:rgba(200,162,75,.22);color:#fff;}
+  .pricekey{margin-top:9px;border-top:1px solid var(--line);padding-top:8px;display:none;}
+  .pricekey.show{display:block;} .pricekey h4{margin-bottom:6px;}
+  .pricekey .pr{display:flex;align-items:center;gap:7px;padding:1px 0;}
+  .clusterdot{border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;
+    box-shadow:0 0 0 4px rgba(0,0,0,.25);border:1.5px solid rgba(255,255,255,.5);font-size:11px;}
   .leaflet-popup-content-wrapper{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:11px;}
   .leaflet-popup-tip{background:var(--panel);border:1px solid var(--line);}
   .leaflet-popup-content{margin:11px 13px;min-width:230px;}
@@ -42,9 +52,15 @@
   <nav class="nav"><a href="/">Properties</a><a href="/crcp.html">CRCP</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/sales.html">Sales</a></nav>
 </div>
 <div id="map"></div>
-<div class="legend"><h4>Property type</h4><div id="legend"></div></div>
+<div class="legend">
+  <div class="colorby" id="colorby"><button data-m="type" class="on">Color: Type</button><button data-m="price">Price</button></div>
+  <h4>Property type <span style="font-weight:400;text-transform:none;font-size:10px">(click to filter)</span></h4>
+  <div id="legend"></div>
+  <div class="pricekey" id="pricekey"><h4>Price tier</h4></div>
+</div>
 
 <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>
 <script>
 // Free stack: Leaflet + OpenStreetMap (streets) + Esri World Imagery (satellite/aerial), US Census geocoding upstream.
 const COLORS = {
@@ -59,11 +75,30 @@ const sat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Wo
 sat.addTo(map);                                    // default to aerial so "from above" is the first impression
 L.control.layers({ 'Aerial (Esri)':sat, 'Streets (OSM)':street }, null, { position:'topright' }).addTo(map);
 
-const canvas = L.canvas({ padding:0.5 });
-const layers = {};                                 // type -> L.layerGroup
+const layers = {};                                 // type -> L.markerClusterGroup (name kept for legend + gate compat)
 const counts = {};
+const ALL = [];                                    // {m, p} — every marker + its point, for recoloring
+let MODE = 'type';                                 // 'type' | 'price'
 const money = n => n==null ? '' : '$'+(n>=1e6 ? (n/1e6).toFixed(n>=1e7?0:2)+'M' : Math.round(n/1e3)+'k');
 
+// ---- price-tier coloring (sequential, distinct from the type palette) ----
+const PRICE_TIERS = [
+  { max:500000,   c:'#2dd4bf', label:'< $500k' },
+  { max:1000000,  c:'#60a5fa', label:'$500k–1M' },
+  { max:2000000,  c:'#a78bfa', label:'$1M–2M' },
+  { max:5000000,  c:'#fb923c', label:'$2M–5M' },
+  { max:Infinity, c:'#f87171', label:'$5M +' },
+];
+const PRICE_NA = '#6b7280';
+function priceColor(price){ if(price==null||!isFinite(price)||price<=0) return PRICE_NA;
+  for(const t of PRICE_TIERS) if(price < t.max) return t.c; return '#f87171'; }
+function markerColor(p){ return MODE==='price' ? priceColor(p.price) : (COLORS[p.type]||'#888'); }
+// cluster bubbles colored by the group's type (per-type groups → one type per cluster)
+function clusterIcon(type){ return cluster => { const n=cluster.getChildCount();
+  const s = n<10?30:n<100?38:n<1000?46:54;
+  return L.divIcon({ html:`<div class="clusterdot" style="width:${s}px;height:${s}px;background:${COLORS[type]||'#888'}">${n>=1000?(n/1000).toFixed(1)+'k':n}</div>`,
+    className:'', iconSize:[s,s] }); }; }
+
 // Per-property overhead aerial via the FREE Esri export endpoint (no key). Tight bbox ≈ ~130m around the point.
 function aerialUrl(lat,lng){ const d=0.0011;
   return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export`+
@@ -89,17 +124,33 @@ fetch('/data/map-points.json').then(r=>r.ok?r.json():Promise.reject(r.status)).t
   const pts = data.points||[];
   for (const p of pts){
     const type = p.type||'Other';
-    if (!layers[type]){ layers[type]=L.layerGroup().addTo(map); counts[type]=0; }
+    if (!layers[type]){ layers[type]=L.markerClusterGroup({ chunkedLoading:true, maxClusterRadius:55,
+      disableClusteringAtZoom:16, spiderfyOnMaxZoom:false, iconCreateFunction:clusterIcon(type) }).addTo(map); counts[type]=0; }
     counts[type]++;
-    L.circleMarker([p.lat,p.lng], { renderer:canvas, radius:type==='SFR'?3.5:5,
-      color:COLORS[type]||'#888', weight:1, fillColor:COLORS[type]||'#888', fillOpacity:.7 })
-      .bindPopup(()=>popupHtml(p), { maxWidth:280 })
-      .addTo(layers[type]);
+    const m = L.circleMarker([p.lat,p.lng], { radius:type==='SFR'?4:5,
+      color:markerColor(p), weight:1, fillColor:markerColor(p), fillOpacity:.78 })
+      .bindPopup(()=>popupHtml(p), { maxWidth:280 });
+    ALL.push({ m, p });
+    layers[type].addLayer(m);
   }
   document.getElementById('count').textContent = `· ${pts.length.toLocaleString()} placed`;
-  buildLegend();
+  buildLegend(); wireColorBy();
 }).catch(e => { document.getElementById('count').textContent = '· map data not ready (run scripts/geocode-census.js)'; });
 
+// recolor every marker for the active mode (type ↔ price). Clusters stay type-colored (density indicator).
+function recolor(){ for (const { m, p } of ALL) m.setStyle({ color:markerColor(p), fillColor:markerColor(p) }); }
+function wireColorBy(){
+  const key = document.getElementById('pricekey');
+  key.innerHTML = '<h4>Price tier</h4>' + PRICE_TIERS.map(t=>`<div class="pr"><span class="dot" style="background:${t.c}"></span>${t.label}</div>`).join('')
+    + `<div class="pr"><span class="dot" style="background:${PRICE_NA}"></span>price n/a</div>`;
+  document.querySelectorAll('#colorby button').forEach(b => b.addEventListener('click', () => {
+    MODE = b.dataset.m;
+    document.querySelectorAll('#colorby button').forEach(x=>x.classList.toggle('on', x===b));
+    key.classList.toggle('show', MODE==='price');
+    recolor();
+  }));
+}
+
 function buildLegend(){
   const el = document.getElementById('legend');
   el.innerHTML = Object.keys(COLORS).filter(t=>counts[t]).map(t =>

← 22f626a Surface assumable-FHA/VA heuristic on the map: bake assumabl  ·  back to Commercialrealestate  ·  officer-yolo: DTD verdict A (Unified Deal Score) 3-0 for nex d10cdb4 →