mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user