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}
-

{t('tours.filter.empty')}

-