diff --git a/src/workspaces/officerdev/src/apps/Photos/MapClusterPanel.tsx b/src/workspaces/officerdev/src/apps/Photos/MapClusterPanel.tsx new file mode 100644 index 00000000..bdaaa2f6 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Photos/MapClusterPanel.tsx @@ -0,0 +1,123 @@ +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 ( + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Photos/MapSection.tsx b/src/workspaces/officerdev/src/apps/Photos/MapSection.tsx index 3a89380d..994cf293 100644 --- a/src/workspaces/officerdev/src/apps/Photos/MapSection.tsx +++ b/src/workspaces/officerdev/src/apps/Photos/MapSection.tsx @@ -1,4 +1,5 @@ import type { GeoJSONSource } from 'maplibre-gl'; +import type { ClusterSelection } from './MapClusterPanel'; import type { MapFilter } from './usePhotosData'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; @@ -8,12 +9,13 @@ import { Button } from '@/components/ui/button'; import { useColorMode } from '@/components/ui/ThemeProvider'; import { useClient } from 'hooks/useClient'; import { AssetViewer } from './AssetViewer'; +import { MapClusterPanel } from './MapClusterPanel'; import { ASSET_PARAM, thumbUrl } from './shared'; import { useMapMarkers, useServerConfig } from './usePhotosData'; // The map, as close to Immich's as the pieces allow. // -// Three things are worth knowing before changing this: +// Four things are worth knowing before changing this: // // 1. The BASEMAP is the only thing on /photos the browser fetches from outside Officer. Its URL comes from // Immich's own `/server/config` (proxied), but the vector tiles themselves come straight from @@ -26,6 +28,9 @@ import { useMapMarkers, useServerConfig } from './usePhotosData'; // 3. The photo THUMBNAILS are DOM markers layered over an invisible-by-occlusion circle layer, capped at // THUMB_LIMIT and rebuilt on `idle`. Rendering an image per asset into the GL context would mean // uploading thousands of textures; the browser only ever holds the ones actually on screen. +// 4. Clicking a cluster OPENS IT rather than zooming — see MapClusterPanel. Zooming is still available from +// the panel, but it is not the answer to "what is in there": photos taken in one place stay one circle at +// every zoom, so expansion alone can leave a count you can never break down. // // maplibre-gl is PINNED TO 5.x on purpose. In 6.0 the tile worker became a separate `maplibre-gl-worker.mjs` // resolved at runtime from `import.meta.url`. Officer bundles the SPA and serves an index.html fallback for @@ -35,6 +40,7 @@ import { useMapMarkers, useServerConfig } from './usePhotosData'; // `setWorkerUrl()`; do not bump the major without doing one of those. const SOURCE = 'photos-assets'; +const HALO_LAYER = 'photos-cluster-halo'; const CLUSTER_LAYER = 'photos-clusters'; const COUNT_LAYER = 'photos-cluster-count'; const POINT_LAYER = 'photos-points'; @@ -101,6 +107,7 @@ export const MapSection = () => { const [ready, setReady] = useState(false); const [mapError, setMapError] = useState(null); const [visibleIds, setVisibleIds] = useState([]); + const [cluster, setCluster] = useState(null); const open = useCallback( (id: string) => { @@ -173,6 +180,7 @@ export const MapSection = () => { map.remove(); mapRef.current = null; setReady(false); + setCluster(null); }; }, [styleUrl]); @@ -187,6 +195,22 @@ export const MapSection = () => { } else { map.addSource(SOURCE, { type: 'geojson', data, cluster: true, clusterRadius: 60, clusterMaxZoom: 15 }); + // Drawn under the clusters so the selected one gets a ring around it. Its filter is set from state; an + // impossible cluster id is the "nothing selected" case, since maplibre has no way to hide a layer's + // features other than a filter that matches none of them. + map.addLayer({ + id: HALO_LAYER, + type: 'circle', + source: SOURCE, + filter: ['==', ['get', 'cluster_id'], -1], + paint: { + 'circle-color': 'rgba(0,0,0,0)', + 'circle-radius': ['step', ['get', 'point_count'], 23, 10, 27, 100, 33, 1000, 41], + 'circle-stroke-width': 3, + 'circle-stroke-color': ACCENT, + }, + }); + map.addLayer({ id: CLUSTER_LAYER, type: 'circle', @@ -226,13 +250,27 @@ export const MapSection = () => { }, }); + // Open the cluster rather than zoom into it. Both the leaves and the expansion zoom are read HERE, while + // the cluster id is still live — it is only meaningful at the zoom it was queried at, so resolving either + // one later (when the panel's zoom button is pressed, say) would be a race against the camera. map.on('click', CLUSTER_LAYER, (ev) => { const feature = ev.features?.[0]; const clusterId = feature?.properties?.cluster_id; if (feature?.geometry.type !== 'Point' || typeof clusterId !== 'number') return; - const [lon, lat] = feature.geometry.coordinates; + const source = map.getSource(SOURCE) as GeoJSONSource | undefined; - void source?.getClusterExpansionZoom(clusterId).then((zoom) => map.easeTo({ center: [lon, lat], zoom })); + if (!source) return; + + const [lon, lat] = feature.geometry.coordinates as [number, number]; + const count = Number(feature.properties?.point_count ?? 0); + + void Promise.all([ + source.getClusterLeaves(clusterId, count, 0), + source.getClusterExpansionZoom(clusterId), + ]).then(([leaves, expansionZoom]) => { + const ids = leaves.map((leaf) => leaf.properties?.id).filter((id): id is string => typeof id === 'string'); + setCluster({ clusterId, ids, center: [lon, lat], expansionZoom }); + }); }); map.on('click', POINT_LAYER, (ev) => { const id = ev.features?.[0]?.properties?.id; @@ -254,6 +292,14 @@ export const MapSection = () => { } }, [ready, data, open]); + // Ring the open cluster. The id goes stale as soon as the camera moves — the ring then simply matches nothing + // and disappears, which is the honest outcome: the panel's list is a snapshot, and that circle is gone. + useEffect(() => { + const map = mapRef.current; + if (!map || !ready || !map.getLayer(HALO_LAYER)) return; + map.setFilter(HALO_LAYER, ['==', ['get', 'cluster_id'], cluster?.clusterId ?? -1]); + }, [ready, cluster]); + // Swap the thumbnail markers whenever the map settles. `idle` rather than `move`, so a drag costs one rebuild // at the end instead of one per frame. useEffect(() => { @@ -293,11 +339,34 @@ export const MapSection = () => { }; }, [ready, token, open]); - const toggle = (key: BoolFilterKey) => + const toggle = (key: BoolFilterKey) => { + // The open cluster is a snapshot of ids from the previous marker set; a filter change can only make it a + // list of photos that are no longer on the map. + setCluster(null); setFilter((current) => ({ ...current, [key]: current[key] ? undefined : true })); + }; const count = markers?.length ?? 0; + // Name the open cluster from the markers' own reverse-geocoding. Computed here rather than at click time + // because the click handler is registered once and would close over the first marker set forever. + const place = useMemo(() => { + if (!cluster || !markers) return null; + + const byId = new Map(markers.map((marker) => [marker.id, marker])); + const counts = new Map(); + for (const id of cluster.ids) { + const marker = byId.get(id); + const name = [marker?.city, marker?.country].filter(Boolean).join(', '); + if (name) counts.set(name, (counts.get(name) ?? 0) + 1); + } + + const ranked = [...counts].sort(([, a], [, b]) => b - a); + const top = ranked[0]?.[0]; + if (!top) return null; + return ranked.length > 1 ? `${top} +${ranked.length - 1} more` : top; + }, [cluster, markers]); + return (
@@ -356,6 +425,17 @@ export const MapSection = () => {
)} + {cluster && ( + mapRef.current?.easeTo({ center: cluster.center, zoom: cluster.expansionZoom })} + onClose={() => setCluster(null)} + /> + )} + {ready && !isLoading && count === 0 && (

@@ -365,7 +445,9 @@ export const MapSection = () => { )}

- + {/* With a cluster open, prev/next walk that cluster — otherwise the arrows would jump to whatever + thumbnails happen to be on the map behind the panel. */} + ); };