← back to Homesonspec
Add robust map explorer (/map): 12k canvas markers, per-field on/off toggles (status/price/beds/builder), color-by + legend, localStorage-persisted
bbbcbc6e4a6614bb82071e732e0edf250ae0a445 · 2026-07-22 17:08:21 -0700 · Steve Abrams
Files touched
A apps/web/src/app/api/map/route.tsM apps/web/src/app/layout.tsxA apps/web/src/app/map/MapCanvas.tsxA apps/web/src/app/map/MapExplorer.tsxA apps/web/src/app/map/page.tsxM apps/web/src/app/page.tsx
Diff
commit bbbcbc6e4a6614bb82071e732e0edf250ae0a445
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 17:08:21 2026 -0700
Add robust map explorer (/map): 12k canvas markers, per-field on/off toggles (status/price/beds/builder), color-by + legend, localStorage-persisted
---
apps/web/src/app/api/map/route.ts | 56 +++++++
apps/web/src/app/layout.tsx | 1 +
apps/web/src/app/map/MapCanvas.tsx | 74 +++++++++
apps/web/src/app/map/MapExplorer.tsx | 292 +++++++++++++++++++++++++++++++++++
apps/web/src/app/map/page.tsx | 13 ++
apps/web/src/app/page.tsx | 5 +-
6 files changed, 440 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/app/api/map/route.ts b/apps/web/src/app/api/map/route.ts
new file mode 100644
index 0000000..ea53e5f
--- /dev/null
+++ b/apps/web/src/app/api/map/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@spechomes/database";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Compact marker feed for the robust map explorer. Returns every mappable,
+ * published, non-demo home with the fields the client toggles on, using terse
+ * keys + rounded coords to keep the payload lean (~12k points gzips small).
+ * Filtering happens client-side so toggling is instant (no round-trips).
+ */
+export async function GET() {
+ const homes = await prisma.inventoryHome.findMany({
+ where: {
+ status: "PUBLISHED",
+ isDemo: false,
+ lat: { not: null },
+ lon: { not: null },
+ },
+ select: {
+ id: true,
+ lat: true,
+ lon: true,
+ price: true,
+ beds: true,
+ constructionStatus: true,
+ city: true,
+ state: true,
+ builder: { select: { slug: true, name: true } },
+ },
+ });
+
+ const builderMap = new Map<string, { slug: string; name: string; count: number }>();
+ const markers = homes.map((h) => {
+ const b = h.builder;
+ const rec = builderMap.get(b.slug) ?? { slug: b.slug, name: b.name, count: 0 };
+ rec.count += 1;
+ builderMap.set(b.slug, rec);
+ return {
+ i: h.id,
+ la: Math.round(Number(h.lat) * 1e5) / 1e5,
+ lo: Math.round(Number(h.lon) * 1e5) / 1e5,
+ p: h.price === null ? null : Number(h.price),
+ b: h.beds, // beds (nullable)
+ s: h.constructionStatus, // MOVE_IN_READY | UNDER_CONSTRUCTION | PLANNED
+ bl: b.slug,
+ c: h.city ? `${h.city}, ${h.state}` : h.state,
+ };
+ });
+
+ return NextResponse.json({
+ markers,
+ builders: [...builderMap.values()].sort((a, b) => b.count - a.count),
+ total: markers.length,
+ });
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 5096474..4851b23 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -24,6 +24,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</Link>
<div className="flex items-center gap-5 text-sm text-neutral-700">
<Link href="/search" className="hover:text-teal-700">Search</Link>
+ <Link href="/map" className="hover:text-teal-700">Map</Link>
<Link href="/builders" className="hover:text-teal-700">Builders</Link>
<Link href="/compare" className="hover:text-teal-700">Compare</Link>
<Link href="/saved" className="hover:text-teal-700">Saved</Link>
diff --git a/apps/web/src/app/map/MapCanvas.tsx b/apps/web/src/app/map/MapCanvas.tsx
new file mode 100644
index 0000000..3c1b39e
--- /dev/null
+++ b/apps/web/src/app/map/MapCanvas.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { MapContainer, TileLayer, useMap } from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import L from "leaflet";
+
+export interface CanvasMarker {
+ i: string;
+ la: number;
+ lo: number;
+ color: string;
+ title: string;
+ sub: string;
+}
+
+/**
+ * Imperative canvas layer — draws thousands of circle markers in a single
+ * canvas pass instead of mounting one React element per point. Rebuilds when
+ * the filtered marker set changes; fits bounds only on the first non-empty set.
+ */
+function CircleLayer({ markers }: { markers: CanvasMarker[] }) {
+ const map = useMap();
+ const rendererRef = useRef<L.Canvas | null>(null);
+ const fittedRef = useRef(false);
+
+ useEffect(() => {
+ if (!rendererRef.current) rendererRef.current = L.canvas({ padding: 0.5 });
+ const renderer = rendererRef.current;
+ const group = L.layerGroup().addTo(map);
+
+ for (const m of markers) {
+ L.circleMarker([m.la, m.lo], {
+ renderer,
+ radius: 5,
+ weight: 1,
+ color: "#ffffff",
+ fillColor: m.color,
+ fillOpacity: 0.85,
+ })
+ .bindPopup(
+ `<div style="min-width:150px"><strong>${m.title}</strong><br>${m.sub}<br>` +
+ `<a href="/homes/${m.i}" style="color:#0f766e;font-weight:600">View home →</a></div>`,
+ )
+ .addTo(group);
+ }
+
+ if (!fittedRef.current && markers.length > 0) {
+ map.fitBounds(
+ L.latLngBounds(markers.map((m) => [m.la, m.lo] as [number, number])).pad(0.1),
+ { maxZoom: 11 },
+ );
+ fittedRef.current = true;
+ }
+
+ return () => {
+ map.removeLayer(group);
+ };
+ }, [map, markers]);
+
+ return null;
+}
+
+export default function MapCanvas({ markers }: { markers: CanvasMarker[] }) {
+ return (
+ <MapContainer center={[39.5, -98.35]} zoom={4} preferCanvas style={{ width: "100%", height: "100%" }}>
+ <TileLayer
+ attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+ url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+ />
+ <CircleLayer markers={markers} />
+ </MapContainer>
+ );
+}
diff --git a/apps/web/src/app/map/MapExplorer.tsx b/apps/web/src/app/map/MapExplorer.tsx
new file mode 100644
index 0000000..b7d77cc
--- /dev/null
+++ b/apps/web/src/app/map/MapExplorer.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import dynamic from "next/dynamic";
+import type { CanvasMarker } from "./MapCanvas";
+
+// Map needs window — client-only, matching the search page pattern.
+const MapCanvas = dynamic(() => import("./MapCanvas"), {
+ ssr: false,
+ loading: () => (
+ <div className="flex h-full w-full items-center justify-center text-sm text-neutral-400">Loading map…</div>
+ ),
+});
+
+interface Raw {
+ i: string;
+ la: number;
+ lo: number;
+ p: number | null;
+ b: number | null;
+ s: string;
+ bl: string;
+ c: string;
+}
+interface Feed {
+ markers: Raw[];
+ builders: { slug: string; name: string; count: number }[];
+ total: number;
+}
+
+// ── Field dimensions (each value is an on/off toggle) ──────────────────────
+const STATUS = [
+ { v: "MOVE_IN_READY", label: "Move-in ready", color: "#16a34a" },
+ { v: "UNDER_CONSTRUCTION", label: "Under construction", color: "#f59e0b" },
+ { v: "PLANNED", label: "Planned", color: "#3b82f6" },
+];
+const STATUS_COLOR: Record<string, string> = Object.fromEntries(STATUS.map((s) => [s.v, s.color]));
+
+const PRICE_BANDS = [
+ { v: "lt300", label: "Under $300k", color: "#0ea5e9", test: (p: number | null) => p !== null && p < 300_000 },
+ { v: "b300", label: "$300k–$500k", color: "#14b8a6", test: (p: number | null) => p !== null && p >= 300_000 && p < 500_000 },
+ { v: "b500", label: "$500k–$750k", color: "#8b5cf6", test: (p: number | null) => p !== null && p >= 500_000 && p < 750_000 },
+ { v: "gte750", label: "$750k and up", color: "#db2777", test: (p: number | null) => p !== null && p >= 750_000 },
+ { v: "noprice", label: "No price listed", color: "#94a3b8", test: (p: number | null) => p === null },
+];
+const PRICE_COLOR: Record<string, string> = Object.fromEntries(PRICE_BANDS.map((b) => [b.v, b.color]));
+const priceBandOf = (p: number | null) => PRICE_BANDS.find((b) => b.test(p))!.v;
+
+const BEDS = [
+ { v: "le2", label: "2 or fewer", test: (b: number | null) => b !== null && b <= 2 },
+ { v: "3", label: "3 beds", test: (b: number | null) => b === 3 },
+ { v: "4", label: "4 beds", test: (b: number | null) => b === 4 },
+ { v: "ge5", label: "5+ beds", test: (b: number | null) => b !== null && b >= 5 },
+ { v: "unknown", label: "Beds unknown", test: (b: number | null) => b === null },
+];
+const bedsBucketOf = (b: number | null) => BEDS.find((x) => x.test(b))!.v;
+
+const BUILDER_PALETTE = ["#0f766e", "#b45309", "#7c3aed", "#be123c", "#0369a1", "#4d7c0f", "#a16207", "#9333ea"];
+
+const COLOR_BY = [
+ { v: "status", label: "Construction status" },
+ { v: "price", label: "Price band" },
+ { v: "builder", label: "Builder" },
+];
+
+const LS_KEY = "spechomes.map.v1";
+
+const fmtPrice = (p: number | null) => (p === null ? "Price on request" : `$${p.toLocaleString()}`);
+const statusLabel = (s: string) => STATUS.find((x) => x.v === s)?.label ?? s;
+
+export default function MapExplorer() {
+ const [feed, setFeed] = useState<Feed | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ // Each dimension holds the SET of currently-ON values. Everything defaults on.
+ const [onStatus, setOnStatus] = useState<Set<string>>(new Set(STATUS.map((s) => s.v)));
+ const [onPrice, setOnPrice] = useState<Set<string>>(new Set(PRICE_BANDS.map((b) => b.v)));
+ const [onBeds, setOnBeds] = useState<Set<string>>(new Set(BEDS.map((b) => b.v)));
+ const [onBuilder, setOnBuilder] = useState<Set<string>>(new Set());
+ const [colorBy, setColorBy] = useState<string>("status");
+ const [restored, setRestored] = useState(false);
+
+ // Load feed
+ useEffect(() => {
+ let active = true;
+ fetch("/api/map")
+ .then((r) => r.json())
+ .then((f: Feed) => {
+ if (!active) return;
+ setFeed(f);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ // Restore persisted toggle state once (builders need the feed to default all-on).
+ useEffect(() => {
+ if (!feed || restored) return;
+ const allBuilders = new Set(feed.builders.map((b) => b.slug));
+ try {
+ const saved = JSON.parse(localStorage.getItem(LS_KEY) ?? "null");
+ if (saved) {
+ setOnStatus(new Set(saved.status ?? STATUS.map((s) => s.v)));
+ setOnPrice(new Set(saved.price ?? PRICE_BANDS.map((b) => b.v)));
+ setOnBeds(new Set(saved.beds ?? BEDS.map((b) => b.v)));
+ setOnBuilder(new Set(saved.builder ?? [...allBuilders]));
+ setColorBy(saved.colorBy ?? "status");
+ } else {
+ setOnBuilder(allBuilders);
+ }
+ } catch {
+ setOnBuilder(allBuilders);
+ }
+ setRestored(true);
+ }, [feed, restored]);
+
+ // Persist
+ useEffect(() => {
+ if (!restored) return;
+ localStorage.setItem(
+ LS_KEY,
+ JSON.stringify({
+ status: [...onStatus],
+ price: [...onPrice],
+ beds: [...onBeds],
+ builder: [...onBuilder],
+ colorBy,
+ }),
+ );
+ }, [onStatus, onPrice, onBeds, onBuilder, colorBy, restored]);
+
+ const builderColor = useMemo(() => {
+ const map: Record<string, string> = {};
+ (feed?.builders ?? []).forEach((b, idx) => {
+ map[b.slug] = BUILDER_PALETTE[idx % BUILDER_PALETTE.length] ?? "#0f766e";
+ });
+ return map;
+ }, [feed]);
+
+ const colorFor = useCallback(
+ (m: Raw) => {
+ if (colorBy === "price") return PRICE_COLOR[priceBandOf(m.p)] ?? "#0f766e";
+ if (colorBy === "builder") return builderColor[m.bl] ?? "#0f766e";
+ return STATUS_COLOR[m.s] ?? "#0f766e";
+ },
+ [colorBy, builderColor],
+ );
+
+ // Apply every active toggle group (AND across dimensions).
+ const visible: CanvasMarker[] = useMemo(() => {
+ if (!feed) return [];
+ const out: CanvasMarker[] = [];
+ for (const m of feed.markers) {
+ if (!onStatus.has(m.s)) continue;
+ if (!onPrice.has(priceBandOf(m.p))) continue;
+ if (!onBeds.has(bedsBucketOf(m.b))) continue;
+ if (!onBuilder.has(m.bl)) continue;
+ out.push({
+ i: m.i,
+ la: m.la,
+ lo: m.lo,
+ color: colorFor(m),
+ title: m.c,
+ sub: `${fmtPrice(m.p)} · ${m.b ?? "—"} bd · ${statusLabel(m.s)}`,
+ });
+ }
+ return out;
+ }, [feed, onStatus, onPrice, onBeds, onBuilder, colorFor]);
+
+ // Counts per value across the full feed (so toggles show their reach).
+ const counts = useMemo(() => {
+ const c = { status: {} as Record<string, number>, price: {} as Record<string, number>, beds: {} as Record<string, number>, builder: {} as Record<string, number> };
+ for (const m of feed?.markers ?? []) {
+ c.status[m.s] = (c.status[m.s] ?? 0) + 1;
+ const pb = priceBandOf(m.p);
+ c.price[pb] = (c.price[pb] ?? 0) + 1;
+ const bb = bedsBucketOf(m.b);
+ c.beds[bb] = (c.beds[bb] ?? 0) + 1;
+ c.builder[m.bl] = (c.builder[m.bl] ?? 0) + 1;
+ }
+ return c;
+ }, [feed]);
+
+ const toggle = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, v: string) =>
+ setter((prev) => {
+ const next = new Set(prev);
+ if (next.has(v)) next.delete(v);
+ else next.add(v);
+ return next;
+ });
+
+ const legend: { v: string; label: string; color: string }[] =
+ colorBy === "price"
+ ? PRICE_BANDS.map((b) => ({ v: b.v, label: b.label, color: b.color }))
+ : colorBy === "builder"
+ ? (feed?.builders ?? []).map((b) => ({ v: b.slug, label: b.name, color: builderColor[b.slug] ?? "#0f766e" }))
+ : STATUS.map((s) => ({ v: s.v, label: s.label, color: s.color }));
+
+ return (
+ <div className="flex h-[calc(100vh-112px)] flex-col lg:flex-row">
+ {/* Toggle panel */}
+ <aside className="w-full shrink-0 overflow-y-auto border-b border-neutral-200 bg-white p-4 text-sm lg:w-80 lg:border-b-0 lg:border-r">
+ <div className="flex items-baseline justify-between">
+ <h1 className="text-lg font-bold">Map explorer</h1>
+ <span className="text-xs text-neutral-500" data-testid="visible-count">
+ {loading ? "loading…" : `${visible.length.toLocaleString()} of ${(feed?.total ?? 0).toLocaleString()}`}
+ </span>
+ </div>
+ <p className="mt-1 text-xs text-neutral-500">Toggle any field on or off to filter what shows on the map.</p>
+
+ {/* Color-by */}
+ <div className="mt-4">
+ <label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Color markers by</label>
+ <select
+ value={colorBy}
+ onChange={(e) => setColorBy(e.target.value)}
+ className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
+ data-testid="color-by"
+ >
+ {COLOR_BY.map((c) => (
+ <option key={c.v} value={c.v}>{c.label}</option>
+ ))}
+ </select>
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1">
+ {legend.map((l) => (
+ <span key={l.v} className="flex items-center gap-1 text-xs text-neutral-600">
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: l.color }} />
+ {l.label}
+ </span>
+ ))}
+ </div>
+ </div>
+
+ <ToggleGroup title="Construction status" items={STATUS.map((s) => ({ v: s.v, label: s.label }))} on={onStatus} counts={counts.status} onToggle={(v) => toggle(setOnStatus, v)} onAll={(all) => setOnStatus(all ? new Set(STATUS.map((s) => s.v)) : new Set())} testid="grp-status" />
+ <ToggleGroup title="Price" items={PRICE_BANDS.map((b) => ({ v: b.v, label: b.label }))} on={onPrice} counts={counts.price} onToggle={(v) => toggle(setOnPrice, v)} onAll={(all) => setOnPrice(all ? new Set(PRICE_BANDS.map((b) => b.v)) : new Set())} testid="grp-price" />
+ <ToggleGroup title="Bedrooms" items={BEDS.map((b) => ({ v: b.v, label: b.label }))} on={onBeds} counts={counts.beds} onToggle={(v) => toggle(setOnBeds, v)} onAll={(all) => setOnBeds(all ? new Set(BEDS.map((b) => b.v)) : new Set())} testid="grp-beds" />
+ {(feed?.builders.length ?? 0) > 1 && (
+ <ToggleGroup title="Builder" items={(feed?.builders ?? []).map((b) => ({ v: b.slug, label: b.name }))} on={onBuilder} counts={counts.builder} onToggle={(v) => toggle(setOnBuilder, v)} onAll={(all) => setOnBuilder(all ? new Set((feed?.builders ?? []).map((b) => b.slug)) : new Set())} testid="grp-builder" />
+ )}
+ </aside>
+
+ {/* Map */}
+ <div className="min-h-[400px] flex-1" data-testid="map-explorer">
+ <MapCanvas markers={visible} />
+ </div>
+ </div>
+ );
+}
+
+function ToggleGroup({
+ title,
+ items,
+ on,
+ counts,
+ onToggle,
+ onAll,
+ testid,
+}: {
+ title: string;
+ items: { v: string; label: string }[];
+ on: Set<string>;
+ counts: Record<string, number>;
+ onToggle: (v: string) => void;
+ onAll: (all: boolean) => void;
+ testid: string;
+}) {
+ const allOn = items.every((i) => on.has(i.v));
+ return (
+ <div className="mt-4 border-t border-neutral-100 pt-3" data-testid={testid}>
+ <div className="flex items-center justify-between">
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</h3>
+ <button className="text-xs text-teal-700 hover:underline" onClick={() => onAll(!allOn)}>
+ {allOn ? "clear" : "all"}
+ </button>
+ </div>
+ <div className="mt-1.5 space-y-1">
+ {items.map((it) => (
+ <label key={it.v} className="flex cursor-pointer items-center justify-between gap-2 rounded px-1 py-0.5 hover:bg-neutral-50">
+ <span className="flex items-center gap-2">
+ <input type="checkbox" checked={on.has(it.v)} onChange={() => onToggle(it.v)} />
+ {it.label}
+ </span>
+ <span className="text-xs text-neutral-400">{(counts[it.v] ?? 0).toLocaleString()}</span>
+ </label>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/map/page.tsx b/apps/web/src/app/map/page.tsx
new file mode 100644
index 0000000..f1e1535
--- /dev/null
+++ b/apps/web/src/app/map/page.tsx
@@ -0,0 +1,13 @@
+import MapExplorer from "./MapExplorer";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = {
+ title: "Map Explorer | HomesOnSpec",
+ description:
+ "Explore every verified new home on an interactive map. Toggle by construction status, price, bedrooms, and builder to filter what shows.",
+};
+
+export default function MapPage() {
+ return <MapExplorer />;
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 87e80e8..50a99bc 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -85,7 +85,10 @@ export default async function HomePage() {
<HeroMap markers={heroMarkers} />
</div>
<p className="mt-2 text-center text-xs text-teal-200/80">
- Every marker is a community with live, verified inventory. Explore the map or search above.
+ Every marker is a community with live, verified inventory.{" "}
+ <Link href="/map" className="font-semibold text-white underline underline-offset-2 hover:text-teal-100">
+ Open the full map explorer →
+ </Link>
</p>
</div>
</section>
← 13c6b83 Add live national map to homepage hero (loads on page load)
·
back to Homesonspec
·
auto-save: 2026-07-22T17:15:39 (2 files) — deploy-heromap.sh be45764 →