import { useEffect, useMemo } from 'react'; import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip, ZoomControl, useMap, } from 'react-leaflet'; import { Box, Text } from '@mantine/core'; import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm'; import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from './location-point'; import { toLeafletLatLngs } from './route-geometry'; import 'leaflet/dist/leaflet.css'; import './leaflet-stacking.css'; export type TimelineMapFootprint = { employeeId: string; latitude: number; longitude: number; recordedAt: number; }; export type TimelineMapActivity = { id: string; type: string; latitude: number; longitude: number; recordedAt: number; label?: string; }; export type TimelineMapPlaybackPosition = { employeeId: string; latitude: number; longitude: number; label?: string; }; export interface TimelineMapProps { footprints?: TimelineMapFootprint[]; activities?: TimelineMapActivity[]; playbackPositions?: TimelineMapPlaybackPosition[]; focusPositions?: Array<[number, number]>; selectedActivityId?: string; height?: number | string; radius?: string | number; fullBleed?: boolean; emptyLabel?: string; } const TRACK_COLORS = [ 'var(--mantine-color-blue-6)', 'var(--mantine-color-teal-6)', 'var(--mantine-color-orange-6)', 'var(--mantine-color-grape-6)', 'var(--mantine-color-cyan-6)', 'var(--mantine-color-pink-6)', ]; function InvalidateSize() { const map = useMap(); useEffect(() => { const container = map.getContainer(); const observer = new ResizeObserver(() => { map.invalidateSize(); }); observer.observe(container); const id = window.setTimeout(() => map.invalidateSize(), 0); return () => { window.clearTimeout(id); observer.disconnect(); }; }, [map]); return null; } function FitTimelineBounds({ positions, }: { positions: Array<[number, number]>; }) { const map = useMap(); const boundsKey = positions.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|'); useEffect(() => { if (positions.length === 0) { map.setView(DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM); return; } if (positions.length === 1) { map.setView(positions[0], 14); return; } map.fitBounds(positions, { padding: [48, 48] }); }, [map, boundsKey]); return null; } function groupFootprintsByEmployee( footprints: readonly TimelineMapFootprint[], ): Map { const grouped = new Map(); for (const point of footprints) { const existing = grouped.get(point.employeeId) ?? []; grouped.set(point.employeeId, [...existing, point]); } for (const [employeeId, points] of grouped) { grouped.set( employeeId, [...points].sort((left, right) => left.recordedAt - right.recordedAt), ); } return grouped; } export function TimelineMap({ footprints = [], activities = [], playbackPositions = [], focusPositions, selectedActivityId, height = 420, radius = 'md', fullBleed = false, emptyLabel = 'No timeline data', }: TimelineMapProps) { const groupedTracks = useMemo( () => groupFootprintsByEmployee(footprints), [footprints], ); const positions = useMemo(() => { const points: Array<[number, number]> = []; for (const footprint of footprints) { points.push([footprint.latitude, footprint.longitude]); } for (const activity of activities) { points.push([activity.latitude, activity.longitude]); } for (const position of playbackPositions) { points.push([position.latitude, position.longitude]); } return points; }, [activities, footprints, playbackPositions]); const boundsPositions = focusPositions && focusPositions.length > 0 ? focusPositions : positions; const employeeIds = [...groupedTracks.keys()]; const hasData = positions.length > 0; return ( {employeeIds.map((employeeId, index) => { const track = groupedTracks.get(employeeId) ?? []; const line = toLeafletLatLngs({ type: 'LineString', coordinates: track.map((point) => [point.longitude, point.latitude]), }); if (line.length < 2) { return null; } return ( ); })} {activities.map((activity) => { const selected = activity.id === selectedActivityId; return ( {activity.label ?? activity.type} ); })} {playbackPositions.map((position) => ( {position.label ?? 'Playback'} ))} {!hasData ? ( {emptyLabel} ) : null} ); }