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

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

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

The GPX routes come with the map's layers through the linked trips, read
only while the journey's track switch is on; the frozen /tracks answer stays
Studio's. The share settings on desktop and phone get Trip plan and Recorded
routes under Map, off by default and only switchable while the map is shared.
This commit is contained in:
Maurice
2026-10-21 18:55:54 +02:00
parent 9b71948497
commit 8f53d98d45
71 changed files with 3714 additions and 184 deletions
+1 -1
View File
@@ -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,
+27
View File
@@ -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']);
});
});
+10
View File
@@ -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<JourneyMapLayersResponse> =>
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
};
@@ -0,0 +1,107 @@
// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-003
//
// JRN-04: the journey map with its layers: what the renderer is handed, and a
// picked mark's card.
import type { JourneyMapPlace } from '@trek/shared';
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyLayeredMap from './JourneyLayeredMap';
import { ALL_VISIBLE } from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const seen = vi.hoisted(() => ({ props: [] as Record<string, unknown>[], engine: [] as string[] }));
vi.mock('./JourneyMap', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('leaflet');
return null;
},
}));
vi.mock('./JourneyMapAuto', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('auto');
return null;
},
}));
const MARK: JourneyMapMark = {
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: {
name: 'Piazza San Marco',
trip_title: 'Venice',
days: [],
time: null,
entry_id: null,
} as unknown as JourneyMapPlace,
};
function layers(over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls: [{ layer: 'entries', count: 1, state: 'ready' }],
tracks: [],
marks: [MARK],
plannedEntryIds: new Set(['3']),
...over,
};
}
const last = () => seen.props[seen.props.length - 1];
const PHOTOS = [{ id: 'p', lat: 1, lng: 2, thumbUrl: '/t.jpg' }];
describe('JourneyLayeredMap', () => {
it('FE-JOURNEY-LAYEREDMAP-001: without layers the map draws as it always did, on the renderer asked for', () => {
render(<JourneyLayeredMap engine="leaflet" checkins={[]} entries={[]} photos={PHOTOS} tracks={[]} />);
expect(seen.engine[seen.engine.length - 1]).toBe('leaflet');
expect(last()).toMatchObject({ photos: PHOTOS, marks: undefined, layerVisibility: undefined });
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
});
it('FE-JOURNEY-LAYEREDMAP-002: hands the renderer the layers, the photos only while their switch is on', () => {
const view = layers();
const { rerender } = render(<JourneyLayeredMap layers={view} checkins={[]} entries={[]} photos={PHOTOS} />);
expect(seen.engine[seen.engine.length - 1]).toBe('auto');
expect(last()).toMatchObject({
photos: PHOTOS,
marks: [MARK],
layerVisibility: ALL_VISIBLE,
plannedEntryIds: view.plannedEntryIds,
});
expect(screen.getByRole('group', { name: 'Map layers' })).toBeInTheDocument();
rerender(
<JourneyLayeredMap
layers={layers({ visibility: { ...ALL_VISIBLE, photos: false } })}
checkins={[]}
entries={[]}
photos={PHOTOS}
/>
);
expect(last().photos).toBeUndefined();
});
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
const onOpenEntry = vi.fn();
const { rerender } = render(
<JourneyLayeredMap layers={layers()} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />
);
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
rerender(<JourneyLayeredMap layers={layers({ marks: [] })} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
import JourneyMapAuto from './JourneyMapAuto';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
type MapProps = Omit<
ComponentProps<typeof JourneyMapAuto>,
'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds'
> & {
/** CARTO key of a shared page, which the Leaflet renderer reads; see JourneyMap. */
cartoApiKey?: string;
};
interface Props extends MapProps {
ref?: Ref<JourneyMapHandle>;
/** The layers of this map; without them it draws as it always did. */
layers?: JourneyMapLayersView | null;
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
engine?: 'auto' | 'leaflet';
/** Icon-only switches, for a phone. */
compactControls?: boolean;
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
controlsTop?: number | string;
/** Leads from a picked place or stay to its entry. */
onOpenEntry?: (entryId: string) => void;
entryTitleOf?: (entryId: number) => string | null;
}
/**
* A journey map with its layers (JRN-04): the renderer, the reader's layer
* switches and the card of a picked plan place or stay. Every journey map
* surface draws through here, the desktop aside, the phone screens and the
* shared page, so the layers mean the same everywhere. A switched-off layer
* is hidden in place: the map is not rebuilt and keeps its view.
*/
export default function JourneyLayeredMap({
ref,
layers,
engine = 'auto',
compactControls = false,
controlsTop = 12,
onOpenEntry,
entryTitleOf,
photos,
tracks,
height = 220,
...rest
}: Props) {
const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null);
const visibility = layers?.visibility;
const onMarksPick = useCallback((marks: JourneyMapMark[]) => {
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
}, []);
const close = useCallback(() => setPicked(null), []);
const openEntry = useCallback(
(entryId: string) => {
setPicked(null);
onOpenEntry?.(entryId);
},
[onOpenEntry]
);
// A pick whose marks a switch took off the map closes with them.
const shownKeys = new Set(layers?.marks.map((m) => m.key));
const card = picked && picked.marks.every((m) => shownKeys.has(m.key)) ? picked : null;
const mapProps = {
...rest,
height,
photos: !visibility || visibility.photos ? photos : undefined,
tracks: layers ? layers.tracks : tracks,
layerVisibility: visibility,
marks: layers?.marks,
onMarksPick,
plannedEntryIds: layers?.plannedEntryIds,
};
return (
<div className="relative w-full" style={{ height: height === 9999 ? '100%' : height }}>
<div className="isolate h-full w-full">
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
</div>
{layers && (
<div
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
style={{ top: controlsTop }}
>
<div className="pointer-events-auto max-w-full">
<JourneyMapLayerControl layers={layers} compact={compactControls} />
</div>
{card && (
<JourneyMapPickCard
key={card.key}
marks={card.marks}
onClose={close}
onOpenEntry={onOpenEntry ? openEntry : undefined}
entryTitleOf={entryTitleOf}
/>
)}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-051
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -823,4 +823,59 @@ describe('JourneyMap', () => {
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
// JRN-04: the layer switches hide in place, and the plan's places and stays draw beside the entries.
it('FE-COMP-JOURNEYMAP-052: switching the entries off takes their pin off the map and back, without a rebuild', () => {
const marker = mockedMarkerWithRemove();
const entry = [entriesWithCoords[0]];
const shown = { plan: true, recorded: true, entries: true };
const { rerender } = render(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={{ ...shown, entries: false }} />);
expect(marker.remove).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(2);
expect(L.map).toHaveBeenCalledTimes(1);
});
it('FE-COMP-JOURNEYMAP-053: a planned place is drawn as its hollow pin, and a click hands it up', () => {
const onMarksPick = vi.fn();
const mark = {
kind: 'plan' as const,
key: 'plan:4',
lat: 45.43,
lng: 12.33,
color: '#e11d48',
place: {
place_id: 4, trip_id: 2, trip_title: 'Venice', name: 'San Marco', lat: 45.43, lng: 12.33,
days: [], time: null, category_color: '#e11d48', category_icon: null, entry_id: null,
},
};
render(<JourneyMap checkins={[]} entries={[]} marks={[mark]} onMarksPick={onMarksPick} />);
const pins = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('stroke-dasharray'));
expect(pins).toHaveLength(1);
expect(pins[0]).toContain('#e11d48');
const click = mockedMarker().on.mock.calls.filter((c: unknown[]) => c[0] === 'click').pop()?.[1] as () => void;
click();
expect(onMarksPick).toHaveBeenCalledWith([mark]);
});
it('FE-COMP-JOURNEYMAP-054: an entry pin whose place the plan had too carries the check', () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} plannedEntryIds={new Set(['e2'])} />);
expect(divIconHtml(0)).not.toContain('#16a34a');
expect(divIconHtml(1)).toContain('#16a34a');
});
});
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
function mockedMarkerWithRemove(): { addTo: Mock; remove: Mock } {
L.marker([0, 0]);
const marker = mockedMarker() as unknown as { addTo: Mock; remove: Mock };
marker.remove = vi.fn();
vi.mocked(L.marker).mockClear();
marker.addTo.mockClear();
return marker;
}
+119 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
@@ -9,6 +9,9 @@ import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLa
import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
export interface MapMarkerItem {
id: string
@@ -74,8 +77,32 @@ interface Props {
paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
/**
* Which drawn layers the reader shows (JRN-04). A hidden one is taken off the
* map and put back as it was, never rebuilt, so the view stays where it is.
*/
layerVisibility?: JourneyMapLayerVisibility
/** The plan's places and the stays of day reports, clustered where they meet. */
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
/** Entry pins whose place the plan had too, marked with a check. */
plannedEntryIds?: ReadonlySet<string>
}
/** The map's own zoom buttons, frosted so they hold on any tile. */
const zoomButtonStyle = (dark?: boolean): CSSProperties => ({
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
})
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const items: MapMarkerItem[] = []
for (const e of entries) {
@@ -101,7 +128,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
@@ -114,10 +141,20 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg>
</div>`
}
function entryIcon(item: MapMarkerItem, highlighted: boolean, planned: boolean): L.DivIcon {
return L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, highlighted, planned),
})
}
/**
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
* and a focus keeps that (discussion #2299).
@@ -147,7 +184,7 @@ const TRACK_FALLBACK_COLOR = '#4f46e5'
const PLANNED_DASH = '8 7'
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// Read through a ref: the flag is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
@@ -183,6 +220,20 @@ function JourneyMap(
const photoLayerRef = useRef<L.LayerGroup | null>(null)
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const markLayerRef = useRef<L.LayerGroup | null>(null)
const onMarksPickRef = useRef(onMarksPick)
// What each switchable layer drew, and which of them are on the map right now.
const drawnRef = useRef<DrawnLayers>(NOTHING_DRAWN())
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const visibilityRef = useRef(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const darkRef = useRef(dark)
darkRef.current = dark
@@ -196,12 +247,7 @@ function JourneyMap(
const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
}))
marker.setIcon(entryIcon(item, false, plannedRef.current.has(item.id)))
marker.setZIndexOffset(0)
}
}
@@ -210,12 +256,7 @@ function JourneyMap(
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true),
}))
marker.setIcon(entryIcon(item, true, plannedRef.current.has(item.id)))
marker.setZIndexOffset(1000)
}
}
@@ -286,6 +327,18 @@ function JourneyMap(
const items = buildMarkerItems(entries)
itemsRef.current = items
// Each switchable layer is drawn whatever its switch says and only put on
// the map while it is shown, so switching it later needs no rebuild.
const drawn = NOTHING_DRAWN()
drawnRef.current = drawn
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
drawn[kind].push(layer)
if (visibilityRef.current[kind]) layer.addTo(map)
return layer
}
const allCoords: L.LatLngTuple[] = []
/**
* Track geometry is kept OUT of the fit set (#2194) and used only when there
@@ -307,10 +360,10 @@ function JourneyMap(
if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
L.polyline(coords, {
place('recorded', L.polyline(coords, {
color: '#6366f1', weight: 3, opacity: 0.4,
dashArray: '6 4', lineCap: 'round',
}).addTo(map)
}))
coords.forEach(c => allCoords.push(c))
}
@@ -322,8 +375,9 @@ function JourneyMap(
if (track.points.length < 2) continue
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
const color = track.color || TRACK_FALLBACK_COLOR
const planned = !track.kind || track.kind === 'trip_gpx'
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map)
const planned = isPlannedTrack(track)
const kind: LeafletLayerKind = planned ? 'plan' : 'recorded'
place(kind, L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }))
const line = L.polyline(coords, {
color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round',
...(planned ? { dashArray: PLANNED_DASH } : {}),
@@ -333,20 +387,20 @@ function JourneyMap(
// transparency switched off. Escaped because a string handed to bindTooltip
// becomes innerHTML, and a track name is a place name off a shared trip.
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map)
place(kind, line)
coords.forEach(c => trackCoords.push(c))
}
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, {
place('entries', L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}).addTo(map)
}))
}
// place markers
@@ -354,14 +408,7 @@ function JourneyMap(
const pos: L.LatLngTuple = [item.lat, item.lng]
allCoords.push(pos)
const icon = L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
})
const marker = L.marker(pos, { icon }).addTo(map)
const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
// The same card the GL renderer shows, from the same builder: which map
// engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came
@@ -420,9 +467,26 @@ function JourneyMap(
detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null
markersRef.current.clear()
drawnRef.current = NOTHING_DRAWN()
}
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
// A switch puts its layer back on the map or takes it off, exactly as drawn.
useEffect(() => {
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer without redrawing the map.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) === plannedEntryIds.has(item.id)) continue
markersRef.current.get(item.id)?.setIcon(entryIcon(item, highlightedRef.current === item.id, plannedEntryIds.has(item.id)))
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds])
// Retile in place rather than through the effect above, which would drop every
// marker and track it just drew. A vector basemap restyles instead, which also
// avoids spending a WebGL context on every theme toggle.
@@ -484,6 +548,29 @@ function JourneyMap(
}
}, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// The plan's places and the stays of day reports (JRN-04), in their own layer
// like the photos: below the entry pins and above the thumbnails, regrouped
// on every move because marks are only joined where they would overlap.
useEffect(() => {
const map = mapRef.current
if (!map) return
const draw = () => {
markLayerRef.current?.remove()
markLayerRef.current = drawLeafletMarks(map, marks, members => onMarksPickRef.current?.(members))
}
draw()
map.on('zoomend', draw)
map.on('moveend', draw)
return () => {
map.off('zoomend', draw)
map.off('moveend', draw)
markLayerRef.current?.remove()
markLayerRef.current = null
}
}, [marks, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
@@ -507,30 +594,8 @@ function JourneyMap(
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button type="button"
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button type="button"
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>−</button>
<button type="button" onClick={zoomIn} style={zoomButtonStyle(dark)}>+</button>
<button type="button" onClick={zoomOut} style={zoomButtonStyle(dark)}>−</button>
</div>
</div>
)
@@ -7,6 +7,8 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer'
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
import type { JourneyMapMark } from './journeyMapMarks'
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,6 +43,11 @@ interface Props {
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
/** JRN-04: the layers the reader shows, and the plan and stay marks, both renderers. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
}
function JourneyMapAuto({ ref, ...props }: Props) {
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-038, FE-COMP-JMAPGL-2453
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn>
@@ -48,6 +48,7 @@ const gl = vi.hoisted(() => {
getSource: vi.fn((): unknown => null),
setTerrain: vi.fn(),
setConfigProperty: vi.fn(),
setLayoutProperty: vi.fn(),
}
const markers: MarkerStub[] = []
const popups: PopupStub[] = []
@@ -686,4 +687,38 @@ describe('JourneyMapGL', () => {
const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />)
expect((container.firstChild as HTMLElement).style.height).toBe('100%')
})
it('FE-COMP-JMAPGL-039: the plan has its own casing, and a layer switched off is added hidden (JRN-04)', () => {
withToken()
render(
<JourneyMapGL
checkins={[]}
entries={entries}
tracks={[{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] }]}
layerVisibility={{ plan: false, recorded: true, entries: false }}
/>,
)
const layer = (id: string) => vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown>; filter?: unknown }).find(l => l.id === id)
expect(layer('journey-tracks-planned-casing')?.filter).toEqual(['==', ['get', 'planned'], true])
expect(layer('journey-tracks-planned-casing')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-planned')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-line')?.layout.visibility).toBeUndefined()
expect(layer('journey-route-line')?.layout.visibility).toBe('none')
expect(gl.markers.every(m => m.element.style.display === 'none')).toBe(true)
})
it('FE-COMP-JMAPGL-040: a switch shows and hides its layers in place, and the check follows the plan', () => {
withToken()
const shown = { plan: true, recorded: true, entries: true }
const { rerender } = render(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={shown} />)
expect(gl.markers[0].element.innerHTML).not.toContain('#16a34a')
rerender(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={{ ...shown, recorded: false }} plannedEntryIds={new Set(['e1'])} />)
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-line', 'visibility', 'none')
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-casing', 'visibility', 'none')
expect(gl.map.setLayoutProperty).not.toHaveBeenCalledWith('journey-tracks-planned', 'visibility', expect.anything())
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
})
})
+116 -9
View File
@@ -6,6 +6,8 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -52,6 +54,11 @@ interface Props {
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
glProvider?: GlMapProvider
/**
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
@@ -98,7 +105,16 @@ function buildItems(entries: MapEntry[]): Item[] {
return items
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
/** The map's own layers each switch takes off and puts back. */
const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
plan: ['journey-tracks-planned-casing', 'journey-tracks-planned'],
recorded: ['journey-tracks-casing', 'journey-tracks-line'],
entries: ['journey-route-line'],
}
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
@@ -121,6 +137,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg>`
wrap.appendChild(inner)
return wrap
@@ -132,7 +149,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
@@ -165,6 +182,18 @@ function JourneyMapGL(
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
const markMarkersRef = useRef<{ remove: () => void }[]>([])
const onMarksPickRef = useRef(onMarksPick)
const visibilityRef = useRef(layerVisibility)
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const darkRef = useRef(dark)
darkRef.current = dark
const mapLangRef = useRef(mapLang)
@@ -242,7 +271,7 @@ function JourneyMapGL(
if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
const next = markerHtml(item.dayColor, item.dayLabel, highlighted, plannedRef.current.has(id))
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML
@@ -314,6 +343,11 @@ function JourneyMapGL(
const map = new gl.Map(mapOptions as any)
mapRef.current = map
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
// A layer whose switch is off is added hidden, so switching it on later needs no rebuild.
const layout = (kind: keyof JourneyMapLayerVisibility, base: Record<string, unknown>) =>
visibilityRef.current[kind] ? base : { ...base, visibility: 'none' }
map.on('load', () => {
if (enableMapbox3d) {
@@ -352,7 +386,7 @@ function JourneyMapGL(
'line-opacity': 0.5,
'line-dasharray': [2, 3],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
})
}
}
@@ -372,7 +406,7 @@ function JourneyMapGL(
properties: {
color: track.color || TRACK_FALLBACK_COLOR,
name: track.name,
planned: !track.kind || track.kind === 'trip_gpx',
planned: isPlannedTrack(track),
},
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
})),
@@ -382,12 +416,22 @@ function JourneyMapGL(
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
} else {
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
// Casing apart for the plan and what was recorded, so each goes with its switch.
map.addLayer({
id: 'journey-tracks-casing',
type: 'line',
source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned-casing',
type: 'line',
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: layout('plan', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-line',
@@ -395,7 +439,7 @@ function JourneyMapGL(
source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned',
@@ -403,7 +447,7 @@ function JourneyMapGL(
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95, 'line-dasharray': [2.2, 2] },
layout: { 'line-cap': 'butt', 'line-join': 'round' },
layout: layout('plan', { 'line-cap': 'butt', 'line-join': 'round' }),
})
}
}
@@ -411,9 +455,10 @@ function JourneyMapGL(
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const el = markerHtml(item.dayColor, item.dayLabel, false, plannedRef.current.has(item.id))
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
el.style.zIndex = '1'
if (!visibilityRef.current.entries) el.style.display = 'none'
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
@@ -487,6 +532,68 @@ function JourneyMapGL(
// gl is the injected engine and does not change for the life of a map.
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// A switch shows or hides its layers where they are (JRN-04): the GL layers by
// their visibility, the entry pins by their element.
useEffect(() => {
const map = mapRef.current
if (!map) return
for (const kind of Object.keys(LAYER_IDS) as (keyof JourneyMapLayerVisibility)[]) {
if (shownRef.current[kind] === layerVisibility[kind]) continue
for (const id of LAYER_IDS[kind]) {
try { map.setLayoutProperty(id, 'visibility', layerVisibility[kind] ? 'visible' : 'none') } catch { /* not drawn on this map */ }
}
if (kind === 'entries') {
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
}
}
shownRef.current = layerVisibility
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer in place.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) !== plannedEntryIds.has(item.id)) setMarkerStyle(item.id, highlightedRef.current === item.id)
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds, setMarkerStyle])
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
// regrouped on every move, between the photos and the entry pins.
useEffect(() => {
const map = mapRef.current
if (!map) return
const clear = () => {
markMarkersRef.current.forEach(m => m.remove())
markMarkersRef.current = []
}
const draw = () => {
clear()
if (!marks?.length) return
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members)
const el = document.createElement('div')
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
el.innerHTML = look.html
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onMarksPickRef.current?.(hit.members)
})
markMarkersRef.current.push(new gl.Marker({ element: el, anchor: look.anchor }).setLngLat([hit.lng, hit.lat]).addTo(map))
}
}
if (map.loaded()) draw()
else map.once('load', draw)
map.on('moveend', draw)
return () => {
map.off('moveend', draw)
map.off('load', draw)
clear()
}
// gl is the injected engine and does not change for the life of a map.
}, [marks, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run.
useEffect(() => {
@@ -0,0 +1,68 @@
// FE-JOURNEY-MAPLAYERCTL-001 to FE-JOURNEY-MAPLAYERCTL-003
//
// JRN-04: the journey map's layer switches and its key.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import { ALL_VISIBLE, type JourneyMapLayerControl as Control } from './journeyMapLayers';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
function view(controls: Control[], over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls,
tracks: [],
marks: [],
plannedEntryIds: new Set(),
...over,
};
}
describe('JourneyMapLayerControl', () => {
it('FE-JOURNEY-MAPLAYERCTL-001: one pressed switch per offered layer with its count, a withheld one not at all', () => {
const layers = view(
[
{ layer: 'recorded', count: 2, state: 'ready' },
{ layer: 'entries', count: 5, state: 'ready' },
{ layer: 'photos', count: 0, state: 'empty' },
],
{ visibility: { ...ALL_VISIBLE, photos: false } }
);
render(<JourneyMapLayerControl layers={layers} />);
expect(screen.queryByTestId('journey-map-layer-plan')).toBeNull();
expect(screen.getByTestId('journey-map-layer-recorded')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('journey-map-layer-entries-badge')).toHaveTextContent('5');
const photos = screen.getByTestId('journey-map-layer-photos');
expect(photos).toHaveAttribute('aria-pressed', 'false');
expect(photos.className).toContain('opacity-55');
expect(screen.queryByTestId('journey-map-layer-photos-badge')).toBeNull();
fireEvent.click(photos);
expect(layers.toggle).toHaveBeenCalledWith('photos');
});
it('FE-JOURNEY-MAPLAYERCTL-002: a layer that could not be read carries the warning tone, a loading one the spinner', () => {
render(
<JourneyMapLayerControl
layers={view([
{ layer: 'plan', count: 0, state: 'unavailable' },
{ layer: 'entries', count: 0, state: 'loading' },
])}
/>
);
expect(screen.getByTestId('journey-map-layer-plan-badge')).toHaveAttribute('data-tone', 'warning');
expect(screen.getByTestId('journey-map-layer-entries')).toHaveAttribute('data-state', 'loading');
expect(screen.getByRole('status')).toHaveTextContent('Plan');
});
it('FE-JOURNEY-MAPLAYERCTL-003: the key opens beside the switches and says a plan is no visit', () => {
render(<JourneyMapLayerControl layers={view([{ layer: 'entries', count: 1, state: 'ready' }])} compact />);
expect(screen.queryByText('Entries')).toBeNull();
fireEvent.click(screen.getByTestId('journey-map-key-button'));
expect(screen.getByTestId('journey-map-key')).toHaveTextContent('A planned place is no proof of a visit');
});
});
@@ -0,0 +1,299 @@
import { CalendarRange, Images, Info, Loader2, NotebookPen, Route } from 'lucide-react';
import type { ComponentType, ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { FROSTED_SHELL, segmentStyle } from '../Map/mapControlShadow';
import { LABEL, PopoverListPanel } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { LAYER_TEXT, type JourneyMapLayerControl as Control, type JourneyMapLayer } from './journeyMapLayers';
import { PLAN_FALLBACK_COLOR } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const ICONS: Record<JourneyMapLayer, ComponentType<{ size?: number; strokeWidth?: number }>> = {
plan: CalendarRange,
recorded: Route,
entries: NotebookPen,
photos: Images,
};
/** The sample colours of the key: an entry pin of the first day and a recorded line, as the map draws them. */
const SAMPLE_DAY = '#6366f1';
const SAMPLE_ROUTE = '#2563eb';
/** What a switch says about its layer: its name, and why it shows nothing when it does not. */
function hintOf(control: Control, t: (key: string, params?: Record<string, string | number>) => string): string {
const text = LAYER_TEXT[control.layer];
const name = t(text.labelKey);
if (control.state === 'unavailable') return `${name}: ${t(text.unavailableKey)}`;
if (control.state === 'empty') return `${name}: ${t(text.emptyKey)}`;
if (control.state === 'loading') return `${name}: ${t('journey.map.layer.loading')}`;
return name;
}
function LayerToggle({
control,
on,
onToggle,
compact,
}: {
control: Control;
on: boolean;
onToggle: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const Icon = ICONS[control.layer];
const name = t(LAYER_TEXT[control.layer].labelKey);
const problem = control.state === 'unavailable';
// Nothing to draw: dimmed rather than gone, and the tooltip says why.
const quiet = control.state === 'empty' || control.state === 'idle';
const showBadge = control.state === 'loading' || problem || (control.state === 'ready' && on);
return (
<Tooltip label={hintOf(control, t)} placement="bottom">
<button
type="button"
onClick={onToggle}
aria-label={name}
aria-pressed={on}
data-testid={`journey-map-layer-${control.layer}`}
data-state={control.state}
className={`${on ? 'bg-accent text-accent-text' : 'text-content-muted hover:bg-surface-hover'} ${quiet ? 'opacity-55' : ''} transition-colors`}
style={segmentStyle(on)}
>
<Icon size={14} strokeWidth={2} />
{!compact && <span>{name}</span>}
{showBadge && (
<span
aria-hidden
data-testid={`journey-map-layer-${control.layer}-badge`}
data-tone={problem ? 'warning' : 'default'}
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
problem
? 'bg-warning-soft text-warning'
: on
? 'bg-accent-text text-accent'
: 'bg-surface-hover text-content-secondary'
}`}
style={fs(10)}
>
{control.state === 'loading' ? (
<Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" />
) : problem ? (
'!'
) : (
control.count
)}
</span>
)}
</button>
</Tooltip>
);
}
/** A hollow plan pin, as the map draws it. */
function PlanPinSample() {
return (
<svg width="16" height="20" viewBox="0 0 22 28" aria-hidden className="flex-none">
<path
d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z"
fill="#ffffff"
stroke={PLAN_FALLBACK_COLOR}
strokeWidth="2"
strokeDasharray="3 2"
/>
<circle cx="11" cy="10.5" r="3" fill={PLAN_FALLBACK_COLOR} />
</svg>
);
}
/** A journal entry's pin, with the plan's check when the plan had that place too. */
function EntryPinSample({ planned = false }: { planned?: boolean }) {
return (
<svg width="16" height="20" viewBox="0 0 28 36" aria-hidden className="flex-none">
<path
d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z"
fill={SAMPLE_DAY}
/>
{planned && (
<>
<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" strokeWidth="1.2" />
<path
d="M20.4 5.6l1.5 1.5 2.8-2.9"
fill="none"
stroke="#16a34a"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
);
}
function StaySample() {
return (
<svg width="16" height="16" viewBox="0 0 14 14" aria-hidden className="flex-none">
<circle cx="7" cy="7" r="5" fill={SAMPLE_DAY} stroke="#ffffff" strokeWidth="2" />
</svg>
);
}
function SeveralSample() {
return (
<svg width="18" height="18" viewBox="0 0 28 28" aria-hidden className="flex-none">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke={SAMPLE_DAY} strokeWidth="2" />
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke={PLAN_FALLBACK_COLOR} strokeWidth="2.5" />
<text x="12" y="16" textAnchor="middle" dominantBaseline="central" fontSize="11" fontWeight="700" fill="#111827">
3
</text>
</svg>
);
}
function LineSample({ dashed }: { dashed: boolean }) {
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="flex-none">
<line x1="1" y1="4" x2="23" y2="4" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<line
x1="1"
y1="4"
x2="23"
y2="4"
stroke={SAMPLE_ROUTE}
strokeWidth="2.5"
strokeLinecap={dashed ? 'butt' : 'round'}
strokeDasharray={dashed ? '4 3' : undefined}
/>
</svg>
);
}
const ROW = 'flex min-w-0 items-center gap-2.5 text-content-secondary';
/**
* The key of the journey map: what each pin, dot and line stands for, with the
* one rule that matters most: a planned place is no proof of a visit.
*/
function JourneyMapKey() {
const { t } = useTranslation();
const rows: { sample: ReactNode; labelKey: string }[] = [
{ sample: <PlanPinSample />, labelKey: 'journey.map.legend.planned' },
{ sample: <EntryPinSample />, labelKey: 'journey.map.legend.entry' },
{ sample: <EntryPinSample planned />, labelKey: 'journey.map.legend.plannedWritten' },
{ sample: <StaySample />, labelKey: 'journey.map.legend.stay' },
{ sample: <SeveralSample />, labelKey: 'journey.map.legend.several' },
];
return (
<div className="flex flex-col gap-3 p-1.5" data-testid="journey-map-key">
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
{t('journey.map.legend')}
</span>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li key={row.labelKey} className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">{row.sample}</span>
<span className="min-w-0">{t(row.labelKey)}</span>
</li>
))}
</ul>
<div className="border-t border-edge-faint pt-3">
<span className={LABEL}>{t('journey.map.legend.lines')}</span>
<ul className="flex flex-col gap-1.5">
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed />
</span>
<span className="min-w-0">{t('journey.map.legend.plannedRoute')}</span>
</li>
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed={false} />
</span>
<span className="min-w-0">{t('journey.map.legend.recordedRoute')}</span>
</li>
</ul>
</div>
<p
className="flex items-start gap-1.5 rounded-lg bg-surface-hover px-2 py-1.5 text-content-secondary"
style={fs(11)}
>
<Info size={12} strokeWidth={2} aria-hidden className="mt-px flex-none" />
{t('journey.map.legend.planIsNoVisit')}
</p>
</div>
);
}
function KeyButton() {
const { t } = useTranslation();
const list = usePopoverList(-1, { estimatedHeight: 360 });
const { open, toggle, triggerRef, listId } = list;
const label = t('journey.map.legend');
return (
<>
<Tooltip label={label} placement="bottom" disabled={open}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
aria-controls={open ? listId : undefined}
data-testid="journey-map-key-button"
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-hover ${open ? 'bg-surface-hover' : ''}`}
>
<Info size={15} strokeWidth={2} />
</button>
</Tooltip>
<PopoverListPanel list={list} label={label} className="w-[268px]">
<JourneyMapKey />
</PopoverListPanel>
</>
);
}
/**
* The layer switches of a journey map (JRN-04), in the frosted pill the
* planner's map controls wear: one per layer the reader is offered, each with
* its count, a spinner while it loads and the warning tone when it could not
* be read, and the key beside them. On a phone the switches drop their words;
* the tooltip and the accessible name still say what each is.
*/
export default function JourneyMapLayerControl({
layers,
compact = false,
}: {
layers: JourneyMapLayersView;
compact?: boolean;
}) {
const { t } = useTranslation();
const live = layers.controls
.filter((control) => control.state === 'loading' || control.state === 'unavailable')
.map((control) => hintOf(control, t))
.join('. ');
return (
<div
style={FROSTED_SHELL}
role="group"
aria-label={t('journey.map.layers')}
className="max-w-full gap-0.5 overflow-x-auto"
>
{layers.controls.map((control) => (
<LayerToggle
key={control.layer}
control={control}
on={layers.visibility[control.layer]}
onToggle={() => layers.toggle(control.layer)}
compact={compact}
/>
))}
<span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />
<KeyButton />
<span role="status" aria-live="polite" className="sr-only">
{live}
</span>
</div>
);
}
@@ -0,0 +1,89 @@
// FE-JOURNEY-MAPPICK-001 to FE-JOURNEY-MAPPICK-003
//
// JRN-04: what a picked plan place or stay is, and several on one spot as a list.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14', '2026-05-15'],
time: '10:00',
category_color: null,
category_icon: null,
entry_id: null,
};
const STAY: JourneyMapStay = {
id: 9,
entry_id: 7,
name: 'Rialto market',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
};
const plan = (over: Partial<JourneyMapPlace> = {}): JourneyMapMark => ({
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: { ...PLACE, ...over },
});
const stay: JourneyMapMark = { kind: 'stay', key: 'stay:9', lat: 45.438, lng: 12.336, color: '#14b8a6', stay: STAY };
describe('JourneyMapPickCard', () => {
it('FE-JOURNEY-MAPPICK-001: a planned place names its trip and days and says when nothing was written about it', () => {
render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onOpenEntry={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
expect(screen.getByText('Planned')).toBeInTheDocument();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText('10:00')).toBeInTheDocument();
expect(screen.getByText('Nothing written about it yet.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open entry' })).toBeNull();
});
it('FE-JOURNEY-MAPPICK-002: a place written about and a stay lead to their entry', () => {
const onOpenEntry = vi.fn();
const { unmount } = render(
<JourneyMapPickCard marks={[plan({ entry_id: 12 })]} onClose={vi.fn()} onOpenEntry={onOpenEntry} />
);
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenCalledWith('12');
unmount();
render(
<JourneyMapPickCard
marks={[stay]}
onClose={vi.fn()}
onOpenEntry={onOpenEntry}
entryTitleOf={() => 'A day in Venice'}
/>
);
expect(screen.getByText('Part of “A day in Venice”')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenLastCalledWith('7');
});
it('FE-JOURNEY-MAPPICK-003: several on one spot come as a list, each reachable, and Escape closes', () => {
const onClose = vi.fn();
render(<JourneyMapPickCard marks={[plan(), stay]} onClose={onClose} />);
expect(screen.getByRole('dialog', { name: 'At this spot' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Rialto market/ }));
expect(screen.getByRole('dialog', { name: 'Rialto market' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to the list' }));
expect(screen.getByRole('button', { name: /Piazza San Marco/ })).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { SoftPill, WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import type { JourneyMapMark } from './journeyMapMarks';
interface Props {
/** What was picked on the map: one mark, or several that lie on one spot. */
marks: JourneyMapMark[];
onClose: () => void;
/** Leads to an entry; absent where the reader cannot open one. */
onOpenEntry?: (entryId: string) => void;
/** The title of an entry the reader can see, for the stay that belongs to it. */
entryTitleOf?: (entryId: number) => string | null;
}
function dayLabel(date: string, locale: string): string {
return new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
}
function timeLabel(iso: string, locale: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (entryId: string) => void }) {
const { t } = useTranslation();
if (!onOpenEntry) return null;
return (
<button
type="button"
onClick={() => onOpenEntry(String(entryId))}
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
style={fs(11.5, 'body')}
>
<NotebookPen size={12} strokeWidth={2.2} aria-hidden />
{t('journey.map.pick.openEntry')}
</button>
);
}
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
const { t, locale } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill icon={<CalendarRange size={11} strokeWidth={2} />}>{t('journey.map.pick.planned')}</SoftPill>
<WhiteBadge>{place.trip_title}</WhiteBadge>
{place.days.map((day) => (
<WhiteBadge key={day}>{dayLabel(day, locale)}</WhiteBadge>
))}
{place.time && <WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>{place.time}</WhiteBadge>}
</div>
{place.entry_id !== null ? (
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
) : (
<p className="text-content-muted" style={fs(11.5, 'body')}>
{t('journey.map.pick.notWritten')}
</p>
)}
</div>
);
}
function StayDetail({
stay,
onOpenEntry,
entryTitleOf,
}: { stay: JourneyMapStay } & Pick<Props, 'onOpenEntry' | 'entryTitleOf'>) {
const { t, locale } = useTranslation();
const entryTitle = entryTitleOf?.(stay.entry_id);
const from = timeLabel(stay.started_at, locale);
const to = timeLabel(stay.ended_at, locale);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill tone="info" icon={<MapPin size={11} strokeWidth={2} />}>
{t('journey.map.pick.stay')}
</SoftPill>
{from && (
<WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>
{to ? t('journey.map.pick.timeRange', { from, to }) : from}
</WhiteBadge>
)}
</div>
{entryTitle && (
<p className="truncate text-content-secondary" style={fs(11.5, 'body')}>
{t('journey.map.pick.partOf', { title: entryTitle })}
</p>
)}
<OpenEntry entryId={stay.entry_id} onOpenEntry={onOpenEntry} />
</div>
);
}
function markName(mark: JourneyMapMark): string {
return mark.kind === 'plan' ? mark.place.name : mark.stay.name;
}
/**
* What a mark picked on the journey map is (JRN-04), docked over the map's
* corner: a planned place with its trip, its days and the entry written about
* it, or a stay with its times and the report it belongs to. Several marks on
* one spot come as a list first, each with what kind it is, so overlapping
* hits stay reachable one by one.
*/
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
const { t, locale } = useTranslation();
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const title = picked ? markName(picked) : t('journey.map.pick.here');
return (
<div
role="dialog"
aria-label={title}
data-testid="journey-map-pick"
className="pointer-events-auto flex w-[280px] max-w-[calc(100%-24px)] flex-col gap-2 rounded-xl border border-edge bg-surface-card p-3 shadow-lg"
>
<div className="flex items-start gap-1.5">
{picked && marks.length > 1 && (
<button
type="button"
onClick={() => setPicked(null)}
aria-label={t('journey.map.pick.back')}
className="-ms-1 grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<ChevronLeft size={14} strokeWidth={2.2} className="rtl:rotate-180" />
</button>
)}
<span className="min-w-0 flex-1 font-semibold text-content" style={fs(13, 'body')}>
{title}
</span>
{!picked && <WhiteBadge>{marks.length}</WhiteBadge>}
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<X size={14} strokeWidth={2.2} />
</button>
</div>
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
{picked?.kind === 'stay' && (
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
)}
{!picked && (
<ul className="-mx-1 flex max-h-[220px] flex-col overflow-y-auto">
{marks.map((mark) => (
<li key={mark.key}>
<button
type="button"
onClick={() => setPicked(mark)}
className="flex w-full items-center gap-2 rounded-lg px-1.5 py-1.5 text-start hover:bg-surface-hover"
>
<span aria-hidden className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: mark.color }} />
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{markName(mark)}
</span>
<SoftPill tone={mark.kind === 'plan' ? 'neutral' : 'info'}>
{mark.kind === 'plan' ? t('journey.map.pick.planned') : t('journey.map.pick.stay')}
</SoftPill>
{mark.kind === 'plan' && mark.place.days[0] && (
<WhiteBadge>{dayLabel(mark.place.days[0], locale)}</WhiteBadge>
)}
<ChevronRight size={13} strokeWidth={2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,4 +1,4 @@
// FE-JRN-SHARE-001 to FE-JRN-SHARE-011
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -214,4 +214,35 @@ describe('JourneyShareSection', () => {
// The temporary textarea is removed again.
expect(document.querySelector('textarea')).toBeNull()
})
it('FE-JRN-SHARE-013: the trip plan and the recorded routes switch on under the map, each on its own (JRN-04)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })),
http.post('/api/journeys/7/share-link', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ token: 'tok-abc' })
}),
)
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toHaveAttribute('aria-pressed', 'false')
act(() => { plan.click() })
await waitFor(() => expect(bodies).toHaveLength(1))
expect(bodies[0]).toMatchObject({ share_plan: true })
expect(bodies[0]).not.toHaveProperty('share_recordings')
expect(screen.getByRole('button', { name: /Trip plan/ })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-JRN-SHARE-014: without the map shared the map extras are dimmed and cannot be switched', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink({ share_map: false, share_plan: true }) })))
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toBeDisabled()
expect(plan).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
})
})
@@ -1,6 +1,48 @@
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
import { Link, List, Grid, MapPin, Check, CalendarRange, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useJourneyShareLink } from './useJourneyShareLink'
import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { SHARE_MAP_EXTRAS, useJourneyShareLink, type JourneyShareLink, type JourneyShareMapExtra } from './useJourneyShareLink'
const EXTRA_ICONS = { share_plan: CalendarRange, share_recordings: Route } as const
/**
* What the shared map shows besides the entries (JRN-04): the trips' plan and
* the recorded days, each off until the owner switches it on, nested under the
* map and dimmed with the reason while the map is not shared.
*/
function ShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => void }) {
const { t } = useTranslation()
const mapShared = !!link.share_map
return (
<div className="ms-5 flex flex-col gap-1.5 border-s border-edge ps-3">
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => {
const Icon = EXTRA_ICONS[key]
const on = mapShared && !!link[key]
return (
<Tooltip key={key} label={mapShared ? '' : t('journey.share.needsMap')} placement="top">
<button
type="button"
aria-pressed={on}
disabled={!mapShared}
onClick={() => onToggle(key)}
className={`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-start transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
on ? 'border-accent bg-accent text-accent-text' : 'border-edge text-content-muted hover:border-content-faint'
}`}
>
<Icon size={13} className="mt-0.5 flex-none" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium" style={fs(12, 'body')}>{t(labelKey)}</span>
<span className={on ? 'text-accent-text opacity-80' : 'text-content-faint'} style={fs(11)}>{t(hintKey)}</span>
</span>
{on && <Check size={12} className="mt-0.5 flex-none" />}
</button>
</Tooltip>
)
})}
</div>
)
}
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
@@ -61,6 +103,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
{link[key] && <Check size={12} className="ms-auto" />}
</button>
))}
<ShareMapExtras link={link} onToggle={key => void togglePerm(key)} />
</div>
</div>
)}
@@ -1,6 +1,8 @@
import { useRef, useEffect } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import JourneyLayeredMap from './JourneyLayeredMap'
import type { JourneyMapLayersView } from './useJourneyMapLayers'
import type { MapPhoto } from './journeyPhotoLayer'
import JourneyEntryCover from './JourneyEntryCover'
import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf } from './journeyCard'
@@ -35,6 +37,14 @@ interface Props {
showWeather?: boolean
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
initialEntryId?: string | null
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
layers?: JourneyMapLayersView | null
entryTitleOf?: (entryId: number) => string | null
/** Geotagged photos on the map, a tap opening them. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
/** Where the layer switches sit, below a bar laid over the top of the map. */
controlsTop?: number | string
}
export default function MobileMapTimeline({
@@ -52,6 +62,11 @@ export default function MobileMapTimeline({
showMood = true,
showWeather = true,
initialEntryId,
layers,
entryTitleOf,
photos,
onPhotoClick,
controlsTop,
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
@@ -96,12 +111,20 @@ export default function MobileMapTimeline({
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
@@ -128,12 +151,20 @@ export default function MobileMapTimeline({
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */}
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
@@ -0,0 +1,67 @@
import L from 'leaflet';
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
/**
* Puts each layer whose switch changed back on the map or takes it off,
* exactly as it was drawn, and answers what is shown now.
*/
export function switchLeafletLayers(
map: L.Map | null,
drawn: DrawnLayers,
shown: JourneyMapLayerVisibility,
next: JourneyMapLayerVisibility
): JourneyMapLayerVisibility {
if (!map) return shown;
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
if (shown[kind] === next[kind]) continue;
for (const layer of drawn[kind]) {
if (next[kind]) layer.addTo(map);
else layer.remove();
}
}
return next;
}
/**
* The plan's places and the stays as one layer: below the entry pins, above
* the photo thumbnails, joined only where they would overlap on screen.
* Null while there is nothing to draw or the map has no view yet.
*/
export function drawLeafletMarks(
map: L.Map,
marks: readonly JourneyMapMark[] | undefined,
onPick: (members: JourneyMapMark[]) => void
): L.LayerGroup | null {
if (!marks?.length) return null;
try {
map.getCenter();
} catch {
// No view yet: the moveend after the first fit draws them.
return null;
}
const group = L.layerGroup();
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members);
const marker = L.marker([hit.lat, hit.lng], {
icon: L.divIcon({
className: '',
iconSize: [look.width, look.height],
iconAnchor: [look.width / 2, look.anchor === 'bottom' ? look.height : look.height / 2],
html: look.html,
}),
zIndexOffset: -200,
keyboard: false,
});
marker.on('click', () => onPick(hit.members));
group.addLayer(marker);
}
group.addTo(map);
return group;
}
@@ -0,0 +1,172 @@
// FE-JOURNEY-MAPLAYERS-001 to FE-JOURNEY-MAPLAYERS-008
//
// JRN-04: the journey map's layer switches as kept in this browser, the marks
// the plan and the stays become, and the switches a reader is offered.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
layersStorageKey,
parseStoredLayers,
readStoredLayers,
sourcesOf,
stateOf,
writeStoredLayers,
} from './journeyMapLayers';
const place = (over: Partial<JourneyMapPlace> = {}): JourneyMapPlace => ({
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: '#e11d48',
category_icon: null,
entry_id: null,
...over,
});
const stay = (over: Partial<JourneyMapStay> = {}): JourneyMapStay => ({
id: 9,
entry_id: 7,
name: 'Rialto',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
...over,
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('journey map layers', () => {
it('FE-JOURNEY-MAPLAYERS-001: keeps the switches per journey, and a link only by a hash of its token', () => {
expect(layersStorageKey({ journeyId: 12 })).toBe('trek:journey-map-layers:12');
const pub = layersStorageKey({ token: 'secret-token-value' });
expect(pub).toMatch(/^trek:journey-map-layers:pub:[0-9a-z]+$/);
expect(pub).not.toContain('secret');
expect(layersStorageKey({ token: 'secret-token-value' })).toBe(pub);
expect(layersStorageKey({ token: 'other' })).not.toBe(pub);
});
it('FE-JOURNEY-MAPLAYERS-002: reads a stored value leniently, a layer it does not name at its default', () => {
expect(parseStoredLayers(null)).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('not json')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('[true]')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('{"plan":false,"photos":"no","extra":true}')).toEqual({ ...ALL_VISIBLE, plan: false });
});
it('FE-JOURNEY-MAPLAYERS-003: storage that refuses leaves every layer on and the switch working for the visit', () => {
writeStoredLayers('k', { ...ALL_VISIBLE, entries: false });
expect(readStoredLayers('k')).toEqual({ ...ALL_VISIBLE, entries: false });
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(readStoredLayers('k')).toEqual(ALL_VISIBLE);
expect(() => writeStoredLayers('k', ALL_VISIBLE)).not.toThrow();
});
it('FE-JOURNEY-MAPLAYERS-004: a planned place an entry pin came from is that pin, with the check; without entries it stands alone', () => {
const written = place({ place_id: 4, entry_id: 7 });
const alone = place({ place_id: 5, entry_id: null, category_color: 'red;evil' });
const onMap = new Set(['7']);
const both = buildMarks({
places: [written, alone],
stays: [],
visibility: ALL_VISIBLE,
entryIdsOnMap: onMap,
stayColor: () => null,
});
expect(both.marks.map((m) => m.key)).toEqual(['plan:5']);
expect(both.marks[0].color).toBe('#64748b');
expect([...both.plannedEntryIds]).toEqual(['7']);
const noEntries = buildMarks({
places: [written, alone],
stays: [stay()],
visibility: { ...ALL_VISIBLE, entries: false },
entryIdsOnMap: onMap,
stayColor: () => '#14b8a6',
});
expect(noEntries.marks.map((m) => m.key)).toEqual(['plan:4', 'plan:5']);
expect(noEntries.plannedEntryIds.size).toBe(0);
});
it('FE-JOURNEY-MAPLAYERS-005: stays follow the entries switch in the colour of their day, the plan its own switch', () => {
const marks = buildMarks({
places: [place()],
stays: [stay(), stay({ id: 10, entry_id: 8 })],
visibility: { ...ALL_VISIBLE, plan: false },
entryIdsOnMap: new Set(),
stayColor: (entryId) => (entryId === 7 ? '#14b8a6' : null),
}).marks;
expect(marks.map((m) => [m.key, m.color])).toEqual([
['stay:9', '#14b8a6'],
['stay:10', '#64748b'],
]);
});
it('FE-JOURNEY-MAPLAYERS-006: a failed read makes every source unavailable; nothing read yet is idle, not empty', () => {
expect(sourcesOf(null).places).toEqual({ status: 'unavailable' });
expect(sourcesOf(undefined)).toEqual({});
expect(stateOf(undefined, false, 0)).toBe('idle');
expect(stateOf(undefined, true, 0)).toBe('loading');
expect(stateOf({ status: 'ok', items: [] }, false, 0)).toBe('empty');
expect(stateOf({ status: 'ok', items: [1] }, false, 1)).toBe('ready');
});
it('FE-JOURNEY-MAPLAYERS-007: a link offers no switch for what it withholds; a journey always offers its recordings', () => {
const withheld = { status: 'withheld' } as const;
const shared = buildControls({
ownJourney: false,
sources: { places: withheld, routes: withheld, stays: { status: 'ok', items: [] }, recordings: withheld },
loading: false,
counts: { places: 0, routes: 0, recordedDays: 0, entries: 3, stays: 0, photos: 0 },
});
expect(shared.map((c) => [c.layer, c.state])).toEqual([
['entries', 'ready'],
['photos', 'empty'],
]);
const own = buildControls({
ownJourney: true,
sources: {},
loading: false,
counts: { places: 0, routes: 0, recordedDays: 2, entries: 0, stays: 0, photos: 4 },
});
expect(own.map((c) => [c.layer, c.state, c.count])).toEqual([
['plan', 'idle', 0],
['recorded', 'ready', 2],
['entries', 'empty', 0],
['photos', 'ready', 4],
]);
});
it('FE-JOURNEY-MAPLAYERS-008: a failed route or stay read warns on its switch while the rest still counts', () => {
const controls = buildControls({
ownJourney: true,
sources: {
places: { status: 'ok', items: [] },
routes: { status: 'unavailable' },
stays: { status: 'unavailable' },
},
loading: false,
counts: { places: 3, routes: 0, recordedDays: 0, entries: 5, stays: 0, photos: 0 },
});
expect(controls.find((c) => c.layer === 'plan')).toEqual({ layer: 'plan', count: 3, state: 'unavailable' });
expect(controls.find((c) => c.layer === 'entries')).toEqual({ layer: 'entries', count: 5, state: 'unavailable' });
});
});
@@ -0,0 +1,244 @@
import type {
JourneyMapLayerResult,
JourneyMapLayersResponse,
JourneyMapPlace,
JourneyMapStay,
JourneyPublicMapLayersResponse,
JourneyTrack,
} from '@trek/shared';
import { safeColor, type JourneyMapMark } from './journeyMapMarks';
/**
* The four layers of a journey map (JRN-04), switched on and off one by one
* by each reader for themselves. A switch only ever hides: it changes no
* trip, entry or photo, and it never shows a reader what the journey does not
* give them.
*/
const JOURNEY_MAP_LAYERS = ['plan', 'recorded', 'entries', 'photos'] as const;
export type JourneyMapLayer = (typeof JOURNEY_MAP_LAYERS)[number];
export type JourneyMapVisibility = Record<JourneyMapLayer, boolean>;
/** The layers a renderer draws itself and switches in place; the photos it is handed or not. */
export type JourneyMapLayerVisibility = Pick<JourneyMapVisibility, 'plan' | 'recorded' | 'entries'>;
export const ALL_VISIBLE: JourneyMapVisibility = { plan: true, recorded: true, entries: true, photos: true };
/** What a layer's switch tells the reader beside its count; `idle` is a layer not read yet because it is off. */
type JourneyMapLayerState = 'ready' | 'loading' | 'unavailable' | 'empty' | 'idle';
export interface JourneyMapLayerControl {
layer: JourneyMapLayer;
count: number;
state: JourneyMapLayerState;
}
/** A switch's name, and what its tooltip adds when the layer has nothing to draw or could not be read. */
export const LAYER_TEXT: Record<JourneyMapLayer, { labelKey: string; emptyKey: string; unavailableKey: string }> = {
plan: {
labelKey: 'journey.map.layer.plan',
emptyKey: 'journey.map.layer.planEmpty',
unavailableKey: 'journey.map.layer.planUnavailable',
},
recorded: {
labelKey: 'journey.map.layer.recorded',
emptyKey: 'journey.map.layer.recordedEmpty',
unavailableKey: 'journey.map.layer.recordedUnavailable',
},
entries: {
labelKey: 'journey.map.layer.entries',
emptyKey: 'journey.map.layer.entriesEmpty',
unavailableKey: 'journey.map.layer.entriesUnavailable',
},
photos: {
labelKey: 'journey.map.layer.photos',
emptyKey: 'journey.map.layer.photosEmpty',
unavailableKey: 'journey.map.layer.photosEmpty',
},
};
const STORAGE_PREFIX = 'trek:journey-map-layers:';
/**
* Where a reader's switches are kept: per journey, and per link on a shared
* page. The link's token is the credential to the journey, so only a hash of
* it is written to the browser's storage.
*/
export function layersStorageKey(scope: { journeyId: number } | { token: string }): string {
if ('journeyId' in scope) return `${STORAGE_PREFIX}${scope.journeyId}`;
let hash = 0x811c9dc5;
for (let i = 0; i < scope.token.length; i++) {
hash ^= scope.token.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return `${STORAGE_PREFIX}pub:${(hash >>> 0).toString(36)}`;
}
/** A stored value read leniently: a layer it does not name stays at its default. */
export function parseStoredLayers(raw: string | null): JourneyMapVisibility {
if (!raw) return ALL_VISIBLE;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return ALL_VISIBLE;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return ALL_VISIBLE;
const stored = parsed as Record<string, unknown>;
const next = { ...ALL_VISIBLE };
for (const layer of JOURNEY_MAP_LAYERS) {
if (typeof stored[layer] === 'boolean') next[layer] = stored[layer] as boolean;
}
return next;
}
export function readStoredLayers(key: string): JourneyMapVisibility {
try {
return parseStoredLayers(window.localStorage.getItem(key));
} catch {
return ALL_VISIBLE;
}
}
export function writeStoredLayers(key: string, visibility: JourneyMapVisibility): void {
try {
window.localStorage.setItem(key, JSON.stringify(visibility));
} catch {
// A private window or blocked storage: the switch works for this visit only.
}
}
/** The items of a source that answered, nothing for one that did not. */
export function itemsOf<T>(layer: JourneyMapLayerResult<T> | undefined): T[] {
return layer?.status === 'ok' ? layer.items : [];
}
/** How a source that the reader may see stands, as its switch shows it. */
export function stateOf(
layer: JourneyMapLayerResult<unknown> | undefined,
loading: boolean,
count: number
): JourneyMapLayerState {
if (layer?.status === 'unavailable') return 'unavailable';
if (!layer) return loading ? 'loading' : 'idle';
return count > 0 ? 'ready' : 'empty';
}
/**
* The marks of the plan and of the stays, as the renderers draw them. A
* planned place an entry on the map came from is left to that entry's pin,
* which carries the plan's check instead: one spot, one pin. Without the
* entries layer the plan pin stands on its own again.
*/
export function buildMarks({
places,
stays,
visibility,
entryIdsOnMap,
stayColor,
}: {
places: JourneyMapPlace[];
stays: JourneyMapStay[];
visibility: JourneyMapVisibility;
entryIdsOnMap: ReadonlySet<string>;
stayColor: (entryId: number) => string | null;
}): { marks: JourneyMapMark[]; plannedEntryIds: ReadonlySet<string> } {
const marks: JourneyMapMark[] = [];
const plannedEntryIds = new Set<string>();
if (visibility.plan) {
for (const place of places) {
const entryId = place.entry_id === null ? null : String(place.entry_id);
if (visibility.entries && entryId && entryIdsOnMap.has(entryId)) {
plannedEntryIds.add(entryId);
continue;
}
marks.push({
kind: 'plan',
key: `plan:${place.place_id}`,
lat: place.lat,
lng: place.lng,
color: safeColor(place.category_color),
place,
});
}
}
if (visibility.entries) {
for (const stay of stays) {
marks.push({
kind: 'stay',
key: `stay:${stay.id}`,
lat: stay.lat,
lng: stay.lng,
color: safeColor(stayColor(stay.entry_id)),
stay,
});
}
}
return { marks, plannedEntryIds };
}
/** What the server answered for a map: a journey's own layers, or a link's with its recorded days. */
export type JourneyMapAnswer = JourneyMapLayersResponse | JourneyPublicMapLayersResponse;
const UNAVAILABLE = { status: 'unavailable' } as const;
/**
* Every source of an answer on its own: undefined while nothing was read, all
* unavailable when the read itself failed (null). The recorded days come only
* with a link's answer; a journey's own map reads them with their details.
*/
export function sourcesOf(answer: JourneyMapAnswer | null | undefined): {
places?: JourneyMapLayersResponse['places'];
routes?: JourneyMapLayersResponse['routes'];
stays?: JourneyMapLayersResponse['stays'];
recordings?: JourneyPublicMapLayersResponse['recordings'];
} {
if (answer === null) return { places: UNAVAILABLE, routes: UNAVAILABLE, stays: UNAVAILABLE, recordings: UNAVAILABLE };
if (!answer) return {};
const recordings = 'recordings' in answer ? (answer as JourneyPublicMapLayersResponse).recordings : undefined;
return { places: answer.places, routes: answer.routes, stays: answer.stays, recordings };
}
/**
* The switches a reader is offered, each with its count and state. A layer a
* share link withholds is not offered at all; one with nothing to draw is,
* dimmed, with the reason in its tooltip. A journey's own map always offers
* what was recorded, which the screen reads itself.
*/
export function buildControls({
ownJourney,
sources,
loading,
counts,
}: {
ownJourney: boolean;
sources: ReturnType<typeof sourcesOf>;
loading: boolean;
counts: { places: number; routes: number; recordedDays: number; entries: number; stays: number; photos: number };
}): JourneyMapLayerControl[] {
const list: JourneyMapLayerControl[] = [];
if (sources.places?.status !== 'withheld') {
const state =
sources.routes?.status === 'unavailable'
? 'unavailable'
: stateOf(sources.places, loading, counts.places + counts.routes);
list.push({ layer: 'plan', count: counts.places, state });
}
if (ownJourney || sources.recordings?.status !== 'withheld') {
const state = ownJourney
? counts.recordedDays > 0
? 'ready'
: 'empty'
: stateOf(sources.recordings, loading, counts.recordedDays);
list.push({ layer: 'recorded', count: counts.recordedDays, state });
}
const entriesState =
sources.stays?.status === 'unavailable' ? 'unavailable' : counts.entries + counts.stays > 0 ? 'ready' : 'empty';
list.push({ layer: 'entries', count: counts.entries, state: entriesState });
list.push({ layer: 'photos', count: counts.photos, state: counts.photos > 0 ? 'ready' : 'empty' });
return list;
}
/** Whether a track belongs to the plan (a trip's GPX) or to what was recorded. */
export function isPlannedTrack(track: JourneyTrack): boolean {
return !track.kind || track.kind === 'trip_gpx';
}
@@ -0,0 +1,57 @@
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
//
// JRN-04: the plan and stay marks both renderers draw, and how they join.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
MARK_CLUSTER_PX,
clusterPoints,
markHitSvg,
planPinSvg,
safeColor,
type JourneyMapMark,
} from './journeyMapMarks';
const planMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'plan',
key,
lat,
lng: 12,
color: '#e11d48',
place: { name: key } as JourneyMapPlace,
});
const stayMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'stay',
key,
lat,
lng: 12,
color: '#14b8a6',
stay: { name: key } as JourneyMapStay,
});
describe('journey map marks', () => {
it('FE-JOURNEY-MAPMARKS-001: joins only what would overlap on screen, anchored on the first', () => {
const project = (lat: number, lng: number) => ({ x: lng, y: lat * 100 });
const hits = clusterPoints([planMark('a', 1), stayMark('b', 1.05), planMark('c', 5)], project, MARK_CLUSTER_PX);
expect(hits.map((h) => h.members.map((m) => m.key))).toEqual([['a', 'b'], ['c']]);
expect(hits[0]).toMatchObject({ lat: 1, lng: 12 });
});
it('FE-JOURNEY-MAPMARKS-002: a lone plan is a hollow dashed pin, a lone stay a dot, several a disc with the count', () => {
const plan = markHitSvg([planMark('a', 1)]);
expect(plan).toMatchObject({ anchor: 'bottom', width: 22, height: 28 });
expect(plan.html).toContain('stroke-dasharray');
expect(markHitSvg([stayMark('b', 1)])).toMatchObject({ anchor: 'center', width: 14 });
const several = markHitSvg([planMark('a', 1), stayMark('b', 1), planMark('c', 1)]);
expect(several.anchor).toBe('center');
expect(several.html).toContain('>3<');
});
it('FE-JOURNEY-MAPMARKS-003: only a hex colour reaches the markup', () => {
expect(safeColor('#ABCDEF')).toBe('#ABCDEF');
expect(safeColor('#abc')).toBe('#abc');
expect(safeColor('"/><script>')).toBe('#64748b');
expect(safeColor(null)).toBe('#64748b');
expect(planPinSvg('red" onload="x')).not.toContain('onload');
});
});
@@ -0,0 +1,120 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
/**
* The marks a journey map draws beside its entries and photos (JRN-04): the
* places the plan puts on a day, and the stays a journal entry was taken over
* from. One module for both renderers, so a mark says the same on Leaflet and
* on GL. The markup is SVG with its colours as attributes: it is drawn over
* any basemap, so it keeps its own contrast instead of following the scheme.
*/
export type JourneyMapMark =
| { kind: 'plan'; key: string; lat: number; lng: number; color: string; place: JourneyMapPlace }
| { kind: 'stay'; key: string; lat: number; lng: number; color: string; stay: JourneyMapStay };
/** The colour of a planned place whose category has none, a neutral slate that reads on light and dark tiles. */
export const PLAN_FALLBACK_COLOR = '#64748b';
/**
* Marks closer than this on screen are one hit: the reader could not pick one
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
* thumbnail, so marks a street apart stay apart.
*/
export const MARK_CLUSTER_PX = 26;
const PLAN_PIN_W = 22;
const PLAN_PIN_H = 28;
const STAY_DOT = 14;
const MARK_CLUSTER_SIZE = 28;
/**
* Grid clustering in screen space, shared by both renderers: points whose
* projected position falls in the same `px` cell become one, anchored on the
* first so the pin points where that member is. `project` is the engine's own
* lat/lng to container-pixel conversion.
*/
export function clusterPoints<T extends { lat: number; lng: number }>(
points: readonly T[],
project: (lat: number, lng: number) => { x: number; y: number },
px: number
): { lat: number; lng: number; members: T[] }[] {
const buckets = new Map<string, T[]>();
for (const point of points) {
const pt = project(point.lat, point.lng);
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
const list = buckets.get(key);
if (list) list.push(point);
else buckets.set(key, [point]);
}
return [...buckets.values()].map((members) => ({ lat: members[0].lat, lng: members[0].lng, members }));
}
/** Only a `#rgb`/`#rrggbb` colour reaches markup; anything else a category holds falls back. */
export function safeColor(color: string | null | undefined, fallback = PLAN_FALLBACK_COLOR): string {
return color && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : fallback;
}
/**
* A planned place: a hollow pin, dashed in its category colour, smaller than
* an entry pin. Hollow on purpose: the plan is what was meant, not what was
* lived, and it never reads as a filled journal pin.
*/
export function planPinSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${PLAN_PIN_W}" height="${PLAN_PIN_H}" viewBox="0 0 22 28" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.25))">
<path d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z" fill="#ffffff" fill-opacity="0.92" stroke="${c}" stroke-width="2" stroke-dasharray="3 2"/>
<circle cx="11" cy="10.5" r="3" fill="${c}"/>
</svg>`;
}
/** A stay of a day report: a dot in the colour of its day, ringed so it holds on any tile. */
function stayDotSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${STAY_DOT}" height="${STAY_DOT}" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))">
<circle cx="7" cy="7" r="5.5" fill="${c}" stroke="#ffffff" stroke-width="2"/>
</svg>`;
}
/**
* Several marks on one spot: a stacked disc with the count, ringed in the
* colours of up to two members so the reader sees what kinds lie beneath.
*/
function markClusterSvg(count: number, colors: string[]): string {
const [a, b] = [safeColor(colors[0]), safeColor(colors[1] ?? colors[0])];
const label = count > 99 ? '99+' : String(count);
return `<svg width="${MARK_CLUSTER_SIZE}" height="${MARK_CLUSTER_SIZE}" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" style="display:block;cursor:pointer;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke="${b}" stroke-width="2"/>
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke="${a}" stroke-width="2.5"/>
<text x="12" y="16" text-anchor="middle" dominant-baseline="central" fill="#111827" font-family="'Poppins',system-ui,sans-serif" font-size="10" font-weight="700">${label}</text>
</svg>`;
}
/** The markup of one hit on the map: a mark alone, or the disc of several. */
export function markHitSvg(members: readonly JourneyMapMark[]): {
html: string;
width: number;
height: number;
anchor: 'bottom' | 'center';
} {
if (members.length > 1) {
return {
html: markClusterSvg(
members.length,
members.map((m) => m.color)
),
width: MARK_CLUSTER_SIZE,
height: MARK_CLUSTER_SIZE,
anchor: 'center',
};
}
const mark = members[0];
return mark.kind === 'plan'
? { html: planPinSvg(mark.color), width: PLAN_PIN_W, height: PLAN_PIN_H, anchor: 'bottom' }
: { html: stayDotSvg(mark.color), width: STAY_DOT, height: STAY_DOT, anchor: 'center' };
}
/**
* The small check an entry pin carries when the plan had that place too: plan
* and memory at one spot read as one pin, not two on top of each other. Sits
* at the pin's top corner, inside its 28 x 36 box.
*/
export const PLANNED_BADGE_SVG = `<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" stroke-width="1.2"/><path d="M20.4 5.6l1.5 1.5 2.8-2.9" fill="none" stroke="#16a34a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
@@ -1,3 +1,5 @@
import { clusterPoints } from './journeyMapMarks'
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
@@ -22,21 +24,9 @@ export function clusterPhotos(
photos: MapPhoto[],
project: (lat: number, lng: number) => { x: number; y: number },
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = project(photo.lat, photo.lng)
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
// Anchored on the first member rather than the centroid: the thumbnail shown
// is that photo's, so the pin should point where that picture was taken.
return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
}
export function photoMarkerHtml(thumbUrl: string, count: number): string {
@@ -0,0 +1,196 @@
// FE-JOURNEY-MAPLAYERS-HOOK-001 to FE-JOURNEY-MAPLAYERS-HOOK-005
//
// JRN-04: the journey map's layers as the desktop page, the phone screen and
// the shared page use them: read lazily, switched per reader, a failed source
// on its own.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { useJourneyMapLayers } from './useJourneyMapLayers';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, string>) => (p?.date ? `${k}:${p.date}` : k),
locale: 'en',
}),
}));
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: null,
category_icon: null,
entry_id: null,
};
const ROUTE: JourneyTrack = {
place_id: 4,
trip_id: 2,
name: 'Ridge',
color: null,
points: [
[1, 1],
[2, 2],
],
kind: 'trip_gpx',
};
const NO_ENTRIES: ReadonlySet<string> = new Set();
const colorOfDay = () => '#14b8a6';
const dateOfEntry = () => '2026-05-14';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
function journeyMap(journeyId: number | null = 7, recordedTracks: JourneyTrack[] = []) {
return renderHook(() =>
useJourneyMapLayers({
source: journeyId === null ? null : { journeyId },
entryIdsOnMap: NO_ENTRIES,
photoCount: 2,
recordedTracks,
colorOfDay,
dateOfEntry,
})
);
}
describe('useJourneyMapLayers', () => {
it('FE-JOURNEY-MAPLAYERS-HOOK-001: reads the journey layers once it is loaded and draws the plan with its routes', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [ROUTE] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
expect(layers).toHaveBeenCalledWith(7, expect.any(AbortSignal));
expect(result.current.tracks).toEqual([ROUTE]);
expect(result.current.controls.find((c) => c.layer === 'plan')).toEqual({
layer: 'plan',
count: 1,
state: 'ready',
});
expect(result.current.controls.find((c) => c.layer === 'photos')).toEqual({
layer: 'photos',
count: 2,
state: 'ready',
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-002: reads nothing before the journey is loaded, nor while plan and entries are off', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
journeyMap(null);
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
const { result } = journeyMap(8);
expect(result.current.visibility).toMatchObject({ plan: false, entries: false, recorded: true, photos: true });
expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('idle');
await act(async () => {});
expect(layers).not.toHaveBeenCalled();
act(() => result.current.toggle('plan'));
await waitFor(() => expect(layers).toHaveBeenCalledWith(8, expect.any(AbortSignal)));
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toMatchObject({
plan: true,
entries: false,
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
const recorded: JourneyTrack = {
place_id: -100001,
trip_id: 0,
name: 'Day 1',
color: '#000000',
points: [
[1, 1],
[2, 2],
],
kind: 'day_recording',
};
const { result } = journeyMap(7, [recorded]);
await waitFor(() => expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('unavailable'));
expect(result.current.controls.find((c) => c.layer === 'entries')?.state).toBe('unavailable');
expect(result.current.controls.find((c) => c.layer === 'recorded')).toEqual({
layer: 'recorded',
count: 1,
state: 'ready',
});
expect(result.current.tracks).toEqual([recorded]);
});
it('FE-JOURNEY-MAPLAYERS-HOOK-004: a shared page draws the recorded days a link shares, each piece apart, and offers no plan it withholds', async () => {
vi.spyOn(journeyMapRepo, 'publicLayers').mockResolvedValue({
places: { status: 'withheld' },
routes: { status: 'withheld' },
stays: { status: 'ok', items: [] },
recordings: {
status: 'ok',
items: [
{
date: '2026-05-14',
pieces: [
[
[1, 1],
[2, 2],
],
[
[3, 3],
[4, 4],
],
],
distance_m: 1200,
},
],
},
});
const { result } = renderHook(() =>
useJourneyMapLayers({
source: { token: 'tok' },
entryIdsOnMap: NO_ENTRIES,
photoCount: 0,
colorOfDay,
dateOfEntry,
})
);
await waitFor(() => expect(result.current.tracks).toHaveLength(2));
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
true
);
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
});
it('FE-JOURNEY-MAPLAYERS-HOOK-005: switching the plan off takes its marks away without reading again', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
act(() => result.current.toggle('plan'));
expect(result.current.marks).toHaveLength(0);
expect(result.current.visibility.plan).toBe(false);
expect(layers).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,207 @@
import type { JourneyTrack } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
itemsOf,
layersStorageKey,
readStoredLayers,
sourcesOf,
writeStoredLayers,
type JourneyMapAnswer,
type JourneyMapLayer,
type JourneyMapLayerControl,
type JourneyMapVisibility,
} from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
/** Whose map it is: a journey its reader belongs to, or one a share link opens. */
type JourneyMapSource = { journeyId: number } | { token: string };
export interface JourneyMapLayersView {
visibility: JourneyMapVisibility;
toggle: (layer: JourneyMapLayer) => void;
/** The switches the reader is offered: a layer a share link does not show is not among them. */
controls: JourneyMapLayerControl[];
/** The plan's routes and what was recorded, each kind hidden by its own switch on the map. */
tracks: JourneyTrack[];
marks: JourneyMapMark[];
/** Entry pins whose place the plan had too: they carry the plan's check. */
plannedEntryIds: ReadonlySet<string>;
}
interface Options {
/** Null until the journey the screen names is the one loaded. */
source: JourneyMapSource | null;
/** Changes when entries come or go, so a place's entry and a report's stays follow. */
refreshKey?: string;
/** The entries that have a pin on the map. */
entryIdsOnMap: ReadonlySet<string>;
photoCount: number;
/** What was recorded that the screen already holds: the kept days and the live trail of a journey's people. */
recordedTracks?: JourneyTrack[];
/** The colour of the pins of a day, so a stay and a recorded day read with their day. */
colorOfDay: (date: string) => string | null;
/** The day an entry is on, for the colour of its stays. */
dateOfEntry: (entryId: number) => string | null;
}
const NO_TRACKS: JourneyTrack[] = [];
/** The reader's switches, kept per journey (or per link) in this browser. */
function useLayerSwitches(storageKey: string | null) {
const [stored, setStored] = useState<{ key: string | null; visibility: JourneyMapVisibility } | null>(null);
const visibility = useMemo(() => {
if (stored && stored.key === storageKey) return stored.visibility;
return storageKey ? readStoredLayers(storageKey) : ALL_VISIBLE;
}, [stored, storageKey]);
const toggle = useCallback(
(layer: JourneyMapLayer) => {
const next = { ...visibility, [layer]: !visibility[layer] };
setStored({ key: storageKey, visibility: next });
if (storageKey) writeStoredLayers(storageKey, next);
},
[visibility, storageKey]
);
return { visibility, toggle };
}
/**
* The layers read from the server, once per `requestKey`. What was read for
* an earlier key of the same map stays until the new answer lands; null is a
* read that failed.
*/
function useLayersRead(
journeyId: number | null,
token: string | null,
requestKey: string | null,
sourceKey: string | null
) {
const [loaded, setLoaded] = useState<{ key: string; layers: JourneyMapAnswer | null } | null>(null);
useEffect(() => {
if (!requestKey) return;
const controller = new AbortController();
const read =
journeyId !== null
? journeyMapRepo.layers(journeyId, controller.signal)
: journeyMapRepo.publicLayers(token ?? '', controller.signal);
read
.then((layers) => setLoaded({ key: requestKey, layers }))
.catch(() => {
if (!controller.signal.aborted) setLoaded({ key: requestKey, layers: null });
});
return () => controller.abort();
}, [requestKey, journeyId, token]);
const layers = loaded && sourceKey && loaded.key.startsWith(`${sourceKey}|`) ? loaded.layers : undefined;
return { layers, loading: !!requestKey && loaded?.key !== requestKey };
}
/**
* The layers of a journey map (JRN-04) for the desktop page, the phone screen
* and the shared page alike: the reader's switches, kept per journey in this
* browser, the plan and the stays read from the server, and what the
* renderers draw from them. Nothing is read while neither the plan nor the
* entries are shown; a source that fails says so on its switch and the
* others keep drawing.
*/
export function useJourneyMapLayers({
source,
refreshKey = '',
entryIdsOnMap,
photoCount,
recordedTracks = NO_TRACKS,
colorOfDay,
dateOfEntry,
}: Options): JourneyMapLayersView {
const { t, locale } = useTranslation();
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
const token = source && 'token' in source ? source.token : null;
const storageKey =
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
const { visibility, toggle } = useLayerSwitches(storageKey);
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
const wanted = token !== null || (journeyId !== null && (visibility.plan || visibility.entries));
const requestKey = wanted && sourceKey ? `${sourceKey}|${refreshKey}` : null;
const { layers, loading } = useLayersRead(journeyId, token, requestKey, sourceKey);
const sources = useMemo(() => sourcesOf(layers), [layers]);
const places = useMemo(() => itemsOf(sources.places), [sources.places]);
const routes = useMemo(() => itemsOf(sources.routes), [sources.routes]);
const stays = useMemo(() => itemsOf(sources.stays), [sources.stays]);
const sharedRecorded = useMemo(
() =>
itemsOf(sources.recordings).flatMap((day, dayIndex) => {
const name = t('journey.recording.mapLine', {
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const color = colorOfDay(day.date) ?? DAWARICH_TRAIL_COLOR;
return day.pieces.map((points, index): JourneyTrack => ({
// Negative and far from the live trail's ids, as the journey's own kept days.
place_id: -(100_000 + dayIndex * 1_000 + index),
trip_id: 0,
name,
color,
points,
kind: 'day_recording',
}));
}),
[sources.recordings, colorOfDay, t, locale]
);
const recorded = recordedTracks.length ? recordedTracks : sharedRecorded;
const tracks = useMemo(
() => (routes.length === 0 ? recorded : recorded.length === 0 ? routes : [...routes, ...recorded]),
[routes, recorded]
);
const stayColor = useCallback(
(entryId: number) => {
const date = dateOfEntry(entryId);
return date ? colorOfDay(date) : null;
},
[dateOfEntry, colorOfDay]
);
const { marks, plannedEntryIds } = useMemo(
() => buildMarks({ places, stays, visibility, entryIdsOnMap, stayColor }),
[places, stays, visibility, entryIdsOnMap, stayColor]
);
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
const controls = useMemo(
() =>
buildControls({
ownJourney: journeyId !== null,
sources,
loading,
counts: {
places: places.length,
routes: routes.length,
recordedDays,
entries: entryIdsOnMap.size,
stays: stays.length,
photos: photoCount,
},
}),
[
journeyId,
sources,
loading,
places.length,
routes.length,
recordedDays,
entryIdsOnMap.size,
stays.length,
photoCount,
]
);
return { visibility, toggle, controls, tracks, marks, plannedEntryIds };
}
@@ -1,4 +1,4 @@
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-009: the public share link logic behind
// both the desktop share section and the phone settings sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
@@ -123,4 +123,15 @@ describe('useJourneyShareLink', () => {
act(() => vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
it('FE-JRN-SHAREHOOK-009: the plan and the recorded days are switched on their own and start off (JRN-04)', async () => {
const { result } = await loaded();
await act(() => result.current.togglePerm('share_plan'));
expect(posted).toEqual([{ share_timeline: true, share_gallery: false, share_map: true, share_plan: true }]);
expect(result.current.link?.share_plan).toBe(true);
expect(result.current.link?.share_recordings).toBeUndefined();
await act(() => result.current.togglePerm('share_recordings'));
expect(posted[1]).toEqual({ share_timeline: true, share_gallery: false, share_map: true, share_recordings: true });
});
});
@@ -10,9 +10,20 @@ export interface JourneyShareLink {
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
/** JRN-04: the trips' plan and the recorded days on the shared map; off unless switched on. */
share_plan?: boolean;
share_recordings?: boolean;
}
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map';
/** What the shared map can show besides the entries, each only while the map itself is shared. */
export type JourneyShareMapExtra = 'share_plan' | 'share_recordings';
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map' | JourneyShareMapExtra;
/** The map extras the share switches offer, with what each puts on the shared map. */
export const SHARE_MAP_EXTRAS: { key: JourneyShareMapExtra; labelKey: string; hintKey: string }[] = [
{ key: 'share_plan', labelKey: 'journey.share.plan', hintKey: 'journey.share.planHint' },
{ key: 'share_recordings', labelKey: 'journey.share.recordings', hintKey: 'journey.share.recordingsHint' },
];
/** The public address of a journey share token. */
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
@@ -71,6 +82,8 @@ export function useJourneyShareLink(journeyId: number) {
share_timeline: updated.share_timeline,
share_gallery: updated.share_gallery,
share_map: updated.share_map,
// The map extras go only when switched: a link read before they existed keeps them off.
...(key === 'share_plan' || key === 'share_recordings' ? { [key]: !!updated[key] } : {}),
});
} catch {
setLink(previous);
+2 -18
View File
@@ -1,14 +1,14 @@
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import { PisteKeyButton } from '../Tours/winter/PisteKey'
import type { ComponentType, CSSProperties } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
import type { FerrataStatus } from '../Ferratas/useTripFerratas'
import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
@@ -25,14 +25,6 @@ export interface TourLayerOffer {
baseHealth?: BaseMapHealth
}
const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
// Both map engines place the switcher with the inset, so they cannot drift apart either.
@@ -92,14 +84,6 @@ const TOUR_LAYERS = [
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
/** A pill segment of the Tours control; `filled` lets its class paint the background. */
const segmentStyle = (filled: boolean): CSSProperties => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: filled ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
})
type Translate = (key: string, params?: Record<string, string | number>) => string
/** What the via ferrata layer is doing, in words, for the toggle's tooltip and the screen reader. */
@@ -1,3 +1,5 @@
import type { CSSProperties } from 'react'
/**
* The drop shadow every floating map control carries.
*
@@ -16,3 +18,20 @@ export const MAP_CARD_GLASS = {
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
/** The frosted round shell the map's control groups sit in. */
export const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
/** A pill segment of a map control group; `filled` lets its class paint the background. */
export const segmentStyle = (filled: boolean): CSSProperties => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: filled ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
})
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
@@ -51,7 +51,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showInvite, setShowInvite,
showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
sidebarMapItems, tracks, recordings, canTakeOverRecording, dayImport, dayImportDates, dawarichByDate, recordingPrep,
sidebarMapItems, tracks, mapLayers, entryTitleOf, mapPhotos, openMapPhotos, recordings, canTakeOverRecording, dayImport, dayImportDates, dawarichByDate, recordingPrep,
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
@@ -301,11 +301,18 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<div className="relative h-dvh overflow-hidden">
{/* Integrated map — always mounted, the gallery overlays it */}
<div className="absolute inset-0 z-0">
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
layers={mapLayers}
compactControls
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
checkins={[]}
entries={sidebarMapItems}
tracks={tracks}
photos={mapPhotos}
onPhotoClick={openMapPhotos}
height={9999}
dark={dark}
activeMarkerId={entries[activeIndex] ? String(entries[activeIndex].id) : null}
@@ -7,7 +7,7 @@ import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useTranslation } from '../../../i18n'
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
import { SHARE_MAP_EXTRAS, useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
import type { JourneyRecordingPrepState } from '../../../components/Journey/useJourneyRecordingPrep'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { journeyCoverSrc } from './mobileJourneyMeta'
@@ -305,6 +305,23 @@ export default function MJourneySettingsSheet({
<MToggle checked={shareLink[key]} onChange={() => toggleSharePerm(key)} ariaLabel={label} />
</div>
))}
{/* What the shared map shows besides the entries (JRN-04), only while the map is shared. */}
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => (
<div key={key} className={`flex items-center gap-[11px] py-[8px] ps-[29px] pe-[2px] ${shareLink.share_map ? '' : 'opacity-50'}`}>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-body font-bold">{t(labelKey)}</span>
<span className="font-geist text-caption text-m-muted">
{shareLink.share_map ? t(hintKey) : t('journey.share.needsMap')}
</span>
</span>
<MToggle
checked={!!shareLink.share_map && !!shareLink[key]}
onChange={() => { void toggleSharePerm(key) }}
ariaLabel={t(labelKey)}
disabled={!shareLink.share_map}
/>
</div>
))}
<button
type="button"
onClick={deleteShareLink}
+10 -3
View File
@@ -1,7 +1,7 @@
import { useAuthStore } from '../store/authStore'
import { journeyApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto'
import JourneyLayeredMap from '../components/Journey/JourneyLayeredMap'
import { DAY_COLORS } from '../components/Journey/dayColors'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../components/Journey/ContributorInviteDialog'
@@ -68,7 +68,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos, mapLayers, entryTitleOf,
recordings, canTakeOverRecording, recordingNeedsConnection, dayImport, recordingPrep,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
@@ -189,6 +189,10 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
entries={timelineEntries}
mapEntries={sidebarMapItems}
tracks={tracks}
layers={mapLayers}
entryTitleOf={entryTitleOf}
photos={mapPhotos}
onPhotoClick={openMapPhotos}
dark={document.documentElement.classList.contains('dark')}
readOnly={!canEditEntries}
onEntryClick={(entry) => setViewingEntry(entry)}
@@ -713,8 +717,11 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
{!isMobile && (
<aside className="w-[44%] max-w-[820px] min-w-[420px] pt-6 pe-4 pb-4 ps-0">
<div className="h-full rounded-[22px] overflow-hidden shadow-sm" style={{ border: '1px solid var(--vg-line)' }}>
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
layers={mapLayers}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
checkins={[]}
entries={sidebarMapItems as any}
tracks={tracks}
@@ -1,4 +1,4 @@
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-046
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-047
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../tests/helpers/render';
import { journeyApi } from '../api/client';
@@ -661,4 +661,22 @@ describe('JourneyDetailPage wiring', () => {
expect(screen.getByTestId('day-import-dialog')).toBeInTheDocument();
expect(mocks.captured.dayImport.prep).toBe(recordingPrep);
});
it('FE-JRN-DETWIRE-047: the sidebar map draws the layers of the hook, its switches above it (JRN-04)', () => {
const mapLayers = {
visibility: { plan: true, recorded: false, entries: true, photos: true },
toggle: vi.fn(),
controls: [{ layer: 'plan', count: 3, state: 'ready' }],
tracks: [],
marks: [],
plannedEntryIds: new Set(['1']),
};
setup({ mapLayers, entryTitleOf: () => null });
expect(mocks.captured.map.layerVisibility).toBe(mapLayers.visibility);
expect(mocks.captured.map.plannedEntryIds).toBe(mapLayers.plannedEntryIds);
expect(mocks.captured.map.tracks).toBe(mapLayers.tracks);
fireEvent.click(screen.getByTestId('journey-map-layer-plan'));
expect(mapLayers.toggle).toHaveBeenCalledWith('plan');
});
});
+23 -3
View File
@@ -21,7 +21,7 @@ import {
} from 'lucide-react';
import { DAY_COLORS } from '../components/Journey/dayColors';
import JournalBody from '../components/Journey/JournalBody';
import JourneyMap from '../components/Journey/JourneyMap';
import JourneyLayeredMap from '../components/Journey/JourneyLayeredMap';
import MobileEntryView from '../components/Journey/MobileEntryView';
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
import PhotoLightbox from '../components/Journey/PhotoLightbox';
@@ -124,6 +124,8 @@ export default function JourneyPublicPage() {
allPhotos,
stopNumberById,
mapPhotos,
mapLayers,
entryTitleOf,
desktopTwoColumn,
} = useJourneyPublic();
@@ -622,8 +624,12 @@ export default function JourneyPublicPage() {
}}
>
<div className="h-full overflow-hidden rounded-2xl border border-zinc-200 shadow-sm dark:border-zinc-800">
<JourneyMap
<JourneyLayeredMap
ref={mapRef}
engine="leaflet"
layers={mapLayers}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
checkins={[]}
entries={sidebarMapItems as any}
photos={photoLayer}
@@ -674,6 +680,10 @@ export default function JourneyPublicPage() {
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/thumbnail`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
layers={mapLayers}
entryTitleOf={entryTitleOf}
photos={photoLayer}
controlsTop={availableViews.length > 1 ? 'calc(env(safe-area-inset-top, 0px) + 56px)' : undefined}
showMood={journey.show_mood !== 0}
showWeather={journey.show_weather !== 0}
/>
@@ -688,7 +698,17 @@ export default function JourneyPublicPage() {
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700">
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} cartoApiKey={cartoApiKey} />
<JourneyLayeredMap
engine="leaflet"
layers={mapLayers}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
checkins={[]}
entries={sidebarMapItems as any}
photos={photoLayer}
height={500}
cartoApiKey={cartoApiKey}
/>
</div>
)}
</div>
@@ -68,6 +68,20 @@ function serveTracks(journeyId: number, tracks: JourneyTrack[] = []) {
return asked;
}
/** Serves the map's layers of a journey, counting the reads. */
function serveLayers(journeyId: number, routes: JourneyTrack[] = []) {
const asked = vi.fn();
server.use(http.get(`/api/journeys/${journeyId}/map-layers`, () => {
asked();
return HttpResponse.json({
places: { status: 'ok', items: [] },
routes: { status: 'ok', items: routes },
stays: { status: 'ok', items: [] },
});
}));
return asked;
}
// ── Harness ──────────────────────────────────────────────────────────────────
type HookState = ReturnType<typeof useJourneyDetail>;
@@ -444,37 +458,40 @@ describe('useJourneyDetail', () => {
expect(scrollTo).toHaveBeenLastCalledWith({ top: 9000, behavior: 'smooth' });
});
// ── Trip GPX tracks (#2194) ────────────────────────────────────────────────
// The switch gates the request, not the drawing: the endpoint ships unthinned
// geometry, megabytes of it for a trip with a season of recorded drives.
// ── Trip GPX tracks (#2194) and the map's layers (JRN-04) ─────────────────
// The GPX tracks come with the plan layer now, read through the linked trips;
// the frozen /tracks answer stays Studio's. The server reads no geometry while
// the journey's switch is off, and the read follows the journey the route names.
it('FE-JRN-DETHOOK-026: a journey with the switch off never asks for the geometry', async () => {
const asked = serveTracks(7, [buildTrack()]);
setup();
await waitFor(() => expect(latest.current).not.toBeNull());
await new Promise(r => setTimeout(r, 20));
expect(asked).not.toHaveBeenCalled();
expect(latest.tracks).toEqual([]);
});
it('FE-JRN-DETHOOK-027: with the switch on the tracks are fetched and handed on', async () => {
// The column is INTEGER, so what arrives over the wire is 1, not true.
it('FE-JRN-DETHOOK-026: the map never asks the frozen tracks endpoint; its routes come with its layers', async () => {
serveJourney(buildDetail({ show_trip_tracks: 1 }));
const asked = serveTracks(7, [buildTrack()]);
const layers = serveLayers(7, [buildTrack({ kind: 'trip_gpx' })]);
setup();
await waitFor(() => expect(latest.tracks).toHaveLength(1));
expect(latest.tracks[0].name).toBe('Ridge walk');
expect(asked).toHaveBeenCalledTimes(1);
await waitFor(() => expect(latest.mapLayers.tracks).toHaveLength(1));
expect(latest.mapLayers.tracks[0].name).toBe('Ridge walk');
expect(layers).toHaveBeenCalledTimes(1);
expect(asked).not.toHaveBeenCalled();
});
it('FE-JRN-DETHOOK-028: the geometry follows the route id, not the journey still on screen', async () => {
// loadJourney leaves the previous journey in place while the next one loads, so an
// unguarded read of the flag would fetch journey B's tracks off journey A's setting.
it('FE-JRN-DETHOOK-027: flipping the track switch reads the layers again', async () => {
serveJourney(buildDetail({ show_trip_tracks: 0 }));
const layers = serveLayers(7);
setup();
await waitFor(() => expect(layers).toHaveBeenCalledTimes(1));
serveJourney(buildDetail({ show_trip_tracks: 1 }));
await act(async () => { await useJourneyStore.getState().loadJourney(7) });
await waitFor(() => expect(layers).toHaveBeenCalledTimes(2));
});
it('FE-JRN-DETHOOK-028: the layers follow the route id, not the journey still on screen', async () => {
// loadJourney leaves the previous journey in place while the next one loads.
routeParams = { id: '8' };
useJourneyStore.setState({ current: buildDetail({ show_trip_tracks: 1 }) });
const asked = serveTracks(8, [buildTrack()]);
const seven = serveLayers(7);
const eight = serveLayers(8);
server.use(http.get('/api/journeys/8', async () => {
await delay(20);
return HttpResponse.json(buildDetail({ id: 8, title: 'Norway 2027' }));
@@ -482,18 +499,18 @@ describe('useJourneyDetail', () => {
setup();
await waitFor(() => expect(latest.current?.id).toBe(8));
expect(asked).not.toHaveBeenCalled();
expect(latest.tracks).toEqual([]);
await waitFor(() => expect(eight).toHaveBeenCalledTimes(1));
expect(seven).not.toHaveBeenCalled();
});
it('FE-JRN-DETHOOK-029: a failed geometry lookup costs the map its lines, not the page', async () => {
it('FE-JRN-DETHOOK-029: a failed layers read costs the map its plan, not the page', async () => {
serveJourney(buildDetail({ show_trip_tracks: 1 }));
server.use(http.get('/api/journeys/7/tracks', () => new HttpResponse(null, { status: 500 })));
server.use(http.get('/api/journeys/7/map-layers', () => new HttpResponse(null, { status: 500 })));
setup();
await waitFor(() => expect(latest.current?.title).toBe('Japan 2026'));
await new Promise(r => setTimeout(r, 20));
expect(latest.tracks).toEqual([]);
await waitFor(() => expect(latest.mapLayers.controls.find(c => c.layer === 'plan')?.state).toBe('unavailable'));
expect(latest.current?.title).toBe('Japan 2026');
expect(latest.mapLayers.tracks).toEqual([]);
expect(addToast).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,7 @@ import { useJourneyRecordingPrep } from '../../components/Journey/useJourneyReco
import { useAddonStore } from '../../store/addonStore'
import { useJourneyDetailDayImport } from './useJourneyDetailDayImport'
import { useJourneyDetailRecordings } from './useJourneyDetailRecordings'
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
import { useProviderPhotoAdds } from './useProviderPhotoAdds'
/** Stable identity for "this journey draws no trip tracks" (#2194). */
@@ -102,36 +103,13 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
// GPX tracks of the trips behind this journey (#1260), drawn only when the
// journey asks for them (#2194). Loaded separately from the journey itself: a
// journey without tracks is the common case, and a failed lookup should cost
// the map its lines, not the whole page.
//
// The switch gates the REQUEST, not just the rendering: the endpoint ships
// full unthinned geometry, which for a trip carrying a season of recorded
// drives is megabytes fetched on every journey open for a map nobody asked
// for. It stays untouched on the server so TREK Studio, which freezes the
// same endpoint's output into a saved book, keeps the set it laid out with.
const [tracks, setTracks] = useState<JourneyTrack[]>(NO_TRACKS)
// Gate on the journey the route actually names: loadJourney leaves the previous
// `current` in place while the next one loads, so reading the flag unguarded
// would fetch the tracks of journey A under the id of journey B — the exact
// megabyte-sized request the switch exists to avoid.
// The GPX tracks of the trips behind this journey (#1260) come with the map's
// plan layer now (JRN-04), and only while the journey asks for them (#2194):
// the server reads no geometry while the switch is off. Gate on the journey
// the route actually names: loadJourney leaves the previous `current` in
// place while the next one loads.
const journeyLoaded = current?.id === Number(id)
const showTripTracks = journeyLoaded && !!current?.show_trip_tracks
useEffect(() => {
if (!id || !showTripTracks) {
// A stable empty array: a fresh literal here would be a new reference on
// every toggle and re-render the map for nothing.
setTracks(NO_TRACKS)
return
}
let cancelled = false
journeyApi.listTracks(Number(id))
.then(res => { if (!cancelled) setTracks(res.tracks ?? NO_TRACKS) })
.catch(() => { if (!cancelled) setTracks(NO_TRACKS) })
return () => { cancelled = true }
}, [id, showTripTracks])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
@@ -554,10 +532,36 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
canEdit: canEditEntries,
dawarichStatus: dawarich.status,
sidebarMapItems,
tripTracks: tracks,
tripTracks: NO_TRACKS,
liveTracks: dawarichTrail.tracks,
})
// The map's layers (JRN-04): the plan of the linked trips, what was recorded,
// the entries with the stays of their day reports, the photos. Read again
// when an entry comes, goes or changes the place it came from, and when a
// trip is linked or the track switch flips.
const entryIdsOnMap = useMemo(() => new Set(sidebarMapItems.map(m => m.id)), [sidebarMapItems])
const entryById = useMemo(() => new Map((current?.entries ?? []).map(e => [e.id, e])), [current?.entries])
const dateOfEntry = useCallback((entryId: number) => entryById.get(entryId)?.entry_date ?? null, [entryById])
const entryTitleOf = useCallback((entryId: number) => {
const entry = entryById.get(entryId)
return entry ? entry.title || entry.location_name || null : null
}, [entryById])
const mapRefreshKey = useMemo(() => [
(current?.entries ?? []).filter(e => e.type !== 'skeleton').map(e => `${e.id}:${e.source_place_id ?? ''}`).join(','),
(current?.trips ?? []).map(trip => trip.trip_id).join(','),
showTripTracks ? 'tracks' : '',
].join('|'), [current?.entries, current?.trips, showTripTracks])
const mapLayers = useJourneyMapLayers({
source: journeyLoaded && current ? { journeyId: current.id } : null,
refreshKey: mapRefreshKey,
entryIdsOnMap,
photoCount: mapPhotos.length,
recordedTracks: mapTracks,
colorOfDay,
dateOfEntry,
})
// The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list.
const afterDayTakeOver = useCallback(() => {
if (current) void loadJourney(current.id)
@@ -628,7 +632,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail, mapLayers, entryTitleOf,
recordings, canTakeOverRecording, recordingNeedsConnection, dayImport, dayImportDates, recordingPrep,
mapPhotos, openMapPhotos,
feedEdge, scrollFeedTo,
@@ -7,6 +7,7 @@ import { useIsMobile } from '../../hooks/useIsMobile'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
/**
* Public-journey (read-only share) data hook — owns the token fetch, the
@@ -116,6 +117,27 @@ export function useJourneyPublic() {
[gallery],
)
// The shared map's layers (JRN-04): the server cuts them to what the link
// shares, so the switches only ever hide. Read only while the map is shared.
const entryIdsOnMap = useMemo(() => new Set(sidebarMapItems.map(i => i.id)), [sidebarMapItems])
const entryById = useMemo(() => new Map(((data?.entries ?? []) as PublicEntry[]).map(e => [e.id, e])), [data?.entries])
const dateOfEntry = useCallback((entryId: number) => entryById.get(entryId)?.entry_date ?? null, [entryById])
const entryTitleOf = useCallback((entryId: number) => {
const entry = entryById.get(entryId)
return entry ? entry.title || entry.location_name || null : null
}, [entryById])
const colorOfDay = useCallback((date: string) => {
const dayIdx = sortedDates.indexOf(date)
return dayIdx === -1 ? null : DAY_COLORS[dayIdx % DAY_COLORS.length]
}, [sortedDates])
const mapLayers = useJourneyMapLayers({
source: token && perms.share_map ? { token } : null,
entryIdsOnMap,
photoCount: mapPhotos.length,
colorOfDay,
dateOfEntry,
})
// A journey shared while the trip is still running reads like a blog, so the owner
// can publish it newest-first (#1614). The reader may flip it either way; only the
// display order changes, never the numbering.
@@ -146,6 +168,7 @@ export function useJourneyPublic() {
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
mapLayers, entryTitleOf,
newestFirst: effectiveNewestFirst, setNewestFirst,
desktopTwoColumn,
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { journeyMapApi } from '../api/journeyMap';
import { journeyMapRepo } from './journeyMapRepo';
import { OnlineOnlyError } from './onlineOnly';
// FE-REPO-JOURNEY-MAP-001 to FE-REPO-JOURNEY-MAP-003
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
const OK = { status: 'ok' as const, items: [] };
const WITHHELD = { status: 'withheld' as const };
beforeEach(() => {
network.offline = false;
vi.restoreAllMocks();
});
describe('journeyMapRepo', () => {
it('FE-REPO-JOURNEY-MAP-001: asks nothing offline', async () => {
network.offline = true;
const layers = vi.spyOn(journeyMapApi, 'layers');
await expect(journeyMapRepo.layers(7)).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(journeyMapRepo.publicLayers('tok')).rejects.toBeInstanceOf(OnlineOnlyError);
expect(layers).not.toHaveBeenCalled();
});
it('FE-REPO-JOURNEY-MAP-002: answers what the contract describes, for the journey and for a link', async () => {
vi.spyOn(journeyMapApi, 'layers').mockResolvedValue({ places: OK, routes: { status: 'unavailable' }, stays: OK });
vi.spyOn(journeyMapApi, 'publicLayers').mockResolvedValue({
places: WITHHELD,
routes: WITHHELD,
stays: OK,
recordings: WITHHELD,
});
expect((await journeyMapRepo.layers(7)).routes).toEqual({ status: 'unavailable' });
expect((await journeyMapRepo.publicLayers('tok')).recordings).toEqual(WITHHELD);
});
it('FE-REPO-JOURNEY-MAP-003: refuses an answer the contract does not describe', async () => {
vi.spyOn(journeyMapApi, 'layers').mockResolvedValue({ places: { status: 'partial' } } as never);
await expect(journeyMapRepo.layers(7)).rejects.toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
import {
journeyMapLayersResponseSchema,
journeyPublicMapLayersResponseSchema,
type JourneyMapLayersResponse,
type JourneyPublicMapLayersResponse,
} from '@trek/shared';
import { journeyMapApi } from '../api/journeyMap';
import { requireOnline } from './onlineOnly';
/**
* JRN-04, online only like the journey itself. Answers are parsed against the
* shared contract, so a server that answered something else fails here and
* the map says the layer could not be loaded instead of drawing nonsense.
*/
export const journeyMapRepo = {
async layers(journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> {
requireOnline();
return journeyMapLayersResponseSchema.parse(await journeyMapApi.layers(journeyId, signal));
},
async publicLayers(token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> {
requireOnline();
return journeyPublicMapLayersResponseSchema.parse(await journeyMapApi.publicLayers(token, signal));
},
};
@@ -1,4 +1,4 @@
// FE-MOB-JSET-001 to FE-MOB-JSET-036
// FE-MOB-JSET-001 to FE-MOB-JSET-037
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
import MJourneySettingsSheet from '../../../../src/mobile/screens/journey/MJourneySettingsSheet';
@@ -480,4 +480,22 @@ describe('MJourneySettingsSheet', () => {
await screen.findByRole('switch', { name: /GPX/i });
expect(screen.queryByRole('switch', { name: 'Prepare recorded days automatically' })).not.toBeInTheDocument();
});
it('FE-MOB-JSET-037: the trip plan and the recorded routes switch under a shared map and wait for it otherwise (JRN-04)', async () => {
vi.mocked(journeyApi.getShareLink).mockResolvedValue({
link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true },
});
const create = vi.spyOn(journeyApi, 'createShareLink').mockResolvedValue({ token: 'tok-9' });
setup();
const plan = await screen.findByRole('switch', { name: 'Trip plan' });
expect(plan).toHaveAttribute('aria-checked', 'false');
fireEvent.click(plan);
await waitFor(() => expect(create).toHaveBeenCalledWith(12, expect.objectContaining({ share_plan: true })));
expect(screen.getByRole('switch', { name: 'Trip plan' })).toHaveAttribute('aria-checked', 'true');
fireEvent.click(screen.getByRole('switch', { name: 'Map' }));
await waitFor(() => expect(screen.getByRole('switch', { name: 'Recorded routes' })).toBeDisabled());
expect(screen.getAllByText('Share the map to switch this on')).toHaveLength(2);
});
});
@@ -21,7 +21,7 @@ export interface JourneyEntryStayWrite {
}
/** A located stay of a journey, as its map draws it (JRN-04). */
export interface JourneyEntryStayMapRow {
interface JourneyEntryStayMapRow {
id: number;
entry_id: number;
name: string;
@@ -28,7 +28,7 @@ export interface JourneyShareTokenRow {
}
/** Every flag of a link, as stored (0/1). */
export interface JourneyShareFlags {
interface JourneyShareFlags {
share_timeline: number;
share_gallery: number;
share_map: number;
@@ -49,7 +49,7 @@ export interface AssignmentTimeRow {
type SyncPlacesKyselyDB = Pick<DB, 'places' | 'day_assignments' | 'days'>;
/** One day a linked trip's plan puts a located place on (JRN-04), {@link JourneyTripsRepository.listMapPlacesForJourney}. */
export interface JourneyMapPlaceRow {
interface JourneyMapPlaceRow {
place_id: number;
trip_id: number;
trip_title: string;
@@ -63,7 +63,7 @@ export interface JourneyMapPlaceRow {
}
/** A place of a linked trip that carries a route (JRN-04), {@link JourneyTripsRepository.listRouteSourcesForJourney}. */
export interface JourneyRouteSourceRow {
interface JourneyRouteSourceRow {
place_id: number;
trip_id: number;
name: string;
+36
View File
@@ -932,5 +932,41 @@ const journey: TranslationStrings = {
'الرحلات التي انتهت قبل أكثر من {count} يومًا لا تُجهَّز في الخلفية. افتح أحد أيامها لجلبه يدويًا.',
'journey.prep.alreadyRunning': 'هناك عملية جلب جارية بالفعل في الخلفية. ستظهر محطات التوقف بمجرد انتهائها.',
'journey.dayImport.running': 'هذا اليوم لم ينتهِ بعد. تظهر محطات التوقف المسجلة لاحقًا بمجرد أن تصل إلى Dawarich.',
'journey.map.layers': 'طبقات الخريطة',
'journey.map.layer.plan': 'الخطة',
'journey.map.layer.recorded': 'المسجَّل',
'journey.map.layer.entries': 'التدوينات',
'journey.map.layer.photos': 'الصور',
'journey.map.layer.loading': 'جارٍ التحميل',
'journey.map.layer.planEmpty': 'لا تحتوي الرحلات المرتبطة على أماكن في أيامها',
'journey.map.layer.planUnavailable': 'تعذّر تحميل الخطة، ويبقى باقي الخريطة ظاهرًا',
'journey.map.layer.recordedEmpty': 'لا يوجد مسار مسجَّل بعد',
'journey.map.layer.recordedUnavailable': 'تعذّر تحميل الأيام المسجَّلة',
'journey.map.layer.entriesEmpty': 'لا تملك أي تدوينة مكانًا بعد',
'journey.map.layer.entriesUnavailable': 'تعذّر تحميل محطات تقارير الأيام، وتبقى التدوينات ظاهرة',
'journey.map.layer.photosEmpty': 'لا توجد صورة بموقع بعد',
'journey.map.legend': 'مفتاح الخريطة',
'journey.map.legend.planned': 'مكان مخطَّط',
'journey.map.legend.entry': 'تدوينة في اليوميات',
'journey.map.legend.plannedWritten': 'مخطَّط ومكتوب عنه',
'journey.map.legend.stay': 'محطة في تقرير يومي',
'journey.map.legend.several': 'عدة عناصر في نقطة واحدة',
'journey.map.legend.lines': 'المسارات',
'journey.map.legend.plannedRoute': 'مسار مخطَّط (GPX لرحلة)',
'journey.map.legend.recordedRoute': 'مسار مسجَّل',
'journey.map.legend.planIsNoVisit': 'المكان المخطَّط ليس دليلًا على الزيارة. وحدها التدوينات والصور والتسجيلات تُظهر أين كنت.',
'journey.map.pick.openEntry': 'فتح التدوينة',
'journey.map.pick.planned': 'مخطَّط',
'journey.map.pick.notWritten': 'لم يُكتب عنه شيء بعد.',
'journey.map.pick.stay': 'محطة',
'journey.map.pick.partOf': 'جزء من «{title}»',
'journey.map.pick.here': 'في هذه النقطة',
'journey.map.pick.back': 'العودة إلى القائمة',
'journey.map.pick.timeRange': 'من {from} إلى {to}',
'journey.share.plan': 'خطة الرحلة',
'journey.share.planHint': 'الأماكن المخطَّطة ومسارات GPX للرحلات المرتبطة على الخريطة',
'journey.share.recordings': 'المسارات المسجَّلة',
'journey.share.recordingsHint': 'الأيام المسجَّلة التي لها تدوينة منشورة. تُظهر أين كنت، بما في ذلك مكان إقامتك.',
'journey.share.needsMap': 'شارك الخريطة لتفعيل هذا',
};
export default journey;
+36
View File
@@ -933,5 +933,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Arxa planda artıq bir alma prosesi gedir. Dayanacaqlar o bitən kimi görünəcək.',
'journey.dayImport.running':
'Bu gün hələ davam edir. Sonra qeydə alınan dayanacaqlar Dawarich-də olan kimi görünəcək.',
'journey.map.layers': 'Xəritə qatları',
'journey.map.layer.plan': 'Plan', // same-as-en
'journey.map.layer.recorded': 'Qeydə alınan',
'journey.map.layer.entries': 'Qeydlər',
'journey.map.layer.photos': 'Fotolar',
'journey.map.layer.loading': 'yüklənir',
'journey.map.layer.planEmpty': 'bağlı səyahətlərin günlərində yer yoxdur',
'journey.map.layer.planUnavailable': 'plan yüklənmədi, xəritənin qalan hissəsi görünür',
'journey.map.layer.recordedEmpty': 'hələ qeydə alınmış marşrut yoxdur',
'journey.map.layer.recordedUnavailable': 'qeydə alınmış günlər yüklənmədi',
'journey.map.layer.entriesEmpty': 'hələ heç bir qeydin yeri yoxdur',
'journey.map.layer.entriesUnavailable': 'gün hesabatlarının dayanacaqları yüklənmədi, qeydlər görünür',
'journey.map.layer.photosEmpty': 'hələ məkanı olan foto yoxdur',
'journey.map.legend': 'Xəritə izahı',
'journey.map.legend.planned': 'Planlaşdırılmış yer',
'journey.map.legend.entry': 'Gündəlik qeydi',
'journey.map.legend.plannedWritten': 'Planlaşdırılıb və yazılıb',
'journey.map.legend.stay': 'Gün hesabatının dayanacağı',
'journey.map.legend.several': 'Bir nöqtədə bir neçəsi',
'journey.map.legend.lines': 'Marşrutlar',
'journey.map.legend.plannedRoute': 'Planlaşdırılmış marşrut (səyahətin GPX-i)',
'journey.map.legend.recordedRoute': 'Qeydə alınmış marşrut',
'journey.map.legend.planIsNoVisit': 'Planlaşdırılmış yer səfərin sübutu deyil. Harada olduğunu yalnız qeydlər, fotolar və yazılar göstərir.',
'journey.map.pick.openEntry': 'Qeydi aç',
'journey.map.pick.planned': 'Planlaşdırılıb',
'journey.map.pick.notWritten': 'Bu barədə hələ heç nə yazılmayıb.',
'journey.map.pick.stay': 'Dayanacaq',
'journey.map.pick.partOf': '“{title}” qeydinin hissəsi',
'journey.map.pick.here': 'Bu nöqtədə',
'journey.map.pick.back': 'Siyahıya qayıt',
'journey.map.pick.timeRange': '{from} ilə {to} arası',
'journey.share.plan': 'Səyahət planı',
'journey.share.planHint': 'Bağlı səyahətlərin planlaşdırılmış yerləri və GPX marşrutları xəritədə',
'journey.share.recordings': 'Qeydə alınmış marşrutlar',
'journey.share.recordingsHint': 'Dərc olunmuş qeydi olan qeydə alınmış günlər. Harada olduğunu, qaldığın yeri də göstərir.',
'journey.share.needsMap': 'Bunu açmaq üçün xəritəni paylaş',
};
export default journey;
+36
View File
@@ -862,5 +862,41 @@ const journey: TranslationStrings = {
'Já há uma busca em andamento em segundo plano. As paradas aparecem assim que ela terminar.',
'journey.dayImport.running':
'Este dia ainda está em andamento. Paradas gravadas depois aparecem assim que o Dawarich as tiver.',
'journey.map.layers': 'Camadas do mapa',
'journey.map.layer.plan': 'Planejado',
'journey.map.layer.recorded': 'Gravado',
'journey.map.layer.entries': 'Registros',
'journey.map.layer.photos': 'Fotos',
'journey.map.layer.loading': 'carregando',
'journey.map.layer.planEmpty': 'as viagens vinculadas não têm lugares em seus dias',
'journey.map.layer.planUnavailable': 'não foi possível carregar o plano, o restante do mapa continua visível',
'journey.map.layer.recordedEmpty': 'ainda não há rota gravada',
'journey.map.layer.recordedUnavailable': 'não foi possível carregar os dias gravados',
'journey.map.layer.entriesEmpty': 'nenhum registro tem lugar ainda',
'journey.map.layer.entriesUnavailable': 'não foi possível carregar as paradas dos relatos do dia, os registros continuam visíveis',
'journey.map.layer.photosEmpty': 'ainda não há foto com localização',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Lugar planejado',
'journey.map.legend.entry': 'Registro do diário',
'journey.map.legend.plannedWritten': 'Planejado e descrito',
'journey.map.legend.stay': 'Parada de um relato do dia',
'journey.map.legend.several': 'Vários no mesmo ponto',
'journey.map.legend.lines': 'Rotas',
'journey.map.legend.plannedRoute': 'Rota planejada (GPX de uma viagem)',
'journey.map.legend.recordedRoute': 'Rota gravada',
'journey.map.legend.planIsNoVisit': 'Um lugar planejado não comprova uma visita. Só registros, fotos e gravações mostram onde você esteve.',
'journey.map.pick.openEntry': 'Abrir registro',
'journey.map.pick.planned': 'Planejado',
'journey.map.pick.notWritten': 'Nada escrito sobre ele ainda.',
'journey.map.pick.stay': 'Parada',
'journey.map.pick.partOf': 'Parte de “{title}”',
'journey.map.pick.here': 'Neste ponto',
'journey.map.pick.back': 'Voltar à lista',
'journey.map.pick.timeRange': 'das {from} às {to}',
'journey.share.plan': 'Plano da viagem',
'journey.share.planHint': 'Lugares planejados e rotas GPX das viagens vinculadas no mapa',
'journey.share.recordings': 'Rotas gravadas',
'journey.share.recordingsHint': 'Dias gravados que têm um registro publicado. Mostram onde você esteve, inclusive sua hospedagem.',
'journey.share.needsMap': 'Compartilhe o mapa para ativar isto',
};
export default journey;
+36
View File
@@ -865,5 +865,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Ja hi ha una obtenció en curs en segon pla. Les estades apareixeran quan acabi.',
'journey.dayImport.running':
'Aquest dia encara no ha acabat. Les estades enregistrades més tard apareixen tan bon punt Dawarich les tingui.',
'journey.map.layers': 'Capes del mapa',
'journey.map.layer.plan': 'Planificat',
'journey.map.layer.recorded': 'Enregistrat',
'journey.map.layer.entries': 'Entrades',
'journey.map.layer.photos': 'Fotos',
'journey.map.layer.loading': 'carregant',
'journey.map.layer.planEmpty': 'els viatges vinculats no tenen llocs als seus dies',
'journey.map.layer.planUnavailable': 'no s’ha pogut carregar el pla, la resta del mapa continua visible',
'journey.map.layer.recordedEmpty': 'encara no hi ha cap ruta enregistrada',
'journey.map.layer.recordedUnavailable': 'no s’han pogut carregar els dies enregistrats',
'journey.map.layer.entriesEmpty': 'cap entrada no té lloc encara',
'journey.map.layer.entriesUnavailable': 'no s’han pogut carregar les parades dels resums del dia, les entrades continuen visibles',
'journey.map.layer.photosEmpty': 'encara no hi ha cap foto amb ubicació',
'journey.map.legend': 'Llegenda',
'journey.map.legend.planned': 'Lloc planificat',
'journey.map.legend.entry': 'Entrada del diari',
'journey.map.legend.plannedWritten': 'Planificat i descrit',
'journey.map.legend.stay': 'Parada d’un resum del dia',
'journey.map.legend.several': 'Diversos en un mateix punt',
'journey.map.legend.lines': 'Rutes',
'journey.map.legend.plannedRoute': 'Ruta planificada (GPX d’un viatge)',
'journey.map.legend.recordedRoute': 'Ruta enregistrada',
'journey.map.legend.planIsNoVisit': 'Un lloc planificat no demostra una visita. Només les entrades, les fotos i els enregistraments mostren on eres.',
'journey.map.pick.openEntry': 'Obre l’entrada',
'journey.map.pick.planned': 'Planificat',
'journey.map.pick.notWritten': 'Encara no hi ha res escrit sobre aquest lloc.',
'journey.map.pick.stay': 'Parada',
'journey.map.pick.partOf': 'Part de «{title}»',
'journey.map.pick.here': 'En aquest punt',
'journey.map.pick.back': 'Torna a la llista',
'journey.map.pick.timeRange': 'de {from} a {to}',
'journey.share.plan': 'Pla del viatge',
'journey.share.planHint': 'Llocs planificats i rutes GPX dels viatges vinculats al mapa',
'journey.share.recordings': 'Rutes enregistrades',
'journey.share.recordingsHint': 'Dies enregistrats amb una entrada publicada. Mostren on eres, també el teu allotjament.',
'journey.share.needsMap': 'Comparteix el mapa per activar-ho',
};
export default journey;
+36
View File
@@ -876,5 +876,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Na pozadí už jedno načítání běží. Jeho zastávky se ukážou, jakmile doběhne.',
'journey.dayImport.running':
'Tento den ještě probíhá. Později zaznamenané pobyty se objeví, jakmile je Dawarich bude mít.',
'journey.map.layers': 'Vrstvy mapy',
'journey.map.layer.plan': 'Plán',
'journey.map.layer.recorded': 'Zaznamenáno',
'journey.map.layer.entries': 'Záznamy',
'journey.map.layer.photos': 'Fotky',
'journey.map.layer.loading': 'načítání',
'journey.map.layer.planEmpty': 'propojené cesty nemají ve svých dnech žádná místa',
'journey.map.layer.planUnavailable': 'plán se nepodařilo načíst, zbytek mapy zůstává viditelný',
'journey.map.layer.recordedEmpty': 'zatím žádná zaznamenaná trasa',
'journey.map.layer.recordedUnavailable': 'zaznamenané dny se nepodařilo načíst',
'journey.map.layer.entriesEmpty': 'zatím žádný záznam nemá místo',
'journey.map.layer.entriesUnavailable': 'zastávky denních zpráv se nepodařilo načíst, záznamy zůstávají viditelné',
'journey.map.layer.photosEmpty': 'zatím žádná fotka s polohou',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Plánované místo',
'journey.map.legend.entry': 'Záznam v deníku',
'journey.map.legend.plannedWritten': 'Plánováno a popsáno',
'journey.map.legend.stay': 'Zastávka denní zprávy',
'journey.map.legend.several': 'Více na jednom místě',
'journey.map.legend.lines': 'Trasy',
'journey.map.legend.plannedRoute': 'Plánovaná trasa (GPX cesty)',
'journey.map.legend.recordedRoute': 'Zaznamenaná trasa',
'journey.map.legend.planIsNoVisit': 'Plánované místo není důkazem návštěvy. Kde jste byli, ukazují jen záznamy, fotky a nahrávky.',
'journey.map.pick.openEntry': 'Otevřít záznam',
'journey.map.pick.planned': 'Plánováno',
'journey.map.pick.notWritten': 'Zatím o něm nic nenapsáno.',
'journey.map.pick.stay': 'Zastávka',
'journey.map.pick.partOf': 'Součást „{title}“',
'journey.map.pick.here': 'Na tomto místě',
'journey.map.pick.back': 'Zpět na seznam',
'journey.map.pick.timeRange': 'od {from} do {to}',
'journey.share.plan': 'Plán cesty',
'journey.share.planHint': 'Plánovaná místa a trasy GPX propojených cest na mapě',
'journey.share.recordings': 'Zaznamenané trasy',
'journey.share.recordingsHint': 'Zaznamenané dny se zveřejněným záznamem. Ukazují, kde jste byli, i vaše ubytování.',
'journey.share.needsMap': 'Pro zapnutí sdílejte mapu',
};
export default journey;
+36
View File
@@ -870,5 +870,41 @@ const journey: TranslationStrings = {
'Im Hintergrund läuft schon ein Abruf. Seine Aufenthalte erscheinen, sobald er durch ist.',
'journey.dayImport.running':
'Dieser Tag läuft noch. Später aufgezeichnete Aufenthalte erscheinen, sobald Dawarich sie hat.',
'journey.map.layers': 'Kartenebenen',
'journey.map.layer.plan': 'Planung',
'journey.map.layer.recorded': 'Aufgezeichnet',
'journey.map.layer.entries': 'Einträge',
'journey.map.layer.photos': 'Fotos',
'journey.map.layer.loading': 'wird geladen',
'journey.map.layer.planEmpty': 'die verknüpften Reisen haben keine Orte an ihren Tagen',
'journey.map.layer.planUnavailable': 'die Planung konnte nicht geladen werden, der Rest der Karte bleibt sichtbar',
'journey.map.layer.recordedEmpty': 'noch keine aufgezeichnete Strecke',
'journey.map.layer.recordedUnavailable': 'die aufgezeichneten Tage konnten nicht geladen werden',
'journey.map.layer.entriesEmpty': 'noch kein Eintrag hat einen Ort',
'journey.map.layer.entriesUnavailable': 'die Aufenthalte der Tagesberichte konnten nicht geladen werden, die Einträge bleiben sichtbar',
'journey.map.layer.photosEmpty': 'noch kein Foto mit Standort',
'journey.map.legend': 'Legende',
'journey.map.legend.planned': 'Geplanter Ort',
'journey.map.legend.entry': 'Journal-Eintrag',
'journey.map.legend.plannedWritten': 'Geplant und darüber geschrieben',
'journey.map.legend.stay': 'Aufenthalt eines Tagesberichts',
'journey.map.legend.several': 'Mehrere an einer Stelle',
'journey.map.legend.lines': 'Strecken',
'journey.map.legend.plannedRoute': 'Geplante Strecke (GPX einer Reise)',
'journey.map.legend.recordedRoute': 'Aufgezeichnete Strecke',
'journey.map.legend.planIsNoVisit': 'Ein geplanter Ort ist kein Besuchsnachweis. Nur Einträge, Fotos und Aufzeichnungen zeigen, wo du warst.',
'journey.map.pick.openEntry': 'Eintrag öffnen',
'journey.map.pick.planned': 'Geplant',
'journey.map.pick.notWritten': 'Noch nichts darüber geschrieben.',
'journey.map.pick.stay': 'Aufenthalt',
'journey.map.pick.partOf': 'Teil von „{title}“',
'journey.map.pick.here': 'An dieser Stelle',
'journey.map.pick.back': 'Zurück zur Liste',
'journey.map.pick.timeRange': '{from} bis {to}',
'journey.share.plan': 'Reiseplanung',
'journey.share.planHint': 'Geplante Orte und GPX-Strecken der verknüpften Reisen auf der Karte',
'journey.share.recordings': 'Aufgezeichnete Strecken',
'journey.share.recordingsHint': 'Aufgezeichnete Tage mit veröffentlichtem Eintrag. Sie zeigen, wo du warst, auch deine Unterkunft.',
'journey.share.needsMap': 'Teile die Karte, um das einzuschalten',
};
export default journey;
+36
View File
@@ -852,5 +852,41 @@ const journey: TranslationStrings = {
'Trips that ended more than {count} day ago are not prepared in the background. Open one of their days to fetch it by hand.',
'journey.prep.alreadyRunning': 'A fetch is already running in the background. Its stays show up once it is through.',
'journey.dayImport.running': 'This day is still running. Stays recorded later show up once Dawarich has them.',
'journey.map.layers': 'Map layers',
'journey.map.layer.plan': 'Plan',
'journey.map.layer.recorded': 'Recorded',
'journey.map.layer.entries': 'Entries',
'journey.map.layer.photos': 'Photos',
'journey.map.layer.loading': 'loading',
'journey.map.layer.planEmpty': 'the linked trips have no places on their days',
'journey.map.layer.planUnavailable': 'the plan could not be loaded, the rest of the map still shows',
'journey.map.layer.recordedEmpty': 'no recorded route yet',
'journey.map.layer.recordedUnavailable': 'the recorded days could not be loaded',
'journey.map.layer.entriesEmpty': 'no entry has a place yet',
'journey.map.layer.entriesUnavailable': 'the stays of day reports could not be loaded, the entries still show',
'journey.map.layer.photosEmpty': 'no photo with a location yet',
'journey.map.legend': 'Map key',
'journey.map.legend.planned': 'Planned place',
'journey.map.legend.entry': 'Journal entry',
'journey.map.legend.plannedWritten': 'Planned and written about',
'journey.map.legend.stay': 'Stay of a day report',
'journey.map.legend.several': 'Several at one spot',
'journey.map.legend.lines': 'Routes',
'journey.map.legend.plannedRoute': 'Planned route (GPX of a trip)',
'journey.map.legend.recordedRoute': 'Recorded route',
'journey.map.legend.planIsNoVisit': 'A planned place is no proof of a visit. Only entries, photos and recordings show where you were.',
'journey.map.pick.openEntry': 'Open entry',
'journey.map.pick.planned': 'Planned',
'journey.map.pick.notWritten': 'Nothing written about it yet.',
'journey.map.pick.stay': 'Stay',
'journey.map.pick.partOf': 'Part of “{title}”',
'journey.map.pick.here': 'At this spot',
'journey.map.pick.back': 'Back to the list',
'journey.map.pick.timeRange': '{from} to {to}',
'journey.share.plan': 'Trip plan',
'journey.share.planHint': 'Planned places and GPX routes of the linked trips on the map',
'journey.share.recordings': 'Recorded routes',
'journey.share.recordingsHint': 'Recorded days that have a published entry. They show where you were, your accommodation too.',
'journey.share.needsMap': 'Share the map to switch this on',
};
export default journey;
+36
View File
@@ -864,5 +864,41 @@ const journey: TranslationStrings = {
'Ya hay una obtención en curso en segundo plano. Las estancias aparecerán en cuanto termine.',
'journey.dayImport.running':
'Este día aún no ha terminado. Las estancias grabadas más tarde aparecen en cuanto Dawarich las tenga.',
'journey.map.layers': 'Capas del mapa',
'journey.map.layer.plan': 'Planificado',
'journey.map.layer.recorded': 'Grabado',
'journey.map.layer.entries': 'Entradas',
'journey.map.layer.photos': 'Fotos',
'journey.map.layer.loading': 'cargando',
'journey.map.layer.planEmpty': 'los viajes vinculados no tienen lugares en sus días',
'journey.map.layer.planUnavailable': 'no se pudo cargar el plan, el resto del mapa sigue visible',
'journey.map.layer.recordedEmpty': 'aún no hay ninguna ruta grabada',
'journey.map.layer.recordedUnavailable': 'no se pudieron cargar los días grabados',
'journey.map.layer.entriesEmpty': 'ninguna entrada tiene lugar todavía',
'journey.map.layer.entriesUnavailable': 'no se pudieron cargar las paradas de los resúmenes del día, las entradas siguen visibles',
'journey.map.layer.photosEmpty': 'aún no hay fotos con ubicación',
'journey.map.legend': 'Leyenda',
'journey.map.legend.planned': 'Lugar planificado',
'journey.map.legend.entry': 'Entrada del diario',
'journey.map.legend.plannedWritten': 'Planificado y descrito',
'journey.map.legend.stay': 'Parada de un resumen del día',
'journey.map.legend.several': 'Varios en un mismo punto',
'journey.map.legend.lines': 'Rutas',
'journey.map.legend.plannedRoute': 'Ruta planificada (GPX de un viaje)',
'journey.map.legend.recordedRoute': 'Ruta grabada',
'journey.map.legend.planIsNoVisit': 'Un lugar planificado no demuestra una visita. Solo las entradas, las fotos y las grabaciones muestran dónde estuviste.',
'journey.map.pick.openEntry': 'Abrir entrada',
'journey.map.pick.planned': 'Planificado',
'journey.map.pick.notWritten': 'Todavía no hay nada escrito sobre él.',
'journey.map.pick.stay': 'Parada',
'journey.map.pick.partOf': 'Parte de «{title}»',
'journey.map.pick.here': 'En este punto',
'journey.map.pick.back': 'Volver a la lista',
'journey.map.pick.timeRange': 'de {from} a {to}',
'journey.share.plan': 'Plan del viaje',
'journey.share.planHint': 'Lugares planificados y rutas GPX de los viajes vinculados en el mapa',
'journey.share.recordings': 'Rutas grabadas',
'journey.share.recordingsHint': 'Días grabados con una entrada publicada. Muestran dónde estuviste, también tu alojamiento.',
'journey.share.needsMap': 'Comparte el mapa para activarlo',
};
export default journey;
+36
View File
@@ -856,5 +856,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Taustal juba käib üks toomine. Selle peatused ilmuvad, kui see on lõpetanud.',
'journey.dayImport.running':
'See päev alles kestab. Hiljem salvestatud peatused ilmuvad kohe, kui Dawarichil need on.',
'journey.map.layers': 'Kaardikihid',
'journey.map.layer.plan': 'Plaan',
'journey.map.layer.recorded': 'Salvestatud',
'journey.map.layer.entries': 'Sissekanded',
'journey.map.layer.photos': 'Fotod',
'journey.map.layer.loading': 'laadimine',
'journey.map.layer.planEmpty': 'seotud reisidel pole nende päevadel kohti',
'journey.map.layer.planUnavailable': 'plaani ei õnnestunud laadida, ülejäänud kaart jääb nähtavaks',
'journey.map.layer.recordedEmpty': 'salvestatud marsruuti veel pole',
'journey.map.layer.recordedUnavailable': 'salvestatud päevi ei õnnestunud laadida',
'journey.map.layer.entriesEmpty': 'ühelgi sissekandel pole veel kohta',
'journey.map.layer.entriesUnavailable': 'päevaaruannete peatusi ei õnnestunud laadida, sissekanded jäävad nähtavaks',
'journey.map.layer.photosEmpty': 'asukohaga fotot veel pole',
'journey.map.legend': 'Legend',
'journey.map.legend.planned': 'Planeeritud koht',
'journey.map.legend.entry': 'Päeviku sissekanne',
'journey.map.legend.plannedWritten': 'Planeeritud ja kirja pandud',
'journey.map.legend.stay': 'Päevaaruande peatus',
'journey.map.legend.several': 'Mitu ühes kohas',
'journey.map.legend.lines': 'Marsruudid',
'journey.map.legend.plannedRoute': 'Planeeritud marsruut (reisi GPX)',
'journey.map.legend.recordedRoute': 'Salvestatud marsruut',
'journey.map.legend.planIsNoVisit': 'Planeeritud koht ei tõenda külastust. Kus sa olid, näitavad ainult sissekanded, fotod ja salvestused.',
'journey.map.pick.openEntry': 'Ava sissekanne',
'journey.map.pick.planned': 'Planeeritud',
'journey.map.pick.notWritten': 'Selle kohta pole veel midagi kirjutatud.',
'journey.map.pick.stay': 'Peatus',
'journey.map.pick.partOf': 'Osa sissekandest „{title}”',
'journey.map.pick.here': 'Selles kohas',
'journey.map.pick.back': 'Tagasi loendisse',
'journey.map.pick.timeRange': '{from} kuni {to}',
'journey.share.plan': 'Reisiplaan',
'journey.share.planHint': 'Seotud reiside planeeritud kohad ja GPX-marsruudid kaardil',
'journey.share.recordings': 'Salvestatud marsruudid',
'journey.share.recordingsHint': 'Salvestatud päevad, millel on avaldatud sissekanne. Need näitavad, kus sa olid, ka su majutust.',
'journey.share.needsMap': 'Selle sisselülitamiseks jaga kaarti',
};
export default journey;
+36
View File
@@ -863,5 +863,41 @@ const journey: TranslationStrings = {
"Une récupération est déjà en cours en arrière-plan. Ses étapes apparaîtront dès qu'elle sera terminée.",
'journey.dayImport.running':
"Cette journée n'est pas terminée. Les séjours enregistrés plus tard apparaissent dès que Dawarich les a.",
'journey.map.layers': 'Calques de la carte',
'journey.map.layer.plan': 'Prévu',
'journey.map.layer.recorded': 'Enregistré',
'journey.map.layer.entries': 'Récits',
'journey.map.layer.photos': 'Clichés',
'journey.map.layer.loading': 'chargement',
'journey.map.layer.planEmpty': 'les voyages liés n’ont aucun lieu sur leurs jours',
'journey.map.layer.planUnavailable': 'le programme n’a pas pu être chargé, le reste de la carte reste visible',
'journey.map.layer.recordedEmpty': 'aucun trajet enregistré pour l’instant',
'journey.map.layer.recordedUnavailable': 'les jours enregistrés n’ont pas pu être chargés',
'journey.map.layer.entriesEmpty': 'aucun récit n’a encore de lieu',
'journey.map.layer.entriesUnavailable': 'les étapes des comptes rendus n’ont pas pu être chargées, les récits restent visibles',
'journey.map.layer.photosEmpty': 'aucune photo localisée pour l’instant',
'journey.map.legend': 'Légende',
'journey.map.legend.planned': 'Lieu prévu',
'journey.map.legend.entry': 'Récit du journal',
'journey.map.legend.plannedWritten': 'Prévu et raconté',
'journey.map.legend.stay': 'Étape d’un compte rendu',
'journey.map.legend.several': 'Plusieurs au même endroit',
'journey.map.legend.lines': 'Trajets',
'journey.map.legend.plannedRoute': 'Trajet prévu (GPX d’un voyage)',
'journey.map.legend.recordedRoute': 'Trajet enregistré',
'journey.map.legend.planIsNoVisit': 'Un lieu prévu ne prouve pas une visite. Seuls les récits, les photos et les enregistrements montrent où vous étiez.',
'journey.map.pick.openEntry': 'Ouvrir le récit',
'journey.map.pick.planned': 'Prévu',
'journey.map.pick.notWritten': 'Rien d’écrit à ce sujet pour l’instant.',
'journey.map.pick.stay': 'Étape',
'journey.map.pick.partOf': 'Fait partie de « {title} »',
'journey.map.pick.here': 'À cet endroit',
'journey.map.pick.back': 'Retour à la liste',
'journey.map.pick.timeRange': 'de {from} à {to}',
'journey.share.plan': 'Programme du voyage',
'journey.share.planHint': 'Lieux prévus et trajets GPX des voyages liés sur la carte',
'journey.share.recordings': 'Trajets enregistrés',
'journey.share.recordingsHint': 'Jours enregistrés ayant un récit publié. Ils montrent où vous étiez, y compris votre hébergement.',
'journey.share.needsMap': 'Partagez la carte pour activer ceci',
};
export default journey;
+36
View File
@@ -873,5 +873,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Μια λήψη τρέχει ήδη στο παρασκήνιο. Οι στάσεις της θα εμφανιστούν μόλις ολοκληρωθεί.',
'journey.dayImport.running':
'Αυτή η μέρα δεν έχει τελειώσει. Στάσεις που καταγράφονται αργότερα εμφανίζονται μόλις τις έχει το Dawarich.',
'journey.map.layers': 'Επίπεδα χάρτη',
'journey.map.layer.plan': 'Σχέδιο',
'journey.map.layer.recorded': 'Καταγεγραμμένα',
'journey.map.layer.entries': 'Καταχωρίσεις',
'journey.map.layer.photos': 'Φωτογραφίες',
'journey.map.layer.loading': 'φόρτωση',
'journey.map.layer.planEmpty': 'τα συνδεδεμένα ταξίδια δεν έχουν μέρη στις μέρες τους',
'journey.map.layer.planUnavailable': 'το σχέδιο δεν φορτώθηκε, ο υπόλοιπος χάρτης παραμένει ορατός',
'journey.map.layer.recordedEmpty': 'καμία καταγεγραμμένη διαδρομή ακόμα',
'journey.map.layer.recordedUnavailable': 'οι καταγεγραμμένες μέρες δεν φορτώθηκαν',
'journey.map.layer.entriesEmpty': 'καμία καταχώριση δεν έχει ακόμα μέρος',
'journey.map.layer.entriesUnavailable': 'οι στάσεις των ημερήσιων αναφορών δεν φορτώθηκαν, οι καταχωρίσεις παραμένουν ορατές',
'journey.map.layer.photosEmpty': 'καμία φωτογραφία με τοποθεσία ακόμα',
'journey.map.legend': 'Υπόμνημα',
'journey.map.legend.planned': 'Προγραμματισμένο μέρος',
'journey.map.legend.entry': 'Καταχώριση ημερολογίου',
'journey.map.legend.plannedWritten': 'Προγραμματισμένο και γραμμένο',
'journey.map.legend.stay': 'Στάση ημερήσιας αναφοράς',
'journey.map.legend.several': 'Πολλά στο ίδιο σημείο',
'journey.map.legend.lines': 'Διαδρομές',
'journey.map.legend.plannedRoute': 'Προγραμματισμένη διαδρομή (GPX ταξιδιού)',
'journey.map.legend.recordedRoute': 'Καταγεγραμμένη διαδρομή',
'journey.map.legend.planIsNoVisit': 'Ένα προγραμματισμένο μέρος δεν αποδεικνύει επίσκεψη. Μόνο οι καταχωρίσεις, οι φωτογραφίες και οι καταγραφές δείχνουν πού ήσουν.',
'journey.map.pick.openEntry': 'Άνοιγμα καταχώρισης',
'journey.map.pick.planned': 'Προγραμματισμένο',
'journey.map.pick.notWritten': 'Δεν έχει γραφτεί τίποτα ακόμα γι’ αυτό.',
'journey.map.pick.stay': 'Στάση',
'journey.map.pick.partOf': 'Μέρος του «{title}»',
'journey.map.pick.here': 'Σε αυτό το σημείο',
'journey.map.pick.back': 'Πίσω στη λίστα',
'journey.map.pick.timeRange': 'από {from} έως {to}',
'journey.share.plan': 'Σχέδιο ταξιδιού',
'journey.share.planHint': 'Προγραμματισμένα μέρη και διαδρομές GPX των συνδεδεμένων ταξιδιών στον χάρτη',
'journey.share.recordings': 'Καταγεγραμμένες διαδρομές',
'journey.share.recordingsHint': 'Καταγεγραμμένες μέρες με δημοσιευμένη καταχώριση. Δείχνουν πού ήσουν, και το κατάλυμά σου.',
'journey.share.needsMap': 'Μοιράσου τον χάρτη για να το ενεργοποιήσεις',
};
export default journey;
+36
View File
@@ -860,5 +860,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'A háttérben már fut egy lekérés. A megállói megjelennek, amint végzett.',
'journey.dayImport.running':
'Ez a nap még tart. A később rögzített tartózkodások megjelennek, amint a Dawarich megkapja őket.',
'journey.map.layers': 'Térképrétegek',
'journey.map.layer.plan': 'Terv',
'journey.map.layer.recorded': 'Rögzített',
'journey.map.layer.entries': 'Bejegyzések',
'journey.map.layer.photos': 'Fotók',
'journey.map.layer.loading': 'betöltés',
'journey.map.layer.planEmpty': 'a kapcsolt utaknak nincsenek helyei a napjaikon',
'journey.map.layer.planUnavailable': 'a tervet nem sikerült betölteni, a térkép többi része látható marad',
'journey.map.layer.recordedEmpty': 'még nincs rögzített útvonal',
'journey.map.layer.recordedUnavailable': 'a rögzített napokat nem sikerült betölteni',
'journey.map.layer.entriesEmpty': 'még egyetlen bejegyzésnek sincs helye',
'journey.map.layer.entriesUnavailable': 'a napi beszámolók megállóit nem sikerült betölteni, a bejegyzések láthatók maradnak',
'journey.map.layer.photosEmpty': 'még nincs helyadattal rendelkező fotó',
'journey.map.legend': 'Jelmagyarázat',
'journey.map.legend.planned': 'Tervezett hely',
'journey.map.legend.entry': 'Naplóbejegyzés',
'journey.map.legend.plannedWritten': 'Tervezett és megírt',
'journey.map.legend.stay': 'Napi beszámoló megállója',
'journey.map.legend.several': 'Több egy helyen',
'journey.map.legend.lines': 'Útvonalak',
'journey.map.legend.plannedRoute': 'Tervezett útvonal (egy út GPX-e)',
'journey.map.legend.recordedRoute': 'Rögzített útvonal',
'journey.map.legend.planIsNoVisit': 'Egy tervezett hely nem bizonyítja a látogatást. Csak a bejegyzések, fotók és felvételek mutatják, hol jártál.',
'journey.map.pick.openEntry': 'Bejegyzés megnyitása',
'journey.map.pick.planned': 'Tervezett',
'journey.map.pick.notWritten': 'Még semmit sem írtál róla.',
'journey.map.pick.stay': 'Megálló',
'journey.map.pick.partOf': 'A(z) „{title}” része',
'journey.map.pick.here': 'Ezen a helyen',
'journey.map.pick.back': 'Vissza a listához',
'journey.map.pick.timeRange': '{from} és {to} között',
'journey.share.plan': 'Úti terv',
'journey.share.planHint': 'A kapcsolt utak tervezett helyei és GPX-útvonalai a térképen',
'journey.share.recordings': 'Rögzített útvonalak',
'journey.share.recordingsHint': 'Rögzített napok, amelyekhez közzétett bejegyzés tartozik. Megmutatják, hol jártál, a szállásodat is.',
'journey.share.needsMap': 'Oszd meg a térképet a bekapcsoláshoz',
};
export default journey;
+36
View File
@@ -837,5 +837,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Pengambilan sudah berjalan di latar belakang. Persinggahannya muncul begitu selesai.',
'journey.dayImport.running':
'Hari ini masih berlangsung. Persinggahan yang terekam kemudian muncul begitu Dawarich memilikinya.',
'journey.map.layers': 'Lapisan peta',
'journey.map.layer.plan': 'Rencana',
'journey.map.layer.recorded': 'Terekam',
'journey.map.layer.entries': 'Catatan',
'journey.map.layer.photos': 'Foto',
'journey.map.layer.loading': 'memuat',
'journey.map.layer.planEmpty': 'perjalanan tertaut tidak punya tempat di hari-harinya',
'journey.map.layer.planUnavailable': 'rencana tidak dapat dimuat, bagian peta lainnya tetap tampil',
'journey.map.layer.recordedEmpty': 'belum ada rute terekam',
'journey.map.layer.recordedUnavailable': 'hari terekam tidak dapat dimuat',
'journey.map.layer.entriesEmpty': 'belum ada catatan yang punya tempat',
'journey.map.layer.entriesUnavailable': 'persinggahan laporan harian tidak dapat dimuat, catatan tetap tampil',
'journey.map.layer.photosEmpty': 'belum ada foto berlokasi',
'journey.map.legend': 'Keterangan peta',
'journey.map.legend.planned': 'Tempat yang direncanakan',
'journey.map.legend.entry': 'Catatan jurnal',
'journey.map.legend.plannedWritten': 'Direncanakan dan ditulis',
'journey.map.legend.stay': 'Persinggahan laporan harian',
'journey.map.legend.several': 'Beberapa di satu titik',
'journey.map.legend.lines': 'Rute',
'journey.map.legend.plannedRoute': 'Rute yang direncanakan (GPX perjalanan)',
'journey.map.legend.recordedRoute': 'Rute terekam',
'journey.map.legend.planIsNoVisit': 'Tempat yang direncanakan bukan bukti kunjungan. Hanya catatan, foto, dan rekaman yang menunjukkan di mana kamu berada.',
'journey.map.pick.openEntry': 'Buka catatan',
'journey.map.pick.planned': 'Direncanakan',
'journey.map.pick.notWritten': 'Belum ada yang ditulis tentangnya.',
'journey.map.pick.stay': 'Persinggahan',
'journey.map.pick.partOf': 'Bagian dari “{title}”',
'journey.map.pick.here': 'Di titik ini',
'journey.map.pick.back': 'Kembali ke daftar',
'journey.map.pick.timeRange': '{from} sampai {to}',
'journey.share.plan': 'Rencana perjalanan',
'journey.share.planHint': 'Tempat yang direncanakan dan rute GPX dari perjalanan tertaut di peta',
'journey.share.recordings': 'Rute terekam',
'journey.share.recordingsHint': 'Hari terekam yang punya catatan terbit. Menunjukkan di mana kamu berada, termasuk penginapanmu.',
'journey.share.needsMap': 'Bagikan peta untuk mengaktifkan ini',
};
export default journey;
+36
View File
@@ -864,5 +864,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Un recupero è già in corso in background. Le soste compariranno appena sarà finito.',
'journey.dayImport.running':
'Questa giornata è ancora in corso. Le soste registrate dopo compaiono appena Dawarich le ha.',
'journey.map.layers': 'Livelli della mappa',
'journey.map.layer.plan': 'Pianificato',
'journey.map.layer.recorded': 'Registrato',
'journey.map.layer.entries': 'Voci',
'journey.map.layer.photos': 'Foto',
'journey.map.layer.loading': 'caricamento',
'journey.map.layer.planEmpty': 'i viaggi collegati non hanno luoghi nei loro giorni',
'journey.map.layer.planUnavailable': 'impossibile caricare il programma, il resto della mappa resta visibile',
'journey.map.layer.recordedEmpty': 'ancora nessun percorso registrato',
'journey.map.layer.recordedUnavailable': 'impossibile caricare i giorni registrati',
'journey.map.layer.entriesEmpty': 'nessuna voce ha ancora un luogo',
'journey.map.layer.entriesUnavailable': 'impossibile caricare le soste dei resoconti, le voci restano visibili',
'journey.map.layer.photosEmpty': 'ancora nessuna foto con posizione',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Luogo pianificato',
'journey.map.legend.entry': 'Voce del diario',
'journey.map.legend.plannedWritten': 'Pianificato e raccontato',
'journey.map.legend.stay': 'Sosta di un resoconto giornaliero',
'journey.map.legend.several': 'Più elementi nello stesso punto',
'journey.map.legend.lines': 'Percorsi',
'journey.map.legend.plannedRoute': 'Percorso pianificato (GPX di un viaggio)',
'journey.map.legend.recordedRoute': 'Percorso registrato',
'journey.map.legend.planIsNoVisit': 'Un luogo pianificato non prova una visita. Solo voci, foto e registrazioni mostrano dove sei stato.',
'journey.map.pick.openEntry': 'Apri la voce',
'journey.map.pick.planned': 'Pianificato',
'journey.map.pick.notWritten': 'Ancora niente di scritto su questo.',
'journey.map.pick.stay': 'Sosta',
'journey.map.pick.partOf': 'Parte di «{title}»',
'journey.map.pick.here': 'In questo punto',
'journey.map.pick.back': 'Torna all’elenco',
'journey.map.pick.timeRange': 'dalle {from} alle {to}',
'journey.share.plan': 'Programma del viaggio',
'journey.share.planHint': 'Luoghi pianificati e percorsi GPX dei viaggi collegati sulla mappa',
'journey.share.recordings': 'Percorsi registrati',
'journey.share.recordingsHint': 'Giorni registrati con una voce pubblicata. Mostrano dove sei stato, anche il tuo alloggio.',
'journey.share.needsMap': 'Condividi la mappa per attivarlo',
};
export default journey;
+36
View File
@@ -823,5 +823,41 @@ const journey: TranslationStrings = {
'{count} 日以上前に終わった旅行はバックグラウンドで準備されません。その日を開くと手動で取得できます。',
'journey.prep.alreadyRunning': 'バックグラウンドで取得がすでに実行中です。終わり次第、滞在先が表示されます。',
'journey.dayImport.running': 'この日はまだ続いています。後から記録された滞在先は Dawarich に届き次第表示されます。',
'journey.map.layers': '地図のレイヤー',
'journey.map.layer.plan': '予定',
'journey.map.layer.recorded': '記録',
'journey.map.layer.entries': 'エントリー',
'journey.map.layer.photos': '写真',
'journey.map.layer.loading': '読み込み中',
'journey.map.layer.planEmpty': 'リンクした旅行の日程に場所がありません',
'journey.map.layer.planUnavailable': '予定を読み込めませんでした。地図のほかの部分は表示されたままです',
'journey.map.layer.recordedEmpty': '記録したルートはまだありません',
'journey.map.layer.recordedUnavailable': '記録した日を読み込めませんでした',
'journey.map.layer.entriesEmpty': '場所のあるエントリーはまだありません',
'journey.map.layer.entriesUnavailable': '日のまとめの立ち寄り先を読み込めませんでした。エントリーは表示されたままです',
'journey.map.layer.photosEmpty': '位置情報のある写真はまだありません',
'journey.map.legend': '凡例',
'journey.map.legend.planned': '予定の場所',
'journey.map.legend.entry': '旅日記のエントリー',
'journey.map.legend.plannedWritten': '予定どおり書いたもの',
'journey.map.legend.stay': '日のまとめの立ち寄り先',
'journey.map.legend.several': '同じ地点に複数',
'journey.map.legend.lines': 'ルート',
'journey.map.legend.plannedRoute': '予定のルート(旅行のGPX)',
'journey.map.legend.recordedRoute': '記録したルート',
'journey.map.legend.planIsNoVisit': '予定の場所は訪問の証明ではありません。どこにいたかを示すのはエントリー、写真、記録だけです。',
'journey.map.pick.openEntry': 'エントリーを開く',
'journey.map.pick.planned': '予定',
'journey.map.pick.notWritten': 'まだ何も書かれていません。',
'journey.map.pick.stay': '立ち寄り先',
'journey.map.pick.partOf': '「{title}」の一部',
'journey.map.pick.here': 'この地点',
'journey.map.pick.back': '一覧に戻る',
'journey.map.pick.timeRange': '{from}〜{to}',
'journey.share.plan': '旅行の予定',
'journey.share.planHint': 'リンクした旅行の予定の場所とGPXルートを地図に表示',
'journey.share.recordings': '記録したルート',
'journey.share.recordingsHint': '公開済みのエントリーがある記録した日。宿泊先も含め、どこにいたかがわかります。',
'journey.share.needsMap': '有効にするには地図を共有してください',
};
export default journey;
+36
View File
@@ -820,5 +820,41 @@ const journey: TranslationStrings = {
'{count}일 이상 전에 끝난 여행은 백그라운드에서 준비되지 않습니다. 해당 날짜를 열어 직접 가져오세요.',
'journey.prep.alreadyRunning': '백그라운드에서 이미 가져오는 중입니다. 끝나는 대로 머문 곳이 표시됩니다.',
'journey.dayImport.running': '아직 진행 중인 날입니다. 나중에 기록된 머문 곳은 Dawarich에 들어오는 대로 표시됩니다.',
'journey.map.layers': '지도 레이어',
'journey.map.layer.plan': '계획',
'journey.map.layer.recorded': '기록',
'journey.map.layer.entries': '항목',
'journey.map.layer.photos': '사진',
'journey.map.layer.loading': '불러오는 중',
'journey.map.layer.planEmpty': '연결된 여행의 날짜에 장소가 없습니다',
'journey.map.layer.planUnavailable': '계획을 불러오지 못했습니다. 지도의 나머지는 그대로 보입니다',
'journey.map.layer.recordedEmpty': '아직 기록된 경로가 없습니다',
'journey.map.layer.recordedUnavailable': '기록된 날을 불러오지 못했습니다',
'journey.map.layer.entriesEmpty': '아직 장소가 있는 항목이 없습니다',
'journey.map.layer.entriesUnavailable': '하루 보고서의 머문 곳을 불러오지 못했습니다. 항목은 그대로 보입니다',
'journey.map.layer.photosEmpty': '아직 위치가 있는 사진이 없습니다',
'journey.map.legend': '범례',
'journey.map.legend.planned': '계획한 장소',
'journey.map.legend.entry': '여행 일지 항목',
'journey.map.legend.plannedWritten': '계획하고 기록함',
'journey.map.legend.stay': '하루 보고서의 머문 곳',
'journey.map.legend.several': '한 지점에 여러 개',
'journey.map.legend.lines': '경로',
'journey.map.legend.plannedRoute': '계획한 경로(여행의 GPX)',
'journey.map.legend.recordedRoute': '기록된 경로',
'journey.map.legend.planIsNoVisit': '계획한 장소는 방문의 증거가 아닙니다. 어디에 있었는지는 항목, 사진, 기록만 보여줍니다.',
'journey.map.pick.openEntry': '항목 열기',
'journey.map.pick.planned': '계획',
'journey.map.pick.notWritten': '아직 아무것도 쓰지 않았습니다.',
'journey.map.pick.stay': '머문 곳',
'journey.map.pick.partOf': '“{title}”의 일부',
'journey.map.pick.here': '이 지점',
'journey.map.pick.back': '목록으로 돌아가기',
'journey.map.pick.timeRange': '{from}부터 {to}까지',
'journey.share.plan': '여행 계획',
'journey.share.planHint': '연결된 여행의 계획한 장소와 GPX 경로를 지도에 표시',
'journey.share.recordings': '기록된 경로',
'journey.share.recordingsHint': '게시된 항목이 있는 기록된 날. 숙소를 포함해 어디에 있었는지 보여줍니다.',
'journey.share.needsMap': '이 기능을 켜려면 지도를 공유하세요',
};
export default journey;
+36
View File
@@ -865,5 +865,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning':
'Er loopt al een ophaalactie op de achtergrond. De verblijven verschijnen zodra die klaar is.',
'journey.dayImport.running': 'Deze dag loopt nog. Later opgenomen verblijven verschijnen zodra Dawarich ze heeft.',
'journey.map.layers': 'Kaartlagen',
'journey.map.layer.plan': 'Planning',
'journey.map.layer.recorded': 'Opgenomen',
'journey.map.layer.entries': 'Verhalen',
'journey.map.layer.photos': 'Foto’s',
'journey.map.layer.loading': 'laden',
'journey.map.layer.planEmpty': 'de gekoppelde reizen hebben geen plekken op hun dagen',
'journey.map.layer.planUnavailable': 'de planning kon niet worden geladen, de rest van de kaart blijft zichtbaar',
'journey.map.layer.recordedEmpty': 'nog geen opgenomen route',
'journey.map.layer.recordedUnavailable': 'de opgenomen dagen konden niet worden geladen',
'journey.map.layer.entriesEmpty': 'nog geen verhaal heeft een plek',
'journey.map.layer.entriesUnavailable': 'de verblijven van dagverslagen konden niet worden geladen, de verhalen blijven zichtbaar',
'journey.map.layer.photosEmpty': 'nog geen foto met locatie',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Geplande plek',
'journey.map.legend.entry': 'Dagboekverhaal',
'journey.map.legend.plannedWritten': 'Gepland en beschreven',
'journey.map.legend.stay': 'Verblijf van een dagverslag',
'journey.map.legend.several': 'Meerdere op één plek',
'journey.map.legend.lines': 'Routes', // same-as-en
'journey.map.legend.plannedRoute': 'Geplande route (GPX van een reis)',
'journey.map.legend.recordedRoute': 'Opgenomen route',
'journey.map.legend.planIsNoVisit': 'Een geplande plek bewijst geen bezoek. Alleen verhalen, foto’s en opnames tonen waar je was.',
'journey.map.pick.openEntry': 'Verhaal openen',
'journey.map.pick.planned': 'Gepland',
'journey.map.pick.notWritten': 'Er is nog niets over geschreven.',
'journey.map.pick.stay': 'Verblijf',
'journey.map.pick.partOf': 'Onderdeel van ‘{title}’',
'journey.map.pick.here': 'Op deze plek',
'journey.map.pick.back': 'Terug naar de lijst',
'journey.map.pick.timeRange': '{from} tot {to}',
'journey.share.plan': 'Reisplanning',
'journey.share.planHint': 'Geplande plekken en GPX-routes van de gekoppelde reizen op de kaart',
'journey.share.recordings': 'Opgenomen routes',
'journey.share.recordingsHint': 'Opgenomen dagen met een gepubliceerd verhaal. Ze tonen waar je was, ook je verblijf.',
'journey.share.needsMap': 'Deel de kaart om dit aan te zetten',
};
export default journey;
+36
View File
@@ -907,5 +907,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'W tle trwa już pobieranie. Postoje pojawią się, gdy się zakończy.',
'journey.dayImport.running':
'Ten dzień jeszcze trwa. Postoje zarejestrowane później pojawią się, gdy tylko Dawarich je będzie mieć.',
'journey.map.layers': 'Warstwy mapy',
'journey.map.layer.plan': 'Plan', // same-as-en
'journey.map.layer.recorded': 'Nagrane',
'journey.map.layer.entries': 'Wpisy',
'journey.map.layer.photos': 'Zdjęcia',
'journey.map.layer.loading': 'wczytywanie',
'journey.map.layer.planEmpty': 'powiązane podróże nie mają miejsc w swoich dniach',
'journey.map.layer.planUnavailable': 'nie udało się wczytać planu, reszta mapy pozostaje widoczna',
'journey.map.layer.recordedEmpty': 'jeszcze brak nagranej trasy',
'journey.map.layer.recordedUnavailable': 'nie udało się wczytać nagranych dni',
'journey.map.layer.entriesEmpty': 'żaden wpis nie ma jeszcze miejsca',
'journey.map.layer.entriesUnavailable': 'nie udało się wczytać postojów relacji dnia, wpisy pozostają widoczne',
'journey.map.layer.photosEmpty': 'jeszcze brak zdjęć z lokalizacją',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Zaplanowane miejsce',
'journey.map.legend.entry': 'Wpis w dzienniku',
'journey.map.legend.plannedWritten': 'Zaplanowane i opisane',
'journey.map.legend.stay': 'Postój relacji dnia',
'journey.map.legend.several': 'Kilka w jednym miejscu',
'journey.map.legend.lines': 'Trasy',
'journey.map.legend.plannedRoute': 'Zaplanowana trasa (GPX podróży)',
'journey.map.legend.recordedRoute': 'Nagrana trasa',
'journey.map.legend.planIsNoVisit': 'Zaplanowane miejsce nie dowodzi wizyty. Tylko wpisy, zdjęcia i nagrania pokazują, gdzie byłeś.',
'journey.map.pick.openEntry': 'Otwórz wpis',
'journey.map.pick.planned': 'Zaplanowane',
'journey.map.pick.notWritten': 'Nic jeszcze o nim nie napisano.',
'journey.map.pick.stay': 'Postój',
'journey.map.pick.partOf': 'Część „{title}”',
'journey.map.pick.here': 'W tym miejscu',
'journey.map.pick.back': 'Wróć do listy',
'journey.map.pick.timeRange': 'od {from} do {to}',
'journey.share.plan': 'Plan podróży',
'journey.share.planHint': 'Zaplanowane miejsca i trasy GPX powiązanych podróży na mapie',
'journey.share.recordings': 'Nagrane trasy',
'journey.share.recordingsHint': 'Nagrane dni z opublikowanym wpisem. Pokazują, gdzie byłeś, także nocleg.',
'journey.share.needsMap': 'Udostępnij mapę, aby to włączyć',
};
export default journey;
+36
View File
@@ -906,5 +906,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'В фоне уже идёт загрузка. Остановки появятся, как только она завершится.',
'journey.dayImport.running':
'Этот день ещё идёт. Остановки, записанные позже, появятся, как только они будут у Dawarich.',
'journey.map.layers': 'Слои карты',
'journey.map.layer.plan': 'План',
'journey.map.layer.recorded': 'Записано',
'journey.map.layer.entries': 'Записи',
'journey.map.layer.photos': 'Фото',
'journey.map.layer.loading': 'загрузка',
'journey.map.layer.planEmpty': 'в связанных поездках нет мест в днях',
'journey.map.layer.planUnavailable': 'не удалось загрузить план, остальная карта по-прежнему видна',
'journey.map.layer.recordedEmpty': 'записанного маршрута пока нет',
'journey.map.layer.recordedUnavailable': 'не удалось загрузить записанные дни',
'journey.map.layer.entriesEmpty': 'ни у одной записи пока нет места',
'journey.map.layer.entriesUnavailable': 'не удалось загрузить остановки дневных отчётов, записи по-прежнему видны',
'journey.map.layer.photosEmpty': 'пока нет фото с местоположением',
'journey.map.legend': 'Легенда',
'journey.map.legend.planned': 'Запланированное место',
'journey.map.legend.entry': 'Запись в дневнике',
'journey.map.legend.plannedWritten': 'Запланировано и описано',
'journey.map.legend.stay': 'Остановка дневного отчёта',
'journey.map.legend.several': 'Несколько в одной точке',
'journey.map.legend.lines': 'Маршруты',
'journey.map.legend.plannedRoute': 'Запланированный маршрут (GPX поездки)',
'journey.map.legend.recordedRoute': 'Записанный маршрут',
'journey.map.legend.planIsNoVisit': 'Запланированное место не доказывает посещение. Где вы были, показывают только записи, фото и треки.',
'journey.map.pick.openEntry': 'Открыть запись',
'journey.map.pick.planned': 'Запланировано',
'journey.map.pick.notWritten': 'Об этом пока ничего не написано.',
'journey.map.pick.stay': 'Остановка',
'journey.map.pick.partOf': 'Часть «{title}»',
'journey.map.pick.here': 'В этой точке',
'journey.map.pick.back': 'Назад к списку',
'journey.map.pick.timeRange': 'с {from} до {to}',
'journey.share.plan': 'План поездки',
'journey.share.planHint': 'Запланированные места и GPX-маршруты связанных поездок на карте',
'journey.share.recordings': 'Записанные маршруты',
'journey.share.recordingsHint': 'Записанные дни с опубликованной записью. Они показывают, где вы были, в том числе ваше жильё.',
'journey.share.needsMap': 'Откройте доступ к карте, чтобы включить это',
};
export default journey;
+36
View File
@@ -882,5 +882,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'Na pozadí už jedno načítanie beží. Jeho zastávky sa zobrazia, hneď ako dobehne.',
'journey.dayImport.running':
'Tento deň ešte prebieha. Neskôr zaznamenané pobyty sa objavia, hneď ako ich Dawarich bude mať.',
'journey.map.layers': 'Vrstvy mapy',
'journey.map.layer.plan': 'Plán',
'journey.map.layer.recorded': 'Zaznamenané',
'journey.map.layer.entries': 'Záznamy',
'journey.map.layer.photos': 'Fotky',
'journey.map.layer.loading': 'načítava sa',
'journey.map.layer.planEmpty': 'prepojené cesty nemajú vo svojich dňoch žiadne miesta',
'journey.map.layer.planUnavailable': 'plán sa nepodarilo načítať, zvyšok mapy zostáva viditeľný',
'journey.map.layer.recordedEmpty': 'zatiaľ žiadna zaznamenaná trasa',
'journey.map.layer.recordedUnavailable': 'zaznamenané dni sa nepodarilo načítať',
'journey.map.layer.entriesEmpty': 'zatiaľ žiadny záznam nemá miesto',
'journey.map.layer.entriesUnavailable': 'zastávky denných správ sa nepodarilo načítať, záznamy zostávajú viditeľné',
'journey.map.layer.photosEmpty': 'zatiaľ žiadna fotka s polohou',
'journey.map.legend': 'Legenda',
'journey.map.legend.planned': 'Plánované miesto',
'journey.map.legend.entry': 'Záznam v denníku',
'journey.map.legend.plannedWritten': 'Plánované a opísané',
'journey.map.legend.stay': 'Zastávka dennej správy',
'journey.map.legend.several': 'Viaceré na jednom mieste',
'journey.map.legend.lines': 'Trasy',
'journey.map.legend.plannedRoute': 'Plánovaná trasa (GPX cesty)',
'journey.map.legend.recordedRoute': 'Zaznamenaná trasa',
'journey.map.legend.planIsNoVisit': 'Plánované miesto nie je dôkazom návštevy. Kde ste boli, ukazujú len záznamy, fotky a nahrávky.',
'journey.map.pick.openEntry': 'Otvoriť záznam',
'journey.map.pick.planned': 'Plánované',
'journey.map.pick.notWritten': 'Zatiaľ o ňom nič nenapísané.',
'journey.map.pick.stay': 'Zastávka',
'journey.map.pick.partOf': 'Súčasť „{title}“',
'journey.map.pick.here': 'Na tomto mieste',
'journey.map.pick.back': 'Späť na zoznam',
'journey.map.pick.timeRange': 'od {from} do {to}',
'journey.share.plan': 'Plán cesty',
'journey.share.planHint': 'Plánované miesta a trasy GPX prepojených ciest na mape',
'journey.share.recordings': 'Zaznamenané trasy',
'journey.share.recordingsHint': 'Zaznamenané dni so zverejneným záznamom. Ukazujú, kde ste boli, aj vaše ubytovanie.',
'journey.share.needsMap': 'Ak to chcete zapnúť, zdieľajte mapu',
};
export default journey;
+36
View File
@@ -856,5 +856,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': 'En hämtning pågår redan i bakgrunden. Vistelserna visas så snart den är klar.',
'journey.dayImport.running':
'Den här dagen pågår fortfarande. Vistelser som spelas in senare visas så snart Dawarich har dem.',
'journey.map.layers': 'Kartlager',
'journey.map.layer.plan': 'Planering',
'journey.map.layer.recorded': 'Inspelat',
'journey.map.layer.entries': 'Inlägg',
'journey.map.layer.photos': 'Foton',
'journey.map.layer.loading': 'läser in',
'journey.map.layer.planEmpty': 'de länkade resorna har inga platser på sina dagar',
'journey.map.layer.planUnavailable': 'planen kunde inte läsas in, resten av kartan syns fortfarande',
'journey.map.layer.recordedEmpty': 'ingen inspelad rutt ännu',
'journey.map.layer.recordedUnavailable': 'de inspelade dagarna kunde inte läsas in',
'journey.map.layer.entriesEmpty': 'inget inlägg har en plats ännu',
'journey.map.layer.entriesUnavailable': 'dagrapporternas stopp kunde inte läsas in, inläggen syns fortfarande',
'journey.map.layer.photosEmpty': 'inget foto med plats ännu',
'journey.map.legend': 'Teckenförklaring',
'journey.map.legend.planned': 'Planerad plats',
'journey.map.legend.entry': 'Dagboksinlägg',
'journey.map.legend.plannedWritten': 'Planerad och beskriven',
'journey.map.legend.stay': 'Stopp i en dagrapport',
'journey.map.legend.several': 'Flera på samma ställe',
'journey.map.legend.lines': 'Rutter',
'journey.map.legend.plannedRoute': 'Planerad rutt (en resas GPX)',
'journey.map.legend.recordedRoute': 'Inspelad rutt',
'journey.map.legend.planIsNoVisit': 'En planerad plats bevisar inget besök. Bara inlägg, foton och inspelningar visar var du var.',
'journey.map.pick.openEntry': 'Öppna inlägg',
'journey.map.pick.planned': 'Planerad',
'journey.map.pick.notWritten': 'Inget skrivet om den ännu.',
'journey.map.pick.stay': 'Stopp',
'journey.map.pick.partOf': 'Del av ”{title}”',
'journey.map.pick.here': 'På den här platsen',
'journey.map.pick.back': 'Tillbaka till listan',
'journey.map.pick.timeRange': '{from} till {to}',
'journey.share.plan': 'Resplan',
'journey.share.planHint': 'Planerade platser och GPX-rutter för de länkade resorna på kartan',
'journey.share.recordings': 'Inspelade rutter',
'journey.share.recordingsHint': 'Inspelade dagar som har ett publicerat inlägg. De visar var du var, även ditt boende.',
'journey.share.needsMap': 'Dela kartan för att slå på detta',
};
export default journey;
+36
View File
@@ -813,5 +813,41 @@ const journey: TranslationStrings = {
'ทริปที่จบไปนานกว่า {count} วันจะไม่ถูกเตรียมเบื้องหลัง เปิดวันใดวันหนึ่งของทริปเพื่อดึงข้อมูลด้วยตนเอง',
'journey.prep.alreadyRunning': 'มีการดึงข้อมูลทำงานอยู่เบื้องหลังแล้ว จุดแวะพักจะปรากฏเมื่อเสร็จ',
'journey.dayImport.running': 'วันนี้ยังไม่จบ จุดแวะพักที่บันทึกภายหลังจะปรากฏเมื่อ Dawarich ได้รับแล้ว',
'journey.map.layers': 'เลเยอร์แผนที่',
'journey.map.layer.plan': 'แผน',
'journey.map.layer.recorded': 'ที่บันทึก',
'journey.map.layer.entries': 'บันทึก',
'journey.map.layer.photos': 'รูปภาพ',
'journey.map.layer.loading': 'กำลังโหลด',
'journey.map.layer.planEmpty': 'ทริปที่เชื่อมโยงไม่มีสถานที่ในวันของทริป',
'journey.map.layer.planUnavailable': 'โหลดแผนไม่ได้ ส่วนอื่นของแผนที่ยังแสดงอยู่',
'journey.map.layer.recordedEmpty': 'ยังไม่มีเส้นทางที่บันทึก',
'journey.map.layer.recordedUnavailable': 'โหลดวันที่บันทึกไม่ได้',
'journey.map.layer.entriesEmpty': 'ยังไม่มีบันทึกใดที่มีสถานที่',
'journey.map.layer.entriesUnavailable': 'โหลดจุดแวะของรายงานประจำวันไม่ได้ บันทึกยังแสดงอยู่',
'journey.map.layer.photosEmpty': 'ยังไม่มีรูปที่มีตำแหน่ง',
'journey.map.legend': 'คำอธิบายแผนที่',
'journey.map.legend.planned': 'สถานที่ตามแผน',
'journey.map.legend.entry': 'บันทึกการเดินทาง',
'journey.map.legend.plannedWritten': 'ตามแผนและเขียนถึงแล้ว',
'journey.map.legend.stay': 'จุดแวะของรายงานประจำวัน',
'journey.map.legend.several': 'หลายรายการในจุดเดียว',
'journey.map.legend.lines': 'เส้นทาง',
'journey.map.legend.plannedRoute': 'เส้นทางตามแผน (GPX ของทริป)',
'journey.map.legend.recordedRoute': 'เส้นทางที่บันทึก',
'journey.map.legend.planIsNoVisit': 'สถานที่ตามแผนไม่ใช่หลักฐานว่าไปมาแล้ว มีเพียงบันทึก รูปภาพ และการบันทึกเส้นทางที่แสดงว่าคุณอยู่ที่ไหน',
'journey.map.pick.openEntry': 'เปิดบันทึก',
'journey.map.pick.planned': 'ตามแผน',
'journey.map.pick.notWritten': 'ยังไม่มีการเขียนถึงที่นี่',
'journey.map.pick.stay': 'จุดแวะ',
'journey.map.pick.partOf': 'ส่วนหนึ่งของ “{title}”',
'journey.map.pick.here': 'ที่จุดนี้',
'journey.map.pick.back': 'กลับไปที่รายการ',
'journey.map.pick.timeRange': '{from} ถึง {to}',
'journey.share.plan': 'แผนการเดินทาง',
'journey.share.planHint': 'สถานที่ตามแผนและเส้นทาง GPX ของทริปที่เชื่อมโยงบนแผนที่',
'journey.share.recordings': 'เส้นทางที่บันทึก',
'journey.share.recordingsHint': 'วันที่บันทึกซึ่งมีบันทึกที่เผยแพร่แล้ว แสดงว่าคุณอยู่ที่ไหน รวมถึงที่พักด้วย',
'journey.share.needsMap': 'แชร์แผนที่เพื่อเปิดใช้งานนี้',
};
export default journey;
+36
View File
@@ -854,5 +854,41 @@ const journey: TranslationStrings = {
'{count} günden daha önce biten geziler arka planda hazırlanmaz. Elle almak için günlerinden birini açın.',
'journey.prep.alreadyRunning': 'Arka planda zaten bir alma işlemi sürüyor. Duraklar o bitince görünür.',
'journey.dayImport.running': 'Bu gün hâlâ sürüyor. Daha sonra kaydedilen duraklar Dawarich onları aldığında görünür.',
'journey.map.layers': 'Harita katmanları',
'journey.map.layer.plan': 'Plan', // same-as-en
'journey.map.layer.recorded': 'Kaydedilen',
'journey.map.layer.entries': 'Girdiler',
'journey.map.layer.photos': 'Fotoğraflar',
'journey.map.layer.loading': 'yükleniyor',
'journey.map.layer.planEmpty': 'bağlı gezilerin günlerinde yer yok',
'journey.map.layer.planUnavailable': 'plan yüklenemedi, haritanın geri kalanı görünmeye devam ediyor',
'journey.map.layer.recordedEmpty': 'henüz kaydedilmiş rota yok',
'journey.map.layer.recordedUnavailable': 'kaydedilen günler yüklenemedi',
'journey.map.layer.entriesEmpty': 'henüz hiçbir girdinin yeri yok',
'journey.map.layer.entriesUnavailable': 'gün raporlarının durakları yüklenemedi, girdiler görünmeye devam ediyor',
'journey.map.layer.photosEmpty': 'henüz konumlu fotoğraf yok',
'journey.map.legend': 'Harita açıklaması',
'journey.map.legend.planned': 'Planlanan yer',
'journey.map.legend.entry': 'Günlük girdisi',
'journey.map.legend.plannedWritten': 'Planlandı ve yazıldı',
'journey.map.legend.stay': 'Bir gün raporunun durağı',
'journey.map.legend.several': 'Aynı noktada birkaç tane',
'journey.map.legend.lines': 'Rotalar',
'journey.map.legend.plannedRoute': 'Planlanan rota (bir gezinin GPX’i)',
'journey.map.legend.recordedRoute': 'Kaydedilen rota',
'journey.map.legend.planIsNoVisit': 'Planlanan bir yer ziyaretin kanıtı değildir. Nerede olduğunu yalnızca girdiler, fotoğraflar ve kayıtlar gösterir.',
'journey.map.pick.openEntry': 'Girdiyi aç',
'journey.map.pick.planned': 'Planlandı',
'journey.map.pick.notWritten': 'Hakkında henüz bir şey yazılmadı.',
'journey.map.pick.stay': 'Durak',
'journey.map.pick.partOf': '“{title}” girdisinin parçası',
'journey.map.pick.here': 'Bu noktada',
'journey.map.pick.back': 'Listeye dön',
'journey.map.pick.timeRange': '{from} ile {to} arası',
'journey.share.plan': 'Gezi planı',
'journey.share.planHint': 'Bağlı gezilerin planlanan yerleri ve GPX rotaları haritada',
'journey.share.recordings': 'Kaydedilen rotalar',
'journey.share.recordingsHint': 'Yayınlanmış bir girdisi olan kaydedilmiş günler. Nerede olduğunu, konakladığın yeri de gösterir.',
'journey.share.needsMap': 'Bunu açmak için haritayı paylaş',
};
export default journey;
+36
View File
@@ -913,5 +913,41 @@ const journey: TranslationStrings = {
'journey.prep.alreadyRunning': "У фоні вже триває завантаження. Зупинки з'являться, щойно воно завершиться.",
'journey.dayImport.running':
"Цей день ще триває. Зупинки, записані пізніше, з'являться, щойно вони будуть у Dawarich.",
'journey.map.layers': 'Шари карти',
'journey.map.layer.plan': 'План',
'journey.map.layer.recorded': 'Записано',
'journey.map.layer.entries': 'Записи',
'journey.map.layer.photos': 'Фото',
'journey.map.layer.loading': 'завантаження',
'journey.map.layer.planEmpty': 'у пов’язаних подорожах немає місць у днях',
'journey.map.layer.planUnavailable': 'не вдалося завантажити план, решта карти й далі видима',
'journey.map.layer.recordedEmpty': 'записаного маршруту ще немає',
'journey.map.layer.recordedUnavailable': 'не вдалося завантажити записані дні',
'journey.map.layer.entriesEmpty': 'жоден запис ще не має місця',
'journey.map.layer.entriesUnavailable': 'не вдалося завантажити зупинки денних звітів, записи й далі видимі',
'journey.map.layer.photosEmpty': 'ще немає фото з місцезнаходженням',
'journey.map.legend': 'Легенда',
'journey.map.legend.planned': 'Заплановане місце',
'journey.map.legend.entry': 'Запис у щоденнику',
'journey.map.legend.plannedWritten': 'Заплановано й описано',
'journey.map.legend.stay': 'Зупинка денного звіту',
'journey.map.legend.several': 'Кілька в одній точці',
'journey.map.legend.lines': 'Маршрути',
'journey.map.legend.plannedRoute': 'Запланований маршрут (GPX подорожі)',
'journey.map.legend.recordedRoute': 'Записаний маршрут',
'journey.map.legend.planIsNoVisit': 'Заплановане місце не доводить відвідування. Де ви були, показують лише записи, фото й треки.',
'journey.map.pick.openEntry': 'Відкрити запис',
'journey.map.pick.planned': 'Заплановано',
'journey.map.pick.notWritten': 'Про це ще нічого не написано.',
'journey.map.pick.stay': 'Зупинка',
'journey.map.pick.partOf': 'Частина «{title}»',
'journey.map.pick.here': 'У цій точці',
'journey.map.pick.back': 'Назад до списку',
'journey.map.pick.timeRange': 'з {from} до {to}',
'journey.share.plan': 'План подорожі',
'journey.share.planHint': 'Заплановані місця й GPX-маршрути пов’язаних подорожей на карті',
'journey.share.recordings': 'Записані маршрути',
'journey.share.recordingsHint': 'Записані дні з опублікованим записом. Вони показують, де ви були, зокрема ваше житло.',
'journey.share.needsMap': 'Відкрийте доступ до карти, щоб увімкнути це',
};
export default journey;
+36
View File
@@ -832,5 +832,41 @@ const journey: TranslationStrings = {
'Các chuyến đi kết thúc hơn {count} ngày trước không được chuẩn bị ở nền. Mở một ngày của chuyến để lấy thủ công.',
'journey.prep.alreadyRunning': 'Đang có một lượt lấy dữ liệu chạy ở nền. Các điểm dừng sẽ hiện khi nó xong.',
'journey.dayImport.running': 'Ngày này vẫn đang diễn ra. Các điểm dừng ghi sau sẽ hiện khi Dawarich có chúng.',
'journey.map.layers': 'Lớp bản đồ',
'journey.map.layer.plan': 'Kế hoạch',
'journey.map.layer.recorded': 'Đã ghi',
'journey.map.layer.entries': 'Bài viết',
'journey.map.layer.photos': 'Ảnh',
'journey.map.layer.loading': 'đang tải',
'journey.map.layer.planEmpty': 'các chuyến đi liên kết không có địa điểm nào trong các ngày',
'journey.map.layer.planUnavailable': 'không tải được kế hoạch, phần còn lại của bản đồ vẫn hiển thị',
'journey.map.layer.recordedEmpty': 'chưa có tuyến đường nào được ghi',
'journey.map.layer.recordedUnavailable': 'không tải được các ngày đã ghi',
'journey.map.layer.entriesEmpty': 'chưa có bài viết nào có địa điểm',
'journey.map.layer.entriesUnavailable': 'không tải được các điểm dừng của báo cáo ngày, các bài viết vẫn hiển thị',
'journey.map.layer.photosEmpty': 'chưa có ảnh nào có vị trí',
'journey.map.legend': 'Chú giải bản đồ',
'journey.map.legend.planned': 'Địa điểm dự kiến',
'journey.map.legend.entry': 'Bài nhật ký',
'journey.map.legend.plannedWritten': 'Đã dự kiến và đã viết',
'journey.map.legend.stay': 'Điểm dừng của báo cáo ngày',
'journey.map.legend.several': 'Nhiều mục tại một điểm',
'journey.map.legend.lines': 'Tuyến đường',
'journey.map.legend.plannedRoute': 'Tuyến dự kiến (GPX của chuyến đi)',
'journey.map.legend.recordedRoute': 'Tuyến đã ghi',
'journey.map.legend.planIsNoVisit': 'Địa điểm dự kiến không chứng minh bạn đã đến. Chỉ bài viết, ảnh và bản ghi mới cho thấy bạn đã ở đâu.',
'journey.map.pick.openEntry': 'Mở bài viết',
'journey.map.pick.planned': 'Dự kiến',
'journey.map.pick.notWritten': 'Chưa viết gì về nơi này.',
'journey.map.pick.stay': 'Điểm dừng',
'journey.map.pick.partOf': 'Thuộc “{title}”',
'journey.map.pick.here': 'Tại điểm này',
'journey.map.pick.back': 'Quay lại danh sách',
'journey.map.pick.timeRange': 'từ {from} đến {to}',
'journey.share.plan': 'Kế hoạch chuyến đi',
'journey.share.planHint': 'Địa điểm dự kiến và tuyến GPX của các chuyến đi liên kết trên bản đồ',
'journey.share.recordings': 'Tuyến đã ghi',
'journey.share.recordingsHint': 'Những ngày đã ghi có bài viết được đăng. Cho thấy bạn đã ở đâu, kể cả chỗ ở.',
'journey.share.needsMap': 'Chia sẻ bản đồ để bật mục này',
};
export default journey;
+36
View File
@@ -802,5 +802,41 @@ const journey: TranslationStrings = {
'journey.prep.outOfReach': '結束超過 {count} 天的旅行不會在背景準備。開啟其中某一天即可手動取得。',
'journey.prep.alreadyRunning': '背景已有一次取得正在進行。完成後停留點就會顯示。',
'journey.dayImport.running': '這一天還沒結束。之後記錄的停留點會在 Dawarich 收到後顯示。',
'journey.map.layers': '地圖圖層',
'journey.map.layer.plan': '計畫',
'journey.map.layer.recorded': '記錄',
'journey.map.layer.entries': '日記',
'journey.map.layer.photos': '照片',
'journey.map.layer.loading': '載入中',
'journey.map.layer.planEmpty': '連結的行程在其日程中沒有地點',
'journey.map.layer.planUnavailable': '無法載入計畫,地圖的其餘部分仍會顯示',
'journey.map.layer.recordedEmpty': '還沒有記錄的路線',
'journey.map.layer.recordedUnavailable': '無法載入記錄的日子',
'journey.map.layer.entriesEmpty': '還沒有帶地點的日記',
'journey.map.layer.entriesUnavailable': '無法載入每日報告的停留點,日記仍會顯示',
'journey.map.layer.photosEmpty': '還沒有帶位置的照片',
'journey.map.legend': '圖例',
'journey.map.legend.planned': '計畫的地點',
'journey.map.legend.entry': '日記項目',
'journey.map.legend.plannedWritten': '已計畫並已記述',
'journey.map.legend.stay': '每日報告的停留點',
'journey.map.legend.several': '同一地點的多個',
'journey.map.legend.lines': '路線',
'journey.map.legend.plannedRoute': '計畫的路線(行程的 GPX)',
'journey.map.legend.recordedRoute': '記錄的路線',
'journey.map.legend.planIsNoVisit': '計畫的地點不能證明到訪過。只有日記、照片和記錄才能顯示你去過哪裡。',
'journey.map.pick.openEntry': '開啟日記',
'journey.map.pick.planned': '已計畫',
'journey.map.pick.notWritten': '還沒有寫過它。',
'journey.map.pick.stay': '停留點',
'journey.map.pick.partOf': '屬於「{title}」',
'journey.map.pick.here': '在此地點',
'journey.map.pick.back': '返回清單',
'journey.map.pick.timeRange': '{from} 至 {to}',
'journey.share.plan': '行程計畫',
'journey.share.planHint': '在地圖上顯示連結行程的計畫地點和 GPX 路線',
'journey.share.recordings': '記錄的路線',
'journey.share.recordingsHint': '有已發布日記的記錄日子。會顯示你去過哪裡,也包括住處。',
'journey.share.needsMap': '分享地圖後才能開啟',
};
export default journey;
+36
View File
@@ -802,5 +802,41 @@ const journey: TranslationStrings = {
'journey.prep.outOfReach': '结束超过 {count} 天的旅行不会在后台准备。打开其中某一天即可手动获取。',
'journey.prep.alreadyRunning': '后台已有一次获取正在进行。完成后停留点就会显示。',
'journey.dayImport.running': '这一天还没结束。之后记录的停留点会在 Dawarich 收到后显示。',
'journey.map.layers': '地图图层',
'journey.map.layer.plan': '计划',
'journey.map.layer.recorded': '记录',
'journey.map.layer.entries': '日记',
'journey.map.layer.photos': '照片',
'journey.map.layer.loading': '加载中',
'journey.map.layer.planEmpty': '关联的行程在其日程中没有地点',
'journey.map.layer.planUnavailable': '无法加载计划,地图的其余部分仍会显示',
'journey.map.layer.recordedEmpty': '还没有记录的路线',
'journey.map.layer.recordedUnavailable': '无法加载记录的日子',
'journey.map.layer.entriesEmpty': '还没有带地点的日记',
'journey.map.layer.entriesUnavailable': '无法加载每日报告的停留点,日记仍会显示',
'journey.map.layer.photosEmpty': '还没有带位置的照片',
'journey.map.legend': '图例',
'journey.map.legend.planned': '计划的地点',
'journey.map.legend.entry': '日记条目',
'journey.map.legend.plannedWritten': '已计划并已记述',
'journey.map.legend.stay': '每日报告的停留点',
'journey.map.legend.several': '同一地点的多个',
'journey.map.legend.lines': '路线',
'journey.map.legend.plannedRoute': '计划的路线(行程的 GPX)',
'journey.map.legend.recordedRoute': '记录的路线',
'journey.map.legend.planIsNoVisit': '计划的地点不能证明到访过。只有日记、照片和记录才能显示你去过哪里。',
'journey.map.pick.openEntry': '打开日记',
'journey.map.pick.planned': '已计划',
'journey.map.pick.notWritten': '还没有写过它。',
'journey.map.pick.stay': '停留点',
'journey.map.pick.partOf': '属于“{title}”',
'journey.map.pick.here': '在此地点',
'journey.map.pick.back': '返回列表',
'journey.map.pick.timeRange': '{from} 至 {to}',
'journey.share.plan': '行程计划',
'journey.share.planHint': '在地图上显示关联行程的计划地点和 GPX 路线',
'journey.share.recordings': '记录的路线',
'journey.share.recordingsHint': '有已发布日记的记录日子。会显示你去过哪里,也包括住处。',
'journey.share.needsMap': '共享地图后才能开启',
};
export default journey;
+15 -2
View File
@@ -100,7 +100,18 @@ The **+** in the bottom bar starts a quick capture for a moment on the move: a t
The journey detail page includes a map on the right (desktop) or an integrated map-timeline (mobile) showing all entry locations alongside the places from linked trips.
**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. Nothing to switch on: the tracks belong to the trips your entries came from, so importing the file while planning is all it takes. Hovering a track shows its name. A trip's track is the plan, so it is drawn dashed; what was recorded, a day the journey keeps or the live [Dawarich](Dawarich) route, is drawn solid.
**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. They come with the trips linked to the journey, also a trip nobody has written about yet, while **Show trip tracks** is on in the journey settings. Hovering a track shows its name. A trip's track is the plan, so it is drawn dashed; what was recorded, a day the journey keeps or the live [Dawarich](Dawarich) route, is drawn solid.
### Map layers
The journey map shows four layers, each switched on and off at the top of the map; the switch is yours and remembered in this browser, per journey, and it changes nothing in the trip, the entries or the photos.
- **Plan**: the places the linked trips put on their days, as hollow dashed pins in their category colour, and the trips' GPX routes. A place you wrote an entry about is not drawn twice: the entry's pin carries a small check instead. A planned place is no proof of a visit.
- **Recorded**: the days of route the journey keeps and the live [Dawarich](Dawarich) route.
- **Entries**: the journal entries with a place, and the stays a day report was taken over from, each as a small dot where it was recorded, so a report that covers several places shows them all without an entry per place.
- **Photos**: the photos that know where they were taken.
Each switch shows how many things it holds; a layer with nothing to show is dimmed and says why, one that could not be loaded is marked and the rest of the map keeps working. Marks on one spot join into a disc with their number, and a click lists them. A click on a planned place or a stay opens a card with its trip, days and times and, where there is one, a button to the entry. The **i** button beside the switches explains every pin, dot and line. The layers work the same on a phone and on a shared journey.
The thin dashed line connecting entries in date order is something else and stays as it is: that one is drawn by TREK, while a track is a route.
@@ -120,7 +131,7 @@ What you should know:
- **Taking the same day over again keeps no second copy.** When Dawarich now holds a different version (a day that was still running, a recording that was fixed), the dialog says so and **Replace** swaps it. Replacing never touches entries or texts.
- **What you keep is what you saw.** If the recording changes in Dawarich while the dialog is open, nothing is kept and the dialog shows the new version first.
- **A failed read changes nothing.** The version the journey keeps stays as it is, and the dialog offers **Try again**.
- **Everyone on the journey sees the route**, also when an editor kept their own recording; the dialog says how many people that is. Shared links do not show recorded days.
- **Everyone on the journey sees the route**, also when an editor kept their own recording; the dialog says how many people that is. Shared links show recorded days only when the owner switches on **Recorded routes**.
- **Remove route** in the dialog takes the day off the journey again, also without a Dawarich connection. Dawarich keeps its own recording either way.
### Taking over a day with its stays
@@ -196,6 +207,8 @@ You can share a journey with a read-only public link. When creating the link you
Both toggles matter. A photo's coordinates follow the **Map** switch: with Map off the gallery is still shared, but every photo's coordinates are stripped before they leave the server. With Gallery off no photos are sent at all, so a map-only share shows the entry pins and nothing else.
**Trip plan** and **Recorded routes** add two more layers to the shared map, both off until you switch them on and both only while **Map** is on. Trip plan shares the planned places and GPX routes of the linked trips; Recorded routes shares the recorded days that have a published entry, which shows where you were, your accommodation too. Stays of published day reports show with the map; drafts and everything on them stay off the shared page.
## See also
- [Addons-Overview](Addons-Overview)