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({ 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(null); const mapRef = useRef(null); const thumbsRef = useRef([]); const shownRef = useRef(''); const loadedRef = useRef(false); const fittedRef = useRef(false); const [ready, setReady] = useState(false); const [mapError, setMapError] = useState(null); const [visibleIds, setVisibleIds] = useState([]); const [cluster, setCluster] = useState(null); const open = useCallback( (id: string) => { setParams((current) => { const next = new URLSearchParams(current); next.set(ASSET_PARAM, id); return next; }); }, [setParams], ); const data = useMemo( () => ({ 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(); 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(); 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 (

Map

{/* 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. */} {markersError ? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}` : isLoading ? 'Loading…' : `${count.toLocaleString()} geotagged`}
{TOGGLES.map(({ key, label, title }) => ( ))}
{/* 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. */}
{!styleUrl && !mapError && (

Loading map…

)} {mapError && (

The map could not load

The basemap is fetched from Immich's tile server, so this needs outbound internet access. {mapError ? ` (${mapError})` : ''}

)} {cluster && ( mapRef.current?.easeTo({ center: cluster.center, zoom: cluster.expansionZoom })} onClose={() => setCluster(null)} /> )} {ready && !isLoading && count === 0 && (

No geotagged photos match this filter.

)}
{/* 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. */}
); };