624 lines
20 KiB
Svelte
624 lines
20 KiB
Svelte
<script lang="ts" module>
|
|
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
|
|
import maplibregl, { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
|
|
import MaplibreWorker from 'maplibre-gl/dist/maplibre-gl-csp-worker?worker';
|
|
import { Protocol } from 'pmtiles';
|
|
|
|
// @ts-expect-error maplibregl types do not include workerClass, but it is required for CSP
|
|
maplibregl.workerClass = MaplibreWorker;
|
|
|
|
let protocol = new Protocol();
|
|
void addProtocol('pmtiles', protocol.tile);
|
|
void setRTLTextPlugin(mapboxRtlUrl, true);
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { afterNavigate } from '$app/navigation';
|
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
|
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
|
import { mapSettings, mapShowHeatmap } from '$lib/stores/preferences.store';
|
|
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
|
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
|
import { Icon, modalManager, Theme, themeManager } from '@immich/ui';
|
|
import {
|
|
mdiCog,
|
|
mdiCrosshairsGps,
|
|
mdiFullscreen,
|
|
mdiFullscreenExit,
|
|
mdiImageMultiple,
|
|
mdiEarth,
|
|
mdiMap,
|
|
mdiMapMarker,
|
|
mdiMinus,
|
|
mdiPlus,
|
|
} from '@mdi/js';
|
|
import type { Feature, Point } from 'geojson';
|
|
import { debounce, isEqual } from 'lodash-es';
|
|
import { DateTime, Duration } from 'luxon';
|
|
import {
|
|
LngLat,
|
|
LngLatBounds,
|
|
Marker,
|
|
type ExpressionSpecification,
|
|
type GeoJSONSource,
|
|
type LngLatLike,
|
|
type ProjectionSpecification,
|
|
type Map,
|
|
type MapMouseEvent,
|
|
} from 'maplibre-gl';
|
|
import { onDestroy, onMount, untrack } from 'svelte';
|
|
import { t } from 'svelte-i18n';
|
|
import { GeoJSON, HeatmapLayer, MapLibre, MarkerLayer, Popup } from 'svelte-maplibre';
|
|
import type { SelectionBBox } from './types';
|
|
import { autoZoomCluster } from './map-utils';
|
|
|
|
interface Props {
|
|
mapMarkers?: MapMarkerResponseDto[];
|
|
showSettings?: boolean;
|
|
zoom?: number | undefined;
|
|
center?: LngLatLike | undefined;
|
|
simplified?: boolean;
|
|
clickable?: boolean;
|
|
useLocationPin?: boolean;
|
|
onOpenInMapView?: (() => Promise<void> | void) | undefined;
|
|
onSelect?: (assetIds: string[]) => void;
|
|
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
|
|
onBoundsChange?: (bbox: SelectionBBox) => void;
|
|
visibleAssetIds?: Set<string> | undefined;
|
|
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
|
|
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
|
|
rounded?: boolean;
|
|
autoFitBounds?: boolean;
|
|
isTimelineOpen?: boolean;
|
|
onToggleTimeline?: () => void;
|
|
sheetHeight?: number;
|
|
isDraggingSheet?: boolean;
|
|
}
|
|
|
|
let {
|
|
mapMarkers = $bindable(),
|
|
showSettings = true,
|
|
zoom = undefined,
|
|
center = $bindable(undefined),
|
|
simplified = false,
|
|
clickable = false,
|
|
useLocationPin = false,
|
|
onOpenInMapView = undefined,
|
|
onSelect = () => {},
|
|
onClusterSelect,
|
|
onBoundsChange,
|
|
visibleAssetIds,
|
|
onClickPoint = () => {},
|
|
popup,
|
|
rounded = false,
|
|
autoFitBounds = true,
|
|
isTimelineOpen = false,
|
|
onToggleTimeline,
|
|
sheetHeight = 50,
|
|
isDraggingSheet = false,
|
|
}: Props = $props();
|
|
|
|
const initialBounds = (() => {
|
|
if (!autoFitBounds || center || zoom !== undefined || !mapMarkers || mapMarkers.length === 0) {
|
|
return undefined;
|
|
}
|
|
const bounds = new LngLatBounds();
|
|
for (const marker of mapMarkers) {
|
|
bounds.extend([marker.lon, marker.lat]);
|
|
}
|
|
return bounds;
|
|
})();
|
|
|
|
let map: Map | undefined = $state.raw();
|
|
let marker: Marker | null = null;
|
|
let abortController: AbortController;
|
|
let isFullscreen = $state(false);
|
|
let isGlobeView = $state(false);
|
|
|
|
let innerWidth = $state(1024);
|
|
let isMobile = $derived(innerWidth < 768);
|
|
let hideMapControls = $derived(isMobile && sheetHeight > 40);
|
|
|
|
const mapTheme = $derived(themeManager.value);
|
|
const styleUrl = $derived(
|
|
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
|
|
);
|
|
const mapProjection = $derived<ProjectionSpecification>({
|
|
type: isGlobeView ? 'globe' : 'mercator',
|
|
});
|
|
|
|
export function addClipMapMarker(lng: number, lat: number) {
|
|
if (map) {
|
|
if (marker) {
|
|
marker.remove();
|
|
}
|
|
center = { lng, lat };
|
|
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
|
}
|
|
}
|
|
|
|
void addClipMapMarker;
|
|
|
|
function handleAssetClick(assetId: string, map: Map | null) {
|
|
if (!map) {
|
|
return;
|
|
}
|
|
onSelect([assetId]);
|
|
}
|
|
|
|
async function handleClusterClick(clusterId: number, mapInstance: Map | null) {
|
|
if (!mapInstance) {
|
|
return;
|
|
}
|
|
const mapSource = mapInstance.getSource('geojson') as GeoJSONSource;
|
|
|
|
await autoZoomCluster({
|
|
map: mapInstance,
|
|
mapSource,
|
|
clusterId,
|
|
onSelect,
|
|
onClusterSelect,
|
|
});
|
|
}
|
|
|
|
const handleBoundsChange = debounce(() => {
|
|
if (!map || !onBoundsChange) {
|
|
return;
|
|
}
|
|
|
|
const bounds = map.getBounds();
|
|
if (!bounds) {
|
|
return;
|
|
}
|
|
|
|
let west = bounds.getWest();
|
|
let east = bounds.getEast();
|
|
let south = Math.max(-90, Math.min(90, bounds.getSouth()));
|
|
let north = Math.max(-90, Math.min(90, bounds.getNorth()));
|
|
|
|
if (east - west >= 360) {
|
|
west = -180;
|
|
east = 180;
|
|
} else {
|
|
west = bounds.getSouthWest().wrap().lng;
|
|
east = bounds.getNorthEast().wrap().lng;
|
|
}
|
|
|
|
onBoundsChange({ west, south, east, north });
|
|
}, 200);
|
|
|
|
function handleMapClick(event: MapMouseEvent) {
|
|
if (clickable) {
|
|
const { lng, lat } = event.lngLat;
|
|
onClickPoint({ lng, lat });
|
|
if (marker) {
|
|
marker.remove();
|
|
}
|
|
if (map) {
|
|
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
|
}
|
|
}
|
|
}
|
|
|
|
type FeaturePoint = Feature<Point, { id: string; city: string | null; state: string | null; country: string | null }>;
|
|
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
|
|
return {
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
|
|
properties: {
|
|
id: marker.id,
|
|
city: marker.city,
|
|
state: marker.state,
|
|
country: marker.country,
|
|
},
|
|
};
|
|
};
|
|
|
|
const asMarker = (feature: Feature): MapMarkerResponseDto => {
|
|
const featurePoint = feature as FeaturePoint;
|
|
const coords = LngLat.convert(featurePoint.geometry.coordinates as [number, number]);
|
|
return {
|
|
lat: coords.lat,
|
|
lon: coords.lng,
|
|
id: featurePoint.properties.id,
|
|
city: featurePoint.properties.city,
|
|
state: featurePoint.properties.state,
|
|
country: featurePoint.properties.country,
|
|
};
|
|
};
|
|
|
|
function getFileCreatedDates() {
|
|
const { relativeDate, dateAfter, dateBefore } = $mapSettings;
|
|
if (relativeDate) {
|
|
const duration = Duration.fromISO(relativeDate);
|
|
return { fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined };
|
|
}
|
|
return {
|
|
fileCreatedAfter: dateAfter?.toUTC().toISO(),
|
|
fileCreatedBefore: dateBefore?.toUTC().toISO(),
|
|
};
|
|
}
|
|
|
|
const filter: ExpressionSpecification | undefined = $derived.by(() =>
|
|
visibleAssetIds
|
|
? (['in', ['get', 'id'], ['literal', Array.from(visibleAssetIds)]] as unknown as ExpressionSpecification)
|
|
: undefined,
|
|
);
|
|
|
|
async function loadMapMarkers() {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
abortController = new AbortController();
|
|
|
|
const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
|
|
const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
|
|
|
|
return await getMapMarkers(
|
|
{
|
|
isArchived: includeArchived || undefined,
|
|
isFavorite: onlyFavorites || undefined,
|
|
fileCreatedAfter,
|
|
fileCreatedBefore,
|
|
withPartners: withPartners || undefined,
|
|
withSharedAlbums: withSharedAlbums || undefined,
|
|
},
|
|
{ signal: abortController.signal },
|
|
);
|
|
}
|
|
|
|
const handleSettingsClick = () => {
|
|
handlePromiseError(
|
|
modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } }).then(async (settings) => {
|
|
if (settings) {
|
|
const shouldUpdate = !isEqual(settings, $mapSettings);
|
|
$mapSettings = settings;
|
|
if (shouldUpdate) {
|
|
mapMarkers = await loadMapMarkers();
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
};
|
|
|
|
afterNavigate(() => {
|
|
if (map) {
|
|
map.resize();
|
|
if (globalThis.location.hash) {
|
|
const hashChangeEvent = new HashChangeEvent('hashchange');
|
|
globalThis.dispatchEvent(hashChangeEvent);
|
|
}
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
void loadMapMarkers().then((markers) => {
|
|
if (!mapMarkers) {
|
|
mapMarkers = markers;
|
|
}
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
abortController?.abort();
|
|
});
|
|
|
|
$effect(() => {
|
|
map?.setStyle(styleUrl, {
|
|
transformStyle: (previousStyle, nextStyle) => {
|
|
if (previousStyle) {
|
|
const customLayers = previousStyle.layers.filter((l) => l.type === 'fill' && l.source === 'geojson');
|
|
const layers = nextStyle.layers.concat(customLayers);
|
|
const sources = nextStyle.sources;
|
|
for (const [key, value] of Object.entries(previousStyle.sources || {})) {
|
|
if (key.startsWith('geojson')) {
|
|
sources[key] = value;
|
|
}
|
|
}
|
|
return { ...nextStyle, sources, layers };
|
|
}
|
|
return nextStyle;
|
|
},
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!center || !zoom) {
|
|
return;
|
|
}
|
|
untrack(() => map?.jumpTo({ center, zoom }));
|
|
});
|
|
|
|
$effect(() => {
|
|
const currentMap = map;
|
|
if (!currentMap) {
|
|
return;
|
|
}
|
|
|
|
if (isMobile && isTimelineOpen) {
|
|
const bottomPadding = (sheetHeight / 100) * innerHeight;
|
|
untrack(() => {
|
|
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: bottomPadding });
|
|
});
|
|
} else {
|
|
untrack(() => {
|
|
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: 0 });
|
|
});
|
|
}
|
|
});
|
|
|
|
const onAssetsDelete = () => {
|
|
handlePromiseError(
|
|
loadMapMarkers().then((markers) => {
|
|
mapMarkers = markers;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const toggleFullscreen = () => {
|
|
if (!map) {
|
|
return;
|
|
}
|
|
const container = map.getContainer().parentElement;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen().catch(() => {});
|
|
isFullscreen = false;
|
|
} else {
|
|
container.requestFullscreen().catch(() => {});
|
|
isFullscreen = true;
|
|
}
|
|
};
|
|
|
|
const handleLocate = () => {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => map?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 15 }),
|
|
(err) => handlePromiseError(Promise.reject(err)),
|
|
{ enableHighAccuracy: true },
|
|
);
|
|
};
|
|
|
|
const toggleMapProjection = () => {
|
|
isGlobeView = !isGlobeView;
|
|
};
|
|
</script>
|
|
|
|
<svelte:window bind:innerWidth />
|
|
|
|
<OnEvents {onAssetsDelete} />
|
|
|
|
<MapLibre
|
|
hash={false}
|
|
style=""
|
|
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
|
{zoom}
|
|
{center}
|
|
bounds={initialBounds}
|
|
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
|
attributionControl={false}
|
|
diffStyleUpdates={true}
|
|
projection={mapProjection}
|
|
onload={(event: Map) => {
|
|
event.setMaxZoom(18);
|
|
event.on('click', handleMapClick);
|
|
event.on('moveend', handleBoundsChange);
|
|
event.on('zoomend', handleBoundsChange);
|
|
|
|
handleBoundsChange();
|
|
|
|
event.on('mouseenter', 'geojson-clusters', () => {
|
|
event.getCanvas().style.cursor = 'pointer';
|
|
});
|
|
event.on('mouseleave', 'geojson-clusters', () => {
|
|
event.getCanvas().style.cursor = '';
|
|
});
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
isFullscreen = !!document.fullscreenElement;
|
|
});
|
|
}}
|
|
bind:map
|
|
>
|
|
{#snippet children({ map }: { map: Map })}
|
|
<div class="pointer-events-none absolute inset-0 z-10 p-4">
|
|
<div class="pointer-events-auto absolute top-4 right-4 flex flex-col gap-3">
|
|
{#if showSettings}
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('map_settings')}
|
|
onclick={handleSettingsClick}
|
|
>
|
|
<Icon icon={mdiCog} size="24" />
|
|
</button>
|
|
{/if}
|
|
|
|
{#if !simplified}
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={isGlobeView ? $t('switch_to_flat_map') : $t('switch_to_globe_map')}
|
|
aria-pressed={isGlobeView}
|
|
onclick={toggleMapProjection}
|
|
>
|
|
<Icon icon={isGlobeView ? mdiMap : mdiEarth} size="24" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={isFullscreen ? $t('exit_fullscreen') : $t('fullscreen')}
|
|
onclick={toggleFullscreen}
|
|
>
|
|
<Icon icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen} size="24" />
|
|
</button>
|
|
{/if}
|
|
|
|
{#if onOpenInMapView}
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('open_in_map_view')}
|
|
onclick={() => void onOpenInMapView()}
|
|
>
|
|
<Icon icon={mdiMap} size="24" />
|
|
</button>
|
|
{/if}
|
|
|
|
{#if onToggleTimeline}
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('timeline')}
|
|
onclick={() => onToggleTimeline?.()}
|
|
>
|
|
<Icon
|
|
icon={mdiImageMultiple}
|
|
size="24"
|
|
class={isTimelineOpen ? 'text-immich-primary dark:text-immich-primary' : ''}
|
|
/>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !simplified}
|
|
<div
|
|
class="absolute right-4 flex flex-col gap-3 transition-all duration-300 ease-out"
|
|
class:opacity-0={hideMapControls}
|
|
class:pointer-events-none={hideMapControls}
|
|
class:pointer-events-auto={!hideMapControls}
|
|
class:transition-none={isDraggingSheet}
|
|
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 1rem)` : '2.5rem'}
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('geolocate')}
|
|
onclick={handleLocate}
|
|
>
|
|
<Icon icon={mdiCrosshairsGps} size="24" />
|
|
</button>
|
|
|
|
<div
|
|
class="flex flex-col overflow-hidden rounded-2xl border border-white/20 shadow-lg backdrop-blur-md dark:border-white/10"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('zoom_in')}
|
|
onclick={() => map.zoomIn()}
|
|
>
|
|
<Icon icon={mdiPlus} size="24" />
|
|
</button>
|
|
<div class="h-px w-full bg-black/10 dark:bg-white/10"></div>
|
|
<button
|
|
type="button"
|
|
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
|
title={$t('zoom_out')}
|
|
onclick={() => map.zoomOut()}
|
|
>
|
|
<Icon icon={mdiMinus} size="24" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="absolute left-4 rounded-sm bg-white/70 px-2 py-0.5 text-[11px] font-medium text-black/80 shadow-sm backdrop-blur-md ease-out {isDraggingSheet
|
|
? 'transition-none'
|
|
: 'transition-all duration-300'} dark:bg-immich-dark-gray/70 dark:text-white/80"
|
|
class:opacity-0={hideMapControls}
|
|
class:pointer-events-none={hideMapControls}
|
|
class:pointer-events-auto={!hideMapControls}
|
|
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 0.5rem)` : '0.5rem'}
|
|
>
|
|
© OpenStreetMap
|
|
</div>
|
|
</div>
|
|
|
|
<GeoJSON
|
|
data={{
|
|
type: 'FeatureCollection',
|
|
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
|
}}
|
|
id="geojson"
|
|
cluster={{ radius: 35, maxZoom: 17 }}
|
|
>
|
|
{#if $mapShowHeatmap}
|
|
<HeatmapLayer
|
|
id="asset-heatmap-layer"
|
|
paint={{
|
|
'heatmap-color': [
|
|
'interpolate',
|
|
['linear'],
|
|
['heatmap-density'],
|
|
0,
|
|
'rgba(255, 255, 255, 0)',
|
|
0.2,
|
|
'rgb(255, 235, 59)',
|
|
0.4,
|
|
'rgb(255, 152, 0)',
|
|
0.7,
|
|
'rgb(244, 67, 54)',
|
|
1,
|
|
'rgb(183, 28, 28)',
|
|
] as ExpressionSpecification,
|
|
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 15, 2] as ExpressionSpecification,
|
|
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 20, 15, 40] as ExpressionSpecification,
|
|
'heatmap-opacity': 0.8,
|
|
}}
|
|
/>
|
|
{:else}
|
|
<MarkerLayer
|
|
applyToClusters
|
|
asButton
|
|
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
|
>
|
|
{#snippet children({ feature })}
|
|
<div
|
|
class="flex size-10 items-center justify-center rounded-full bg-immich-primary font-mono font-bold text-white opacity-90 shadow-lg transition-all duration-200 hover:bg-immich-dark-primary hover:text-immich-dark-bg"
|
|
>
|
|
{feature.properties?.point_count?.toLocaleString()}
|
|
</div>
|
|
{/snippet}
|
|
</MarkerLayer>
|
|
<MarkerLayer
|
|
applyToClusters={false}
|
|
filter={filter as never}
|
|
asButton
|
|
onclick={(event) => {
|
|
if (!popup) {
|
|
handleAssetClick(event.feature.properties?.id, map);
|
|
}
|
|
}}
|
|
>
|
|
{#snippet children({ feature }: { feature: Feature })}
|
|
{#if useLocationPin}
|
|
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
|
|
{:else}
|
|
<img
|
|
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
|
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
|
|
alt={feature.properties?.city && feature.properties.country
|
|
? $t('map_marker_for_images', {
|
|
values: { city: feature.properties.city, country: feature.properties.country },
|
|
})
|
|
: $t('map_marker_with_image')}
|
|
/>
|
|
{/if}
|
|
{#if popup}
|
|
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
|
{@render popup?.({ marker: asMarker(feature) })}
|
|
</Popup>
|
|
{/if}
|
|
{/snippet}
|
|
</MarkerLayer>
|
|
{/if}
|
|
</GeoJSON>
|
|
{/snippet}
|
|
</MapLibre>
|