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>
454 lines
20 KiB
TypeScript
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'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>
|
|
);
|
|
};
|