mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(web): Global map showing all assets with geo information (#2355)
* First crude implementation of the global asset map in web * Use single DOM element for all markers * Minor layout changes * Refactor * Add asset viewer * Add API endpoint that returns only assets with location information (Thanks @EPP100) * Remove sidebar icon flip * Add dark theme support * Center map to most recent asset * Allow cluster viewing * Fix linter errors * Add newlines * Fix ts errors * Fix eslint error * Run prettier * Server code style * Fix openapi mobile code generation issues * Map markers test * fix: Support video thumbnails * Update API * Review suggestions * Review suggestions * Linter error * Chage mapMarker endpoint to map-marker * Clean up leaflet imports
This commit is contained in:
		| @@ -296,7 +296,7 @@ | ||||
|  | ||||
| <section | ||||
| 	id="immich-asset-viewer" | ||||
| 	class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4" | ||||
| 	class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4" | ||||
| > | ||||
| 	<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform"> | ||||
| 		<AssetViewerNavBar | ||||
|   | ||||
| @@ -0,0 +1,119 @@ | ||||
| <script lang="ts" context="module"> | ||||
| 	import { createContext } from '$lib/utils/context'; | ||||
| 	import { MarkerClusterGroup, Marker, Icon, LeafletEvent } from 'leaflet'; | ||||
|  | ||||
| 	const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>(); | ||||
|  | ||||
| 	export const getClusterContext = () => { | ||||
| 		return getContext()(); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import 'leaflet.markercluster'; | ||||
| 	import { onDestroy, onMount } from 'svelte'; | ||||
| 	import { getMapContext } from './map.svelte'; | ||||
| 	import { MapMarkerResponseDto, api } from '@api'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| 	class AssetMarker extends Marker { | ||||
| 		marker: MapMarkerResponseDto; | ||||
|  | ||||
| 		constructor(marker: MapMarkerResponseDto) { | ||||
| 			super([marker.lat, marker.lon], { | ||||
| 				icon: new Icon({ | ||||
| 					iconUrl: api.getAssetThumbnailUrl(marker.id), | ||||
| 					iconRetinaUrl: api.getAssetThumbnailUrl(marker.id), | ||||
| 					iconSize: [60, 60], | ||||
| 					iconAnchor: [12, 41], | ||||
| 					popupAnchor: [1, -34], | ||||
| 					tooltipAnchor: [16, -28], | ||||
| 					shadowSize: [41, 41] | ||||
| 				}) | ||||
| 			}); | ||||
|  | ||||
| 			this.marker = marker; | ||||
| 		} | ||||
|  | ||||
| 		getAssetId(): string { | ||||
| 			return this.marker.id; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const dispatch = createEventDispatcher<{ view: { assets: string[] } }>(); | ||||
|  | ||||
| 	export let markers: MapMarkerResponseDto[]; | ||||
|  | ||||
| 	const map = getMapContext(); | ||||
|  | ||||
| 	let cluster: MarkerClusterGroup; | ||||
|  | ||||
| 	setClusterContext(() => cluster); | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		cluster = new MarkerClusterGroup({ | ||||
| 			showCoverageOnHover: false, | ||||
| 			zoomToBoundsOnClick: false, | ||||
| 			spiderfyOnMaxZoom: false, | ||||
| 			maxClusterRadius: 30, | ||||
| 			spiderLegPolylineOptions: { opacity: 0 }, | ||||
| 			spiderfyDistanceMultiplier: 3 | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('clusterclick', (event: LeafletEvent) => { | ||||
| 			const ids = event.sourceTarget | ||||
| 				.getAllChildMarkers() | ||||
| 				.map((marker: AssetMarker) => marker.getAssetId()); | ||||
| 			dispatch('view', { assets: ids }); | ||||
| 		}); | ||||
|  | ||||
| 		for (let marker of markers) { | ||||
| 			const leafletMarker = new AssetMarker(marker); | ||||
|  | ||||
| 			leafletMarker.on('click', () => { | ||||
| 				dispatch('view', { assets: [marker.id] }); | ||||
| 			}); | ||||
|  | ||||
| 			cluster.addLayer(leafletMarker); | ||||
| 		} | ||||
|  | ||||
| 		map.addLayer(cluster); | ||||
| 	}); | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		if (cluster) cluster.remove(); | ||||
| 	}); | ||||
| </script> | ||||
|  | ||||
| {#if cluster} | ||||
| 	<slot /> | ||||
| {/if} | ||||
|  | ||||
| <style> | ||||
| 	:global(.leaflet-marker-icon) { | ||||
| 		border-radius: 25%; | ||||
| 	} | ||||
|  | ||||
| 	:global(.marker-cluster) { | ||||
| 		background-clip: padding-box; | ||||
| 		border-radius: 20px; | ||||
| 	} | ||||
|  | ||||
| 	:global(.marker-cluster div) { | ||||
| 		width: 40px; | ||||
| 		height: 40px; | ||||
| 		margin-left: 5px; | ||||
| 		margin-top: 5px; | ||||
|  | ||||
| 		text-align: center; | ||||
| 		border-radius: 20px; | ||||
| 		font-weight: bold; | ||||
|  | ||||
| 		background-color: rgb(236, 237, 246); | ||||
| 		color: rgb(69, 80, 169); | ||||
| 	} | ||||
|  | ||||
| 	:global(.marker-cluster span) { | ||||
| 		line-height: 40px; | ||||
| 	} | ||||
| </style> | ||||
| @@ -1,3 +1,4 @@ | ||||
| export { default as Map } from './map.svelte'; | ||||
| export { default as Marker } from './marker.svelte'; | ||||
| export { default as TileLayer } from './tile-layer.svelte'; | ||||
| export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte'; | ||||
|   | ||||
| @@ -5,15 +5,30 @@ | ||||
|  | ||||
| 	export let urlTemplate: string; | ||||
| 	export let options: TileLayerOptions | undefined = undefined; | ||||
| 	export let allowDarkMode = false; | ||||
|  | ||||
| 	let tileLayer: TileLayer; | ||||
|  | ||||
| 	const map = getMapContext(); | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		tileLayer = new TileLayer(urlTemplate, options).addTo(map); | ||||
| 		tileLayer = new TileLayer(urlTemplate, { | ||||
| 			className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer', | ||||
| 			...options | ||||
| 		}).addTo(map); | ||||
| 	}); | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		if (tileLayer) tileLayer.remove(); | ||||
| 	}); | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| 	:global(.leaflet-layer-dynamic) { | ||||
| 		filter: brightness(100%) contrast(100%) saturate(80%); | ||||
| 	} | ||||
|  | ||||
| 	:global(.dark .leaflet-layer-dynamic) { | ||||
| 		filter: invert(100%) brightness(130%) saturate(0%); | ||||
| 	} | ||||
| </style> | ||||
|   | ||||
| @@ -6,6 +6,7 @@ | ||||
| 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; | ||||
| 	import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte'; | ||||
| 	import Magnify from 'svelte-material-icons/Magnify.svelte'; | ||||
| 	import Map from 'svelte-material-icons/Map.svelte'; | ||||
| 	import StarOutline from 'svelte-material-icons/StarOutline.svelte'; | ||||
| 	import { AppRoute } from '../../../constants'; | ||||
| 	import LoadingSpinner from '../loading-spinner.svelte'; | ||||
| @@ -108,6 +109,9 @@ | ||||
| 			isSelected={$page.route.id === '/(user)/explore'} | ||||
| 		/> | ||||
| 	</a> | ||||
| 	<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false"> | ||||
| 		<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} /> | ||||
| 	</a> | ||||
| 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false"> | ||||
| 		<SideBarButton | ||||
| 			title="Sharing" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user