diff --git a/apps/frontend/src/assets/map-poster-dark.avif b/apps/frontend/src/assets/map-poster-dark.avif new file mode 100644 index 0000000..2c0f5d4 Binary files /dev/null and b/apps/frontend/src/assets/map-poster-dark.avif differ diff --git a/apps/frontend/src/assets/map-poster-light.avif b/apps/frontend/src/assets/map-poster-light.avif new file mode 100644 index 0000000..8436307 Binary files /dev/null and b/apps/frontend/src/assets/map-poster-light.avif differ diff --git a/apps/frontend/src/assets/map-poster.avif b/apps/frontend/src/assets/map-poster.avif deleted file mode 100644 index 42f9cb6..0000000 Binary files a/apps/frontend/src/assets/map-poster.avif and /dev/null differ diff --git a/apps/frontend/src/components/ClientMap.tsx b/apps/frontend/src/components/ClientMap.tsx index d041a8a..a2e0309 100644 --- a/apps/frontend/src/components/ClientMap.tsx +++ b/apps/frontend/src/components/ClientMap.tsx @@ -1,7 +1,9 @@ import { lazy, Suspense, useEffect, useState } from 'react' import type { ComponentProps } from 'react' -import mapPoster from '#/assets/map-poster.avif' +import mapPosterDark from '#/assets/map-poster-dark.avif' +import mapPosterLight from '#/assets/map-poster-light.avif' +import { MAP_ATTRIBUTION } from '#/lib/map-styles' import type MapComponent from './Map' // Leaflet touches `window` while its module is evaluated, so it may only be @@ -16,30 +18,37 @@ const LazyMap = mapModulePromise ? lazy(() => mapModulePromise) : null type MapProps = ComponentProps /** - * A picture of the map's default view (Trondheim, zoom 15) rendered on the - * server. The map is the largest thing on the first screen, so pulling that - * paint out of the JavaScript bundle lets the browser show it immediately - * instead of waiting for Leaflet and a handful of map tiles. + * Pictures of the map's default view (Trondheim, zoom 3) rendered on the + * server, one per theme. The map is the largest thing on the first screen, so + * pulling that paint out of the JavaScript bundle lets the browser show it + * immediately instead of waiting for Leaflet and a handful of map tiles. * - * Regenerate it by screenshotting `#map` at a 1112px viewport width (the widest - * the container gets) with no location available, then `avifenc -q 40 -s 6`. + * Regenerate them by screenshotting `#map` at a 1280px viewport width with the + * mock location (968x400), then encode each to AVIF at quality ~35. */ function MapPoster({ ready }: { ready: boolean }) { return ( <> + {/* The live map brings its own attribution control. */} ) diff --git a/apps/frontend/src/components/Map.tsx b/apps/frontend/src/components/Map.tsx index 453e044..a23244c 100644 --- a/apps/frontend/src/components/Map.tsx +++ b/apps/frontend/src/components/Map.tsx @@ -1,13 +1,7 @@ 'use client' import { useEffect } from 'react' -import { - CircleMarker, - MapContainer, - Marker, - Tooltip, - useMap, -} from 'react-leaflet' +import { MapContainer, Marker, Tooltip, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' import 'leaflet/dist/leaflet.css' @@ -16,24 +10,37 @@ import { useNavigate } from '@tanstack/react-router' import { formatPrice } from '../lib/format-price' import harpIconUrl from '#/assets/harp.svg?url' import harpShadowUrl from '#/assets/harp_shadow.svg?url' -import { MOCK_LOCATION_NAME } from '../lib/location' +import { MOCK_LOCATION, MOCK_LOCATION_NAME } from '../lib/location' import type { Coordinates } from '../lib/location' import { MAP_STYLE_URLS } from '../lib/map-styles' import { useThemeMode } from '../lib/use-theme-mode' import type { SearchResult } from '../types/search' import { VectorTileLayer } from './VectorTileLayer' -const DEFAULT_CENTER: [number, number] = [63.4305, 10.3951] -const DEFAULT_ZOOM = 15 +// Both SSR posters are centred on the mock location at this zoom, so the live +// map starts on exactly the same frame and then flies to the user. +const POSTER_CENTER: [number, number] = [MOCK_LOCATION.lat, MOCK_LOCATION.lon] +const POSTER_ZOOM = 3 +const USER_ZOOM = 13 -function FlyToResult({ coordinates }: { coordinates: number[] | undefined }) { +function FlyToUser({ location }: { location?: Coordinates | null }) { const map = useMap() + const lat = location?.lat + const lon = location?.lon useEffect(() => { - if (coordinates && coordinates.length == 2) { - map.flyTo([coordinates[0], coordinates[1]], 12) - } - }, [coordinates, map]) + if (lat == null || lon == null) return + + // Let the poster's first frame paint, then fly in from its view. + const timeout = window.setTimeout(() => { + map.flyTo([lat, lon], USER_ZOOM, { + duration: 1.6, + easeLinearity: 0.25, + }) + }, 300) + + return () => window.clearTimeout(timeout) + }, [lat, lon, map]) return null } @@ -48,6 +55,97 @@ const harpIcon = new L.Icon({ popupAnchor: [0, -25], }) +const userLocationIcon = L.divIcon({ + className: 'gspotter-user-location', + html: '', + iconSize: [20, 20], + iconAnchor: [10, 10], +}) + +function UserLocationMarker({ + location, + isMockLocation, +}: { + location: Coordinates + isMockLocation: boolean +}) { + const map = useMap() + const label = isMockLocation + ? `Mock location: ${MOCK_LOCATION_NAME}` + : 'Your location' + + useEffect(() => { + const container = map.getContainer() + const setDotScale = (scale: number) => { + container.style.setProperty('--gspotter-dot-scale', String(scale)) + } + + let frame = 0 + let animating = false + + // Leaflet animates a marker by scaling its icon element with a CSS + // transition. Read that live scale each frame and apply its inverse to the + // dot so it keeps the same on-screen size throughout the zoom. + const readIconScale = () => { + const icon = container.querySelector( + '.gspotter-user-location', + ) + if (!icon) return 1 + + const transform = window.getComputedStyle(icon).transform + const values = transform.match(/matrix(?:3d)?\(([^)]+)\)/)?.[1] + if (!values) return 1 + + const [a = 1, b = 0] = values.split(',').map(Number) + const scale = Math.hypot(a, b) + return scale > 0 ? scale : 1 + } + + const tick = () => { + setDotScale(1 / readIconScale()) + frame = window.requestAnimationFrame(tick) + } + + const start = () => { + if (animating) return + animating = true + frame = window.requestAnimationFrame(tick) + } + + const stop = () => { + if (!animating) return + animating = false + window.cancelAnimationFrame(frame) + setDotScale(1) + } + + map.on('zoomstart', start) + map.on('zoomanim', start) + map.on('zoomend', stop) + + return () => { + map.off('zoomstart', start) + map.off('zoomanim', start) + map.off('zoomend', stop) + window.cancelAnimationFrame(frame) + setDotScale(1) + } + }, [map]) + + return ( + + + {label} + + + ) +} + /** * Overlapping pins are merged into a single, larger target. This keeps the map * usable on touch screens: without clustering the pins on top of each other @@ -69,25 +167,19 @@ function createClusterIcon(cluster: L.MarkerCluster) { export interface MapProps { nearby?: SearchResult[] userLocation?: Coordinates | null - flyTo?: number[] isMockLocation?: boolean } export default function Map({ nearby = [], userLocation, - flyTo, isMockLocation = false, }: MapProps) { const navigate = useNavigate() const themeMode = useThemeMode() - // Start at the user's position when we already know it. Otherwise Leaflet - // first downloads tiles for the default view and immediately replaces them - // after flying to the user, wasting both time and bandwidth. - const initialCenter: [number, number] = userLocation - ? [userLocation.lat, userLocation.lon] - : DEFAULT_CENTER - const initialZoom = userLocation ? 12 : DEFAULT_ZOOM + // Always start on the poster's view, then fly to the user once it is known. + const initialCenter: [number, number] = POSTER_CENTER + const initialZoom = POSTER_ZOOM const markers = nearby.filter( (venue, index, all) => all.findIndex( @@ -108,7 +200,7 @@ export default function Map({ className="h-100 w-full rounded-2xl" > - + {userLocation && ( - - - {isMockLocation - ? `Mock location: ${MOCK_LOCATION_NAME}` - : 'Your location'} - - + )} diff --git a/apps/frontend/src/components/MockLocationNotice.tsx b/apps/frontend/src/components/MockLocationNotice.tsx index 83b6f84..11d05f1 100644 --- a/apps/frontend/src/components/MockLocationNotice.tsx +++ b/apps/frontend/src/components/MockLocationNotice.tsx @@ -13,7 +13,6 @@ const REPOSITORY_URL = 'https://git.ntnu.no/IT2810-H26/T09-Project-2' export default function MockLocationNotice() { return (