← 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
M 5x/map-assert.jsM public/map.html
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 →