Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale

This commit is contained in:
Maurice
2026-10-15 22:25:34 +02:00
parent 485e360c64
commit a0884685ef
7 changed files with 230 additions and 156 deletions
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+5 -71
View File
@@ -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 }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
))}
{/* 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 }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* 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 }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: '#000', weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => 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 && (
<>
<Polyline
key={`gpx-selected-${selectedTrack.place.id}-casing`}
positions={selectedTrack.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${selectedTrack.place.id}`}
positions={selectedTrack.coords}
pathOptions={{ color: selectedTrack.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
)
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 */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
{selectedTrackLine}
<TrackLines tracks={gpxTracks} casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined} selectRef={markerClickRef} />
<SelectedTrackLine tracks={gpxTracks} selectedPlaceId={selectedPlaceId} />
{routeProfileFocus && (
<CircleMarker
+6 -78
View File
@@ -41,7 +41,7 @@ import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor, TRACK_CASING_COLOR } from './trackColors'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
@@ -66,9 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
const GPX_SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing'
const GPX_SELECTED_LAYER_ID = 'trip-gpx-selected'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
@@ -1347,58 +1344,8 @@ export function MapViewGL({
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Casing under the tracks that carry a picked colour (#776) — keeps them
// 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: 'trip-gpx',
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: 'trip-gpx',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// PR1-08: the picked tour's track on top, wider with a light rim, as the Leaflet map draws
// it. The filter follows the selection; until something is picked it matches no track.
map.addLayer({
id: GPX_SELECTED_CASING_LAYER_ID,
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'place_id'], -1],
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: GPX_SELECTED_LAYER_ID,
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'place_id'], -1],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#3b82f6'], 'line-width': 5.5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// Invisible fat line that catches the click — 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: 'trip-gpx',
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
if (!hasGpxTrackLayers(map)) {
addGpxTrackLayers(map)
const selectTrack = (e: any) => { // 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-
+109
View File
@@ -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 }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={casingPane}
pathOptions={{
color: TRACK_CASING_COLOR,
weight: 6.5,
opacity: cased ? 0.7 : 0,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* 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 }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* 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 }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => 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 (
<>
<Polyline
key={`gpx-selected-${track.place.id}-casing`}
positions={track.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${track.place.id}`}
positions={track.coords}
pathOptions={{ color: track.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
);
}
+103
View File
@@ -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);
}
}
@@ -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}
<span className="font-geist text-[0.6875rem] opacity-60">{count}</span>
<span className="font-geist text-caption opacity-60">{count}</span>
</button>
)
}
@@ -69,8 +69,8 @@ export default function MToursSelectionList({ planner, shell, filters, canAssign
<>
{facetRow}
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
<p className="font-geist text-[0.78125rem] text-m-muted">{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg">
<p className="font-geist text-caption text-m-muted">{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-caption font-semibold text-m-actfg">
{t('tours.filter.reset')}
</button>
</div>