feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
388
web/src/routes/map/+page.svelte
Normal file
388
web/src/routes/map/+page.svelte
Normal file
@@ -0,0 +1,388 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type MapMouseEvent,
|
||||
type MapSourceDataEvent
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||
queryKey: ['geo'],
|
||||
queryFn: () => listGeo(),
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
let mapEl: HTMLDivElement | undefined = $state();
|
||||
let map: maplibregl.Map | undefined;
|
||||
/** Reactive flag flipped on once the MapLibre `load` event has fired
|
||||
* and the `photos` source has been installed. The data-push `$effect`
|
||||
* depends on this — otherwise, if the geoQuery resolves before the
|
||||
* basemap style finishes loading, the effect runs with no source
|
||||
* available and never re-runs (since `map` itself is not `$state`),
|
||||
* leaving the map permanently empty. */
|
||||
let mapReady = $state(false);
|
||||
|
||||
/** Markers currently attached to the map, keyed by feature id (UIDs
|
||||
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
|
||||
* current `querySourceFeatures` set on every render to add markers
|
||||
* that came into view and remove ones that scrolled out / got
|
||||
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
|
||||
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
|
||||
const markers = new Map<string, maplibregl.Marker>();
|
||||
const markersOnScreen = new Map<string, maplibregl.Marker>();
|
||||
|
||||
onMount(() => {
|
||||
if (!mapEl) return;
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
// PhotoPrism's default basemap style (CDN-hosted, no key required).
|
||||
// The style JSON already references the correct glyphs URL, so
|
||||
// no explicit override is needed here.
|
||||
style: 'https://cdn.photoprism.app/maps/default.json',
|
||||
center: [0, 20],
|
||||
zoom: 1,
|
||||
attributionControl: { compact: true }
|
||||
});
|
||||
map.addControl(
|
||||
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
|
||||
'top-right'
|
||||
);
|
||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
map.on('load', () => {
|
||||
addPhotoLayers();
|
||||
mapReady = true;
|
||||
});
|
||||
|
||||
// PhotoPrism's update strategy: re-reconcile markers on every map
|
||||
// movement, on resize (so cluster bubbles re-balance when the
|
||||
// viewport changes), on idle (catches the post-`fitBounds` settle),
|
||||
// and on `sourcedata` filtered to "source fully loaded" — that's
|
||||
// the moment MapLibre has processed clustering and
|
||||
// `querySourceFeatures` returns meaningful results.
|
||||
const onSourceData = (e: MapSourceDataEvent) => {
|
||||
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
|
||||
};
|
||||
map.on('sourcedata', onSourceData);
|
||||
map.on('move', updateMarkers);
|
||||
map.on('moveend', updateMarkers);
|
||||
map.on('resize', updateMarkers);
|
||||
map.on('idle', updateMarkers);
|
||||
|
||||
return () => {
|
||||
map?.off('sourcedata', onSourceData);
|
||||
map?.off('move', updateMarkers);
|
||||
map?.off('moveend', updateMarkers);
|
||||
map?.off('resize', updateMarkers);
|
||||
map?.off('idle', updateMarkers);
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
map?.remove();
|
||||
map = undefined;
|
||||
mapReady = false;
|
||||
};
|
||||
});
|
||||
|
||||
function addPhotoLayers() {
|
||||
if (!map) return;
|
||||
map.addSource('photos', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
// PhotoPrism's clustering parameters — points within ~80px merge
|
||||
// below zoom 17, individual photos render above that.
|
||||
clusterMaxZoom: 17,
|
||||
clusterRadius: 80
|
||||
});
|
||||
// Invisible layer for clusters — PhotoPrism does this so the source
|
||||
// reports cluster features via `querySourceFeatures` (which only
|
||||
// returns features actually rendered by some layer) while the
|
||||
// visual presentation is owned by HTML markers below.
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
|
||||
});
|
||||
// Click an (invisible) cluster anywhere on the map → zoom to its
|
||||
// expansion level. The marker DOM also has a click handler, but
|
||||
// pointer-through to the map needs this as a fallback.
|
||||
map.on('click', 'clusters', (e: MapMouseEvent) => {
|
||||
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
|
||||
const clusterId = features[0]?.properties?.cluster_id;
|
||||
if (clusterId == null) return;
|
||||
const source = map!.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
const geometry = features[0]?.geometry;
|
||||
if (!geometry || geometry.type !== 'Point') return;
|
||||
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Cluster bubble diameter, scaled by the number of contained photos
|
||||
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
|
||||
function clusterSize(count: number): number {
|
||||
if (count >= 10000) return 74;
|
||||
if (count >= 1000) return 70;
|
||||
if (count >= 750) return 68;
|
||||
if (count >= 200) return 66;
|
||||
if (count >= 100) return 64;
|
||||
return 60;
|
||||
}
|
||||
|
||||
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
|
||||
function abbreviateCount(value: number): string {
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
if (title) el.title = title;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
openPreview(uid, allUids);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildClusterMarker(clusterId: number, count: number) {
|
||||
const size = clusterSize(count);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'cluster-marker';
|
||||
el.appendChild(grid);
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = abbreviateCount(count);
|
||||
el.appendChild(badge);
|
||||
|
||||
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
|
||||
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
|
||||
// source is captured once here; `getClusterLeaves` returns a
|
||||
// Promise, so this populates asynchronously and the bubble shows a
|
||||
// dark placeholder until the thumbs arrive.
|
||||
if (map) {
|
||||
const source = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (source && typeof source.getClusterLeaves === 'function') {
|
||||
source
|
||||
.getClusterLeaves(clusterId, 4, 0)
|
||||
.then((leaves) => {
|
||||
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
|
||||
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
|
||||
for (let i = 0; i < previewCount; i++) {
|
||||
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
|
||||
const props = (leaf?.properties ?? {}) as { Hash?: string };
|
||||
if (!props.Hash) continue;
|
||||
const tile = document.createElement('div');
|
||||
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!map) return;
|
||||
const source = map.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
// Use the marker's current LngLat — set just below in updateMarkers.
|
||||
const m = markers.get(`cluster:${clusterId}`);
|
||||
const ll = m?.getLngLat();
|
||||
if (!ll) return;
|
||||
map!.easeTo({ center: ll, zoom });
|
||||
});
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Reconcile HTML markers against what's currently in the rendered
|
||||
* source. PhotoPrism's `updateMarkers`. */
|
||||
function updateMarkers() {
|
||||
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
|
||||
const features = map.querySourceFeatures('photos');
|
||||
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const f of features) {
|
||||
const props = (f.properties ?? {}) as Record<string, unknown> & {
|
||||
cluster?: boolean;
|
||||
cluster_id?: number;
|
||||
point_count?: number;
|
||||
UID?: string;
|
||||
Hash?: string;
|
||||
Title?: string;
|
||||
};
|
||||
const geom = f.geometry;
|
||||
if (geom.type !== 'Point') continue;
|
||||
const coords = geom.coordinates as [number, number];
|
||||
|
||||
let key: string;
|
||||
let buildEl: () => HTMLElement;
|
||||
if (props.cluster) {
|
||||
if (props.cluster_id == null) continue;
|
||||
key = `cluster:${props.cluster_id}`;
|
||||
const cid = props.cluster_id;
|
||||
const count = props.point_count ?? 0;
|
||||
buildEl = () => buildClusterMarker(cid, count);
|
||||
} else {
|
||||
if (!props.UID || !props.Hash) continue;
|
||||
key = props.UID;
|
||||
const uid = props.UID;
|
||||
const hash = props.Hash;
|
||||
const title = props.Title;
|
||||
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
let marker = markers.get(key);
|
||||
if (!marker) {
|
||||
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
|
||||
markers.set(key, marker);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
}
|
||||
if (!markersOnScreen.has(key)) {
|
||||
marker.addTo(map);
|
||||
markersOnScreen.set(key, marker);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, marker] of markersOnScreen) {
|
||||
if (!seen.has(key)) {
|
||||
marker.remove();
|
||||
markersOnScreen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push new geo data into the source whenever the query resolves AND
|
||||
// the map is ready. Both orderings are handled: if data arrives first,
|
||||
// the effect re-runs when `mapReady` flips; if the map is ready first,
|
||||
// it re-runs when `data` arrives.
|
||||
$effect(() => {
|
||||
const data = geoQuery.data as
|
||||
| (PpGeoCollection & { bbox?: number[] })
|
||||
| undefined;
|
||||
if (!map || !mapReady || !data) return;
|
||||
const src = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
src.setData(data as GeoJSON.FeatureCollection);
|
||||
|
||||
// Drop stale markers; updateMarkers will rebuild for the current
|
||||
// visible set on the next `sourcedata` (fired by setData) or `idle`.
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
|
||||
// Fit to data extent on the first non-empty load — prefer the
|
||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||
// the features.
|
||||
if ((data.features?.length ?? 0) > 0) {
|
||||
let bounds: maplibregl.LngLatBoundsLike | null = null;
|
||||
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
|
||||
bounds = [
|
||||
[data.bbox[0], data.bbox[1]],
|
||||
[data.bbox[2], data.bbox[3]]
|
||||
];
|
||||
} else {
|
||||
const b = new maplibregl.LngLatBounds();
|
||||
for (const f of data.features as PpGeoFeature[]) {
|
||||
const c = f.geometry.coordinates as [number, number];
|
||||
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
|
||||
}
|
||||
if (!b.isEmpty()) bounds = b;
|
||||
}
|
||||
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Map
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{geoQuery.data?.features?.length ?? 0} geotagged
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
|
||||
|
||||
<style>
|
||||
/* PhotoPrism's marker / cluster styling, ported from
|
||||
frontend/src/css/places.css. `:global` because MapLibre appends
|
||||
markers outside Svelte's scoped CSS reach. */
|
||||
:global(.maplibregl-map .marker) {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ffffff99;
|
||||
background-color: rgba(23, 23, 23, 0.23);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker > div) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
:global(.maplibregl-map .badge) {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: #53478a;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user