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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user