Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/frontend/src/assets/map-poster-dark.avif
Binary file not shown.
Binary file added apps/frontend/src/assets/map-poster-light.avif
Binary file not shown.
Binary file removed apps/frontend/src/assets/map-poster.avif
Binary file not shown.
29 changes: 19 additions & 10 deletions apps/frontend/src/components/ClientMap.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,30 +18,37 @@ const LazyMap = mapModulePromise ? lazy(() => mapModulePromise) : null
type MapProps = ComponentProps<typeof MapComponent>

/**
* 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 (
<>
<img
src={mapPoster}
src={mapPosterLight}
alt=""
fetchPriority="high"
decoding="async"
className="h-full w-full object-cover"
className="map-poster-light h-full w-full object-cover"
/>
<img
src={mapPosterDark}
alt=""
fetchPriority="high"
decoding="async"
className="map-poster-dark h-full w-full object-cover"
/>
{/* The live map brings its own attribution control. */}
<span
hidden={ready}
className="absolute right-0 bottom-0 bg-white/80 px-1 text-[10px] leading-4 text-[#333]"
>
© OpenStreetMap contributors
{MAP_ATTRIBUTION}
</span>
</>
)
Expand Down
161 changes: 121 additions & 40 deletions apps/frontend/src/components/Map.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
}
Expand All @@ -48,6 +55,97 @@ const harpIcon = new L.Icon({
popupAnchor: [0, -25],
})

const userLocationIcon = L.divIcon({
className: 'gspotter-user-location',
html: '<span class="gspotter-user-location__dot"></span>',
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<HTMLElement>(
'.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 (
<Marker
position={[location.lat, location.lon]}
icon={userLocationIcon}
title={label}
alt={label}
>
<Tooltip direction="top" offset={[0, -14]}>
{label}
</Tooltip>
</Marker>
)
}

/**
* 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
Expand All @@ -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(
Expand All @@ -108,7 +200,7 @@ export default function Map({
className="h-100 w-full rounded-2xl"
>
<VectorTileLayer styleUrl={MAP_STYLE_URLS[themeMode]} />
<FlyToResult coordinates={flyTo} />
<FlyToUser location={userLocation} />
<MarkerClusterGroup
chunkedLoading
maxClusterRadius={60}
Expand Down Expand Up @@ -146,21 +238,10 @@ export default function Map({
))}
</MarkerClusterGroup>
{userLocation && (
<CircleMarker
center={[userLocation.lat, userLocation.lon]}
radius={8}
pathOptions={{
color: '#d3ad57',
fillColor: '#e6c77f',
fillOpacity: 0.85,
}}
>
<Tooltip direction="top" offset={[0, -12]}>
{isMockLocation
? `Mock location: ${MOCK_LOCATION_NAME}`
: 'Your location'}
</Tooltip>
</CircleMarker>
<UserLocationMarker
location={userLocation}
isMockLocation={isMockLocation}
/>
)}
</MapContainer>
</section>
Expand Down
1 change: 0 additions & 1 deletion apps/frontend/src/components/MockLocationNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const REPOSITORY_URL = 'https://git.ntnu.no/IT2810-H26/T09-Project-2'
export default function MockLocationNotice() {
return (
<aside className="demo-alert mock-location-notice rise-in mb-6">
<p className="island-kicker">Mock position</p>
<h2 className="mt-1 text-base font-extrabold">
We are showing a mock position instead of your real one
</h2>
Expand Down
Loading