diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 6836a5f7e..6c5dc9e0b 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -9,7 +9,7 @@ "max-params": 35, "trek/store-without-selector": 69, "react-hooks/refs": 558, - "react-hooks/set-state-in-effect": 197, + "react-hooks/set-state-in-effect": 196, "@typescript-eslint/no-floating-promises": 167, "react-hooks/exhaustive-deps": 121, "@typescript-eslint/no-unused-vars": 129, diff --git a/client/src/api/journeyMap.test.ts b/client/src/api/journeyMap.test.ts new file mode 100644 index 000000000..0f2a60681 --- /dev/null +++ b/client/src/api/journeyMap.test.ts @@ -0,0 +1,27 @@ +// FE-API-JOURNEY-MAP-001 +// +// JRN-04: both reads pin their path and hand back what the server answered. +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; +import { server } from '../../tests/helpers/msw/server'; +import { journeyMapApi } from './journeyMap'; + +describe('journeyMapApi', () => { + it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => { + const layers = { places: { status: 'withheld' } }; + const paths: string[] = []; + server.use( + http.get('/api/journeys/7/map-layers', ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json(layers); + }), + http.get('/api/public/journey/:token/map-layers', ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json(layers); + }) + ); + expect(await journeyMapApi.layers(7)).toEqual(layers); + expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers); + expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']); + }); +}); diff --git a/client/src/api/journeyMap.ts b/client/src/api/journeyMap.ts new file mode 100644 index 000000000..9528bf1c7 --- /dev/null +++ b/client/src/api/journeyMap.ts @@ -0,0 +1,10 @@ +import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared'; +import apiClient from './client'; + +/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */ +export const journeyMapApi = { + layers: (journeyId: number, signal?: AbortSignal): Promise => + apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data), + publicLayers: (token: string, signal?: AbortSignal): Promise => + apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data), +}; diff --git a/client/src/components/Journey/JourneyLayeredMap.test.tsx b/client/src/components/Journey/JourneyLayeredMap.test.tsx new file mode 100644 index 000000000..de1876e83 --- /dev/null +++ b/client/src/components/Journey/JourneyLayeredMap.test.tsx @@ -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[], engine: [] as string[] })); + +vi.mock('./JourneyMap', () => ({ + default: (props: Record) => { + seen.props.push(props); + seen.engine.push('leaflet'); + return null; + }, +})); +vi.mock('./JourneyMapAuto', () => ({ + default: (props: Record) => { + 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 { + 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(); + 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(); + 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( + + ); + 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( + + ); + 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(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/client/src/components/Journey/JourneyLayeredMap.tsx b/client/src/components/Journey/JourneyLayeredMap.tsx new file mode 100644 index 000000000..fe2196089 --- /dev/null +++ b/client/src/components/Journey/JourneyLayeredMap.tsx @@ -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, + '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; + /** 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 ( +
+
+ {engine === 'leaflet' ? : } +
+ {layers && ( +
+
+ +
+ {card && ( + + )} +
+ )} +
+ ); +} diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index cdd344c99..f7b1d3310 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -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(); + expect(marker.addTo).toHaveBeenCalledTimes(1); + + rerender(); + expect(marker.remove).toHaveBeenCalledTimes(1); + rerender(); + 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(); + + 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(); + 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; +} diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index e1f8c7f17..7bcd4667a 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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 } +/** 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 = 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 ${label} + ${planned ? PLANNED_BADGE_SVG : ''} ` } +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(null) const onPhotoClickRef = useRef(onPhotoClick) onPhotoClickRef.current = onPhotoClick + const markLayerRef = useRef(null) + const onMarksPickRef = useRef(onMarksPick) + // What each switchable layer drew, and which of them are on the map right now. + const drawnRef = useRef(NOTHING_DRAWN()) + const shownRef = useRef(layerVisibility) + const visibilityRef = useRef(layerVisibility) + const plannedRef = useRef(plannedEntryIds) + const iconPlannedRef = useRef>(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 = (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%' }} />
- - + +
) diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 5880557e2..90c2ed0ff 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -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 } function JourneyMapAuto({ ref, ...props }: Props) { diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index a069be3c9..c984f07bf 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -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 @@ -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() 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( + , + ) + + const layer = (id: string) => vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record; 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() + expect(gl.markers[0].element.innerHTML).not.toContain('#16a34a') + + rerender() + 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) + }) }) diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 115dd9d3f..7b1864775 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -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 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 = { + 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 = 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 ${label} + ${planned ? PLANNED_BADGE_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(layerVisibility) + const plannedRef = useRef(plannedEntryIds) + const iconPlannedRef = useRef>(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) => + 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(() => { diff --git a/client/src/components/Journey/JourneyMapLayerControl.test.tsx b/client/src/components/Journey/JourneyMapLayerControl.test.tsx new file mode 100644 index 000000000..6d54f2f84 --- /dev/null +++ b/client/src/components/Journey/JourneyMapLayerControl.test.tsx @@ -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 { + 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(); + + 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( + + ); + + 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(); + + 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'); + }); +}); diff --git a/client/src/components/Journey/JourneyMapLayerControl.tsx b/client/src/components/Journey/JourneyMapLayerControl.tsx new file mode 100644 index 000000000..10274e9bd --- /dev/null +++ b/client/src/components/Journey/JourneyMapLayerControl.tsx @@ -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> = { + 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 { + 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 ( + + + + ); +} + +/** A hollow plan pin, as the map draws it. */ +function PlanPinSample() { + return ( + + + + + ); +} + +/** A journal entry's pin, with the plan's check when the plan had that place too. */ +function EntryPinSample({ planned = false }: { planned?: boolean }) { + return ( + + + {planned && ( + <> + + + + )} + + ); +} + +function StaySample() { + return ( + + + + ); +} + +function SeveralSample() { + return ( + + + + + 3 + + + ); +} + +function LineSample({ dashed }: { dashed: boolean }) { + return ( + + + + + ); +} + +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: , labelKey: 'journey.map.legend.planned' }, + { sample: , labelKey: 'journey.map.legend.entry' }, + { sample: , labelKey: 'journey.map.legend.plannedWritten' }, + { sample: , labelKey: 'journey.map.legend.stay' }, + { sample: , labelKey: 'journey.map.legend.several' }, + ]; + return ( +
+ + {t('journey.map.legend')} + +
    + {rows.map((row) => ( +
  • + {row.sample} + {t(row.labelKey)} +
  • + ))} +
+
+ {t('journey.map.legend.lines')} +
    +
  • + + + + {t('journey.map.legend.plannedRoute')} +
  • +
  • + + + + {t('journey.map.legend.recordedRoute')} +
  • +
+
+

+ + {t('journey.map.legend.planIsNoVisit')} +

+
+ ); +} + +function KeyButton() { + const { t } = useTranslation(); + const list = usePopoverList(-1, { estimatedHeight: 360 }); + const { open, toggle, triggerRef, listId } = list; + const label = t('journey.map.legend'); + return ( + <> + + + + + + + + ); +} + +/** + * 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 ( +
+ {layers.controls.map((control) => ( + layers.toggle(control.layer)} + compact={compact} + /> + ))} + + + + {live} + +
+ ); +} diff --git a/client/src/components/Journey/JourneyMapPickCard.test.tsx b/client/src/components/Journey/JourneyMapPickCard.test.tsx new file mode 100644 index 000000000..53078c78e --- /dev/null +++ b/client/src/components/Journey/JourneyMapPickCard.test.tsx @@ -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 = {}): 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(); + + 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( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open entry' })); + expect(onOpenEntry).toHaveBeenCalledWith('12'); + unmount(); + + render( + '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(); + + 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(); + }); +}); diff --git a/client/src/components/Journey/JourneyMapPickCard.tsx b/client/src/components/Journey/JourneyMapPickCard.tsx new file mode 100644 index 000000000..bf51ebd9b --- /dev/null +++ b/client/src/components/Journey/JourneyMapPickCard.tsx @@ -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 ( + + ); +} + +function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) { + const { t, locale } = useTranslation(); + return ( +
+
+ }>{t('journey.map.pick.planned')} + {place.trip_title} + {place.days.map((day) => ( + {dayLabel(day, locale)} + ))} + {place.time && }>{place.time}} +
+ {place.entry_id !== null ? ( + + ) : ( +

+ {t('journey.map.pick.notWritten')} +

+ )} +
+ ); +} + +function StayDetail({ + stay, + onOpenEntry, + entryTitleOf, +}: { stay: JourneyMapStay } & Pick) { + 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 ( +
+
+ }> + {t('journey.map.pick.stay')} + + {from && ( + }> + {to ? t('journey.map.pick.timeRange', { from, to }) : from} + + )} +
+ {entryTitle && ( +

+ {t('journey.map.pick.partOf', { title: entryTitle })} +

+ )} + +
+ ); +} + +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(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 ( +
+
+ {picked && marks.length > 1 && ( + + )} + + {title} + + {!picked && {marks.length}} + +
+ {picked?.kind === 'plan' && } + {picked?.kind === 'stay' && ( + + )} + {!picked && ( +
    + {marks.map((mark) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/client/src/components/Journey/JourneyShareSection.test.tsx b/client/src/components/Journey/JourneyShareSection.test.tsx index 4430658bf..1508937d1 100644 --- a/client/src/components/Journey/JourneyShareSection.test.tsx +++ b/client/src/components/Journey/JourneyShareSection.test.tsx @@ -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[] = [] + 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) + return HttpResponse.json({ token: 'tok-abc' }) + }), + ) + render() + + 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() + + 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() + }) }) diff --git a/client/src/components/Journey/JourneyShareSection.tsx b/client/src/components/Journey/JourneyShareSection.tsx index 58651d316..687ea539c 100644 --- a/client/src/components/Journey/JourneyShareSection.tsx +++ b/client/src/components/Journey/JourneyShareSection.tsx @@ -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 ( +
+ {SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => { + const Icon = EXTRA_ICONS[key] + const on = mapShared && !!link[key] + return ( + + + + ) + })} +
+ ) +} export default function JourneyShareSection({ journeyId }: { journeyId: number }) { const { t } = useTranslation() @@ -61,6 +103,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number } {link[key] && } ))} + void togglePerm(key)} /> )} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index ee68f1f44..6432cdceb 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -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)' }} > - {/* Full-screen map */} - ; + +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; +} diff --git a/client/src/components/Journey/journeyMapLayers.test.ts b/client/src/components/Journey/journeyMapLayers.test.ts new file mode 100644 index 000000000..356cd8478 --- /dev/null +++ b/client/src/components/Journey/journeyMapLayers.test.ts @@ -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 => ({ + 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 => ({ + 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' }); + }); +}); diff --git a/client/src/components/Journey/journeyMapLayers.ts b/client/src/components/Journey/journeyMapLayers.ts new file mode 100644 index 000000000..5ee50b3e2 --- /dev/null +++ b/client/src/components/Journey/journeyMapLayers.ts @@ -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; + +/** The layers a renderer draws itself and switches in place; the photos it is handed or not. */ +export type JourneyMapLayerVisibility = Pick; + +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 = { + 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; + 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(layer: JourneyMapLayerResult | 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 | 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; + stayColor: (entryId: number) => string | null; +}): { marks: JourneyMapMark[]; plannedEntryIds: ReadonlySet } { + const marks: JourneyMapMark[] = []; + const plannedEntryIds = new Set(); + 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; + 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'; +} diff --git a/client/src/components/Journey/journeyMapMarks.test.ts b/client/src/components/Journey/journeyMapMarks.test.ts new file mode 100644 index 000000000..fd9b8c04e --- /dev/null +++ b/client/src/components/Journey/journeyMapMarks.test.ts @@ -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('"/>