← back to Stayclaim

src/components/MiniMap.tsx

100 lines

/**
 * MiniMap — server-rendered SVG map.
 * Auto-fits a bounding box around the pins with a touch of padding.
 * Same ink/sand/moss/coral palette as MapHero so the visual lineage is consistent.
 *
 * For multi-city pins (e.g. architect across LA), uses a wider bbox.
 * For a single pin, falls back to a fixed-zoom Beverly Hills frame.
 */

export type MiniPin = {
  lat: number;
  lng: number;
  label?: string;
  featured?: boolean;
};

export function MiniMap({
  pins,
  height = 180,
  className = '',
  caption,
}: {
  pins: MiniPin[];
  height?: number;
  className?: string;
  caption?: string;
}) {
  if (pins.length === 0) return null;

  // bbox with min span so a single pin doesn't render a degenerate map
  const lats = pins.map(p => p.lat);
  const lngs = pins.map(p => p.lng);
  let minLat = Math.min(...lats);
  let maxLat = Math.max(...lats);
  let minLng = Math.min(...lngs);
  let maxLng = Math.max(...lngs);
  const minSpan = 0.04;
  if (maxLat - minLat < minSpan) {
    const c = (maxLat + minLat) / 2;
    minLat = c - minSpan / 2;
    maxLat = c + minSpan / 2;
  }
  if (maxLng - minLng < minSpan) {
    const c = (maxLng + minLng) / 2;
    minLng = c - minSpan / 2;
    maxLng = c + minSpan / 2;
  }
  // 8% padding on each axis
  const padLat = (maxLat - minLat) * 0.08;
  const padLng = (maxLng - minLng) * 0.08;
  minLat -= padLat;
  maxLat += padLat;
  minLng -= padLng;
  maxLng += padLng;

  const w = 420;
  const h = height;
  const project = (p: MiniPin) => ({
    x: ((p.lng - minLng) / (maxLng - minLng)) * w,
    y: ((maxLat - p.lat) / (maxLat - minLat)) * h,
  });

  return (
    <figure className={`block ${className}`}>
      <svg
        viewBox={`0 0 ${w} ${h}`}
        preserveAspectRatio="xMidYMid slice"
        width="100%"
        height={h}
        role="img"
        aria-label={`Map with ${pins.length} ${pins.length === 1 ? 'location' : 'locations'}`}
        className="bg-ink"
      >
        <defs>
          <pattern id="mini-grid" width="20" height="20" patternUnits="userSpaceOnUse">
            <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f6f1e818" strokeWidth="0.5" />
          </pattern>
        </defs>
        <rect width={w} height={h} fill="url(#mini-grid)" />
        {pins.map((p, i) => {
          const { x, y } = project(p);
          const r = p.featured ? 5 : 4;
          const fill = p.featured ? '#d4664a' : '#4a5d45';
          return (
            <g key={i}>
              <circle cx={x} cy={y} r={r + 2} fill={fill} fillOpacity={0.25} />
              <circle cx={x} cy={y} r={r} fill={fill} stroke="#f6f1e8" strokeWidth={1} />
            </g>
          );
        })}
      </svg>
      {caption && (
        <figcaption className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
          {caption}
        </figcaption>
      )}
    </figure>
  );
}