← 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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> · © <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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> · © <a href="https://carto.com/attributions">CARTO</a>',
};
return {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> · © <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>
);
}