← back to Homesonspec
apps/web/src/app/map/MapExplorer.tsx
296 lines
"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 = "homesonspec.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="font-display text-lg font-semibold text-brand-900">Map explorer</h1>
<span
className="rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700"
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="eyebrow">Color markers by</label>
<select
value={colorBy}
onChange={(e) => setColorBy(e.target.value)}
className="mt-1.5 w-full rounded-xl border-0 bg-white px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
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="eyebrow">{title}</h3>
<button className="text-xs font-semibold text-brand-700 hover:text-brand-800" onClick={() => onAll(!allOn)}>
{allOn ? "clear" : "all"}
</button>
</div>
<div className="mt-1.5 space-y-0.5">
{items.map((it) => (
<label key={it.v} className="flex cursor-pointer items-center justify-between gap-2 rounded-lg px-1.5 py-1 hover:bg-brand-50">
<span className="flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-700" 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>
);
}