Skip to content
Merged
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
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { useRoute } from './hooks/useRoute'
import { ChallengesPage } from './pages/ChallengesPage'
import { HomePage } from './pages/HomePage'
import { MapPage } from './pages/MapPage'
import { RoutesPage } from './pages/RoutesPage'
import { UserPage } from './pages/UserPage'

const pages = {
home: HomePage,
map: MapPage,
routes: RoutesPage,
user: UserPage,
challenges: ChallengesPage,
}
Expand Down
40 changes: 40 additions & 0 deletions client/src/api/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

describe('routes api', () => {
beforeEach(() => {
window.localStorage.clear()
// Rutene ligger i minnet mellom kall, så hver test starter med en ny modul.
vi.resetModules()
})

it('starts with example routes and saves new ones first', async () => {
const { fetchMyRoutes, saveRoute } = await import('./routes')

const initial = await fetchMyRoutes()
expect(initial.length).toBeGreaterThan(0)

const saved = await saveRoute(' Kveldstur ', ['a', 'b'])
expect(saved).toMatchObject({ name: 'Kveldstur', stopIds: ['a', 'b'] })

const routes = await fetchMyRoutes()
expect(routes[0]).toEqual(saved)
expect(routes).toHaveLength(initial.length + 1)
})

it('keeps saved routes after a reload', async () => {
const first = await import('./routes')
await first.saveRoute('Kveldstur', ['a', 'b'])

vi.resetModules()
const second = await import('./routes')

expect((await second.fetchMyRoutes())[0].name).toBe('Kveldstur')
})

it('ignores stored data that is not a list of routes', async () => {
window.localStorage.setItem('tapsailor.routes', '{"broken":true}')
const { fetchMyRoutes } = await import('./routes')

expect((await fetchMyRoutes()).length).toBeGreaterThan(0)
})
})
87 changes: 87 additions & 0 deletions client/src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { SavedRoute } from '../types/route'

/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */
const LATENCY_MS = 200

const STORAGE_KEY = 'tapsailor.routes'

/** Eksempelruter som vises til brukeren har laget egne. */
const exampleRoutes: SavedRoute[] = [
{
id: 'example-elvekveld',
name: 'Elvekveld',
stopIds: ['nidelven-pub', 'bakklandet-taphus', 'brygga-bryggeri'],
},
{
id: 'example-studentrunden',
name: 'Studentrunden',
stopIds: ['gløshaugen-bar', 'torget-vinbar', 'solsiden-sjøbod'],
},
]

const isSavedRoute = (value: unknown): value is SavedRoute => {
if (typeof value !== 'object' || value === null) return false
const { id, name, stopIds } = value as Partial<SavedRoute>
return (
typeof id === 'string' &&
typeof name === 'string' &&
Array.isArray(stopIds) &&
stopIds.every((stopId) => typeof stopId === 'string')
)
}

/** Rutene i minnet, slik at de også finnes når nettleserlagring ikke virker. */
let routes: SavedRoute[] | null = null

function loadRoutes(): SavedRoute[] {
if (routes) return routes

try {
const stored: unknown = JSON.parse(
window.localStorage.getItem(STORAGE_KEY) ?? 'null',
)
if (Array.isArray(stored)) routes = stored.filter(isSavedRoute)
} catch {
// Lagringen kan være blokkert eller ødelagt. Da brukes eksemplene.
}

routes ??= exampleRoutes
return routes
}

function persistRoutes(next: SavedRoute[]) {
routes = next

try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// Rutene beholdes i minnet så lenge siden er åpen.
}
}

/**
* Henter rutene til den innloggede brukeren.
*
* Foreløpig lagres de i nettleseren. Når GraphQL-backenden er klar, byttes
* innholdet i denne og neste funksjon ut med kall mot serveren.
*/
export async function fetchMyRoutes(): Promise<SavedRoute[]> {
await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))
return loadRoutes()
}

/** Lagrer en ny rute øverst i listen og returnerer den. */
export async function saveRoute(
name: string,
stopIds: string[],
): Promise<SavedRoute> {
await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))

const route: SavedRoute = {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
name: name.trim(),
stopIds,
}
persistRoutes([route, ...loadRoutes()])
return route
}
5 changes: 4 additions & 1 deletion client/src/components/MainNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CHALLENGES_HREF,
HOME_HREF,
MAP_HREF,
ROUTES_HREF,
USER_HREF,
useRoute,
} from '../hooks/useRoute'
Expand All @@ -27,7 +28,7 @@ function Icon({ children }: { children: ReactNode }) {
)
}

/** Lenkene i hovednavigasjonen. «Ruter» har ingen side ennå. */
/** Lenkene i hovednavigasjonen. */
const links: {
label: string
icon: ReactNode
Expand All @@ -48,6 +49,8 @@ const links: {
},
{
label: 'Ruter',
href: ROUTES_HREF,
route: 'routes',
// Ruten mellom to punkter
icon: (
<Icon>
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/PageFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const quotes: Record<Route, { text: string; author: string }> = {
text: 'Man cannot discover new oceans unless he has the courage to lose sight of the shore.',
author: 'André Gide',
},
routes: {
text: 'If one does not know to which port one is sailing, no wind is favorable.',
author: 'Seneca',
},
user: {
text: 'A sailor is an artist whose medium is the wind.',
author: 'Webb Chiles',
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ const content: Record<Route, HeaderContent> = {
helpTitle: 'Slik bruker du kartet',
help: 'Kartet viser utestedene i Trondheim som små skattemerker. Dra i kartet for å flytte deg, og bruk rulling, knappene nederst til høyre eller to fingre for å zoome. Søk etter et sted i feltet til venstre, så vises bare de stedene på kartet. Trykk på et sted i listen eller et merke på kartet for å se informasjon om stedet i ruten. Trykk på pilen øverst i ruten for å gå tilbake til søket. Foreløpig er dette et stilisert prøvekart, ikke et ekte kart.',
},
routes: {
title: 'Ruter',
subtitle: 'Planlegg kvelden stopp for stopp',
helpTitle: 'Slik bruker du rutene',
help: 'Her samler du stedene du vil innom, i den rekkefølgen du vil besøke dem. Under «Mine ruter» ser du rutene du har laget. Trykk på en rute for å se den på kartet, og trykk på pilen øverst i ruten for å gå tilbake til alle rutene. Under «Lag ny rute» søker du opp stedet du vil starte på, eller trykker på et merke på kartet. Trykk «Neste stopp» for å legge til flere stopp, og «Lagre rute» når du er fornøyd. Da får du gi ruten et navn, og den dukker opp under «Mine ruter».',
},
user: {
title: 'Profil',
subtitle: 'Din logg over kvelder til sjøs',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ exports[`SiteHeader > matches the shared header structure 1`] = `
</li>
<li>
<a
href="#"
href="#/ruter"
>
<svg
aria-hidden="true"
Expand Down
70 changes: 70 additions & 0 deletions client/src/components/map/MapViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ type MapViewportProps = {
selectedId: string | null
hoveredId: string | null
inset: Inset
/** Stedene i en rute, i rekkefølge. Kartet tegner en linje mellom dem. */
routeStops?: Place[]
/** Stoppets nummer i ruten, for hvert sted som er med. */
stopOrder?: ReadonlyMap<string, number>
/** Når disse endres, zoomer kartet så alle stedene synes. */
fitPlaces?: Place[] | null
onSelect: (id: string) => void
}

Expand All @@ -24,6 +30,7 @@ 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) =>
Expand Down Expand Up @@ -70,6 +77,9 @@ export function MapViewport({
selectedId,
hoveredId,
inset,
routeStops,
stopOrder,
fitPlaces,
onSelect,
}: MapViewportProps) {
const sectionRef = useRef<HTMLElement>(null)
Expand Down Expand Up @@ -175,6 +185,48 @@ export function MapViewport({
// 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
Expand Down Expand Up @@ -332,6 +384,17 @@ export function MapViewport({
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

return (
<section ref={sectionRef} className="map-viewport" aria-label="Kart">
<svg
Expand All @@ -350,6 +413,12 @@ export function MapViewport({
<g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
<StylizedMap />
</g>
{routeLine && (
<g aria-hidden="true">
<polyline className="map-route-casing" points={routeLine} />
<polyline className="map-route-line" points={routeLine} />
</g>
)}
{orderedPlaces.map((place) => {
const point = toMapPoint(place.latitude, place.longitude)
return (
Expand All @@ -360,6 +429,7 @@ export function MapViewport({
y={view.y + point.y * view.k}
isSelected={place.id === selectedId}
isHovered={place.id === hoveredId}
order={stopOrder?.get(place.id)}
onSelect={() => onSelect(place.id)}
/>
)
Expand Down
13 changes: 10 additions & 3 deletions client/src/components/map/PlaceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import { PlaceImage } from '../PlaceImage'
type PlaceDetailsProps = {
place: Place
now: Date
/** Teksten på pilen. Den er «Tilbake til søk» som standard. */
backLabel?: string
onBack: () => void
}

/** Alt om ett sted, med en pil som går tilbake til søket. */
export function PlaceDetails({ place, now, onBack }: PlaceDetailsProps) {
export function PlaceDetails({
place,
now,
backLabel = 'Tilbake til søk',
onBack,
}: PlaceDetailsProps) {
const headingRef = useRef<HTMLHeadingElement>(null)

// Flytter fokus hit, så skjermlesere får lest opp stedet som ble valgt.
Expand All @@ -24,8 +31,8 @@ export function PlaceDetails({ place, now, onBack }: PlaceDetailsProps) {
<button
type="button"
className="map-control back-button"
aria-label="Tilbake til søk"
title="Tilbake til søk"
aria-label={backLabel}
title={backLabel}
onClick={onBack}
>
<svg
Expand Down
14 changes: 12 additions & 2 deletions client/src/components/map/PlacePin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ type PlacePinProps = {
y: number
isSelected: boolean
isHovered: boolean
/** Stoppets nummer i en rute. Da vises nummeret i stedet for krysset. */
order?: number
onSelect: () => void
}

Expand All @@ -18,12 +20,14 @@ export function PlacePin({
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(' ')
Expand All @@ -41,7 +45,7 @@ export function PlacePin({
transform={`translate(${x} ${y})`}
role="button"
tabIndex={0}
aria-label={place.name}
aria-label={order === undefined ? place.name : `${order}. ${place.name}`}
aria-pressed={isSelected}
onClick={onSelect}
onKeyDown={handleKeyDown}
Expand All @@ -53,7 +57,13 @@ export function PlacePin({
className="map-pin-shape"
d="M0 0C-4-8-14-16-14-26a14 14 0 1 1 28 0C14-16 4-8 0 0Z"
/>
<path className="map-pin-mark" d="M-5-31 5-21M5-31-5-21" />
{order === undefined ? (
<path className="map-pin-mark" d="M-5-31 5-21M5-31-5-21" />
) : (
<text className="map-pin-number" y="-21" textAnchor="middle">
{order}
</text>
)}
</g>
<text className="map-pin-label" y="-52" textAnchor="middle">
{place.name}
Expand Down
Loading