diff --git a/client/package-lock.json b/client/package-lock.json
index d34d68b..428b386 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -8,13 +8,16 @@
"name": "tapfinder-client",
"version": "0.0.0",
"dependencies": {
+ "leaflet": "^1.9.4",
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
+ "@types/leaflet": "^1.9.22",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
@@ -636,6 +639,17 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
@@ -1030,6 +1044,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/leaflet": {
+ "version": "1.9.22",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
+ "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "24.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
@@ -1421,6 +1452,12 @@
}
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1945,6 +1982,20 @@
"license": "MIT",
"peer": true
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/client/package.json b/client/package.json
index fed851d..29565f6 100644
--- a/client/package.json
+++ b/client/package.json
@@ -14,13 +14,16 @@
"test:watch": "vitest"
},
"dependencies": {
+ "leaflet": "^1.9.4",
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
+ "@types/leaflet": "^1.9.22",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
diff --git a/client/src/components/map/LeafletMap.test.tsx b/client/src/components/map/LeafletMap.test.tsx
new file mode 100644
index 0000000..59518bc
--- /dev/null
+++ b/client/src/components/map/LeafletMap.test.tsx
@@ -0,0 +1,91 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { makePlace } from '../../test/places'
+import LeafletMap from './LeafletMap'
+import { MapViewport } from './MapViewport'
+
+const places = [
+ makePlace(),
+ makePlace({
+ id: 'torget-vinbar',
+ name: 'Torget Vinbar',
+ latitude: 63.431,
+ longitude: 10.395,
+ }),
+]
+
+const props = {
+ places,
+ selectedId: null,
+ hoveredId: null,
+ inset: { left: 0, bottom: 0 },
+ onSelect: vi.fn(),
+}
+
+describe('LeafletMap', () => {
+ it('shows a pin for every place, with the name as its label', () => {
+ render()
+
+ expect(
+ screen.getByRole('button', { name: 'Nidelven Pub' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Torget Vinbar' }),
+ ).toBeInTheDocument()
+ })
+
+ it('selects a place when its pin is clicked', () => {
+ const onSelect = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Torget Vinbar' }))
+
+ expect(onSelect).toHaveBeenCalledWith('torget-vinbar')
+ })
+
+ it('has zoom buttons and a described map for keyboard and screen reader users', () => {
+ render()
+
+ expect(screen.getByRole('button', { name: 'Zoom inn' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Zoom ut' })).toBeInTheDocument()
+ expect(
+ screen.getByRole('group', { name: /Kart over utesteder/ }),
+ ).toBeInTheDocument()
+ })
+
+ it('numbers the pins of a route and draws a line between the stops', () => {
+ const { container } = render(
+ [id, index + 1]))}
+ fitPlaces={places}
+ />,
+ )
+
+ expect(
+ screen.getByRole('button', { name: /^2\.\s?Torget Vinbar$/ }),
+ ).toBeInTheDocument()
+ expect(container.querySelector('.map-route-line')).toBeInTheDocument()
+ })
+
+ it('credits OpenStreetMap', () => {
+ render()
+
+ expect(screen.getByRole('link', { name: 'OpenStreetMap' })).toHaveAttribute(
+ 'href',
+ 'https://www.openstreetmap.org/copyright',
+ )
+ })
+})
+
+describe('MapViewport', () => {
+ it('shows a loading message until the map library has loaded', async () => {
+ render()
+
+ expect(screen.getByText('Laster kart …')).toBeInTheDocument()
+ expect(
+ await screen.findByRole('button', { name: 'Zoom inn' }),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/client/src/components/map/LeafletMap.tsx b/client/src/components/map/LeafletMap.tsx
new file mode 100644
index 0000000..06e9515
--- /dev/null
+++ b/client/src/components/map/LeafletMap.tsx
@@ -0,0 +1,241 @@
+import 'leaflet/dist/leaflet.css'
+import { latLng, latLngBounds } from 'leaflet'
+import type { LatLngTuple, Map as LeafletMapInstance } from 'leaflet'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import {
+ AttributionControl,
+ MapContainer,
+ Marker,
+ Polyline,
+ TileLayer,
+ useMap,
+} from 'react-leaflet'
+import type { Place } from '../../types/place'
+import { PIN_HEIGHT, createPinIcon } from './mapPins'
+import type { Inset, MapViewportProps } from './MapViewport'
+
+/** Midt i Trondheim, med så mye zoom at byen fyller kartet. */
+const TRONDHEIM: LatLngTuple = [63.431, 10.395]
+const DEFAULT_ZOOM = 13
+const MIN_ZOOM = 10
+/** Så langt zoomes det inn når et sted velges, hvis man ikke allerede er nærmere. */
+const SELECT_ZOOM = 16
+const FIT_MAX_ZOOM = 16
+/** Luft mellom stedene og kanten av det synlige området. */
+const PADDING = 64
+
+// Fliser fra OpenStreetMap (ingen nøkkel nødvendig). Må krediteres.
+const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
+const ATTRIBUTION =
+ '© OpenStreetMap-bidragsytere'
+
+const prefersReducedMotion = () =>
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
+
+type PlaceMarkerProps = {
+ place: Place
+ order?: number
+ isSelected: boolean
+ isHovered: boolean
+ onSelect: (id: string) => void
+}
+
+/** Ett merke på kartet. Ikonet lages på nytt bare når det endrer utseende. */
+function PlaceMarker({
+ place,
+ order,
+ isSelected,
+ isHovered,
+ onSelect,
+}: PlaceMarkerProps) {
+ const icon = useMemo(
+ () => createPinIcon({ place, order, isSelected, isHovered }),
+ [place, order, isSelected, isHovered],
+ )
+ const eventHandlers = useMemo(
+ () => ({ click: () => onSelect(place.id) }),
+ [onSelect, place.id],
+ )
+
+ return (
+
+ )
+}
+
+type MapControllerProps = {
+ places: Place[]
+ selectedId: string | null
+ fitPlaces?: Place[] | null
+ inset: Inset
+ onReady: (map: LeafletMapInstance) => void
+}
+
+/** Flytter kartet når et sted velges eller en rute vises. Tegner ingenting. */
+function MapController({
+ places,
+ selectedId,
+ fitPlaces,
+ inset,
+ onReady,
+}: MapControllerProps) {
+ const map = useMap()
+
+ // Siste verdier, så kartet bare flytter seg når stedet eller ruten byttes,
+ // ikke når listen eller størrelsen på ruten endres.
+ const latest = useRef({ places, inset })
+ useEffect(() => {
+ latest.current = { places, inset }
+ })
+
+ useEffect(() => {
+ onReady(map)
+ map.getContainer().setAttribute('role', 'group')
+ map
+ .getContainer()
+ .setAttribute(
+ 'aria-label',
+ 'Kart over utesteder. Dra for å flytte kartet. Bruk piltastene for å flytte og pluss og minus for å zoome.',
+ )
+ }, [map, onReady])
+
+ // Sentrerer på det valgte stedet, midt i området ruten ikke dekker.
+ useEffect(() => {
+ const place = latest.current.places.find(({ id }) => id === selectedId)
+ if (!place) return
+
+ const { left, bottom } = latest.current.inset
+ map.flyToBounds(latLngBounds([latLng(place.latitude, place.longitude)]), {
+ // Merkets spiss peker på stedet, så det trenger plass over seg.
+ paddingTopLeft: [left + PADDING, PIN_HEIGHT + PADDING],
+ paddingBottomRight: [PADDING, bottom + PADDING],
+ maxZoom: Math.max(map.getZoom(), SELECT_ZOOM),
+ animate: !prefersReducedMotion(),
+ })
+ }, [map, selectedId])
+
+ // Zoomer så hele ruten synes, midt i området ruten ikke dekker.
+ useEffect(() => {
+ if (!fitPlaces || fitPlaces.length === 0) return
+
+ const { left, bottom } = latest.current.inset
+ map.flyToBounds(
+ latLngBounds(
+ fitPlaces.map(({ latitude, longitude }) => latLng(latitude, longitude)),
+ ),
+ {
+ paddingTopLeft: [left + PADDING, PIN_HEIGHT + PADDING],
+ paddingBottomRight: [PADDING, bottom + PADDING],
+ maxZoom: FIT_MAX_ZOOM,
+ animate: !prefersReducedMotion(),
+ },
+ )
+ }, [map, fitPlaces])
+
+ return null
+}
+
+export default function LeafletMap({
+ places,
+ selectedId,
+ hoveredId,
+ inset,
+ routeStops,
+ stopOrder,
+ fitPlaces,
+ onSelect,
+}: MapViewportProps) {
+ const [map, setMap] = useState(null)
+
+ const routeLine = useMemo(
+ () =>
+ routeStops && routeStops.length > 1
+ ? routeStops.map(({ latitude, longitude }) => [latitude, longitude])
+ : null,
+ [routeStops],
+ )
+
+ return (
+
+
+ {/* Oppe i hjørnet, så kreditteringen ikke havner bak panelet på mobil. */}
+
+
+
+ {routeLine && (
+ <>
+
+
+ >
+ )}
+
+ {places.map((place) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/map/MapViewport.tsx b/client/src/components/map/MapViewport.tsx
index 2baf059..7dec240 100644
--- a/client/src/components/map/MapViewport.tsx
+++ b/client/src/components/map/MapViewport.tsx
@@ -1,17 +1,10 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import type { KeyboardEvent, PointerEvent } from 'react'
+import { lazy, Suspense } from 'react'
import type { Place } from '../../types/place'
-import { MAP_HEIGHT, MAP_WIDTH, toMapPoint } from '../../utils/mapProjection'
-import { PlacePin } from './PlacePin'
-import { StylizedMap } from './StylizedMap'
-/** Kartets plassering på skjermen: forskyvning i piksler og zoomnivå. */
-type View = { x: number; y: number; k: number }
-type Size = { width: number; height: number }
/** Plass som er dekket av ruten, så kartet ikke skjules bak den. */
export type Inset = { left: number; bottom: number }
-type MapViewportProps = {
+export type MapViewportProps = {
places: Place[]
selectedId: string | null
hoveredId: string | null
@@ -25,457 +18,22 @@ type MapViewportProps = {
onSelect: (id: string) => void
}
-const MAX_ZOOM_FACTOR = 8
-const BUTTON_ZOOM = 1.5
-const DRAG_THRESHOLD = 4
-const KEY_PAN = 80
-const FLY_MS = 400
-const FIT_PADDING = 64
-
-/** Minste zoom: kartet skal alltid fylle det synlige området. */
-const minScale = (size: Size, inset: Inset) =>
- Math.max((size.width - inset.left) / MAP_WIDTH, size.height / MAP_HEIGHT)
-
-/** Holder kartet innenfor kantene og zoomen innenfor grensene. */
-function clampView(view: View, size: Size, inset: Inset): View {
- const min = minScale(size, inset)
- const k = Math.min(Math.max(view.k, min), min * MAX_ZOOM_FACTOR)
-
- return {
- k,
- x: Math.min(inset.left, Math.max(size.width - MAP_WIDTH * k, view.x)),
- y: Math.min(0, Math.max(size.height - MAP_HEIGHT * k, view.y)),
- }
-}
-
-/** Zoomer med punktet (px, py) som fast punkt. */
-function zoomView(
- view: View,
- factor: number,
- px: number,
- py: number,
- size: Size,
- inset: Inset,
-): View {
- const min = minScale(size, inset)
- const k = Math.min(Math.max(view.k * factor, min), min * MAX_ZOOM_FACTOR)
- const ratio = k / view.k
-
- return clampView(
- { k, x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio },
- size,
- inset,
- )
-}
-
-const prefersReducedMotion = () =>
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
-
-/** Kartflaten med dra, zoom og merker for hvert sted. */
-export function MapViewport({
- places,
- selectedId,
- hoveredId,
- inset,
- routeStops,
- stopOrder,
- fitPlaces,
- onSelect,
-}: MapViewportProps) {
- const sectionRef = useRef(null)
- const [size, setSize] = useState(null)
- const [view, setView] = useState(null)
-
- const viewRef = useRef(null)
- const animationRef = useRef(0)
- const pointersRef = useRef(new Map())
- const gestureRef = useRef({
- originX: 0,
- originY: 0,
- isDragging: false,
- pinchDistance: 0,
- })
-
- useEffect(() => {
- viewRef.current = view
- }, [view])
-
- useEffect(() => {
- const section = sectionRef.current
- if (!section) return
-
- const update = () =>
- setSize({ width: section.clientWidth, height: section.clientHeight })
- update()
-
- const observer = new ResizeObserver(update)
- observer.observe(section)
- return () => observer.disconnect()
- }, [])
-
- // Viser hele kartet første gang, og holder det innenfor kantene senere.
- useEffect(() => {
- if (!size) return
-
- setView((previous) => {
- if (previous) return clampView(previous, size, inset)
-
- const k = minScale(size, inset)
- return clampView(
- {
- k,
- x: inset.left + (size.width - inset.left - MAP_WIDTH * k) / 2,
- y: (size.height - MAP_HEIGHT * k) / 2,
- },
- size,
- inset,
- )
- })
- }, [size, inset])
-
- const stopFlying = useCallback(
- () => cancelAnimationFrame(animationRef.current),
- [],
- )
-
- /** Glir mykt til en ny visning. */
- const flyTo = useCallback(
- (target: View) => {
- stopFlying()
- const from = viewRef.current
-
- if (!from || prefersReducedMotion()) {
- setView(target)
- return
- }
-
- const start = performance.now()
- const step = (now: number) => {
- const t = Math.min(1, (now - start) / FLY_MS)
- const eased = 1 - (1 - t) ** 3
-
- setView({
- k: from.k + (target.k - from.k) * eased,
- x: from.x + (target.x - from.x) * eased,
- y: from.y + (target.y - from.y) * eased,
- })
- if (t < 1) animationRef.current = requestAnimationFrame(step)
- }
- animationRef.current = requestAnimationFrame(step)
- },
- [stopFlying],
- )
-
- useEffect(() => stopFlying, [stopFlying])
-
- // Sentrerer kartet på det valgte stedet, midt i det området ruten ikke dekker.
- useEffect(() => {
- const current = viewRef.current
- const place = places.find(({ id }) => id === selectedId)
- if (!current || !size || !place) return
-
- const { x, y } = toMapPoint(place.latitude, place.longitude)
- const k = Math.max(current.k, minScale(size, inset) * 2.5)
- const centerX = inset.left + (size.width - inset.left) / 2
- // Merkets spiss er under sentrum; løft kartet litt så hele merket synes.
- const centerY = (size.height - inset.bottom) / 2 + 24
-
- flyTo(clampView({ k, x: centerX - x * k, y: centerY - y * k }, size, inset))
- // Skal bare sentrere når et nytt sted velges, ikke når størrelsen endres.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedId])
-
- // Zoomer ut så hele ruten synes, midt i området ruten ikke dekker.
- useEffect(() => {
- const current = viewRef.current
- if (!current || !size || !fitPlaces || fitPlaces.length === 0) return
-
- const points = fitPlaces.map(({ latitude, longitude }) =>
- toMapPoint(latitude, longitude),
- )
- const xs = points.map(({ x }) => x)
- const ys = points.map(({ y }) => y)
- const minX = Math.min(...xs)
- const maxX = Math.max(...xs)
- const minY = Math.min(...ys)
- const maxY = Math.max(...ys)
-
- const min = minScale(size, inset)
- const availableWidth = size.width - inset.left - FIT_PADDING * 2
- const availableHeight = size.height - inset.bottom - FIT_PADDING * 2 - 48
- const k = Math.min(
- availableWidth / Math.max(maxX - minX, 1),
- availableHeight / Math.max(maxY - minY, 1),
- min * 3,
- )
- // Merkenes spisser er under sentrum; løft kartet litt så hele merkene synes.
- const centerX = inset.left + (size.width - inset.left) / 2
- const centerY = (size.height - inset.bottom) / 2 + 24
-
- flyTo(
- clampView(
- {
- k,
- x: centerX - ((minX + maxX) / 2) * k,
- y: centerY - ((minY + maxY) / 2) * k,
- },
- size,
- inset,
- ),
- )
- // Skal bare zoome når ruten som vises byttes, ikke når størrelsen endres.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [fitPlaces])
-
- const zoomBy = useCallback(
- (factor: number, px: number, py: number) => {
- if (!size) return
- stopFlying()
- setView((previous) =>
- previous ? zoomView(previous, factor, px, py, size, inset) : previous,
- )
- },
- [size, inset, stopFlying],
- )
-
- // Må registreres direkte for å kunne hindre at hele siden ruller.
- useEffect(() => {
- const section = sectionRef.current
- if (!section) return
-
- const handleWheel = (event: WheelEvent) => {
- event.preventDefault()
- const rect = section.getBoundingClientRect()
- const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
- zoomBy(
- Math.exp(-delta * 0.002),
- event.clientX - rect.left,
- event.clientY - rect.top,
- )
- }
-
- section.addEventListener('wheel', handleWheel, { passive: false })
- return () => section.removeEventListener('wheel', handleWheel)
- }, [zoomBy])
-
- const handlePointerDown = (event: PointerEvent) => {
- stopFlying()
- pointersRef.current.set(event.pointerId, {
- x: event.clientX,
- y: event.clientY,
- })
- gestureRef.current = {
- originX: event.clientX,
- originY: event.clientY,
- isDragging: false,
- pinchDistance: 0,
- }
- }
-
- const handlePointerMove = (event: PointerEvent) => {
- const pointers = pointersRef.current
- const previous = pointers.get(event.pointerId)
- if (!previous || !size) return
-
- const gesture = gestureRef.current
- const point = { x: event.clientX, y: event.clientY }
- const target = event.currentTarget
-
- if (pointers.size === 1) {
- // Små bevegelser regnes som et trykk, slik at merkene kan trykkes på.
- if (
- !gesture.isDragging &&
- Math.hypot(point.x - gesture.originX, point.y - gesture.originY) <
- DRAG_THRESHOLD
- ) {
- return
- }
-
- gesture.isDragging = true
- if (!target.hasPointerCapture(event.pointerId)) {
- target.setPointerCapture(event.pointerId)
- }
- pointers.set(event.pointerId, point)
-
- const dx = point.x - previous.x
- const dy = point.y - previous.y
- setView((current) =>
- current
- ? clampView(
- { ...current, x: current.x + dx, y: current.y + dy },
- size,
- inset,
- )
- : current,
- )
- return
- }
-
- // To fingre: zoom etter hvor langt de er fra hverandre.
- pointers.set(event.pointerId, point)
- const [a, b] = [...pointers.values()]
- const distance = Math.hypot(a.x - b.x, a.y - b.y)
- const rect = target.getBoundingClientRect()
-
- gesture.isDragging = true
- if (!target.hasPointerCapture(event.pointerId)) {
- target.setPointerCapture(event.pointerId)
- }
- if (gesture.pinchDistance > 0) {
- zoomBy(
- distance / gesture.pinchDistance,
- (a.x + b.x) / 2 - rect.left,
- (a.y + b.y) / 2 - rect.top,
- )
- }
- gesture.pinchDistance = distance
- }
-
- const handlePointerEnd = (event: PointerEvent) => {
- pointersRef.current.delete(event.pointerId)
- gestureRef.current.pinchDistance = 0
- }
-
- const zoomFromCenter = (factor: number) => {
- if (size) zoomBy(factor, size.width / 2, size.height / 2)
- }
-
- const handleKeyDown = (event: KeyboardEvent) => {
- // Merkene håndterer sine egne taster.
- if (event.target !== event.currentTarget || !size) return
-
- const pan = (dx: number, dy: number) => {
- event.preventDefault()
- stopFlying()
- setView((current) =>
- current
- ? clampView(
- { ...current, x: current.x + dx, y: current.y + dy },
- size,
- inset,
- )
- : current,
- )
- }
-
- switch (event.key) {
- case 'ArrowLeft':
- return pan(KEY_PAN, 0)
- case 'ArrowRight':
- return pan(-KEY_PAN, 0)
- case 'ArrowUp':
- return pan(0, KEY_PAN)
- case 'ArrowDown':
- return pan(0, -KEY_PAN)
- case '+':
- case '=':
- event.preventDefault()
- return zoomFromCenter(BUTTON_ZOOM)
- case '-':
- event.preventDefault()
- return zoomFromCenter(1 / BUTTON_ZOOM)
- }
- }
-
- // Det valgte og det utpekte merket tegnes sist, så de ligger øverst.
- const orderedPlaces = useMemo(() => {
- const rank = (id: string) =>
- id === selectedId ? 2 : id === hoveredId ? 1 : 0
- return [...places].sort((a, b) => rank(a.id) - rank(b.id))
- }, [places, selectedId, hoveredId])
-
- // Linjen mellom stoppene, i skjermpiksler.
- const routeLine =
- view && routeStops && routeStops.length > 1
- ? routeStops
- .map(({ latitude, longitude }) => {
- const { x, y } = toMapPoint(latitude, longitude)
- return `${view.x + x * view.k},${view.y + y * view.k}`
- })
- .join(' ')
- : null
+// Kartbiblioteket er stort, så det lastes først når en side med kart vises.
+const LeafletMap = lazy(() => import('./LeafletMap'))
+/** Kartflaten: et ekte kart med dra, zoom og et merke for hvert sted. */
+export function MapViewport(props: MapViewportProps) {
return (
-
-
-
-
-
-
-
+
+
+ Laster kart …
+
+
+ }
+ >
+
+
)
}
diff --git a/client/src/components/map/PlacePin.tsx b/client/src/components/map/PlacePin.tsx
deleted file mode 100644
index 456ed5c..0000000
--- a/client/src/components/map/PlacePin.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { KeyboardEvent } from 'react'
-import type { Place } from '../../types/place'
-
-type PlacePinProps = {
- place: Place
- /** Plassering i skjermpiksler, der spissen på merket peker. */
- x: number
- y: number
- isSelected: boolean
- isHovered: boolean
- /** Stoppets nummer i en rute. Da vises nummeret i stedet for krysset. */
- order?: number
- onSelect: () => void
-}
-
-/** Et «X marks the spot»-merke som markerer ett utested på kartet. */
-export function PlacePin({
- place,
- x,
- y,
- isSelected,
- isHovered,
- order,
- onSelect,
-}: PlacePinProps) {
- const className = [
- 'map-pin',
- isSelected && 'map-pin-selected',
- isHovered && 'map-pin-hover',
- order !== undefined && 'map-pin-ordered',
- ]
- .filter(Boolean)
- .join(' ')
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault()
- onSelect()
- }
- }
-
- return (
-
- {place.name}
-
-
-
- {order === undefined ? (
-
- ) : (
-
- {order}
-
- )}
-
-
- {place.name}
-
-
- )
-}
diff --git a/client/src/components/map/StylizedMap.tsx b/client/src/components/map/StylizedMap.tsx
deleted file mode 100644
index e0f6a42..0000000
--- a/client/src/components/map/StylizedMap.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { memo } from 'react'
-import { MAP_HEIGHT, MAP_WIDTH } from '../../utils/mapProjection'
-
-/** Elva og fjorden er tegnet for hånd; alt annet er enkle former. */
-const COAST =
- 'M-700-500H1700V110C1500 140 1350 90 1100 120S800 150 650 150 450 120 330 145 100 110-100 140-500 130-700 150Z'
-const RIVER =
- 'M790 920 760 800C740 640 690 560 600 520S470 400 520 330 640 250 650 150'
-
-const ROADS = [
- 'M-100 330C150 300 300 360 520 330S850 280 1100 300',
- 'M330 160C340 300 300 480 360 640S420 800 440 900',
- 'M700 160C760 300 820 420 900 520S960 700 1000 900',
- 'M-100 560C200 560 400 620 600 520M600 600C700 590 800 620 1100 560',
- 'M150 150C180 300 200 420 150 520S120 700 100 900',
- 'M520 330C560 400 640 380 700 440S760 600 770 800',
-]
-
-/** Små hus: x, y, bredde, høyde. */
-const BUILDINGS = [
- [250, 260, 22, 16],
- [285, 262, 16, 22],
- [400, 340, 24, 18],
- [430, 370, 18, 24],
- [630, 300, 22, 16],
- [660, 330, 16, 20],
- [720, 380, 24, 18],
- [800, 300, 18, 22],
- [870, 380, 22, 16],
- [230, 470, 24, 18],
- [260, 500, 18, 22],
- [330, 560, 22, 16],
- [420, 660, 24, 18],
- [540, 640, 18, 22],
- [620, 700, 22, 16],
- [790, 680, 24, 18],
- [860, 590, 18, 22],
- [940, 470, 22, 16],
- [150, 420, 22, 16],
- [310, 230, 20, 14],
-]
-
-const WAVES = [
- [120, 70],
- [260, 40],
- [420, 85],
- [560, 50],
- [760, 75],
- [900, 40],
- [40, 100],
- [690, 30],
-]
-
-/** Rutenett som i et sjøkart, med en strek for hver 200. enhet. */
-const GRID = Array.from({ length: 4 }, (_, index) => {
- const offset = (index + 1) * 200
- return `M${offset} -500V1300M-700 ${offset}H1700`
-}).join('')
-
-/** Tegner et stilisert kart over Trondheim. Det er ikke et ekte kart. */
-export const StylizedMap = memo(function StylizedMap() {
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {WAVES.map(([x, y]) => (
-
- ))}
-
-
-
-
- {ROADS.map((road) => (
-
-
-
-
- ))}
-
- {BUILDINGS.map(([x, y, width, height]) => (
-
- ))}
-
-
- TRONDHEIMSFJORDEN
-
-
- Nidelva
-
-
- Stilisert kart · {MAP_WIDTH} × {MAP_HEIGHT}
-
- >
- )
-})
diff --git a/client/src/components/map/mapPins.test.ts b/client/src/components/map/mapPins.test.ts
new file mode 100644
index 0000000..d7e1f60
--- /dev/null
+++ b/client/src/components/map/mapPins.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { makePlace } from '../../test/places'
+import { createPinIcon, escapeHtml } from './mapPins'
+
+const pin = (options: Partial[0]> = {}) =>
+ createPinIcon({
+ place: makePlace(),
+ isSelected: false,
+ isHovered: false,
+ ...options,
+ }).options
+
+describe('escapeHtml', () => {
+ it('makes names from the database safe to put in the page', () => {
+ expect(escapeHtml(`
& co`)).toBe(
+ '<img src=x onerror="alert('hei')"> & co',
+ )
+ })
+})
+
+describe('createPinIcon', () => {
+ it('shows the name and a cross, with the tip of the pin at the bottom', () => {
+ const { html, iconAnchor, iconSize } = pin()
+
+ expect(html).toContain('Nidelven Pub')
+ expect(html).toContain('×')
+ expect(iconAnchor).toEqual([16, (iconSize as number[])[1]])
+ })
+
+ it('never lets a place name become markup', () => {
+ const { html } = pin({ place: makePlace({ name: 'Pub' }) })
+
+ expect(html).not.toContain('')
+ expect(html).toContain('<b>Pub</b>')
+ })
+
+ it('marks the state of the pin with classes', () => {
+ expect(pin().className).toBe('map-pin')
+ expect(pin({ isSelected: true, isHovered: true }).className).toBe(
+ 'map-pin map-pin-selected map-pin-hover',
+ )
+ })
+
+ it('shows the stop number for a place in a route, also to screen readers', () => {
+ const { html, className } = pin({ order: 2 })
+
+ expect(className).toContain('map-pin-ordered')
+ expect(html).toContain('>2')
+ expect(html).toContain('2. ')
+ expect(html).not.toContain('×')
+ })
+})
diff --git a/client/src/components/map/mapPins.ts b/client/src/components/map/mapPins.ts
new file mode 100644
index 0000000..a4dec55
--- /dev/null
+++ b/client/src/components/map/mapPins.ts
@@ -0,0 +1,58 @@
+import { divIcon } from 'leaflet'
+import type { Place } from '../../types/place'
+
+/** Størrelsen på merket i piksler. Spissen nederst peker på stedet. */
+export const PIN_WIDTH = 32
+export const PIN_HEIGHT = 34
+
+type PinOptions = {
+ place: Place
+ isSelected: boolean
+ isHovered: boolean
+ /** Stoppets nummer i en rute. Da vises nummeret i stedet for krysset. */
+ order?: number
+}
+
+const ESCAPES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+}
+
+/** Stedsnavn kommer fra databasen, så de må gjøres trygge før de settes inn. */
+export function escapeHtml(text: string) {
+ return text.replace(/[&<>"']/g, (character) => ESCAPES[character])
+}
+
+/**
+ * Lager det lille merket som peker på et sted. Navnet ligger alltid i merket,
+ * så skjermlesere finner det, men vises bare når merket er i fokus eller valgt.
+ */
+export function createPinIcon({
+ place,
+ isSelected,
+ isHovered,
+ order,
+}: PinOptions) {
+ const className = [
+ 'map-pin',
+ isSelected && 'map-pin-selected',
+ isHovered && 'map-pin-hover',
+ order !== undefined && 'map-pin-ordered',
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ const mark = order === undefined ? '×' : String(order)
+ const orderPrefix =
+ order === undefined ? '' : `${order}. `
+
+ return divIcon({
+ className,
+ iconSize: [PIN_WIDTH, PIN_HEIGHT],
+ iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT],
+ html: `${mark}${orderPrefix}${escapeHtml(place.name)}`,
+ })
+}
diff --git a/client/src/pages/MapPage.css b/client/src/pages/MapPage.css
index e02540a..b24a911 100644
--- a/client/src/pages/MapPage.css
+++ b/client/src/pages/MapPage.css
@@ -15,150 +15,102 @@
.map-viewport {
position: absolute;
inset: 0;
+ /* Lagene til Leaflet skal ikke legge seg over ruten og knappene. */
+ z-index: 0;
}
.map-canvas {
- display: block;
width: 100%;
height: 100%;
- cursor: grab;
- touch-action: none;
- user-select: none;
-}
-
-.map-canvas:active {
- cursor: grabbing;
+ background: var(--blue-100);
+ font: inherit;
}
.map-canvas:focus-visible {
+ outline: 3px solid var(--navy-900);
outline-offset: -6px;
}
-.map-land {
- fill: #efe6cc;
-}
-
-.map-grid {
- fill: none;
- stroke: var(--navy-500);
- stroke-dasharray: 4 8;
- opacity: 0.15;
-}
-
-.map-park {
- fill: #d9e5c8;
-}
-
-.map-water {
- fill: var(--blue-100);
- stroke: var(--navy-500);
- stroke-width: 2;
-}
-
-.map-depth {
- fill: none;
- stroke: var(--blue-300);
- stroke-width: 1.5;
- stroke-dasharray: 2 7;
- stroke-linecap: round;
-}
-
-.map-wave {
- fill: none;
- stroke: var(--blue-300);
- stroke-width: 1.5;
- stroke-linecap: round;
-}
-
-.map-river-edge,
-.map-river,
-.map-road-edge,
-.map-road {
- fill: none;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-
-.map-river-edge {
- stroke: var(--navy-500);
- stroke-width: 26;
-}
-
-.map-river {
- stroke: var(--blue-100);
- stroke-width: 22;
-}
-
-.map-road-edge {
- stroke: #d8ccab;
- stroke-width: 9;
-}
-
-.map-road {
- stroke: var(--surface);
- stroke-width: 6;
+.map-loading {
+ display: grid;
+ height: 100%;
+ margin: 0;
+ place-items: center;
+ background: var(--blue-100);
+ color: var(--muted);
}
-.map-building {
- fill: #ddd1b0;
- stroke: #bfb28c;
+.map-canvas .leaflet-control-attribution {
+ background: rgb(255 253 247 / 0.85);
+ color: var(--muted);
+ font-size: 0.7rem;
}
-.map-label {
- fill: var(--navy-500);
- font-size: 20px;
- font-style: italic;
- letter-spacing: 0.25em;
- opacity: 0.8;
- pointer-events: none;
+/* Bølgekanten på banneret ligger over kartets øvre kant. */
+.map-canvas .leaflet-top.leaflet-right {
+ margin-top: 1.5rem;
}
-.map-label-small {
- font-size: 15px;
- letter-spacing: 0.1em;
+.map-canvas .leaflet-control-attribution a {
+ color: var(--navy-700);
}
/* ---------- Merker ---------- */
+/* Merket er en rotert, avrundet firkant med spissen ned mot stedet. */
.map-pin {
+ background: none;
+ border: 0;
cursor: pointer;
outline: none;
}
.map-pin-body {
+ position: absolute;
+ inset: 0;
+ transform-origin: 50% 100%;
transition: transform 0.15s;
}
-.map-pin-halo {
- fill: var(--blue-300);
- opacity: 0;
- transition: opacity 0.15s;
-}
-
.map-pin-shape {
- fill: var(--navy-700);
- stroke: var(--surface);
- stroke-width: 2.5;
- transition: fill 0.15s;
+ position: absolute;
+ top: 0;
+ left: 2px;
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border: 2.5px solid var(--surface);
+ border-radius: 50% 50% 50% 0;
+ background: var(--navy-700);
+ box-shadow: 0 2px 6px rgb(10 34 64 / 0.35);
+ transform: rotate(-45deg);
+ transition: background 0.15s;
}
.map-pin-mark {
- fill: none;
- stroke: var(--surface);
- stroke-width: 3;
- stroke-linecap: round;
+ color: var(--surface);
+ font-size: 15px;
+ font-weight: 700;
+ line-height: 1;
+ transform: rotate(45deg);
}
.map-pin-label {
- fill: var(--navy-900);
- font-size: 14px;
+ position: absolute;
+ bottom: calc(100% + 0.6rem);
+ left: 50%;
+ padding: 0.1rem 0.6rem;
+ border: 1.5px solid var(--navy-900);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--navy-900);
+ font-size: 0.8rem;
font-weight: 700;
+ white-space: nowrap;
opacity: 0;
- paint-order: stroke;
pointer-events: none;
- stroke: var(--surface);
- stroke-linejoin: round;
- stroke-width: 4px;
+ transform: translateX(-50%);
transition: opacity 0.15s;
}
@@ -171,14 +123,12 @@
.map-pin:hover .map-pin-shape,
.map-pin:focus-visible .map-pin-shape,
.map-pin-hover .map-pin-shape {
- fill: var(--navy-500);
+ background: var(--navy-500);
}
-.map-pin:hover .map-pin-halo,
-.map-pin:focus-visible .map-pin-halo,
-.map-pin-hover .map-pin-halo,
-.map-pin-selected .map-pin-halo {
- opacity: 0.85;
+.map-pin:focus-visible .map-pin-shape {
+ outline: 3px solid var(--gold);
+ outline-offset: 2px;
}
.map-pin:hover .map-pin-label,
@@ -192,45 +142,20 @@
transform: scale(1.4);
}
-.map-pin-selected .map-pin-shape {
- fill: var(--gold);
- stroke: var(--navy-900);
-}
-
-.map-pin-selected .map-pin-mark {
- stroke: var(--navy-900);
-}
-
-/* ---------- Kompass og zoom ---------- */
-
-.map-compass {
- position: absolute;
- top: 0.75rem;
- right: 0.75rem;
- width: 3.5rem;
- pointer-events: none;
-}
-
-.compass-ring {
- fill: var(--surface);
- fill-opacity: 0.85;
- stroke: var(--navy-900);
- stroke-width: 1.5;
+.map-pin-selected .map-pin-label {
+ bottom: calc(100% + 0.9rem);
}
-.compass-ns {
- fill: var(--navy-900);
+.map-pin-selected .map-pin-shape {
+ border-color: var(--navy-900);
+ background: var(--gold);
}
-.compass-ew {
- fill: var(--blue-300);
+.map-pin-selected .map-pin-mark {
+ color: var(--navy-900);
}
-.map-compass text {
- fill: var(--navy-900);
- font-size: 10px;
- font-weight: 700;
-}
+/* ---------- Zoom ---------- */
.zoom-controls {
position: absolute;
diff --git a/client/src/pages/RoutesPage.css b/client/src/pages/RoutesPage.css
index 3733ad8..a5832f4 100644
--- a/client/src/pages/RoutesPage.css
+++ b/client/src/pages/RoutesPage.css
@@ -180,17 +180,6 @@ button.stop-name:focus-visible {
stroke-width: 4;
}
-.map-pin-number {
- fill: var(--surface);
- font-size: 15px;
- font-weight: 700;
- pointer-events: none;
-}
-
-.map-pin-selected .map-pin-number {
- fill: var(--navy-900);
-}
-
.map-pin-ordered .map-pin-label {
opacity: 1;
}
diff --git a/client/src/utils/mapProjection.ts b/client/src/utils/mapProjection.ts
index c86b6eb..d822ea4 100644
--- a/client/src/utils/mapProjection.ts
+++ b/client/src/utils/mapProjection.ts
@@ -1,24 +1,17 @@
-/** Størrelsen på det stiliserte kartet, i kartets egne enheter. */
-export const MAP_WIDTH = 1000
-export const MAP_HEIGHT = 800
+/**
+ * Gjør posisjoner i et rutenett om til koordinater. Brukes bare av mockdataene,
+ * som ble laget før kartet var ekte.
+ */
+const MAP_WIDTH = 1000
+const MAP_HEIGHT = 800
-// Utsnittet av Trondheim som kartet dekker (omtrent 5 × 5 km).
+// Utsnittet av Trondheim mockdataene er plassert i (omtrent 5 × 5 km).
const WEST = 10.34
const LONGITUDE_SPAN = 0.12
const NORTH = 63.455
const LATITUDE_SPAN = 0.048
-export type MapPoint = { x: number; y: number }
-
-/** Gjør breddegrad og lengdegrad om til et punkt på kartet. */
-export function toMapPoint(latitude: number, longitude: number): MapPoint {
- return {
- x: ((longitude - WEST) / LONGITUDE_SPAN) * MAP_WIDTH,
- y: ((NORTH - latitude) / LATITUDE_SPAN) * MAP_HEIGHT,
- }
-}
-
-/** Det omvendte: et punkt på kartet blir til koordinater. */
+/** Gjør en posisjon i rutenettet om til koordinater. */
export function toCoordinates(x: number, y: number) {
return {
latitude: Number((NORTH - (y / MAP_HEIGHT) * LATITUDE_SPAN).toFixed(5)),