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