mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature property filled. Leaflet needed more work: the GPX polyline passed its style as bare color/weight props, and react-leaflet only calls setStyle when the pathOptions reference changes — a recoloured track would have kept its mount-time colour until the next reload. Every other vector layer in here already uses pathOptions. A track with a colour gets a white casing so it survives satellite imagery and dark basemaps. The casings live in their own pane below the lines: Leaflet stacks paths in insertion order, so drawn inline they end up over the line of a track that was added earlier. They are always mounted and hidden by opacity, otherwise giving a track a colour remounts the path into the wrong position. Tracks are clickable now — 3.5px is not a target, so the hit area is a transparent 14px line on top. bubblingMouseEvents is off on it: paths bubble to the map by default (markers do not), and the map's own click handler would clear the selection the track just made. The GL side needs the mirror image of that, a guard so a click on a cluster bubble over a track still belongs to the cluster.
This commit is contained in:
@@ -15,6 +15,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import type { Reservation, RouteVia } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
|
||||
@@ -338,6 +339,28 @@ function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZo
|
||||
return null
|
||||
}
|
||||
|
||||
const TRACK_CASING_PANE = 'trek-track-casing'
|
||||
|
||||
/**
|
||||
* Pane that holds the white casing under GPX tracks (#776). Leaflet stacks paths
|
||||
* in insertion order, so without a pane of its own a casing mounted later — a
|
||||
* newly imported track, or one that just got a colour — would paint over an
|
||||
* earlier track's line. Pane support is an optimization: a renderer without the
|
||||
* pane API still draws everything, just in insertion order.
|
||||
*/
|
||||
function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (typeof map.getPane !== 'function' || typeof map.createPane !== 'function') return
|
||||
if (!map.getPane(TRACK_CASING_PANE)) {
|
||||
const pane = map.createPane(TRACK_CASING_PANE)
|
||||
if (pane) pane.style.zIndex = '398' // under overlayPane (400) and the plugin pane (399)
|
||||
}
|
||||
onReady(true)
|
||||
}, [map, onReady])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
@@ -574,6 +597,8 @@ export const MapView = memo(function MapView({
|
||||
return () => window.removeEventListener('scroll', clear, true)
|
||||
}, [hoveredPlace])
|
||||
|
||||
const [hasCasingPane, setHasCasingPane] = useState(false)
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
// Clear the hover card right away: the recenter that follows moves the
|
||||
// marker out from under the cursor, so no mouseout will ever fire (#1404).
|
||||
@@ -686,22 +711,66 @@ export const MapView = memo(function MapView({
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
// Parsing track geometry is the expensive part (tracks run to tens of thousands
|
||||
// of points), so it hangs off `places` alone — a selection change must not
|
||||
// re-parse every track.
|
||||
const gpxTracks = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
// Keeps the click handler out of the polyline memo below: `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: '#ffffff', 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}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
|
||||
interactive={false}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
))}
|
||||
{/* 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])
|
||||
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
@@ -776,6 +845,7 @@ export const MapView = memo(function MapView({
|
||||
] : [])}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
<ReservationOverlay
|
||||
|
||||
Reference in New Issue
Block a user