← 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
M package-lock.jsonM package.jsonA public/geo-search.cssA public/geo-search.jsM public/index.htmlM src/server/index.ts
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, '&').replace(/</g, '<').replace(/"/g, '"');
+
+ 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 →