'use client' import { useEffect, useRef, useMemo } from 'react' import L from 'leaflet' import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet' import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store' import { useMapState } from '@/hooks/useMapState' import { getSemanticLocationLabel, SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial, } from '@cal/shared' import type { EventListItem, UnifiedEvent } from '@cal/shared' import { clsx } from 'clsx' // Fix Leaflet default marker icons for Next.js bundling delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl L.Icon.Default.mergeOptions({ iconRetinaUrl: '/leaflet/marker-icon-2x.png', iconUrl: '/leaflet/marker-icon.png', shadowUrl: '/leaflet/marker-shadow.png', }) interface SpatioTemporalMapProps { events: EventListItem[] } /** Syncs the Leaflet map viewport with the Zustand store. */ function MapController({ center, zoom }: { center: [number, number]; zoom: number }) { const map = useMap() const isUserInteraction = useRef(false) const { setSpatialGranularity, zoomCoupled } = useCalendarStore() const prevCenter = useRef(center) const prevZoom = useRef(zoom) // Sync store → map (animate when the store changes) useEffect(() => { if (isUserInteraction.current) { isUserInteraction.current = false return } if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1] || prevZoom.current !== zoom) { map.flyTo(center, zoom, { duration: 0.8 }) prevCenter.current = center prevZoom.current = zoom } }, [map, center, zoom]) // Sync map → store (when user manually zooms/pans) useMapEvents({ zoomend: () => { const mapZoom = map.getZoom() if (Math.abs(mapZoom - zoom) > 0.5 && !zoomCoupled) { isUserInteraction.current = true setSpatialGranularity(leafletZoomToSpatial(mapZoom)) } }, }) return null } /** Renders event markers, clustering at broad zooms. */ function EventMarkers({ events }: { events: EventListItem[] }) { const spatialGranularity = useEffectiveSpatialGranularity() const isBroadZoom = spatialGranularity <= SpatialGranularity.COUNTRY // Group events with coordinates by semantic location at broad zooms const markers = useMemo(() => { // Filter events that have coordinate-like data embedded in location_raw // (EventListItem doesn't have lat/lng directly, so we pass through all events // and rely on the full UnifiedEvent type if available) const eventsWithCoords = events.filter((e) => { const ev = e as EventListItem & { latitude?: number | null; longitude?: number | null } return ev.latitude != null && ev.longitude != null }) as (EventListItem & { latitude: number; longitude: number })[] if (!isBroadZoom) { // Fine zoom: individual markers return eventsWithCoords.map((e) => ({ key: e.id, lat: e.latitude, lng: e.longitude, label: e.title, locationLabel: getSemanticLocationLabel(e, spatialGranularity), color: e.source_color || '#3b82f6', count: 1, events: [e], })) } // Broad zoom: cluster by semantic location const groups = new Map() for (const e of eventsWithCoords) { const label = getSemanticLocationLabel(e, spatialGranularity) const key = label || `${e.latitude.toFixed(1)},${e.longitude.toFixed(1)}` const group = groups.get(key) || { events: [], sumLat: 0, sumLng: 0 } group.events.push(e) group.sumLat += e.latitude group.sumLng += e.longitude groups.set(key, group) } return Array.from(groups.entries()).map(([label, group]) => ({ key: label, lat: group.sumLat / group.events.length, lng: group.sumLng / group.events.length, label, locationLabel: label, color: group.events[0].source_color || '#3b82f6', count: group.events.length, events: group.events, })) }, [events, spatialGranularity, isBroadZoom]) const markerRadius = isBroadZoom ? 12 : 6 return ( <> {markers.map((marker) => ( 1 ? Math.min(markerRadius + Math.log2(marker.count) * 3, 24) : markerRadius} pathOptions={{ color: marker.color, fillColor: marker.color, fillOpacity: 0.7, weight: 2, }} >
{marker.count > 1 ? ( <>
{marker.locationLabel}
{marker.count} events
    {marker.events.slice(0, 5).map((e) => (
  • {e.title}
  • ))} {marker.count > 5 && (
  • +{marker.count - 5} more
  • )}
) : ( <>
{marker.label}
{marker.locationLabel && marker.locationLabel !== marker.label && (
{marker.locationLabel}
)} {!marker.events[0].all_day && (
{new Date(marker.events[0].start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', })}
)} )}
))} ) } export function SpatioTemporalMap({ events }: SpatioTemporalMapProps) { const { center, zoom } = useMapState(events) const spatialGranularity = useEffectiveSpatialGranularity() const { zoomCoupled, toggleZoomCoupled } = useCalendarStore() return (
{/* Overlay: granularity indicator + coupling toggle */}
{GRANULARITY_LABELS[spatialGranularity]}
) }