fix(maps): the satellite switch answers on the phone, and the compass stops hiding under it

Two faults behind one round button on the phone map.

The base layer switcher that both engines draw in the bottom left corner
sat on top of the GL compass, which started 8px to its left and ran on
under the frosted shell, so it read as a second button showing through.
The compass now stands beside the switcher, placed off the switcher's own
exported inset and size so the two cannot drift apart again.

Satellite never drew on GL because applySatellite gave up whenever
isStyleLoaded() was false and only retried on styledata. That flag is false
while any source has tiles or a setData in flight, which on a phone is the
usual state, and styledata does not fire when those finish. On the road
trip stage every tap met a busy style, because the settings write
re-rendered the shell and handed the map fresh stage arrays. The guard is
gone (the pass only runs after load, and addSource/addLayer only refuse a
style document that is not in yet), an existing layer just flips its
visibility, and a source left without its layer still gets the layer.

The stage data in MMapArea is memoised, so a store write no longer refits
the camera or sets the route, access and place sources again.
This commit is contained in:
Maurice
2026-09-16 22:22:46 +00:00
parent aaff8f9db4
commit bd347f553b
7 changed files with 383 additions and 54 deletions
@@ -1,8 +1,8 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-005
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -41,4 +41,17 @@ describe('MapLayerSwitcher', () => {
fireEvent.mouseLeave(button)
expect(button.style.background).toBe('transparent')
})
it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
const shell = button.parentElement as HTMLElement
// The phone's compass stands beside the switcher at inset + size + gap. If the
// markup grew without the constant, the compass would slide back under the shell.
expect(shell.style.padding).toBe('4px')
expect(button.style.width).toBe('34px')
expect(button.style.height).toBe('34px')
expect(parseFloat(button.style.width) + 2 * parseFloat(shell.style.padding)).toBe(MAP_ROUND_CONTROL_SIZE)
})
})
+16 -3
View File
@@ -5,9 +5,22 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
// Round base-layer switcher for the Leaflet planner map (default street tiles ↔
// satellite). Same frosted shell as MapCompassPill so it lines up with the other
// map controls; the icon shows the layer it switches to.
// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
// Both map engines place the switcher with the inset, so they cannot drift apart either.
/** Distance from the map's left edge (plus any side panel) to the switcher. */
export const MAP_LAYER_SWITCHER_INSET = 20
/**
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
* them to this number.
*/
export const MAP_ROUND_CONTROL_SIZE = 42
// Round base-layer switcher for both planner maps, Leaflet and GL (default street
// tiles ↔ satellite). Same frosted shell as MapCompassPill so it lines up with the
// other map controls; the icon shows the layer it switches to.
export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onToggle: () => void }) {
const { t } = useTranslation()
const isSatellite = active === 'satellite'
+2 -2
View File
@@ -63,7 +63,7 @@ import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -1242,7 +1242,7 @@ export const MapView = memo(function MapView({
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
+132 -1
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render } from '../../../tests/helpers/render'
import { act, waitFor } from '@testing-library/react'
import { act, fireEvent, waitFor } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { server } from '../../../tests/helpers/msw/server'
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import maplibregl from 'maplibre-gl'
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import type { PluginMapLayer, PluginMapLayerFeature, PluginMapMarker } from '../../api/client'
import type { Poi } from './poiCategories'
@@ -254,6 +255,12 @@ beforeEach(() => {
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'))
glMap.getBearing.mockReturnValue(0)
glMap.queryTerrainElevation.mockReturnValue(null)
// A busy style, a refused source and a custom layer list are each set by one test
// for the case it covers. Put back here, or every test after it would silently run
// against a map that never settles.
glMap.isStyleLoaded.mockReturnValue(true)
glMap.getStyle.mockReturnValue({ layers: [] })
glMap.addSource.mockImplementation(() => undefined)
glMarkers.clear()
// clearAllMocks() wipes call history but keeps implementations, so anything a
// test overrides with mockReturnValue has to be put back here.
@@ -2065,6 +2072,130 @@ describe('MapViewGL', () => {
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
// A map is busy (isStyleLoaded() false) for as long as tiles or a setData are in flight,
// which on a phone is most of the time and on the road trip stage was every tap. The
// imagery used to wait for a settled style that `styledata` never announced.
it('FE-COMP-MAPVIEWGL-079: a stored satellite choice is drawn even while the map is still busy', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// Right after `load` the basemap tiles and the overlay sources are still loading.
glMap.isStyleLoaded.mockReturnValue(false)
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' }))
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-080: a tap on the switcher while a source update is in flight still shows the imagery', async () => {
loadOnAttach()
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
const { getByRole } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
glMap.isStyleLoaded.mockReturnValue(false)
fireEvent.click(getByRole('button', { name: 'Switch to satellite view' }))
await flushFrames()
expect(useSettingsStore.getState().settings.map_base_layer).toBe('satellite')
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' }))
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-081: imagery that is already on the map flips visibility at once, busy or not', async () => {
loadOnAttach()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
const { getByRole } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
glMap.setLayoutProperty.mockClear()
glMap.isStyleLoaded.mockReturnValue(false)
fireEvent.click(getByRole('button', { name: 'Switch to satellite view' }))
await flushFrames()
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'visible')
// Flipping it is all a second tap takes: nothing is built a second time.
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything())
})
it('FE-COMP-MAPVIEWGL-082: a style that is not in yet keeps the plain basemap until styledata brings it', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// What both engines do when the style document itself has not loaded.
let styleIn = false
glMap.addSource.mockImplementation((id: string) => {
if (id === 'trip-satellite' && !styleIn) throw new Error('Style is not done loading.')
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything())
styleIn = true
const subscribed = glMap.on.mock.calls.filter(c => c[0] === 'styledata')
const styledata = subscribed[subscribed.length - 1]?.[1] as (() => void) | undefined
expect(styledata).toBeDefined()
act(() => { styledata!() })
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-083: the switcher sits the shared inset off the map edge, past any side panel', async () => {
const { getByRole, rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
// Button, then its frosted shell, then the positioned wrapper.
const wrapper = () => getByRole('button', { name: 'Switch to satellite view' }).parentElement!.parentElement!
// The phone's compass is placed off the same inset, so the two cannot drift apart.
expect(wrapper().style.left).toBe(`${MAP_LAYER_SWITCHER_INSET}px`)
rerender(<MapViewGL places={[]} fitKey={1} leftWidth={240} />)
await flushFrames()
expect(wrapper().style.left).toBe(`${240 + MAP_LAYER_SWITCHER_INSET}px`)
})
it('FE-COMP-MAPVIEWGL-084: a source left behind without its layer still gets the layer', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// An earlier pass that got the source in and then failed on the layer.
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
// Guarded on the source, the pass would have seen it and built nothing, for good.
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
+35 -21
View File
@@ -9,7 +9,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
@@ -277,15 +277,30 @@ function addPlaceClusterLayers(map: any): void {
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
* a no-op and only the visibility flips.
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
try {
if (!map.isStyleLoaded?.()) return
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(SATELLITE_SOURCE_ID)) {
if (!on) return // Nothing to build while it is switched off.
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
@@ -293,21 +308,18 @@ function applySatellite(map: any, on: boolean): void {
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
})
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
@@ -2281,7 +2293,9 @@ export function MapViewGL({
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
// and reloading into the other keeps the choice. `styledata` is subscribed because a
// basemap change rebuilds the style from scratch and takes the imagery with it.
// basemap change rebuilds the style from scratch and takes the imagery with it. It is
// the retry for a style that is not in yet, never for a busy one: it fires on style
// edits, not when tiles arrive, which is why applySatellite does not wait for those.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
@@ -2331,7 +2345,7 @@ export function MapViewGL({
card too, and used to send the pill to the top of the map for a corner it never
covers. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
+46 -23
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../components/Map/MapLayerSwitcher'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
@@ -16,6 +17,13 @@ import type { Poi } from '../../../../components/Map/poiCategories'
/** One array, so an explore set switched off does not move every pin on the stage. */
const NO_POIS: Poi[] = []
/**
* The compass stands one gap to the right of the base-layer switcher both engines draw
* in the bottom left corner. Worked out from the switcher's own numbers rather than
* written down as 70, so moving or resizing the switcher carries the compass along.
*/
const COMPASS_LEFT = MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8
/**
* Fullscreen map layer of the mobile trip screen (plan tab). Stays mounted for
* the whole plan-tab lifetime — the plan timeline / places browser overlays
@@ -26,10 +34,11 @@ const NO_POIS: Poi[] = []
* setting) with the full desktop feature set: clusters, photo/icon markers,
* day-order badges, dashed day route, transport overlays per booking, POI
* explore markers and long-press → add place. Only the floating chrome is
* mobile: the POI bar spans the full width below the day-chip rail, and the two
* round controls share the band just above the dock — the compass on the left,
* the map's built-in three-state locate button on the right, both riding the
* --bottom-nav-h contract the map already reads so they cannot drift apart.
* mobile: the POI bar spans the full width below the day-chip rail, and the round
* controls share the band just above the dock: the map's own base-layer switcher
* with the compass beside it on the left, the map's built-in three-state locate
* button on the right, all riding the --bottom-nav-h contract the map already reads
* so they cannot drift apart.
*
* Marker data honours the shared places category filter (#1541) because
* planner.mapPlaces is derived from tripStore's placesCategoryFilter — the
@@ -53,17 +62,30 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// through unconditionally while that tab is open, list half included: `focusPoints`
// going array → undefined → array is two dependency changes to React, and the
// second one would throw away whatever the traveller had panned to.
const stage = onStage ? stageOf(planner.roadtripRoutes.days, planner.selectedDayId) : null
const stageMap = onStage
? stageMapData(planner.roadtripRoutes, stage, !!dayColorsOn)
: null
//
// Memoised on what the stage is made of rather than rebuilt per render: the shell
// re-renders on every store write, a settings toggle included, and a fresh
// `focusPoints` array reframes the camera while fresh lines and places set their
// GeoJSON sources again. On the stage that also kept the GL style busy, which is how
// the satellite switch came to miss every tap there.
const stage = useMemo(
() => onStage ? stageOf(planner.roadtripRoutes.days, planner.selectedDayId) : null,
[onStage, planner.roadtripRoutes.days, planner.selectedDayId],
)
const stageMap = useMemo(
() => onStage ? stageMapData(planner.roadtripRoutes, stage, !!dayColorsOn) : null,
[onStage, planner.roadtripRoutes, stage, dayColorsOn],
)
// Only what the stage carries, so a trip's other 200 pins stay off a screen that
// is answering one question. Without a stage (the all-days view) every planned
// place comes back, which is what the drive looks like end to end.
const stagePlaces = stageMap && stage
? planner.mapPlaces.filter(p => stageMap.placeIds.has(p.id))
: planner.roadtripMapPlaces
const stagePlaces = useMemo(
() => stageMap && stage
? planner.mapPlaces.filter(p => stageMap.placeIds.has(p.id))
: planner.roadtripMapPlaces,
[stageMap, stage, planner.mapPlaces, planner.roadtripMapPlaces],
)
/**
* The pins, and they are not the same question on the two tabs.
@@ -169,23 +191,24 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
</div>
)}
{/* Compass — GL maps only (Leaflet can't rotate). Bottom-left, mirroring
the locate button's own `right: 12` off the same --bottom-nav-h, so the
two round controls always sit on one line. Leaflet has no compass but
puts its base-layer switcher in the same corner, so the band reads the
same either way. */}
{/* Compass, GL maps only (Leaflet cannot rotate). Both engines draw the base-layer
switcher in the bottom left corner, so the compass sits beside it rather than in
the corner: at `left-3` it started 8px left of the switcher and ran on under it,
reading as a second button showing through the frosted shell. Same
--bottom-nav-h band as the locate button's `right: 12`, so the round controls
still share one line. The left offset is inline because it is computed from the
switcher's own numbers. */}
{mapActive && glMap && (
<div className="pointer-events-none absolute left-3 z-[25]" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 12px)' }}>
<div className="pointer-events-none absolute z-[25]" style={{ left: COMPASS_LEFT, bottom: 'calc(var(--bottom-nav-h, 84px) + 12px)' }}>
<MapCompassPill map={glMap} />
</div>
)}
{/* Whole-trip overview (#1736): the stages stack above their toggle on the right,
clear of the round-controls band below it and of the compass and Leaflet's
base-layer switcher, which both sit in the bottom-LEFT corner. The offset is
Tailwind rather than inline because the compass band is identified by being
the one element with an inline --bottom-nav-h, and a second would make that
ambiguous. */}
clear of the round-controls band below it and of the base-layer switcher and
the compass, which both sit bottom left. The offset is Tailwind rather than
inline because the compass band is identified by being the one element with
an inline --bottom-nav-h, and a second would make that ambiguous. */}
{mapActive && !onStage && (!planner.roadtripActive || planner.dawarichEnabled) && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{!planner.roadtripActive && planner.overviewActive && (
+137 -2
View File
@@ -1,14 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildPlace } from '../../../helpers/factories'
import type { AccessSpur, RoadtripDay } from '@trek/shared/roadtrip'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../src/components/Map/MapLayerSwitcher'
import { useSettingsStore } from '../../../../src/store/settingsStore'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-014
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-018
const mocks = vi.hoisted(() => ({
poi: {} as Record<string, unknown>,
/** The traveller's road trip preferences, read per trip by the area. */
prefs: {} as Record<string, unknown>,
/** Handed to onMapReady, standing in for a GL map that can rotate. */
glMap: null as CompassMap | null,
/** Last props the area handed the renderer, so its callbacks can be fired. */
@@ -28,6 +33,10 @@ vi.mock('../../../../src/components/Map/usePoiExplore', () => ({
usePoiExplore: () => mocks.poi,
}))
vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (p: Record<string, unknown>) => unknown) => select(mocks.prefs),
}))
import MMapArea from '../../../../src/mobile/screens/trip/map/MMapArea'
const COMPASS: CompassMap = {
@@ -50,8 +59,52 @@ function renderArea(shellOver: Partial<MTripShellApi> = {}, plannerOver: Partial
const compassBand = (container: HTMLElement) =>
container.querySelector('[style*="--bottom-nav-h"]') as HTMLElement | null
/** One stage of a drive: two stops on day 1, one of them reached down a short spur. */
function stageDay(): RoadtripDay {
return {
dayId: 3, dayNumber: 1, date: '2026-05-01', title: null,
stops: [
{
assignmentId: 31, ownerDayId: 3, ownerIndex: 0, placeId: 11, name: 'Hamburg',
lat: 53.55, lng: 9.99, time: null, dwellMinutes: null,
legMode: null, incomingLegMode: null, stopType: null,
},
{
assignmentId: 32, ownerDayId: 3, ownerIndex: 1, placeId: 12, name: 'Lübeck',
lat: 53.87, lng: 10.69, time: null, dwellMinutes: null,
legMode: null, incomingLegMode: null, stopType: null,
},
],
legs: [], legVias: [[]], geometry: [], distance: 0, duration: 0,
schedule: { entries: [], warnings: [] }, driveWarnings: [], dayWarning: null,
} as unknown as RoadtripDay
}
/** The planner carrying that stage, plus a place from another day the stage leaves off. */
function stagePlanner(selectedDayId: number | null): TripPlanner {
const base = buildPlanner()
const spur: AccessSpur = { line: [[53.87, 10.69], [53.871, 10.692]], meters: 180, stopKey: '53.87000,10.69000,,' }
return buildPlanner({
selectedDayId,
mapPlaces: [buildPlace({ id: 11 }), buildPlace({ id: 12 }), buildPlace({ id: 99 })],
// Every planned place, which is what the all days view draws.
roadtripMapPlaces: [buildPlace({ id: 11 }), buildPlace({ id: 12 })],
roadtripRoutes: {
...base.roadtripRoutes,
days: [stageDay()],
lines: [[[53.55, 9.99], [53.87, 10.69]]],
lineDays: [1],
accessLines: [spur],
},
} as unknown as Partial<TripPlanner>)
}
/** What the area handed the renderer that a fresh copy would make it draw again. */
const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] as const
beforeEach(() => {
mocks.glMap = COMPASS
mocks.prefs = {}
mocks.poi = {
active: new Set<string>(), pois: [], loadingKeys: new Set<string>(), errorKeys: new Set<string>(),
moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
@@ -73,7 +126,10 @@ describe('MMapArea', () => {
// LocationButton hard-codes `right: 12` off the same variable, so matching
// the offset here is what keeps the two round controls on one line.
expect(compassBand(container)?.style.bottom).toBe('calc(var(--bottom-nav-h, 84px) + 12px)')
expect(compassBand(container)?.className).toContain('left-3')
// Beside the base-layer switcher both engines draw in the corner: its inset, its
// size and one gap. In the corner itself it lay under the switcher's frosted shell.
expect(compassBand(container)?.style.left).toBe(`${MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8}px`)
expect(compassBand(container)?.className).not.toContain('left-3')
})
it('FE-MOB-MAPAREA-003: the map layer floats those controls a dock gap above the dock', () => {
@@ -190,4 +246,83 @@ describe('MMapArea', () => {
// array is two dependency changes, and the second throws away the traveller's pan.
expect(Array.isArray(mocks.props.focusPoints)).toBe(true)
})
it('FE-MOB-MAPAREA-015: the compass never takes the base-layer switcher\'s slot, on either tab', () => {
for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial<MTripShellApi>[]) {
const { container, unmount } = renderArea(shellOver)
expect(parseFloat(compassBand(container)?.style.left ?? '0'))
.toBeGreaterThanOrEqual(MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE)
// With the renderer mocked, the compass is still the one element in this layer
// that sets --bottom-nav-h inline; its left offset joined the same style object.
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
unmount()
}
})
it('FE-MOB-MAPAREA-016: a re-render with nothing new on the stage hands the map the same drawing', () => {
const planner = stagePlanner(3)
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
const first = { ...mocks.props }
// The stage really is what is drawn, so the identities below are about something.
expect((first.places as Array<{ id: number }>).map(p => p.id)).toEqual([11, 12])
expect(first.route).toHaveLength(1)
expect(first.accessLines).toHaveLength(1)
expect(first.focusPoints).toEqual([[53.55, 9.99], [53.87, 10.69]])
// The shell re-renders on every store write, a satellite tap included. Fresh arrays
// here would refit the camera over the traveller's pan and set the map's sources
// again, which is also what kept the style too busy to draw the imagery.
rerender(<MMapArea planner={planner} shell={shell} />)
for (const key of DRAWN) expect(mocks.props[key]).toBe(first[key])
})
it('FE-MOB-MAPAREA-017: the all days view keeps its day colours between renders too', () => {
mocks.prefs = { roadtrip_day_colors: true }
const planner = stagePlanner(null)
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
const first = { ...mocks.props }
// One colour per line, worked out per call, so without the memo a new array each time.
expect(first.routeColors).toHaveLength(1)
// Still an array with no stage, which FE-MOB-MAPAREA-012 depends on.
expect(Array.isArray(first.focusPoints)).toBe(true)
rerender(<MMapArea planner={planner} shell={shell} />)
for (const key of DRAWN) expect(mocks.props[key]).toBe(first[key])
})
it('FE-MOB-MAPAREA-018: what is kept still follows the stage, the day colours and the places', () => {
const planner = stagePlanner(3)
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.routeColors).toBeUndefined()
// Kept on its inputs, not frozen: each one below has to reach the map, or a memo
// missing it would leave the traveller looking at a stage they already left.
mocks.prefs = { roadtrip_day_colors: true }
rerender(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.routeColors).toHaveLength(1)
const renamed = buildPlace({ id: 12, name: 'Travemünde' })
const edited = { ...planner, mapPlaces: [planner.mapPlaces[0], renamed, planner.mapPlaces[2]] }
rerender(<MMapArea planner={edited} shell={shell} />)
expect(mocks.props.places).toContain(renamed)
const allDays = { ...edited, selectedDayId: null }
rerender(<MMapArea planner={allDays} shell={shell} />)
expect(mocks.props.places).toBe(allDays.roadtripMapPlaces)
expect(mocks.props.focusPoints).toEqual([])
// Off the tab the stage hands nothing through, so the plan tab frames itself again.
rerender(<MMapArea planner={allDays} shell={{ ...shell, trTab: 'plan' }} />)
expect(mocks.props.accessLines).toBeUndefined()
expect(mocks.props.focusPoints).toBeUndefined()
expect(mocks.props.places).toBe(allDays.mapPlaces)
})
})