From bd347f553bf914f5fed075b6c9e74c75d1e1d1e5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 16 Sep 2026 22:22:46 +0000 Subject: [PATCH] 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. --- .../components/Map/MapLayerSwitcher.test.tsx | 17 ++- .../src/components/Map/MapLayerSwitcher.tsx | 19 ++- client/src/components/Map/MapView.tsx | 4 +- client/src/components/Map/MapViewGL.test.tsx | 133 ++++++++++++++++- client/src/components/Map/MapViewGL.tsx | 56 ++++--- .../src/mobile/screens/trip/map/MMapArea.tsx | 69 ++++++--- .../tests/unit/mobile/trip/MMapArea.test.tsx | 139 +++++++++++++++++- 7 files changed, 383 insertions(+), 54 deletions(-) diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index 5c66c9977..c4579c3d9 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -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( {}} />) + 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) + }) }) diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index f9700d8dd..3dd3fb8aa 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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' diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 770d14589..6d63ec97a 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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. */} -
+
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index e6258e4e9..bd2ef5379 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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)) }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index f989434c0..48f8d5001 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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. */}
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) {
)} - {/* 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 && ( -
+
)} {/* 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) && (
{!planner.roadtripActive && planner.overviewActive && ( diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx index c36c91a01..a49924a69 100644 --- a/client/tests/unit/mobile/trip/MMapArea.test.tsx +++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx @@ -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, + /** The traveller's road trip preferences, read per trip by the area. */ + prefs: {} as Record, /** 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) => unknown) => select(mocks.prefs), +})) + import MMapArea from '../../../../src/mobile/screens/trip/map/MMapArea' const COMPASS: CompassMap = { @@ -50,8 +59,52 @@ function renderArea(shellOver: Partial = {}, 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) +} + +/** 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(), pois: [], loadingKeys: new Set(), errorKeys: new Set(), 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[]) { + 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() + 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() + + 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() + 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() + + 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() + 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() + 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() + expect(mocks.props.places).toContain(renamed) + + const allDays = { ...edited, selectedDayId: null } + rerender() + 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() + expect(mocks.props.accessLines).toBeUndefined() + expect(mocks.props.focusPoints).toBeUndefined() + expect(mocks.props.places).toBe(allDays.mapPlaces) + }) })