瀏覽代碼

First crude implementation of the global asset map in web

Matthias Rupp 2 年之前
父節點
當前提交
7b550023a8

+ 15 - 0
web/package-lock.json

@@ -13,6 +13,7 @@
 				"handlebars": "^4.7.7",
 				"justified-layout": "^4.1.0",
 				"leaflet": "^1.9.3",
+				"leaflet.markercluster": "^1.5.3",
 				"lodash-es": "^4.17.21",
 				"luxon": "^3.2.1",
 				"rxjs": "^7.8.0",
@@ -9044,6 +9045,14 @@
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 		},
+		"node_modules/leaflet.markercluster": {
+			"version": "1.5.3",
+			"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+			"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+			"peerDependencies": {
+				"leaflet": "^1.3.1"
+			}
+		},
 		"node_modules/leven": {
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -18045,6 +18054,12 @@
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 		},
+		"leaflet.markercluster": {
+			"version": "1.5.3",
+			"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+			"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+			"requires": {}
+		},
 		"leven": {
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",

+ 1 - 0
web/package.json

@@ -61,6 +61,7 @@
 		"handlebars": "^4.7.7",
 		"justified-layout": "^4.1.0",
 		"leaflet": "^1.9.3",
+		"leaflet.markercluster": "^1.5.3",
 		"lodash-es": "^4.17.21",
 		"luxon": "^3.2.1",
 		"rxjs": "^7.8.0",

+ 29 - 0
web/src/app.css

@@ -93,3 +93,32 @@ input:focus-visible {
 		scrollbar-width: none;
 	}
 }
+
+/* Leaflet */
+
+.leaflet-marker-icon {
+	border-radius: 25%;
+}
+
+.marker-cluster {
+	background-clip: padding-box;
+	border-radius: 20px;
+}
+
+.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);
+}
+
+.marker-cluster span {
+	line-height: 40px;
+}

+ 53 - 0
web/src/lib/components/shared-components/leaflet/asset-cluster-marker.svelte

@@ -0,0 +1,53 @@
+<script lang="ts">
+	import { onDestroy, onMount } from 'svelte';
+	import { Marker, Icon, type LatLngExpression, type Content } from 'leaflet';
+	import { getClusterContext } from './marker-cluster.svelte';
+	import { AssetResponseDto } from '@api';
+
+	export let asset: AssetResponseDto;
+	let marker: Marker;
+
+	const defaultIcon = new Icon({
+		iconUrl: `/api/asset/thumbnail/${asset.id}?format=WEBP`,
+		iconRetinaUrl: `/api/asset/thumbnail/${asset.id}?format=WEBP`,
+
+		// Default values from Leaflet
+		iconSize: [40, 40],
+		iconAnchor: [12, 41],
+		popupAnchor: [1, -34],
+		tooltipAnchor: [16, -28],
+		shadowSize: [41, 41]
+	});
+	const cluster = getClusterContext();
+
+	onMount(() => {
+		if (asset.exifInfo) {
+			const lat = asset.exifInfo.latitude;
+			const lon = asset.exifInfo.longitude;
+
+			if (lat && lon) {
+				marker = new Marker([lat, lon], {
+					icon: defaultIcon,
+					alt: ''
+				});
+
+				cluster.addLayer(marker);
+			}
+		}
+	});
+
+	onDestroy(() => {
+		if (marker) marker.remove();
+	});
+
+	$: if (marker) {
+		if (asset.exifInfo) {
+			const lat = asset.exifInfo.latitude;
+			const lon = asset.exifInfo.longitude;
+
+			if (lat && lon) {
+				marker.setLatLng([lat, lon]);
+			}
+		}
+	}
+</script>

+ 2 - 0
web/src/lib/components/shared-components/leaflet/index.ts

@@ -1,3 +1,5 @@
 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 MarkerCluster } from './marker-cluster.svelte';
+export { default as AssetClusterMarker } from './asset-cluster-marker.svelte';

+ 2 - 0
web/src/lib/components/shared-components/leaflet/map.svelte

@@ -40,3 +40,5 @@
 		<slot />
 	{/if}
 </div>
+
+

+ 40 - 0
web/src/lib/components/shared-components/leaflet/marker-cluster.svelte

@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+	import { createContext } from '$lib/utils/context';
+
+	const { get: getContext, set: setClusterContext } = createContext<() => L.MarkerClusterGroup>();
+
+	export const getClusterContext = () => {
+		return getContext()();
+	};
+</script>
+
+<script lang="ts">
+	import { onDestroy, onMount } from 'svelte';
+	import 'leaflet.markercluster';
+	import { getMapContext } from './map.svelte';
+
+	const map = getMapContext();
+	let cluster: L.MarkerClusterGroup;
+
+	setClusterContext(() => cluster);
+
+	onMount(() => {
+		cluster = new L.MarkerClusterGroup({
+			showCoverageOnHover: false,
+			zoomToBoundsOnClick: true,
+			spiderfyOnMaxZoom: true,
+			maxClusterRadius: 30,
+			spiderLegPolylineOptions: { opacity: 0 }
+		});
+		map.addLayer(cluster);
+	});
+
+	onDestroy(() => {
+		if (cluster) cluster.remove();
+	});
+</script>
+
+{#if cluster}
+	<slot />
+{/if}
+

+ 8 - 0
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -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,13 @@
 			isSelected={$page.route.id === '/(user)/explore'}
 		/>
 	</a>
+	<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
+		<SideBarButton
+			title="Map"
+			logo={Map}
+			flippedLogo={true}
+			isSelected={$page.route.id === '/(user)/map'} />
+	</a>
 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 		<SideBarButton
 			title="Sharing"

+ 2 - 1
web/src/lib/constants.ts

@@ -14,7 +14,8 @@ export enum AppRoute {
 	EXPLORE = '/explore',
 	SHARING = '/sharing',
 	SEARCH = '/search',
-
+	MAP = '/map',
+	
 	AUTH_LOGIN = '/auth/login',
 	AUTH_LOGOUT = '/auth/logout',
 	AUTH_REGISTER = '/auth/register',

+ 23 - 0
web/src/routes/(user)/map/+page.server.ts

@@ -0,0 +1,23 @@
+import { AppRoute } from '$lib/constants';
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load = (async ({ locals: { api, user } }) => {
+	if (!user) {
+		throw redirect(302, AppRoute.AUTH_LOGIN);
+	}
+
+	try {
+		const { data: assets } = await api.assetApi.getAllAssets();
+
+		return {
+			user,
+			assets,
+			meta: {
+				title: 'Map'
+			}
+		};
+	} catch (e) {
+		throw redirect(302, AppRoute.AUTH_LOGIN);
+	}
+}) satisfies PageServerLoad;

+ 33 - 0
web/src/routes/(user)/map/+page.svelte

@@ -0,0 +1,33 @@
+<script lang="ts">
+	import type { PageData } from '../map/$types';
+	import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
+	import { Map, TileLayer, MarkerCluster, AssetClusterMarker } from '$lib/components/shared-components/leaflet';
+
+	export let data: PageData;
+</script>
+
+<UserPageLayout user={data.user} title={data.meta.title}>
+	<div slot="buttons">
+
+	</div>
+
+	<div class="h-full w-full">
+		<Map latlng={[48, 11]} zoom={6}>
+			<TileLayer
+				urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
+				options={{
+					attribution:
+						'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+				}}
+			/>
+			<MarkerCluster>
+				{#each data.assets as asset}
+					<AssetClusterMarker asset={asset} />
+				{/each}
+			</MarkerCluster>
+		</Map>
+	</div>
+
+
+</UserPageLayout>
+