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 ( -
    - - {view && ( - <> - - - - {routeLine && ( - - )} - {orderedPlaces.map((place) => { - const point = toMapPoint(place.latitude, place.longitude) - return ( - onSelect(place.id)} - /> - ) - })} - - )} - - - - - -
  • - -
  • -
  • - -
  • -
    -
    + +

    + 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: `${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)),