import { useEffect, useRef, useState } from 'react'; import { X, ZoomIn } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { thumbUrl } from './shared'; // The contents of one map cluster, listed. Clicking a circle marked "712" used to do nothing but zoom, which // answers "where are they" and never "what are they" — and for a tight cluster of photos taken in one place, // zooming never separates them at all. // // The id list is SNAPSHOT at click time by the caller, not paged out of the cluster on demand. maplibre's // cluster ids are assigned per zoom level and stop existing the moment the camera moves, so a panel that // fetched page 2 lazily would fail exactly when someone scrolled it and then nudged the map. A cluster of // 12,000 assets is 12,000 short strings — cheap to hold, and it makes the panel independent of the map. // // Lazy is therefore about DOM and images, not data: `PAGE` tiles at a time, grown by an IntersectionObserver. /** What the caller snapshots when a cluster is clicked. */ export type ClusterSelection = { /** maplibre's cluster id — only valid at the zoom it was read at, so it is used for highlighting and nothing else. */ clusterId: number; /** Every asset in the cluster, in the order the source returned them. */ ids: string[]; center: [number, number]; /** The zoom that would break this cluster apart, resolved at click time while the id was still live. */ expansionZoom: number; }; /** How many tiles to add each time the end of the list comes into view. */ const PAGE = 90; type MapClusterPanelProps = { cluster: ClusterSelection; /** Human name for the area, derived from the markers' own city/country. */ place: string | null; token: string | null; onOpen: (id: string) => void; onZoom: () => void; onClose: () => void; }; export const MapClusterPanel = ({ cluster, place, token, onOpen, onZoom, onClose }: MapClusterPanelProps) => { const [shown, setShown] = useState(PAGE); const sentinelRef = useRef(null); const total = cluster.ids.length; useEffect(() => setShown(PAGE), [cluster.ids]); // The sentinel is always rendered, so the observer never has to be torn down and re-attached as the list // grows; once everything is shown the callback just clamps to the same value and React bails out. useEffect(() => { const node = sentinelRef.current; if (!node) return; const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) setShown((count) => Math.min(count + PAGE, total)); }, { rootMargin: '400px' }, ); observer.observe(node); return () => observer.disconnect(); }, [total]); // Safe to own Escape: AssetViewer only registers its own handler while an asset is open, and when one is it // sits above this panel, so the two never both fire. useEffect(() => { const onKey = (ev: KeyboardEvent) => { if (ev.key !== 'Escape') return; onClose(); ev.preventDefault(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return ( ); };