Revert coordinate transformation (PhotoPrism already returns correct [lng,lat] format). Fix basePath filter query string to properly quote paths with special characters and add wildcard suffix using same quoteIfNeeded logic as filters store. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
436 lines
15 KiB
Svelte
436 lines
15 KiB
Svelte
<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 { goto } from '$app/navigation';
|
|
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
|
import { isAuthenticated, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
|
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
|
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
|
|
|
// Helper to quote if needed (matches filters.svelte.ts logic)
|
|
function quoteIfNeeded(v: string): string {
|
|
if (!v) return '';
|
|
if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v;
|
|
return `"${v.replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
const geoQuery = createQuery<PpGeoCollection>(() => {
|
|
const bp = userBasePath();
|
|
// Build path filter: add wildcard first, then quote if needed
|
|
const q = bp ? `path:${quoteIfNeeded(bp + '*')}` : '';
|
|
return {
|
|
queryKey: ['geo', bp],
|
|
queryFn: () => listGeo(q),
|
|
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();
|
|
setOrder(allUids);
|
|
setFocused(uid);
|
|
setAnchor(uid);
|
|
void goto('/');
|
|
});
|
|
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();
|
|
|
|
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
|
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
|
// rather than fitting to the full library extent. Strip the params
|
|
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
|
// through to the default fitBounds when the params aren't present.
|
|
const sp = new URL(window.location.href).searchParams;
|
|
const latParam = Number(sp.get('lat'));
|
|
const lngParam = Number(sp.get('lng'));
|
|
if (
|
|
(data.features?.length ?? 0) > 0 &&
|
|
Number.isFinite(latParam) &&
|
|
Number.isFinite(lngParam) &&
|
|
sp.has('lat') &&
|
|
sp.has('lng')
|
|
) {
|
|
const zoom = Number(sp.get('zoom')) || 17;
|
|
map.jumpTo({ center: [lngParam, latParam], zoom });
|
|
const stripped = new URL(window.location.href);
|
|
stripped.searchParams.delete('lat');
|
|
stripped.searchParams.delete('lng');
|
|
stripped.searchParams.delete('zoom');
|
|
stripped.searchParams.delete('focus');
|
|
const qs = stripped.searchParams.toString();
|
|
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
|
replaceState: true,
|
|
keepFocus: true,
|
|
noScroll: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 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>
|