diff --git a/client/src/components/Map/MapAttributionToggle.tsx b/client/src/components/Map/MapAttributionToggle.tsx new file mode 100644 index 000000000..b4da0f194 --- /dev/null +++ b/client/src/components/Map/MapAttributionToggle.tsx @@ -0,0 +1,57 @@ +import { Info } from 'lucide-react' +import { MAP_CONTROL_SHADOW } from './mapControlShadow' +import { useTranslation } from '../../i18n' + +interface MapAttributionToggleProps { + open: boolean + onToggle: () => void + /** Same band as the locate button, so the two sit on one line. */ + bottomOffset: string +} + +/** + * The little (i) that stands in for the credit on a phone. + * + * Both GL engines ship one of these: below 640px they collapse their attribution to a + * button and open it on a tap. Leaflet has no such mode, so on a screen the map fills, + * its credit is a white two-line block parked in the middle of the picture. This is the + * missing half of that pattern, and it is a React button rather than surgery on + * Leaflet's own container because Leaflet rewrites that container's markup from scratch + * whenever a layer with an attribution is added or removed. + * + * Nothing is hidden that cannot be read: one tap shows the full credit, every link in + * it included, which is the same bargain the GL engines strike. + */ +export default function MapAttributionToggle({ open, onToggle, bottomOffset }: MapAttributionToggleProps) { + const { t } = useTranslation() + const label = t('map.attribution') + return ( + + ) +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 6e36b8e36..770d14589 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -34,6 +34,7 @@ function alternativeLabelIcon(label: string, note: string, background: string, a }) } import L from 'leaflet' +import MapAttributionToggle from './MapAttributionToggle' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' @@ -978,6 +979,16 @@ export const MapView = memo(function MapView({ ? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 84px) + 12px)' + /** + * Whether the credit is showing, on a phone where it starts as the (i). + * + * Held here rather than pushed into Leaflet's own container: Leaflet rewrites that + * container's markup from scratch whenever a layer with an attribution is added or + * removed, so anything injected into it dies on the next basemap switch. A class on + * the wrapper survives that, and the CSS lives with the rest of the mobile chrome. + */ + const [attribOpen, setAttribOpen] = useState(false) + const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' const updateSetting = useSettingsStore(s => s.updateSetting) const isSatellite = baseLayer === 'satellite' @@ -996,7 +1007,7 @@ export const MapView = memo(function MapView({ return ( <> -
+
} + {/* The credit as the little (i), which is what both GL engines do on a narrow map + and Leaflet has no mode for. */} + {isMobile && ( + setAttribOpen(v => !v)} + bottomOffset={locationButtonBottom} + /> + )} {/* 20px off the sidebar, not 12: the pill is round and frosted, so at the smaller gap its shadow ran into the sidebar edge and the two read as one surface. */} diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 059f295b9..e6258e4e9 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -2066,3 +2066,48 @@ describe('MapViewGL', () => { expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none') }) }) +describe('MapViewGL attribution', () => { + const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) + + /** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */ + function compactAttribution(container: HTMLElement): HTMLElement { + const el = document.createElement('details') + el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show' + el.setAttribute('open', '') + // Inside the map container itself, where maplibre puts its controls. + container.querySelectorAll('div')[1]?.appendChild(el) + return el + } + + it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => { + const { container } = render() + await flushFrames() + const attrib = compactAttribution(container) + + const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined + expect(idle).toBeDefined() + act(() => { idle!() }) + + // Both engines open theirs on a phone and only tuck it away on the first drag, so + // the first thing on a screen the map fills is a two-line band across the bottom. + expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false) + expect(attrib.hasAttribute('open')).toBe(false) + // Still there, still one tap away: nothing about the credit is removed. + expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true) + }) + + it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => { + const { container } = render() + await flushFrames() + const el = document.createElement('details') + // No `compact-show`, which is what a map over 640px reports. + el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib' + el.setAttribute('open', '') + container.querySelectorAll('div')[1]?.appendChild(el) + + const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined + act(() => { idle!() }) + + expect(el.hasAttribute('open')).toBe(true) + }) +}) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index f21dddf04..f989434c0 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1170,6 +1170,23 @@ export function MapViewGL({ maxWidth: '240px', className: 'trek-map-popup', }) + /* + * The credit starts as the little (i), not as a ribbon across the map. + * + * Both engines collapse their attribution below 640px, and both then open it + * anyway and wait for a drag before tucking it away. On a phone, where the map IS + * the screen, that means the first thing anybody sees is a two-line grey band + * over the bottom of it. Dragging is exactly what maplibre does on its own + * `drag` handler; this only starts where that would have ended up, so the credit + * is one tap away and nothing about it is removed. + */ + map.once('idle', () => { + const attrib = containerRef.current?.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show') + ?? containerRef.current?.querySelector('.mapboxgl-ctrl-attrib.mapboxgl-compact-show') + attrib?.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show') + attrib?.removeAttribute('open') + }) + // Hand the map out so the trip planner can render its own compass pill next to // the POI pill (a custom round control instead of Mapbox's default top-right one). onMapReadyRef.current?.(map) diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index dabd0fca5..505b5f82c 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -12,6 +12,7 @@ import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' import { serviceColor } from './roadtripModel' +import { alongLabel, offRouteLabel } from './corridorSearchModel' import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds' import { FS } from './typeScale' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' @@ -154,14 +155,11 @@ function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => vo row still wraps cleanly at a narrow width because nothing has to break around a separator. */}
- - {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - - - {poi.alongKm < 0.5 - ? t('roadtrip.poi.atStart') - : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} - + {/* Written by the shared model rather than here: the phone says the same two + things in one line, and the wording of a hit should not depend on which + shell is reading it. */} + {offRouteLabel(poi.offRouteKm, distanceUnit, t)} + {alongLabel(poi.alongKm, distanceUnit, t)} {/* What the charger offers, where OSM says. Socket names are proper nouns and stay as they are; the numbers around them are what decides whether a car can use it at all. A station that says nothing shows nothing rather than a row @@ -455,7 +453,9 @@ export default function RoadtripCorridorPanel({
+ )} + {trTab === 'dateien' && (
} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} /> @@ -581,13 +679,16 @@ export default function MTripShell({ ) })()} - {trTab === 'plan' ? ( + {/* Same slot, same markup, same icons in both map tabs, so the switch does + not move or change shape when the tab does. The two views stay separate + states: coupling them would drag one tab into the other's half. */} + {MAP_TABS.has(trTab) ? ( - {view === 'plan' ? : } + {mapFront ? : } ) : ( diff --git a/client/src/mobile/screens/trip/dockTabs.test.ts b/client/src/mobile/screens/trip/dockTabs.test.ts new file mode 100644 index 000000000..27ce77f51 --- /dev/null +++ b/client/src/mobile/screens/trip/dockTabs.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { DOCK_CAP, DOCK_PRIORITY, dockTabIds, pickDockTabs } from './dockTabs' + +// FE-MOB-DOCK-001 to FE-MOB-DOCK-008 + +/** Alles, was ein voll ausgestatteter Trip anbieten kann, in der Reihenfolge des Docks. */ +const ALL = ['plan', 'roadtrip', 'transports', 'buchungen', 'finanzplan', 'listen'] + +const ids = (enabled: string[]) => pickDockTabs(new Set(enabled)).map((tab) => tab.id) + +describe('pickDockTabs', () => { + it('FE-MOB-DOCK-001: five seats beside More, so the packing list gives way to the drive', () => { + // Ein sechster Kreis laesst 3px Abstand, auf die kein Daumen zielt. + expect(DOCK_CAP).toBe(5) + expect(ids(ALL)).toEqual(['plan', 'roadtrip', 'transports', 'buchungen', 'finanzplan']) + expect(ids(ALL)).not.toContain('listen') + }) + + it('FE-MOB-DOCK-002: without the road trip the packing list has its seat back', () => { + expect(ids(ALL.filter((id) => id !== 'roadtrip'))).toEqual([ + 'plan', + 'transports', + 'buchungen', + 'finanzplan', + 'listen', + ]) + }) + + it('FE-MOB-DOCK-003: priority decides who is cut, not the order the tabs were enabled', () => { + const scrambled = ['listen', 'finanzplan', 'roadtrip', 'plan'] + expect(ids(scrambled)).toEqual(['plan', 'roadtrip', 'finanzplan', 'listen']) + }) + + it('FE-MOB-DOCK-004: a trip with few sections gets few seats, never padded', () => { + expect(ids(['plan', 'listen'])).toEqual(['plan', 'listen']) + expect(ids([])).toEqual([]) + }) + + it('FE-MOB-DOCK-005: a plugin tab never takes a seat, it belongs in the More sheet', () => { + expect(ids(['plugin:trip-todos', 'plan'])).toEqual(['plan']) + expect(ids(['plugin:trip-todos', 'plugin:weather'])).toEqual([]) + }) + + it('FE-MOB-DOCK-006: a seat is the priority entry itself, icon and all', () => { + // Das Dock zeichnet den Kreis aus tab.icon; eine umgebaute Kopie ohne Icon bliebe leer. + expect(pickDockTabs(new Set(ALL))).toEqual(DOCK_PRIORITY.slice(0, DOCK_CAP)) + expect(pickDockTabs(new Set(ALL))[1]).toBe(DOCK_PRIORITY[1]) + }) +}) + +describe('dockTabIds', () => { + it('FE-MOB-DOCK-007: names exactly the ids that got a seat, so the More sheet leaves them out', () => { + const enabled = new Set(ALL) + expect([...dockTabIds(enabled)]).toEqual(pickDockTabs(enabled).map((tab) => tab.id)) + expect(dockTabIds(enabled).has('listen')).toBe(false) + expect(dockTabIds(enabled).has('roadtrip')).toBe(true) + }) + + it('FE-MOB-DOCK-008: never names more than the dock can hold', () => { + expect(dockTabIds(new Set([...ALL, 'plugin:trip-todos'])).size).toBe(DOCK_CAP) + }) +}) diff --git a/client/src/mobile/screens/trip/dockTabs.ts b/client/src/mobile/screens/trip/dockTabs.ts new file mode 100644 index 000000000..8b30b0add --- /dev/null +++ b/client/src/mobile/screens/trip/dockTabs.ts @@ -0,0 +1,45 @@ +import { Map as MapIcon, TrainFront, Ticket, Wallet, PackageCheck, Route } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +/** + * Which trip sections get a seat in the phone dock, and in which order. + * + * The dock is a fixed 62px pill with `justify-around` and no overflow, so seats are + * a real budget, not a preference: at 375px it measures 343px, 315px of it usable, + * and every circle is 42px. Five sections plus More leave 9px between them. A sixth + * takes that down to 3px, which is not a gap any thumb can aim at. + * + * Hence DOCK_CAP. Everything past it surfaces as a tile in the More sheet, which is + * where files, collaboration and plugin tabs have always lived. + * + * This list is the ONE source for both sides. It used to be two hand-kept copies + * (one here, one in MMehrSheet), and extending only one of them showed the same tab + * in the dock AND as a tile. + */ +export const DOCK_PRIORITY: { id: string; icon: LucideIcon }[] = [ + { id: 'plan', icon: MapIcon }, + { id: 'roadtrip', icon: Route }, + { id: 'transports', icon: TrainFront }, + { id: 'buchungen', icon: Ticket }, + { id: 'finanzplan', icon: Wallet }, + { id: 'listen', icon: PackageCheck }, +] + +/** Seats beside the More button. */ +export const DOCK_CAP = 5 + +/** + * The dock's seats for a trip, given the tabs that are actually enabled. + * + * Priority order decides who is cut, not enabled order: with the road trip addon on, + * the packing list moves into the More sheet rather than the drive never getting a + * seat. Turning the addon off puts it straight back. + */ +export function pickDockTabs(enabledTabIds: ReadonlySet): { id: string; icon: LucideIcon }[] { + return DOCK_PRIORITY.filter(tab => enabledTabIds.has(tab.id)).slice(0, DOCK_CAP) +} + +/** The ids that got a seat, for the More sheet to leave out. */ +export function dockTabIds(enabledTabIds: ReadonlySet): Set { + return new Set(pickDockTabs(enabledTabIds).map(tab => tab.id)) +} diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx index a8c695249..3810bda71 100644 --- a/client/src/mobile/screens/trip/map/MMapArea.tsx +++ b/client/src/mobile/screens/trip/map/MMapArea.tsx @@ -5,8 +5,16 @@ import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../compo import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill' import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill' import { usePoiExplore } from '../../../../components/Map/usePoiExplore' +import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois' +import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel' +import { stageMapData } from '../../../../components/Roadtrip/stageMap' +import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings' import { useSettingsStore } from '../../../../store/settingsStore' import type { MMapAreaProps } from '../MTripShell' +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[] = [] /** * Fullscreen map layer of the mobile trip screen (plan tab). Stays mounted for @@ -33,7 +41,47 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const mapActive = shell.view === 'map' + const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId) + + // One instance, two tabs. `mapFront` is true whenever the map is the front layer + // in either of them, so the floating chrome below keys off that rather than off + // `view`, which only ever meant the plan tab. + const mapActive = shell.mapFront + const onStage = shell.trTab === 'roadtrip' + + // The stage the road trip tab is looking at, and what the map draws for it. Passed + // 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 + + // 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 + + /** + * The pins, and they are not the same question on the two tabs. + * + * On the stage: what the corridor search found and what the fuel search is offering, + * both measured along the drive. The explore categories stay off it, and so does their + * bar. "What is around this piece of map" has no relationship to a route, and two + * search bars stacked over a 375px map is the clutter this tab exists to avoid. The + * plan tab is one tap away and shows the same map. + * + * Merged through the planner's own hook rather than concatenated here, because the + * sets overlap: the same petrol station found twice would be two pins on one roof. + */ + const pois = useMergedMapPois( + onStage ? planner.roadtripCorridor.visible : null, + onStage ? NO_POIS : poi.pois, + planner.refuel.offered, + ) return ( // `isolate` keeps the map's internal z-indexes (Leaflet panes, the z-1000 @@ -42,22 +90,37 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { // The dock is 62px tall at safe-bottom + 12, so a 74px --bottom-nav-h puts // the round controls (which add their own 12px) a dock's gap above it — // close enough to the thumb to reach one-handed, clear of the dock itself. -
+ // The stage bar takes the band the round controls normally sit in, so on that + // tab they move up by its height plus its own gap. The lift is its OWN variable + // folded into the class rather than an inline --bottom-nav-h: the compass band + // below is identified by being the one element that sets that name inline, and a + // second one would make that ambiguous. +
shell.openSheet('transport', { reservationId: rid })} - pois={poi.pois} - onPoiClick={marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)} + pois={pois} + // On the stage, through the road trip's own door: it recognises a corridor hit by + // the distance it carries and sends it to the draft sheet with the stop kind, the + // stay and the position in the chain already worked out. The plan tab has no + // chain to place anything in, so there it stays the plain place form. + onPoiClick={onStage + ? planner.handlePoiClick + : marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)} onViewportChange={poi.onViewportChange} onMapReady={setGlMap} /> @@ -86,7 +155,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { sits below the day-chip rail (safe-top + 50px + ~42px chip height) and takes the full width between the screen margins, so its segments are the same size as everything else the thumb aims at on this screen. */} - {mapActive && poiPillEnabled && ( + {mapActive && !onStage && poiPillEnabled && (
{!planner.roadtripActive && planner.overviewActive && ( +