[object Object]

← 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

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='&copy; <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 →