mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
feat(roadtrip): bring the road trip addon to the phone
A road trip tab on the mobile trip screen: the stage as a chain, the same map underneath it, the sheets a passenger needs, and a search along the route. The addon was desktop only because road trip MODE is a data switch rather than a view switch. It rewrites `assignments` and `places` for the whole planner hook, and every mobile sheet is permanently mounted and reads those same two lists, so turning it on there brought back documented regressions. The mode flag is therefore untouched. A second flag, `roadtripFeedActive`, feeds only the routing round and does it off the STORED assignments, so the phone gets the drive without what the mode drags along with it. The map is one instance shared by the plan tab and the road trip tab, hoisted into a single JSX expression so React keeps it in the same position and the WebGL context is never torn down on a tab switch. The corridor search is scoped to what a phone can afford: a window of the day rather than all of it, a smaller tile ceiling, a capped retry pass and a deadline. The standard search is three requests where the desk makes fifteen. The categories are deliberately not narrowed, because they all ride one query per box, so a second one costs nothing. Two gaps found while wiring it up are closed here as well. The fuel search drew its offers nowhere on the phone, so the button did nothing visible; they are in the dry band now, with the detour, the spare range and an accept button. And the map credit opened as a two line ribbon across a screen the map fills: both GL engines now start it collapsed, and Leaflet, which has no collapsed mode of its own, is given one.
This commit is contained in:
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
// Clear of the locate button at right: 12 plus its 42, with a gap.
|
||||
right: 62,
|
||||
zIndex: 1000,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-card, white)',
|
||||
color: 'var(--text-muted, #6b7280)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Info size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<div className={`w-full h-full relative${isMobile && attribOpen ? ' m-attrib-open' : ''}`}>
|
||||
<MapContainer
|
||||
// The datum is in the element's identity, because react-leaflet builds the
|
||||
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
|
||||
@@ -1215,6 +1226,15 @@ export const MapView = memo(function MapView({
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
{/* The credit as the little (i), which is what both GL engines do on a narrow map
|
||||
and Leaflet has no mode for. */}
|
||||
{isMobile && (
|
||||
<MapAttributionToggle
|
||||
open={attribOpen}
|
||||
onToggle={() => 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. */}
|
||||
|
||||
@@ -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(<MapViewGL places={[]} fitKey={1} />)
|
||||
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(<MapViewGL places={[]} fitKey={1} />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user