[object Object]

← back to Nationalrealestate

USRE: top location search (county/city/state) — /api/geo-search + typeahead pill on the Map, jumps to market page (v0.12.0)

0a3f34dae9f8724d232610ece5fc7b06321b7ff3 · 2026-07-26 20:59:34 -0700 · Steve

Files touched

Diff

commit 0a3f34dae9f8724d232610ece5fc7b06321b7ff3
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 20:59:34 2026 -0700

    USRE: top location search (county/city/state) — /api/geo-search + typeahead pill on the Map, jumps to market page (v0.12.0)
---
 package-lock.json     |  4 +--
 package.json          |  2 +-
 public/geo-search.css | 28 ++++++++++++++++++++
 public/geo-search.js  | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html     |  2 ++
 src/server/index.ts   | 31 ++++++++++++++++++++++
 6 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 9d952d5..d793155 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "nationalrealestate",
-  "version": "0.11.0",
+  "version": "0.12.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "nationalrealestate",
-      "version": "0.11.0",
+      "version": "0.12.0",
       "dependencies": {
         "better-sqlite3": "^13.0.1",
         "dotenv": "^16.4.5",
diff --git a/package.json b/package.json
index df3697e..d3eefc9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "nationalrealestate",
-  "version": "0.11.0",
+  "version": "0.12.0",
   "private": true,
   "type": "module",
   "description": "USRealEstate — national U.S. residential market explorer. Free-data v1 (Redfin Data Center, Zillow Research, Census ACS, FHFA). Internal analyst tool behind basic auth.",
diff --git a/public/geo-search.css b/public/geo-search.css
new file mode 100644
index 0000000..a8f3059
--- /dev/null
+++ b/public/geo-search.css
@@ -0,0 +1,28 @@
+/* geo-search.css — top-center location search pill (pairs with geo-search.js).
+ * Inherits the host page's --bg/--panel/--line/--fg/--muted/--gold CSS vars. */
+#geoSearch{position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:1100;width:min(440px,86vw);}
+#geoSearchBox{display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;
+  background:rgba(17,21,29,.96);border:1px solid var(--line,#222936);border-radius:22px;
+  backdrop-filter:blur(6px);box-shadow:0 2px 14px rgba(0,0,0,.5);}
+#geoSearchBox:focus-within{border-color:var(--gold,#c8a24b);}
+#geoSearchIc{color:var(--gold,#c8a24b);font-size:16px;line-height:1;}
+#geoSearchInput{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--fg,#e8ecf2);
+  font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;}
+#geoSearchInput::placeholder{color:var(--muted,#8a93a3);}
+#geoSearchClear{background:none;border:0;color:var(--muted,#8a93a3);cursor:pointer;font-size:13px;padding:2px 4px;}
+#geoSearchClear:hover{color:var(--fg,#e8ecf2);}
+#geoSearchResults{display:none;margin-top:6px;max-height:60vh;overflow-y:auto;
+  background:rgba(17,21,29,.98);border:1px solid var(--line,#222936);border-radius:12px;
+  box-shadow:0 8px 24px rgba(0,0,0,.55);}
+#geoSearchResults.open{display:block;}
+.geo-item{display:flex;align-items:center;gap:10px;padding:10px 14px;text-decoration:none;
+  color:var(--fg,#e8ecf2);border-bottom:1px solid var(--line,#222936);}
+.geo-item:last-child{border-bottom:0;}
+.geo-item:hover,.geo-item.active{background:rgba(200,162,75,.12);}
+.geo-name{flex:1;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.geo-state{font-size:11px;color:var(--muted,#8a93a3);font-weight:700;font-variant-numeric:tabular-nums;}
+.geo-badge{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gold,#c8a24b);
+  border:1px solid var(--line,#222936);border-radius:10px;padding:2px 7px;white-space:nowrap;}
+.geo-empty{padding:12px 14px;color:var(--muted,#8a93a3);font-size:13px;line-height:1.5;}
+/* mobile: clear the fixed upper-left hamburger, don't center under it */
+@media(max-width:820px){#geoSearch{left:64px;right:12px;width:auto;transform:none;}}
diff --git a/public/geo-search.js b/public/geo-search.js
new file mode 100644
index 0000000..b91ed06
--- /dev/null
+++ b/public/geo-search.js
@@ -0,0 +1,71 @@
+/* geo-search.js — top-center location search. Type a county / city / state,
+ * get a typeahead of markets (via /api/geo-search), and jump to that market page.
+ * National coverage from the region table. Include on any page + a matching CSS link. */
+(function () {
+  function init() {
+    if (document.getElementById('geoSearch')) return;
+    const wrap = document.createElement('div'); wrap.id = 'geoSearch';
+    wrap.innerHTML =
+      '<div id="geoSearchBox"><span id="geoSearchIc">⌕</span>' +
+      '<input id="geoSearchInput" type="text" autocomplete="off" spellcheck="false" ' +
+      'placeholder="Search a county, city, or state…" aria-label="Location search">' +
+      '<button id="geoSearchClear" type="button" aria-label="Clear" style="display:none">✕</button></div>' +
+      '<div id="geoSearchResults"></div>';
+    document.body.appendChild(wrap);
+    const input = wrap.querySelector('#geoSearchInput');
+    const results = wrap.querySelector('#geoSearchResults');
+    const clear = wrap.querySelector('#geoSearchClear');
+    let items = [], active = -1, t = null, lastQ = '';
+
+    const close = () => { results.innerHTML = ''; results.classList.remove('open'); items = []; active = -1; };
+    const go = u => { if (u) location.href = u; };
+    const badge = ty => ty === 'county' ? 'County' : ty === 'metro' ? 'Metro' : 'State';
+    const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+
+    function render(rows) {
+      items = rows;
+      if (!rows.length) {
+        const zipish = /^\d{5}$/.test(lastQ), addrish = /^\d+\s+\S/.test(lastQ);
+        results.innerHTML = '<div class="geo-empty">' +
+          (zipish || addrish
+            ? 'No place match. ZIP / street-address lookup is per-county — open a market, then use its property search.'
+            : 'No matches.') + '</div>';
+        results.classList.add('open'); return;
+      }
+      results.innerHTML = rows.map((r, i) =>
+        `<a class="geo-item" data-i="${i}" href="${esc(r.url)}">` +
+        `<span class="geo-name">${esc(r.name)}</span>` +
+        (r.state ? `<span class="geo-state">${esc(r.state)}</span>` : '') +
+        `<span class="geo-badge">${badge(r.type)}</span></a>`).join('');
+      results.classList.add('open'); active = -1;
+    }
+    function setActive(n) {
+      const a = results.querySelectorAll('.geo-item'); if (!a.length) return;
+      active = (n + a.length) % a.length;
+      a.forEach((x, i) => x.classList.toggle('active', i === active));
+      a[active].scrollIntoView({ block: 'nearest' });
+    }
+    function search(q) {
+      fetch('/api/geo-search?q=' + encodeURIComponent(q)).then(r => r.json()).then(d => {
+        if (d.q !== input.value.trim()) return; // ignore stale responses
+        render(d.results || []);
+      }).catch(() => {});
+    }
+    input.addEventListener('input', () => {
+      const q = input.value.trim(); lastQ = q;
+      clear.style.display = q ? 'block' : 'none';
+      clearTimeout(t);
+      if (q.length < 2) { close(); return; }
+      t = setTimeout(() => search(q), 160);
+    });
+    input.addEventListener('keydown', e => {
+      if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); }
+      else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active - 1); }
+      else if (e.key === 'Enter') { if (active >= 0 && items[active]) go(items[active].url); else if (items[0]) go(items[0].url); }
+      else if (e.key === 'Escape') { close(); input.blur(); }
+    });
+    clear.addEventListener('click', () => { input.value = ''; clear.style.display = 'none'; close(); input.focus(); });
+    document.addEventListener('click', e => { if (!wrap.contains(e.target)) close(); });
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})();
diff --git a/public/index.html b/public/index.html
index b0130c0..19befef 100644
--- a/public/index.html
+++ b/public/index.html
@@ -72,6 +72,7 @@
   #mapfbar #mapfbar-reset:hover{filter:brightness(1.08);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -104,6 +105,7 @@
 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
         integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 <script>
 const METRICS = [
   { key: 'zhvi',              label: 'ZHVI',              fmt: usd0 },
diff --git a/src/server/index.ts b/src/server/index.ts
index 6d0426f..64a9f49 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -122,6 +122,37 @@ app.get('/api/regions', async (req, res) => {
   }
 });
 
+// universal location search — resolves a typed county / city / metro / state to a
+// market page. National coverage via the region table (fast; ~4.2k rows). ZIP + exact
+// street address are county-scoped and live on the property page (no national ZIP index).
+app.get('/api/geo-search', async (req, res) => {
+  try {
+    const q = String(req.query.q || '').trim();
+    if (q.length < 2) return res.json({ q, results: [] });
+    const esc = q.replace(/[%_\\]/g, '\\$&');
+    const like = '%' + esc + '%';
+    const starts = esc + '%';
+    const r = await query<{ region_type: string; canonical_key: string; name: string; state_code: string | null }>(
+      `SELECT region_type, canonical_key, name, state_code
+         FROM region
+        WHERE name ILIKE $1 ESCAPE '\\'
+           OR (char_length($2) = 2 AND region_type = 'state' AND state_code = upper($2))
+        ORDER BY (char_length($2) = 2 AND region_type = 'state' AND state_code = upper($2)) DESC,
+                 (name ILIKE $3 ESCAPE '\\') DESC,
+                 CASE region_type WHEN 'county' THEN 0 WHEN 'metro' THEN 1 ELSE 2 END,
+                 population DESC NULLS LAST
+        LIMIT 12`,
+      [like, q, starts],
+    );
+    res.json({ q, results: r.rows.map(x => ({
+      type: x.region_type, name: x.name, state: x.state_code || null,
+      key: x.canonical_key, url: '/market.html?key=' + encodeURIComponent(x.canonical_key),
+    })) });
+  } catch (e: any) {
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
 // choropleth data: {fips: value} for the latest period of a metric (M2 fills metric_series)
 app.get('/api/choropleth', async (req, res) => {
   try {

← c5b668f USRE Map: URL-shareable county filters + on-map Copy link (p  ·  back to Nationalrealestate  ·  USRE: geo-search now resolves ZIP (zip_county crosswalk) + s e08091b →