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:
Maurice
2026-07-27 17:35:41 +02:00
committed by Maurice
parent 1b5317a817
commit fcabb4c645
6 changed files with 288 additions and 12 deletions
@@ -676,4 +676,38 @@ describe('MapViewGL', () => {
expect(glMap.fitBounds.mock.calls.length).toBe(afterDayFit)
})
// ── Track colours (#776) ──────────────────────────────────────────────────
// The gpx layer paints from a per-feature property, so what matters is the
// GeoJSON handed to setData. getSource returns null by default in this suite,
// which makes the effect bail out — the source has to be stubbed per test.
const gpxFeatures = () => {
const gpxSource = { setData: vi.fn() }
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
return () => {
const calls = gpxSource.setData.mock.calls
return calls.length ? (calls[calls.length - 1][0]?.features ?? []) : []
}
}
it('FE-COMP-MAPVIEWGL-021: a picked route_color reaches the feature, casing on', async () => {
const features = gpxFeatures()
const places = [buildMapPlace({
id: 5, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
category_color: '#00ff00', route_color: '#e11d48',
})]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
expect(features()[0]?.properties).toMatchObject({ color: '#e11d48', cased: true, place_id: 5 })
})
it('FE-COMP-MAPVIEWGL-022: without a pick it stays on the category colour and skips the casing', async () => {
const features = gpxFeatures()
const places = [buildMapPlace({
id: 6, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
category_color: '#00ff00',
})]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
expect(features()[0]?.properties).toMatchObject({ color: '#00ff00', cased: false })
})
})