feat: quality of life sweep from the feature discussions

Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
This commit is contained in:
Maurice
2026-09-30 23:00:12 +02:00
parent 91dfab3f4b
commit 8fd46c4c18
691 changed files with 9491 additions and 927 deletions
+21
View File
@@ -0,0 +1,21 @@
import { Lock, Unlock } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MapTogglePill } from './MapTogglePill'
/**
* Locks the map view (#2010): picking days and places then leaves the camera where it is.
* Sits on top of the base-layer switcher, the other control about the view itself.
*/
export function MapLockPill({ locked, onToggle }: { locked: boolean; onToggle: () => void }) {
const { t } = useTranslation()
return (
<MapTogglePill
active={locked}
onToggle={onToggle}
label={locked ? t('map.lock.unlock') : t('map.lock.lock')}
testId="map-lock-pill"
tooltipPlacement="right"
icon={locked ? <Lock size={16} strokeWidth={2} /> : <Unlock size={16} strokeWidth={2} />}
/>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
/**
* One round on/off control in the frosted shell every map control wears, so a new
* one lines up with the compass, the layer switcher and the overview toggle.
*/
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
active: boolean
onToggle: () => void
label: string
icon: ReactNode
testId?: string
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
tooltipPlacement?: 'left' | 'right'
}) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
{/* TREK's own tooltip, not the browser's: the native one ignores the colour
scheme, waits a second and a half, and cannot be read on a touch device. */}
<Tooltip label={label} placement={tooltipPlacement}>
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid={testId}
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
{icon}
</button>
</Tooltip>
</div>
)
}
+40 -17
View File
@@ -46,6 +46,7 @@ import { PluginMapLayers } from './MapPluginLayers'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { placeMarkerLook } from './markerLook'
import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
@@ -64,6 +65,7 @@ import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -136,7 +138,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}`
const look = placeMarkerLook(place, isSelected)
const cacheKey = `${place.id}:${isSelected}:${look.key}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
@@ -151,11 +154,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconCache.set(cacheKey, icon)
return icon
}
const size = isSelected ? 44 : 36
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white')
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
@@ -164,7 +166,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Number badges (bottom-right), or the rating where there are none: a numbered stop
// is one already planned into a day, and the rating answers the question asked before
// that. The two never want the same corner at the same time.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
let badgeHtml = look.showRating ? ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) : ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -183,7 +185,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy + uploaded
// custom images (#1136) as a fallback while the thumb is still being generated
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/') || place.image_url.startsWith('/uploads/'))) {
if (look.showPhoto && place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/') || place.image_url.startsWith('/uploads/'))) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -194,7 +196,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
overflow:hidden;background:${bgColor};${look.circleCss}
">
${markerPhotoHtml(place.image_url)}
</div>
@@ -217,9 +219,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
will-change:transform;contain:layout style;
will-change:transform;contain:layout style;${look.circleCss}
">
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
${categoryIconSvg(place.category_icon, look.iconSize)}
${badgeHtml}
</div>`,
iconSize: [size, size],
@@ -355,6 +357,8 @@ function ViewportController({ onViewportChange }: { onViewportChange?: (b: { sou
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
@@ -362,12 +366,12 @@ interface SelectionControllerProps {
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
if (follow && selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
@@ -386,7 +390,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
return null
}
@@ -429,6 +433,8 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -449,7 +455,7 @@ function leafletPadding(box: ViewportPadding): L.FitBoundsOptions {
}
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding, follow = true }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -503,6 +509,8 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
useEffect(() => {
if (!awaitingRoute.current || routeCoords.length === 0) return
awaitingRoute.current = false
// A map locked since that fit keeps its view when the route turns up (#2010).
if (!follow) return
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -733,6 +741,12 @@ export const MapView = memo(function MapView({
// One colour pair per entry of `route`, or absent for the blue the route has always
// been. Only the road trip passes these, and only while colouring by day is on.
routeColors = null,
// One flag per entry of `route`: a walked stretch is drawn dashed (#2532).
routeWalking = null,
// False while the map is locked (#2010): picking a place leaves the view alone.
followSelection = true,
// Given, the lock that sets followSelection sits above the layer switcher (#2010).
onToggleFollow,
routeSegments = [],
selectedPlaceId = null,
// The selected place itself, for when no pin on this map stands for it.
@@ -1186,8 +1200,8 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1202,7 +1216,15 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 4, opacity: 1, dashArray: '1 9', lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [
<Polyline
key={`${i}-casing`}
positions={seg}
@@ -1223,7 +1245,7 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
])}
{/* The last bit to a place the road does not reach.
Dashed and thin, over the route rather than under it, because it is the one
@@ -1349,7 +1371,8 @@ 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 + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
+40 -14
View File
@@ -10,8 +10,10 @@ import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
@@ -147,6 +149,12 @@ interface Props {
* been. Only the road trip passes these, and only while colouring by day is on.
*/
routeColors?: ({ line: string; casing: string } | undefined)[] | null
/** One flag per entry of `route`: true where the stretch is walked, drawn dashed (#2532). */
routeWalking?: boolean[] | null
/** False while the map is locked (#2010): picking a place leaves the view alone. */
followSelection?: boolean
/** Given, the lock that sets followSelection sits above the layer switcher. */
onToggleFollow?: () => void
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
/** The selected place itself, for when no pin on this map stands for it. */
@@ -355,7 +363,7 @@ function applySatellite(map: any, on: boolean): void {
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
// No number badge either, for the same reason the rail gives it none.
@@ -368,10 +376,10 @@ function createMarkerElement(place: Place & { category_color?: string; category_
return wrap
}
const size = selected ? 44 : 36
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
const borderWidth = selected ? 3 : 2.5
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
@@ -386,7 +394,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// Same corner, same rule as the Leaflet map: numbers when the place is planned into a
// day, the rating when it is not.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
let badgeHtml = look.showRating ? ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) : ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -413,7 +421,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// to its stacked slot, not to the map viewport.
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
const hasPhoto = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
if (hasPhoto) {
wrap.innerHTML = `
<div style="
@@ -422,7 +430,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
box-sizing:content-box;
box-sizing:content-box;${look.circleCss}
">
${markerPhotoHtml(photoUrl)}
</div>
@@ -437,9 +445,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
box-shadow:${shadow};
background:${bgColor};
display:flex;align-items:center;justify-content:center;
box-sizing:content-box;
box-sizing:content-box;${look.circleCss}
">
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
${categoryIconSvg(place.category_icon, look.iconSize)}
</div>
${badgeHtml}
`
@@ -678,6 +686,9 @@ export function MapViewGL({
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeWalking = null,
followSelection = true,
onToggleFollow,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
selectedPlace = null,
@@ -1344,6 +1355,8 @@ export function MapViewGL({
source: 'trip-route',
// Per feature where the caller gave one, else the blue the route has always
// been. `coalesce` rather than a second layer: one source, one stroke.
// A walked stretch has no casing; it is drawn dashed by its own layer (#2532).
filter: ['!=', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
@@ -1361,9 +1374,18 @@ export function MapViewGL({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
filter: ['!=', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-walk',
type: 'line',
source: 'trip-route',
filter: ['==', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 4, 'line-dasharray': [0.1, 2.2] },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// The last bit to a place the road does not reach (#1797). Added after the route
// so it draws over it: it is the one piece of the line that is not driving, and it
@@ -1851,7 +1873,7 @@ export function MapViewGL({
const orderNumbers = dayOrderMap[place.id] ?? null
const photoUrl = placePhotoUrl(place, photoUrls)
const selected = place.id === selectedPlaceId
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
const el = createMarkerElement(place as Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
// on every reconcile, so the listeners go with the element and need no
// teardown of their own.
@@ -2129,17 +2151,17 @@ export function MapViewGL({
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = (route || [])
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
.map((seg, i) => ({ seg, colors: routeColors?.[i], walks: !!routeWalking?.[i] }))
.filter(({ seg }) => seg && seg.length > 1)
.map(({ seg, colors }) => ({
.map(({ seg, colors, walks }) => ({
type: 'Feature' as const,
// Null rather than absent: `coalesce` in the paint expression falls through on
// null, and an absent property would make every line the default colour.
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null, walks },
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route, routeColors, mapReady])
}, [route, routeColors, routeWalking, mapReady])
// Update access-spur geojson
useEffect(() => {
@@ -2239,6 +2261,8 @@ export function MapViewGL({
&& !!routeFitKey
&& routeFitKey !== pendingRouteFitRef.current.routeKey
if (!fitKeyChanged && !routeArrivedForPendingFit) return
// Locked since the last fit (#2010): the day's route arriving must not move the view.
if (!fitKeyChanged && !followSelection) { pendingRouteFitRef.current = null; return }
const map = mapRef.current
if (!map) return
@@ -2310,7 +2334,7 @@ export function MapViewGL({
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId) return
if (!map || !selectedPlaceId || !followSelection) return
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return
try {
@@ -2425,10 +2449,12 @@ export function MapViewGL({
covers. */}
<div style={{
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
display: 'flex', flexDirection: 'column', gap: 8,
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -2,7 +2,7 @@ import { Fragment, createElement, useMemo, useState } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
@@ -13,8 +13,8 @@ import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
@@ -27,6 +27,8 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
// Valley to mountain station as the rope runs: a straight line, never a road (#2535).
cable_car: { color: TRANSPORT_COLOR, icon: CableCar, geodesic: false },
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
}
+10 -37
View File
@@ -1,9 +1,8 @@
import { AlertTriangle, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { MapTogglePill } from './MapTogglePill'
import { routeModeIcon } from '../Planner/routeModes'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
@@ -13,40 +12,14 @@ import type { DistanceUnit } from '../../types'
*/
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const label = active ? t('map.overview.hide') : t('map.overview.show')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
{/* TREK's own tooltip, not the browser's — the native one ignores the
colour scheme, waits a second and a half, and cannot be read on a touch
device at all. `left`, because these controls hug the right edge of the
map and a tooltip to the right would hang off it. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
<MapTogglePill
active={active}
onToggle={onToggle}
label={active ? t('map.overview.hide') : t('map.overview.show')}
testId="trip-overview-pill"
icon={<Route size={17} strokeWidth={2} />}
/>
)
}
@@ -123,7 +96,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
{day.modes.map(mode => {
const Icon = profileIcon(mode)
const Icon = routeModeIcon(mode)
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
+3 -1
View File
@@ -4,7 +4,7 @@ import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type C
import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
export interface DayRoutePoint {
@@ -86,6 +86,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
// Kept on the day, shown on the map, but not driven to (#2532).
if ((a as { route_excluded?: boolean }).route_excluded) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
+41
View File
@@ -0,0 +1,41 @@
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
* planner hands to the map. Underscored because they are the map's reading of the plan,
* never fields of a stored place.
*/
export interface PlaceMarkerFlags {
/** Not planned into any day, drawn small and without its photo (#2024). */
_compact?: boolean
/** Its stay's booking still waits for a confirmation (#2281). */
_pending?: boolean
}
export interface PlaceMarkerLook {
size: number
borderWidth: number
showPhoto: boolean
showRating: boolean
iconSize: number
/** Extra CSS for the round marker: a dashed ring and a faded face for a pending stay. */
circleCss: string
/** Part of the icon cache key, so a changed look is never served from the cache. */
key: string
}
/**
* How a place marker is drawn, the same way on the Leaflet and the GL map. A selected
* marker always shows in full: whatever made it quiet, it is the one being looked at.
*/
export function placeMarkerLook(place: PlaceMarkerFlags, selected: boolean): PlaceMarkerLook {
const compact = !!place._compact && !selected
const pending = !!place._pending
return {
size: selected ? 44 : compact ? 24 : 36,
borderWidth: selected ? 3 : compact ? 2 : 2.5,
showPhoto: !compact,
showRating: !compact,
iconSize: selected ? 18 : compact ? 12 : 15,
circleCss: pending ? 'border-style:dashed;opacity:0.62;' : '',
key: `${compact ? 'c' : ''}${pending ? 'p' : ''}`,
}
}
@@ -9,7 +9,7 @@
import { createElement } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
@@ -22,8 +22,8 @@ export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
/** Sits under the coloured transit lines; named here so teardown can find it. */
export const TRANSIT_CASING_LAYER_ID = `${RESERVATION_LINE_LAYER_ID}-transit-casing`
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
@@ -35,6 +35,7 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
cable_car: { icon: CableCar, geodesic: false },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
}