[object Object]

← back to Costa Rica

costa-rica: places map viewer (/map) — Leaflet + marker clustering, 8352 geocoded places, category filters, color-coded, popup->detail — TK-10346

a273d21a3f9bc931fbd41ecb0419a43a4d8b1cee · 2026-08-07 11:25:08 -0700 · Steve

Files touched

Diff

commit a273d21a3f9bc931fbd41ecb0419a43a4d8b1cee
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 11:25:08 2026 -0700

    costa-rica: places map viewer (/map) — Leaflet + marker clustering, 8352 geocoded places, category filters, color-coded, popup->detail — TK-10346
---
 public/map.html | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js       | 13 +++++++++++
 2 files changed, 81 insertions(+)

diff --git a/public/map.html b/public/map.html
new file mode 100644
index 0000000..7380e74
--- /dev/null
+++ b/public/map.html
@@ -0,0 +1,68 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Costa Rica — Places Map</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>
+  html,body{margin:0;height:100%;font:14px -apple-system,Segoe UI,Roboto,sans-serif;}
+  #map{position:absolute;inset:0;}
+  .panel{position:absolute;top:12px;left:12px;z-index:1000;background:#fff;border-radius:12px;
+    box-shadow:0 2px 12px rgba(0,0,0,.15);padding:14px 16px;min-width:210px;}
+  .panel h1{font-size:16px;margin:0 0 8px;color:#0a7d55;}
+  .panel .count{color:#6b756e;font-size:12px;margin-bottom:10px;}
+  .chip{display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;user-select:none;}
+  .chip input{accent-color:#0a7d55;} .dot{width:11px;height:11px;border-radius:50%;display:inline-block;}
+  .leaflet-popup-content{font:13px -apple-system,sans-serif;} .leaflet-popup-content b{color:#0a7d55;}
+</style></head>
+<body>
+<div id="map"></div>
+<div class="panel">
+  <h1>🇨🇷 Places Map</h1>
+  <div class="count" id="count">loading…</div>
+  <div id="filters"></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>
+const COLORS = { tourism:'#0a7d55', rentals:'#b8860b', service:'#2b6cb0' };
+const map = L.map('map').setView([9.75, -84.0], 8);
+L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+  { attribution:'© OpenStreetMap © CARTO', maxZoom:19 }).addTo(map);
+
+const layers = {}; // category -> cluster group
+let all = [];
+
+function icon(cat){ const c=COLORS[cat]||'#888';
+  return L.divIcon({ className:'', iconSize:[12,12],
+    html:`<span style="width:11px;height:11px;border-radius:50%;background:${c};display:block;border:1.5px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.5)"></span>` }); }
+
+async function load(){
+  const d = await (await fetch('/api/map')).json();
+  all = d.places||[];
+  document.getElementById('count').textContent = `${all.length.toLocaleString()} geocoded places`;
+  const cats = [...new Set(all.map(p=>p.category))].sort();
+  const F = document.getElementById('filters');
+  for (const cat of cats){
+    const grp = L.markerClusterGroup({ chunkedLoading:true, maxClusterRadius:50 });
+    for (const p of all.filter(x=>x.category===cat)){
+      const m = L.marker([p.lat,p.lng], {icon:icon(cat)});
+      m.bindPopup(`<b>${p.name}</b><br>${String(p.vertical).replace(/_/g,' ')}${p.region?' · '+p.region:''}`
+        + (p.rating?`<br>★ ${p.rating}`:'')
+        + `<br><a href="/p/${p.slug}" target="_blank">details →</a>`);
+      grp.addLayer(m);
+    }
+    layers[cat]=grp; map.addLayer(grp);
+    const n = all.filter(x=>x.category===cat).length;
+    F.insertAdjacentHTML('beforeend',
+      `<label class="chip"><input type="checkbox" checked data-cat="${cat}">
+       <span class="dot" style="background:${COLORS[cat]||'#888'}"></span>${cat} (${n.toLocaleString()})</label>`);
+  }
+  F.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{
+    const g=layers[cb.dataset.cat]; if(cb.checked) map.addLayer(g); else map.removeLayer(g);
+  });
+}
+load();
+</script>
+</body></html>
diff --git a/server.js b/server.js
index a540f81..c44f4c3 100644
--- a/server.js
+++ b/server.js
@@ -60,6 +60,19 @@ app.use('/api/admin', require('./routes/admin'));
 app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
 app.use('/api/build', require('./routes/build'));
 app.get('/build', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
+// Map of all geocoded places (desktop viewer)
+app.get('/api/map', async (req, res) => {
+  try {
+    const { rows } = await pool.query(
+      `SELECT p.slug, p.name, p.category, p.vertical, p.lat, p.lng, p.rating, r.name AS region
+         FROM places p LEFT JOIN regions r ON r.id=p.region_id
+        WHERE p.lat IS NOT NULL AND p.lng IS NOT NULL
+          AND ($1::text IS NULL OR p.category=$1)
+        LIMIT 20000`, [req.query.category || null]);
+    res.json({ ok: true, count: rows.length, places: rows });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+app.get('/map', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'map.html')));
 
 const VERTICALS = {
   tourism: ['tourism_hotel','tourism_tour','tourism_beach','tourism_restaurant','tourism_surf'],

← 633756d costa-rica: payment-adapter tests — tilopay+onvo sandbox pat  ·  back to Costa Rica  ·  costa-rica: fix ONVO getCharge declined-status fallthrough ( 0ed0c3d →