photos: inspect the pictures behind a map cluster

clicking a cluster only zoomed, so a circle marked 700 was unopenable at any
zoom level that still grouped them. it now opens a lazy list of exactly the
assets that cluster covers, independent of zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:05:49 +00:00
co-authored by Claude Opus 5
parent 67d6a9702a
commit c77e7ee598
2 changed files with 210 additions and 5 deletions
@@ -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<HTMLDivElement | null>(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 (
<aside className="absolute inset-y-0 left-0 z-10 flex w-[min(21rem,72vw)] flex-col border-r bg-background/95 shadow-xl backdrop-blur">
<header className="flex items-center gap-1 border-b px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{place ?? 'Selected area'}</p>
<p className="text-xs text-muted-foreground">
{total.toLocaleString()} photo{total === 1 ? '' : 's'}
</p>
</div>
<Button variant="ghost" size="icon" title="Zoom in on this cluster" onClick={onZoom}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Close (Esc)" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
<div className="grid grid-cols-3 gap-1">
{cluster.ids.slice(0, shown).map((id) => (
<button
key={id}
type="button"
title="Open photo"
onClick={() => onOpen(id)}
className="relative aspect-square overflow-hidden rounded-sm bg-muted"
>
<img
src={thumbUrl(id, token)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition hover:opacity-80"
/>
</button>
))}
</div>
<div ref={sentinelRef} className="h-1" />
{shown < total && (
<p className="py-2 text-center text-xs text-muted-foreground">{(total - shown).toLocaleString()} more</p>
)}
</div>
</aside>
);
};
@@ -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<string | null>(null);
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const [cluster, setCluster] = useState<ClusterSelection | null>(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<string, number>();
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 (
<div className="flex h-full flex-col">
<header className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
@@ -356,6 +425,17 @@ export const MapSection = () => {
</div>
)}
{cluster && (
<MapClusterPanel
cluster={cluster}
place={place}
token={token}
onOpen={open}
onZoom={() => mapRef.current?.easeTo({ center: cluster.center, zoom: cluster.expansionZoom })}
onClose={() => setCluster(null)}
/>
)}
{ready && !isLoading && count === 0 && (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
<p className="rounded-full bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow">
@@ -365,7 +445,9 @@ export const MapSection = () => {
)}
</div>
<AssetViewer ids={visibleIds} token={token} />
{/* 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. */}
<AssetViewer ids={cluster ? cluster.ids : visibleIds} token={token} />
</div>
);
};