feat(client): show Dawarich stays, routes and marks where they belong

The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
@@ -0,0 +1,78 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
trailSegments,
type DawarichTrailSegment,
} from './dawarichTrail'
/**
* The recorded route on the Leaflet renderer.
*
* Three lines per segment, the same construction the GPX tracks next door use:
* a white casing so the line survives a satellite tile, the coloured line
* itself, and nothing on top — this overlay is not clickable. That last part is
* deliberate: a fat invisible hit-line would sit over the places and the
* planned route, and swallow the clicks that select them, in exchange for a
* popup nobody asked for.
*
* Dashed rather than solid. The planned route is already a solid line in the
* user's accent, and the whole point of this layer is to be visibly the other
* thing — what actually happened, next to what was planned. Telling them apart
* has to survive being printed, screenshotted and looked at by someone who
* cannot distinguish the two hues.
*/
export default function DawarichTrailLayer({
track,
selectedDate,
casingPane,
}: {
track: DawarichTrack | null
/** When set, only that local day is drawn. */
selectedDate?: string | null
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
if (segments.length === 0) return null
return (
<>
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-casing-${segment.id}`}
positions={segment.points}
pane={casingPane}
// theme-lint-disable — map paint
pathOptions={{
color: DAWARICH_TRAIL_CASING,
weight: 6,
opacity: 0.55,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions rather than bare props: react-leaflet only calls setStyle
when the pathOptions reference changes, so a bare `color` would stick
at its mount-time value and a day recolour would never arrive. */}
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-line-${segment.id}`}
positions={segment.points}
pathOptions={{
color: segment.color,
weight: 3,
opacity: 0.85,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
</>
)
}
@@ -0,0 +1,93 @@
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import DawarichIcon from '../shared/DawarichIcon'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { DawarichTrailStatus } from './useDawarichTrail'
/**
* Toggle for the recorded-route overlay (#2279), in the same frosted shell as
* the compass, the layer switcher and the trip-route overview so the map's
* controls stay one family.
*
* The status rides on the button rather than in a banner: the states that
* matter — loading, nothing recorded for these dates, instance unreachable,
* device offline — are all answers to "why is there no line", and the place
* someone looks for that answer is the control they just pressed.
*/
export function DawarichTrailPill({
active,
status,
onToggle,
}: {
active: boolean
status: DawarichTrailStatus
onToggle: () => void
}) {
const { t } = useTranslation()
const label = !active
? t('dawarich.trail.show')
: status === 'loading'
? t('dawarich.trail.loading')
: status === 'empty'
? t('dawarich.trail.empty')
: status === 'offline'
? t('dawarich.trail.offline')
: status === 'unavailable'
? t('dawarich.trail.unavailable')
: t('dawarich.trail.hide')
// A problem worth seeing without reading the tooltip: the layer is on and
// there is nothing to show.
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: 4,
borderRadius: 999,
pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
>
{/* TREK's own tooltip: the native one is unreadable in dark mode and never
appears on a touch device, and this control's whole job is to explain
why there is no line. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="dawarich-trail-pill"
style={{
display: 'block',
width: 34,
height: 34,
borderRadius: 999,
border: 'none',
cursor: 'pointer',
background: 'transparent',
padding: 0,
// The mark fills the circle and is clipped by it, so the control reads
// as the Dawarich button rather than as a frame with a logo in it.
overflow: 'hidden',
// Its own colours stay; on/off is carried by opacity, because a
// recoloured logo is a different logo.
opacity: muted ? 0.35 : active ? 1 : 0.6,
transition: 'opacity 0.14s, transform 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'none' }}
>
<DawarichIcon fill />
</button>
</Tooltip>
</div>
)
}
+12
View File
@@ -53,6 +53,7 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
@@ -669,6 +670,8 @@ export const MapView = memo(function MapView({
routeVias = [],
dayBoundaryControls,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -1179,6 +1182,15 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
over it — the plan is what the user is editing. */}
<DawarichTrailLayer
track={dawarichTrack}
selectedDate={dawarichSelectedDate}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
@@ -20,6 +20,9 @@ import { useTranslation } from '../../i18n/TranslationContext'
export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
// `dawarichTrack` arrives as a prop rather than being fetched here: the pill
// that switches it on lives at page level and needs the load status, so the
// fetch sits in useTripPlanner and both shells read the same one.
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
@@ -34,6 +34,11 @@ const glMap = vi.hoisted(() => ({
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
// Every overlay that adds a layer takes it away again on cleanup; a real
// MapLibre/Mapbox map has both halves, and a double with only the adding half
// fails the teardown rather than the behaviour under test.
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import type { DawarichTrack, RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -30,6 +30,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
@@ -190,6 +191,10 @@ interface Props {
*/
clusterLoosely?: boolean
hazards?: RoadtripHazard[]
/** The route recorded in Dawarich, already fetched by MapViewAuto (#2279). */
dawarichTrack?: DawarichTrack | null
/** Draw only this local day of the recording. */
dawarichSelectedDate?: string | null
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -635,6 +640,8 @@ export function MapViewGL({
focusPoints,
clusterLoosely = false,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -712,6 +719,8 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
+25 -19
View File
@@ -1,5 +1,6 @@
import { Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
@@ -21,25 +22,30 @@ export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; o
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
{/* TREK's own tooltip, not the browser's — the native one ignores the
colour scheme, waits a second and a half, and cannot be read on a touch
device at all. `left`, because these controls hug the right edge of the
map and a tooltip to the right would hang off it. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
/**
* Everything both map renderers need to agree on for the recorded-route
* overlay, in one module.
*
* TREK draws no line twice the same way — Leaflet takes `<Polyline>` elements
* and GL takes a GeoJSON source — so the *shape* cannot be shared. The colour,
* the day ordering and the identity of a segment can, and they are exactly what
* would drift between the two renderers if each carried its own copy.
*/
/**
* The palette for the recorded route.
*
* Deliberately not derived from `var(--accent)`: the planned route already uses
* the accent, and the whole point of this overlay is that it reads as *the
* other thing* — what happened, next to what was planned. These are the same
* hues Dawarich itself uses for its track modes, which makes the two products
* agree about a line the user has seen in both.
*
* theme-lint-disable — map paint: Leaflet path options and GL paint
* expressions cannot read a CSS variable, and the map is the one surface where
* a fixed palette is the documented exception (client/src/theme/README.md).
*/
export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map paint
/** The white casing under the line, so it stays visible over a satellite tile. */
export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
/**
* Per-day hues, cycled.
*
* A multi-day trip drawn in one colour is a scribble; per-day colours make "we
* were here on Tuesday" readable at a glance. Six is enough to tell adjacent
* days apart and few enough that the map does not turn into a rainbow.
*
* theme-lint-disable — map paint, same reason as above.
*/
export const DAWARICH_DAY_COLORS = [
'#2563EB', // theme-lint-disable — map paint
'#7C3AED', // theme-lint-disable — map paint
'#0891B2', // theme-lint-disable — map paint
'#DB2777', // theme-lint-disable — map paint
'#EA580C', // theme-lint-disable — map paint
'#059669', // theme-lint-disable — map paint
] as const
export function dayColor(index: number): string {
return DAWARICH_DAY_COLORS[index % DAWARICH_DAY_COLORS.length]!
}
/** One drawable piece of the overlay, flattened out of the day/segment nesting. */
export interface DawarichTrailSegment {
/** Stable across refetches, so a renderer can key on it instead of on array order. */
id: string
date: string
color: string
/** `[lat, lng]`, TREK's internal order. GeoJSON flips it at the boundary. */
points: Array<[number, number]>
mode: string | null
startedAt: string
endedAt: string
distanceMeters: number | null
}
/**
* Flatten a fetched track into drawable segments, optionally limited to one day.
*
* The day filter is what the timeline scrubber drives: selecting a day on the
* planner should dim the rest of the trip rather than reload it, so the filter
* happens here over data that is already in memory.
*/
export function trailSegments(
track: DawarichTrack | null,
onlyDate?: string | null,
): DawarichTrailSegment[] {
if (!track) return []
const days: DawarichTrackDay[] = onlyDate
? track.days.filter(day => day.date === onlyDate)
: track.days
const out: DawarichTrailSegment[] = []
for (const day of days) {
// The colour index comes from the day's position in the WHOLE track, not in
// the filtered list — otherwise picking a day would repaint it.
const index = track.days.findIndex(candidate => candidate.date === day.date)
day.segments.forEach((segment, i) => {
// Zod's tuple infers as "maybe two numbers, maybe more", so the pairs are
// narrowed at runtime rather than cast. In practice the schema has already
// guaranteed both are numbers; this is what makes the type honest without
// an `as` at the boundary.
const points = segment.points.flatMap((pair): Array<[number, number]> =>
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : [],
)
if (points.length < 2) return
out.push({
id: `${day.date}-${i}`,
date: day.date,
color: dayColor(index < 0 ? 0 : index),
points,
mode: segment.mode,
startedAt: segment.startedAt,
endedAt: segment.endedAt,
distanceMeters: segment.distanceMeters,
})
})
}
return out
}
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
export function trailGeoJson(segments: DawarichTrailSegment[]): {
type: 'FeatureCollection'
features: Array<{
type: 'Feature'
properties: { id: string; color: string; date: string; mode: string | null }
geometry: { type: 'LineString'; coordinates: Array<[number, number]> }
}>
} {
return {
type: 'FeatureCollection',
features: segments.map(segment => ({
type: 'Feature' as const,
properties: {
id: segment.id,
color: segment.color,
date: segment.date,
mode: segment.mode,
},
geometry: {
type: 'LineString' as const,
// Internal order is [lat, lng] everywhere in TREK; GeoJSON wants the
// other one, and this is the single place the overlay flips it.
coordinates: segment.points.map((point): [number, number] => [point[1], point[0]]),
},
})),
}
}
/** Total kilometres the overlay covers, for the panel that reports it. */
export function trailDistanceKm(segments: DawarichTrailSegment[]): number | null {
const known = segments.filter(segment => segment.distanceMeters !== null)
if (known.length === 0) return null
return known.reduce((sum, segment) => sum + (segment.distanceMeters ?? 0), 0) / 1000
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { DawarichTrack } from '@trek/shared'
import { dawarichRepo } from '../../repo/dawarichRepo'
import { useAddonStore } from '../../store/addonStore'
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
export type DawarichTrailStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'unavailable' | 'offline'
export interface DawarichTrailState {
track: DawarichTrack | null
status: DawarichTrailStatus
/** ISO-8601 of the last fetch that actually returned something. */
fetchedAt: string | null
reload: () => void
}
/**
* The recorded route for a trip, fetched while the overlay is on.
*
* Called **once** in `MapViewAuto` and mirrored into both renderers as a prop —
* the same shape `useRoadtripHazards` uses, and for the same reason: two
* renderers each fetching would double the load on somebody's self-hosted
* instance and could draw two different answers.
*
* Refreshes on a timer while the layer is on, so a trip in progress catches up
* without anyone reloading the page. Two minutes rather than seconds: this
* reaches across the network to a server the user runs, and the server caches
* for a minute anyway, so a tighter loop would buy nothing and cost them.
*/
const REFRESH_MS = 120_000
export function useDawarichTrail(
tripId: number | undefined,
enabled: boolean,
range?: { from?: string; to?: string },
): DawarichTrailState {
const addonEnabled = useAddonStore(s => s.isEnabled)
const active = enabled && !!tripId && addonEnabled('dawarich')
const [track, setTrack] = useState<DawarichTrack | null>(null)
const [status, setStatus] = useState<DawarichTrailStatus>('idle')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [reloadToken, setReloadToken] = useState(0)
// Kept in a ref so changing the window does not re-create the effect's
// identity on every render of the parent — the object literal would.
const rangeRef = useRef(range)
rangeRef.current = range
const rangeKey = `${range?.from ?? ''}|${range?.to ?? ''}`
const reload = useCallback(() => setReloadToken(token => token + 1), [])
useEffect(() => {
if (!active || !tripId) {
setTrack(null)
setStatus('idle')
return
}
let controller: AbortController | null = null
let cancelled = false
const load = () => {
controller?.abort()
controller = new AbortController()
const signal = controller.signal
if (isEffectivelyOffline()) {
setStatus('offline')
return
}
// The previous line stays on screen while the next one loads: blanking it
// makes a refresh look like the recording disappeared.
setStatus(current => (current === 'ready' ? 'ready' : 'loading'))
dawarichRepo
.tripTrack(tripId, rangeRef.current, signal)
.then(result => {
if (cancelled || signal.aborted) return
setTrack(result)
setFetchedAt(result.fetchedAt)
setStatus(result.days.length > 0 ? 'ready' : 'empty')
})
.catch(() => {
if (cancelled || signal.aborted) return
// Keep whatever was drawn. A line from two minutes ago is more useful
// than an empty map, and the status is what says it may be stale.
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
})
}
load()
const timer = window.setInterval(load, REFRESH_MS)
const unsubscribe = onNetworkModeChange(load)
return () => {
cancelled = true
controller?.abort()
window.clearInterval(timer)
unsubscribe()
}
}, [active, tripId, rangeKey, reloadToken])
return { track: active ? track : null, status: active ? status : 'idle', fetchedAt, reload }
}
@@ -0,0 +1,90 @@
import { useEffect } from 'react'
import type { Map } from 'mapbox-gl'
import type { GeoJSONSource } from 'mapbox-gl'
import type { DawarichTrack } from '@trek/shared'
import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail'
/**
* The recorded route on the GL renderer — the imperative twin of
* `DawarichTrailLayer`.
*
* The ids start with `trek-` because `applySatellite` finds the first layer
* whose id carries one of TREK's own prefixes and inserts the satellite tile
* beneath it. A layer named `dawarich-trail` would end up *under* the
* satellite image and simply not be there when someone switched basemap on.
*
* Everything is rebuilt on `style.load`, because the whole GL map is torn down
* and reassembled when the provider, the style or the token changes, and a
* source added outside that effect does not survive it.
*/
const SOURCE = 'trek-dawarich-trail'
const CASING_LAYER = 'trek-dawarich-trail-casing'
const LINE_LAYER = 'trek-dawarich-trail-line'
export function useDawarichTrailGL(
map: Map | null,
ready: boolean,
track: DawarichTrack | null | undefined,
selectedDate?: string | null,
/** Insert beneath this layer, so the planned route stays on top. */
beforeId?: string,
): void {
useEffect(() => {
if (!map || !ready) return
const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
const draw = () => {
if (!map.isStyleLoaded()) return
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
if (existing) {
// setData rather than remove-and-add: a rebuilt source flickers, and a
// refresh every two minutes would flicker every two minutes.
existing.setData(data as never)
return
}
map.addSource(SOURCE, { type: 'geojson', data: data as never })
const anchor = beforeId && map.getLayer(beforeId) ? beforeId : undefined
map.addLayer(
{
id: CASING_LAYER,
type: 'line',
source: SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' },
// theme-lint-disable — map paint
paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 },
},
anchor,
)
map.addLayer(
{
id: LINE_LAYER,
type: 'line',
source: SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
// Per-day colour off the feature, with the same fallback the GPX
// layer uses when a feature somehow carries none.
'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
'line-width': 3,
'line-opacity': 0.85,
// Dashed, so the recording is distinguishable from the planned
// route without relying on colour alone.
'line-dasharray': [2, 1.6],
},
},
anchor,
)
}
draw()
map.on('style.load', draw)
return () => {
map.off('style.load', draw)
for (const layer of [LINE_LAYER, CASING_LAYER]) {
if (map.getLayer(layer)) map.removeLayer(layer)
}
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
}
}, [map, ready, track, selectedDate, beforeId])
}