mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user