Files
photos/web/MapSection.tsx
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

454 lines
20 KiB
TypeScript

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';
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
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.
//
// 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
// tiles.immich.cloud — exactly as Immich's web app does it. Proxying a basemap would mean relaying
// thousands of tile requests that contain none of the owner's data, through a sidecar whose job is the
// library. The tile server learns which parts of the world are being looked at and nothing else.
// 2. CLUSTERING is maplibre's, not ours. A geotagged library is tens of thousands of pins, and the GeoJSON
// source does the bucketing on a worker thread. That is also why markers are fetched in one unbucketed
// request: a marker is six numbers, and clustering needs all of them at once to be correct.
// 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
// unknown paths, so that request comes back as HTML and the browser refuses it — "non-JavaScript MIME type of
// text/html", and no map at all. 5.x inlines the worker as a blob, which needs no serving infrastructure.
// Upgrading means either copying maplibre's worker + shared chunks into public/ at build time or calling
// `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';
/** How many photo thumbnails to draw at once. Past this it is a wall of images, not a map. */
const THUMB_LIMIT = 120;
const ACCENT = '#10b981';
type PointFeature = {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: { id: string };
};
type PointCollection = { type: 'FeatureCollection'; features: PointFeature[] };
type BoolFilterKey = 'isFavorite' | 'isArchived' | 'withPartners' | 'withSharedAlbums';
const TOGGLES: { key: BoolFilterKey; label: string; title: string }[] = [
{ key: 'isFavorite', label: 'Favorites', title: 'Only favorited photos' },
{ key: 'isArchived', label: 'Archived', title: 'Include archived photos' },
{ key: 'withPartners', label: 'Partners', title: "Include partners' photos" },
{ key: 'withSharedAlbums', label: 'Shared', title: 'Include photos from shared albums' },
];
/**
* A marker is a plain DOM button rather than JSX, because maplibre owns the element's position and React must
* not re-parent it. Styles are inline for the same reason Tailwind is avoided here: the element is created
* outside the render tree, so keeping its appearance next to its construction is the readable choice.
*/
const makeThumbElement = (id: string, token: string | null, onOpen: (id: string) => void) => {
const el = document.createElement('button');
el.type = 'button';
el.title = 'Open photo';
el.style.cssText =
'width:46px;height:46px;padding:0;border-radius:9999px;border:2px solid rgba(255,255,255,.85);' +
'box-shadow:0 2px 8px rgba(0,0,0,.45);background-color:rgba(0,0,0,.35);background-size:cover;' +
'background-position:center;cursor:pointer;display:block';
el.style.backgroundImage = `url("${thumbUrl(id, token)}")`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
onOpen(id);
});
return el;
};
export const MapSection = () => {
const { token } = useClient();
const { colorMode } = useColorMode();
const [, setParams] = useSearchParams();
const [filter, setFilter] = useState<MapFilter>({ withPartners: true, withSharedAlbums: true });
const { data: config } = useServerConfig();
const { data: markers, isLoading, error: markersError } = useMapMarkers(filter);
const styleUrl = colorMode === 'dark' ? config?.mapDarkStyleUrl : config?.mapLightStyleUrl;
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const thumbsRef = useRef<Marker[]>([]);
const shownRef = useRef('');
const loadedRef = useRef(false);
const fittedRef = useRef(false);
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) => {
setParams((current) => {
const next = new URLSearchParams(current);
next.set(ASSET_PARAM, id);
return next;
});
},
[setParams],
);
const data = useMemo<PointCollection>(
() => ({
type: 'FeatureCollection',
features: (markers ?? []).map((marker) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
properties: { id: marker.id },
})),
}),
[markers],
);
// Build the map. Keyed on the style URL, so flipping Officer's light/dark mode rebuilds it against Immich's
// matching basemap — cheaper to reason about than swapping the style and re-adding every source by hand.
useEffect(() => {
const container = containerRef.current;
if (!container || !styleUrl) return;
loadedRef.current = false;
fittedRef.current = false;
setMapError(null);
const map = new MapLibreMap({
container,
style: styleUrl,
center: [0, 25],
zoom: 1.2,
attributionControl: { compact: true },
});
map.addControl(new NavigationControl({ showCompass: false }), 'top-right');
// Immich's basemap style asks for icons its own sprite sheet doesn't ship — `places_locality` selects
// `capital` for capital cities, and none of the four sprite variants contain it (`townspot` is there).
// That is upstream's bug, visible in Immich's own web map too, and it costs nothing but a console warning
// per tile batch. Handing maplibre a blank keeps the log readable; the label still draws, just iconless.
map.on('styleimagemissing', (ev) => {
if (!map.hasImage(ev.id)) map.addImage(ev.id, { width: 1, height: 1, data: new Uint8Array(4) });
});
map.on('load', () => {
loadedRef.current = true;
setReady(true);
});
// Registering ANY `error` listener turns OFF maplibre's own console logging, so log it back out. Only
// pre-load failures are banner-worthy — a single tile 404 after that would make a working map look
// broken — but swallowing the rest leaves a blank map above an empty console, which cannot be debugged.
map.on('error', (ev) => {
console.error('[photos/map]', ev.error ?? ev);
if (!loadedRef.current) setMapError(ev.error?.message ?? 'The basemap could not be loaded.');
});
mapRef.current = map;
return () => {
thumbsRef.current.forEach((marker) => marker.remove());
thumbsRef.current = [];
shownRef.current = '';
map.remove();
mapRef.current = null;
setReady(false);
setCluster(null);
};
}, [styleUrl]);
// Feed the markers in, adding the layers the first time and only swapping data after that.
useEffect(() => {
const map = mapRef.current;
if (!map || !ready) return;
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined;
if (existing) {
existing.setData(data);
} 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',
source: SOURCE,
filter: ['has', 'point_count'],
paint: {
'circle-color': ACCENT,
'circle-opacity': 0.85,
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 100, 26, 1000, 34],
'circle-stroke-width': 2,
'circle-stroke-color': 'rgba(255,255,255,.75)',
},
});
map.addLayer({
id: COUNT_LAYER,
type: 'symbol',
source: SOURCE,
filter: ['has', 'point_count'],
// Immich's own style ships this fontstack; anything else would 404 and silently drop the numbers.
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['Noto Sans Medium'],
'text-size': 12,
},
paint: { 'text-color': '#05231b' },
});
map.addLayer({
id: POINT_LAYER,
type: 'circle',
source: SOURCE,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': ACCENT,
'circle-radius': 6,
'circle-stroke-width': 2,
'circle-stroke-color': 'rgba(255,255,255,.8)',
},
});
// 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 source = map.getSource(SOURCE) as GeoJSONSource | undefined;
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;
if (typeof id === 'string') open(id);
});
for (const layer of [CLUSTER_LAYER, POINT_LAYER]) {
map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
}
}
// Frame the library once. Re-fitting on every filter change would yank the view out from under someone
// who just zoomed into a city and then unticked "archived".
if (!fittedRef.current && data.features.length > 0) {
const bounds = new LngLatBounds();
for (const feature of data.features) bounds.extend(feature.geometry.coordinates);
map.fitBounds(bounds, { padding: 64, maxZoom: 12, animate: false });
fittedRef.current = true;
}
}, [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(() => {
const map = mapRef.current;
if (!map || !ready) return;
const refresh = () => {
if (!map.getLayer(POINT_LAYER)) return;
const seen = new Set<string>();
const picked: { id: string; at: [number, number] }[] = [];
for (const feature of map.queryRenderedFeatures({ layers: [POINT_LAYER] })) {
const id = feature.properties?.id;
if (typeof id !== 'string' || seen.has(id) || feature.geometry.type !== 'Point') continue;
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== 'number' || typeof lat !== 'number') continue;
seen.add(id);
picked.push({ id, at: [lon, lat] });
if (picked.length >= THUMB_LIMIT) break;
}
// Rebuilding identical markers would restart every image fade for no reason.
const signature = picked.map((item) => item.id).join(',');
if (signature === shownRef.current) return;
shownRef.current = signature;
thumbsRef.current.forEach((marker) => marker.remove());
thumbsRef.current = picked.map(({ id, at }) =>
new Marker({ element: makeThumbElement(id, token, open) }).setLngLat(at).addTo(map),
);
setVisibleIds(picked.map((item) => item.id));
};
map.on('idle', refresh);
return () => {
map.off('idle', refresh);
};
}, [ready, token, open]);
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">
<h1 className="text-sm font-semibold">Map</h1>
{/* A failed marker fetch used to look identical to an empty library: the map simply stayed on its
opening view. Say so instead — the basemap can be perfectly fine while the pins are missing. */}
<span className={`text-xs ${markersError ? 'text-destructive' : 'text-muted-foreground'}`}>
{markersError
? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}`
: isLoading
? 'Loading…'
: `${count.toLocaleString()} geotagged`}
</span>
<div className="flex-1" />
{TOGGLES.map(({ key, label, title }) => (
<Button
key={key}
size="sm"
variant={filter[key] ? 'default' : 'ghost'}
title={title}
onClick={() => toggle(key)}
>
{label}
</Button>
))}
</header>
<div className="relative min-h-0 flex-1">
{/*
Sized with h-full/w-full, NOT `absolute inset-0`, and that is not a style preference.
maplibre puts `position: relative; overflow: hidden` on whatever element it is handed, via its own
stylesheet's `.maplibregl-map`. Tailwind 4 emits its utilities inside `@layer utilities`, and an
UNLAYERED rule beats a layered one no matter which comes later in the file — so `.maplibregl-map`
wins over `.absolute`, `inset-0` stops applying, and the div collapses to height 0. It then clips
its own canvas away, which looks exactly like a map that failed to load: the GL context is healthy,
tiles download, pixels are drawn, and none of it is ever composited. Nothing logs a thing.
h-full survives because maplibre's stylesheet sets no height, so there is no conflict to lose.
*/}
<div ref={containerRef} className="h-full w-full" />
{!styleUrl && !mapError && (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
Loading map…
</p>
)}
{mapError && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">The map could not load</p>
<p className="max-w-sm text-xs text-muted-foreground">
The basemap is fetched from Immich&apos;s tile server, so this needs outbound internet access.
{mapError ? ` (${mapError})` : ''}
</p>
</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">
No geotagged photos match this filter.
</p>
</div>
)}
</div>
{/* 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>
);
};