From 8f53d98d4510cc7ad59987f7ccd110932f93940e Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 21 Oct 2026 18:55:54 +0200 Subject: [PATCH] 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. --- client/scripts/eslint-baseline.json | 2 +- client/src/api/journeyMap.test.ts | 27 ++ client/src/api/journeyMap.ts | 10 + .../Journey/JourneyLayeredMap.test.tsx | 107 +++++++ .../components/Journey/JourneyLayeredMap.tsx | 107 +++++++ .../components/Journey/JourneyMap.test.tsx | 57 +++- client/src/components/Journey/JourneyMap.tsx | 173 ++++++---- .../src/components/Journey/JourneyMapAuto.tsx | 7 + .../components/Journey/JourneyMapGL.test.tsx | 37 ++- .../src/components/Journey/JourneyMapGL.tsx | 125 +++++++- .../Journey/JourneyMapLayerControl.test.tsx | 68 ++++ .../Journey/JourneyMapLayerControl.tsx | 299 ++++++++++++++++++ .../Journey/JourneyMapPickCard.test.tsx | 89 ++++++ .../components/Journey/JourneyMapPickCard.tsx | 184 +++++++++++ .../Journey/JourneyShareSection.test.tsx | 33 +- .../Journey/JourneyShareSection.tsx | 47 ++- .../components/Journey/MobileMapTimeline.tsx | 37 ++- .../Journey/journeyLeafletLayers.ts | 67 ++++ .../Journey/journeyMapLayers.test.ts | 172 ++++++++++ .../components/Journey/journeyMapLayers.ts | 244 ++++++++++++++ .../Journey/journeyMapMarks.test.ts | 57 ++++ .../src/components/Journey/journeyMapMarks.ts | 120 +++++++ .../components/Journey/journeyPhotoLayer.ts | 20 +- .../Journey/useJourneyMapLayers.test.ts | 196 ++++++++++++ .../components/Journey/useJourneyMapLayers.ts | 207 ++++++++++++ .../Journey/useJourneyShareLink.test.ts | 13 +- .../components/Journey/useJourneyShareLink.ts | 15 +- .../src/components/Map/MapLayerSwitcher.tsx | 20 +- client/src/components/Map/mapControlShadow.ts | 19 ++ .../mobile/screens/journey/MJourneyDetail.tsx | 13 +- .../screens/journey/MJourneySettingsSheet.tsx | 19 +- client/src/pages/JourneyDetailPage.tsx | 13 +- .../pages/JourneyDetailPage.wiring.test.tsx | 20 +- client/src/pages/JourneyPublicPage.tsx | 26 +- .../journeyDetail/useJourneyDetail.test.tsx | 75 +++-- .../pages/journeyDetail/useJourneyDetail.ts | 64 ++-- .../pages/journeyPublic/useJourneyPublic.ts | 23 ++ client/src/repo/journeyMapRepo.test.ts | 44 +++ client/src/repo/journeyMapRepo.ts | 25 ++ .../journey/MJourneySettingsSheet.test.tsx | 20 +- .../JourneyEntryStays.repository.ts | 2 +- .../JourneyShareTokens.repository.ts | 2 +- .../repositories/JourneyTrips.repository.ts | 4 +- shared/src/i18n/ar/journey.ts | 36 +++ shared/src/i18n/az/journey.ts | 36 +++ shared/src/i18n/br/journey.ts | 36 +++ shared/src/i18n/ca/journey.ts | 36 +++ shared/src/i18n/cs/journey.ts | 36 +++ shared/src/i18n/de/journey.ts | 36 +++ shared/src/i18n/en/journey.ts | 36 +++ shared/src/i18n/es/journey.ts | 36 +++ shared/src/i18n/et/journey.ts | 36 +++ shared/src/i18n/fr/journey.ts | 36 +++ shared/src/i18n/gr/journey.ts | 36 +++ shared/src/i18n/hu/journey.ts | 36 +++ shared/src/i18n/id/journey.ts | 36 +++ shared/src/i18n/it/journey.ts | 36 +++ shared/src/i18n/ja/journey.ts | 36 +++ shared/src/i18n/ko/journey.ts | 36 +++ shared/src/i18n/nl/journey.ts | 36 +++ shared/src/i18n/pl/journey.ts | 36 +++ shared/src/i18n/ru/journey.ts | 36 +++ shared/src/i18n/sk/journey.ts | 36 +++ shared/src/i18n/sv/journey.ts | 36 +++ shared/src/i18n/th/journey.ts | 36 +++ shared/src/i18n/tr/journey.ts | 36 +++ shared/src/i18n/uk/journey.ts | 36 +++ shared/src/i18n/vi/journey.ts | 36 +++ shared/src/i18n/zh-TW/journey.ts | 36 +++ shared/src/i18n/zh/journey.ts | 36 +++ wiki/Journey-Journal.md | 17 +- 71 files changed, 3714 insertions(+), 184 deletions(-) create mode 100644 client/src/api/journeyMap.test.ts create mode 100644 client/src/api/journeyMap.ts create mode 100644 client/src/components/Journey/JourneyLayeredMap.test.tsx create mode 100644 client/src/components/Journey/JourneyLayeredMap.tsx create mode 100644 client/src/components/Journey/JourneyMapLayerControl.test.tsx create mode 100644 client/src/components/Journey/JourneyMapLayerControl.tsx create mode 100644 client/src/components/Journey/JourneyMapPickCard.test.tsx create mode 100644 client/src/components/Journey/JourneyMapPickCard.tsx create mode 100644 client/src/components/Journey/journeyLeafletLayers.ts create mode 100644 client/src/components/Journey/journeyMapLayers.test.ts create mode 100644 client/src/components/Journey/journeyMapLayers.ts create mode 100644 client/src/components/Journey/journeyMapMarks.test.ts create mode 100644 client/src/components/Journey/journeyMapMarks.ts create mode 100644 client/src/components/Journey/useJourneyMapLayers.test.ts create mode 100644 client/src/components/Journey/useJourneyMapLayers.ts create mode 100644 client/src/repo/journeyMapRepo.test.ts create mode 100644 client/src/repo/journeyMapRepo.ts 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('"/>