feat(countries): replace Map view with browse-by-country (like Tags)

Removes the maplibre-gl Map view and adds "Countries" as a sixth
TagCategory, reusing the existing /tags/[category]/[[value]] browse
machinery instead of a bespoke map UI. Backed by a new self-contained
sidecar endpoint that aggregates photos.photo_country with BasePath
scoping, mirroring handleLabels/handleScopedCounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:25:24 +02:00
parent 74bae78270
commit ba5684d120
12 changed files with 242 additions and 736 deletions

View File

@@ -214,7 +214,8 @@
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
ratings: 'Ratings',
countries: 'Countries'
};
function isTagCategoryActive(cat: TagCategory): boolean {
@@ -381,7 +382,7 @@
//
// `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant.
// Map and Tags intentionally render without a count badge; the count
// Tags intentionally renders without a count badge; the count
// columns inside the TagsBrowserSidebar are the canonical surface for
// per-tag totals. Review rolls in the duplicates tabs hosted under
// /review — stacks always contributes; cross-folder only contributes
@@ -396,10 +397,9 @@
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// Ratings/Countries) and a chevron, neither of which fits the flat
// section/route ViewItem shape. Notes lives under that expandable
// alongside the tag categories.
];

View File

@@ -6,7 +6,6 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
@@ -16,10 +15,10 @@
Calendar,
File,
Folder,
Globe,
HardDrive,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
@@ -43,8 +42,9 @@
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
@@ -432,28 +432,22 @@
</span>
</div>
<!-- Location (read-only label + open-on-map icon). The arrow-up-
right icon flies the in-app map to the photo's coordinates at
zoom 17 (close enough for the photo's marker to be its own,
out of any cluster). Hidden when the photo has no
coordinates. -->
<!-- Location (read-only label + jump-to-country icon). Hidden when
the photo has no resolved country. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if photo.Lat && photo.Lng}
{#if photo.Country && photo.Country !== 'zz'}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void goto(
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
)}
title="Open on map"
aria-label="Open on map"
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
title={`View other photos from ${countryName(photo.Country)}`}
aria-label={`View other photos from ${countryName(photo.Country)}`}
>
<MapIcon class="h-3 w-3" />
<Globe class="h-3 w-3" />
</button>
{/if}
</div>

View File

@@ -3,11 +3,13 @@
import {
aggregateKeywords,
getAllMarks,
listCountries,
listLabels,
listPhotosByUids,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
type PpCountry,
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
@@ -20,9 +22,10 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag, User } from 'lucide-svelte';
import { Globe, Hash, Tag, User } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -64,6 +67,12 @@
enabled: isAuthenticated() && category === 'people'
}));
const countriesQuery = createQuery<PpCountry[]>(() => ({
queryKey: ['countries'],
queryFn: listCountries,
enabled: isAuthenticated() && category === 'countries'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -125,6 +134,19 @@
);
});
// PhotoPrism returns countries unsorted; sort by photo count descending so
// the most-photographed countries surface first (mirrors labels/people).
const countriesSorted = $derived(
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
);
const filteredCountries = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return countriesSorted;
return countriesSorted.filter((c) =>
countryName(c.Code).toLowerCase().includes(q)
);
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -152,9 +174,11 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -179,6 +203,9 @@
const s = String(r);
if (selectedValue !== s) onSelect(s);
}
function pickCountry(code: string) {
if (selectedValue !== code) onSelect(code);
}
// First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors
@@ -203,6 +230,9 @@
const g = ratingGroups[0];
return g ? String(g.rating) : null;
}
if (category === 'countries') {
return countriesSorted[0]?.Code ?? null;
}
return null;
});
@@ -228,11 +258,16 @@
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
: category === 'countries'
? 'Countries'
: 'Ratings'
);
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
</script>
@@ -448,6 +483,69 @@
{/if}
</div>
{/if}
{:else if category === 'countries'}
{#if countriesQuery.isPending}
<InlineLoader size="sm" label="Loading countries…" />
{:else if countriesQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
{:else if filteredCountries.length === 0}
<EmptyState
size="compact"
icon={Globe}
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleCountries as countryRow (countryRow.Code)}
{@const active = countryRow.Code === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickCountry(countryRow.Code)}
title={countryName(countryRow.Code)}
>
{#if countryRow.Thumb}
<img
src={thumbUrl(countryRow.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
{:else}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
{countryFlag(countryRow.Code)}
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{countryRow.PhotoCount}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreCountries,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreCountries}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredCountries.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>

View File

@@ -560,36 +560,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
return out;
}
// ── Geo ──────────────────────────────────────────────────────────────────────
// ── Countries ────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
type: 'Feature';
id: string;
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
UID: string;
Hash: string;
Title?: string;
TakenAt?: string;
FavId?: number;
};
export interface PpCountry {
Code: string;
PhotoCount: number;
Thumb?: string;
}
export interface PpGeoCollection {
type: 'FeatureCollection';
features: PpGeoFeature[];
bbox?: number[];
}
export async function listGeo(q = ''): Promise<PpGeoCollection> {
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
// matching geocoded photo. MapLibre's native clustering handles 50k+
// points without breaking a sweat (PhotoPrism upstream documents
// 500k); we ask for a generous cap that covers realistic libraries.
const { data } = await http.get<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined }
});
export async function listCountries(): Promise<PpCountry[]> {
// Self-contained sidecar aggregation (groups photos.photo_country directly,
// no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
// caller's BasePath the same way /labels and /counts are.
const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
return data;
}

View File

@@ -17,14 +17,21 @@ export type Section =
| 'hidden'
| 'heap';
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export type TagCategory =
| 'labels'
| 'keywords'
| 'people'
| 'colors'
| 'ratings'
| 'countries';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'people',
'colors',
'ratings'
'ratings',
'countries'
] as const;
export function isTagCategory(v: unknown): v is TagCategory {
@@ -239,6 +246,8 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'people') {
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'countries') {
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search));

View File

@@ -0,0 +1,29 @@
// Country code (ISO 3166-1 alpha-2, lowercase — PhotoPrism's `Country` field
// shape) → display helpers for the Countries tag-browser category.
let regionNames: Intl.DisplayNames | undefined;
function getRegionNames(): Intl.DisplayNames | undefined {
if (regionNames) return regionNames;
try {
regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
regionNames = undefined;
}
return regionNames;
}
export function countryName(code: string): string {
if (!code) return code;
const name = getRegionNames()?.of(code.toUpperCase());
return name ?? code;
}
const REGIONAL_INDICATOR_OFFSET = 0x1f1a5; // 0x1f1e6 ('A') - 'A'.charCodeAt(0)
export function countryFlag(code: string): string {
if (!code || code.length !== 2) return '';
const upper = code.toUpperCase();
return Array.from(upper)
.map((ch) => String.fromCodePoint(ch.charCodeAt(0) + REGIONAL_INDICATOR_OFFSET))
.join('');
}

View File

@@ -1,435 +0,0 @@
<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>

View File

@@ -29,6 +29,7 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
@@ -69,7 +70,10 @@
// label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them).
const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
const drillQ = $derived(
useServer && selectedValue
@@ -161,6 +165,7 @@
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
);
}
if (category === 'countries') return countryName(selectedValue);
return selectedValue;
});