feat(journey): show the plan, what was recorded, the entries and the photos as layers of the journey map

Every journey map (the desktop aside, the phone screens and the shared page)
now carries four switches: Plan, Recorded, Entries and Photos, each with its
count, a spinner while it loads, the warning tone when it could not be read
and the reason when it has nothing to show. A switch only hides, in place:
the map is not rebuilt and keeps its view, and the choice is kept in this
browser per journey (per link on a shared page, by a hash of its token).

The plan draws the places the linked trips put on their days as hollow dashed
pins and the trips' GPX routes dashed; a place an entry was written about is
not drawn twice, the entry's pin carries a check instead. The stays a day
report was taken over from show as dots in the colour of their day. Marks on
one spot join into a disc with their number and open as a list; a picked
place or stay opens a card with its trip, days and times and leads to its
entry. A key beside the switches explains every pin and line and says that
a plan is no proof of a visit. The phone timelines now show the photos too.

The GPX routes come with the map's layers through the linked trips, read
only while the journey's track switch is on; the frozen /tracks answer stays
Studio's. The share settings on desktop and phone get Trip plan and Recorded
routes under Map, off by default and only switchable while the map is shared.
This commit is contained in:
Maurice
2026-10-21 18:55:54 +02:00
parent 9b71948497
commit 8f53d98d45
71 changed files with 3714 additions and 184 deletions
@@ -0,0 +1,107 @@
// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-003
//
// JRN-04: the journey map with its layers: what the renderer is handed, and a
// picked mark's card.
import type { JourneyMapPlace } from '@trek/shared';
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyLayeredMap from './JourneyLayeredMap';
import { ALL_VISIBLE } from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const seen = vi.hoisted(() => ({ props: [] as Record<string, unknown>[], engine: [] as string[] }));
vi.mock('./JourneyMap', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('leaflet');
return null;
},
}));
vi.mock('./JourneyMapAuto', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('auto');
return null;
},
}));
const MARK: JourneyMapMark = {
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: {
name: 'Piazza San Marco',
trip_title: 'Venice',
days: [],
time: null,
entry_id: null,
} as unknown as JourneyMapPlace,
};
function layers(over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls: [{ layer: 'entries', count: 1, state: 'ready' }],
tracks: [],
marks: [MARK],
plannedEntryIds: new Set(['3']),
...over,
};
}
const last = () => seen.props[seen.props.length - 1];
const PHOTOS = [{ id: 'p', lat: 1, lng: 2, thumbUrl: '/t.jpg' }];
describe('JourneyLayeredMap', () => {
it('FE-JOURNEY-LAYEREDMAP-001: without layers the map draws as it always did, on the renderer asked for', () => {
render(<JourneyLayeredMap engine="leaflet" checkins={[]} entries={[]} photos={PHOTOS} tracks={[]} />);
expect(seen.engine[seen.engine.length - 1]).toBe('leaflet');
expect(last()).toMatchObject({ photos: PHOTOS, marks: undefined, layerVisibility: undefined });
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
});
it('FE-JOURNEY-LAYEREDMAP-002: hands the renderer the layers, the photos only while their switch is on', () => {
const view = layers();
const { rerender } = render(<JourneyLayeredMap layers={view} checkins={[]} entries={[]} photos={PHOTOS} />);
expect(seen.engine[seen.engine.length - 1]).toBe('auto');
expect(last()).toMatchObject({
photos: PHOTOS,
marks: [MARK],
layerVisibility: ALL_VISIBLE,
plannedEntryIds: view.plannedEntryIds,
});
expect(screen.getByRole('group', { name: 'Map layers' })).toBeInTheDocument();
rerender(
<JourneyLayeredMap
layers={layers({ visibility: { ...ALL_VISIBLE, photos: false } })}
checkins={[]}
entries={[]}
photos={PHOTOS}
/>
);
expect(last().photos).toBeUndefined();
});
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
const onOpenEntry = vi.fn();
const { rerender } = render(
<JourneyLayeredMap layers={layers()} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />
);
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
rerender(<JourneyLayeredMap layers={layers({ marks: [] })} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
import JourneyMapAuto from './JourneyMapAuto';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
type MapProps = Omit<
ComponentProps<typeof JourneyMapAuto>,
'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds'
> & {
/** CARTO key of a shared page, which the Leaflet renderer reads; see JourneyMap. */
cartoApiKey?: string;
};
interface Props extends MapProps {
ref?: Ref<JourneyMapHandle>;
/** The layers of this map; without them it draws as it always did. */
layers?: JourneyMapLayersView | null;
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
engine?: 'auto' | 'leaflet';
/** Icon-only switches, for a phone. */
compactControls?: boolean;
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
controlsTop?: number | string;
/** Leads from a picked place or stay to its entry. */
onOpenEntry?: (entryId: string) => void;
entryTitleOf?: (entryId: number) => string | null;
}
/**
* A journey map with its layers (JRN-04): the renderer, the reader's layer
* switches and the card of a picked plan place or stay. Every journey map
* surface draws through here, the desktop aside, the phone screens and the
* shared page, so the layers mean the same everywhere. A switched-off layer
* is hidden in place: the map is not rebuilt and keeps its view.
*/
export default function JourneyLayeredMap({
ref,
layers,
engine = 'auto',
compactControls = false,
controlsTop = 12,
onOpenEntry,
entryTitleOf,
photos,
tracks,
height = 220,
...rest
}: Props) {
const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null);
const visibility = layers?.visibility;
const onMarksPick = useCallback((marks: JourneyMapMark[]) => {
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
}, []);
const close = useCallback(() => setPicked(null), []);
const openEntry = useCallback(
(entryId: string) => {
setPicked(null);
onOpenEntry?.(entryId);
},
[onOpenEntry]
);
// A pick whose marks a switch took off the map closes with them.
const shownKeys = new Set(layers?.marks.map((m) => m.key));
const card = picked && picked.marks.every((m) => shownKeys.has(m.key)) ? picked : null;
const mapProps = {
...rest,
height,
photos: !visibility || visibility.photos ? photos : undefined,
tracks: layers ? layers.tracks : tracks,
layerVisibility: visibility,
marks: layers?.marks,
onMarksPick,
plannedEntryIds: layers?.plannedEntryIds,
};
return (
<div className="relative w-full" style={{ height: height === 9999 ? '100%' : height }}>
<div className="isolate h-full w-full">
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
</div>
{layers && (
<div
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
style={{ top: controlsTop }}
>
<div className="pointer-events-auto max-w-full">
<JourneyMapLayerControl layers={layers} compact={compactControls} />
</div>
{card && (
<JourneyMapPickCard
key={card.key}
marks={card.marks}
onClose={close}
onOpenEntry={onOpenEntry ? openEntry : undefined}
entryTitleOf={entryTitleOf}
/>
)}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-051
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -823,4 +823,59 @@ describe('JourneyMap', () => {
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
// JRN-04: the layer switches hide in place, and the plan's places and stays draw beside the entries.
it('FE-COMP-JOURNEYMAP-052: switching the entries off takes their pin off the map and back, without a rebuild', () => {
const marker = mockedMarkerWithRemove();
const entry = [entriesWithCoords[0]];
const shown = { plan: true, recorded: true, entries: true };
const { rerender } = render(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={{ ...shown, entries: false }} />);
expect(marker.remove).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(2);
expect(L.map).toHaveBeenCalledTimes(1);
});
it('FE-COMP-JOURNEYMAP-053: a planned place is drawn as its hollow pin, and a click hands it up', () => {
const onMarksPick = vi.fn();
const mark = {
kind: 'plan' as const,
key: 'plan:4',
lat: 45.43,
lng: 12.33,
color: '#e11d48',
place: {
place_id: 4, trip_id: 2, trip_title: 'Venice', name: 'San Marco', lat: 45.43, lng: 12.33,
days: [], time: null, category_color: '#e11d48', category_icon: null, entry_id: null,
},
};
render(<JourneyMap checkins={[]} entries={[]} marks={[mark]} onMarksPick={onMarksPick} />);
const pins = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('stroke-dasharray'));
expect(pins).toHaveLength(1);
expect(pins[0]).toContain('#e11d48');
const click = mockedMarker().on.mock.calls.filter((c: unknown[]) => c[0] === 'click').pop()?.[1] as () => void;
click();
expect(onMarksPick).toHaveBeenCalledWith([mark]);
});
it('FE-COMP-JOURNEYMAP-054: an entry pin whose place the plan had too carries the check', () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} plannedEntryIds={new Set(['e2'])} />);
expect(divIconHtml(0)).not.toContain('#16a34a');
expect(divIconHtml(1)).toContain('#16a34a');
});
});
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
function mockedMarkerWithRemove(): { addTo: Mock; remove: Mock } {
L.marker([0, 0]);
const marker = mockedMarker() as unknown as { addTo: Mock; remove: Mock };
marker.remove = vi.fn();
vi.mocked(L.marker).mockClear();
marker.addTo.mockClear();
return marker;
}
+119 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
@@ -9,6 +9,9 @@ import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLa
import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
export interface MapMarkerItem {
id: string
@@ -74,8 +77,32 @@ interface Props {
paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
/**
* Which drawn layers the reader shows (JRN-04). A hidden one is taken off the
* map and put back as it was, never rebuilt, so the view stays where it is.
*/
layerVisibility?: JourneyMapLayerVisibility
/** The plan's places and the stays of day reports, clustered where they meet. */
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
/** Entry pins whose place the plan had too, marked with a check. */
plannedEntryIds?: ReadonlySet<string>
}
/** The map's own zoom buttons, frosted so they hold on any tile. */
const zoomButtonStyle = (dark?: boolean): CSSProperties => ({
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
})
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const items: MapMarkerItem[] = []
for (const e of entries) {
@@ -101,7 +128,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
@@ -114,10 +141,20 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg>
</div>`
}
function entryIcon(item: MapMarkerItem, highlighted: boolean, planned: boolean): L.DivIcon {
return L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, highlighted, planned),
})
}
/**
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
* and a focus keeps that (discussion #2299).
@@ -147,7 +184,7 @@ const TRACK_FALLBACK_COLOR = '#4f46e5'
const PLANNED_DASH = '8 7'
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// Read through a ref: the flag is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
@@ -183,6 +220,20 @@ function JourneyMap(
const photoLayerRef = useRef<L.LayerGroup | null>(null)
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const markLayerRef = useRef<L.LayerGroup | null>(null)
const onMarksPickRef = useRef(onMarksPick)
// What each switchable layer drew, and which of them are on the map right now.
const drawnRef = useRef<DrawnLayers>(NOTHING_DRAWN())
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const visibilityRef = useRef(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const darkRef = useRef(dark)
darkRef.current = dark
@@ -196,12 +247,7 @@ function JourneyMap(
const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
}))
marker.setIcon(entryIcon(item, false, plannedRef.current.has(item.id)))
marker.setZIndexOffset(0)
}
}
@@ -210,12 +256,7 @@ function JourneyMap(
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true),
}))
marker.setIcon(entryIcon(item, true, plannedRef.current.has(item.id)))
marker.setZIndexOffset(1000)
}
}
@@ -286,6 +327,18 @@ function JourneyMap(
const items = buildMarkerItems(entries)
itemsRef.current = items
// Each switchable layer is drawn whatever its switch says and only put on
// the map while it is shown, so switching it later needs no rebuild.
const drawn = NOTHING_DRAWN()
drawnRef.current = drawn
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
drawn[kind].push(layer)
if (visibilityRef.current[kind]) layer.addTo(map)
return layer
}
const allCoords: L.LatLngTuple[] = []
/**
* Track geometry is kept OUT of the fit set (#2194) and used only when there
@@ -307,10 +360,10 @@ function JourneyMap(
if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
L.polyline(coords, {
place('recorded', L.polyline(coords, {
color: '#6366f1', weight: 3, opacity: 0.4,
dashArray: '6 4', lineCap: 'round',
}).addTo(map)
}))
coords.forEach(c => allCoords.push(c))
}
@@ -322,8 +375,9 @@ function JourneyMap(
if (track.points.length < 2) continue
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
const color = track.color || TRACK_FALLBACK_COLOR
const planned = !track.kind || track.kind === 'trip_gpx'
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map)
const planned = isPlannedTrack(track)
const kind: LeafletLayerKind = planned ? 'plan' : 'recorded'
place(kind, L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }))
const line = L.polyline(coords, {
color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round',
...(planned ? { dashArray: PLANNED_DASH } : {}),
@@ -333,20 +387,20 @@ function JourneyMap(
// transparency switched off. Escaped because a string handed to bindTooltip
// becomes innerHTML, and a track name is a place name off a shared trip.
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map)
place(kind, line)
coords.forEach(c => trackCoords.push(c))
}
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, {
place('entries', L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}).addTo(map)
}))
}
// place markers
@@ -354,14 +408,7 @@ function JourneyMap(
const pos: L.LatLngTuple = [item.lat, item.lng]
allCoords.push(pos)
const icon = L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
})
const marker = L.marker(pos, { icon }).addTo(map)
const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
// The same card the GL renderer shows, from the same builder: which map
// engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came
@@ -420,9 +467,26 @@ function JourneyMap(
detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null
markersRef.current.clear()
drawnRef.current = NOTHING_DRAWN()
}
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
// A switch puts its layer back on the map or takes it off, exactly as drawn.
useEffect(() => {
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer without redrawing the map.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) === plannedEntryIds.has(item.id)) continue
markersRef.current.get(item.id)?.setIcon(entryIcon(item, highlightedRef.current === item.id, plannedEntryIds.has(item.id)))
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds])
// Retile in place rather than through the effect above, which would drop every
// marker and track it just drew. A vector basemap restyles instead, which also
// avoids spending a WebGL context on every theme toggle.
@@ -484,6 +548,29 @@ function JourneyMap(
}
}, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// The plan's places and the stays of day reports (JRN-04), in their own layer
// like the photos: below the entry pins and above the thumbnails, regrouped
// on every move because marks are only joined where they would overlap.
useEffect(() => {
const map = mapRef.current
if (!map) return
const draw = () => {
markLayerRef.current?.remove()
markLayerRef.current = drawLeafletMarks(map, marks, members => onMarksPickRef.current?.(members))
}
draw()
map.on('zoomend', draw)
map.on('moveend', draw)
return () => {
map.off('zoomend', draw)
map.off('moveend', draw)
markLayerRef.current?.remove()
markLayerRef.current = null
}
}, [marks, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
@@ -507,30 +594,8 @@ function JourneyMap(
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button type="button"
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button type="button"
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>−</button>
<button type="button" onClick={zoomIn} style={zoomButtonStyle(dark)}>+</button>
<button type="button" onClick={zoomOut} style={zoomButtonStyle(dark)}>−</button>
</div>
</div>
)
@@ -7,6 +7,8 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer'
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
import type { JourneyMapMark } from './journeyMapMarks'
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,6 +43,11 @@ interface Props {
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
/** JRN-04: the layers the reader shows, and the plan and stay marks, both renderers. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
}
function JourneyMapAuto({ ref, ...props }: Props) {
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-038, FE-COMP-JMAPGL-2453
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn>
@@ -48,6 +48,7 @@ const gl = vi.hoisted(() => {
getSource: vi.fn((): unknown => null),
setTerrain: vi.fn(),
setConfigProperty: vi.fn(),
setLayoutProperty: vi.fn(),
}
const markers: MarkerStub[] = []
const popups: PopupStub[] = []
@@ -686,4 +687,38 @@ describe('JourneyMapGL', () => {
const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />)
expect((container.firstChild as HTMLElement).style.height).toBe('100%')
})
it('FE-COMP-JMAPGL-039: the plan has its own casing, and a layer switched off is added hidden (JRN-04)', () => {
withToken()
render(
<JourneyMapGL
checkins={[]}
entries={entries}
tracks={[{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] }]}
layerVisibility={{ plan: false, recorded: true, entries: false }}
/>,
)
const layer = (id: string) => vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown>; filter?: unknown }).find(l => l.id === id)
expect(layer('journey-tracks-planned-casing')?.filter).toEqual(['==', ['get', 'planned'], true])
expect(layer('journey-tracks-planned-casing')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-planned')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-line')?.layout.visibility).toBeUndefined()
expect(layer('journey-route-line')?.layout.visibility).toBe('none')
expect(gl.markers.every(m => m.element.style.display === 'none')).toBe(true)
})
it('FE-COMP-JMAPGL-040: a switch shows and hides its layers in place, and the check follows the plan', () => {
withToken()
const shown = { plan: true, recorded: true, entries: true }
const { rerender } = render(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={shown} />)
expect(gl.markers[0].element.innerHTML).not.toContain('#16a34a')
rerender(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={{ ...shown, recorded: false }} plannedEntryIds={new Set(['e1'])} />)
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-line', 'visibility', 'none')
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-casing', 'visibility', 'none')
expect(gl.map.setLayoutProperty).not.toHaveBeenCalledWith('journey-tracks-planned', 'visibility', expect.anything())
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
})
})
+116 -9
View File
@@ -6,6 +6,8 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -52,6 +54,11 @@ interface Props {
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
glProvider?: GlMapProvider
/**
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
@@ -98,7 +105,16 @@ function buildItems(entries: MapEntry[]): Item[] {
return items
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
/** The map's own layers each switch takes off and puts back. */
const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
plan: ['journey-tracks-planned-casing', 'journey-tracks-planned'],
recorded: ['journey-tracks-casing', 'journey-tracks-line'],
entries: ['journey-route-line'],
}
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
@@ -121,6 +137,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg>`
wrap.appendChild(inner)
return wrap
@@ -132,7 +149,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
@@ -165,6 +182,18 @@ function JourneyMapGL(
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
const markMarkersRef = useRef<{ remove: () => void }[]>([])
const onMarksPickRef = useRef(onMarksPick)
const visibilityRef = useRef(layerVisibility)
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const darkRef = useRef(dark)
darkRef.current = dark
const mapLangRef = useRef(mapLang)
@@ -242,7 +271,7 @@ function JourneyMapGL(
if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
const next = markerHtml(item.dayColor, item.dayLabel, highlighted, plannedRef.current.has(id))
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML
@@ -314,6 +343,11 @@ function JourneyMapGL(
const map = new gl.Map(mapOptions as any)
mapRef.current = map
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
// A layer whose switch is off is added hidden, so switching it on later needs no rebuild.
const layout = (kind: keyof JourneyMapLayerVisibility, base: Record<string, unknown>) =>
visibilityRef.current[kind] ? base : { ...base, visibility: 'none' }
map.on('load', () => {
if (enableMapbox3d) {
@@ -352,7 +386,7 @@ function JourneyMapGL(
'line-opacity': 0.5,
'line-dasharray': [2, 3],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
})
}
}
@@ -372,7 +406,7 @@ function JourneyMapGL(
properties: {
color: track.color || TRACK_FALLBACK_COLOR,
name: track.name,
planned: !track.kind || track.kind === 'trip_gpx',
planned: isPlannedTrack(track),
},
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
})),
@@ -382,12 +416,22 @@ function JourneyMapGL(
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
} else {
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
// Casing apart for the plan and what was recorded, so each goes with its switch.
map.addLayer({
id: 'journey-tracks-casing',
type: 'line',
source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned-casing',
type: 'line',
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: layout('plan', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-line',
@@ -395,7 +439,7 @@ function JourneyMapGL(
source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned',
@@ -403,7 +447,7 @@ function JourneyMapGL(
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95, 'line-dasharray': [2.2, 2] },
layout: { 'line-cap': 'butt', 'line-join': 'round' },
layout: layout('plan', { 'line-cap': 'butt', 'line-join': 'round' }),
})
}
}
@@ -411,9 +455,10 @@ function JourneyMapGL(
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const el = markerHtml(item.dayColor, item.dayLabel, false, plannedRef.current.has(item.id))
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
el.style.zIndex = '1'
if (!visibilityRef.current.entries) el.style.display = 'none'
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
@@ -487,6 +532,68 @@ function JourneyMapGL(
// gl is the injected engine and does not change for the life of a map.
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// A switch shows or hides its layers where they are (JRN-04): the GL layers by
// their visibility, the entry pins by their element.
useEffect(() => {
const map = mapRef.current
if (!map) return
for (const kind of Object.keys(LAYER_IDS) as (keyof JourneyMapLayerVisibility)[]) {
if (shownRef.current[kind] === layerVisibility[kind]) continue
for (const id of LAYER_IDS[kind]) {
try { map.setLayoutProperty(id, 'visibility', layerVisibility[kind] ? 'visible' : 'none') } catch { /* not drawn on this map */ }
}
if (kind === 'entries') {
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
}
}
shownRef.current = layerVisibility
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer in place.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) !== plannedEntryIds.has(item.id)) setMarkerStyle(item.id, highlightedRef.current === item.id)
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds, setMarkerStyle])
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
// regrouped on every move, between the photos and the entry pins.
useEffect(() => {
const map = mapRef.current
if (!map) return
const clear = () => {
markMarkersRef.current.forEach(m => m.remove())
markMarkersRef.current = []
}
const draw = () => {
clear()
if (!marks?.length) return
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members)
const el = document.createElement('div')
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
el.innerHTML = look.html
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onMarksPickRef.current?.(hit.members)
})
markMarkersRef.current.push(new gl.Marker({ element: el, anchor: look.anchor }).setLngLat([hit.lng, hit.lat]).addTo(map))
}
}
if (map.loaded()) draw()
else map.once('load', draw)
map.on('moveend', draw)
return () => {
map.off('moveend', draw)
map.off('load', draw)
clear()
}
// gl is the injected engine and does not change for the life of a map.
}, [marks, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run.
useEffect(() => {
@@ -0,0 +1,68 @@
// FE-JOURNEY-MAPLAYERCTL-001 to FE-JOURNEY-MAPLAYERCTL-003
//
// JRN-04: the journey map's layer switches and its key.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import { ALL_VISIBLE, type JourneyMapLayerControl as Control } from './journeyMapLayers';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
function view(controls: Control[], over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls,
tracks: [],
marks: [],
plannedEntryIds: new Set(),
...over,
};
}
describe('JourneyMapLayerControl', () => {
it('FE-JOURNEY-MAPLAYERCTL-001: one pressed switch per offered layer with its count, a withheld one not at all', () => {
const layers = view(
[
{ layer: 'recorded', count: 2, state: 'ready' },
{ layer: 'entries', count: 5, state: 'ready' },
{ layer: 'photos', count: 0, state: 'empty' },
],
{ visibility: { ...ALL_VISIBLE, photos: false } }
);
render(<JourneyMapLayerControl layers={layers} />);
expect(screen.queryByTestId('journey-map-layer-plan')).toBeNull();
expect(screen.getByTestId('journey-map-layer-recorded')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('journey-map-layer-entries-badge')).toHaveTextContent('5');
const photos = screen.getByTestId('journey-map-layer-photos');
expect(photos).toHaveAttribute('aria-pressed', 'false');
expect(photos.className).toContain('opacity-55');
expect(screen.queryByTestId('journey-map-layer-photos-badge')).toBeNull();
fireEvent.click(photos);
expect(layers.toggle).toHaveBeenCalledWith('photos');
});
it('FE-JOURNEY-MAPLAYERCTL-002: a layer that could not be read carries the warning tone, a loading one the spinner', () => {
render(
<JourneyMapLayerControl
layers={view([
{ layer: 'plan', count: 0, state: 'unavailable' },
{ layer: 'entries', count: 0, state: 'loading' },
])}
/>
);
expect(screen.getByTestId('journey-map-layer-plan-badge')).toHaveAttribute('data-tone', 'warning');
expect(screen.getByTestId('journey-map-layer-entries')).toHaveAttribute('data-state', 'loading');
expect(screen.getByRole('status')).toHaveTextContent('Plan');
});
it('FE-JOURNEY-MAPLAYERCTL-003: the key opens beside the switches and says a plan is no visit', () => {
render(<JourneyMapLayerControl layers={view([{ layer: 'entries', count: 1, state: 'ready' }])} compact />);
expect(screen.queryByText('Entries')).toBeNull();
fireEvent.click(screen.getByTestId('journey-map-key-button'));
expect(screen.getByTestId('journey-map-key')).toHaveTextContent('A planned place is no proof of a visit');
});
});
@@ -0,0 +1,299 @@
import { CalendarRange, Images, Info, Loader2, NotebookPen, Route } from 'lucide-react';
import type { ComponentType, ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { FROSTED_SHELL, segmentStyle } from '../Map/mapControlShadow';
import { LABEL, PopoverListPanel } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { LAYER_TEXT, type JourneyMapLayerControl as Control, type JourneyMapLayer } from './journeyMapLayers';
import { PLAN_FALLBACK_COLOR } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const ICONS: Record<JourneyMapLayer, ComponentType<{ size?: number; strokeWidth?: number }>> = {
plan: CalendarRange,
recorded: Route,
entries: NotebookPen,
photos: Images,
};
/** The sample colours of the key: an entry pin of the first day and a recorded line, as the map draws them. */
const SAMPLE_DAY = '#6366f1';
const SAMPLE_ROUTE = '#2563eb';
/** What a switch says about its layer: its name, and why it shows nothing when it does not. */
function hintOf(control: Control, t: (key: string, params?: Record<string, string | number>) => string): string {
const text = LAYER_TEXT[control.layer];
const name = t(text.labelKey);
if (control.state === 'unavailable') return `${name}: ${t(text.unavailableKey)}`;
if (control.state === 'empty') return `${name}: ${t(text.emptyKey)}`;
if (control.state === 'loading') return `${name}: ${t('journey.map.layer.loading')}`;
return name;
}
function LayerToggle({
control,
on,
onToggle,
compact,
}: {
control: Control;
on: boolean;
onToggle: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const Icon = ICONS[control.layer];
const name = t(LAYER_TEXT[control.layer].labelKey);
const problem = control.state === 'unavailable';
// Nothing to draw: dimmed rather than gone, and the tooltip says why.
const quiet = control.state === 'empty' || control.state === 'idle';
const showBadge = control.state === 'loading' || problem || (control.state === 'ready' && on);
return (
<Tooltip label={hintOf(control, t)} placement="bottom">
<button
type="button"
onClick={onToggle}
aria-label={name}
aria-pressed={on}
data-testid={`journey-map-layer-${control.layer}`}
data-state={control.state}
className={`${on ? 'bg-accent text-accent-text' : 'text-content-muted hover:bg-surface-hover'} ${quiet ? 'opacity-55' : ''} transition-colors`}
style={segmentStyle(on)}
>
<Icon size={14} strokeWidth={2} />
{!compact && <span>{name}</span>}
{showBadge && (
<span
aria-hidden
data-testid={`journey-map-layer-${control.layer}-badge`}
data-tone={problem ? 'warning' : 'default'}
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
problem
? 'bg-warning-soft text-warning'
: on
? 'bg-accent-text text-accent'
: 'bg-surface-hover text-content-secondary'
}`}
style={fs(10)}
>
{control.state === 'loading' ? (
<Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" />
) : problem ? (
'!'
) : (
control.count
)}
</span>
)}
</button>
</Tooltip>
);
}
/** A hollow plan pin, as the map draws it. */
function PlanPinSample() {
return (
<svg width="16" height="20" viewBox="0 0 22 28" aria-hidden className="flex-none">
<path
d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z"
fill="#ffffff"
stroke={PLAN_FALLBACK_COLOR}
strokeWidth="2"
strokeDasharray="3 2"
/>
<circle cx="11" cy="10.5" r="3" fill={PLAN_FALLBACK_COLOR} />
</svg>
);
}
/** A journal entry's pin, with the plan's check when the plan had that place too. */
function EntryPinSample({ planned = false }: { planned?: boolean }) {
return (
<svg width="16" height="20" viewBox="0 0 28 36" aria-hidden className="flex-none">
<path
d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z"
fill={SAMPLE_DAY}
/>
{planned && (
<>
<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" strokeWidth="1.2" />
<path
d="M20.4 5.6l1.5 1.5 2.8-2.9"
fill="none"
stroke="#16a34a"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
);
}
function StaySample() {
return (
<svg width="16" height="16" viewBox="0 0 14 14" aria-hidden className="flex-none">
<circle cx="7" cy="7" r="5" fill={SAMPLE_DAY} stroke="#ffffff" strokeWidth="2" />
</svg>
);
}
function SeveralSample() {
return (
<svg width="18" height="18" viewBox="0 0 28 28" aria-hidden className="flex-none">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke={SAMPLE_DAY} strokeWidth="2" />
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke={PLAN_FALLBACK_COLOR} strokeWidth="2.5" />
<text x="12" y="16" textAnchor="middle" dominantBaseline="central" fontSize="11" fontWeight="700" fill="#111827">
3
</text>
</svg>
);
}
function LineSample({ dashed }: { dashed: boolean }) {
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="flex-none">
<line x1="1" y1="4" x2="23" y2="4" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<line
x1="1"
y1="4"
x2="23"
y2="4"
stroke={SAMPLE_ROUTE}
strokeWidth="2.5"
strokeLinecap={dashed ? 'butt' : 'round'}
strokeDasharray={dashed ? '4 3' : undefined}
/>
</svg>
);
}
const ROW = 'flex min-w-0 items-center gap-2.5 text-content-secondary';
/**
* The key of the journey map: what each pin, dot and line stands for, with the
* one rule that matters most: a planned place is no proof of a visit.
*/
function JourneyMapKey() {
const { t } = useTranslation();
const rows: { sample: ReactNode; labelKey: string }[] = [
{ sample: <PlanPinSample />, labelKey: 'journey.map.legend.planned' },
{ sample: <EntryPinSample />, labelKey: 'journey.map.legend.entry' },
{ sample: <EntryPinSample planned />, labelKey: 'journey.map.legend.plannedWritten' },
{ sample: <StaySample />, labelKey: 'journey.map.legend.stay' },
{ sample: <SeveralSample />, labelKey: 'journey.map.legend.several' },
];
return (
<div className="flex flex-col gap-3 p-1.5" data-testid="journey-map-key">
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
{t('journey.map.legend')}
</span>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li key={row.labelKey} className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">{row.sample}</span>
<span className="min-w-0">{t(row.labelKey)}</span>
</li>
))}
</ul>
<div className="border-t border-edge-faint pt-3">
<span className={LABEL}>{t('journey.map.legend.lines')}</span>
<ul className="flex flex-col gap-1.5">
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed />
</span>
<span className="min-w-0">{t('journey.map.legend.plannedRoute')}</span>
</li>
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed={false} />
</span>
<span className="min-w-0">{t('journey.map.legend.recordedRoute')}</span>
</li>
</ul>
</div>
<p
className="flex items-start gap-1.5 rounded-lg bg-surface-hover px-2 py-1.5 text-content-secondary"
style={fs(11)}
>
<Info size={12} strokeWidth={2} aria-hidden className="mt-px flex-none" />
{t('journey.map.legend.planIsNoVisit')}
</p>
</div>
);
}
function KeyButton() {
const { t } = useTranslation();
const list = usePopoverList(-1, { estimatedHeight: 360 });
const { open, toggle, triggerRef, listId } = list;
const label = t('journey.map.legend');
return (
<>
<Tooltip label={label} placement="bottom" disabled={open}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
aria-controls={open ? listId : undefined}
data-testid="journey-map-key-button"
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-hover ${open ? 'bg-surface-hover' : ''}`}
>
<Info size={15} strokeWidth={2} />
</button>
</Tooltip>
<PopoverListPanel list={list} label={label} className="w-[268px]">
<JourneyMapKey />
</PopoverListPanel>
</>
);
}
/**
* The layer switches of a journey map (JRN-04), in the frosted pill the
* planner's map controls wear: one per layer the reader is offered, each with
* its count, a spinner while it loads and the warning tone when it could not
* be read, and the key beside them. On a phone the switches drop their words;
* the tooltip and the accessible name still say what each is.
*/
export default function JourneyMapLayerControl({
layers,
compact = false,
}: {
layers: JourneyMapLayersView;
compact?: boolean;
}) {
const { t } = useTranslation();
const live = layers.controls
.filter((control) => control.state === 'loading' || control.state === 'unavailable')
.map((control) => hintOf(control, t))
.join('. ');
return (
<div
style={FROSTED_SHELL}
role="group"
aria-label={t('journey.map.layers')}
className="max-w-full gap-0.5 overflow-x-auto"
>
{layers.controls.map((control) => (
<LayerToggle
key={control.layer}
control={control}
on={layers.visibility[control.layer]}
onToggle={() => layers.toggle(control.layer)}
compact={compact}
/>
))}
<span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />
<KeyButton />
<span role="status" aria-live="polite" className="sr-only">
{live}
</span>
</div>
);
}
@@ -0,0 +1,89 @@
// FE-JOURNEY-MAPPICK-001 to FE-JOURNEY-MAPPICK-003
//
// JRN-04: what a picked plan place or stay is, and several on one spot as a list.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14', '2026-05-15'],
time: '10:00',
category_color: null,
category_icon: null,
entry_id: null,
};
const STAY: JourneyMapStay = {
id: 9,
entry_id: 7,
name: 'Rialto market',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
};
const plan = (over: Partial<JourneyMapPlace> = {}): JourneyMapMark => ({
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: { ...PLACE, ...over },
});
const stay: JourneyMapMark = { kind: 'stay', key: 'stay:9', lat: 45.438, lng: 12.336, color: '#14b8a6', stay: STAY };
describe('JourneyMapPickCard', () => {
it('FE-JOURNEY-MAPPICK-001: a planned place names its trip and days and says when nothing was written about it', () => {
render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onOpenEntry={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
expect(screen.getByText('Planned')).toBeInTheDocument();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText('10:00')).toBeInTheDocument();
expect(screen.getByText('Nothing written about it yet.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open entry' })).toBeNull();
});
it('FE-JOURNEY-MAPPICK-002: a place written about and a stay lead to their entry', () => {
const onOpenEntry = vi.fn();
const { unmount } = render(
<JourneyMapPickCard marks={[plan({ entry_id: 12 })]} onClose={vi.fn()} onOpenEntry={onOpenEntry} />
);
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenCalledWith('12');
unmount();
render(
<JourneyMapPickCard
marks={[stay]}
onClose={vi.fn()}
onOpenEntry={onOpenEntry}
entryTitleOf={() => 'A day in Venice'}
/>
);
expect(screen.getByText('Part of “A day in Venice”')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenLastCalledWith('7');
});
it('FE-JOURNEY-MAPPICK-003: several on one spot come as a list, each reachable, and Escape closes', () => {
const onClose = vi.fn();
render(<JourneyMapPickCard marks={[plan(), stay]} onClose={onClose} />);
expect(screen.getByRole('dialog', { name: 'At this spot' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Rialto market/ }));
expect(screen.getByRole('dialog', { name: 'Rialto market' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to the list' }));
expect(screen.getByRole('button', { name: /Piazza San Marco/ })).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { SoftPill, WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import type { JourneyMapMark } from './journeyMapMarks';
interface Props {
/** What was picked on the map: one mark, or several that lie on one spot. */
marks: JourneyMapMark[];
onClose: () => void;
/** Leads to an entry; absent where the reader cannot open one. */
onOpenEntry?: (entryId: string) => void;
/** The title of an entry the reader can see, for the stay that belongs to it. */
entryTitleOf?: (entryId: number) => string | null;
}
function dayLabel(date: string, locale: string): string {
return new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
}
function timeLabel(iso: string, locale: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (entryId: string) => void }) {
const { t } = useTranslation();
if (!onOpenEntry) return null;
return (
<button
type="button"
onClick={() => onOpenEntry(String(entryId))}
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
style={fs(11.5, 'body')}
>
<NotebookPen size={12} strokeWidth={2.2} aria-hidden />
{t('journey.map.pick.openEntry')}
</button>
);
}
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
const { t, locale } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill icon={<CalendarRange size={11} strokeWidth={2} />}>{t('journey.map.pick.planned')}</SoftPill>
<WhiteBadge>{place.trip_title}</WhiteBadge>
{place.days.map((day) => (
<WhiteBadge key={day}>{dayLabel(day, locale)}</WhiteBadge>
))}
{place.time && <WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>{place.time}</WhiteBadge>}
</div>
{place.entry_id !== null ? (
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
) : (
<p className="text-content-muted" style={fs(11.5, 'body')}>
{t('journey.map.pick.notWritten')}
</p>
)}
</div>
);
}
function StayDetail({
stay,
onOpenEntry,
entryTitleOf,
}: { stay: JourneyMapStay } & Pick<Props, 'onOpenEntry' | 'entryTitleOf'>) {
const { t, locale } = useTranslation();
const entryTitle = entryTitleOf?.(stay.entry_id);
const from = timeLabel(stay.started_at, locale);
const to = timeLabel(stay.ended_at, locale);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill tone="info" icon={<MapPin size={11} strokeWidth={2} />}>
{t('journey.map.pick.stay')}
</SoftPill>
{from && (
<WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>
{to ? t('journey.map.pick.timeRange', { from, to }) : from}
</WhiteBadge>
)}
</div>
{entryTitle && (
<p className="truncate text-content-secondary" style={fs(11.5, 'body')}>
{t('journey.map.pick.partOf', { title: entryTitle })}
</p>
)}
<OpenEntry entryId={stay.entry_id} onOpenEntry={onOpenEntry} />
</div>
);
}
function markName(mark: JourneyMapMark): string {
return mark.kind === 'plan' ? mark.place.name : mark.stay.name;
}
/**
* What a mark picked on the journey map is (JRN-04), docked over the map's
* corner: a planned place with its trip, its days and the entry written about
* it, or a stay with its times and the report it belongs to. Several marks on
* one spot come as a list first, each with what kind it is, so overlapping
* hits stay reachable one by one.
*/
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
const { t, locale } = useTranslation();
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const title = picked ? markName(picked) : t('journey.map.pick.here');
return (
<div
role="dialog"
aria-label={title}
data-testid="journey-map-pick"
className="pointer-events-auto flex w-[280px] max-w-[calc(100%-24px)] flex-col gap-2 rounded-xl border border-edge bg-surface-card p-3 shadow-lg"
>
<div className="flex items-start gap-1.5">
{picked && marks.length > 1 && (
<button
type="button"
onClick={() => setPicked(null)}
aria-label={t('journey.map.pick.back')}
className="-ms-1 grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<ChevronLeft size={14} strokeWidth={2.2} className="rtl:rotate-180" />
</button>
)}
<span className="min-w-0 flex-1 font-semibold text-content" style={fs(13, 'body')}>
{title}
</span>
{!picked && <WhiteBadge>{marks.length}</WhiteBadge>}
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<X size={14} strokeWidth={2.2} />
</button>
</div>
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
{picked?.kind === 'stay' && (
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
)}
{!picked && (
<ul className="-mx-1 flex max-h-[220px] flex-col overflow-y-auto">
{marks.map((mark) => (
<li key={mark.key}>
<button
type="button"
onClick={() => setPicked(mark)}
className="flex w-full items-center gap-2 rounded-lg px-1.5 py-1.5 text-start hover:bg-surface-hover"
>
<span aria-hidden className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: mark.color }} />
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{markName(mark)}
</span>
<SoftPill tone={mark.kind === 'plan' ? 'neutral' : 'info'}>
{mark.kind === 'plan' ? t('journey.map.pick.planned') : t('journey.map.pick.stay')}
</SoftPill>
{mark.kind === 'plan' && mark.place.days[0] && (
<WhiteBadge>{dayLabel(mark.place.days[0], locale)}</WhiteBadge>
)}
<ChevronRight size={13} strokeWidth={2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,4 +1,4 @@
// FE-JRN-SHARE-001 to FE-JRN-SHARE-011
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -214,4 +214,35 @@ describe('JourneyShareSection', () => {
// The temporary textarea is removed again.
expect(document.querySelector('textarea')).toBeNull()
})
it('FE-JRN-SHARE-013: the trip plan and the recorded routes switch on under the map, each on its own (JRN-04)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })),
http.post('/api/journeys/7/share-link', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ token: 'tok-abc' })
}),
)
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toHaveAttribute('aria-pressed', 'false')
act(() => { plan.click() })
await waitFor(() => expect(bodies).toHaveLength(1))
expect(bodies[0]).toMatchObject({ share_plan: true })
expect(bodies[0]).not.toHaveProperty('share_recordings')
expect(screen.getByRole('button', { name: /Trip plan/ })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-JRN-SHARE-014: without the map shared the map extras are dimmed and cannot be switched', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink({ share_map: false, share_plan: true }) })))
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toBeDisabled()
expect(plan).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
})
})
@@ -1,6 +1,48 @@
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
import { Link, List, Grid, MapPin, Check, CalendarRange, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useJourneyShareLink } from './useJourneyShareLink'
import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { SHARE_MAP_EXTRAS, useJourneyShareLink, type JourneyShareLink, type JourneyShareMapExtra } from './useJourneyShareLink'
const EXTRA_ICONS = { share_plan: CalendarRange, share_recordings: Route } as const
/**
* What the shared map shows besides the entries (JRN-04): the trips' plan and
* the recorded days, each off until the owner switches it on, nested under the
* map and dimmed with the reason while the map is not shared.
*/
function ShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => void }) {
const { t } = useTranslation()
const mapShared = !!link.share_map
return (
<div className="ms-5 flex flex-col gap-1.5 border-s border-edge ps-3">
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => {
const Icon = EXTRA_ICONS[key]
const on = mapShared && !!link[key]
return (
<Tooltip key={key} label={mapShared ? '' : t('journey.share.needsMap')} placement="top">
<button
type="button"
aria-pressed={on}
disabled={!mapShared}
onClick={() => onToggle(key)}
className={`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-start transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
on ? 'border-accent bg-accent text-accent-text' : 'border-edge text-content-muted hover:border-content-faint'
}`}
>
<Icon size={13} className="mt-0.5 flex-none" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium" style={fs(12, 'body')}>{t(labelKey)}</span>
<span className={on ? 'text-accent-text opacity-80' : 'text-content-faint'} style={fs(11)}>{t(hintKey)}</span>
</span>
{on && <Check size={12} className="mt-0.5 flex-none" />}
</button>
</Tooltip>
)
})}
</div>
)
}
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
@@ -61,6 +103,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
{link[key] && <Check size={12} className="ms-auto" />}
</button>
))}
<ShareMapExtras link={link} onToggle={key => void togglePerm(key)} />
</div>
</div>
)}
@@ -1,6 +1,8 @@
import { useRef, useEffect } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import JourneyLayeredMap from './JourneyLayeredMap'
import type { JourneyMapLayersView } from './useJourneyMapLayers'
import type { MapPhoto } from './journeyPhotoLayer'
import JourneyEntryCover from './JourneyEntryCover'
import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf } from './journeyCard'
@@ -35,6 +37,14 @@ interface Props {
showWeather?: boolean
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
initialEntryId?: string | null
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
layers?: JourneyMapLayersView | null
entryTitleOf?: (entryId: number) => string | null
/** Geotagged photos on the map, a tap opening them. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
/** Where the layer switches sit, below a bar laid over the top of the map. */
controlsTop?: number | string
}
export default function MobileMapTimeline({
@@ -52,6 +62,11 @@ export default function MobileMapTimeline({
showMood = true,
showWeather = true,
initialEntryId,
layers,
entryTitleOf,
photos,
onPhotoClick,
controlsTop,
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
@@ -96,12 +111,20 @@ export default function MobileMapTimeline({
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
@@ -128,12 +151,20 @@ export default function MobileMapTimeline({
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */}
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
@@ -0,0 +1,67 @@
import L from 'leaflet';
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
/**
* Puts each layer whose switch changed back on the map or takes it off,
* exactly as it was drawn, and answers what is shown now.
*/
export function switchLeafletLayers(
map: L.Map | null,
drawn: DrawnLayers,
shown: JourneyMapLayerVisibility,
next: JourneyMapLayerVisibility
): JourneyMapLayerVisibility {
if (!map) return shown;
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
if (shown[kind] === next[kind]) continue;
for (const layer of drawn[kind]) {
if (next[kind]) layer.addTo(map);
else layer.remove();
}
}
return next;
}
/**
* The plan's places and the stays as one layer: below the entry pins, above
* the photo thumbnails, joined only where they would overlap on screen.
* Null while there is nothing to draw or the map has no view yet.
*/
export function drawLeafletMarks(
map: L.Map,
marks: readonly JourneyMapMark[] | undefined,
onPick: (members: JourneyMapMark[]) => void
): L.LayerGroup | null {
if (!marks?.length) return null;
try {
map.getCenter();
} catch {
// No view yet: the moveend after the first fit draws them.
return null;
}
const group = L.layerGroup();
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members);
const marker = L.marker([hit.lat, hit.lng], {
icon: L.divIcon({
className: '',
iconSize: [look.width, look.height],
iconAnchor: [look.width / 2, look.anchor === 'bottom' ? look.height : look.height / 2],
html: look.html,
}),
zIndexOffset: -200,
keyboard: false,
});
marker.on('click', () => onPick(hit.members));
group.addLayer(marker);
}
group.addTo(map);
return group;
}
@@ -0,0 +1,172 @@
// FE-JOURNEY-MAPLAYERS-001 to FE-JOURNEY-MAPLAYERS-008
//
// JRN-04: the journey map's layer switches as kept in this browser, the marks
// the plan and the stays become, and the switches a reader is offered.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
layersStorageKey,
parseStoredLayers,
readStoredLayers,
sourcesOf,
stateOf,
writeStoredLayers,
} from './journeyMapLayers';
const place = (over: Partial<JourneyMapPlace> = {}): JourneyMapPlace => ({
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: '#e11d48',
category_icon: null,
entry_id: null,
...over,
});
const stay = (over: Partial<JourneyMapStay> = {}): JourneyMapStay => ({
id: 9,
entry_id: 7,
name: 'Rialto',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
...over,
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('journey map layers', () => {
it('FE-JOURNEY-MAPLAYERS-001: keeps the switches per journey, and a link only by a hash of its token', () => {
expect(layersStorageKey({ journeyId: 12 })).toBe('trek:journey-map-layers:12');
const pub = layersStorageKey({ token: 'secret-token-value' });
expect(pub).toMatch(/^trek:journey-map-layers:pub:[0-9a-z]+$/);
expect(pub).not.toContain('secret');
expect(layersStorageKey({ token: 'secret-token-value' })).toBe(pub);
expect(layersStorageKey({ token: 'other' })).not.toBe(pub);
});
it('FE-JOURNEY-MAPLAYERS-002: reads a stored value leniently, a layer it does not name at its default', () => {
expect(parseStoredLayers(null)).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('not json')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('[true]')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('{"plan":false,"photos":"no","extra":true}')).toEqual({ ...ALL_VISIBLE, plan: false });
});
it('FE-JOURNEY-MAPLAYERS-003: storage that refuses leaves every layer on and the switch working for the visit', () => {
writeStoredLayers('k', { ...ALL_VISIBLE, entries: false });
expect(readStoredLayers('k')).toEqual({ ...ALL_VISIBLE, entries: false });
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(readStoredLayers('k')).toEqual(ALL_VISIBLE);
expect(() => writeStoredLayers('k', ALL_VISIBLE)).not.toThrow();
});
it('FE-JOURNEY-MAPLAYERS-004: a planned place an entry pin came from is that pin, with the check; without entries it stands alone', () => {
const written = place({ place_id: 4, entry_id: 7 });
const alone = place({ place_id: 5, entry_id: null, category_color: 'red;evil' });
const onMap = new Set(['7']);
const both = buildMarks({
places: [written, alone],
stays: [],
visibility: ALL_VISIBLE,
entryIdsOnMap: onMap,
stayColor: () => null,
});
expect(both.marks.map((m) => m.key)).toEqual(['plan:5']);
expect(both.marks[0].color).toBe('#64748b');
expect([...both.plannedEntryIds]).toEqual(['7']);
const noEntries = buildMarks({
places: [written, alone],
stays: [stay()],
visibility: { ...ALL_VISIBLE, entries: false },
entryIdsOnMap: onMap,
stayColor: () => '#14b8a6',
});
expect(noEntries.marks.map((m) => m.key)).toEqual(['plan:4', 'plan:5']);
expect(noEntries.plannedEntryIds.size).toBe(0);
});
it('FE-JOURNEY-MAPLAYERS-005: stays follow the entries switch in the colour of their day, the plan its own switch', () => {
const marks = buildMarks({
places: [place()],
stays: [stay(), stay({ id: 10, entry_id: 8 })],
visibility: { ...ALL_VISIBLE, plan: false },
entryIdsOnMap: new Set(),
stayColor: (entryId) => (entryId === 7 ? '#14b8a6' : null),
}).marks;
expect(marks.map((m) => [m.key, m.color])).toEqual([
['stay:9', '#14b8a6'],
['stay:10', '#64748b'],
]);
});
it('FE-JOURNEY-MAPLAYERS-006: a failed read makes every source unavailable; nothing read yet is idle, not empty', () => {
expect(sourcesOf(null).places).toEqual({ status: 'unavailable' });
expect(sourcesOf(undefined)).toEqual({});
expect(stateOf(undefined, false, 0)).toBe('idle');
expect(stateOf(undefined, true, 0)).toBe('loading');
expect(stateOf({ status: 'ok', items: [] }, false, 0)).toBe('empty');
expect(stateOf({ status: 'ok', items: [1] }, false, 1)).toBe('ready');
});
it('FE-JOURNEY-MAPLAYERS-007: a link offers no switch for what it withholds; a journey always offers its recordings', () => {
const withheld = { status: 'withheld' } as const;
const shared = buildControls({
ownJourney: false,
sources: { places: withheld, routes: withheld, stays: { status: 'ok', items: [] }, recordings: withheld },
loading: false,
counts: { places: 0, routes: 0, recordedDays: 0, entries: 3, stays: 0, photos: 0 },
});
expect(shared.map((c) => [c.layer, c.state])).toEqual([
['entries', 'ready'],
['photos', 'empty'],
]);
const own = buildControls({
ownJourney: true,
sources: {},
loading: false,
counts: { places: 0, routes: 0, recordedDays: 2, entries: 0, stays: 0, photos: 4 },
});
expect(own.map((c) => [c.layer, c.state, c.count])).toEqual([
['plan', 'idle', 0],
['recorded', 'ready', 2],
['entries', 'empty', 0],
['photos', 'ready', 4],
]);
});
it('FE-JOURNEY-MAPLAYERS-008: a failed route or stay read warns on its switch while the rest still counts', () => {
const controls = buildControls({
ownJourney: true,
sources: {
places: { status: 'ok', items: [] },
routes: { status: 'unavailable' },
stays: { status: 'unavailable' },
},
loading: false,
counts: { places: 3, routes: 0, recordedDays: 0, entries: 5, stays: 0, photos: 0 },
});
expect(controls.find((c) => c.layer === 'plan')).toEqual({ layer: 'plan', count: 3, state: 'unavailable' });
expect(controls.find((c) => c.layer === 'entries')).toEqual({ layer: 'entries', count: 5, state: 'unavailable' });
});
});
@@ -0,0 +1,244 @@
import type {
JourneyMapLayerResult,
JourneyMapLayersResponse,
JourneyMapPlace,
JourneyMapStay,
JourneyPublicMapLayersResponse,
JourneyTrack,
} from '@trek/shared';
import { safeColor, type JourneyMapMark } from './journeyMapMarks';
/**
* The four layers of a journey map (JRN-04), switched on and off one by one
* by each reader for themselves. A switch only ever hides: it changes no
* trip, entry or photo, and it never shows a reader what the journey does not
* give them.
*/
const JOURNEY_MAP_LAYERS = ['plan', 'recorded', 'entries', 'photos'] as const;
export type JourneyMapLayer = (typeof JOURNEY_MAP_LAYERS)[number];
export type JourneyMapVisibility = Record<JourneyMapLayer, boolean>;
/** The layers a renderer draws itself and switches in place; the photos it is handed or not. */
export type JourneyMapLayerVisibility = Pick<JourneyMapVisibility, 'plan' | 'recorded' | 'entries'>;
export const ALL_VISIBLE: JourneyMapVisibility = { plan: true, recorded: true, entries: true, photos: true };
/** What a layer's switch tells the reader beside its count; `idle` is a layer not read yet because it is off. */
type JourneyMapLayerState = 'ready' | 'loading' | 'unavailable' | 'empty' | 'idle';
export interface JourneyMapLayerControl {
layer: JourneyMapLayer;
count: number;
state: JourneyMapLayerState;
}
/** A switch's name, and what its tooltip adds when the layer has nothing to draw or could not be read. */
export const LAYER_TEXT: Record<JourneyMapLayer, { labelKey: string; emptyKey: string; unavailableKey: string }> = {
plan: {
labelKey: 'journey.map.layer.plan',
emptyKey: 'journey.map.layer.planEmpty',
unavailableKey: 'journey.map.layer.planUnavailable',
},
recorded: {
labelKey: 'journey.map.layer.recorded',
emptyKey: 'journey.map.layer.recordedEmpty',
unavailableKey: 'journey.map.layer.recordedUnavailable',
},
entries: {
labelKey: 'journey.map.layer.entries',
emptyKey: 'journey.map.layer.entriesEmpty',
unavailableKey: 'journey.map.layer.entriesUnavailable',
},
photos: {
labelKey: 'journey.map.layer.photos',
emptyKey: 'journey.map.layer.photosEmpty',
unavailableKey: 'journey.map.layer.photosEmpty',
},
};
const STORAGE_PREFIX = 'trek:journey-map-layers:';
/**
* Where a reader's switches are kept: per journey, and per link on a shared
* page. The link's token is the credential to the journey, so only a hash of
* it is written to the browser's storage.
*/
export function layersStorageKey(scope: { journeyId: number } | { token: string }): string {
if ('journeyId' in scope) return `${STORAGE_PREFIX}${scope.journeyId}`;
let hash = 0x811c9dc5;
for (let i = 0; i < scope.token.length; i++) {
hash ^= scope.token.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return `${STORAGE_PREFIX}pub:${(hash >>> 0).toString(36)}`;
}
/** A stored value read leniently: a layer it does not name stays at its default. */
export function parseStoredLayers(raw: string | null): JourneyMapVisibility {
if (!raw) return ALL_VISIBLE;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return ALL_VISIBLE;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return ALL_VISIBLE;
const stored = parsed as Record<string, unknown>;
const next = { ...ALL_VISIBLE };
for (const layer of JOURNEY_MAP_LAYERS) {
if (typeof stored[layer] === 'boolean') next[layer] = stored[layer] as boolean;
}
return next;
}
export function readStoredLayers(key: string): JourneyMapVisibility {
try {
return parseStoredLayers(window.localStorage.getItem(key));
} catch {
return ALL_VISIBLE;
}
}
export function writeStoredLayers(key: string, visibility: JourneyMapVisibility): void {
try {
window.localStorage.setItem(key, JSON.stringify(visibility));
} catch {
// A private window or blocked storage: the switch works for this visit only.
}
}
/** The items of a source that answered, nothing for one that did not. */
export function itemsOf<T>(layer: JourneyMapLayerResult<T> | undefined): T[] {
return layer?.status === 'ok' ? layer.items : [];
}
/** How a source that the reader may see stands, as its switch shows it. */
export function stateOf(
layer: JourneyMapLayerResult<unknown> | undefined,
loading: boolean,
count: number
): JourneyMapLayerState {
if (layer?.status === 'unavailable') return 'unavailable';
if (!layer) return loading ? 'loading' : 'idle';
return count > 0 ? 'ready' : 'empty';
}
/**
* The marks of the plan and of the stays, as the renderers draw them. A
* planned place an entry on the map came from is left to that entry's pin,
* which carries the plan's check instead: one spot, one pin. Without the
* entries layer the plan pin stands on its own again.
*/
export function buildMarks({
places,
stays,
visibility,
entryIdsOnMap,
stayColor,
}: {
places: JourneyMapPlace[];
stays: JourneyMapStay[];
visibility: JourneyMapVisibility;
entryIdsOnMap: ReadonlySet<string>;
stayColor: (entryId: number) => string | null;
}): { marks: JourneyMapMark[]; plannedEntryIds: ReadonlySet<string> } {
const marks: JourneyMapMark[] = [];
const plannedEntryIds = new Set<string>();
if (visibility.plan) {
for (const place of places) {
const entryId = place.entry_id === null ? null : String(place.entry_id);
if (visibility.entries && entryId && entryIdsOnMap.has(entryId)) {
plannedEntryIds.add(entryId);
continue;
}
marks.push({
kind: 'plan',
key: `plan:${place.place_id}`,
lat: place.lat,
lng: place.lng,
color: safeColor(place.category_color),
place,
});
}
}
if (visibility.entries) {
for (const stay of stays) {
marks.push({
kind: 'stay',
key: `stay:${stay.id}`,
lat: stay.lat,
lng: stay.lng,
color: safeColor(stayColor(stay.entry_id)),
stay,
});
}
}
return { marks, plannedEntryIds };
}
/** What the server answered for a map: a journey's own layers, or a link's with its recorded days. */
export type JourneyMapAnswer = JourneyMapLayersResponse | JourneyPublicMapLayersResponse;
const UNAVAILABLE = { status: 'unavailable' } as const;
/**
* Every source of an answer on its own: undefined while nothing was read, all
* unavailable when the read itself failed (null). The recorded days come only
* with a link's answer; a journey's own map reads them with their details.
*/
export function sourcesOf(answer: JourneyMapAnswer | null | undefined): {
places?: JourneyMapLayersResponse['places'];
routes?: JourneyMapLayersResponse['routes'];
stays?: JourneyMapLayersResponse['stays'];
recordings?: JourneyPublicMapLayersResponse['recordings'];
} {
if (answer === null) return { places: UNAVAILABLE, routes: UNAVAILABLE, stays: UNAVAILABLE, recordings: UNAVAILABLE };
if (!answer) return {};
const recordings = 'recordings' in answer ? (answer as JourneyPublicMapLayersResponse).recordings : undefined;
return { places: answer.places, routes: answer.routes, stays: answer.stays, recordings };
}
/**
* The switches a reader is offered, each with its count and state. A layer a
* share link withholds is not offered at all; one with nothing to draw is,
* dimmed, with the reason in its tooltip. A journey's own map always offers
* what was recorded, which the screen reads itself.
*/
export function buildControls({
ownJourney,
sources,
loading,
counts,
}: {
ownJourney: boolean;
sources: ReturnType<typeof sourcesOf>;
loading: boolean;
counts: { places: number; routes: number; recordedDays: number; entries: number; stays: number; photos: number };
}): JourneyMapLayerControl[] {
const list: JourneyMapLayerControl[] = [];
if (sources.places?.status !== 'withheld') {
const state =
sources.routes?.status === 'unavailable'
? 'unavailable'
: stateOf(sources.places, loading, counts.places + counts.routes);
list.push({ layer: 'plan', count: counts.places, state });
}
if (ownJourney || sources.recordings?.status !== 'withheld') {
const state = ownJourney
? counts.recordedDays > 0
? 'ready'
: 'empty'
: stateOf(sources.recordings, loading, counts.recordedDays);
list.push({ layer: 'recorded', count: counts.recordedDays, state });
}
const entriesState =
sources.stays?.status === 'unavailable' ? 'unavailable' : counts.entries + counts.stays > 0 ? 'ready' : 'empty';
list.push({ layer: 'entries', count: counts.entries, state: entriesState });
list.push({ layer: 'photos', count: counts.photos, state: counts.photos > 0 ? 'ready' : 'empty' });
return list;
}
/** Whether a track belongs to the plan (a trip's GPX) or to what was recorded. */
export function isPlannedTrack(track: JourneyTrack): boolean {
return !track.kind || track.kind === 'trip_gpx';
}
@@ -0,0 +1,57 @@
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
//
// JRN-04: the plan and stay marks both renderers draw, and how they join.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
MARK_CLUSTER_PX,
clusterPoints,
markHitSvg,
planPinSvg,
safeColor,
type JourneyMapMark,
} from './journeyMapMarks';
const planMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'plan',
key,
lat,
lng: 12,
color: '#e11d48',
place: { name: key } as JourneyMapPlace,
});
const stayMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'stay',
key,
lat,
lng: 12,
color: '#14b8a6',
stay: { name: key } as JourneyMapStay,
});
describe('journey map marks', () => {
it('FE-JOURNEY-MAPMARKS-001: joins only what would overlap on screen, anchored on the first', () => {
const project = (lat: number, lng: number) => ({ x: lng, y: lat * 100 });
const hits = clusterPoints([planMark('a', 1), stayMark('b', 1.05), planMark('c', 5)], project, MARK_CLUSTER_PX);
expect(hits.map((h) => h.members.map((m) => m.key))).toEqual([['a', 'b'], ['c']]);
expect(hits[0]).toMatchObject({ lat: 1, lng: 12 });
});
it('FE-JOURNEY-MAPMARKS-002: a lone plan is a hollow dashed pin, a lone stay a dot, several a disc with the count', () => {
const plan = markHitSvg([planMark('a', 1)]);
expect(plan).toMatchObject({ anchor: 'bottom', width: 22, height: 28 });
expect(plan.html).toContain('stroke-dasharray');
expect(markHitSvg([stayMark('b', 1)])).toMatchObject({ anchor: 'center', width: 14 });
const several = markHitSvg([planMark('a', 1), stayMark('b', 1), planMark('c', 1)]);
expect(several.anchor).toBe('center');
expect(several.html).toContain('>3<');
});
it('FE-JOURNEY-MAPMARKS-003: only a hex colour reaches the markup', () => {
expect(safeColor('#ABCDEF')).toBe('#ABCDEF');
expect(safeColor('#abc')).toBe('#abc');
expect(safeColor('"/><script>')).toBe('#64748b');
expect(safeColor(null)).toBe('#64748b');
expect(planPinSvg('red" onload="x')).not.toContain('onload');
});
});
@@ -0,0 +1,120 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
/**
* The marks a journey map draws beside its entries and photos (JRN-04): the
* places the plan puts on a day, and the stays a journal entry was taken over
* from. One module for both renderers, so a mark says the same on Leaflet and
* on GL. The markup is SVG with its colours as attributes: it is drawn over
* any basemap, so it keeps its own contrast instead of following the scheme.
*/
export type JourneyMapMark =
| { kind: 'plan'; key: string; lat: number; lng: number; color: string; place: JourneyMapPlace }
| { kind: 'stay'; key: string; lat: number; lng: number; color: string; stay: JourneyMapStay };
/** The colour of a planned place whose category has none, a neutral slate that reads on light and dark tiles. */
export const PLAN_FALLBACK_COLOR = '#64748b';
/**
* Marks closer than this on screen are one hit: the reader could not pick one
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
* thumbnail, so marks a street apart stay apart.
*/
export const MARK_CLUSTER_PX = 26;
const PLAN_PIN_W = 22;
const PLAN_PIN_H = 28;
const STAY_DOT = 14;
const MARK_CLUSTER_SIZE = 28;
/**
* Grid clustering in screen space, shared by both renderers: points whose
* projected position falls in the same `px` cell become one, anchored on the
* first so the pin points where that member is. `project` is the engine's own
* lat/lng to container-pixel conversion.
*/
export function clusterPoints<T extends { lat: number; lng: number }>(
points: readonly T[],
project: (lat: number, lng: number) => { x: number; y: number },
px: number
): { lat: number; lng: number; members: T[] }[] {
const buckets = new Map<string, T[]>();
for (const point of points) {
const pt = project(point.lat, point.lng);
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
const list = buckets.get(key);
if (list) list.push(point);
else buckets.set(key, [point]);
}
return [...buckets.values()].map((members) => ({ lat: members[0].lat, lng: members[0].lng, members }));
}
/** Only a `#rgb`/`#rrggbb` colour reaches markup; anything else a category holds falls back. */
export function safeColor(color: string | null | undefined, fallback = PLAN_FALLBACK_COLOR): string {
return color && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : fallback;
}
/**
* A planned place: a hollow pin, dashed in its category colour, smaller than
* an entry pin. Hollow on purpose: the plan is what was meant, not what was
* lived, and it never reads as a filled journal pin.
*/
export function planPinSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${PLAN_PIN_W}" height="${PLAN_PIN_H}" viewBox="0 0 22 28" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.25))">
<path d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z" fill="#ffffff" fill-opacity="0.92" stroke="${c}" stroke-width="2" stroke-dasharray="3 2"/>
<circle cx="11" cy="10.5" r="3" fill="${c}"/>
</svg>`;
}
/** A stay of a day report: a dot in the colour of its day, ringed so it holds on any tile. */
function stayDotSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${STAY_DOT}" height="${STAY_DOT}" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))">
<circle cx="7" cy="7" r="5.5" fill="${c}" stroke="#ffffff" stroke-width="2"/>
</svg>`;
}
/**
* Several marks on one spot: a stacked disc with the count, ringed in the
* colours of up to two members so the reader sees what kinds lie beneath.
*/
function markClusterSvg(count: number, colors: string[]): string {
const [a, b] = [safeColor(colors[0]), safeColor(colors[1] ?? colors[0])];
const label = count > 99 ? '99+' : String(count);
return `<svg width="${MARK_CLUSTER_SIZE}" height="${MARK_CLUSTER_SIZE}" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" style="display:block;cursor:pointer;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke="${b}" stroke-width="2"/>
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke="${a}" stroke-width="2.5"/>
<text x="12" y="16" text-anchor="middle" dominant-baseline="central" fill="#111827" font-family="'Poppins',system-ui,sans-serif" font-size="10" font-weight="700">${label}</text>
</svg>`;
}
/** The markup of one hit on the map: a mark alone, or the disc of several. */
export function markHitSvg(members: readonly JourneyMapMark[]): {
html: string;
width: number;
height: number;
anchor: 'bottom' | 'center';
} {
if (members.length > 1) {
return {
html: markClusterSvg(
members.length,
members.map((m) => m.color)
),
width: MARK_CLUSTER_SIZE,
height: MARK_CLUSTER_SIZE,
anchor: 'center',
};
}
const mark = members[0];
return mark.kind === 'plan'
? { html: planPinSvg(mark.color), width: PLAN_PIN_W, height: PLAN_PIN_H, anchor: 'bottom' }
: { html: stayDotSvg(mark.color), width: STAY_DOT, height: STAY_DOT, anchor: 'center' };
}
/**
* The small check an entry pin carries when the plan had that place too: plan
* and memory at one spot read as one pin, not two on top of each other. Sits
* at the pin's top corner, inside its 28 x 36 box.
*/
export const PLANNED_BADGE_SVG = `<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" stroke-width="1.2"/><path d="M20.4 5.6l1.5 1.5 2.8-2.9" fill="none" stroke="#16a34a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
@@ -1,3 +1,5 @@
import { clusterPoints } from './journeyMapMarks'
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
@@ -22,21 +24,9 @@ export function clusterPhotos(
photos: MapPhoto[],
project: (lat: number, lng: number) => { x: number; y: number },
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = project(photo.lat, photo.lng)
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
// Anchored on the first member rather than the centroid: the thumbnail shown
// is that photo's, so the pin should point where that picture was taken.
return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
}
export function photoMarkerHtml(thumbUrl: string, count: number): string {
@@ -0,0 +1,196 @@
// FE-JOURNEY-MAPLAYERS-HOOK-001 to FE-JOURNEY-MAPLAYERS-HOOK-005
//
// JRN-04: the journey map's layers as the desktop page, the phone screen and
// the shared page use them: read lazily, switched per reader, a failed source
// on its own.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { useJourneyMapLayers } from './useJourneyMapLayers';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, string>) => (p?.date ? `${k}:${p.date}` : k),
locale: 'en',
}),
}));
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: null,
category_icon: null,
entry_id: null,
};
const ROUTE: JourneyTrack = {
place_id: 4,
trip_id: 2,
name: 'Ridge',
color: null,
points: [
[1, 1],
[2, 2],
],
kind: 'trip_gpx',
};
const NO_ENTRIES: ReadonlySet<string> = new Set();
const colorOfDay = () => '#14b8a6';
const dateOfEntry = () => '2026-05-14';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
function journeyMap(journeyId: number | null = 7, recordedTracks: JourneyTrack[] = []) {
return renderHook(() =>
useJourneyMapLayers({
source: journeyId === null ? null : { journeyId },
entryIdsOnMap: NO_ENTRIES,
photoCount: 2,
recordedTracks,
colorOfDay,
dateOfEntry,
})
);
}
describe('useJourneyMapLayers', () => {
it('FE-JOURNEY-MAPLAYERS-HOOK-001: reads the journey layers once it is loaded and draws the plan with its routes', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [ROUTE] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
expect(layers).toHaveBeenCalledWith(7, expect.any(AbortSignal));
expect(result.current.tracks).toEqual([ROUTE]);
expect(result.current.controls.find((c) => c.layer === 'plan')).toEqual({
layer: 'plan',
count: 1,
state: 'ready',
});
expect(result.current.controls.find((c) => c.layer === 'photos')).toEqual({
layer: 'photos',
count: 2,
state: 'ready',
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-002: reads nothing before the journey is loaded, nor while plan and entries are off', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
journeyMap(null);
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
const { result } = journeyMap(8);
expect(result.current.visibility).toMatchObject({ plan: false, entries: false, recorded: true, photos: true });
expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('idle');
await act(async () => {});
expect(layers).not.toHaveBeenCalled();
act(() => result.current.toggle('plan'));
await waitFor(() => expect(layers).toHaveBeenCalledWith(8, expect.any(AbortSignal)));
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toMatchObject({
plan: true,
entries: false,
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
const recorded: JourneyTrack = {
place_id: -100001,
trip_id: 0,
name: 'Day 1',
color: '#000000',
points: [
[1, 1],
[2, 2],
],
kind: 'day_recording',
};
const { result } = journeyMap(7, [recorded]);
await waitFor(() => expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('unavailable'));
expect(result.current.controls.find((c) => c.layer === 'entries')?.state).toBe('unavailable');
expect(result.current.controls.find((c) => c.layer === 'recorded')).toEqual({
layer: 'recorded',
count: 1,
state: 'ready',
});
expect(result.current.tracks).toEqual([recorded]);
});
it('FE-JOURNEY-MAPLAYERS-HOOK-004: a shared page draws the recorded days a link shares, each piece apart, and offers no plan it withholds', async () => {
vi.spyOn(journeyMapRepo, 'publicLayers').mockResolvedValue({
places: { status: 'withheld' },
routes: { status: 'withheld' },
stays: { status: 'ok', items: [] },
recordings: {
status: 'ok',
items: [
{
date: '2026-05-14',
pieces: [
[
[1, 1],
[2, 2],
],
[
[3, 3],
[4, 4],
],
],
distance_m: 1200,
},
],
},
});
const { result } = renderHook(() =>
useJourneyMapLayers({
source: { token: 'tok' },
entryIdsOnMap: NO_ENTRIES,
photoCount: 0,
colorOfDay,
dateOfEntry,
})
);
await waitFor(() => expect(result.current.tracks).toHaveLength(2));
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
true
);
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
});
it('FE-JOURNEY-MAPLAYERS-HOOK-005: switching the plan off takes its marks away without reading again', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
act(() => result.current.toggle('plan'));
expect(result.current.marks).toHaveLength(0);
expect(result.current.visibility.plan).toBe(false);
expect(layers).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,207 @@
import type { JourneyTrack } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
itemsOf,
layersStorageKey,
readStoredLayers,
sourcesOf,
writeStoredLayers,
type JourneyMapAnswer,
type JourneyMapLayer,
type JourneyMapLayerControl,
type JourneyMapVisibility,
} from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
/** Whose map it is: a journey its reader belongs to, or one a share link opens. */
type JourneyMapSource = { journeyId: number } | { token: string };
export interface JourneyMapLayersView {
visibility: JourneyMapVisibility;
toggle: (layer: JourneyMapLayer) => void;
/** The switches the reader is offered: a layer a share link does not show is not among them. */
controls: JourneyMapLayerControl[];
/** The plan's routes and what was recorded, each kind hidden by its own switch on the map. */
tracks: JourneyTrack[];
marks: JourneyMapMark[];
/** Entry pins whose place the plan had too: they carry the plan's check. */
plannedEntryIds: ReadonlySet<string>;
}
interface Options {
/** Null until the journey the screen names is the one loaded. */
source: JourneyMapSource | null;
/** Changes when entries come or go, so a place's entry and a report's stays follow. */
refreshKey?: string;
/** The entries that have a pin on the map. */
entryIdsOnMap: ReadonlySet<string>;
photoCount: number;
/** What was recorded that the screen already holds: the kept days and the live trail of a journey's people. */
recordedTracks?: JourneyTrack[];
/** The colour of the pins of a day, so a stay and a recorded day read with their day. */
colorOfDay: (date: string) => string | null;
/** The day an entry is on, for the colour of its stays. */
dateOfEntry: (entryId: number) => string | null;
}
const NO_TRACKS: JourneyTrack[] = [];
/** The reader's switches, kept per journey (or per link) in this browser. */
function useLayerSwitches(storageKey: string | null) {
const [stored, setStored] = useState<{ key: string | null; visibility: JourneyMapVisibility } | null>(null);
const visibility = useMemo(() => {
if (stored && stored.key === storageKey) return stored.visibility;
return storageKey ? readStoredLayers(storageKey) : ALL_VISIBLE;
}, [stored, storageKey]);
const toggle = useCallback(
(layer: JourneyMapLayer) => {
const next = { ...visibility, [layer]: !visibility[layer] };
setStored({ key: storageKey, visibility: next });
if (storageKey) writeStoredLayers(storageKey, next);
},
[visibility, storageKey]
);
return { visibility, toggle };
}
/**
* The layers read from the server, once per `requestKey`. What was read for
* an earlier key of the same map stays until the new answer lands; null is a
* read that failed.
*/
function useLayersRead(
journeyId: number | null,
token: string | null,
requestKey: string | null,
sourceKey: string | null
) {
const [loaded, setLoaded] = useState<{ key: string; layers: JourneyMapAnswer | null } | null>(null);
useEffect(() => {
if (!requestKey) return;
const controller = new AbortController();
const read =
journeyId !== null
? journeyMapRepo.layers(journeyId, controller.signal)
: journeyMapRepo.publicLayers(token ?? '', controller.signal);
read
.then((layers) => setLoaded({ key: requestKey, layers }))
.catch(() => {
if (!controller.signal.aborted) setLoaded({ key: requestKey, layers: null });
});
return () => controller.abort();
}, [requestKey, journeyId, token]);
const layers = loaded && sourceKey && loaded.key.startsWith(`${sourceKey}|`) ? loaded.layers : undefined;
return { layers, loading: !!requestKey && loaded?.key !== requestKey };
}
/**
* The layers of a journey map (JRN-04) for the desktop page, the phone screen
* and the shared page alike: the reader's switches, kept per journey in this
* browser, the plan and the stays read from the server, and what the
* renderers draw from them. Nothing is read while neither the plan nor the
* entries are shown; a source that fails says so on its switch and the
* others keep drawing.
*/
export function useJourneyMapLayers({
source,
refreshKey = '',
entryIdsOnMap,
photoCount,
recordedTracks = NO_TRACKS,
colorOfDay,
dateOfEntry,
}: Options): JourneyMapLayersView {
const { t, locale } = useTranslation();
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
const token = source && 'token' in source ? source.token : null;
const storageKey =
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
const { visibility, toggle } = useLayerSwitches(storageKey);
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
const wanted = token !== null || (journeyId !== null && (visibility.plan || visibility.entries));
const requestKey = wanted && sourceKey ? `${sourceKey}|${refreshKey}` : null;
const { layers, loading } = useLayersRead(journeyId, token, requestKey, sourceKey);
const sources = useMemo(() => sourcesOf(layers), [layers]);
const places = useMemo(() => itemsOf(sources.places), [sources.places]);
const routes = useMemo(() => itemsOf(sources.routes), [sources.routes]);
const stays = useMemo(() => itemsOf(sources.stays), [sources.stays]);
const sharedRecorded = useMemo(
() =>
itemsOf(sources.recordings).flatMap((day, dayIndex) => {
const name = t('journey.recording.mapLine', {
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const color = colorOfDay(day.date) ?? DAWARICH_TRAIL_COLOR;
return day.pieces.map((points, index): JourneyTrack => ({
// Negative and far from the live trail's ids, as the journey's own kept days.
place_id: -(100_000 + dayIndex * 1_000 + index),
trip_id: 0,
name,
color,
points,
kind: 'day_recording',
}));
}),
[sources.recordings, colorOfDay, t, locale]
);
const recorded = recordedTracks.length ? recordedTracks : sharedRecorded;
const tracks = useMemo(
() => (routes.length === 0 ? recorded : recorded.length === 0 ? routes : [...routes, ...recorded]),
[routes, recorded]
);
const stayColor = useCallback(
(entryId: number) => {
const date = dateOfEntry(entryId);
return date ? colorOfDay(date) : null;
},
[dateOfEntry, colorOfDay]
);
const { marks, plannedEntryIds } = useMemo(
() => buildMarks({ places, stays, visibility, entryIdsOnMap, stayColor }),
[places, stays, visibility, entryIdsOnMap, stayColor]
);
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
const controls = useMemo(
() =>
buildControls({
ownJourney: journeyId !== null,
sources,
loading,
counts: {
places: places.length,
routes: routes.length,
recordedDays,
entries: entryIdsOnMap.size,
stays: stays.length,
photos: photoCount,
},
}),
[
journeyId,
sources,
loading,
places.length,
routes.length,
recordedDays,
entryIdsOnMap.size,
stays.length,
photoCount,
]
);
return { visibility, toggle, controls, tracks, marks, plannedEntryIds };
}
@@ -1,4 +1,4 @@
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-009: the public share link logic behind
// both the desktop share section and the phone settings sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
@@ -123,4 +123,15 @@ describe('useJourneyShareLink', () => {
act(() => vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
it('FE-JRN-SHAREHOOK-009: the plan and the recorded days are switched on their own and start off (JRN-04)', async () => {
const { result } = await loaded();
await act(() => result.current.togglePerm('share_plan'));
expect(posted).toEqual([{ share_timeline: true, share_gallery: false, share_map: true, share_plan: true }]);
expect(result.current.link?.share_plan).toBe(true);
expect(result.current.link?.share_recordings).toBeUndefined();
await act(() => result.current.togglePerm('share_recordings'));
expect(posted[1]).toEqual({ share_timeline: true, share_gallery: false, share_map: true, share_recordings: true });
});
});
@@ -10,9 +10,20 @@ export interface JourneyShareLink {
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
/** JRN-04: the trips' plan and the recorded days on the shared map; off unless switched on. */
share_plan?: boolean;
share_recordings?: boolean;
}
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map';
/** What the shared map can show besides the entries, each only while the map itself is shared. */
export type JourneyShareMapExtra = 'share_plan' | 'share_recordings';
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map' | JourneyShareMapExtra;
/** The map extras the share switches offer, with what each puts on the shared map. */
export const SHARE_MAP_EXTRAS: { key: JourneyShareMapExtra; labelKey: string; hintKey: string }[] = [
{ key: 'share_plan', labelKey: 'journey.share.plan', hintKey: 'journey.share.planHint' },
{ key: 'share_recordings', labelKey: 'journey.share.recordings', hintKey: 'journey.share.recordingsHint' },
];
/** The public address of a journey share token. */
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
@@ -71,6 +82,8 @@ export function useJourneyShareLink(journeyId: number) {
share_timeline: updated.share_timeline,
share_gallery: updated.share_gallery,
share_map: updated.share_map,
// The map extras go only when switched: a link read before they existed keeps them off.
...(key === 'share_plan' || key === 'share_recordings' ? { [key]: !!updated[key] } : {}),
});
} catch {
setLink(previous);
+2 -18
View File
@@ -1,14 +1,14 @@
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import { PisteKeyButton } from '../Tours/winter/PisteKey'
import type { ComponentType, CSSProperties } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
import type { FerrataStatus } from '../Ferratas/useTripFerratas'
import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
@@ -25,14 +25,6 @@ export interface TourLayerOffer {
baseHealth?: BaseMapHealth
}
const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
// Both map engines place the switcher with the inset, so they cannot drift apart either.
@@ -92,14 +84,6 @@ const TOUR_LAYERS = [
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
/** A pill segment of the Tours control; `filled` lets its class paint the background. */
const segmentStyle = (filled: boolean): CSSProperties => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: filled ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
})
type Translate = (key: string, params?: Record<string, string | number>) => string
/** What the via ferrata layer is doing, in words, for the toggle's tooltip and the screen reader. */
@@ -1,3 +1,5 @@
import type { CSSProperties } from 'react'
/**
* The drop shadow every floating map control carries.
*
@@ -16,3 +18,20 @@ export const MAP_CARD_GLASS = {
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
/** The frosted round shell the map's control groups sit in. */
export const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
/** A pill segment of a map control group; `filled` lets its class paint the background. */
export const segmentStyle = (filled: boolean): CSSProperties => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: filled ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
})