← 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>
  );
}