diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json
index e7a876d95..da77f265e 100644
--- a/client/scripts/size-baseline.json
+++ b/client/scripts/size-baseline.json
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
- "src/components/Map/MapView.tsx": 1450,
+ "src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
- "src/components/Map/MapViewGL.tsx": 2414,
+ "src/components/Map/MapViewGL.tsx": 2372,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json
index 8542a7ee9..eb70e63a0 100644
--- a/client/scripts/theme-baseline.json
+++ b/client/scripts/theme-baseline.json
@@ -62,7 +62,7 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
- "components/Map/MapView.tsx": 9,
+ "components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index ee214d860..18a29172f 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -60,7 +60,8 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
-import { resolveTrackColor, hasManualTrackColor, TRACK_CASING_COLOR } from './trackColors'
+import { resolveTrackColor, hasManualTrackColor } from './trackColors'
+import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
@@ -1034,78 +1035,11 @@ export const MapView = memo(function MapView({
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
- // Keeps the click handler out of the polyline memo below: `handleMarkerClick`
+ // Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
const markerClickRef = useRef(handleMarkerClick)
markerClickRef.current = handleMarkerClick
- const gpxPolylines = useMemo(() => (
- <>
- {/* Casings live in their own pane below the lines. Leaflet stacks paths by
- insertion order, so drawing them inline would put the casing of a track
- added later — or of one just given a colour — on top of an earlier
- track's line. Always rendered, hidden via opacity when there is no
- colour, so toggling one never remounts the path. */}
- {gpxTracks.map(({ place, coords, cased }) => (
-
- ))}
- {/* pathOptions, not bare color/weight props: react-leaflet only calls
- setStyle when the pathOptions reference changes, so bare props would
- stick at their mount-time colour and a repaint would never arrive. */}
- {gpxTracks.map(({ place, coords, cased, color }) => (
-
- ))}
- {/* Invisible fat line on top so the track can actually be hit — 3.5px is
- not a target, least of all on touch, and the start markers cluster below
- zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
- default (markers do not), and the map's own click handler clears the
- selection this one just made. */}
- {gpxTracks.map(({ place, coords }) => (
- markerClickRef.current(place.id) }}
- />
- ))}
- >
- ), [gpxTracks, hasCasingPane])
-
- // PR1-08: the track of the picked tour stands out, wider and with a light rim, so the line and
- // the list's row read as one. Drawn on its own, so a selection never redraws every track.
- const selectedTrack = useMemo(
- () => (selectedPlaceId == null ? null : gpxTracks.find(track => track.place.id === selectedPlaceId) ?? null),
- [gpxTracks, selectedPlaceId],
- )
- const selectedTrackLine = selectedTrack && (
- <>
-
-
- >
- )
-
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
@@ -1335,8 +1269,8 @@ export const MapView = memo(function MapView({
{/* GPX imported route geometries */}
- {gpxPolylines}
- {selectedTrackLine}
+
+
{routeProfileFocus && (
{ // eslint-disable-line @typescript-eslint/no-explicit-any
// A click on a cluster bubble sitting over a track belongs to the
// cluster (zoom-to-expand), not to the line underneath it.
@@ -2095,32 +2042,13 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
- const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
- if (!src) return
- const features = places.flatMap(place => {
- const coords = parseRenderableRouteGeometry(place.route_geometry)
- if (!coords) return []
- return [{
- type: 'Feature' as const,
- properties: {
- color: resolveTrackColor(place),
- cased: hasManualTrackColor(place),
- place_id: place.id,
- },
- geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
- }]
- })
- src.setData({ type: 'FeatureCollection', features })
+ setGpxTracks(map, places)
}, [places, mapReady])
- // The picked tour's track stands out (PR1-08); -1 matches no track.
+ // The picked tour's track stands out (PR1-08).
useEffect(() => {
const map = mapRef.current
- if (!map || !mapReady || typeof map.getLayer !== 'function') return
- const filter = ['==', ['get', 'place_id'], selectedPlaceId ?? -1] as mapboxgl.FilterSpecification
- for (const id of [GPX_SELECTED_CASING_LAYER_ID, GPX_SELECTED_LAYER_ID]) {
- if (map.getLayer(id)) map.setFilter(id, filter)
- }
+ if (map && mapReady) selectGpxTrack(map, selectedPlaceId)
}, [selectedPlaceId, mapReady])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
diff --git a/client/src/components/Map/TrackLines.tsx b/client/src/components/Map/TrackLines.tsx
new file mode 100644
index 000000000..1b48d2afe
--- /dev/null
+++ b/client/src/components/Map/TrackLines.tsx
@@ -0,0 +1,109 @@
+import { memo, type RefObject } from 'react';
+import { Polyline } from 'react-leaflet';
+import type { Place } from '../../types';
+import { TRACK_CASING_COLOR } from './trackColors';
+
+/** A place's track as the Leaflet map draws it: its points, its colour, and whether it carries a rim. */
+export interface TrackLine {
+ place: Place;
+ coords: [number, number][];
+ cased: boolean;
+ color: string;
+}
+
+interface TrackLinesProps {
+ tracks: readonly TrackLine[];
+ /** The pane the rims go into, below the lines; none while the map has not made it yet. */
+ casingPane?: string;
+ /** Read on click, so a new selection handler never redraws every track. */
+ selectRef: RefObject<(placeId: number) => void>;
+}
+
+/**
+ * Every track of the map: the rims, the lines and an invisible fat line on top
+ * that takes the click. Memoised on the tracks alone; a selection changes
+ * nothing here, the picked track is drawn by {@link SelectedTrackLine}.
+ */
+export const TrackLines = memo(function TrackLines({ tracks, casingPane, selectRef }: TrackLinesProps) {
+ return (
+ <>
+ {/* Casings live in their own pane below the lines. Leaflet stacks paths by
+ insertion order, so drawing them inline would put the casing of a track
+ added later — or of one just given a colour — on top of an earlier
+ track's line. Always rendered, hidden via opacity when there is no
+ colour, so toggling one never remounts the path. */}
+ {tracks.map(({ place, coords, cased }) => (
+
+ ))}
+ {/* pathOptions, not bare color/weight props: react-leaflet only calls
+ setStyle when the pathOptions reference changes, so bare props would
+ stick at their mount-time colour and a repaint would never arrive. */}
+ {tracks.map(({ place, coords, cased, color }) => (
+
+ ))}
+ {/* Invisible fat line on top so the track can actually be hit — 3.5px is
+ not a target, least of all on touch, and the start markers cluster below
+ zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
+ default (markers do not), and the map's own click handler clears the
+ selection this one just made. */}
+ {tracks.map(({ place, coords }) => (
+ selectRef.current?.(place.id) }}
+ />
+ ))}
+ >
+ );
+});
+
+/**
+ * PR1-08: the track of the picked tour, wider and with a light rim on top of
+ * the others, so the line and the list's row read as one. It takes no click:
+ * the hit line below still does.
+ */
+export function SelectedTrackLine({
+ tracks,
+ selectedPlaceId,
+}: {
+ tracks: readonly TrackLine[];
+ selectedPlaceId: number | null;
+}) {
+ const track = selectedPlaceId == null ? null : tracks.find((item) => item.place.id === selectedPlaceId);
+ if (!track) return null;
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/client/src/components/Map/gpxTrackLayers.ts b/client/src/components/Map/gpxTrackLayers.ts
new file mode 100644
index 000000000..463c507e0
--- /dev/null
+++ b/client/src/components/Map/gpxTrackLayers.ts
@@ -0,0 +1,103 @@
+import { TRACK_COLOR_FALLBACK } from '@trek/shared';
+import type mapboxgl from 'mapbox-gl';
+import type { Place } from '../../types';
+import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
+import { hasManualTrackColor, resolveTrackColor, TRACK_CASING_COLOR } from './trackColors';
+
+const SOURCE_ID = 'trip-gpx';
+/** The invisible fat line that takes a click on a track. */
+export const GPX_HIT_LAYER_ID = 'trip-gpx-hit';
+const SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing';
+const SELECTED_LAYER_ID = 'trip-gpx-selected';
+
+const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
+const NO_TRACK = ['==', ['get', 'place_id'], -1] as mapboxgl.FilterSpecification;
+
+/**
+ * The tracks of the vector map, as the Leaflet map draws them (TrackLines): a
+ * rim under the tracks that carry a picked colour, the lines, the picked
+ * track on top wider with a light rim (PR1-08), and an invisible fat line that
+ * takes the click. Added once with the style; whoever adds it wires the click.
+ */
+export function addGpxTrackLayers(map: mapboxgl.Map): void {
+ map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
+ // Casing under the tracks that carry a picked colour (#776): legible on satellite and dark styles.
+ // Untouched tracks are filtered out, so they look exactly as they did before.
+ map.addLayer({
+ id: 'trip-gpx-casing',
+ type: 'line',
+ source: SOURCE_ID,
+ filter: ['==', ['get', 'cased'], true],
+ paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 6.5, 'line-opacity': 0.7 },
+ layout: ROUND,
+ });
+ map.addLayer({
+ id: 'trip-gpx-line',
+ type: 'line',
+ source: SOURCE_ID,
+ paint: {
+ 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK],
+ 'line-width': 3.5,
+ 'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
+ },
+ layout: ROUND,
+ });
+ // The filter follows the selection (selectGpxTrack); until something is picked it matches no track.
+ map.addLayer({
+ id: SELECTED_CASING_LAYER_ID,
+ type: 'line',
+ source: SOURCE_ID,
+ filter: NO_TRACK,
+ paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
+ layout: ROUND,
+ });
+ map.addLayer({
+ id: SELECTED_LAYER_ID,
+ type: 'line',
+ source: SOURCE_ID,
+ filter: NO_TRACK,
+ paint: { 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK], 'line-width': 5.5 },
+ layout: ROUND,
+ });
+ // 3.5px is not a target, and the start markers cluster below zoom 11, so without this a track
+ // is unreachable at the very zoom where you compare walks side by side.
+ map.addLayer({
+ id: GPX_HIT_LAYER_ID,
+ type: 'line',
+ source: SOURCE_ID,
+ paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 14, 'line-opacity': 0 },
+ layout: ROUND,
+ });
+}
+
+/** Whether the track layers are there yet (they come with the style). */
+export function hasGpxTrackLayers(map: mapboxgl.Map): boolean {
+ return !!map.getSource(SOURCE_ID);
+}
+
+/** The places' tracks into the source, each with its colour and whether it carries a rim. */
+export function setGpxTracks(map: mapboxgl.Map, places: readonly Place[]): void {
+ const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
+ if (!src) return;
+ const features = places.flatMap((place) => {
+ const coords = parseRenderableRouteGeometry(place.route_geometry);
+ if (!coords) return [];
+ return [
+ {
+ type: 'Feature' as const,
+ properties: { color: resolveTrackColor(place), cased: hasManualTrackColor(place), place_id: place.id },
+ geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
+ },
+ ];
+ });
+ src.setData({ type: 'FeatureCollection', features });
+}
+
+/** PR1-08: the picked place's track stands out; none picked, none does. */
+export function selectGpxTrack(map: mapboxgl.Map, placeId: number | null): void {
+ if (typeof map.getLayer !== 'function') return;
+ const filter = ['==', ['get', 'place_id'], placeId ?? -1] as mapboxgl.FilterSpecification;
+ for (const id of [SELECTED_CASING_LAYER_ID, SELECTED_LAYER_ID]) {
+ if (map.getLayer(id)) map.setFilter(id, filter);
+ }
+}
diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx
index abb3b6a8a..b728dd438 100644
--- a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx
+++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx
@@ -22,12 +22,12 @@ function FacetChip({ active, label, count, onClick }: { active: boolean; label:
type="button"
onClick={onClick}
aria-pressed={active}
- className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[0.75rem] font-semibold ${
+ className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-caption font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
}`}
>
{label}
- {count}
+ {count}
)
}
@@ -69,8 +69,8 @@ export default function MToursSelectionList({ planner, shell, filters, canAssign
<>
{facetRow}