← back to Stayclaim

src/components/InteractiveMap.tsx

169 lines

'use client';
/**
 * InteractiveMap — Leaflet wrapper, client-only.
 *
 * Uses Carto Voyager basemaps (no API key) so the visual lineage matches
 * the rest of the editorial system. Markers are coral-tinted SVG pins.
 * Each pin's popup links to its address page.
 */
import { useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L, { type LatLngBoundsExpression } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import Link from 'next/link';

export type MapPin = {
  lat: number;
  lng: number;
  slug?: string;
  title: string;
  subtitle?: string;
  featured?: boolean;
  /**
   * Explicit href for the popup link. If omitted and `slug` is set, falls back
   * to `/address/{slug}` for backwards compatibility. Set to `null` to render
   * the title as plain text (no link) — used by Walk of Fame pins where there
   * is no per-honoree detail page yet.
   */
  href?: string | null;
};

function makeIcon(color: string, size = 22) {
  // Hand-rolled SVG so we don't ship Leaflet's default markers (which need
  // image hosts) and so the pin matches our editorial palette.
  const html = `<span style="display:block;width:${size}px;height:${size}px;">
    <svg viewBox="0 0 24 24" fill="${color}" stroke="#f6f1e8" stroke-width="1.5"
         xmlns="http://www.w3.org/2000/svg" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.35));">
      <path d="M12 2C7.6 2 4 5.6 4 10c0 5.5 8 12 8 12s8-6.5 8-12c0-4.4-3.6-8-8-8z"/>
      <circle cx="12" cy="10" r="3" fill="#f6f1e8"/>
    </svg>
  </span>`;
  return L.divIcon({
    className: 'pastdoor-pin',
    html,
    iconSize: [size, size],
    iconAnchor: [size / 2, size],
    popupAnchor: [0, -size + 2],
  });
}

const ICON_DEFAULT = typeof window !== 'undefined' ? makeIcon('#4a5d45') : null;
const ICON_FEATURED = typeof window !== 'undefined' ? makeIcon('#d4664a', 26) : null;

function isValidLatLng(lat: unknown, lng: unknown): boolean {
  return (
    typeof lat === 'number' && typeof lng === 'number' &&
    Number.isFinite(lat) && Number.isFinite(lng) &&
    Math.abs(lat) <= 90 && Math.abs(lng) <= 180 &&
    !(lat === 0 && lng === 0)
  );
}

// Reject javascript:/data:/vbscript: URLs — Leaflet popups don't sanitize hrefs.
function safeHref(href: string): string | null {
  const trimmed = href.trim();
  if (/^(javascript|data|vbscript):/i.test(trimmed)) return null;
  return trimmed;
}

function FitBounds({ pins }: { pins: MapPin[] }) {
  const map = useMap();
  useEffect(() => {
    if (pins.length === 0) return;
    if (pins.length === 1) {
      map.setView([pins[0]!.lat, pins[0]!.lng], 16, { animate: false });
      return;
    }
    const bounds: LatLngBoundsExpression = pins.map(p => [p.lat, p.lng]);
    map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16, animate: false });
  }, [pins, map]);
  return null;
}

export function InteractiveMap({
  pins,
  height = 380,
  className = '',
  variant = 'voyager',
}: {
  pins: MapPin[];
  height?: number;
  className?: string;
  variant?: 'voyager' | 'positron' | 'dark';
}) {
  const tile = useMemo(() => {
    if (variant === 'dark') return {
      url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
    };
    if (variant === 'positron') return {
      url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
    };
    return {
      url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
    };
  }, [variant]);

  // Sane LA default in case no pins yet have coords.
  const fallbackCenter: [number, number] = [34.0736, -118.4004];

  return (
    <div className={className} style={{ height, width: '100%' }}>
      <MapContainer
        center={pins[0] ? [pins[0].lat, pins[0].lng] : fallbackCenter}
        zoom={12}
        scrollWheelZoom={true}
        style={{ height: '100%', width: '100%', borderRadius: 0, background: '#1a1410' }}
      >
        <TileLayer url={tile.url} attribution={tile.attribution} />
        <FitBounds pins={pins.filter(p => isValidLatLng(p.lat, p.lng))} />
        {pins.filter(p => isValidLatLng(p.lat, p.lng)).map((p, i) => (
          <Marker
            key={`${p.slug ?? p.title}-${i}`}
            position={[p.lat, p.lng]}
            icon={(p.featured ? ICON_FEATURED : ICON_DEFAULT) ?? undefined}
          >
            <Popup>
              <div style={{ minWidth: 180 }}>
                <p style={{ fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', color: '#1a141090', margin: 0 }}>
                  {p.subtitle ?? 'Address'}
                </p>
                {(() => {
                  let resolved: string | null | undefined;
                  if (p.href === null) resolved = null;
                  else if (typeof p.href === 'string') resolved = safeHref(p.href);
                  else if (p.slug) resolved = `/address/${p.slug}`;
                  else resolved = null;
                  const href = resolved;
                  return href ? (
                    <>
                      <Link
                        href={href}
                        style={{ display: 'block', fontSize: 17, fontFamily: 'Instrument Serif, serif', color: '#1a1410', marginTop: 4, lineHeight: 1.15 }}
                      >
                        {p.title}
                      </Link>
                      <Link
                        href={href}
                        style={{ display: 'inline-block', marginTop: 8, fontSize: 10, letterSpacing: 1.5, textTransform: 'uppercase', color: '#d4664a' }}
                      >
                        Open the record →
                      </Link>
                    </>
                  ) : (
                    <span style={{ display: 'block', fontSize: 17, fontFamily: 'Instrument Serif, serif', color: '#1a1410', marginTop: 4, lineHeight: 1.15 }}>
                      {p.title}
                    </span>
                  );
                })()}
              </div>
            </Popup>
          </Marker>
        ))}
      </MapContainer>
    </div>
  );
}