Compare commits

..

56 Commits

Author SHA1 Message Date
Maurice 309bef4db7 fix(ferratas): leaving 3D with via ferratas on no longer crashes, the removed map is not asked for its layers 2026-10-14 23:37:58 +02:00
Maurice 6e59240328 test(tours): follow the reworked planner, ferrata and map stage in their suites; keep the map's waypoint list stable between renders 2026-10-14 23:01:52 +02:00
Maurice bdb082d490 test(ferratas): a way without geometry really has none, the tile cache's columns join the NOT NULL count 2026-10-14 22:15:45 +02:00
Maurice 8957503dbe test(i18n): the tours locale count follows the ferrata and planner strings 2026-10-14 21:41:21 +02:00
Maurice 0a50119c52 chore(layout): lower the navbar's theme baseline to what it holds now 2026-10-14 21:24:15 +02:00
Maurice 1d090e0ac9 fix(tours): the 3D relief slider uses the app's range style 2026-10-14 21:14:28 +02:00
Maurice d118c3cee9 chore(tours): drop the planner strings nothing shows any more 2026-10-14 21:09:05 +02:00
Maurice e3f3b0458b style(tours): format the touched planner and map files 2026-10-14 20:54:44 +02:00
Maurice 976a612351 fix(tours): ferrata map tokens in a style module of their own, keep module-private helpers unexported 2026-10-14 20:44:53 +02:00
Maurice d87e276831 fix(tours): translate the ferrata badge everywhere, count the tile cache writes as standalone, settle the new test warnings 2026-10-14 20:34:20 +02:00
Maurice 62b50e3f9b fix(tours): a ferrata waypoint's number keeps the size of the others, its ring sits inside 2026-10-14 20:21:39 +02:00
Maurice 84d637f5fe feat(tours): via ferratas on the tour as fixed legs of their own line, crossing warnings, violet ferrata look, tour list and editor polish 2026-10-14 20:21:39 +02:00
Maurice 77c4785a9d fix(maps): the hover label on the GL maps goes away again when the pointer leaves a pin or the map moves 2026-10-14 20:21:39 +02:00
Maurice 6d43f3a4e6 feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass 2026-10-14 20:21:39 +02:00
Maurice a805b4b882 fix(maps): hover labels on the GL maps use the app font, not the map's Arial 2026-10-14 20:21:39 +02:00
Maurice e3983ef857 feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map 2026-10-14 20:21:39 +02:00
Maurice 160bc89e78 feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge 2026-10-14 20:21:38 +02:00
Maurice a9882a6415 feat(tours): via ferrata layer mounted on the tours map (2D and 3D) 2026-10-14 20:21:37 +02:00
Maurice 6b7461ee03 feat(planner): a floating variant of the location search in the map controls' frosted glass 2026-10-14 20:16:15 +02:00
Maurice 0eca20f2a4 feat(layout): in a trip the back arrow and the trip name share one pill beside the logo 2026-10-14 20:16:14 +02:00
Maurice 796bbb065d feat(layout): the trip name sits beside the logo as a badge instead of after a slash 2026-10-14 20:15:57 +02:00
Maurice 20d8bae46b test(tours): the wiring test follows the map stage's shared edit gate 2026-10-14 14:02:27 +02:00
Maurice 98cc57942b feat(tours): 3D terrain view with flyover and grade colours, imported tours carry no hiking difficulty
The tours map gets a 3D mode on the same map surface between the side panels:
a "3D" switch in the layer pill drapes the route over open Terrarium elevation
tiles (AWS Open Data), colours it by grade, plays a flyover tied to the
elevation profile in the rail, and keeps planning working by clicking and
dragging waypoints on the terrain. The corridor around the route is preloaded
before the flyover so the landscape never loads in front of the camera.

GPX imports no longer get a made-up hiking difficulty: max_hiking_difficulty
becomes nullable (new migration, waypoints kept), the import writes null and
the client shows no T badge for it. Drawn tours still default to T2.

Info hints in the planner show their tooltip only, and the discard
confirmation reads "Discard changes".
2026-10-14 14:02:27 +02:00
Maurice e7f2e17954 fix(ai-import): give the keeper search a starting flight 2026-10-14 13:24:27 +02:00
Maurice f746c22a83 test: keep the day plan sidebar test within its size baseline 2026-10-14 13:24:27 +02:00
Maurice 723bfa507b test: follow the note row's action wrapper and keep waitFor polling in the users refresh test 2026-10-14 13:24:27 +02:00
Maurice 3c8fa07f44 fix(admin): show when users were last active and keep the list current
The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
2026-10-14 13:24:27 +02:00
Maurice 4ceade9d8b fix(ai-import): read a long document's end and charge a shared total once (#2652, #2651)
Cloud models got the first 4,000 characters, so totals and lodging sections
further down never reached them, without a word. They now get 24,000 with the
start and the end kept and a warning when the middle is left out; a server
whose context is too small is asked once more with 8,000. A total the model
repeated on every flight of one booking stays on the first flight only, when
the document prints it once as a total, and the prompt says so too.
2026-10-14 13:24:27 +02:00
Maurice dae20c166a fix(admin): keep an Anthropic Messages gateway as the AI parsing base URL (#2655)
Both admin forms hid the field for Anthropic and the shared hook saved an
empty address, wiping a gateway configured on the server. The field shows for
every provider now and is saved as typed; switching provider empties it, so an
address meant for one never receives another provider's key.
2026-10-14 13:24:27 +02:00
Maurice 0d4ddb2089 fix(booking-import): join flights only on a layover that can be shown (#2653)
A missing arrival time skipped the time check, so a return listed first
merged into the outbound flight. The layover is now measured from the
arrival, or from the departure when there is none, must be forward and under
a day, and a leg landing anywhere the chain has been is a return.
2026-10-14 13:24:27 +02:00
Maurice 775b35d5af fix(dashboard): leave planned public transit out of upcoming reservations
Journeys from the transit search are routing, not bookings, and a day of them
pushed the real bookings out of the six slots. Booked trains and buses keep
their own types and stay. The MCP tool reads the same feed.
2026-10-14 13:24:27 +02:00
Maurice 76b7b305b2 fix(journey): do not count the suggested weather as an unsaved change
The entry dialog fills an empty weather from the forecast, and leaving it
untouched then asked to discard changes. The suggestion is not an edit. In
development StrictMode also dropped the suggestion before it arrived.
2026-10-14 13:24:27 +02:00
Maurice eb4e74b5c3 fix(forms): keep password managers out of a place's contact fields and the admin user forms
1Password filled the signed-in user's login into a place's e-mail field, and
offered the admin's own credentials for an account being created. The fields
now carry each manager's opt-out; autocomplete=off alone is ignored.
2026-10-14 13:24:27 +02:00
Maurice 10a987fec1 fix(files): list a hotel booking's files and files pinned to a stop on the place card
A hotel booking reaches its place through the stay, not its own place_id, so
the card listed the booking but not its confirmation. Links to a stop, which
MCP's link_trip_file and plugins can make, were never returned by the file
list at all; it now carries linked_assignment_ids. Without files the row no
longer opens to nothing.
2026-10-14 13:24:27 +02:00
Maurice 7c6b7622fc fix(planner): open the places filters across the whole sidebar row
Hung off an icon near the end of the row, the category and rating lists ran
out of the sidebar and were cut off.
2026-10-14 13:24:27 +02:00
Maurice 341520f98b fix(planner): give day plan rows their full width until the controls are wanted
The hover controls took their room at rest, so a place's line and a note's
text broke early. A place now gives way only while hovered, a note's controls
fade in over the end of its text in the note's own colour, and a picked place
shows none. All three controls of a place come out together: the add-booking
ticket waited for a React hover state and came a beat late. A click no longer
leaves them showing; keyboard focus and an open menu do.
2026-10-14 13:24:27 +02:00
Maurice 2fe8590c0b fix(planner): show a place photo again once its missing cache file is fetched back
A proxy image_url whose cached file was gone answered 204, and the self-heal
got the same URL back, so the list avatar kept its broken image until a
reload while the inspector showed the photo. The image is now mounted afresh
after the refetch; a second failure still ends on the category icon.
2026-10-14 13:24:27 +02:00
Maurice b3ef13616f fix(nav): put the prerelease badge beside the logo and never let the actions cover the tab pill
Both side columns keep their content width, so the pill gives way: it drops
its labels to icons when they no longer fit (measured, since plugins and
languages change the width) and scrolls only after that. The username is no
longer cut at 96px.
2026-10-14 13:24:27 +02:00
Maurice db531d2bef test(tours): the wiring test reads the map stage, the phone timeline test hands the planner its tours 2026-10-14 12:05:49 +02:00
Maurice f463c44d03 test(i18n): the tours locale count follows the three removed button keys 2026-10-14 11:22:16 +02:00
Maurice 07a82be64e fix(tours): short save and discard labels, info hints level with their label 2026-10-14 11:11:48 +02:00
Maurice 4efc50c012 chore(tours): keep the waypoint drag coordinate type module-private 2026-10-14 10:48:53 +02:00
Maurice 7a2846b3fa style(tours): format the tour service test 2026-10-14 10:41:21 +02:00
Maurice e87c68b811 test(tours): seed and read the tour tests through the row factories instead of raw SQL 2026-10-14 10:33:14 +02:00
Maurice 26833f374c fix(tours): keep the places repository at its size and format baselines 2026-10-14 10:23:22 +02:00
Maurice 7795061630 refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline 2026-10-14 10:00:08 +02:00
Maurice 09227ec0d1 fix(tours): the map search sits between the panels without a physical side, and the day row facts use the caption tier 2026-10-14 09:22:38 +02:00
Maurice dbee1664af fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev 2026-10-14 09:11:47 +02:00
Maurice 4c487ceefa fix(tours): keep the generated entity byte-identical and the strict baseline flat after the rebase onto dev 2026-10-14 08:49:47 +02:00
elmocito dd43999f1f fix(tours): resolve client lint warnings 2026-10-14 08:39:32 +02:00
elmocito 9a84890142 fix(tours): align generated Tour entity 2026-10-14 08:39:32 +02:00
elmocito eb13d9f785 fix(i18n): add Tours locale keys 2026-10-14 08:39:31 +02:00
elmocito 57ea219a43 feat(tours): complete planner follow-up scope
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
2026-10-14 08:39:31 +02:00
Maurice af9f013c5e docs(chart): NOTES lists every key the chart reads from existingSecret
SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY and DEMO_ADMIN_PASS were read but missing from the install notes; values.yaml already listed them.
2026-10-13 21:10:41 +02:00
Maurice f870dfc6d1 fix(server): the newer-database refusal also names a development build as the cause
A database a feature-branch build migrated carries migrations this build does not ship too, and was told it came from a newer TREK.
2026-10-13 21:10:41 +02:00
Maurice bf26175724 docs(server): say which builds refuse a migrated database and which do not
The pre-migrate snapshot docstring said an older release refuses a database a newer TREK migrated. Only images from 5.0 on do; 4.3.x and earlier start on it without a warning. server/CLAUDE.md said anything the legacy baseline cannot map refuses the boot, which holds only above step 258.
2026-10-13 21:10:41 +02:00
395 changed files with 22738 additions and 1519 deletions
+3 -2
View File
@@ -5,8 +5,9 @@
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
+1 -1
View File
@@ -3,7 +3,7 @@
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 876,
"no-console": 88,
"complexity": 231,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 148,
"trek/no-swallowed-catch": 133,
"max-params": 35,
-5
View File
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Tours/planner/TourPlannerPanels.tsx',
template: 'tours.planner.${role}',
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -8,7 +8,7 @@
"collection.ts": 35,
"common.ts": 7,
"dashboard.ts": 65,
"dawarich.ts": 16,
"dawarich.ts": 15,
"dayplan.ts": 18,
"docsync.ts": 25,
"files.ts": 4,
+5 -6
View File
@@ -5,14 +5,14 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1461,
"src/components/Map/MapView.tsx": 1450,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2425,
"src/components/Map/MapViewGL.tsx": 2414,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
"src/components/Planner/DayPlanSidebar.tsx": 2978,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2971,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
@@ -24,8 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1168,
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
"src/pages/TripPlannerPage.tsx": 1153,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+1 -1
View File
@@ -89,7 +89,7 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 11,
"src/components/Tours/tourParts.tsx": 8,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+1 -1
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 23,
"components/Layout/Navbar.tsx": 22,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
+33
View File
@@ -0,0 +1,33 @@
// FE-API-FERRATAS-001 to FE-API-FERRATAS-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { ferratasApi } from './ferratas';
const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
describe('ferratasApi.list', () => {
it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/ferratas', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900');
});
it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => {
server.use(
http.get('/api/trips/7/ferratas', () =>
HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 })
)
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy();
});
});
+25
View File
@@ -0,0 +1,25 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/ferratas.
*
* The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS
* (25 s by default) and answers 502 when none of them made it. The shared 8 s
* on `apiClient` would give up on a slow but healthy mirror and show an error
* the server was about to resolve, so this sits just above the server's own
* ceiling.
*/
const FERRATAS_TIMEOUT_MS = 30_000;
/**
* GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside
* one map area. In its own file rather than in the `client.ts` god module. The
* answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared
* contract before anything draws it.
*/
export const ferratasApi = {
list: (tripId: number, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
@@ -646,6 +646,8 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
+6 -8
View File
@@ -14,7 +14,7 @@ import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
@@ -265,7 +265,7 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving,
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
@@ -288,15 +288,13 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
{provider !== 'anthropic' && (
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</EditorField>
)}
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
@@ -64,6 +64,19 @@ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name:
export const MASKED = '••••••••';
export const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1';
/** What an empty Base URL stands for with each provider, shown as its placeholder. */
export const LLM_BASE_URL_PLACEHOLDER: Record<string, string> = {
local: DEFAULT_OLLAMA_URL,
openai: 'https://api.openai.com/v1',
// The Anthropic client appends /v1/messages itself, so a gateway is given without /v1.
anthropic: 'https://api.anthropic.com',
};
/** Under the Anthropic key: what only Anthropic can read, and what a gateway in its place needs (#2655). */
export const ANTHROPIC_HINT =
'Anthropic reads PDFs (including scans) natively. Local and OpenAI models receive extracted text, so scanned PDFs need Anthropic. ' +
'A gateway in place of api.anthropic.com goes in Base URL without /v1 and has to accept PDF document blocks and forced tool calls.';
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
@@ -52,15 +52,19 @@ describe('useLlmParsingConfig', () => {
expect(result.current.isInstalled('llama3')).toBe(false);
});
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => {
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
await act(() => result.current.loadModels());
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
// The OpenAI address does not follow to another provider (#2655).
act(() => result.current.setProvider('local'));
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1'));
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
act(() => result.current.setBaseUrl(' http://box:11434/v1 '));
await act(() => result.current.loadModels());
expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1');
});
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
@@ -148,7 +152,7 @@ describe('useLlmParsingConfig', () => {
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => {
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
act(() => {
result.current.setProvider('anthropic');
@@ -160,6 +164,50 @@ describe('useLlmParsingConfig', () => {
});
});
it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' }))
);
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({
provider: 'anthropic',
baseUrl: 'http://gateway.lan:4000',
});
});
it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
act(() => result.current.setProvider('anthropic'));
act(() => result.current.setBaseUrl(' http://gw:4000 '));
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' });
});
it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' }))
);
act(() => result.current.setProvider('local'));
expect(result.current.baseUrl).toBe('');
act(() => result.current.setProvider('openai'));
expect(result.current.baseUrl).toBe('');
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
act(() => result.current.setBaseUrl('http://typed:4000'));
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrl).toBe('http://typed:4000');
});
it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1');
act(() => result.current.setProvider('openai'));
expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1');
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com');
});
it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => {
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
@@ -4,7 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
/**
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
@@ -15,7 +15,7 @@ import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
export function useLlmParsingConfig(addon: Addon) {
const toast = useToast();
const cfg = addon.config ?? {};
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local');
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
@@ -30,6 +30,18 @@ export function useLlmParsingConfig(addon: Addon) {
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
/**
* A different provider never inherits the last one's endpoint: an Ollama address
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
* saved provider, its saved address returns. Any provider, Anthropic included, may
* keep an address the admin typed for it (#2655); empty means the provider default.
*/
const setProvider = (next: string) => {
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
@@ -89,7 +101,7 @@ export function useLlmParsingConfig(addon: Addon) {
config: {
provider,
model: model.trim(),
baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(),
baseUrl: baseUrl.trim(),
apiKey,
vision,
},
@@ -109,6 +121,7 @@ export function useLlmParsingConfig(addon: Addon) {
setModel,
baseUrl,
setBaseUrl,
baseUrlPlaceholder,
apiKey,
setApiKey,
vision,
@@ -0,0 +1,115 @@
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-009: the card for one picked via ferrata.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailCard from './FerrataDetailCard';
import { type FerrataGroup, groupFerratas } from './ferrataGroups';
/** About 222 m of latitude per segment. */
const segment = (id: number, from: number, overrides: Partial<Ferrata> = {}): Ferrata => ({
id: `way/${id}`,
name: 'Via ferrata Pisciadù',
grade: { raw: '5', level: 5, plus: '+' },
routeName: 'Sella group',
geometry: [
[from, 11.81],
[from + 0.002, 11.81],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
...overrides,
});
const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } });
const UPPER = segment(78, 46.532);
function groupOf(...segments: Ferrata[]): FerrataGroup {
const [group] = groupFerratas(segments);
if (!group) throw new Error('no group');
return group;
}
describe('FerrataDetailCard', () => {
it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument();
expect(screen.getByText('Sella group')).toBeInTheDocument();
// A badge, not a line colour: the grade is neutral, never the tour's green to red.
expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary');
expect(screen.getByText('445 m')).toBeInTheDocument();
expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument();
});
it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
const link = screen.getByRole('link', { name: /View in OpenStreetMap/ });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => {
const unnamed = segment(77, 46.53, { name: null });
render(<FerrataDetailCard group={groupOf(unnamed)} segment={unnamed} />);
expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument();
expect(screen.getAllByText('Sella group')).toHaveLength(1);
});
it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => {
const { rerender } = render(<FerrataDetailCard group={groupOf(UPPER)} segment={UPPER} />);
expect(screen.getByText('K5+')).toBeInTheDocument();
const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(letter)} segment={letter} />);
expect(screen.getByText('C/D')).toHaveClass('text-content-secondary');
const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(bare)} segment={bare} />);
expect(screen.getByText('Grade unknown')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument();
});
it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => {
const onClose = vi.fn();
const group = groupOf(UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
rerender(<FerrataDetailCard group={group} segment={UPPER} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => {
const { container } = render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={LOWER} />);
expect(container.textContent).not.toContain('·');
});
it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => {
const long = 'Herrmann von Barth Klettersteig über den Wetterstein';
render(<FerrataDetailCard group={groupOf(segment(79, 46.6, { name: long }))} segment={UPPER} />);
const heading = screen.getByRole('heading', { name: long });
expect(heading).toHaveClass('line-clamp-2');
expect(heading).not.toHaveClass('truncate');
});
it('FE-FERRATA-CARD-008: offers "Add to tour" only where the tour can be edited, and hands over the ferrata', () => {
const group = groupOf(LOWER, UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
const onAdopt = vi.fn();
rerender(<FerrataDetailCard group={group} segment={UPPER} onAdopt={onAdopt} />);
fireEvent.click(screen.getByTestId('ferrata-adopt'));
expect(onAdopt).toHaveBeenCalledWith(group);
});
it('FE-FERRATA-CARD-009: a ferrata the tour already climbs says so instead of offering it again', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} onAdopt={vi.fn()} onTour />);
expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour');
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,125 @@
import { Check, ExternalLink, MapPinPlus, MoveHorizontal, Route, X } from 'lucide-react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
import { SoftPill } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/** The map controls' frosted glass, so the card reads as one of them. */
const MAP_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
};
/**
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
* `segment` is the OSM way that was clicked, which the source link opens.
* OpenStreetMap's data is ODbL, which asks for the attribution next to
* anything shown from it, so the card carries it rather than relying on the
* map's own corner.
*/
export default function FerrataDetailCard({
group,
segment,
onClose,
onAdopt,
onTour = false,
width,
}: {
group: FerrataGroup;
segment: Ferrata;
onClose?: () => void;
/** Puts the ferrata onto the tour being planned; absent where the tour cannot be edited. */
onAdopt?: (group: FerrataGroup) => void;
/** The tour on the map already climbs this ferrata: the card says so instead of offering it again. */
onTour?: boolean;
/** Set when the card stands on another control and takes its width. */
width?: number;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit);
const label = ferrataLabel(group, t);
const name = label.name;
// The route line only adds something when it is not already the title.
const route = group.routeName && group.routeName !== name ? group.routeName : null;
// Without any grade the badge says so, rather than leaving a gap.
const grade = label.grade ?? t('ferratas.unknownGrade');
return (
<article
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_GLASS, width }}
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
{name}
</h3>
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<SoftPill>{grade}</SoftPill>
{group.lengthM > 0 && (
<SoftPill icon={<MoveHorizontal size={11} aria-hidden="true" />}>
{formatDistance(group.lengthM / 1000, unit ?? 'metric')}
</SoftPill>
)}
</div>
{onTour ? (
<div className="mt-3 flex" data-testid="ferrata-on-tour">
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
{t('ferratas.onTour')}
</SoftPill>
</div>
) : (
onAdopt && (
<button
type="button"
onClick={() => onAdopt(group)}
data-testid="ferrata-adopt"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(12, 'body')}
>
<MapPinPlus size={13} strokeWidth={2.2} aria-hidden="true" />
{t('ferratas.adopt')}
</button>
)
)}
<a
href={ferrataOsmUrl(segment)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
style={fs(12, 'body')}
>
{t('ferratas.viewInOsm')}
<ExternalLink size={13} aria-hidden="true" />
</a>
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
{t('ferratas.attribution')}
</p>
</article>
);
}
@@ -0,0 +1,77 @@
// FE-FERRATA-OVERLAY-001 to FE-FERRATA-OVERLAY-004: where the picked ferrata's card sits over the map, and how it closes.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailOverlay from './FerrataDetailOverlay';
import { groupFerratas } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
const FERRATA: Ferrata = {
id: 'way/42',
name: 'Via delle Trincee',
grade: { raw: '4', level: 4, plus: null },
routeName: null,
geometry: [
[46.52, 11.8],
[46.53, 11.81],
],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const [GROUP] = groupFerratas([FERRATA]);
if (!GROUP) throw new Error('no group');
const SELECTION: FerrataSelection = { group: GROUP, segment: FERRATA };
describe('FerrataDetailOverlay', () => {
it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => {
render(<FerrataDetailOverlay selection={null} onClose={vi.fn()} insetEnd={400} bottom={66} />);
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
});
it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => {
render(<FerrataDetailOverlay selection={SELECTION} onClose={vi.fn()} insetEnd={400} bottom={66} />);
const overlay = screen.getByTestId('ferrata-detail-overlay');
expect(overlay.style.insetInlineEnd).toBe('400px');
// Logical only: no physical side, so a right-to-left page mirrors it.
expect(overlay.style.right).toBe('');
expect(overlay.style.left).toBe('');
expect(overlay.style.bottom).toBe('66px');
expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card'));
});
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => {
const onClose = vi.fn();
const onOutsidePress = vi.fn();
render(
<div>
<button type="button">map</button>
<FerrataDetailOverlay
selection={SELECTION}
onClose={onClose}
onOutsidePress={onOutsidePress}
insetEnd={400}
bottom={66}
/>
</div>
);
fireEvent.pointerDown(screen.getByText('Via delle Trincee'));
expect(onClose).not.toHaveBeenCalled();
expect(onOutsidePress).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOutsidePress).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => {
const onClose = vi.fn();
render(<FerrataDetailOverlay selection={SELECTION} onClose={onClose} insetEnd={400} bottom={66} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataGroup } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
/**
* The picked ferrata's card over the map, anchored at the bottom of the inline
* end, above the row of map controls. `insetEnd` is what the panel on that
* side covers plus the gap the layer pill keeps to the panel on the other, so
* the card mirrors the pill and never slides under the panel; `bottom` clears
* the control row (the layer pill on the flat map, the dock and the grade
* legend in 3D).
*
* It closes on a press anywhere outside it and on Escape. A press on another
* ferrata closes this card first and the click then opens that one's. The
* press outside is handed to `onOutsidePress` as well, so the map under it can
* leave the click that follows alone (see `useOutsidePressGuard`).
*/
export default function FerrataDetailOverlay({
selection,
onClose,
onOutsidePress,
insetEnd,
bottom,
width,
onAdopt,
onTourIds,
}: {
selection: FerrataSelection | null;
onClose: () => void;
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
width?: number;
onAdopt?: (group: FerrataGroup) => void;
/** The ferratas the tour on the map already climbs. */
onTourIds?: ReadonlySet<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
const card = ref.current;
if (card && event.target instanceof Node && card.contains(event.target)) return;
onOutsidePress?.();
onClose();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
};
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, onOutsidePress]);
if (!selection) return null;
return (
<div
ref={ref}
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="ferrata-detail-overlay"
>
<FerrataDetailCard
group={selection.group}
segment={selection.segment}
onClose={onClose}
onAdopt={onAdopt}
onTour={onTourIds?.has(selection.group.id) ?? false}
width={width}
/>
</div>
);
}
@@ -0,0 +1,42 @@
/**
* The via ferrata mark as a React element: the ladder on a disc that the map
* draws at a ferrata's midpoint, in the same `--map-ferrata` tokens, for the
* places beside the map that point at one (a waypoint at its foot or top).
*/
export function FerrataGlyph({ size = 14, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 22 22" aria-hidden="true" className={className}>
<circle cx="11" cy="11" r="9.75" fill="var(--map-ferrata-casing)" stroke="var(--map-ferrata)" strokeWidth="1.5" />
<path
d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6"
fill="none"
stroke="var(--map-ferrata)"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/**
* The same ladder as a line icon, drawn the way lucide draws its icons (24 grid,
* round caps, the current colour), for controls that sit among lucide icons:
* the via ferrata switch in the map's layer pill.
*/
export function FerrataLineIcon({ size = 15, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3v18M16 3v18M8 7h8M8 12h8M8 17h8" />
</svg>
);
}
@@ -0,0 +1,24 @@
import type { Map } from 'mapbox-gl';
import type { FerrataGroup } from './ferrataGroups';
import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL';
/**
* The via ferrata layer as a mountable piece for a GL map view: it renders
* nothing itself, the lines live on `map`. See useFerrataLayerGL for the
* layering, the highlight and the click handling.
*/
export default function FerrataLayerGL({
map,
groups,
onSelect,
selectedId = null,
}: {
map: Map | null;
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
useFerrataLayerGL(map, groups, onSelect, selectedId);
return null;
}
@@ -0,0 +1,269 @@
/**
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on
* the Leaflet renderer.
*
* Leaflet itself is not under test: the mock reflects each polyline's and
* marker's props back as data attributes, so the assertions read which lines
* are drawn, in which order and with what options. Casing and line stay
* non-interactive so they cannot swallow clicks meant for places; only the
* invisible hit line on top carries the tooltip, the hover and the click.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerLeaflet from './FerrataLayerLeaflet';
import { groupFerratas } from './ferrataGroups';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.values(params).join(',')})` : key,
}),
}));
const zoom = { value: 12 };
const zoomEnd: { handler: (() => void) | null } = { handler: null };
type Handlers = Record<string, ((event: { latlng: { lat: number; lng: number } }) => void) | undefined>;
const at = { latlng: { lat: 46.505, lng: 11.705 } };
vi.mock('react-leaflet', () => ({
Polyline: ({
positions,
pathOptions,
interactive,
bubblingMouseEvents,
eventHandlers,
children,
}: {
positions: unknown;
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string };
interactive?: boolean;
bubblingMouseEvents?: boolean;
eventHandlers?: Handlers;
children?: ReactNode;
}) => (
<div
data-testid="polyline"
data-positions={JSON.stringify(positions)}
data-color={pathOptions.color}
data-weight={pathOptions.weight}
data-opacity={pathOptions.opacity}
data-dash={pathOptions.dashArray ?? ''}
data-class={pathOptions.className ?? ''}
data-interactive={interactive === false ? 'false' : 'true'}
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
onClick={() => eventHandlers?.click?.(at)}
onMouseOver={() => eventHandlers?.mouseover?.(at)}
onMouseOut={() => eventHandlers?.mouseout?.(at)}
>
{children}
</div>
),
Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => (
<button
type="button"
data-testid="ferrata-icon"
data-position={JSON.stringify(position)}
onClick={() => eventHandlers?.click?.(at)}
/>
),
Tooltip: ({ children, className }: { children: ReactNode; className?: string }) => (
<span data-testid="tooltip" data-class={className ?? ''}>
{children}
</span>
),
// A pixel per 0.00001 degree: enough to tell icons apart that sit a few metres from each other.
useMap: () => ({
getZoom: () => zoom.value,
project: ([lat, lng]: [number, number]) => ({ x: lng * 100_000, y: -lat * 100_000 }),
}),
useMapEvents: (handlers: { zoomend?: () => void }) => {
zoomEnd.handler = handlers.zoomend ?? null;
return null;
},
}));
const ferrata = (
id: string,
name: string | null,
level: number | null,
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
): Ferrata => {
const lat = options.lat ?? 46.5;
const lng = options.lng ?? 11.7;
return {
id,
name,
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
routeName: options.routeName ?? null,
geometry: [
[lat, lng],
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
};
};
const lines = () => screen.getAllByTestId('polyline');
beforeEach(() => {
zoom.value = 12;
});
describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => {
const { container } = render(<FerrataLayerLeaflet groups={[]} onSelect={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
root.style.setProperty('--map-ferrata-casing', 'white');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
expect(lines().map((line) => line.dataset.color)).toEqual(['white', 'black', 'white']);
expect(lines().map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
expect(lines()[1]?.dataset.dash).toBe('6 5');
expect(lines()[2]?.dataset.opacity).toBe('0');
expect(JSON.parse(lines()[0]?.dataset.positions ?? '[]')).toEqual([
[46.5, 11.7],
[46.51, 11.71],
]);
root.style.removeProperty('--map-ferrata');
root.style.removeProperty('--map-ferrata-casing');
});
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
const groups = groupFerratas([
ferrata('way/1', 'Pisciadù', 2),
// Picks up where way/1 ends: one ferrata in two segments.
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
ferrata('way/4', null, null, { lat: 48 }),
]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([
'Pisciadùferratas.gradeRange(K2,K4+)',
'Pisciadùferratas.gradeRange(K2,K4+)',
'Sentiero attrezzatoK3',
'ferratas.unnamed',
]);
});
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const onSelect = vi.fn();
const target = ferrata('way/9', 'Tridentina', 2);
const groups = groupFerratas([target]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
const hit = lines()[2];
if (!hit) throw new Error('no hit line');
fireEvent.click(hit);
expect(onSelect).toHaveBeenCalledWith(groups[0], target, { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-005: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[2]?.dataset.bubbles).toBe('false');
});
it('FE-FERRATA-LEAFLET-006: the grade sits beside the name as its own label, without a separator', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 4)])} onSelect={vi.fn()} />);
const tip = screen.getByTestId('tooltip');
expect(tip.textContent).not.toContain('·');
expect(tip.querySelector('span')?.textContent).toBe('K4');
});
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the deeper violet, and so does the open card', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata-lit', 'navy');
const groups = groupFerratas([
ferrata('way/1', 'Via A', 2),
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
ferrata('way/3', 'Via B', 3, { lat: 47 }),
]);
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
// Casings 0 to 2, lines 3 to 5, hit lines 6 to 8.
const lineColours = () =>
lines()
.slice(3, 6)
.map((line) => line.dataset.color);
expect(lineColours()).not.toContain('navy');
const hit = lines()[7];
if (!hit) throw new Error('no hit line');
fireEvent.mouseOver(hit);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([true, true, false]);
fireEvent.mouseOut(hit);
expect(lineColours()).not.toContain('navy');
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
root.style.removeProperty('--map-ferrata-lit');
});
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
const onSelect = vi.fn();
const groups = groupFerratas([ferrata('way/1', 'Via A', 2), ferrata('way/2', 'Via B', 3, { lat: 47 })]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
expect(screen.queryAllByTestId('ferrata-icon')).toHaveLength(0);
zoom.value = 14;
act(() => zoomEnd.handler?.());
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(2);
const [lat, lng] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.505, 4);
expect(lng).toBeCloseTo(11.705, 4);
fireEvent.click(icons[1] as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(groups[1], groups[1]?.segments[0], { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[1]?.dataset.color).toBe('black');
await act(async () => {
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
root.style.removeProperty('--map-ferrata');
});
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[0]?.id ?? null} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['9', '4', '16']);
expect(lines()[1]?.dataset.dash).toBe('');
expect(lines()[2]?.dataset.class).toBe('trek-ferrata-hit');
expect(screen.getByTestId('tooltip').dataset.class).toBe('map-tooltip');
});
it('FE-FERRATA-LEAFLET-011: of two icons that would touch only the longer ferrata keeps its own', () => {
const short = ferrata('way/1', 'Via A', 2);
const long: Ferrata = {
...ferrata('way/2', 'Via B', 3, { lat: 46.5001 }),
geometry: [
[46.4951, 11.695],
[46.5151, 11.715],
],
};
const groups = groupFerratas([short, long]);
zoom.value = 14;
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(1);
const [lat] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.5051, 4);
});
});
@@ -0,0 +1,185 @@
import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import type { FerrataMapLayer } from './useFerrataMapLayer';
import { useFerrataPaint } from './useFerrataPaint';
/**
* The route icon for Leaflet: the shared SVG in a divIcon, painted straight
* from the tokens, so a theme change restyles it without rebuilding anything.
* Built once; it carries no user content.
*/
/** The hit line's class, which index.css keeps free of the focus ring a clicked path draws. */
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
const ICON = L.divIcon({
html: ferrataIconSvg({ line: 'var(--map-ferrata)', casing: 'var(--map-ferrata-casing)' }),
className: 'trek-ferrata-icon',
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
});
/**
* The ferratas whose icon shows: none below FERRATA_ICON_MIN_ZOOM, and above
* it only those whose icon touches no other one at this zoom, the longer
* ferrata first. Leaflet has no symbol placement of its own, so this is what
* GL's `icon-allow-overlap: false` does there. Positions are projected at the
* zoom, which a pan does not change, so only a zoom or new ferratas place again.
*/
function useIconGroups(groups: readonly FerrataGroup[]): readonly FerrataGroup[] {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
return useMemo(() => {
if (zoom < FERRATA_ICON_MIN_ZOOM) return [];
const longestFirst = [...groups].sort((a, b) => b.lengthM - a.lengthM);
const shown = placeFerrataIcons(
longestFirst.map((group) => {
const { x, y } = map.project(group.midpoint, zoom);
return { id: group.id, x, y };
})
);
return groups.filter((group) => shown.has(group.id));
}, [map, groups, zoom]);
}
/** The tooltip's words: the ferrata's name and its grade. */
function FerrataTooltipText({ group }: { group: FerrataGroup }) {
const { t } = useTranslation();
const { name, grade } = ferrataLabel(group, t);
return (
<>
{name}
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
</>
);
}
/**
* The via ferrata layer on the Leaflet renderer, in the same construction as
* the GL layer: per OSM way a light casing and the dark dashed line, the whole
* ferrata lit (solid, wider, on a wider casing, in the highlight colour) while
* the pointer is on it or its card is open, a sticky tooltip in the map's
* tooltip look naming it with its grade, and from FERRATA_ICON_MIN_ZOOM a
* small route icon halfway along it where it does not touch another one. A
* click hands the ferrata and the way to `onSelect`, like a click on the GL
* layer does.
*/
export default function FerrataLayerLeaflet({
groups,
onSelect,
selectedId = null,
}: {
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
const lines = useMemo(() => {
const casing = { color: paint.casing, weight: 6, opacity: 0.9, lineCap: 'round', lineJoin: 'round' } as const;
const litCasing = { ...casing, weight: 9, opacity: 1 } as const;
const line = {
color: paint.line,
weight: 2.5,
opacity: 1,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
} as const;
// Solid: an empty dash list clears the dashes Leaflet would otherwise keep from the line's options.
const highlight = {
color: paint.highlight,
weight: 4,
opacity: 1,
dashArray: '',
lineCap: 'round',
lineJoin: 'round',
} as const;
// The class lets the stylesheet drop the focus ring a clicked SVG path keeps; the card has its own focus.
const hit = { color: paint.casing, weight: 16, opacity: 0, className: FERRATA_HIT_CLASS } as const;
return groups.flatMap((group) =>
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
casing,
litCasing,
line,
highlight,
hit,
}))
);
}, [groups, paint]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
return (
<>
{lines.map(({ group, segment, positions, casing, litCasing }) => (
<Polyline
key={`${segment.id}-casing`}
positions={positions}
pathOptions={lit(group) ? litCasing : casing}
interactive={false}
/>
))}
{lines.map(({ group, segment, positions, line, highlight }) => (
<Polyline
key={`${segment.id}-line`}
positions={positions}
pathOptions={lit(group) ? highlight : line}
interactive={false}
/>
))}
{/* A wide, invisible line on top carries the tooltip, the hover and the click, so a
thin dashed route is still easy to hit with a finger. The click stops here: it
opens the ferrata's card and must not also drop a control point under it. */}
{lines.map(({ group, segment, positions, hit }) => (
<Polyline
key={`${segment.id}-hit`}
positions={positions}
pathOptions={hit}
bubblingMouseEvents={false}
eventHandlers={{
click: (event) => onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat }),
mouseover: () => setHovered(group.id),
mouseout: () => setHovered((current) => (current === group.id ? null : current)),
}}
>
<Tooltip sticky direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
<FerrataTooltipText group={group} />
</Tooltip>
</Polyline>
))}
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
click: (event) => {
const segment = group.segments[0];
if (segment) onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat });
},
}}
/>
))}
</>
);
}
/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */
export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) {
if (!layer?.active) return null;
return <FerrataLayerLeaflet groups={layer.groups} onSelect={layer.onSelect} selectedId={layer.selectedId} />;
}
@@ -0,0 +1,123 @@
// FE-FERRATA-CROSS-001 to FE-FERRATA-CROSS-009: where a tour's line climbs a via ferrata, and the tiles along it.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { crossingLegs, ferrataEnds, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import { groupFerratas } from './ferrataGroups';
/** About 11 m of latitude. */
const STEP = 0.0001;
function ferrata(id: number, from: [number, number], steps: number): Ferrata {
return {
id: `way/${id}`,
name: 'Via Test',
routeName: null,
grade: { raw: '3', level: 3, plus: null },
geometry: Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]] as [number, number]),
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
/** A line north from a point, one point every STEP. */
function northFrom(from: [number, number], steps: number): [number, number][] {
return Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]]);
}
describe('routeFerrataCrossings', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 20)]);
it('FE-FERRATA-CROSS-001: a route climbing along the ferrata gives one stretch, measured along the route', () => {
const route = [...northFrom([47.399, 11.0], 9), ...northFrom([47.4, 11.0], 20)];
const [stretch, ...rest] = routeFerrataCrossings(route, groups);
expect(rest).toEqual([]);
expect(stretch.group.id).toBe(groups[0].id);
expect(stretch.fromM).toBeGreaterThan(95);
expect(stretch.toM - stretch.fromM).toBeGreaterThan(200);
});
it('FE-FERRATA-CROSS-002: a trail that only crosses the ferrata is no stretch', () => {
const across: [number, number][] = Array.from({ length: 21 }, (_, i) => [47.401, 10.999 + i * STEP]);
expect(routeFerrataCrossings(across, groups)).toEqual([]);
});
it('FE-FERRATA-CROSS-003: a route nowhere near a ferrata, or no route, gives nothing', () => {
expect(routeFerrataCrossings(northFrom([46.0, 10.0], 20), groups)).toEqual([]);
expect(routeFerrataCrossings(null, groups)).toEqual([]);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 20), [])).toEqual([]);
});
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
// Two pieces of one ferrata with 44 m missing between them: the route point
// in the middle of the hole is out of reach, the ones beside it are not.
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
expect(pieces).toHaveLength(1);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
});
});
describe('routeFerrataCrossings at the end of a ferrata', () => {
it('FE-FERRATA-CROSS-007: a route that only runs beside the end of a long ferrata is no stretch', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 40)]);
// Ten points (about 100 m) along the ferrata's top, then away.
const route = [...northFrom([47.4033, 11.0], 9), ...northFrom([47.405, 11.0], 10)];
expect(routeFerrataCrossings(route, groups)).toEqual([]);
});
});
describe('routeFerrataCrossings beside a second ferrata', () => {
it('FE-FERRATA-CROSS-008: where two ferratas run a few metres apart, the route belongs to the nearer one', () => {
const near = ferrata(1, [47.4, 11.0], 30);
// A second ferrata about 15 m to the east, along the same stretch.
const beside = { ...ferrata(2, [47.4, 11.0002], 30), name: 'Via Beside' };
const groups = groupFerratas([beside, near]);
const [stretch, ...rest] = routeFerrataCrossings(northFrom([47.4, 11.0], 30), groups);
expect(rest).toEqual([]);
expect(stretch.group.name).toBe('Via Test');
});
});
describe('crossingLegs', () => {
it('FE-FERRATA-CROSS-009: places waypoints in order along a route that passes the same spot twice, and a stretch by its middle', () => {
// Out 300 m north and back: the turn point is waypoint b, and a and c stand at the same spot.
const samples = [0, 100, 200, 300, 400, 500, 600].map((distanceMeters, i) => ({
distanceMeters,
lat: 47.4 + (i <= 3 ? i : 6 - i) * 0.0009,
lng: 11,
}));
const waypoints = [
{ id: 'a', lat: 47.4, lng: 11 },
{ id: 'b', lat: 47.4027, lng: 11 },
{ id: 'c', lat: 47.4, lng: 11 },
];
expect(
crossingLegs(waypoints, samples, [
{ fromM: 50, toM: 250 },
{ fromM: 280, toM: 520 },
])
).toEqual(['a', 'b']);
expect(crossingLegs([], samples, [{ fromM: 50, toM: 80 }])).toEqual([null]);
});
});
describe('ferrataTilesAlong', () => {
it('FE-FERRATA-CROSS-005: names each grid tile the route passes once, and gives up past the cap', () => {
const tiles = ferrataTilesAlong(northFrom([47.4, 11.0], 20));
expect(tiles).toHaveLength(1);
const long: [number, number][] = Array.from({ length: 400 }, (_, i) => [40 + i * 0.06, 11]);
expect(ferrataTilesAlong(long)).toBeNull();
expect(ferrataTilesAlong(null)).toEqual([]);
});
});
describe('ferrataEnds', () => {
it('FE-FERRATA-CROSS-006: the two ends farthest apart, across the segments of one ferrata', () => {
const [group] = groupFerratas([ferrata(1, [47.4, 11.0], 5), ferrata(2, [47.4005, 11.0], 5)]);
const ends = ferrataEnds(group);
expect(ends).not.toBeNull();
const lats = ends ? ends.map(([lat]) => lat).sort() : [];
expect(lats[0]).toBeCloseTo(47.4, 6);
expect(lats[1]).toBeCloseTo(47.401, 6);
});
});
@@ -0,0 +1,270 @@
import { ferrataTileAt, ferrataTileKey, type FerrataTile } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* Where a tour's line runs along a via ferrata. The router walks a ferrata
* like any trail once the difficulty cap allows alpine routes (T4 and up), so
* a tour can climb one without anyone having picked it; this finds those
* stretches on the line the planner shows, for the warning badge and the band
* on the elevation profile.
*/
export interface FerrataCrossing {
group: FerrataGroup;
/** Where the stretch starts and ends, in metres along the route, as the elevation profile counts them. */
fromM: number;
toM: number;
}
/** How close a route point has to come to a ferrata to count as on it. */
const ON_FERRATA_M = 20;
/**
* A shorter run is a trail crossing the ferrata or meeting it at an end, not a
* climb along it. A ferrata shorter than that counts once most of it is walked.
*/
const MIN_STRETCH_M = 150;
const MIN_SHARE_OF_SHORT_FERRATA = 0.6;
/** Two stretches on the same ferrata this close together are one: a gap in the data, not a way off it. */
const JOIN_GAP_M = 30;
/** The most grid tiles a route asks for, about a thousand kilometres of line. */
const MAX_ROUTE_TILES = 200;
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: readonly [number, number], [lat2, lng2]: readonly [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
/** The distance from a point to a polyline, in metres, on a flat projection around the point (fine at these scales). */
function distanceToLineM(point: readonly [number, number], line: readonly [number, number][]): number {
const metresPerLat = EARTH_RADIUS_M * RAD;
const metresPerLng = metresPerLat * Math.cos(point[0] * RAD);
const project = ([lat, lng]: readonly [number, number]) => [
(lng - point[1]) * metresPerLng,
(lat - point[0]) * metresPerLat,
];
let best = Number.POSITIVE_INFINITY;
for (let i = 1; i < line.length; i += 1) {
const [ax, ay] = project(line[i - 1]);
const [bx, by] = project(line[i]);
const dx = bx - ax;
const dy = by - ay;
const span = dx * dx + dy * dy;
const t = span === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / span));
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
}
if (line.length === 1) {
const [x, y] = project(line[0]);
best = Math.hypot(x, y);
}
return best;
}
/** Whether a point lies inside a group's box widened by the reach. */
function nearBox(point: readonly [number, number], group: FerrataGroup): boolean {
const padLat = ON_FERRATA_M / (EARTH_RADIUS_M * RAD);
const padLng = padLat / Math.max(0.1, Math.cos(point[0] * RAD));
const { south, north, west, east } = group.bbox;
return (
point[0] >= south - padLat && point[0] <= north + padLat && point[1] >= west - padLng && point[1] <= east + padLng
);
}
/** The ferrata a route point is on, if any. */
function ferrataAt(
point: readonly [number, number],
groups: readonly FerrataGroup[],
lines: ReadonlyMap<string, [number, number][][]>
): FerrataGroup | null {
// The nearest one within reach: two ferratas often start a few metres apart at the same foot.
let nearest: FerrataGroup | null = null;
let nearestM = ON_FERRATA_M;
for (const group of groups) {
if (!nearBox(point, group)) continue;
for (const line of lines.get(group.id) ?? []) {
const gap = distanceToLineM(point, line);
if (gap <= nearestM) {
nearestM = gap;
nearest = group;
}
}
}
return nearest;
}
/**
* The stretches of the route that run along a ferrata, in route order. A
* stretch is measured between the route points that lie on the ferrata, so a
* trail that only crosses one or meets its foot stays out.
*/
export function routeFerrataCrossings(
route: readonly (readonly [number, number])[] | null | undefined,
groups: readonly FerrataGroup[]
): FerrataCrossing[] {
if (!route || route.length < 2 || groups.length === 0) return [];
const lines = new Map(groups.map((group) => [group.id, group.segments.map(ferrataPositions)]));
const stretches: FerrataCrossing[] = [];
let along = 0;
let open: FerrataCrossing | null = null;
for (let i = 0; i < route.length; i += 1) {
if (i > 0) along += distanceM(route[i - 1], route[i]);
const group = ferrataAt(route[i], groups, lines);
if (open && group === open.group) {
open.toM = along;
continue;
}
if (open) stretches.push(open);
open = group ? { group, fromM: along, toM: along } : null;
}
if (open) stretches.push(open);
const joined: FerrataCrossing[] = [];
for (const stretch of stretches) {
const last = joined[joined.length - 1];
if (last && last.group === stretch.group && stretch.fromM - last.toM <= JOIN_GAP_M) last.toM = stretch.toM;
else joined.push({ ...stretch });
}
return joined.filter(
(stretch) =>
stretch.toM - stretch.fromM >= Math.min(MIN_STRETCH_M, stretch.group.lengthM * MIN_SHARE_OF_SHORT_FERRATA)
);
}
/** The grid tiles the route runs through, so the ferratas along it can be loaded with the layer off; null past the cap. */
export function ferrataTilesAlong(
route: readonly (readonly [number, number])[] | null | undefined
): FerrataTile[] | null {
if (!route || route.length < 2) return [];
const tiles = new Map<string, FerrataTile>();
for (const [lat, lng] of route) {
const tile = ferrataTileAt(lat, lng);
tiles.set(ferrataTileKey(tile), tile);
if (tiles.size > MAX_ROUTE_TILES) return null;
}
return [...tiles.values()];
}
/** The two ends of a ferrata that lie farthest apart, the start and the top of the climb in either order. */
export function ferrataEnds(group: FerrataGroup): [[number, number], [number, number]] | null {
const ends = group.segments.flatMap((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return first && last ? [first, last] : [];
});
let best: [[number, number], [number, number]] | null = null;
let bestM = -1;
for (let i = 0; i < ends.length; i += 1) {
for (let j = i + 1; j < ends.length; j += 1) {
const span = distanceM(ends[i], ends[j]);
if (span > bestM) {
bestM = span;
best = [ends[i], ends[j]];
}
}
}
return best;
}
/** The widest gap two pieces of one ferrata may leave between them and still be walked as one line. */
const CHAIN_GAP_M = 80;
/**
* The ferrata as one line from `from` (one of its ends) to its other end: its
* OSM pieces, which arrive in any order and either direction, put end to end,
* each turned the way the walk goes. Null when the pieces do not join up.
*/
export function ferrataLine(group: FerrataGroup, from: [number, number]): [number, number][] | null {
const pieces = group.segments.map((segment) => ferrataPositions(segment)).filter((line) => line.length >= 2);
if (pieces.length === 0) return null;
const line: [number, number][] = [];
let tail = from;
while (pieces.length > 0) {
let bestIndex = -1;
let bestReversed = false;
let bestGap = Number.POSITIVE_INFINITY;
pieces.forEach((piece, index) => {
const startGap = distanceM(tail, piece[0]);
const endGap = distanceM(tail, piece[piece.length - 1]);
if (startGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, false, startGap];
if (endGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, true, endGap];
});
if (bestGap > CHAIN_GAP_M) return null;
const [piece] = pieces.splice(bestIndex, 1);
const oriented = bestReversed ? [...piece].reverse() : piece;
line.push(...(line.length > 0 ? oriented.slice(1) : oriented));
tail = oriented[oriented.length - 1];
}
return line;
}
/** How close a waypoint has to stand to a ferrata's end to count as at its foot or top. */
const AT_END_M = 40;
/**
* The ferrata each waypoint stands at an end of, by waypoint id, among the
* ferratas the route climbs: the ones "Add to tour" set, or any placed there.
*/
export function waypointsAtFerrataEnds<T>(
waypoints: readonly { id: string; lat: number; lng: number }[],
crossings: readonly FerrataCrossing[],
describe: (group: FerrataGroup) => T
): Record<string, T> {
const marks: Record<string, T> = {};
const groups = [...new Map(crossings.map(({ group }) => [group.id, group])).values()];
for (const point of waypoints) {
const at = groups.find((group) =>
group.segments.some((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return [first, last].some((end) => !!end && distanceM([point.lat, point.lng], end) <= AT_END_M);
})
);
if (at) marks[point.id] = describe(at);
}
return marks;
}
/**
* For each stretch, the waypoint it follows on the route: each waypoint is
* placed at the nearest point of the line after the waypoint before it
* (`samples`, metres along it as the profile counts them), and a stretch
* belongs to the leg its middle lies on. Null
* before the first waypoint or without any.
*/
export function crossingLegs(
waypoints: readonly { id: string; lat: number; lng: number }[],
samples: readonly { distanceMeters: number; lat?: number; lng?: number }[],
crossings: readonly Pick<FerrataCrossing, 'fromM' | 'toM'>[]
): Array<string | null> {
// In order along the line: each waypoint is looked for from where the one
// before it stands, so a route that passes a spot twice keeps its legs apart.
let from = 0;
const along = waypoints.map((point) => {
let best = Number.POSITIVE_INFINITY;
let at = from;
for (const sample of samples) {
if (sample.distanceMeters < from || sample.lat == null || sample.lng == null) continue;
const gap = distanceM([point.lat, point.lng], [sample.lat, sample.lng]);
if (gap < best) {
best = gap;
at = sample.distanceMeters;
}
}
from = at;
return { id: point.id, at };
});
return crossings.map((crossing) => {
// By its middle: the trail to a ferrata's foot often runs beside it for a few metres first.
const middle = (crossing.fromM + crossing.toM) / 2;
let after: string | null = null;
for (const point of along) if (point.at <= middle) after = point.id;
return after;
});
}
@@ -0,0 +1,149 @@
// FE-FERRATA-GROUP-001 to FE-FERRATA-GROUP-009: OSM's short segments read back as the ferratas a climber means.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import {
FERRATA_NAME_REACH_M,
ferrataBboxesMeet,
ferrataGradeRange,
ferrataGroupOf,
ferrataGroupsInView,
groupFerratas,
} from './ferrataGroups';
/** About 111 m of latitude. */
const STEP = 0.001;
function segment(
id: number,
from: [number, number],
options: {
name?: string | null;
routeName?: string | null;
level?: number | null;
plus?: '+' | '-' | null;
raw?: string;
} = {}
): Ferrata {
const level = options.level === undefined ? 3 : options.level;
return {
id: `way/${id}`,
name: options.name === undefined ? 'Via X' : options.name,
routeName: options.routeName ?? null,
grade: { raw: options.raw ?? (level === null ? null : String(level)), level, plus: options.plus ?? null },
geometry: [from, [from[0] + STEP, from[1]]],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
describe('groupFerratas', () => {
it('FE-FERRATA-GROUP-001: joins touching segments of one name into one ferrata, named after them', () => {
const groups = groupFerratas([
segment(2, [46.5, 11.7], { level: 4 }),
segment(1, [46.5 + STEP, 11.7], { level: 2 }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 3 }),
]);
expect(groups).toHaveLength(1);
const [group] = groups;
expect(group?.id).toBe('group/way/1');
expect(group?.name).toBe('Via X');
expect(group?.segments.map((s) => s.id)).toEqual(['way/2', 'way/1', 'way/3']);
expect(group?.lengthM).toBeGreaterThan(330);
expect(group?.lengthM).toBeLessThan(340);
});
it('FE-FERRATA-GROUP-002: chains segments within the reach, and keeps a homonym far away apart', () => {
const gap = (FERRATA_NAME_REACH_M - 50) / 111_195;
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP + gap, 11.7]),
// The same name ten kilometres north is another ferrata.
segment(3, [46.6, 11.7]),
]);
expect(groups.map((g) => g.segments.map((s) => s.id))).toEqual([['way/1', 'way/2'], ['way/3']]);
});
it('FE-FERRATA-GROUP-003: reads names without case or outer spaces, and keeps every unnamed segment alone', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7], { name: 'Via X' }),
segment(2, [46.5 + STEP, 11.7], { name: ' via x ' }),
segment(3, [46.5, 11.71], { name: null }),
segment(4, [46.5 + STEP, 11.71], { name: null }),
]);
expect(groups.map((g) => g.segments.length)).toEqual([2, 1, 1]);
expect(groups[1]?.name).toBeNull();
});
it('FE-FERRATA-GROUP-004: groups by the route relation first, over a wider reach and whatever the ways are named', () => {
const groups = groupFerratas([
segment(10, [46.5, 11.7], { routeName: 'Brigata Tridentina', name: 'Pitch 1' }),
// 1.5 km on, the same route.
segment(11, [46.5135, 11.7], { routeName: 'Brigata Tridentina', name: null }),
// A way named like the route but outside the relation is its own ferrata.
segment(12, [46.5 + STEP, 11.7], { name: 'Brigata Tridentina' }),
]);
expect(groups).toHaveLength(2);
expect(groups[0]?.segments.map((s) => s.id)).toEqual(['way/10', 'way/11']);
expect(groups[0]?.routeName).toBe('Brigata Tridentina');
expect(groups[0]?.name).toBe('Pitch 1');
});
it('FE-FERRATA-GROUP-005: spans the grades from the easiest to the hardest segment, minus before plus', () => {
const [group] = groupFerratas([
segment(1, [46.5, 11.7], { level: 4, plus: '+' }),
segment(2, [46.5 + STEP, 11.7], { level: 2, plus: '-' }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 2 }),
segment(4, [46.5 + 3 * STEP, 11.7], { level: null }),
]);
expect(group && ferrataGradeRange(group)).toEqual({ from: 'K2-', to: 'K4+' });
expect(group?.rawGrade).toBeNull();
});
it('FE-FERRATA-GROUP-006: without a numeric grade keeps the tag as written, and has no range', () => {
const [group] = groupFerratas([segment(1, [46.5, 11.7], { level: null, raw: 'C/D' })]);
expect(group && ferrataGradeRange(group)).toBeNull();
expect(group?.rawGrade).toBe('C/D');
const [single] = groupFerratas([segment(2, [46.5, 11.7], { level: 3 })]);
expect(single && ferrataGradeRange(single)).toEqual({ from: 'K3', to: 'K3' });
});
it('FE-FERRATA-GROUP-007: puts the label halfway along the longest segment and spans every segment in its box', () => {
const long: Ferrata = {
...segment(1, [46.5, 11.7]),
geometry: [
[46.5, 11.7],
[46.5 + 4 * STEP, 11.7],
],
};
const [group] = groupFerratas([segment(2, [46.5 + 4 * STEP, 11.7]), long]);
expect(group?.midpoint[0]).toBeCloseTo(46.502, 5);
expect(group?.midpoint[1]).toBeCloseTo(11.7, 5);
expect(group?.bbox.south).toBe(46.5);
expect(group?.bbox.north).toBeCloseTo(46.505, 9);
expect(group?.bbox.west).toBe(11.7);
expect(group?.bbox.east).toBe(11.7);
});
it('FE-FERRATA-GROUP-008: finds the ferrata a way belongs to, and counts the ferratas reaching into a view', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP, 11.7]),
segment(3, [46.6, 11.8], { name: 'Other' }),
]);
expect(ferrataGroupOf(groups, 'way/2')?.id).toBe('group/way/1');
expect(ferrataGroupOf(groups, 'way/99')).toBeNull();
expect(ferrataGroupOf(groups, null)).toBeNull();
expect(ferrataGroupsInView(groups, { south: 46.49, west: 11.69, north: 46.51, east: 11.71 })).toBe(1);
expect(ferrataGroupsInView(groups, { south: 46.4, west: 11.6, north: 46.7, east: 11.9 })).toBe(2);
expect(ferrataGroupsInView(groups, null)).toBe(0);
});
it('FE-FERRATA-GROUP-009: boxes meet on a shared edge, not across a gap; a line under two points is dropped', () => {
const box = { south: 0, west: 0, north: 1, east: 1 };
expect(ferrataBboxesMeet(box, { south: 1, west: 1, north: 2, east: 2 })).toBe(true);
expect(ferrataBboxesMeet(box, { south: 1.1, west: 0, north: 2, east: 1 })).toBe(false);
const point = { ...segment(1, [46.5, 11.7]), geometry: [[46.5, 11.7]] } as Ferrata;
expect(groupFerratas([point])).toEqual([]);
});
});
@@ -0,0 +1,270 @@
import type { Ferrata, FerrataBbox, FerrataGrade } from '@trek/shared';
import { ferrataGradeLabel, ferrataPositions } from './ferrataModel';
/**
* OpenStreetMap maps one via ferrata as many short ways: the approach, each
* pitch, a bridge, the exit. This turns those segments back into the routes a
* climber means, so the layer counts, highlights and describes one ferrata
* rather than a dozen pieces of it. Pure and React-free.
*
* Segments belong together when they share their route relation's name, or,
* with no relation, their own name, and lie close to each other: a chain of
* segments joins as long as each one comes within the reach of the next.
* Unnamed segments stay on their own; nothing says two of them are one route.
*/
/** How close two segments of the same name have to come to read as one ferrata. */
export const FERRATA_NAME_REACH_M = 300;
/**
* The same for a route relation's name. A relation is one route by definition,
* so the reach is wider; it only keeps two distant routes that happen to share
* a name apart.
*/
const FERRATA_ROUTE_REACH_M = 2000;
export interface FerrataGroup {
/** Stable for as long as the group keeps its lowest way: `group/<way id>`. */
id: string;
name: string | null;
routeName: string | null;
/** The OSM ways it is made of, in the order they arrived. */
segments: readonly Ferrata[];
/** The easiest and the hardest numeric grade among the segments, or null when none has one. */
grades: { min: FerrataGrade; max: FerrataGrade } | null;
/** A grade as tagged when no segment has a numeric one (a letter scale such as "C/D"). */
rawGrade: string | null;
/** The summed length of every segment, in metres. */
lengthM: number;
bbox: FerrataBbox;
/** Where its label goes: halfway along its longest segment, as [lat, lng]. */
midpoint: [number, number];
}
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
function lengthOf(points: readonly [number, number][]): number {
let total = 0;
for (let i = 1; i < points.length; i += 1) total += distanceM(points[i - 1], points[i]);
return total;
}
/**
* The box around the points, the ones that are not finite numbers skipped, or
* null when none is left.
*/
export function ferrataPointsBbox(points: Iterable<readonly [number, number]>): FerrataBbox | null {
let south = Infinity;
let west = Infinity;
let north = -Infinity;
let east = -Infinity;
for (const [lat, lng] of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
return south <= north && west <= east ? { south, west, north, east } : null;
}
/** A segment's box; the schema guarantees it at least two placed points. */
function bboxOf(points: readonly [number, number][]): FerrataBbox {
return ferrataPointsBbox(points) ?? { south: 0, west: 0, north: 0, east: 0 };
}
/** Whether two boxes share any point, edges included. */
export function ferrataBboxesMeet(a: FerrataBbox, b: FerrataBbox): boolean {
return a.south <= b.north && b.south <= a.north && a.west <= b.east && b.west <= a.east;
}
/** The point halfway along a line, by length. */
function halfway(points: readonly [number, number][]): [number, number] {
const half = lengthOf(points) / 2;
let walked = 0;
for (let i = 1; i < points.length; i += 1) {
const step = distanceM(points[i - 1], points[i]);
if (walked + step >= half && step > 0) {
const t = (half - walked) / step;
const [lat1, lng1] = points[i - 1];
const [lat2, lng2] = points[i];
return [lat1 + (lat2 - lat1) * t, lng1 + (lng2 - lng1) * t];
}
walked += step;
}
return points[0] ?? [0, 0];
}
interface Segment {
ferrata: Ferrata;
points: [number, number][];
bbox: FerrataBbox;
lengthM: number;
}
/** Whether two boxes come within `reach` metres of each other, a cheap test before any point is compared. */
function boxesWithin(a: FerrataBbox, b: FerrataBbox, reach: number): boolean {
const latPad = reach / (EARTH_RADIUS_M * RAD);
const lngPad = latPad / Math.max(0.01, Math.cos(((a.south + a.north) / 2) * RAD));
return (
a.south - latPad <= b.north && b.south - latPad <= a.north && a.west - lngPad <= b.east && b.west - lngPad <= a.east
);
}
/** Whether an end of either segment comes within `reach` of a point of the other: they touch or nearly do. */
function segmentsMeet(a: Segment, b: Segment, reach: number): boolean {
if (!boxesWithin(a.bbox, b.bbox, reach)) return false;
const near = (ends: Segment, line: Segment) => {
const tips = [ends.points[0], ends.points[ends.points.length - 1]];
return tips.some((tip) => tip !== undefined && line.points.some((point) => distanceM(tip, point) <= reach));
};
return near(a, b) || near(b, a);
}
/** What segments group by, or null for one that stays alone. Case and outer spaces do not tell two names apart. */
function groupKey(ferrata: Ferrata): { key: string; reach: number } | null {
const route = ferrata.routeName?.trim().toLocaleLowerCase();
if (route) return { key: `route:${route}`, reach: FERRATA_ROUTE_REACH_M };
const name = ferrata.name?.trim().toLocaleLowerCase();
if (name) return { key: `name:${name}`, reach: FERRATA_NAME_REACH_M };
return null;
}
/** Segments of one key joined into chains: any two that meet end up in one set. */
function chains(segments: Segment[], reach: number): Segment[][] {
const parent = segments.map((_, index) => index);
const find = (i: number): number => {
let root = i;
while (parent[root] !== root) root = parent[root];
parent[i] = root;
return root;
};
for (let i = 0; i < segments.length; i += 1) {
for (let j = i + 1; j < segments.length; j += 1) {
if (find(i) !== find(j) && segmentsMeet(segments[i], segments[j], reach)) parent[find(j)] = find(i);
}
}
const sets = new Map<number, Segment[]>();
segments.forEach((segment, index) => {
const root = find(index);
const set = sets.get(root);
if (set) set.push(segment);
else sets.set(root, [segment]);
});
return [...sets.values()];
}
/** -, none, + in that order within a level. */
const gradeRank = (grade: FerrataGrade) =>
(grade.level ?? 0) * 3 + (grade.plus === '-' ? 0 : grade.plus === '+' ? 2 : 1);
function wayNumber(id: string): number {
const n = Number(id.slice(id.indexOf('/') + 1));
return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
}
function toGroup(members: Segment[]): FerrataGroup {
const segments = members.map((member) => member.ferrata);
const graded = segments.map((segment) => segment.grade).filter((grade) => grade.level !== null);
const sorted = [...graded].sort((a, b) => gradeRank(a) - gradeRank(b));
const longest = members.reduce((best, member) => (member.lengthM > best.lengthM ? member : best), members[0]);
// The name of the longest named segment speaks for the group.
const named = [...members].sort((a, b) => b.lengthM - a.lengthM).find((member) => member.ferrata.name);
const lowest = [...segments].sort((a, b) => wayNumber(a.id) - wayNumber(b.id))[0];
return {
id: `group/${lowest.id}`,
name: named?.ferrata.name ?? null,
routeName: segments.find((segment) => segment.routeName)?.routeName ?? null,
segments,
grades: sorted.length ? { min: sorted[0], max: sorted[sorted.length - 1] } : null,
rawGrade: sorted.length ? null : (segments.find((segment) => segment.grade.raw)?.grade.raw ?? null),
lengthM: members.reduce((sum, member) => sum + member.lengthM, 0),
bbox: members.slice(1).reduce(
(box, member) => ({
south: Math.min(box.south, member.bbox.south),
west: Math.min(box.west, member.bbox.west),
north: Math.max(box.north, member.bbox.north),
east: Math.max(box.east, member.bbox.east),
}),
members[0].bbox
),
midpoint: halfway(longest.points),
};
}
/** The segments as ferratas, in the order their first segment arrived. */
export function groupFerratas(ferratas: readonly Ferrata[]): FerrataGroup[] {
const keyed = new Map<string, { reach: number; segments: Segment[] }>();
const ordered: Array<Segment[] | string> = [];
for (const ferrata of ferratas) {
const points = ferrataPositions(ferrata);
if (points.length < 2) continue;
const segment: Segment = { ferrata, points, bbox: bboxOf(points), lengthM: lengthOf(points) };
const key = groupKey(ferrata);
if (!key) {
ordered.push([segment]);
continue;
}
const bucket = keyed.get(key.key);
if (bucket) bucket.segments.push(segment);
else {
keyed.set(key.key, { reach: key.reach, segments: [segment] });
ordered.push(key.key);
}
}
return ordered.flatMap((entry) => {
if (typeof entry !== 'string') return [toGroup(entry)];
const bucket = keyed.get(entry);
return bucket ? chains(bucket.segments, bucket.reach).map(toGroup) : [];
});
}
/** The group a way belongs to, for a click or a selection that names one segment. */
export function ferrataGroupOf(groups: readonly FerrataGroup[], segmentId: string | null): FerrataGroup | null {
if (!segmentId) return null;
return groups.find((group) => group.segments.some((segment) => segment.id === segmentId)) ?? null;
}
/** How many ferratas reach into the area on screen. */
export function ferrataGroupsInView(groups: readonly FerrataGroup[], view: FerrataBbox | null): number {
if (!view) return 0;
return groups.reduce((count, group) => (ferrataBboxesMeet(group.bbox, view) ? count + 1 : count), 0);
}
/**
* The group's grade on the K scale: the easiest and the hardest segment
* (`K2`, `K4`), the same label twice when they agree, null when no segment has
* a numeric grade.
*/
export function ferrataGradeRange(group: Pick<FerrataGroup, 'grades'>): { from: string; to: string } | null {
if (!group.grades) return null;
const from = ferrataGradeLabel(group.grades.min);
const to = ferrataGradeLabel(group.grades.max);
return from && to ? { from, to } : null;
}
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* What a ferrata is called on the map and on its card: its own name, else its
* route's, else "unnamed"; and its grade, `K3` or `K2 to K4`, or without a
* numeric grade the tag as written (a letter scale such as "C/D" still says
* something), or null when there is none at all.
*/
export function ferrataLabel(
group: Pick<FerrataGroup, 'name' | 'routeName' | 'grades' | 'rawGrade'>,
t: Translate
): { name: string; grade: string | null } {
const name = group.name ?? group.routeName ?? t('ferratas.unnamed');
const range = ferrataGradeRange(group);
if (!range) return { name, grade: group.rawGrade };
return { name, grade: range.from === range.to ? range.from : t('ferratas.gradeRange', range) };
}
@@ -0,0 +1,66 @@
// FE-FERRATA-ICON-001 to FE-FERRATA-ICON-004: the route icon halfway along a via ferrata.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FERRATA_ICON_GAP,
FERRATA_ICON_PIXEL_RATIO,
FERRATA_ICON_SIZE,
ferrataIconSvg,
loadFerrataIcon,
placeFerrataIcons,
} from './ferrataIcon';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('ferrataIcon', () => {
it('FE-FERRATA-ICON-001: draws a ladder in the line colour on a disc of the casing colour', () => {
const svg = ferrataIconSvg({ line: 'var(--text-primary)', casing: 'rgb(255, 255, 255)' });
expect(svg).toContain('fill="rgb(255, 255, 255)"');
expect(svg).toContain('stroke="var(--text-primary)"');
expect(svg).toContain(`width="${FERRATA_ICON_SIZE}"`);
});
it('FE-FERRATA-ICON-002: lets nothing but a colour into the markup', () => {
const svg = ferrataIconSvg({ line: '"/><script>alert(1)</script>', casing: 'white' });
expect(svg).not.toContain('<script>');
expect(svg).toContain('stroke="currentColor"');
});
it('FE-FERRATA-ICON-003: hands GL a sharp image of the same SVG, and rejects when it cannot be decoded', async () => {
const made: Array<{ src: string; width?: number }> = [];
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(public width?: number) {
made.push(this as unknown as { src: string; width?: number });
}
set src(value: string) {
(this as unknown as { _src: string })._src = value;
queueMicrotask(() => (value.includes('fail') ? this.onerror?.() : this.onload?.()));
}
get src() {
return (this as unknown as { _src: string })._src;
}
}
vi.stubGlobal('Image', FakeImage);
const image = await loadFerrataIcon({ line: 'black', casing: 'white' });
expect(image).toBe(made[0]);
expect(made[0]?.width).toBe(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
expect(decodeURIComponent(made[0]?.src ?? '')).toContain('stroke="black"');
await expect(loadFerrataIcon({ line: 'fail', casing: 'white' })).rejects.toThrow('could not be drawn');
});
it('FE-FERRATA-ICON-004: leaves out an icon that would touch one already placed, first come first placed', () => {
const placed = placeFerrataIcons([
{ id: 'a', x: 100, y: 100 },
{ id: 'b', x: 100 + FERRATA_ICON_GAP - 1, y: 100 },
{ id: 'c', x: 100, y: 100 + FERRATA_ICON_GAP },
{ id: 'd', x: 400, y: 400 },
]);
expect([...placed]).toEqual(['a', 'c', 'd']);
expect(FERRATA_ICON_GAP).toBeGreaterThan(FERRATA_ICON_SIZE);
});
});
@@ -0,0 +1,79 @@
import type { FerrataPaint } from './ferrataPaint';
/**
* The small route icon a via ferrata carries at its midpoint once the map is
* zoomed in: a ladder in the line colour on a disc of the casing colour, so it
* reads as part of the line it sits on.
*
* One SVG for both renderers: Leaflet puts it in a divIcon, where it can take
* `var(--token)` straight from the page; GL needs an image with the colours
* resolved, which `loadFerrataIcon` makes from the resolved paint.
*/
/** The zoom from which the icons show; below it they would crowd a valley full of ferratas. */
export const FERRATA_ICON_MIN_ZOOM = 13;
/** The icon's size on screen, in CSS pixels. */
export const FERRATA_ICON_SIZE = 22;
/** The name the GL renderer registers the image under. */
export const FERRATA_ICON_IMAGE = 'trek-ferrata-icon';
/**
* A colour as it may go into the markup: a resolved token (a hex, `rgb()` or
* named value) or a `var(--token)`. Anything else, which a token never
* resolves to, falls back to `currentColor` rather than reaching the SVG.
*/
function safeColour(value: string): string {
return /^[#\w\s(),.%-]+$/.test(value) ? value : 'currentColor';
}
export function ferrataIconSvg(paint: Pick<FerrataPaint, 'line' | 'casing'>, size = FERRATA_ICON_SIZE): string {
const line = safeColour(paint.line);
const casing = safeColour(paint.casing);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22" aria-hidden="true">`,
`<circle cx="11" cy="11" r="9.75" fill="${casing}" stroke="${line}" stroke-width="1.5"/>`,
`<path d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6" fill="none" stroke="${line}" stroke-width="1.6" stroke-linecap="round"/>`,
'</svg>',
].join('');
}
/** How sharp the GL image is drawn; the map scales it back to FERRATA_ICON_SIZE. */
export const FERRATA_ICON_PIXEL_RATIO = 2;
/**
* The icon as an image for a GL map, in the colours the tokens resolve to now.
* Rejects when the browser cannot decode it, and the lines then simply go
* without their icons.
*/
export function loadFerrataIcon(paint: Pick<FerrataPaint, 'line' | 'casing'>): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('The via ferrata icon could not be drawn'));
const svg = ferrataIconSvg(paint, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
});
}
/** The room an icon keeps to the next one, in CSS pixels, so two discs never touch. */
export const FERRATA_ICON_GAP = FERRATA_ICON_SIZE + 4;
/**
* Which icons fit on screen without touching one another, for a renderer that
* does not place symbols itself (Leaflet; GL keeps `icon-allow-overlap` off for
* the same effect). `points` are the icons' screen positions in pixels, in the
* order they should win: an icon closer than FERRATA_ICON_GAP to one already
* placed is left out.
*/
export function placeFerrataIcons(points: ReadonlyArray<{ id: string; x: number; y: number }>): Set<string> {
const placed: Array<{ x: number; y: number }> = [];
const ids = new Set<string>();
for (const point of points) {
if (placed.some((other) => Math.hypot(other.x - point.x, other.y - point.y) < FERRATA_ICON_GAP)) continue;
placed.push(point);
ids.add(point.id);
}
return ids;
}
@@ -0,0 +1,97 @@
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-007: the pure half of the via ferrata layer.
import { describe, expect, it } from 'vitest';
import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared';
import { groupFerratas } from './ferrataGroups';
import {
ferrataBboxFits,
ferrataBboxParam,
ferrataFeatureCollection,
ferrataGradeLabel,
ferrataLabelCollection,
ferrataOsmUrl,
ferrataPositions,
} from './ferrataModel';
const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({
level,
plus,
raw,
});
const FERRATA: Ferrata = {
id: 'way/123',
name: 'Pisciadù',
grade: grade(3, '+', '3+'),
routeName: 'Brigata Tridentina',
geometry: [
[46.53, 11.81],
[46.54, 11.82],
],
osmUrl: 'https://www.openstreetmap.org/way/123',
};
describe('ferrataModel', () => {
it('FE-FERRATA-MODEL-001: labels a numeric grade on the K scale with its plus or minus', () => {
expect(ferrataGradeLabel(grade(3))).toBe('K3');
expect(ferrataGradeLabel(grade(4, '+'))).toBe('K4+');
expect(ferrataGradeLabel(grade(2, '-'))).toBe('K2-');
expect(ferrataGradeLabel(grade(0))).toBe('K0');
});
it('FE-FERRATA-MODEL-002: has no label for a way without a numeric level, letter scales included', () => {
expect(ferrataGradeLabel(grade(null))).toBeNull();
expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull();
});
it('FE-FERRATA-MODEL-003: takes an area only up to the server limit and only a real box', () => {
const max = FERRATA_BBOX_MAX_SPAN_DEG;
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false);
expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false);
// The server's own rule: a 0.500 span that reads 0.5000000000000071 still goes out.
expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true);
});
it('FE-FERRATA-MODEL-004: writes the bbox parameter in south,west,north,east order', () => {
expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500');
});
it('FE-FERRATA-MODEL-005: builds one GeoJSON line per way, in [lng, lat], carrying only the way and its group', () => {
const collection = ferrataFeatureCollection(groupFerratas([FERRATA]));
expect(collection).toEqual({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { id: 'way/123', group: 'group/way/123' },
geometry: {
type: 'LineString',
coordinates: [
[11.81, 46.53],
[11.82, 46.54],
],
},
},
],
});
expect(ferrataPositions(FERRATA)).toEqual(FERRATA.geometry);
expect(ferrataFeatureCollection([]).features).toEqual([]);
});
it('FE-FERRATA-MODEL-006: puts one label point per ferrata halfway along it, in [lng, lat]', () => {
const [feature] = ferrataLabelCollection(groupFerratas([FERRATA])).features;
expect(feature?.properties).toEqual({ group: 'group/way/123' });
expect(feature?.geometry.type).toBe('Point');
const [lng, lat] = feature?.geometry.coordinates ?? [0, 0];
expect(lng).toBeCloseTo(11.815, 4);
expect(lat).toBeCloseTo(46.535, 4);
});
it('FE-FERRATA-MODEL-007: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123');
expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/');
});
});
@@ -0,0 +1,109 @@
import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
/**
* The pure half of the via ferrata layer: grade wording, the area checks a
* request makes, and the GeoJSON the GL renderer draws. React-free, so both
* renderers and the detail card read the same answers.
*/
/**
* The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has
* no numeric level (untagged, or a letter scale such as "C/D" the server keeps
* as `raw` and does not convert).
*/
export function ferrataGradeLabel(grade: FerrataGrade): string | null {
if (grade.level === null || grade.level === undefined) return null;
return `K${grade.level}${grade.plus ?? ''}`;
}
/**
* The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two
* numbers, maybe more" where the client compiles without strict mode, so the
* pairs are narrowed here rather than cast; the schema has already guaranteed
* both are numbers.
*/
export function ferrataPositions(ferrata: Pick<Ferrata, 'geometry'>): Array<[number, number]> {
return ferrata.geometry.flatMap((pair): Array<[number, number]> =>
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : []
);
}
const OSM_ORIGIN = 'https://www.openstreetmap.org/';
/**
* The way's page on openstreetmap.org. Built from the id the contract pins to
* `way/<digits>` rather than taken from `osmUrl` as sent, so the link can only
* ever point at OpenStreetMap whatever an Overpass mirror put in the answer.
*/
export function ferrataOsmUrl(ferrata: Pick<Ferrata, 'id'>): string {
return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN;
}
/**
* Whether the server takes this area: a real box no wider than
* FERRATA_BBOX_MAX_SPAN_DEG per side. Past it the layer asks the user to zoom
* in instead of loading every tile of the view.
*/
export function ferrataBboxFits(bbox: FerrataBbox): boolean {
// The span check is the server's own, so a box the client sends is never refused as too large.
return bbox.north > bbox.south && bbox.east > bbox.west && ferrataBboxSpanOk(bbox);
}
/** The `bbox` query parameter, in the south,west,north,east order the contract reads. */
export function ferrataBboxParam(bbox: FerrataBbox): string {
return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(',');
}
interface FerrataLineFeature {
type: 'Feature';
properties: { id: string; group: string };
geometry: { type: 'LineString'; coordinates: Array<[number, number]> };
}
interface FerrataLabelFeature {
type: 'Feature';
properties: { group: string };
geometry: { type: 'Point'; coordinates: [number, number] };
}
interface FeatureCollection<F> {
type: 'FeatureCollection';
features: F[];
}
const lngLat = ([lat, lng]: [number, number]): [number, number] => [lng, lat];
/**
* The segments as a GeoJSON FeatureCollection for MapLibre or Mapbox GL, one
* line per OSM way. The contract carries [lat, lng] like the rest of TREK;
* GeoJSON wants [lng, lat], and this is the one place the layer flips it. Only
* the way and its group ride along: a click looks the ferrata up by id, so
* names never pass through the map's own feature state, and a highlight
* filters on the group.
*/
export function ferrataFeatureCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLineFeature> {
return {
type: 'FeatureCollection',
features: groups.flatMap((group) =>
group.segments.map((segment) => ({
type: 'Feature' as const,
properties: { id: segment.id, group: group.id },
geometry: { type: 'LineString' as const, coordinates: ferrataPositions(segment).map(lngLat) },
}))
),
};
}
/** One point per ferrata, halfway along it, where the GL renderer puts the route icon. */
export function ferrataLabelCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLabelFeature> {
return {
type: 'FeatureCollection',
features: groups.map((group) => ({
type: 'Feature' as const,
properties: { group: group.id },
geometry: { type: 'Point' as const, coordinates: lngLat(group.midpoint) },
})),
};
}
@@ -0,0 +1,44 @@
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-003: the line colours come from the --map-ferrata tokens.
import { afterEach, describe, expect, it } from 'vitest';
import { ferrataPaint, sameFerrataPaint } from './ferrataPaint';
const root = document.documentElement;
afterEach(() => {
for (const name of [
'--map-ferrata',
'--map-ferrata-casing',
'--map-ferrata-lit',
'--success',
'--warning',
'--danger',
])
root.style.removeProperty(name);
});
describe('ferrataPaint', () => {
it('FE-FERRATA-PAINT-001: draws the line, its halo and the lit line in the via ferrata tokens', () => {
root.style.setProperty('--map-ferrata', 'violet');
root.style.setProperty('--map-ferrata-casing', 'white');
root.style.setProperty('--map-ferrata-lit', 'indigo');
expect(ferrataPaint()).toEqual({ line: 'violet', casing: 'white', highlight: 'indigo' });
});
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--warning', 'orange');
root.style.setProperty('--danger', 'crimson');
const paint = Object.values(ferrataPaint());
expect(paint).not.toContain('green');
expect(paint).not.toContain('orange');
expect(paint).not.toContain('crimson');
});
it('FE-FERRATA-PAINT-003: falls back to currentColor where a token is not defined, and compares by colour', () => {
const paint = ferrataPaint();
expect(paint).toEqual({ line: 'currentColor', casing: 'currentColor', highlight: 'currentColor' });
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
});
});
@@ -0,0 +1,49 @@
/**
* The colours the via ferrata lines are drawn in.
*
* Ferratas have a look of their own: a dashed violet line on a white halo.
* Violet is the one colour nothing else on the planner map uses (the route is
* blue, its grades run green to red, the waypoints are blue), and the halo
* keeps it readable over a satellite tile or dark hillshade. The ferrata under
* the pointer or open in the card is drawn solid, wider and in a deeper violet.
* The grade is a badge on the card, never a line colour.
*
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
* `--map-ferrata*` tokens are read off the document when the layer draws (the
* precedent is the hazard overlay in useHazardLayerGL), which keeps light and
* dark mode apart without a colour literal in the layer.
*/
export interface FerrataPaint {
/** The dashed line itself. */
line: string;
/** The halo under the line, so it stays readable over a satellite or hillshade tile. */
casing: string;
/** The ferrata under the pointer or open in the card. */
highlight: string;
}
const TOKENS = {
line: '--map-ferrata',
casing: '--map-ferrata-casing',
highlight: '--map-ferrata-lit',
} as const;
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
function token(name: string, style: CSSStyleDeclaration | null): string {
const value = style?.getPropertyValue(name).trim();
return value || 'currentColor';
}
export function ferrataPaint(): FerrataPaint {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
return {
line: token(TOKENS.line, style),
casing: token(TOKENS.casing, style),
highlight: token(TOKENS.highlight, style),
};
}
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
}
@@ -0,0 +1,295 @@
/**
* FE-FERRATA-TILES-001 to FE-FERRATA-TILES-012: the per-trip tile store behind
* the via ferrata layer.
*
* Pinned: nothing goes out before the device cache was read, a tile is asked
* for once and never dropped, the view goes before any prefetch and runs
* beside one on its way rather than behind it, answers are
* kept per tile, the view's failures are reported and retried, and a stop
* calls off what is on its way but keeps what is loaded.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { FerrataTileStore, ferrataRowsOf } from './ferrataTileStore';
const ferrata = (id: number, lat: number, lng: number, lat2 = lat + 0.01, lng2 = lng): Ferrata => ({
id: `way/${id}`,
name: `Ferrata ${id}`,
grade: { raw: '3', level: 3, plus: null },
routeName: null,
geometry: [
[lat, lng],
[lat2, lng2],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
const reply = (ferratas: Ferrata[], truncated = false): FerratasResponse => ({
ferratas,
truncated,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
});
interface Call {
bbox: FerrataBbox;
signal: AbortSignal;
resolve: (value: FerratasResponse) => void;
reject: (error: unknown) => void;
}
function harness(options: { persist?: boolean } = {}) {
const calls: Call[] = [];
const persisted: FerrataTileRow[][] = [];
const fetch = vi.fn(
(bbox: FerrataBbox, signal: AbortSignal) =>
new Promise<FerratasResponse>((resolve, reject) => calls.push({ bbox, signal, resolve, reject }))
);
const persist = vi.fn((rows: FerrataTileRow[]) => {
persisted.push(rows);
return Promise.resolve();
});
const store = new FerrataTileStore({
fetch,
persist: options.persist === false ? undefined : persist,
now: () => 1_000,
retryDelaysMs: [100, 200],
});
return { store, calls, fetch, persist, persisted };
}
/** Lets a settled fetch's handlers run. */
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
const A = { row: 930, col: 236 }; // 46.50 to 46.55, 11.80 to 11.85
const B = { row: 930, col: 237 };
const FAR = { row: 940, col: 236 };
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('FerrataTileStore', () => {
it('FE-FERRATA-TILES-001: asks for nothing before the cache was read, and nothing the cache already had', () => {
const { store, fetch } = harness();
expect(store.beginHydration()).toBe(true);
expect(store.beginHydration()).toBe(false);
store.want([A, B], 'visible');
expect(fetch).not.toHaveBeenCalled();
expect(store.getSnapshot().waiting.size).toBe(2);
const cached = ferrata(1, 46.52, 11.82);
store.hydrate([
{ tile: '930:236', fetchedAt: 1, ferratas: [cached] },
{ tile: '930:237', fetchedAt: 1, ferratas: [] },
]);
expect(fetch).not.toHaveBeenCalled();
const snapshot = store.getSnapshot();
expect(snapshot.hydrated).toBe(true);
expect([...snapshot.loaded].sort()).toEqual(['930:236', '930:237']);
expect(snapshot.waiting.size).toBe(0);
expect(snapshot.ferratas).toEqual([cached]);
});
it('FE-FERRATA-TILES-002: asks for the missing tiles in one box, keeps them, and never asks for them again', async () => {
const { store, calls, fetch } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
expect(fetch).toHaveBeenCalledTimes(1);
expect(calls[0]?.bbox).toEqual({ south: 46.5, west: 11.8, north: 46.55, east: 11.9 });
const found = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([found]));
await flush();
expect(store.getSnapshot().ferratas).toEqual([found]);
expect(store.getSnapshot().loaded.has('930:237')).toBe(true);
store.want([A, B], 'visible');
store.want([A], 'background');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-TILES-003: keeps every ferrata it ever loaded, a later answer adding to them by id', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
store.want([FAR], 'visible');
// The same way again, as a tile across its end returns it, and one more.
calls[1]?.resolve(reply([ferrata(1, 46.52, 11.82), ferrata(2, 47.01, 11.82)]));
await flush();
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/1', 'way/2']);
});
it('FE-FERRATA-TILES-004: the view goes out beside a prefetch on its way, and prefetches go one at a time', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
expect(calls).toHaveLength(1);
// The view does not wait for the prefetch already on its way.
store.want([B], 'visible');
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox).toEqual({ south: 46.5, west: 11.85, north: 46.55, east: 11.9 });
calls[1]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls[2]?.bbox.south).toBe(47);
});
it('FE-FERRATA-TILES-012: runs two of the view requests at once and queues the rest behind them', async () => {
const { store, calls } = harness();
store.hydrate([]);
// Three blocks of the grid apart: three requests.
store.want([A, { row: 930, col: 240 }, { row: 930, col: 244 }], 'visible');
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(3);
expect(calls[2]?.bbox.west).toBe(12.2);
});
it('FE-FERRATA-TILES-005: a tile a queued prefetch holds moves up with it when the view wants it', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
store.want([B], 'background');
store.want([B], 'visible');
expect(store.getSnapshot().waiting.has('930:237')).toBe(true);
// Moved up, it takes the view's lane at once instead of waiting behind the other prefetches.
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox.west).toBe(11.85);
});
it('FE-FERRATA-TILES-006: keeps each answer per tile, an empty tile included', async () => {
const { store, calls, persisted } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
const inA = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([inA]));
await flush();
expect(persisted).toEqual([
[
{ tile: '930:236', fetchedAt: 1_000, ferratas: [inA] },
{ tile: '930:237', fetchedAt: 1_000, ferratas: [] },
],
]);
});
it('FE-FERRATA-TILES-007: reports the view failing and asks again after each delay; a 400 reads as zoom in', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(2);
calls[1]?.resolve(reply([]));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([FAR], 'visible');
calls[2]?.reject({ response: { status: 400 } });
await flush();
expect(store.getSnapshot().failure).toBe('refused');
await vi.advanceTimersByTimeAsync(1_000);
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-008: a failed prefetch says nothing and is asked for again on the next want', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([A], 'background');
expect(calls).toHaveLength(2);
});
it('FE-FERRATA-TILES-009: a stop aborts what is on its way and drops the queue, and keeps what is loaded', async () => {
const { store, calls } = harness();
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
store.want([A], 'visible');
store.want([B], 'background');
expect(calls).toHaveLength(2);
store.stop();
expect(calls.map((call) => call.signal.aborted)).toEqual([true, true]);
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
expect(calls).toHaveLength(2);
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/9']);
expect(store.getSnapshot().waiting.size).toBe(0);
// Wanted again, the tiles go out again.
store.want([A], 'visible');
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-010: tells its listeners only of a change, and keeps the list while only the tiles change', async () => {
const { store, calls } = harness({ persist: false });
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
const first = store.getSnapshot();
expect(listener).toHaveBeenCalledTimes(1);
store.want([FAR], 'visible');
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getSnapshot()).toBe(first);
store.want([A], 'visible');
calls[0]?.resolve(reply([]));
await flush();
expect(store.getSnapshot()).not.toBe(first);
expect(store.getSnapshot().ferratas).toBe(first.ferratas);
unsubscribe();
});
it('FE-FERRATA-TILES-011: a prefetch on its way that holds a tile of the view reports its failure as the view', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([A], 'visible');
expect(calls).toHaveLength(1);
expect(store.getSnapshot().waiting.has('930:236')).toBe(true);
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
});
});
describe('ferrataRowsOf', () => {
it('gives a ferrata to every tile of the box its own line reaches into', () => {
// From 46.52 to 46.57: across the row line at 46.55.
const across = ferrata(1, 46.52, 11.82, 46.57, 11.82);
const rows = ferrataRowsOf({ minRow: 930, maxRow: 931, minCol: 236, maxCol: 237 }, [across], 5);
expect(rows.map((row) => [row.tile, row.ferratas.length])).toEqual([
['930:236', 1],
['930:237', 0],
['931:236', 1],
['931:237', 0],
]);
expect(rows.every((row) => row.fetchedAt === 5)).toBe(true);
});
});
@@ -0,0 +1,340 @@
import {
type Ferrata,
type FerrataBbox,
type FerratasResponse,
type FerrataTile,
type FerrataTileRange,
ferrataTileKey,
ferrataTileRange,
ferrataTileRequests,
ferrataTilesOf,
} from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { ferrataPointsBbox } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* The via ferrata layer's tiles for one trip, outside React.
*
* Every ferrata loaded for the trip stays, in a map by OSM id, whatever the
* map does afterwards: a pan or a zoom only asks for the tiles of the shared
* 0.05 degree grid that are not loaded yet, never for ones it has, and never
* drops any. Tiles wanted for the view on screen go first; the ring around it
* and the trip's area load behind them. The two have lanes of their own: the
* view's requests run beside a prefetch on its way rather than behind it,
* VISIBLE_AT_ONCE of them at a time, while prefetches go one at a time, so
* the view is never kept waiting by a prefetch. Answers go to `persist` per tile, and a
* trip's cached tiles come back through `hydrate`, which holds every request
* until the cache has been read, so a cached tile is never asked for again.
*
* Built for `useSyncExternalStore`: `subscribe` and `getSnapshot`, and the
* snapshot is a new object only when something in it changed.
*/
type FerrataTilePriority = 'visible' | 'background';
/**
* Why the last request for the view failed: `refused` (a 400, the server would
* not take the box, read as "zoom in") or `unavailable` (anything else: the
* Overpass mirrors, worth another try).
*/
type FerrataTileFailure = 'refused' | 'unavailable';
export interface FerrataTileSnapshot {
/** Every tile loaded for the trip, cached or fetched. */
loaded: ReadonlySet<string>;
/** Every ferrata those tiles hold, each once. */
ferratas: readonly Ferrata[];
/** Tiles wanted for the view that are queued or on their way. */
waiting: ReadonlySet<string>;
failure: FerrataTileFailure | null;
/** True when an answer for the view was cut at the server's cap. */
truncated: boolean;
/** Whether the cached tiles have been read. */
hydrated: boolean;
}
interface FerrataTileStoreDeps {
fetch: (bbox: FerrataBbox, signal: AbortSignal) => Promise<FerratasResponse>;
persist?: (rows: FerrataTileRow[]) => Promise<void>;
now?: () => number;
/** How long to wait before asking again for the view after the mirrors failed it. */
retryDelaysMs?: readonly number[];
}
interface Job {
bbox: FerrataBbox;
range: FerrataTileRange;
keys: string[];
priority: FerrataTilePriority;
}
const FERRATA_RETRY_DELAYS_MS = [5_000, 15_000, 45_000] as const;
/** Requests for the view on screen that may be on their way at once. */
const VISIBLE_AT_ONCE = 2;
/** Prefetches on their way at once: one, beside the view's own. */
const BACKGROUND_AT_ONCE = 1;
/** A job on its way, and the lane it took: the lane is what it counts against, even once it is moved up. */
interface Flight {
job: Job;
lane: FerrataTilePriority;
controller: AbortController;
}
function failureOf(error: unknown): FerrataTileFailure {
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
return status === 400 ? 'refused' : 'unavailable';
}
/**
* The ferratas of an answer per tile of the box it was for: each one goes to
* every tile its own box reaches into, and every tile gets a row, an empty one
* included, because "no ferrata here" is an answer worth keeping too.
*/
export function ferrataRowsOf(range: FerrataTileRange, ferratas: readonly Ferrata[], at: number): FerrataTileRow[] {
const rows = new Map(ferrataTilesOf(range).map((tile) => [ferrataTileKey(tile), [] as Ferrata[]]));
for (const ferrata of ferratas) {
const box = ferrataPointsBbox(ferrataPositions(ferrata));
if (!box) continue;
const own = ferrataTileRange(box);
const minRow = Math.max(own.minRow, range.minRow);
const maxRow = Math.min(own.maxRow, range.maxRow);
const minCol = Math.max(own.minCol, range.minCol);
const maxCol = Math.min(own.maxCol, range.maxCol);
if (minRow > maxRow || minCol > maxCol) continue;
for (const tile of ferrataTilesOf({ minRow, maxRow, minCol, maxCol }))
rows.get(ferrataTileKey(tile))?.push(ferrata);
}
return [...rows].map(([tile, list]) => ({ tile, fetchedAt: at, ferratas: list }));
}
export class FerrataTileStore {
private readonly loaded = new Set<string>();
private readonly byId = new Map<string, Ferrata>();
/** Tiles in a queued or in-flight job, so no tile is asked for twice at once. */
private readonly pending = new Set<string>();
private readonly waiting = new Set<string>();
private queue: Job[] = [];
private readonly inFlight = new Set<Flight>();
private failure: FerrataTileFailure | null = null;
private truncated = false;
private hydrated = false;
private hydrating = false;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryAttempt = 0;
private readonly listeners = new Set<() => void>();
private snapshot: FerrataTileSnapshot;
/** Writes to the device cache that failed; the tiles still draw, they only load again next time. */
persistFailures = 0;
constructor(private readonly deps: FerrataTileStoreDeps) {
this.snapshot = this.build();
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): FerrataTileSnapshot => this.snapshot;
/** Starts reading the cache; requests wait until `hydrate` hands its rows over. */
beginHydration(): boolean {
if (this.hydrated || this.hydrating) return false;
this.hydrating = true;
return true;
}
/** The cached tiles. Queued jobs whose tiles all came back from the cache are dropped. */
hydrate(rows: readonly FerrataTileRow[]): void {
for (const row of rows) {
this.loaded.add(row.tile);
this.waiting.delete(row.tile);
for (const ferrata of row.ferratas) this.byId.set(ferrata.id, ferrata);
}
this.hydrating = false;
this.hydrated = true;
this.queue = this.queue.filter((job) => {
if (!job.keys.every((key) => this.loaded.has(key))) return true;
for (const key of job.keys) this.pending.delete(key);
return false;
});
this.emit();
this.pump();
}
/**
* Asks for the tiles not loaded or on their way yet. `visible` ones jump the
* queue, and a background job already holding one of them is moved up with
* it; the error of an earlier view is cleared, its retry called off.
*/
want(tiles: readonly FerrataTile[], priority: FerrataTilePriority): void {
const keys = tiles.map(ferrataTileKey);
if (priority === 'visible') {
this.clearRetry();
// The ones on their way too, so their failure is reported and retried like the view's own.
const jobs = [...[...this.inFlight].map((flight) => flight.job), ...this.queue];
for (const job of jobs) {
if (job.priority === 'background' && job.keys.some((key) => keys.includes(key))) job.priority = 'visible';
}
for (const key of keys) if (!this.loaded.has(key)) this.waiting.add(key);
}
const missing = tiles.filter((tile, index) => !this.loaded.has(keys[index]) && !this.pending.has(keys[index]));
for (const request of ferrataTileRequests(missing)) {
const jobKeys = ferrataTilesOf(request.range).map(ferrataTileKey);
for (const key of jobKeys) this.pending.add(key);
this.queue.push({ ...request, keys: jobKeys, priority });
}
this.emit();
this.pump();
}
/** Calls off everything queued, in flight or due for a retry; what is loaded stays. */
stop(): void {
this.clearRetry();
for (const flight of this.inFlight) flight.controller.abort();
this.inFlight.clear();
this.queue = [];
this.pending.clear();
this.waiting.clear();
this.emit();
}
private clearRetry(): void {
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
this.retryTimer = null;
}
/** How many jobs of a lane are on their way. */
private flying(lane: FerrataTilePriority): number {
let count = 0;
for (const flight of this.inFlight) if (flight.lane === lane) count += 1;
return count;
}
/** The next queued job of a priority, taken off the queue, or null for none. */
private take(priority: FerrataTilePriority): Job | null {
const index = this.queue.findIndex((job) => job.priority === priority);
if (index < 0) return null;
const [job] = this.queue.splice(index, 1);
return job ?? null;
}
/** Starts what the lanes have room for: the view's jobs in theirs, the prefetches in theirs. */
private pump(): void {
if (!this.hydrated) return;
for (const [lane, room] of [
['visible', VISIBLE_AT_ONCE],
['background', BACKGROUND_AT_ONCE],
] as const) {
while (this.flying(lane) < room) {
const job = this.take(lane);
if (!job) break;
this.launch(job, lane);
}
}
}
private launch(job: Job, lane: FerrataTilePriority): void {
const controller = new AbortController();
const flight: Flight = { job, lane, controller };
this.inFlight.add(flight);
void this.deps.fetch(job.bbox, controller.signal).then(
(reply) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.settle(job, reply);
},
(error: unknown) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.fail(job, failureOf(error));
}
);
}
private settle(job: Job, reply: FerratasResponse): void {
const at = (this.deps.now ?? Date.now)();
for (const ferrata of reply.ferratas) this.byId.set(ferrata.id, ferrata);
for (const key of job.keys) {
this.loaded.add(key);
this.pending.delete(key);
this.waiting.delete(key);
}
if (job.priority === 'visible') {
this.failure = null;
this.retryAttempt = 0;
this.truncated = reply.truncated;
}
const persist = this.deps.persist;
if (persist) {
void persist(ferrataRowsOf(job.range, reply.ferratas, at)).catch(() => {
this.persistFailures += 1;
});
}
this.emit();
this.pump();
}
private fail(job: Job, failure: FerrataTileFailure): void {
for (const key of job.keys) this.pending.delete(key);
if (job.priority === 'visible') {
// The view's tiles are no longer on their way: the status reads the failure instead.
for (const key of job.keys) this.waiting.delete(key);
this.failure = failure;
const delay = (this.deps.retryDelaysMs ?? FERRATA_RETRY_DELAYS_MS)[this.retryAttempt];
if (failure === 'unavailable' && delay !== undefined) {
this.retryAttempt += 1;
const tiles = ferrataTilesOf(job.range);
this.clearRetry();
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.want(tiles, 'visible');
}, delay);
}
}
this.emit();
this.pump();
}
private build(): FerrataTileSnapshot {
return {
loaded: new Set(this.loaded),
ferratas: [...this.byId.values()],
waiting: new Set(this.waiting),
failure: this.failure,
truncated: this.truncated,
hydrated: this.hydrated,
};
}
private emit(): void {
const next = this.build();
const prev = this.snapshot;
// An unchanged list keeps its reference, so what is drawn from it is not rebuilt.
const sameFerratas =
prev.ferratas.length === next.ferratas.length &&
prev.ferratas.every((ferrata, index) => ferrata === next.ferratas[index]);
const same =
sameFerratas &&
prev.failure === next.failure &&
prev.truncated === next.truncated &&
prev.hydrated === next.hydrated &&
sameSet(prev.loaded, next.loaded) &&
sameSet(prev.waiting, next.waiting);
if (same) return;
this.snapshot = sameFerratas ? { ...next, ferratas: prev.ferratas } : next;
for (const listener of [...this.listeners]) listener();
}
}
function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const value of a) if (!b.has(value)) return false;
return true;
}
@@ -0,0 +1,333 @@
/**
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
* MapLibre or Mapbox GL map.
*
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
* still loading, the failure the hazard overlay had), the line is the dark
* dashed via ferrata violet and never a grade colour, new data replaces the
* sources' contents instead of rebuilding the layers, a click on the hit line
* hands the ferrata and the way to `onSelect`, hover and selection highlight
* the whole ferrata (solid, on a wider casing) and name it above the pointer,
* the 3D terrain draws wider lines, the route icon joins once its image is drawn, a theme
* change repaints the installed layers in place, and an unmount leaves nothing
* behind.
*/
import { act, render, renderHook, waitFor } from '@testing-library/react';
import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerGL from './FerrataLayerGL';
import { groupFerratas } from './ferrataGroups';
import { useFerrataLayerGL } from './useFerrataLayerGL';
vi.mock('./ferrataIcon', async (importOriginal) => ({
...(await importOriginal<typeof import('./ferrataIcon')>()),
loadFerrataIcon: vi.fn(() => Promise.resolve({ width: 44, height: 44 })),
}));
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = 'trek-ferratas-labels';
const CASING = 'trek-ferratas-casing';
const LINE = 'trek-ferratas-line';
const HIGHLIGHT_CASING = 'trek-ferratas-highlight-casing';
const HIGHLIGHT = 'trek-ferratas-highlight';
const HIT = 'trek-ferratas-hit';
const LABEL = 'trek-ferratas-label';
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
id,
name,
grade: { raw: '2', level: 2, plus: null },
routeName: null,
geometry: [
[lat, lng],
[lat + 0.01, lng + 0.01],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
});
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
const GROUPS = groupFerratas([
ferrata('way/1', 46.5, 'Via A'),
ferrata('way/2', 46.51, 'Via A', 11.71),
ferrata('way/3', 47, 'Via B'),
]);
type Handler = (event?: unknown) => void;
function fakeGlMap(styleLoaded = true) {
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const layers = new Set<string>();
const images = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const once = new Map<string, Set<Handler>>();
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
const map = {
isStyleLoaded: vi.fn(() => state.styleLoaded),
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string) => {
sources.set(id, { setData: vi.fn() });
}),
removeSource: vi.fn((id: string) => {
sources.delete(id);
}),
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
getLayer: vi.fn((id: string) => {
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
return layers.has(id) ? {} : undefined;
}),
addLayer: vi.fn((spec: { id: string }) => {
layers.add(spec.id);
}),
removeLayer: vi.fn((id: string) => {
layers.delete(id);
}),
setPaintProperty: vi.fn(),
setFilter: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
}),
removeImage: vi.fn((id: string) => {
images.delete(id);
}),
queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])),
getCanvas: vi.fn(() => canvas),
getContainer: vi.fn(() => container),
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const key = keyOf(type, layerOrFn);
const set = handlers.get(key) ?? new Set<Handler>();
set.add((fn ?? layerOrFn) as Handler);
handlers.set(key, set);
}),
once: vi.fn((type: string, fn: Handler) => {
const set = once.get(type) ?? new Set<Handler>();
set.add(fn);
once.set(type, set);
}),
off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const target = (fn ?? layerOrFn) as Handler;
handlers.get(keyOf(type, layerOrFn))?.delete(target);
once.get(type)?.delete(target);
}),
};
const emit = (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event));
return {
map: map as unknown as MapboxMap,
spies: map,
state,
sources,
layers,
images,
canvas,
container,
emit,
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
emitIdle: () => {
const pending = [...(once.get('idle') ?? [])];
once.delete('idle');
pending.forEach((fn) => fn());
},
};
}
const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent;
const addedIds = (gl: ReturnType<typeof fakeGlMap>) => gl.spies.addLayer.mock.calls.map(([layer]) => layer.id);
const spec = (gl: ReturnType<typeof fakeGlMap>, id: string) =>
gl.spies.addLayer.mock.calls.map(([layer]) => layer).find((layer) => layer.id === id) as unknown as {
paint: Record<string, unknown>;
filter?: unknown;
minzoom?: number;
layout?: Record<string, unknown>;
};
const filterOf = (ids: string[]) => ['in', ['get', 'group'], ['literal', ids]];
describe('useFerrataLayerGL', () => {
it('FE-FERRATA-GL-001: adds the line and label sources and the casing, line, highlight casing, highlight and hit layers, in that order', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(gl.spies.addSource).toHaveBeenCalledWith(LABEL_SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-002: draws a dashed violet line on a white casing, never by grade', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
root.style.setProperty('--map-ferrata-casing', 'white');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
const line = spec(gl, LINE);
expect(line.paint['line-color']).toBe('black');
expect(line.paint['line-dasharray']).toEqual([2, 1.5]);
expect(spec(gl, CASING).paint['line-color']).toBe('white');
root.style.removeProperty('--map-ferrata');
root.style.removeProperty('--map-ferrata-casing');
});
it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => {
const gl = fakeGlMap(false);
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).not.toHaveBeenCalled();
gl.state.styleLoaded = true;
gl.emitIdle();
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-004: new ferratas replace the sources data without rebuilding the layers', () => {
const gl = fakeGlMap();
const { rerender } = renderHook(({ groups }) => useFerrataLayerGL(gl.map, groups, vi.fn()), {
initialProps: { groups: GROUPS.slice(0, 1) },
});
rerender({ groups: GROUPS });
expect(addedIds(gl)).toEqual(LAYERS);
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({
features: [
expect.objectContaining({ properties: { id: 'way/1', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/2', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/3', group: 'group/way/3' } }),
],
})
);
expect(gl.sources.get(LABEL_SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({ features: [expect.anything(), expect.anything()] })
);
});
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const gl = fakeGlMap();
const onSelect = vi.fn();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, onSelect));
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
gl.emit('click', click);
expect(onSelect).toHaveBeenCalledWith(GROUPS[0], GROUPS[0]?.segments[1], { lng: 11.705, lat: 46.505 });
onSelect.mockClear();
gl.state.hit = null;
gl.emit('click', click);
expect(onSelect).not.toHaveBeenCalled();
});
it('FE-FERRATA-GL-006: the pointer turns into a hand over a line, and the whole ferrata under it lights up', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(spec(gl, HIGHLIGHT).filter).toEqual(filterOf([]));
gl.emit(`mouseenter:${HIT}`);
expect(gl.canvas.style.cursor).toBe('pointer');
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/1']));
act(() => gl.emit(`mouseleave:${HIT}`));
expect(gl.canvas.style.cursor).toBe('');
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf([]));
});
it('FE-FERRATA-GL-007: the ferrata open in the card stays highlighted', () => {
const gl = fakeGlMap();
const { rerender } = renderHook(({ selected }) => useFerrataLayerGL(gl.map, GROUPS, vi.fn(), selected), {
initialProps: { selected: null as string | null },
});
rerender({ selected: 'group/way/3' });
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/3']));
});
it('FE-FERRATA-GL-008: adds the route icon layer from the zoom it shows at, once its image is drawn', async () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
expect(gl.images.has('trek-ferrata-icon')).toBe(true);
const label = spec(gl, LABEL);
expect(label.minzoom).toBe(13);
expect(label.layout?.['icon-image']).toBe('trek-ferrata-icon');
});
it('FE-FERRATA-GL-009: unmounting removes the layers, the sources, the icon and every listener; no map, no work', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
unmount();
expect(gl.layers.size).toBe(0);
expect(gl.sources.size).toBe(0);
expect(gl.images.size).toBe(0);
expect(gl.listeners()).toBe(0);
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
});
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
gl.state.removed = true;
expect(() => unmount()).not.toThrow();
expect(gl.listeners()).toBe(0);
});
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.setPaintProperty).not.toHaveBeenCalled();
await act(async () => {
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory'));
expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS);
root.style.removeProperty('--map-ferrata');
});
it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => {
const gl = fakeGlMap();
const { unmount } = renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
const label = gl.container.querySelector<HTMLElement>('[data-testid="ferrata-hover-label"]');
expect(label?.style.display).toBe('');
expect(label?.textContent).toBe('Via AK2');
expect(label?.querySelector('span')?.textContent).toBe('K2');
expect(label?.style.transform).toContain('translate(4px, -8px)');
expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint');
act(() => gl.emit(`mouseleave:${HIT}`));
expect(label?.style.display).toBe('none');
unmount();
expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull();
});
it('FE-FERRATA-GL-012: lights a ferrata solid on a wider casing, and draws wider lines on the 3D terrain', () => {
const flat = fakeGlMap();
renderHook(() => useFerrataLayerGL(flat.map, GROUPS, vi.fn()));
expect(spec(flat, CASING).paint['line-width']).toBe(6);
expect(spec(flat, LINE).paint['line-width']).toBe(2.5);
expect(spec(flat, HIGHLIGHT_CASING).paint['line-width']).toBe(9);
expect(spec(flat, HIGHLIGHT).paint['line-width']).toBe(4);
expect(spec(flat, HIGHLIGHT).paint['line-dasharray']).toBeUndefined();
const terrain = fakeGlMap();
renderHook(() => useFerrataLayerGL(terrain.map, GROUPS, vi.fn(), null, 'terrain'));
expect(spec(terrain, CASING).paint['line-width']).toBe(8);
expect(spec(terrain, LINE).paint['line-width']).toBe(3.5);
});
});
@@ -0,0 +1,332 @@
import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { mapHoverLabel } from '../Map/mapHoverLabel';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
import type { FerrataPaint } from './ferrataPaint';
import { useFerrataPaint } from './useFerrataPaint';
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = `${SOURCE}-labels`;
const CASING = `${SOURCE}-casing`;
const LINE = `${SOURCE}-line`;
/** The wider casing under a lit ferrata, so it stands out by its shape and not only its colour. */
const HIGHLIGHT_CASING = `${SOURCE}-highlight-casing`;
/** The whole ferrata under the pointer or open in the card, drawn solid over its own line. */
const HIGHLIGHT = `${SOURCE}-highlight`;
/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */
const HIT = `${SOURCE}-hit`;
const LABEL = `${SOURCE}-label`;
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT, LABEL];
const LIT_LAYERS = [HIGHLIGHT_CASING, HIGHLIGHT];
/**
* The line widths, in pixels. On the 3D terrain the map is seen at a slant
* and over a hillshade, so the lines there are drawn wider to stay readable.
*/
interface FerrataWidths {
casing: number;
line: number;
litCasing: number;
lit: number;
}
const FLAT_WIDTHS: FerrataWidths = { casing: 6, line: 2.5, litCasing: 9, lit: 4 };
const TERRAIN_WIDTHS: FerrataWidths = { casing: 8, line: 3.5, litCasing: 11, lit: 5 };
/** Where the layer is drawn: the flat map, or the 3D terrain, which draws its lines wider. */
type FerrataSurface = 'flat' | 'terrain';
/** Where on the map a ferrata was clicked, for a popup or card anchored there. */
interface FerrataClickPoint {
lng: number;
lat: number;
}
/** A click on a line: the ferrata it belongs to, the OSM way under the pointer and where it landed. */
export type FerrataSelectHandler = (group: FerrataGroup, segment: Ferrata, at: FerrataClickPoint) => void;
/** The groups to highlight, as a GL filter on the features' `group`. */
function highlightFilter(ids: readonly string[]): ExpressionSpecification {
return ['in', ['get', 'group'], ['literal', [...ids]]];
}
const LINE_LAYOUT = { 'line-cap': 'round', 'line-join': 'round' } as const;
/**
* The two sources and the five line layers, bottom to top: casing, dashed
* line, the lit ferrata's wider casing and its solid line, hit line.
*/
function addFerrataLayers(
map: Map,
paint: FerrataPaint,
widths: FerrataWidths,
data: GeoJSON.GeoJSON,
labels: GeoJSON.GeoJSON
): void {
map.addSource(SOURCE, { type: 'geojson', data });
map.addSource(LABEL_SOURCE, { type: 'geojson', data: labels });
map.addLayer({
id: CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': widths.casing, 'line-opacity': 0.9 },
});
map.addLayer({
id: LINE,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.line, 'line-width': widths.line, 'line-dasharray': [2, 1.5] },
});
map.addLayer({
id: HIGHLIGHT_CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.casing, 'line-width': widths.litCasing },
});
map.addLayer({
id: HIGHLIGHT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.highlight, 'line-width': widths.lit },
});
map.addLayer({
id: HIT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
});
}
/**
* Draws the route icon in `paint` and, the first time, the symbol layer that
* shows it from FERRATA_ICON_MIN_ZOOM. Done once the image is decoded; a
* layer torn down meanwhile (`gone`) is left alone.
*/
function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): void {
void loadFerrataIcon(paint).then(
(image) => {
if (gone() || !map.getSource(LABEL_SOURCE)) return;
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
map.addImage(FERRATA_ICON_IMAGE, image, { pixelRatio: FERRATA_ICON_PIXEL_RATIO });
if (map.getLayer(LABEL)) return;
map.addLayer({
id: LABEL,
type: 'symbol',
source: LABEL_SOURCE,
minzoom: FERRATA_ICON_MIN_ZOOM,
layout: { 'icon-image': FERRATA_ICON_IMAGE, 'icon-allow-overlap': false, 'icon-ignore-placement': false },
});
},
// Undecodable: the lines simply go without their icons.
() => undefined
);
}
function removeFerrataLayers(map: Map): void {
// A map already removed with its view (leaving 3D) took its style and the layers with it;
// asking it for a layer then throws.
if (!map.getStyle?.()) return;
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
}
/**
* The via ferrata layer on a MapLibre or Mapbox GL map: a source of the OSM
* ways and one of a point per ferrata, a light casing, the dark dashed line,
* the highlight of the ferrata under the pointer or `selectedId` (solid and
* wider, on a wider casing), a hit line that turns a click into
* `onSelect(group, way, point)` and shows the name and grade above the
* pointer, and from FERRATA_ICON_MIN_ZOOM a small route icon halfway along
* each ferrata. `surface` 'terrain' draws the lines wider for the 3D map.
*
* The layers are installed once per map and again after a style swap (a
* basemap or theme change drops every custom layer); new ferratas only replace
* the sources' data, so tiles loading after a pan do not rebuild the layers.
* Like the hazard overlay, a draw that finds the style busy loading tiles
* waits for `idle` rather than for a `style.load` that may never come. A theme
* change repaints the installed layers in place and redraws the icon.
*/
export function useFerrataLayerGL(
map: Map | null,
groups: readonly FerrataGroup[],
onSelect: FerrataSelectHandler,
selectedId: string | null = null,
surface: FerrataSurface = 'flat'
) {
const { t } = useTranslation();
const data = useMemo(() => ferrataFeatureCollection(groups), [groups]);
const labels = useMemo(() => ferrataLabelCollection(groups), [groups]);
const paint = useFerrataPaint();
const [hovered, setHovered] = useState<string | null>(null);
// The handlers read the newest props without reinstalling the layers on every render.
const latest = useRef({ groups, data, labels, onSelect, paint, t, surface });
// The paint the installed layers were last drawn in, so a mount does not repaint what it just drew.
const painted = useRef<FerrataPaint | null>(null);
// Set by the install effect: redraws the icon in the current paint on a theme change.
const iconRepaint = useRef<(() => void) | null>(null);
useEffect(() => {
latest.current = { groups, data, labels, onSelect, paint, t, surface };
});
useEffect(() => {
if (!map) return;
let waiting = false;
let disposed = false;
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
const draw = () => {
if (map.getSource(SOURCE)) return;
if (!map.isStyleLoaded()) {
if (!waiting) {
waiting = true;
map.once('idle', retry);
}
return;
}
const { paint, data, labels } = latest.current;
painted.current = paint;
const widths = latest.current.surface === 'terrain' ? TERRAIN_WIDTHS : FLAT_WIDTHS;
addFerrataLayers(map, paint, widths, data, labels);
addFerrataIcon(map, paint, () => disposed);
};
const retry = () => {
waiting = false;
draw();
};
const featureAt = (event: MapMouseEvent) => {
if (!map.getLayer(HIT)) return null;
const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0];
return hit?.properties ?? null;
};
const click = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.id;
for (const group of latest.current.groups) {
const segment = group.segments.find((candidate) => candidate.id === id);
if (segment) {
latest.current.onSelect(group, segment, { lng: event.lngLat.lng, lat: event.lngLat.lat });
return;
}
}
};
const move = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.group;
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
setHovered(group ? group.id : null);
if (group) {
const label = ferrataLabel(group, latest.current.t);
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
} else hoverLabel.hide();
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
};
const leave = () => {
map.getCanvas().style.cursor = '';
setHovered(null);
hoverLabel.hide();
};
const repaintIcon = () => addFerrataIcon(map, latest.current.paint, () => disposed);
draw();
map.on('style.load', draw);
map.on('click', click);
map.on('mousemove', HIT, move);
map.on('mouseenter', HIT, enter);
map.on('mouseleave', HIT, leave);
iconRepaint.current = repaintIcon;
return () => {
disposed = true;
iconRepaint.current = null;
map.off('style.load', draw);
map.off('idle', retry);
map.off('click', click);
map.off('mousemove', HIT, move);
map.off('mouseenter', HIT, enter);
map.off('mouseleave', HIT, leave);
hoverLabel.remove();
removeFerrataLayers(map);
};
}, [map]);
useEffect(() => {
(map?.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(data);
(map?.getSource(LABEL_SOURCE) as GeoJSONSource | undefined)?.setData(labels);
}, [map, data, labels]);
const highlighted = useMemo(
() => [...new Set([hovered, selectedId].filter((id): id is string => !!id))],
[hovered, selectedId]
);
useEffect(() => {
for (const layer of LIT_LAYERS) if (map?.getLayer(layer)) map.setFilter(layer, highlightFilter(highlighted));
}, [map, highlighted]);
useEffect(() => {
if (!map || painted.current === paint || !map.getLayer(LINE)) return;
painted.current = paint;
for (const layer of [CASING, HIGHLIGHT_CASING])
if (map.getLayer(layer)) map.setPaintProperty(layer, 'line-color', paint.casing);
map.setPaintProperty(LINE, 'line-color', paint.line);
if (map.getLayer(HIGHLIGHT)) map.setPaintProperty(HIGHLIGHT, 'line-color', paint.highlight);
iconRepaint.current?.();
}, [map, paint]);
}
/** The GL map the layer draws on. MapLibre's map answers the same calls the layer makes. */
export type FerrataGlMap = Map;
/**
* Just the two calls a hit test needs, so the Mapbox and the MapLibre map both
* fit, each with the point type of its own click events. Optional because a stand-in map (a test double, a map torn down mid
* click) may lack them, and a hit test must then answer no rather than throw.
*/
interface HitTestMap<Point> {
getLayer?(id: string): unknown;
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
}
/**
* Whether a click at `point` landed on a drawn ferrata. A map's own click
* handler asks this first, so picking a ferrata opens its card instead of also
* dropping a control point or a place under it.
*/
export function isFerrataHit<Point>(map: HitTestMap<Point>, point: Point): boolean {
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0;
}
const NO_GROUPS: readonly FerrataGroup[] = [];
const IGNORE_SELECT: FerrataSelectHandler = () => {};
/**
* The layer as a map view mounts it from a `FerrataMapLayer`: drawn only while
* it is switched on, so a map with the layer off carries no source or layers.
*/
export function useFerrataMapLayerGL(
map: Map | null,
layer:
| { active: boolean; groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; selectedId: string | null }
| undefined,
surface: FerrataSurface = 'flat'
) {
useFerrataLayerGL(
layer?.active ? map : null,
layer?.groups ?? NO_GROUPS,
layer?.onSelect ?? IGNORE_SELECT,
layer?.selectedId ?? null,
surface
);
}
@@ -0,0 +1,150 @@
/**
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-008: the via ferrata layer as
* one map surface holds it: off by default, the area the map reports, the
* trip's area to prefetch, the ferratas grouped from OSM's segments with their
* count in view, and the card a click opens.
*
* useTripFerratas is mocked: what it asks the server is its own suite's
* subject. This one checks what the layer hands it (the view, whether it is on
* at all, the trip's area) and what it hands the map back.
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { ferrataAreaOf, useFerrataMapLayer } from './useFerrataMapLayer';
import { useTripFerratas } from './useTripFerratas';
vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
const OTHER: FerrataBbox = { south: 47.4, west: 11.6, north: 47.5, east: 11.8 };
const segment = (id: number, lat: number): Ferrata => ({
id: `way/${id}`,
name: 'Via delle Trincee',
grade: { raw: String(id), level: id, plus: null },
routeName: null,
geometry: [
[lat, 11.8],
[lat + 0.001, 11.8],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
// Two touching segments of one ferrata.
const LOWER = segment(2, 46.52);
const UPPER = segment(4, 46.521);
const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER], status: 'ready', truncated: false });
});
describe('useFerrataMapLayer', () => {
it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
expect(result.current.layer.active).toBe(false);
expect(result.current.layer.count).toBe(0);
expect(result.current.selected).toBeNull();
expect(lastCall()).toEqual([7, null, false, null, []]);
});
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
const renders = vi.mocked(useTripFerratas).mock.calls.length;
act(() => result.current.onViewportChange(BOX));
expect(vi.mocked(useTripFerratas).mock.calls.length).toBe(renders);
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(true);
expect(lastCall()?.slice(0, 3)).toEqual([7, BOX, true]);
});
it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
act(() => result.current.onViewportChange(OTHER));
expect(lastCall()?.slice(0, 3)).toEqual([7, OTHER, true]);
});
it("FE-FERRATA-MAPLAYER-004: hands the trip's area around its lines to the loader", () => {
const lines = [
[[46.5, 11.7]],
[
[46.7, 11.6],
[46.6, 11.9],
],
] as Array<Array<[number, number]>>;
renderHook(() => useFerrataMapLayer(7, lines));
expect(lastCall()?.[3]).toEqual({ south: 46.5, west: 11.6, north: 46.7, east: 11.9 });
expect(ferrataAreaOf([])).toBeNull();
expect(ferrataAreaOf([[[Number.NaN, 1]]])).toBeNull();
});
it('FE-FERRATA-MAPLAYER-005: groups the segments into one ferrata and counts the ferratas in view', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.onViewportChange(BOX));
act(() => result.current.layer.onToggle());
expect(result.current.layer.groups).toHaveLength(1);
expect(result.current.layer.groups[0]?.segments).toEqual([LOWER, UPPER]);
expect(result.current.layer.count).toBe(1);
act(() => result.current.onViewportChange(OTHER));
expect(result.current.layer.count).toBe(0);
// Out of view, still drawn.
expect(result.current.layer.groups).toHaveLength(1);
});
it('FE-FERRATA-MAPLAYER-006: a click opens the whole ferrata in the card, highlighted, and close shuts it', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
expect(result.current.selected?.group.id).toBe(group.id);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe(group.id);
act(() => result.current.close());
expect(result.current.selected).toBeNull();
expect(result.current.layer.selectedId).toBeNull();
});
it('FE-FERRATA-MAPLAYER-007: the card follows its ferrata when more of its segments load', () => {
const { result, rerender } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
// A lower way loads and joins the ferrata; its id is now the group's.
const lowest = segment(1, 46.519);
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER, lowest], status: 'ready', truncated: false });
rerender();
expect(result.current.selected?.group.segments).toHaveLength(3);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe('group/way/1');
});
it('FE-FERRATA-MAPLAYER-008: switching the layer off closes the card and stops asking', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, LOWER, { lat: 46.52, lng: 11.8 }));
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(false);
expect(result.current.selected).toBeNull();
expect(lastCall()?.[2]).toBe(false);
// On again, the card stays closed until the next click.
act(() => result.current.layer.onToggle());
expect(result.current.selected).toBeNull();
});
});
@@ -0,0 +1,108 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { type FerrataCrossing, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import {
type FerrataGroup,
ferrataGroupOf,
ferrataGroupsInView,
ferrataPointsBbox,
groupFerratas,
} from './ferrataGroups';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import { type FerrataStatus, useTripFerratas } from './useTripFerratas';
/**
* The via ferrata layer as a map hands it around: whether it is on, the switch
* the layer pill shows with its count badge, the ferratas to draw (grouped
* from OSM's segments), the one open in the card, and what a click on a line
* does. Both flat renderers and the 3D terrain map take this one object, so
* the three cannot drift apart on what the layer is.
*/
export interface FerrataMapLayer {
active: boolean;
onToggle: () => void;
status: FerrataStatus;
/** How many ferratas reach into the view on screen. */
count: number;
groups: readonly FerrataGroup[];
/** The ferrata open in the card, drawn highlighted. */
selectedId: string | null;
onSelect: FerrataSelectHandler;
}
/** The ferrata open in the card, and the OSM way that was picked on it. */
export interface FerrataSelection {
group: FerrataGroup;
segment: Ferrata;
}
/** The box around every point of every line the stage hands over, or null for none. */
export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>): FerrataBbox | null {
return ferrataPointsBbox(lines.flat());
}
/**
* The via ferrata layer of one map surface: off by default, and nothing is
* read or asked for while it is off.
*
* `onViewportChange` takes the visible area from whichever map is on screen
* (the flat planner map or the 3D terrain). While the layer is off the area is
* only remembered, not put into state, so panning a map with the layer off
* re-renders nothing; switching it on starts from the last area the map
* reported instead of waiting for the next pan. `areaLines` are the lines the
* trip's area is made of (tour routes, waypoints, places), prefetched once
* the layer is on. The selection names the OSM way that was clicked, so it
* follows its ferrata when more segments load and join it, and closes when the
* layer is switched off.
*/
export function useFerrataMapLayer(
tripId: number | null | undefined,
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [],
/** The tour's line as the elevation profile measures it; the ferratas along it load with the layer off too. */
routeLine: readonly (readonly [number, number])[] | null = null
) {
const [active, setActive] = useState(false);
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
const lastArea = useRef<FerrataBbox | null>(null);
const activeRef = useRef(false);
const area = ferrataAreaOf(areaLines);
const routeTiles = useMemo(() => ferrataTilesAlong(routeLine), [routeLine]);
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area, routeTiles);
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
const crossings = useMemo<FerrataCrossing[]>(() => routeFerrataCrossings(routeLine, groups), [routeLine, groups]);
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
const onViewportChange = useCallback((next: FerrataBbox) => {
lastArea.current = next;
if (activeRef.current) setBbox(next);
}, []);
const onToggle = useCallback(() => {
const next = !activeRef.current;
activeRef.current = next;
setActive(next);
setSelectedSegment(null);
if (next) setBbox(lastArea.current);
}, []);
const close = useCallback(() => setSelectedSegment(null), []);
const onSelect = useCallback<FerrataSelectHandler>((_group, segment) => setSelectedSegment(segment.id), []);
const selected = useMemo<FerrataSelection | null>(() => {
if (!active) return null;
const group = ferrataGroupOf(groups, selectedSegment);
const segment = group?.segments.find((candidate) => candidate.id === selectedSegment);
return group && segment ? { group, segment } : null;
}, [active, groups, selectedSegment]);
const selectedId = selected?.group.id ?? null;
const layer = useMemo<FerrataMapLayer>(
() => ({ active, onToggle, status, count, groups, selectedId, onSelect }),
[active, onToggle, status, count, groups, selectedId, onSelect]
);
return { layer, onViewportChange, selected, close, crossings };
}
@@ -0,0 +1,47 @@
// FE-FERRATA-USEPAINT-001 to FE-FERRATA-USEPAINT-003: the line colours follow the theme on <html>.
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { useFerrataPaint } from './useFerrataPaint';
const root = document.documentElement;
afterEach(() => {
root.style.removeProperty('--map-ferrata');
root.classList.remove('dark', 'unrelated');
root.removeAttribute('data-scheme');
});
describe('useFerrataPaint', () => {
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
expect(result.current.line).toBe('black');
});
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
await act(async () => {
root.setAttribute('data-scheme', 'forest');
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(result.current.line).toBe('ivory'));
});
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
const first = result.current;
await act(async () => {
root.classList.add('unrelated');
await Promise.resolve();
});
expect(result.current).toBe(first);
});
});
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint';
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
/**
* The line colours, resolved again whenever the light/dark class, the colour
* scheme or a custom accent on `<html>` changes. Map paint holds resolved
* strings rather than `var(--token)`, so without this a theme toggle would leave
* the lines in the old scheme's colours. Same observer approach as `useIsDark`,
* which only watches the class. The reference only changes when a colour does,
* so a memo keyed on it does not churn.
*/
export function useFerrataPaint(): FerrataPaint {
const [paint, setPaint] = useState(ferrataPaint);
useEffect(() => {
if (typeof document === 'undefined') return;
const observer = new MutationObserver(() => {
const next = ferrataPaint();
setPaint((current) => (sameFerrataPaint(current, next) ? current : next));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
return () => observer.disconnect();
}, []);
return paint;
}
@@ -0,0 +1,34 @@
// FE-FERRATA-GUARD-001: the press that closed a ferrata's card is kept from also acting on the map.
import { act, fireEvent, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useOutsidePressGuard } from './useOutsidePressGuard';
describe('useOutsidePressGuard', () => {
it('FE-FERRATA-GUARD-001: the guard drops the one map click after the closing press and is off once that click went by', () => {
vi.useFakeTimers();
try {
const handler = vi.fn();
const { result } = renderHook(() => useOutsidePressGuard());
const guarded = result.current.guard(handler);
expect(result.current.guard(undefined)).toBeUndefined();
act(() => result.current.arm());
guarded?.('first');
expect(handler).not.toHaveBeenCalled();
guarded?.('second');
expect(handler).toHaveBeenCalledWith('second');
// Armed, but the click lands somewhere that is not the map: the guard comes off right after it.
act(() => result.current.arm());
fireEvent.click(document.body);
act(() => {
vi.runAllTimers();
});
guarded?.('third');
expect(handler).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Keeps the press that closed the card from also acting on the map.
*
* `arm` goes to the overlay's `onOutsidePress`; `guard` wraps the map's own
* click handlers (adding a waypoint, say). The click that ends the closing
* press is dropped once, and the guard is off again as soon as that click has
* gone by, or at the next press when the first one never became a click (a
* pan that ended off the page). A press on another ferrata keeps working: its
* click opens that ferrata's card, which never runs a guarded handler.
*/
export function useOutsidePressGuard() {
const armed = useRef(false);
const disarm = useRef<(() => void) | null>(null);
useEffect(() => () => disarm.current?.(), []);
const arm = useCallback(() => {
disarm.current?.();
armed.current = true;
let timer: number | undefined;
const off = () => {
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener('click', afterClick, true);
document.removeEventListener('pointerdown', nextPress, true);
disarm.current = null;
};
// The map's handler runs during this click's dispatch; the guard comes off right after it.
const afterClick = () => {
document.removeEventListener('click', afterClick, true);
timer = window.setTimeout(() => {
armed.current = false;
off();
}, 0);
};
const nextPress = () => {
armed.current = false;
off();
};
document.addEventListener('click', afterClick, true);
// Listened for from the next task on, so the press being dispatched now does not count.
window.setTimeout(() => {
if (armed.current && disarm.current === off) document.addEventListener('pointerdown', nextPress, true);
}, 0);
disarm.current = off;
}, []);
const guard = useCallback(<A extends unknown[]>(handler: ((...args: A) => void) | undefined) => {
if (!handler) return undefined;
return (...args: A) => {
if (armed.current) {
armed.current = false;
return;
}
handler(...args);
};
}, []);
return { arm, guard };
}
@@ -0,0 +1,214 @@
/**
* FE-FERRATA-HOOK-001 to FE-FERRATA-HOOK-010: when the via ferrata layer asks
* the server, what it keeps, and what it reports while it does.
*
* The requests reach a public Overpass mirror behind the server, so the hook
* has to be frugal: nothing while the layer is off, a trip's cached tiles
* first, then only the tiles of the shared grid that are not loaded yet, never
* one twice, and none at all for an area the server would refuse or a device
* that is offline.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { ferrataRepo } from '../../repo/ferrataRepo';
import { FERRATA_AREA_PREFETCH_MAX_TILES, useTripFerratas } from './useTripFerratas';
vi.mock('../../repo/ferrataRepo', () => ({
ferrataRepo: { list: vi.fn(), cachedTiles: vi.fn(), saveTiles: vi.fn(), cacheName: vi.fn(() => 'trek-offline') },
}));
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: vi.fn() }));
/** Exactly the tile 930:236. */
const TILE: FerrataBbox = { south: 46.5, west: 11.8, north: 46.55, east: 11.85 };
const TILE_PARAM = '46.500,11.800,46.550,11.850';
/** The first block of the ring around it, south west of it: the ring goes out a 0.1 degree block at a time. */
const RING_FIRST_PARAM = '46.450,11.750,46.500,11.800';
/** A view inside the same tile, after a small pan. */
const INSIDE: FerrataBbox = { south: 46.51, west: 11.81, north: 46.54, east: 11.84 };
const WIDE: FerrataBbox = { south: 46, west: 11, north: 47, east: 12 };
const FERRATA: Ferrata = {
id: 'way/42',
name: 'Via delle Trincee',
grade: { raw: '4', level: 4, plus: null },
routeName: null,
geometry: [
[46.52, 11.82],
[46.53, 11.82],
],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const reply = (ferratas: Ferrata[] = [FERRATA]): FerratasResponse => ({
ferratas,
truncated: false,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
});
const network = (offline: boolean) =>
vi.mocked(useNetworkMode).mockReturnValue({ offline, forced: false, setForced: vi.fn() });
const params = () => vi.mocked(ferrataRepo.list).mock.calls.map(([, bbox]) => bbox);
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
network(false);
vi.mocked(ferrataRepo.list).mockResolvedValue(reply());
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([]);
vi.mocked(ferrataRepo.saveTiles).mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
/** Lets the cache read and settled requests run their handlers. */
async function drain() {
await act(async () => {
// Enough turns for the prefetches, which go out one after another, to settle too.
for (let i = 0; i < 40; i += 1) await Promise.resolve();
});
}
/** Lets the debounce run out, then everything it started settle. */
async function settle(ms = 200) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await drain();
}
describe('useTripFerratas', () => {
it('FE-FERRATA-HOOK-001: reads and asks for nothing while the layer is off', async () => {
const { result } = renderHook(() => useTripFerratas(7, TILE, false));
await settle(1_000);
expect(ferrataRepo.cachedTiles).not.toHaveBeenCalled();
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('idle');
});
it('FE-FERRATA-HOOK-002: switched on, asks for the missing tiles of the view after the debounce, then the ring', async () => {
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await drain();
expect(ferrataRepo.cachedTiles).toHaveBeenCalledWith(7);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('loading');
await settle();
expect(params()[0]).toBe(TILE_PARAM);
expect(params().slice(1)).toContain(RING_FIRST_PARAM);
expect(vi.mocked(ferrataRepo.list).mock.calls[0]?.[0]).toBe(7);
expect(result.current.status).toBe('ready');
expect(result.current.ferratas).toEqual([FERRATA]);
expect(ferrataRepo.saveTiles).toHaveBeenCalledWith(7, expect.any(Array), 'trek-offline');
});
it('FE-FERRATA-HOOK-003: draws the cached tiles at once and does not ask for them again', async () => {
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await drain();
expect(result.current.status).toBe('ready');
expect(result.current.ferratas).toEqual([FERRATA]);
await settle();
expect(params()).not.toContain(TILE_PARAM);
});
it('FE-FERRATA-HOOK-004: a pan inside loaded tiles asks for nothing, and keeps every line drawn', async () => {
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: TILE },
});
await settle();
const asked = params().length;
rerender({ view: INSIDE });
await settle();
expect(params()).toHaveLength(asked);
expect(result.current.ferratas).toEqual([FERRATA]);
expect(result.current.status).toBe('ready');
});
it('FE-FERRATA-HOOK-005: a fast pan ends in one request for where the map stopped', async () => {
const { rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
rerender({ view: TILE });
await settle();
expect(params()[0]).toBe(TILE_PARAM);
});
it('FE-FERRATA-HOOK-006: offline it asks for nothing and still draws what the device kept', async () => {
network(true);
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('offline');
expect(result.current.ferratas).toEqual([FERRATA]);
});
it('FE-FERRATA-HOOK-007: a view wider than the server takes reads as zoom in and asks for nothing', async () => {
const { result } = renderHook(() => useTripFerratas(7, WIDE, true));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('tooLarge');
});
it('FE-FERRATA-HOOK-008: reports the mirrors failing as an error and a refused box as zoom in', async () => {
vi.mocked(ferrataRepo.list).mockRejectedValue(new Error('502'));
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: TILE },
});
await settle();
expect(result.current.status).toBe('error');
vi.mocked(ferrataRepo.list).mockRejectedValue({ response: { status: 400 } });
rerender({ view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } });
await settle();
expect(result.current.status).toBe('tooLarge');
});
it('FE-FERRATA-HOOK-009: switched off and on again asks for no tile it already has', async () => {
const { result, rerender } = renderHook(({ on }) => useTripFerratas(7, TILE, on), {
initialProps: { on: true },
});
await settle();
const asked = params().length;
rerender({ on: false });
expect(result.current.status).toBe('idle');
rerender({ on: true });
await settle();
expect(params()).toHaveLength(asked);
expect(ferrataRepo.cachedTiles).toHaveBeenCalledTimes(1);
expect(result.current.ferratas).toEqual([FERRATA]);
expect(result.current.status).toBe('ready');
});
it("FE-FERRATA-HOOK-010: prefetches the trip's area in the background, but not one wider than the cap", async () => {
const area: FerrataBbox = { south: 46.0, west: 11.0, north: 46.1, east: 11.1 };
const first = renderHook(() => useTripFerratas(7, TILE, true, area));
await settle();
expect(params()).toContain('46.000,11.000,46.100,11.100');
first.unmount();
vi.mocked(ferrataRepo.list).mockClear();
// 2 by 2 degrees is 1600 tiles, past FERRATA_AREA_PREFETCH_MAX_TILES.
expect(FERRATA_AREA_PREFETCH_MAX_TILES).toBeLessThan(1600);
renderHook(() => useTripFerratas(8, null, true, { south: 44, west: 10, north: 46, east: 12 }));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,176 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import {
ferrataTileCount,
ferrataTileKey,
ferrataTileRange,
ferrataTilesOf,
growFerrataTileRange,
parseFerrataTileKey,
type Ferrata,
type FerrataBbox,
type FerrataTile,
type FerrataTileRange,
} from '@trek/shared';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { ferrataRepo } from '../../repo/ferrataRepo';
import { ferrataBboxFits, ferrataBboxParam } from './ferrataModel';
import { FerrataTileStore, type FerrataTileSnapshot } from './ferrataTileStore';
/**
* Where the layer stands for the view on screen. `tooLarge` and `offline` are
* answers to "why are there no new lines" that need no request at all: the
* server refuses a box wider than its limit, and an offline device should not
* fire one. Whatever was loaded before stays drawn in both.
*/
export type FerrataStatus = 'idle' | 'loading' | 'ready' | 'error' | 'tooLarge' | 'offline';
/** Long enough that a pan or a zoom gesture settles before the missing tiles are asked for. */
const DEBOUNCE_MS = 200;
/** The ring of tiles around the view that loads behind it, so a short pan finds its lines already there. */
const PREFETCH_RINGS = 1;
/**
* The trip's area is prefetched up to this many tiles (about 0.4 by 0.4
* degree, at most sixteen requests of 0.1 degree); a trip spread wider loads
* as the map gets there.
*/
export const FERRATA_AREA_PREFETCH_MAX_TILES = 64;
const NO_TILES: readonly string[] = [];
const EMPTY_SNAPSHOT: FerrataTileSnapshot = {
loaded: new Set(),
ferratas: [],
waiting: new Set(),
failure: null,
truncated: false,
hydrated: false,
};
const subscribeNothing = () => () => {};
const emptySnapshot = () => EMPTY_SNAPSHOT;
/** A range as a dependency-friendly string, and back. */
const rangeKeyOf = (range: FerrataTileRange) => `${range.minRow},${range.maxRow},${range.minCol},${range.maxCol}`;
function rangeOfKey(key: string): FerrataTileRange {
const [minRow = 0, maxRow = 0, minCol = 0, maxCol = 0] = key.split(',').map(Number);
return { minRow, maxRow, minCol, maxCol };
}
/** `missing`: some tile of the view is not loaded yet. */
function statusOf(
active: boolean,
offline: boolean,
fits: boolean,
missing: boolean,
snapshot: FerrataTileSnapshot
): FerrataStatus {
if (!active) return 'idle';
if (offline) return 'offline';
if (!fits || (missing && snapshot.failure === 'refused')) return 'tooLarge';
if (!missing) return 'ready';
return snapshot.failure === 'unavailable' ? 'error' : 'loading';
}
/** One tile store per trip; it lives as long as the map surface does, so 2D, 3D and a toggle share it. */
function useTileStore(tripId: number | null | undefined): FerrataTileStore | null {
return useMemo(() => {
if (!tripId) return null;
const cacheName = ferrataRepo.cacheName();
return new FerrataTileStore({
fetch: (bbox, signal) => ferrataRepo.list(tripId, ferrataBboxParam(bbox), signal),
persist: (rows) => ferrataRepo.saveTiles(tripId, rows, cacheName),
});
}, [tripId]);
}
/**
* The via ferratas of a trip, for the map layer, loaded per tile of the shared
* 0.05 degree grid (see `FerrataTileStore`).
*
* Nothing is read or requested while the layer is off. Switched on, the trip's
* cached tiles come back from the device first and draw at once, offline too;
* then the view's missing tiles are asked for DEBOUNCE_MS after the map
* settles, the ring around them behind that, and `area` (the trip's own
* extent) in the background once, after the same pause, so the view's tiles
* are always asked for first. Every ferrata loaded stays drawn: a pan, a
* zoom, a switch between 2D and 3D or the layer off and on again never drops
* one, and never asks again for a tile it has.
*/
export function useTripFerratas(
tripId: number | null | undefined,
view: FerrataBbox | null,
enabled: boolean,
area: FerrataBbox | null = null,
routeTiles: readonly FerrataTile[] | null = null
): { ferratas: readonly Ferrata[]; status: FerrataStatus; truncated: boolean } {
const { offline } = useNetworkMode();
const store = useTileStore(tripId);
const snapshot = useSyncExternalStore(
store ? store.subscribe : subscribeNothing,
store ? store.getSnapshot : emptySnapshot
);
// The tiles a tour's line runs through load with the layer off too, for the warning
// that a route climbs a ferrata; only the layer's own state decides what it shows.
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
const layerOn = enabled && !!store;
const active = layerOn || (!!routeKey && !!store);
const fits = !!view && ferrataBboxFits(view);
const viewKey = view && fits ? rangeKeyOf(ferrataTileRange(view)) : null;
const areaRange = area && ferrataBboxFitsArea(area) ? ferrataTileRange(area) : null;
const areaKey =
areaRange && ferrataTileCount(areaRange) <= FERRATA_AREA_PREFETCH_MAX_TILES ? rangeKeyOf(areaRange) : null;
const visibleKeys = useMemo(
() => (viewKey ? ferrataTilesOf(rangeOfKey(viewKey)).map(ferrataTileKey) : NO_TILES),
[viewKey]
);
useEffect(() => {
if (!active || !store || !tripId) return;
if (store.beginHydration()) {
void ferrataRepo.cachedTiles(tripId).then(
(rows) => store.hydrate(rows),
// An unreadable cache is an empty one: the tiles load from the network instead.
() => store.hydrate([])
);
}
return () => store.stop();
}, [active, store, tripId]);
useEffect(() => {
if (!layerOn || !store || offline || !viewKey) return;
const timer = window.setTimeout(() => {
const range = rangeOfKey(viewKey);
store.want(ferrataTilesOf(range), 'visible');
store.want(ferrataTilesOf(growFerrataTileRange(range, PREFETCH_RINGS)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [layerOn, store, offline, viewKey]);
// Declared after the view's effect, so with both due its timer fires second.
useEffect(() => {
if (!layerOn || !store || offline || !areaKey) return;
const timer = window.setTimeout(() => {
store.want(ferrataTilesOf(rangeOfKey(areaKey)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [layerOn, store, offline, areaKey]);
useEffect(() => {
if (!active || !store || offline || !routeKey) return;
const timer = window.setTimeout(() => {
const tiles = routeKey.split('|').flatMap((key) => parseFerrataTileKey(key) ?? []);
store.want(tiles, 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, routeKey]);
const missing = visibleKeys.some((key) => !snapshot.loaded.has(key));
const status = statusOf(layerOn, offline, fits, missing, snapshot);
return { ferratas: snapshot.ferratas, status, truncated: layerOn && snapshot.truncated };
}
/** A trip's area is any real box; only its tile count limits the prefetch. */
function ferrataBboxFitsArea(bbox: FerrataBbox): boolean {
return bbox.north >= bbox.south && bbox.east >= bbox.west;
}
@@ -1,7 +1,7 @@
// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-024: the journey entry form logic behind both
// the desktop editor dialog and the phone entry sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import type React from 'react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
@@ -341,6 +341,51 @@ describe('useJourneyEntryForm', () => {
expect(get).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-016b: the weather it fills in is a suggestion, so an untouched entry closes without asking', async () => {
vi.spyOn(weatherApi, 'get').mockResolvedValue({ main: 'Rain', description: 'light rain' } as never);
const { result } = setup({ entry: entry({ weather: '' }), autoFillWeather: true });
await waitFor(() => expect(result.current.weather).toBe('rainy'));
// 5.0.0-pre.1 feedback: Cancel asked about unsaved changes nobody made.
expect(result.current.isDirty).toBe(false);
act(() => result.current.setWeather('sunny'));
expect(result.current.isDirty).toBe(true);
act(() => result.current.setWeather(''));
expect(result.current.isDirty).toBe(false);
act(() => result.current.setWeather('rainy'));
expect(result.current.isDirty).toBe(false);
});
it('FE-JRN-ENTRYFORM-016d: an icon picked while the forecast is out, then cleared, stays cleared', async () => {
let answer: (value: unknown) => void = () => {};
const get = vi
.spyOn(weatherApi, 'get')
.mockImplementation(() => new Promise((resolve) => (answer = resolve)) as never);
const { result } = setup({ entry: entry({ weather: '' }), autoFillWeather: true });
act(() => result.current.setWeather('sunny'));
await act(async () => answer({ main: 'Rain', description: 'light rain' }));
act(() => result.current.setWeather(''));
expect(result.current.weather).toBe('');
expect(get).toHaveBeenCalledTimes(1);
});
it("FE-JRN-ENTRYFORM-016c: StrictMode's double mount does not swallow the weather", async () => {
vi.spyOn(weatherApi, 'get').mockResolvedValue({ main: 'Rain', description: 'light rain' } as never);
const props: JourneyEntryFormOptions = {
entry: entry({ weather: '' }),
journeyId: 3,
trips: [],
galleryPhotos: [],
onSave: vi.fn().mockResolvedValue(4),
onUploadPhotos: vi.fn().mockResolvedValue({ ok: [], failed: [] }),
onDone: vi.fn(),
autoFillWeather: true,
};
// The first mount's answer is dropped by its cleanup; the second mount has to ask again.
const { result } = renderHook(() => useJourneyEntryForm(props), { wrapper: React.StrictMode });
await waitFor(() => expect(result.current.weather).toBe('rainy'));
});
it('FE-JRN-ENTRYFORM-017: quick capture locates the traveller and names the place and the weather', async () => {
locate.mockResolvedValue({ lat: 5, lng: 6 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: 'Main St' } as never);
@@ -106,6 +106,9 @@ export function useJourneyEntryForm({
const locationSearchSeqRef = useRef(0);
const [mood, setMood] = useState(entry.mood || '');
const [weather, setWeather] = useState(entry.weather || '');
// The icon the form filled in by itself (see the weather effect below). It is a
// suggestion, not an edit, so leaving the dialog must not ask about it.
const [suggestedWeather, setSuggestedWeather] = useState('');
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false);
// The trip this day belongs to, when the journey does not follow it yet (#2265).
const tripSuggestion = useJourneyTripSuggestion(
@@ -155,7 +158,7 @@ export function useJourneyEntryForm({
((locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null))) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
(weather !== (entry.weather || '') && weather !== suggestedWeather) ||
statsExcluded !== (entry.stats_excluded ?? false) ||
isDraft !== (entry.is_draft ?? false) ||
joinRows(pros) !== (entry.pros_cons?.pros ?? []).join('\n') ||
@@ -234,9 +237,17 @@ export function useJourneyEntryForm({
* day from the ERA5 archive, so writing up last Tuesday gets last Tuesday's weather.
*
* Only ever fills an empty field, and each place and day is tried once, so a cleared
* icon stays cleared and a chosen one is never overwritten.
* icon stays cleared and a chosen one is never overwritten. The tried place and day
* are forgotten on unmount only, so StrictMode's mount, cleanup, mount in development
* asks again, while picking or clearing an icon does not.
*/
const weatherTriedRef = useRef<string | null>(null);
useEffect(
() => () => {
weatherTriedRef.current = null;
},
[]
);
useEffect(() => {
if (!autoFillWeather) return;
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return;
@@ -253,9 +264,10 @@ export function useJourneyEntryForm({
// does not stop it reaching here in production.
if (!active || !result || result.error || typeof result.main !== 'string') return;
const category = journeyWeatherCategory(result.main, result.description ?? '');
// Re-checked rather than trusted from the closure: the request is a
// round trip and the traveller may have picked an icon while it was out.
setWeather((current) => current || category);
// No need to re-check the field: picking an icon while the request was out
// re-runs this effect, whose cleanup has already set `active` to false.
setSuggestedWeather(category);
setWeather(category);
})
.catch(() => {
/* no weather is a fine outcome for a journal entry */
@@ -106,6 +106,17 @@ describe('Navbar', () => {
expect(backBtns.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-012b: in a trip the back arrow and the trip name are one pill', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(<Navbar tripTitle="Paris 2026" showBack onBack={onBack} />);
const pill = screen.getByRole('button', { name: 'Back: Paris 2026' });
expect(pill).toHaveTextContent('Paris 2026');
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
await user.click(pill);
expect(onBack).toHaveBeenCalledOnce();
});
it('FE-COMP-NAVBAR-013: clicking back button calls onBack', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
@@ -612,6 +623,27 @@ describe('Navbar layout (#1983)', () => {
expect(pill.style.overflowX).toBe('auto');
});
it('keeps both columns at their content width, so the actions never spill over the pill', () => {
withAddons(8);
const { container } = render(<Navbar />);
const nav = container.querySelector('nav') as HTMLElement;
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
// A min-w-0 column shrinks below its content and its justify-end children
// overflow leftwards across the pill (5.0.0-pre.1 feedback).
for (const c of columns) {
expect(c.classList.contains('min-w-0')).toBe(false);
expect(c.classList.contains('min-w-max')).toBe(true);
}
});
it('shows the prerelease badge in the brand column, beside the logo', () => {
seedStore(useAuthStore, { isPrerelease: true, appVersion: '5.0.0-pre.1' });
const { container } = render(<Navbar />);
const nav = container.querySelector('nav') as HTMLElement;
const brandColumn = nav.children[0] as HTMLElement;
expect(brandColumn.contains(screen.getByText('5.0.0-pre.1'))).toBe(true);
});
it('still renders every addon as a reachable link, however many there are', () => {
withAddons(8);
render(<Navbar />);
+52 -31
View File
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed'
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -95,6 +96,16 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
return translated !== key ? translated : addon.name
}
const tabs = [{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
]
// Labels go when they no longer fit beside the logo and the actions; the
// title and aria-label on each tab keep it named.
const { ref: pillRef, collapsed: pillCollapsed } = useCollapseOnOverflow<HTMLDivElement>(tabs.map(tab => tab.label).join('|'))
return (
<nav style={{
background: dark
@@ -113,9 +124,13 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). */}
<div className="flex items-center gap-3 min-w-0 flex-1 basis-0">
{showBack && (
being centred on top of both (#1983). Both columns keep at least their
content width (min-w-max): a column squeezed below it spilled over the
pill, so the pill is the one that gives way, first by dropping its
labels and then by scrolling. On a trip page there is no pill, and the
trip title truncates instead. */}
<div className={`flex items-center gap-3 flex-1 basis-0 ${tripTitle ? 'min-w-0' : 'min-w-max'}`}>
{showBack && !tripTitle && (
<button type="button" onClick={onBack}
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0 text-content-muted"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -130,14 +145,34 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
</Link>
{tripTitle && (
<>
<span className="hidden sm:inline text-content-faint">/</span>
<span className="hidden sm:inline text-sm font-medium truncate max-w-48 text-content-muted">
{tripTitle}
</span>
</>
{/* Prerelease badge. Beside the logo it names the build, and it stays out
of the action cluster on the right, which has no room to spare once
plugins add tabs to the pill. */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
{appVersion}
</span>
)}
{/* In a trip the way back lives in the trip's own pill: arrow and name in one. */}
{tripTitle && (showBack ? (
<button
type="button"
onClick={onBack}
aria-label={`${t('common.back')}: ${tripTitle}`}
className="trek-back-btn inline-flex min-w-0 max-w-64 items-center gap-1 rounded-full bg-surface-card py-1 ps-1.5 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronLeft className="trek-back-icon h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="truncate text-caption font-semibold">{tripTitle}</span>
</button>
) : (
<span className="hidden sm:inline-flex min-w-0 max-w-56 items-center rounded-full bg-surface-card px-2.5 py-1 shadow-sm ring-1 ring-inset ring-edge-faint">
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span>
))}
</div>
{/* Centred liquid-glass tab menu (design handoff).
@@ -157,6 +192,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
than push the actions off the bar. */}
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
<div
ref={pillRef}
className="trek-nav-pill min-w-0"
style={{
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
@@ -166,17 +202,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
}}
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
].map(tab => {
{tabs.map(tab => {
const isActive = location.pathname === tab.path
return (
<Link key={tab.id} to={tab.path}
title={tab.label} aria-label={tab.label}
className="flex items-center gap-1.5 transition-colors"
className="flex items-center gap-1.5 whitespace-nowrap transition-colors"
style={{
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
@@ -186,7 +217,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
onMouseEnter={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-muted)' }}>
<tab.Icon className="w-4 h-4" />
<span className="hidden lg:inline">{tab.label}</span>
<span className={pillCollapsed ? 'hidden' : 'hidden lg:inline'}>{tab.label}</span>
</Link>
)
})}
@@ -212,7 +243,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
is centred on the bar (#1983). Was a bare flex-1 spacer followed by
loose siblings, which centred nothing and left the pill to fend for
itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-0 justify-end">
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
@@ -225,16 +256,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
)}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
{appVersion}
</span>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />}
@@ -268,7 +289,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{user.username?.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm hidden sm:inline max-w-24 truncate text-content-secondary">
<span className="text-sm hidden sm:inline max-w-40 truncate text-content-secondary">
{user.username}
</span>
<ChevronDown className="w-4 h-4 text-content-faint" />
@@ -1,8 +1,9 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -71,3 +72,134 @@ describe('MapLayerSwitcher', () => {
expect(parseFloat(button.style.width) + 2 * parseFloat(shell.style.padding)).toBe(MAP_ROUND_CONTROL_SIZE)
})
})
describe('TourMapLayerSwitcher 3D switch', () => {
it('FE-COMP-MAPLAYER-009: offers no 3D switch on a map that has no terrain mode', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
expect(screen.queryByRole('button', { name: '3D' })).toBeNull()
})
it('FE-COMP-MAPLAYER-010: toggles the terrain mode and shows whether it is on', () => {
const onToggleTerrain = vi.fn()
const { rerender } = render(<TourMapLayerSwitcher active="topo" onChange={() => {}} onToggleTerrain={onToggleTerrain} />)
const off = screen.getByRole('button', { name: '3D' })
expect(off).toHaveAttribute('aria-pressed', 'false')
expect(off).toHaveClass('text-content-muted')
expect(off.style.background).toBe('transparent')
fireEvent.click(off)
expect(onToggleTerrain).toHaveBeenCalledTimes(1)
rerender(<TourMapLayerSwitcher active="topo" onChange={() => {}} onToggleTerrain={onToggleTerrain} terrainActive />)
const on = screen.getByRole('button', { name: '3D' })
expect(on).toHaveAttribute('aria-pressed', 'true')
expect(on).toHaveClass('bg-accent', 'text-accent-text')
expect(on.style.background).toBe('')
// The base layer stays selectable beside it.
expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'true')
})
})
const ferrataLayer = (overrides: Partial<FerrataMapLayer> = {}): FerrataMapLayer => ({
active: false,
onToggle: vi.fn(),
status: 'idle',
count: 0,
groups: [],
selectedId: null,
onSelect: vi.fn(),
...overrides,
})
describe('TourMapLayerSwitcher via ferrata switch', () => {
it('FE-COMP-MAPLAYER-011: offers no via ferrata switch without a layer', () => {
const { container } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
expect(screen.queryByRole('button', { name: 'Via ferratas' })).toBeNull()
expect(container.firstElementChild).toHaveAttribute('role', 'group')
})
it('FE-COMP-MAPLAYER-012: the switch toggles the layer, pressed and in the accent while it is on', () => {
const layer = ferrataLayer()
const { rerender } = render(<TourMapLayerSwitcher active="topo" onChange={() => {}} ferrataLayer={layer} />)
const off = screen.getByRole('button', { name: 'Via ferratas' })
expect(off).toHaveAttribute('aria-pressed', 'false')
expect(off).toHaveClass('text-content-muted')
fireEvent.click(off)
expect(layer.onToggle).toHaveBeenCalledTimes(1)
rerender(<TourMapLayerSwitcher active="topo" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading' })} />)
const on = screen.getByRole('button', { name: 'Via ferratas' })
expect(on).toHaveAttribute('aria-pressed', 'true')
expect(on).toHaveClass('bg-accent', 'text-accent-text')
})
it('FE-COMP-MAPLAYER-013: the count rides in the switch as a badge, nothing beside the pill, and none while off', () => {
const { container, rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ count: 4 })} />)
expect(screen.queryByTestId('ferrata-count-badge')).toBeNull()
expect(container.firstElementChild).toHaveAttribute('role', 'group')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 4 })} />)
const badge = screen.getByTestId('ferrata-count-badge')
expect(badge).toHaveTextContent('4')
expect(badge).toHaveAttribute('data-tone', 'default')
expect(screen.getByRole('button', { name: 'Via ferratas' })).toContainElement(badge)
expect(screen.getByRole('status')).toHaveTextContent('4 via ferrata routes')
expect(screen.queryByTestId('ferrata-status-pill')).toBeNull()
})
it('FE-COMP-MAPLAYER-014: a tiny spinner in the badge while tiles load', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading', count: 2 })} />)
const badge = screen.getByTestId('ferrata-count-badge')
expect(badge).toContainElement(screen.getByTestId('ferrata-count-spinner'))
expect(badge).not.toHaveTextContent('2')
expect(screen.getByRole('status')).toHaveTextContent('Loading via ferratas…')
})
it('FE-COMP-MAPLAYER-015: offline, the mirrors down or the view too wide: a warning tone, the reason in the tooltip', async () => {
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'offline', count: 1 })} />)
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
expect(screen.getByTestId('ferrata-count-badge')).toHaveClass('bg-warning-soft', 'text-warning')
expect(screen.getByRole('status')).toHaveTextContent('Via ferratas need a connection')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'tooLarge' })} />)
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Via ferratas' }))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to load via ferratas')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'error' })} />)
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
expect(screen.getByRole('status')).toHaveTextContent('Overpass unreachable')
})
it('FE-COMP-MAPLAYER-016: sits beside the 3D switch behind one divider', () => {
render(
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 2 })} />
)
const group = screen.getByRole('group')
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D'])
})
it('FE-COMP-MAPLAYER-017: the "set points" switch leads the tools on its icon, pressed in the accent while on', async () => {
const onToggle = vi.fn()
const { rerender } = render(
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
ferrataLayer={ferrataLayer()} placeTool={{ active: true, onToggle }} />
)
const group = screen.getByRole('group')
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-3)).toEqual(['Set points', 'Via ferratas', '3D'])
const tool = screen.getByRole('button', { name: 'Set points' })
expect(tool).toHaveAttribute('aria-pressed', 'true')
expect(tool).toHaveClass('bg-accent', 'text-accent-text')
expect(tool).not.toHaveTextContent('Set points')
fireEvent.click(tool)
expect(onToggle).toHaveBeenCalledTimes(1)
fireEvent.mouseEnter(tool)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Set points')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} placeTool={{ active: false, onToggle }} />)
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
})
})
+155 -18
View File
@@ -1,5 +1,10 @@
import { Map as MapIcon, Mountain, Satellite } from 'lucide-react'
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import type { CSSProperties } 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'
@@ -71,13 +76,133 @@ const TOUR_LAYERS = [
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
/** Per-view Tours control. It never reads or writes the global map setting. */
/** 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. */
function ferrataStatusLabel(status: Exclude<FerrataStatus, 'idle'>, count: number, t: Translate): string {
switch (status) {
case 'loading':
return t('ferratas.loading')
case 'error':
return t('ferratas.error')
case 'offline':
return t('ferratas.offline')
case 'tooLarge':
return t('ferratas.zoomIn')
case 'ready':
return t('ferratas.count', { count })
}
}
/**
* The via ferrata switch with its count as a badge: how many ferratas reach
* into the view, a small spinner while tiles load, and the warning tone when
* there is a reason it cannot load (offline, the mirrors down, the view too
* wide), which the tooltip names. Nothing beside the switch, so the bottom row
* stays one pill wide. `compact` drops the word and keeps the icon and the
* badge; the tooltip and the accessible name still say what it is.
*/
function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; compact?: boolean }) {
const { t } = useTranslation()
const { active, status, count } = layer
const live = active && status !== 'idle' ? ferrataStatusLabel(status, count, t) : null
const problem = status === 'error' || status === 'offline' || status === 'tooLarge'
return (
<>
<Tooltip label={live ?? t('ferratas.layer')} placement="top">
<button
type="button"
onClick={layer.onToggle}
aria-label={t('ferratas.layer')}
aria-pressed={active}
className={active ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(active)}
>
<FerrataLineIcon size={15} strokeWidth={2} />
{!compact && <span>{t('ferratas.layer')}</span>}
{live && (
<span
aria-hidden
data-testid="ferrata-count-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' : 'bg-accent-text text-accent'
}`}
style={fs(10)}
>
{status === 'loading'
? <Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" data-testid="ferrata-count-spinner" />
: count}
</span>
)}
</button>
</Tooltip>
{/* Polite, so a screen reader hears the count arrive without being interrupted. */}
<span role="status" aria-live="polite" className="sr-only">{live ?? ''}</span>
</>
)
}
/** The tour map's "set points" tool: while on, a click on the map places a point. */
export interface MapPlaceTool {
active: boolean
onToggle: () => void
}
/** The switch for the "set points" tool, on its icon, named by its tooltip. */
function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
const { t } = useTranslation()
const label = t('tours.planner.placePoints')
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={tool.onToggle}
aria-label={label}
aria-pressed={tool.active}
data-testid="map-place-tool"
className={tool.active ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(tool.active)}
>
<MapPinPlus size={15} strokeWidth={2} />
</button>
</Tooltip>
)
}
/**
* Per-view Tours control. It never reads or writes the global map setting.
* `compact` shows the base layers and the via ferrata switch by their icons
* alone, each named by its tooltip, for a bottom row that has to share its
* width (the 3D map's, beside the flyover dock).
*/
export function TourMapLayerSwitcher({
active,
onChange,
onToggleTerrain,
terrainActive = false,
ferrataLayer,
placeTool,
compact = false,
}: {
active: TourBaseLayer
onChange: (layer: TourBaseLayer) => void
/** Offers the 3D terrain switch beside the base layers. */
onToggleTerrain?: () => void
terrainActive?: boolean
/** Offers the via ferrata switch, with its count badge, beside the base layers. */
ferrataLayer?: FerrataMapLayer
/** Offers the "set points" switch, while a tour is being edited. */
placeTool?: MapPlaceTool
compact?: boolean
}) {
const { t } = useTranslation()
@@ -87,24 +212,36 @@ export function TourMapLayerSwitcher({
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={selected}
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: selected ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}
>
<Icon size={15} strokeWidth={2} />
<span>{label}</span>
</button>
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
<button
type="button"
onClick={() => onChange(id)}
aria-pressed={selected}
aria-label={compact ? label : undefined}
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
style={segmentStyle(selected)}
>
<Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
</button>
</Tooltip>
)
})}
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
{onToggleTerrain && (
<button
type="button"
onClick={onToggleTerrain}
aria-pressed={terrainActive}
className={terrainActive ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(terrainActive)}
>
<Rotate3d size={15} strokeWidth={2} />
<span>3D</span>
</button>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { Check, Undo2 } from 'lucide-react';
import { fs } from '../shared/DialogShell';
import { MAP_CONTROL_SHADOW } from './mapControlShadow';
export interface MapUndoHintProps {
message: string;
undoLabel: string;
onUndo: () => void;
/** True while the pointer rests on the hint, so it does not vanish under it. */
onHold?: (held: boolean) => void;
}
/**
* A short note at the foot of the map that something just happened there, with
* the way back: a glass pill like the map's other controls. The caller places it
* and decides how long it stays.
*/
export function MapUndoHint({ message, undoLabel, onUndo, onHold }: Readonly<MapUndoHintProps>) {
return (
<div
role="status"
aria-live="polite"
data-testid="map-undo-hint"
className="trek-toast-enter pointer-events-auto flex max-w-full items-center gap-2 rounded-full p-1 ps-3 text-content"
style={{
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
onMouseEnter={() => onHold?.(true)}
onMouseLeave={() => onHold?.(false)}
onFocus={() => onHold?.(true)}
onBlur={() => onHold?.(false)}
>
<Check size={14} strokeWidth={2.4} className="flex-none text-accent" aria-hidden />
<span className="min-w-0 truncate font-semibold" style={fs(12, 'body')}>
{message}
</span>
<button
type="button"
onClick={onUndo}
className="bg-accent-soft flex h-[30px] flex-none items-center gap-1.5 rounded-full px-3 font-semibold text-accent hover:opacity-90"
style={fs(11)}
>
<Undo2 size={13} strokeWidth={2.2} aria-hidden />
{undoLabel}
</button>
</div>
);
}
@@ -23,6 +23,13 @@ vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
}))
// What the via ferrata layer draws is FerrataLayerLeaflet's own suite; here only what the
// renderer hands it. jsdom has no SVG renderer for Leaflet to draw real lines into.
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({
FerrataMapLayerLeaflet: ({ layer }: { layer?: { active: boolean; groups: readonly unknown[] } }) =>
layer?.active ? <div data-testid="ferrata-leaflet-layer" data-count={layer.groups.length} /> : null,
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
@@ -34,6 +41,8 @@ vi.mock('../../services/photoService', () => ({
import L from 'leaflet'
import { MapView } from './MapView'
import { ContextMenu } from '../shared/ContextMenu'
import type { Ferrata } from '@trek/shared'
import { groupFerratas } from '../Ferratas/ferrataGroups'
// Every map this file builds, newest last, for the tests that reach past the component.
const maps: L.Map[] = []
@@ -94,6 +103,27 @@ function clickOn(el: Element) {
})
}
/** The Leaflet marker that renders `el`; throws when the map has none. */
function markerOf(map: L.Map, el: Element): L.Marker {
let found: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === el) found = layer
})
if (!found) throw new Error('no marker renders this element')
return found
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: 1 },
})
return event
}
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
const places = [
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
@@ -114,6 +144,136 @@ async function renderMap() {
}
describe('MapView on a real Leaflet map', () => {
it('RC-08: previews waypoint drags locally and commits only at pointer up without panning', async () => {
const waypoint = { id: 'via-1', lat: 34.689, lng: 135.8398, role: 'via' as const }
const waypoints = [waypoint]
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
/>
)
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
const marker = markerOf(map, pin)
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
const expected = map.containerPointToLatLng(end)
const center = map.getCenter()
act(() => {
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
})
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
expect(onMove).not.toHaveBeenCalled()
expect(waypoints[0]).toEqual(waypoint)
view.rerender(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
act(() => {
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
pin.dispatchEvent(mouse('mouseup', end.x, end.y))
pin.dispatchEvent(mouse('click', end.x, end.y))
})
expect(onMove).toHaveBeenCalledOnce()
expect(onMove).toHaveBeenCalledWith(waypoint.id, expected.lat, expected.lng)
expect(onSelect).not.toHaveBeenCalled()
expect(onMapClick).not.toHaveBeenCalled()
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
view.rerender(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[{ ...waypoint, lat: expected.lat, lng: expected.lng }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[expected.lat, expected.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
})
it('RC-08: cancellation restores the marker and a plain click still selects without affecting map-add', async () => {
const waypoint = { id: 'start', lat: 34.689, lng: 135.8398, role: 'start' as const }
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
/>
)
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
const marker = markerOf(map, pin)
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
act(() => {
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
})
expect(marker.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
expect(onMove).not.toHaveBeenCalled()
clickOn(pin)
expect(onSelect).toHaveBeenCalledOnce()
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
expect(onMapClick).not.toHaveBeenCalled()
act(() => { map.fire('click', { latlng: L.latLng(34.7, 135.85) }) })
expect(onMapClick).toHaveBeenCalledOnce()
})
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
@@ -297,4 +457,30 @@ describe('MapView on a real Leaflet map', () => {
panMap(container)
expect(map.getCenter()).not.toEqual(before)
})
it('RC-10: the tour layer pill offers the via ferrata switch, and the layer is drawn only while it is on', async () => {
const ferrata: Ferrata = { id: 'way/42', name: 'Via delle Trincee', grade: { raw: '4', level: 4, plus: null }, routeName: null,
geometry: [[46.52, 11.8], [46.53, 11.81]], osmUrl: 'https://www.openstreetmap.org/way/42' }
const layer = {
active: false, onToggle: vi.fn(), status: 'idle' as const, count: 0, groups: groupFerratas([ferrata]),
selectedId: null, onSelect: vi.fn(),
}
const view = render(
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={layer} />,
)
const toggle = await view.findByRole('button', { name: 'Via ferratas' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect(view.queryByTestId('ferrata-leaflet-layer')).toBeNull()
act(() => toggle.click())
expect(layer.onToggle).toHaveBeenCalledOnce()
view.rerender(
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={{ ...layer, active: true, status: 'ready', count: 1 }} />,
)
await waitFor(() => expect(view.getByTestId('ferrata-leaflet-layer')).toHaveAttribute('data-count', '1'))
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true')
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1')
})
})
@@ -442,6 +442,48 @@ describe('MapView', () => {
expect(mapMock.fitBounds).toHaveBeenCalled()
})
it('honors a location-search camera intent and preserves it through later route updates', () => {
const searchPoint: [number, number] = [35.0116, 135.7681];
const { rerender } = render(
<MapView
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
const mapContainer = screen.getByTestId('map-container');
mapMock.fitBounds.mockClear();
rerender(
<MapView
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
/>
);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
expect(screen.getByTestId('map-container')).toBe(mapContainer);
rerender(
<MapView
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
});
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(
+21 -32
View File
@@ -6,7 +6,10 @@ import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers'
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -108,12 +111,6 @@ function routeViaIcon(tone: string): L.DivIcon {
return icon
}
function formatViaDwell(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60)
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
function nightPauseIcon(via: RouteVia): L.DivIcon {
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
const cached = viaIconCache.get(key)
@@ -449,7 +446,7 @@ interface BoundsControllerProps {
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number
focusKey?: number | string
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
@@ -757,14 +754,9 @@ const MemoMarker = memo(function MemoMarker({
)
})
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
const size = selected ? 30 : 26
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer">${index + 1}</span>`,
})
/** The look of a planner control point, drawn by PlannerWaypointMarker and kept beside the route blue. */
function plannerWaypointPinCss(size: number, selected: boolean): string {
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
}
export const MapView = memo(function MapView({
@@ -836,9 +828,9 @@ export const MapView = memo(function MapView({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
}: MapViewProps) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
@@ -1306,21 +1298,16 @@ export const MapView = memo(function MapView({
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
<Marker
{plannerWaypoints.map((point, index) => (
<PlannerWaypointMarker
key={`planner-waypoint-${point.id}`}
position={[point.lat, point.lng]}
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
eventHandlers={{
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
onPlannerWaypointClick?.(point.id)
},
}}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
point={point}
index={index}
selected={point.id === selectedPlannerWaypointId}
onClick={onPlannerWaypointClick}
onMove={onPlannerWaypointMove}
pinCss={plannerWaypointPinCss}
/>
))}
{/* GPX imported route geometries */}
@@ -1346,6 +1333,7 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<FerrataMapLayerLeaflet layer={ferrataLayer} />
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
@@ -1392,7 +1380,8 @@ export const MapView = memo(function MapView({
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
@@ -0,0 +1,575 @@
import { act } from '@testing-library/react';
import maplibregl from 'maplibre-gl';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
import { useSettingsStore } from '../../store/settingsStore';
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
// Stable fake map so fitBounds call counts survive re-renders. The canvas
// container is a single element so listeners registered by the component are
// reachable from tests via dispatchEvent.
const glCanvasContainer = vi.hoisted(() => document.createElement('div'));
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
// Every overlay that adds a layer takes it away again on cleanup; a real
// MapLibre/Mapbox map has both halves, and a double with only the adding half
// fails the teardown rather than the behaviour under test.
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
setFilter: vi.fn(),
hasImage: vi.fn().mockReturnValue(false),
addImage: vi.fn(),
removeImage: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => glCanvasContainer),
getContainer: vi.fn(() => glCanvasContainer),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
// The MapLibre place pins position themselves with this, so a mock without it
// never exercises the path the real map takes.
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
easeTo: vi.fn(),
getCanvas: vi.fn(() => document.createElement('canvas')),
getBearing: vi.fn(() => 0),
setTerrain: vi.fn(),
setConfigProperty: vi.fn(),
queryTerrainElevation: vi.fn((): number | null => null),
}));
const glBounds = vi.hoisted(() => {
const state = {
instances: [] as Array<{ extend: ReturnType<typeof vi.fn> }>,
};
return {
get instances() {
return state.instances;
},
clear: () => {
state.instances = [];
},
create: () => {
const bounds = {
extend: vi.fn(() => bounds),
};
state.instances.push(bounds);
return bounds;
},
};
});
// Markers and popups are created through the mocked GL constructors, so the
// tests can reach the very instances the component built (their DOM element,
// the coordinates they were placed at, the popup content they were given).
interface FakeMarker {
element: HTMLElement;
lngLat: number[] | null;
setLngLat: (ll: number[]) => FakeMarker;
getLngLat: () => { lng: number; lat: number };
addTo: (map: unknown) => FakeMarker;
remove: () => void;
getElement: () => HTMLElement;
}
interface FakePopup {
setLngLat: (ll: unknown) => FakePopup;
setHTML: (html: string) => FakePopup;
setText: (text: string) => FakePopup;
setDOMContent: (node: HTMLElement) => FakePopup;
addTo: (map: unknown) => FakePopup;
remove: () => void;
}
const glMarkers = vi.hoisted(() => {
const created: FakeMarker[] = [];
return {
get created() {
return created;
},
clear: () => {
created.length = 0;
},
make: (opts?: { element?: HTMLElement }): FakeMarker => {
const element = opts?.element ?? document.createElement('div');
const marker: FakeMarker = {
element,
lngLat: null,
setLngLat: vi.fn((ll: number[]) => {
marker.lngLat = ll;
return marker;
}),
getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })),
addTo: vi.fn((_map: unknown) => marker),
remove: vi.fn(),
getElement: vi.fn(() => element),
};
created.push(marker);
return marker;
},
};
});
const glPopup = vi.hoisted((): FakePopup => {
const popup: FakePopup = {
setLngLat: vi.fn((_ll: unknown) => popup),
setHTML: vi.fn((_html: string) => popup),
setText: vi.fn((_text: string) => popup),
setDOMContent: vi.fn((_node: HTMLElement) => popup),
addTo: vi.fn((_map: unknown) => popup),
remove: vi.fn(),
};
return popup;
});
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
}));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}));
const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() }));
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => locationMarker),
}));
const reservationOverlay = vi.hoisted(() => ({
update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}),
destroy: vi.fn(),
}));
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () {
return reservationOverlay;
}),
}));
const geoStub = vi.hoisted(() => ({
position: null as GeoPosition | null,
mode: 'off' as TrackingMode,
error: null as string | null,
errorCode: null as string | null,
cycleMode: vi.fn(async () => {}),
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
}));
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => geoStub),
}));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}));
import mapboxgl from 'mapbox-gl';
import * as photoService from '../../services/photoService';
import { groupFerratas } from '../Ferratas/ferrataGroups';
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
import * as mapboxSetup from './mapboxSetup';
// The engine is a prop, not a module import (see MapViewGL.test.tsx). This shim makes
// the same choice glLazy.tsx makes in production.
function MapViewGL(props: Omit<React.ComponentProps<typeof MapViewGLWithEngine>, 'gl'>) {
return <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: 1 },
});
return event;
}
beforeEach(() => {
glMap.on.mockImplementation(() => glMap);
glMap.off.mockImplementation(() => glMap);
glMap.once.mockImplementation(() => glMap);
glMap.loaded.mockReturnValue(true);
glMap.getSource.mockReturnValue(null);
glMap.getLayer.mockReturnValue(null);
glMap.queryRenderedFeatures.mockReturnValue([]);
glMap.querySourceFeatures.mockReturnValue([]);
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'));
glMap.getBearing.mockReturnValue(0);
glMap.queryTerrainElevation.mockReturnValue(null);
// A busy style, a refused source and a custom layer list are each set by one test
// for the case it covers. Put back here, or every test after it would silently run
// against a map that never settles.
glMap.isStyleLoaded.mockReturnValue(true);
glMap.getStyle.mockReturnValue({ layers: [] });
glMap.addSource.mockImplementation(() => undefined);
glMarkers.clear();
// clearAllMocks() wipes call history but keeps implementations, so anything a
// test overrides with mockReturnValue has to be put back here.
vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false);
vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false);
vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false);
vi.mocked(photoService.getCached).mockReturnValue(undefined);
vi.mocked(photoService.isLoading).mockReturnValue(false);
vi.mocked(photoService.onThumbReady).mockReturnValue(() => {});
vi.mocked(photoService.getAllThumbs).mockReturnValue({});
geoStub.position = null;
geoStub.mode = 'off';
geoStub.error = null;
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
});
});
afterEach(() => {
vi.clearAllMocks();
glBounds.clear();
resetAllStores();
});
describe('MapViewGL', () => {
function loadOnAttach() {
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)();
return glMap;
});
}
it('honors a location-search camera intent and preserves it through later route updates', async () => {
loadOnAttach();
const searchPoint: [number, number] = [35.0116, 135.7681];
const { rerender } = render(
<MapViewGL
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
const mapConstructions = vi.mocked(maplibregl.Map).mock.calls.length;
glMap.fitBounds.mockClear();
rerender(
<MapViewGL
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).not.toHaveBeenCalled();
expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions);
rerender(
<MapViewGL
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
});
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
async (provider) => {
loadOnAttach();
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }));
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 });
glMap.getContainer.mockReturnValue(glCanvasContainer);
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const };
const waypoints = [waypoint];
const onMove = vi.fn(() => true);
const onSelect = vi.fn();
const onMapClick = vi.fn();
const view = render(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
glProvider={provider}
/>
);
await act(async () => {});
glMap.fitBounds.mockClear();
const pin =
provider === 'maplibre-gl'
? (glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement)
: (glMarkers.created.find((marker) => marker.element.getAttribute('aria-label') === 'Waypoint 1')
?.element as HTMLButtonElement);
const startTransform = pin.style.transform;
const libraryMarker =
provider === 'mapbox-gl' ? glMarkers.created.find((marker) => marker.element === pin) : undefined;
const startLngLat = libraryMarker?.lngLat;
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10));
pin.dispatchEvent(pointer('pointermove', 30, 40));
});
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
expect(onMove).not.toHaveBeenCalled();
expect(waypoints[0]).toEqual(waypoint);
view.rerender(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
);
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
act(() => {
pin.dispatchEvent(pointer('pointerup', 30, 40));
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(onMove).toHaveBeenCalledOnce();
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522);
expect(onSelect).not.toHaveBeenCalled();
expect(onMapClick).not.toHaveBeenCalled();
view.rerender(
<MapViewGL
places={[]}
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[48.4, 11.3]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
);
expect(glMap.fitBounds).not.toHaveBeenCalled();
expect(glMap.flyTo).not.toHaveBeenCalled();
expect(glMap.jumpTo).not.toHaveBeenCalled();
}
);
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
loadOnAttach();
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const };
const onMove = vi.fn(() => true);
const onSelect = vi.fn();
const onMapClick = vi.fn();
render(
<MapViewGL
places={[]}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement;
const initialTransform = pin.style.transform;
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10));
pin.dispatchEvent(pointer('pointermove', 30, 40));
pin.dispatchEvent(pointer('pointercancel', 30, 40));
});
expect(pin.style.transform).toBe(initialTransform);
expect(onMove).not.toHaveBeenCalled();
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10));
pin.dispatchEvent(pointer('pointerup', 10, 10));
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(waypoint.id);
expect(onMapClick).not.toHaveBeenCalled();
});
const FERRATA = {
id: 'way/42',
name: 'Via delle Trincee',
grade: { raw: '4', level: 4, plus: null },
routeName: null,
geometry: [
[46.52, 11.8],
[46.53, 11.81],
] as [number, number][],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const FERRATA_GROUPS = groupFerratas([FERRATA]);
const ferrataLayer = (active: boolean) => ({
active,
onToggle: vi.fn(),
status: active ? ('ready' as const) : ('idle' as const),
count: active ? 1 : 0,
groups: FERRATA_GROUPS,
selectedId: null,
onSelect: vi.fn(),
});
const addedFerratas = () => glMap.addSource.mock.calls.filter(([id]) => id === 'trek-ferratas');
it('RC-10: the via ferrata layer is drawn on the ready map only while it is on, its switch in the tour pill', async () => {
loadOnAttach();
const off = ferrataLayer(false);
const view = render(
<MapViewGL
places={[]}
viewBaseLayer="topo"
onViewBaseLayerChange={vi.fn()}
ferrataLayer={off}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(addedFerratas()).toHaveLength(0);
const toggle = view.getByRole('button', { name: 'Via ferratas' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
act(() => toggle.click());
expect(off.onToggle).toHaveBeenCalledOnce();
view.rerender(
<MapViewGL
places={[]}
viewBaseLayer="topo"
onViewBaseLayerChange={vi.fn()}
ferrataLayer={ferrataLayer(true)}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(addedFerratas()).toHaveLength(1);
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true');
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1');
});
it('RC-10: a click on a drawn ferrata opens it and is not a map click, so no control point lands under it', async () => {
loadOnAttach();
glMap.getLayer.mockImplementation((id: string) => (id === 'trek-ferratas-hit' ? { id } : null));
glMap.queryRenderedFeatures.mockImplementation((_point: unknown, options?: { layers?: string[] }) =>
options?.layers?.includes('trek-ferratas-hit') ? [{ properties: { id: FERRATA.id } }] : []
);
const layer = ferrataLayer(true);
const onMapClick = vi.fn();
render(
<MapViewGL
places={[]}
viewBaseLayer="topo"
onViewBaseLayerChange={vi.fn()}
ferrataLayer={layer}
onMapClick={onMapClick}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
const mapClicks = glMap.on.mock.calls
.filter(([event, handler]) => event === 'click' && typeof handler === 'function')
.map(([, handler]) => handler as (event: unknown) => void);
expect(mapClicks.length).toBeGreaterThan(0);
const click = {
point: { x: 5, y: 5 },
lngLat: { lat: 46.52, lng: 11.8 },
originalEvent: { target: document.createElement('div') },
};
act(() => mapClicks.forEach((handler) => handler(click)));
expect(onMapClick).not.toHaveBeenCalled();
expect(layer.onSelect).toHaveBeenCalledWith(FERRATA_GROUPS[0], FERRATA, { lat: 46.52, lng: 11.8 });
});
});
+25 -37
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
@@ -31,6 +32,8 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { groupCoincidentPlaces } from './coincidentPlaces'
import { useHazardLayerGL } from './useHazardLayerGL'
import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
import { formatViaDwell } from './routeViaDwell'
import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
@@ -418,12 +421,6 @@ function buildPluginLayerData(layers: PluginMapLayer[]) {
return { type: 'FeatureCollection' as const, features }
}
function formatViaDwellGl(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60)
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
/**
* A place pin on the map, whatever is carrying it.
*
@@ -568,6 +565,11 @@ export function brandLogoMarkup(_brandWikidata?: string | null): string {
return ''
}
/** The look of a planner control point, drawn by usePlannerWaypointMarkersGL. */
function plannerWaypointPinCss(size: number, selected: boolean): string {
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
}
export function MapViewGL({
places = NO_PLACES,
dayPlaces = NO_PLACES,
@@ -622,9 +624,9 @@ export function MapViewGL({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -656,6 +658,7 @@ export function MapViewGL({
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const [mapReady, setMapReady] = useState(false)
const [readyMap, setReadyMap] = useState<FerrataGlMap | null>(null) // the ready map, read without touching mapRef
// Hover tooltip — a cursor-following name/category/address card, matching the
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
@@ -682,6 +685,7 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -739,33 +743,14 @@ export function MapViewGL({
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId
const size = selected ? 30 : 26
const el = document.createElement('button')
el.type = 'button'
el.textContent = String(index + 1)
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
const swallow = (event: Event) => event.stopPropagation()
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
}
return () => {
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
}
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
const attachPlannerWaypointPin = useCallback(
(el: HTMLElement, lng: number, lat: number) => attachPin(mapRef.current, gl, pinLayerRef.current, el, lng, lat),
[gl],
)
usePlannerWaypointMarkersGL({
mapRef, mapReady, pinsRef: plannerWaypointPinsRef, attachPin: attachPlannerWaypointPin, pinCss: plannerWaypointPinCss,
plannerWaypoints, selectedPlannerWaypointId, onPlannerWaypointClick, onPlannerWaypointMove,
})
useEffect(() => () => {
profileFocusPinRef.current?.remove()
@@ -1500,6 +1485,7 @@ export function MapViewGL({
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
setReadyMap(map)
})
// Layer for the hand-positioned place pins, inside the canvas container so it
@@ -1548,6 +1534,7 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -1997,7 +1984,7 @@ export function MapViewGL({
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
}
if (v.label || v.dwellSeconds != null) {
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
const text = [v.label, v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : null].filter(Boolean).join(' · ')
el.addEventListener('click', (ev) => {
ev.stopPropagation()
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
@@ -2366,7 +2353,8 @@ export function MapViewGL({
}}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange} />
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -0,0 +1,86 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26;
return L.divIcon({
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
});
}
export default function PlannerWaypointMarker({
point,
index,
selected,
onClick,
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
index: number;
selected: boolean;
onClick?: (id: string) => void;
onMove?: (id: string, lat: number, lng: number) => boolean;
pinCss: PinCss;
}) {
const map = useMap();
const [marker, setMarker] = useState<L.Marker | null>(null);
const handlersRef = useRef({ onClick, onMove });
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
const eventHandlers = useMemo(
() => ({
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation();
handlersRef.current.onClick?.(point.id);
},
}),
[point.id]
);
useEffect(() => {
if (!marker || !onMove) return;
const element = marker.getElement();
if (!element) return;
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
},
setPosition: ({ lat, lng }) => {
marker.setLatLng([lat, lng]);
},
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
});
}, [map, marker, onMove, point.id, point.lat, point.lng]);
return (
<Marker
ref={setMarker}
position={position}
icon={icon}
zIndexOffset={selected ? 1200 : 1100}
eventHandlers={eventHandlers}
>
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
<span className="font-medium">{point.name ?? index + 1}</span>
{point.detail && <span className={MAP_LABEL_BADGE_CLASS}>{point.detail}</span>}
</Tooltip>
</Marker>
);
}
+134
View File
@@ -0,0 +1,134 @@
/**
* A name and a short detail floating over a GL map (the via ferrata under the
* pointer and its grade, a tour waypoint and its role), the twin of the
* Leaflet `map-tooltip`: the look of the shared Tooltip (card surface, faint
* edge, caption tier), just above the point it is shown for.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
/** The detail beside the name (a role, a grade) as a badge, as the shared pills draw one. */
export const MAP_LABEL_BADGE_CLASS =
'ms-1.5 inline-flex h-[18px] items-center rounded-full bg-surface-secondary px-1.5 font-semibold text-content-secondary ring-1 ring-inset ring-edge-faint';
const LABEL_CLASS =
'pointer-events-none absolute z-20 flex items-center whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
export interface MapHoverLabel {
/** `owner` is what the label is shown for, so a stale hide from another pin leaves it alone. */
show(text: { name: string; detail: string | null }, point: { x: number; y: number }, owner?: object): void;
/** Whether the label is up for this owner. */
shownFor(owner: object): boolean;
hide(): void;
remove(): void;
}
const NO_LABEL: MapHoverLabel = {
show: () => undefined,
shownFor: () => false,
hide: () => undefined,
remove: () => undefined,
};
export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
let shownOwner: object | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = testId;
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
// The map's own stylesheet sets Helvetica/Arial on its container; the label speaks in the app's face.
element.style.fontFamily = 'var(--font-system)';
container.appendChild(element);
return element;
};
return {
show({ name, detail }, { x, y }, owner) {
const label = ensure();
shownOwner = owner ?? null;
label.replaceChildren(document.createTextNode(name));
if (detail) {
const badge = document.createElement('span');
badge.className = MAP_LABEL_BADGE_CLASS;
badge.textContent = detail;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
// Not the hidden attribute: the label's `flex` class would win over it and keep it on screen.
label.style.display = '';
},
shownFor(owner) {
return !!element && element.style.display !== 'none' && shownOwner === owner;
},
hide() {
shownOwner = null;
if (element) element.style.display = 'none';
},
remove() {
element?.remove();
element = null;
},
};
}
/** What a map pin names on hover: its place and, beside it, its role. */
export interface PinHoverText {
name: string;
detail: string | null;
}
/**
* Shows the label above a pin while the pointer rests on it, and hides it the
* moment the pin is pressed, so it never trails a drag. A map that moves its
* markers every frame (the 3D terrain) can leave a pin without a leave event,
* so a move anywhere else on the map, or off it, hides the label too, as long
* as it is still this pin's. Returns the unwiring.
*/
export function bindPinHoverLabel(
pin: HTMLElement,
label: MapHoverLabel,
container: HTMLElement,
text: PinHoverText
): () => void {
const show = () => {
const box = pin.getBoundingClientRect();
const frame = container.getBoundingClientRect();
label.show(text, { x: box.left + box.width / 2 - frame.left, y: box.top - frame.top }, pin);
};
const hide = () => label.hide();
const away = (event: Event) => {
if (!label.shownFor(pin)) return;
if (event.type === 'pointerleave' || !(event.target instanceof Node && pin.contains(event.target))) label.hide();
};
pin.addEventListener('pointerenter', show);
pin.addEventListener('pointerleave', hide);
pin.addEventListener('pointerdown', hide);
container.addEventListener('pointermove', away);
container.addEventListener('pointerleave', away);
return () => {
pin.removeEventListener('pointerenter', show);
pin.removeEventListener('pointerleave', hide);
pin.removeEventListener('pointerdown', hide);
container.removeEventListener('pointermove', away);
container.removeEventListener('pointerleave', away);
};
}
+116
View File
@@ -0,0 +1,116 @@
// FE-MAP-PRESS-001 to FE-MAP-PRESS-008: only a real click on the map places a point, never a drag or a long hold.
import { describe, expect, it } from 'vitest';
import { createPressTracker, isPlacementTap, TAP_MAX_DURATION_MS, TAP_MAX_TRAVEL_PX } from './mapPress';
function pointer(type: string, x: number, y: number, pointerId = 1): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: x },
clientY: { value: y },
pointerId: { value: pointerId },
});
return event;
}
/** A tracker on a fresh element, with a clock the test moves. */
function setup() {
const el = document.createElement('div');
let clock = 0;
const tracker = createPressTracker(el, () => clock);
return {
el,
tracker,
at: (ms: number) => {
clock = ms;
},
send: (type: string, x: number, y: number, id = 1) => el.dispatchEvent(pointer(type, x, y, id)),
};
}
describe('isPlacementTap', () => {
it('FE-MAP-PRESS-001: a short press that barely moved places', () => {
expect(isPlacementTap({ travelPx: 0, durationMs: 80, pointers: 1 })).toBe(true);
expect(
isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX - 0.5, durationMs: TAP_MAX_DURATION_MS - 1, pointers: 1 })
).toBe(true);
});
it('FE-MAP-PRESS-002: 4 px of travel, 300 ms of hold or a second finger never place', () => {
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX, durationMs: 50, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: TAP_MAX_DURATION_MS, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: 50, pointers: 2 })).toBe(false);
});
it("FE-MAP-PRESS-003: a click no press was seen for (keyboard, synthetic) is the map's own call", () => {
expect(isPlacementTap(null)).toBe(true);
});
});
describe('createPressTracker', () => {
it('FE-MAP-PRESS-004: hands out the finished press once, with its travel and length', () => {
const { tracker, at, send } = setup();
at(1000);
send('pointerdown', 10, 10);
at(1100);
send('pointerup', 12, 11);
const press = tracker.take();
expect(press?.durationMs).toBe(100);
expect(press?.travelPx).toBeCloseTo(Math.hypot(2, 1));
expect(press?.pointers).toBe(1);
expect(isPlacementTap(press)).toBe(true);
expect(tracker.take()).toBeNull();
});
it('FE-MAP-PRESS-005: a drag that came back to where it started still counts as a drag', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 100, 100);
send('pointermove', 140, 100);
at(120);
send('pointerup', 100, 100);
const press = tracker.take();
expect(press?.travelPx).toBe(40);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-006: a pinch or a two-finger rotate is no tap, and ends with the last finger', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10, 1);
send('pointerdown', 50, 50, 2);
at(80);
send('pointerup', 50, 50, 2);
expect(tracker.take()).toBeNull();
send('pointerup', 10, 10, 1);
const press = tracker.take();
expect(press?.pointers).toBe(2);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-007: a cancelled press (the browser took it for a gesture) never places', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10);
at(50);
send('pointercancel', 10, 10);
expect(isPlacementTap(tracker.take())).toBe(false);
});
it('FE-MAP-PRESS-008: a new press drops the one nobody read, and dispose stops listening', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 0, 0);
at(500);
send('pointerup', 0, 0);
send('pointerdown', 0, 0);
expect(tracker.take()).toBeNull();
at(550);
send('pointerup', 0, 0);
expect(tracker.take()?.durationMs).toBe(50);
tracker.dispose();
send('pointerdown', 0, 0);
send('pointerup', 0, 0);
expect(tracker.take()).toBeNull();
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Telling a click that should put something on the map from the end of a pan,
* a rotation or a tilt. Both map engines fire their click after short drags
* and long holds too, which in 3D, where every look around is a drag, kept
* dropping waypoints nobody meant to set.
*/
/** How far the pointer may travel during a press that still counts as a click. */
export const TAP_MAX_TRAVEL_PX = 4;
/** How long a press that still counts as a click may last. */
export const TAP_MAX_DURATION_MS = 300;
/** One finished press on the map. */
export interface MapPress {
/** The farthest the first pointer got from where it went down. */
travelPx: number;
durationMs: number;
/** The most pointers down at once: two fingers pinch or rotate, they never place. */
pointers: number;
}
/**
* Whether a press places a point. A press that was never seen (a keyboard
* activation, a synthetic click) is left to the map, which only fires a click
* for it on purpose.
*/
export function isPlacementTap(press: MapPress | null): boolean {
if (!press) return true;
return press.pointers <= 1 && press.travelPx < TAP_MAX_TRAVEL_PX && press.durationMs < TAP_MAX_DURATION_MS;
}
export interface PressTracker {
/** The press that ended last, handed out once: the click it caused reads it, the next one does not. */
take: () => MapPress | null;
dispose: () => void;
}
interface Active {
id: number;
x: number;
y: number;
at: number;
travelPx: number;
pointers: Set<number>;
most: number;
cancelled: boolean;
}
/**
* Follows the presses on an element in the capture phase, so neither engine
* can swallow them first, and keeps the last finished one for the click that
* follows it.
*/
export function createPressTracker(target: EventTarget, now: () => number = () => performance.now()): PressTracker {
let active: Active | null = null;
let last: MapPress | null = null;
const down = (event: Event) => {
const e = event as PointerEvent;
if (active) {
active.pointers.add(e.pointerId);
active.most = Math.max(active.most, active.pointers.size);
return;
}
last = null;
active = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
at: now(),
travelPx: 0,
pointers: new Set([e.pointerId]),
most: 1,
cancelled: false,
};
};
const move = (event: Event) => {
const e = event as PointerEvent;
if (!active || e.pointerId !== active.id) return;
active.travelPx = Math.max(active.travelPx, Math.hypot(e.clientX - active.x, e.clientY - active.y));
};
const up = (event: Event) => {
const e = event as PointerEvent;
if (!active) return;
if (e.pointerId === active.id && !active.cancelled) {
move(e);
}
if (event.type === 'pointercancel') active.cancelled = true;
active.pointers.delete(e.pointerId);
if (active.pointers.size > 0) return;
last = {
travelPx: active.cancelled ? Number.POSITIVE_INFINITY : active.travelPx,
durationMs: now() - active.at,
pointers: active.most,
};
active = null;
};
const options = { capture: true, passive: true } as const;
target.addEventListener('pointerdown', down, options);
target.addEventListener('pointermove', move, options);
target.addEventListener('pointerup', up, options);
target.addEventListener('pointercancel', up, options);
return {
take: () => {
const press = last;
last = null;
return press;
},
dispose: () => {
target.removeEventListener('pointerdown', down, options);
target.removeEventListener('pointermove', move, options);
target.removeEventListener('pointerup', up, options);
target.removeEventListener('pointercancel', up, options);
},
};
}
+18 -3
View File
@@ -2,10 +2,11 @@ import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared';
import type { Day, Place, Reservation, RouteVia } from '../../types';
import type { ViewportPadding } from '../../utils/mapViewport';
import type { RouteProfileFocus } from '../../utils/routeGeometry';
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
import type { DayBoundaryControls } from './dayBoundaryDrag';
import type { CompassMap } from './MapCompassPill';
import type { TourBaseLayer } from './MapLayerSwitcher';
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
import type { Poi } from './poiCategories';
/*
@@ -108,12 +109,26 @@ export interface MapViewProps {
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
onPlannerWaypointMove?: (id: string, lat: number, lng: number) => boolean;
routeProfileFocus?: RouteProfileFocus | null;
viewBaseLayer?: TourBaseLayer;
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
/** Offers the 3D terrain switch in the tour layer pill. */
onToggleTerrain3d?: () => void;
/** Offers the "set points" switch in the tour layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
/**
* The via ferrata layer (RC-10): its switch, with the count as a badge, joins
* the tour layer pill, and while it is on its lines are drawn and a click on
* one hands the ferrata to `onSelect`. Both renderers mount it through the
* Ferratas modules.
*/
ferrataLayer?: FerrataMapLayer;
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
@@ -123,7 +138,7 @@ export interface MapViewProps {
*/
focusPoints?: [number, number][];
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number;
focusKey?: number | string;
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
function pointer(type: string, x: number, y: number, pointerId = 1, pointerType: 'mouse' | 'touch' = 'mouse'): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: pointerId },
pointerType: { value: pointerType },
});
return event;
}
describe('Tour waypoint marker drag lifecycle', () => {
it('previews during a drag and commits once on pointer up, swallowing only the generated click', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const onCommit = vi.fn(() => true);
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY / 100, lng: clientX / 100 }),
setPosition: preview,
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 11, 11));
expect(preview).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointermove', 20, 30));
expect(preview).toHaveBeenLastCalledWith({ lat: 0.3, lng: 0.2 });
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointerup', 30, 40));
expect(preview).toHaveBeenLastCalledWith({ lat: 0.4, lng: 0.3 });
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith({ lat: 0.4, lng: 0.3 });
const generatedClick = new MouseEvent('click', { bubbles: true, cancelable: true });
element.dispatchEvent(generatedClick);
expect(generatedClick.defaultPrevented).toBe(true);
const nextClick = new MouseEvent('click', { bubbles: true, cancelable: true });
element.dispatchEvent(nextClick);
expect(nextClick.defaultPrevented).toBe(false);
cleanup();
});
it('restores the starting coordinate on pointer cancellation and does not commit', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const onCommit = vi.fn();
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
setPosition: preview,
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 20, 30));
element.dispatchEvent(pointer('pointercancel', 20, 30));
expect(preview).toHaveBeenLastCalledWith(initial);
expect(onCommit).not.toHaveBeenCalled();
cleanup();
});
it('restores the preview when the canonical commit is rejected', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
setPosition: preview,
onCommit: () => false,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 20, 30));
element.dispatchEvent(pointer('pointerup', 20, 30));
expect(preview).toHaveBeenLastCalledWith(initial);
cleanup();
});
it('keeps pointer, mouse, and touch starts from bubbling into map pan handlers', () => {
const mapSurface = document.createElement('div');
const element = document.createElement('button');
mapSurface.append(element);
const mapPan = vi.fn();
mapSurface.addEventListener('pointerdown', mapPan);
mapSurface.addEventListener('mousedown', mapPan);
mapSurface.addEventListener('touchstart', mapPan);
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: () => ({ lat: 48, lng: 11 }),
setPosition: vi.fn(),
onCommit: vi.fn(),
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
element.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true }));
expect(mapPan).not.toHaveBeenCalled();
cleanup();
});
it('commits a touch-pointer drag once at release', () => {
const element = document.createElement('button');
const onCommit = vi.fn(() => true);
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: (clientX, clientY) => ({ lat: clientY / 10, lng: clientX / 10 }),
setPosition: vi.fn(),
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 5, 5, 9, 'touch'));
element.dispatchEvent(pointer('pointermove', 25, 35, 9, 'touch'));
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointerup', 25, 35, 9, 'touch'));
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith({ lat: 3.5, lng: 2.5 });
cleanup();
});
it('restores existing touch and selection styles when editing drag wiring is removed', () => {
const element = document.createElement('button');
element.style.touchAction = 'pan-x';
element.style.userSelect = 'text';
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: () => ({ lat: 48, lng: 11 }),
setPosition: vi.fn(),
onCommit: vi.fn(),
});
expect(element.style.touchAction).toBe('none');
expect(element.style.userSelect).toBe('none');
cleanup();
expect(element.style.touchAction).toBe('pan-x');
expect(element.style.userSelect).toBe('text');
});
});
@@ -0,0 +1,124 @@
interface PlannerWaypointCoordinate {
lat: number;
lng: number;
}
interface PlannerWaypointDragOptions {
element: HTMLElement;
initial: PlannerWaypointCoordinate;
coordinateAt: (clientX: number, clientY: number) => PlannerWaypointCoordinate;
setPosition: (coordinate: PlannerWaypointCoordinate) => void;
onCommit: (coordinate: PlannerWaypointCoordinate) => boolean | void;
}
const DRAG_THRESHOLD_PX = 4;
export function wirePlannerWaypointDrag({
element,
initial,
coordinateAt,
setPosition,
onCommit,
}: PlannerWaypointDragOptions): () => void {
let pointerId: number | null = null;
let start: { x: number; y: number } | null = null;
let dragged = false;
let suppressClick = false;
const originalTouchAction = element.style.touchAction;
const originalUserSelect = element.style.userSelect;
const stopMapGesture = (event: Event) => event.stopPropagation();
const restore = () => setPosition(initial);
const cancel = (event?: Event) => {
if (pointerId === null) return;
event?.stopPropagation();
restore();
pointerId = null;
start = null;
dragged = false;
suppressClick = false;
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || pointerId !== null) {
event.stopPropagation();
return;
}
event.stopPropagation();
pointerId = event.pointerId;
start = { x: event.clientX, y: event.clientY };
dragged = false;
suppressClick = false;
try {
element.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is unavailable in some test DOMs and older browsers.
}
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerId !== pointerId || !start) return;
event.stopPropagation();
if (!dragged && Math.hypot(event.clientX - start.x, event.clientY - start.y) < DRAG_THRESHOLD_PX) return;
dragged = true;
event.preventDefault();
setPosition(coordinateAt(event.clientX, event.clientY));
};
const onPointerUp = (event: PointerEvent) => {
if (event.pointerId !== pointerId || !start) return;
event.stopPropagation();
const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y);
if (dragged || travelled >= DRAG_THRESHOLD_PX) {
const coordinate = coordinateAt(event.clientX, event.clientY);
setPosition(coordinate);
const accepted = onCommit(coordinate) !== false;
suppressClick = true;
if (!accepted) restore();
}
pointerId = null;
start = null;
dragged = false;
};
const onClickCapture = (event: MouseEvent) => {
if (!suppressClick) return;
suppressClick = false;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const onTouchCancel = (event: Event) => cancel(event);
element.addEventListener('pointerdown', onPointerDown);
element.addEventListener('pointermove', onPointerMove);
element.addEventListener('pointerup', onPointerUp);
element.addEventListener('pointercancel', cancel);
element.addEventListener('lostpointercapture', cancel);
element.addEventListener('click', onClickCapture, true);
element.addEventListener('mousedown', stopMapGesture);
element.addEventListener('touchstart', stopMapGesture, { passive: true });
element.addEventListener('touchmove', stopMapGesture, { passive: true });
element.addEventListener('touchend', stopMapGesture, { passive: true });
element.addEventListener('touchcancel', onTouchCancel, { passive: true });
element.style.touchAction = 'none';
element.style.userSelect = 'none';
return () => {
cancel();
element.removeEventListener('pointerdown', onPointerDown);
element.removeEventListener('pointermove', onPointerMove);
element.removeEventListener('pointerup', onPointerUp);
element.removeEventListener('pointercancel', cancel);
element.removeEventListener('lostpointercapture', cancel);
element.removeEventListener('click', onClickCapture, true);
element.removeEventListener('mousedown', stopMapGesture);
element.removeEventListener('touchstart', stopMapGesture);
element.removeEventListener('touchmove', stopMapGesture);
element.removeEventListener('touchend', stopMapGesture);
element.removeEventListener('touchcancel', onTouchCancel);
element.style.touchAction = originalTouchAction;
element.style.userSelect = originalUserSelect;
};
}
@@ -0,0 +1,17 @@
// FE-COMP-VIADWELL-001 to FE-COMP-VIADWELL-002: the dwell label both map renderers put on a route via.
import { describe, expect, it } from 'vitest';
import { formatViaDwell } from './routeViaDwell';
describe('formatViaDwell', () => {
it('FE-COMP-VIADWELL-001: under an hour reads as minutes, rounded', () => {
expect(formatViaDwell(0)).toBe('0 min');
expect(formatViaDwell(25 * 60 + 29)).toBe('25 min');
expect(formatViaDwell(25 * 60 + 31)).toBe('26 min');
});
it('FE-COMP-VIADWELL-002: an hour or more splits the hours off', () => {
expect(formatViaDwell(3600)).toBe('1 h 0 min');
expect(formatViaDwell(2 * 3600 + 15 * 60)).toBe('2 h 15 min');
});
});
@@ -0,0 +1,11 @@
/**
* How long a route via (a charging stop, a rest area a plugin route places on
* the drawn day) holds the drive, as both map renderers label it: whole
* minutes, with the hours split off once there are any. One function for both,
* so the Leaflet tooltip and the GL label cannot disagree on the wording.
*/
export function formatViaDwell(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
return h > 0 ? `${h} h ${m} min` : `${m} min`;
}
@@ -0,0 +1,127 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
interface PlannerWaypointMap {
getContainer: () => HTMLElement;
unproject: (point: [number, number]) => { lat: number; lng: number };
/** Optional so a stand-in map without events still takes the pins. */
on?: (type: 'movestart', listener: () => void) => unknown;
off?: (type: 'movestart', listener: () => void) => unknown;
}
/** The slice of a hand-positioned pin the waypoint markers drive. */
interface PlannerWaypointPin {
remove: () => void;
setLngLat: (value: [number, number]) => void;
}
interface PlannerWaypointMarkersOptions<Pin extends PlannerWaypointPin> {
mapRef: { readonly current: PlannerWaypointMap | null };
mapReady: boolean;
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
pinsRef: { current: Pin[] };
/** Puts a waypoint element on the map at the given position. */
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
/** The pin's inline style for a size and selection; the renderer owns the colour. */
pinCss: (size: number, selected: boolean) => string;
plannerWaypoints: NonNullable<MapViewProps['plannerWaypoints']>;
selectedPlannerWaypointId: string | null;
onPlannerWaypointClick: MapViewProps['onPlannerWaypointClick'];
onPlannerWaypointMove: MapViewProps['onPlannerWaypointMove'];
}
/**
* The numbered control points of a list-first route editor, drawn as pins on the GL map.
*
* The handlers are read through a ref so a parent re-rendering with fresh callbacks does
* not tear the pins down mid-drag; only a change in whether they can be dragged does.
*/
export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
mapRef,
mapReady,
pinsRef,
attachPin,
pinCss,
plannerWaypoints,
selectedPlannerWaypointId,
onPlannerWaypointClick,
onPlannerWaypointMove,
}: PlannerWaypointMarkersOptions<Pin>): void {
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove });
useLayoutEffect(() => {
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove };
}, [onPlannerWaypointClick, onPlannerWaypointMove]);
const plannerWaypointsDraggable = !!onPlannerWaypointMove;
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
const dragCleanups: (() => void)[] = [];
const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label');
// A label left behind by a pan or a zoom would float over the wrong spot.
const hideOnMove = () => hoverLabel.hide();
map.on?.('movestart', hideOnMove);
dragCleanups.push(() => map.off?.('movestart', hideOnMove));
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
const size = selected ? 30 : 26;
const el = document.createElement('button');
el.type = 'button';
el.textContent = String(index + 1);
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
const swallow = (event: Event) => event.stopPropagation();
const select = (event: Event) => {
event.stopPropagation();
plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id);
};
el.addEventListener('pointerdown', swallow);
el.addEventListener('touchstart', swallow, { passive: true });
el.addEventListener('click', select);
const pin = attachPin(el, point.lng, point.lat);
pinsRef.current.push(pin);
dragCleanups.push(bindPinHoverLabel(el, hoverLabel, map.getContainer(), { name, detail: point.detail ?? null }));
if (plannerWaypointsDraggable) {
el.style.cursor = 'grab';
dragCleanups.push(
wirePlannerWaypointDrag({
element: el,
initial: { lat: point.lat, lng: point.lng },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) =>
plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
})
);
}
}
return () => {
dragCleanups.forEach((cleanup) => cleanup());
hoverLabel.remove();
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
};
}, [
attachPin,
mapReady,
mapRef,
pinCss,
pinsRef,
plannerWaypoints,
plannerWaypointsDraggable,
selectedPlannerWaypointId,
]);
}
@@ -0,0 +1,108 @@
import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import TourDayRowFacts from '../Tours/TourDayRowFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
interface DayPlanPlaceSublineProps {
place: Place;
cat: Category | undefined;
tourTimes: DayRowTourTimes | undefined;
isTourPlace: boolean;
timeFormat: string;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
if (!tourTimes) return false;
return tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null;
}
// Walking, break and planned-total pills of a tour stop that has no tour row of its own.
function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
const { t } = useTranslation();
const manual = tourTimes.manuallyOverridden;
return (
<>
{tourTimes.walkingMinutes != null && (
<TimePill>
<span
data-testid="trip-plan-tour-walking-time"
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
>
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{tourTimes.breakMinutes != null && (
<TimePill>
<span
data-testid="trip-plan-tour-breaks"
aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}
>
{t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span
data-testid="trip-plan-tour-planned-duration"
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
>
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
</>
);
}
// Description line; tour stops get two clamped lines instead of one.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
<div
data-testid={isTourPlace ? 'trip-plan-tour-description' : undefined}
className="collab-note-md min-w-0 flex-1 text-content-faint"
style={{
...fs(10.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
lineHeight: 1.3,
maxHeight: isTourPlace ? '2.6em' : '1.3em',
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
}}
>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
);
}
// Second line of a place row in the day plan: time, tour facts or planned
// tour durations, then the clamped description.
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
const description = place.description || place.address || cat?.name;
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
return (
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
}
@@ -1572,10 +1572,10 @@ describe('DayPlanSidebar', () => {
expect(document.querySelector('.note-edit-buttons')).toBeNull()
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
// Positional, not just by class: the reorder column sits at the row's right edge,
// with only the row's "…" after it.
expect(row.lastElementChild).toHaveAccessibleName('More options')
expect(row.lastElementChild?.previousElementSibling).toHaveClass('reorder-buttons')
// Positional: the actions close the row, the reorder column right before their "…".
const actions = row.lastElementChild
expect(actions?.lastElementChild).toHaveAccessibleName('More options')
expect(actions?.lastElementChild?.previousElementSibling).toHaveClass('reorder-buttons')
})
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
@@ -4015,8 +4015,7 @@ describe('DayPlanSidebar', () => {
expect(document.querySelectorAll('img[src*="a.jpg"]')).toHaveLength(1)
})
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut appears while the row is hovered', async () => {
const user = userEvent.setup()
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut comes out with the row hover in CSS, like the "…"', () => {
const place = buildPlace({ id: 1, name: 'Hover place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
@@ -4024,13 +4023,12 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
})} />)
const row = dragRow(screen.getByText('Hover place'))
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
fireEvent.mouseEnter(row)
await user.click(screen.getByLabelText('Add booking'))
// Already in the row, hidden and inert until the row is hovered. Waiting for a
// React hover state put it on screen a beat after the arrows and the "…".
const button = screen.getByLabelText('Add booking')
expect(button).toHaveClass('dp-add-booking')
fireEvent.click(button)
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
fireEvent.mouseLeave(row)
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
@@ -0,0 +1,244 @@
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
// and that it falls back to the plain Place row when it is off. The mocks are the
// ones DayPlanSidebar.test.tsx renders the sidebar with.
import { http, HttpResponse } from 'msw';
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import DayPlanSidebar from './DayPlanSidebar';
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
const mockDayNotesState = vi.hoisted(() => ({
noteUi: {} as Record<string, unknown>,
dayNotes: {} as Record<string, unknown[]>,
setNoteUi: vi.fn(),
noteInputRef: { current: null } as { current: null },
openAddNote: vi.fn(),
openEditNote: vi.fn(),
cancelNote: vi.fn(),
saveNote: vi.fn(),
deleteNote: vi.fn(),
moveNote: vi.fn(),
}));
// ── Module mocks ────────────────────────────────────────────────────────────
vi.mock('../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/client')>();
return {
...actual,
assignmentsApi: {
reorder: vi.fn().mockResolvedValue({}),
remove: vi.fn().mockResolvedValue({}),
updateTime: vi.fn().mockResolvedValue({}),
updateTransport: vi.fn().mockResolvedValue({}),
},
reservationsApi: {
list: vi.fn().mockResolvedValue({ reservations: [] }),
updatePositions: vi.fn().mockResolvedValue({}),
},
daysApi: {
...actual.daysApi,
updateTransport: vi.fn().mockResolvedValue({}),
},
};
});
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../Map/RouteCalculator', () => ({
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
Promise.resolve({
distanceText: '2 km',
durationText: '10 min',
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
distanceText: '2 km',
durationText: '10 min',
drivingText: '10 min',
walkingText: '25 min',
})),
})
),
}));
// PlaceAvatar needs IntersectionObserver
class MockIO {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeAll(() => {
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIO;
});
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
}));
vi.mock('../../hooks/useDayNotes', () => ({
useDayNotes: () => mockDayNotesState,
}));
vi.mock('../Weather/WeatherWidget', () => ({
default: (props: { locationName?: string | null }) => (
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
),
}));
// A stable toast object so tests can assert on the messages the sidebar raises.
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({
useToast: () => mockToast,
}));
vi.mock('../../store/permissionsStore', async (importOriginal) => ({
...(await importOriginal<typeof import('../../store/permissionsStore')>()),
useCanDo: () => () => true,
}));
const trip = buildTrip({ id: 1, currency: 'EUR' });
function makeDefaultProps(overrides = {}) {
return {
tripId: 1,
trip,
days: [],
places: [],
categories: [],
assignments: {},
selectedDayId: null,
selectedPlaceId: null,
selectedAssignmentId: null,
onSelectDay: vi.fn(),
onPlaceClick: vi.fn(),
onDayDetail: vi.fn(),
accommodations: [],
onReorder: vi.fn(),
onUpdateDayTitle: vi.fn(),
onRouteCalculated: vi.fn(),
onAssignToDay: vi.fn(),
onRemoveAssignment: vi.fn(),
onEditPlace: vi.fn(),
onDeletePlace: vi.fn(),
reservations: [],
onAddReservation: vi.fn(),
onNavigateToFiles: vi.fn(),
...overrides,
};
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
sessionStorage.clear();
localStorage.clear();
clearExchangeRateCache();
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])));
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as never);
});
describe('DayPlanSidebar', () => {
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
const props = makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([place.id]),
plannedTourDurations: new Map([[place.id, times]]),
assignments: { '10': [assignment] },
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
const view = render(<DayPlanSidebar {...props} />);
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
expect(tourPreview).toHaveTextContent(description);
expect(tourPreview.style.display).toBe('-webkit-box');
expect(tourPreview.style.webkitLineClamp).toBe('2');
expect(tourPreview.style.maxHeight).toBe('2.6em');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
view.rerender(
<DayPlanSidebar
{...{
...props,
plannedTourDurations: new Map([[place.id, { ...times, plannedTotalMinutes: 125, manuallyOverridden: true }]]),
}}
/>
);
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
view.rerender(<DayPlanSidebar {...props} />);
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
expect(view.container.querySelector('[data-testid="trip-plan-tour-planned-duration"]')).toBeNull();
expect(screen.getByText(description).closest('.collab-note-md')).toHaveStyle({
whiteSpace: 'nowrap',
maxHeight: '1.3em',
});
});
it('RC-01: uses facet type and compact facts only for Tours, preserving Tours-off Place presentation', () => {
const place = buildPlace({ id: 45, name: 'Ridge walk', description: 'Above the lake', tour_place_id: 45 });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
const tour = {
place_id: 45,
name: place.name,
tour_type: 'hike' as const,
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: 53,
break_additional_minutes: 10,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: true,
caution: false,
};
const props = makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([45]),
assignments: { '10': [assignment] },
plannedTourDurations: new Map([
[45, { tour, walkingMinutes: 53, breakMinutes: 10, plannedTotalMinutes: 63, manuallyOverridden: false }],
]),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
const view = render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
view.rerender(<DayPlanSidebar {...props} />);
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
});
});
@@ -34,6 +34,7 @@ import { routeModeIcon, useRouteModeOptions } from './routeModes'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
import './dayPlanRows.css'
/** The round white button a row carries on its right (a route on the map, a journey's legs). */
const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors'
@@ -60,7 +61,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
import { withDayPlanDefaults } from './DayPlanSidebar.defaults'
import { noteSurface } from './noteSurface'
import { dayBodyStyle, noteSurface } from './noteSurface'
import { findTodayDayId } from './today'
import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
@@ -80,6 +81,10 @@ import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import type { TourPlannedTimes } from '../Tours/tourPresentation'
import type { TourListItem } from '@trek/shared'
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
export interface DayPlanSidebarProps {
tripId: number
@@ -87,6 +92,7 @@ export interface DayPlanSidebarProps {
days: Day[]
places: Place[]
tourPlaceIds?: ReadonlySet<number>
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
@@ -1277,7 +1283,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const canManageShare = can('share_manage', trip)
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
@@ -1726,7 +1731,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
className="border-t border-edge-faint px-1.5 pb-1.5 pt-1.5"
// The activity list — the largest region and the one behind the
// densest text, so its tint is the faintest of the three.
style={{ background: dayTintBackground(dayTint, 'activity', '--day-tint-activity') ?? undefined }}
style={dayBodyStyle(dayTintBackground(dayTint, 'activity', '--day-tint-activity'))}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
@@ -1823,6 +1828,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const assignment = item.data
const place = assignment.place
if (!place) return null
const isTourPlace = toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)
const tourTimes = isTourPlace ? props.plannedTourDurations?.get(place.id) : undefined
const dayRowTour = tourTimes?.tour
const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
const isDraggingThis = draggingId === assignment.id
@@ -1967,8 +1975,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
selectPlace()
}}
onContextMenu={e => ctxMenu.open(e, placeMenu())}
onMouseEnter={() => setHoveredAssignmentId(assignment.id)}
onMouseLeave={() => setHoveredAssignmentId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
paddingBlock: 6, paddingInlineEnd: 4, paddingInlineStart: gripsShown ? 2 : 8,
@@ -1994,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onMouseLeave={() => setLockHoverId(null)}
className="relative flex flex-none rounded-full"
>
<PlaceAvatar place={place} category={cat} size={32} />
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || isLocked) && (
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
@@ -2006,7 +2012,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex min-w-0 items-center gap-1.5">
{cat && (() => {
{!dayRowTour && cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return (
<Tooltip label={cat.name}>
@@ -2027,20 +2033,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
{(place.place_time || place.description || place.address || cat?.name) && (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{(place.description || place.address || cat?.name) && (
<div className="collab-note-md min-w-0 flex-1 text-content-faint" style={{ ...fs(10.5), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3, maxHeight: '1.3em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
</div>
)}
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
@@ -2139,7 +2132,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
</div>
{/* The row's own controls, with room between them so each is its own target. */}
<div className="flex flex-none items-center gap-2.5 ps-1">
<div className="dp-row-actions flex flex-none items-center gap-2.5 ps-1">
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={moveUp} disabled={idx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge">
<ChevronUp size={12} strokeWidth={2} />
@@ -2148,14 +2141,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && (hoveredAssignmentId === assignment.id || isPlaceSelected) && (
{canEditDays && onAddBookingToAssignment && (
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
className="dp-add-booking grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
>
<Ticket size={12} strokeWidth={2} />
</button>
@@ -2505,7 +2498,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
background: noteSkin.background,
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
}}
'--dp-note-tint': noteSkin.background,
} as React.CSSProperties}
>
{canEditDays && !dragDisabled && <div className="dp-grip flex flex-none cursor-grab items-center text-content-faint opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-40">
<GripVertical size={13} strokeWidth={1.8} />
@@ -2526,17 +2520,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} aria-label={t('dayplan.moveDown')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
{canEditDays && (
{canEditDays && <div className="dp-note-actions flex flex-none items-center">
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} aria-label={t('dayplan.moveDown')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronDown size={12} strokeWidth={2} /></button>
</div>
<MoreButton label={t('files.menu')} size={24} items={[
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
]} />
)}
</div>}
</div>
</React.Fragment>
)
@@ -364,4 +364,43 @@ describe('LocationSelect', () => {
expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument();
expect(screen.getByText('Gare de Lyon')).toBeInTheDocument();
});
it('marks the keyboard-highlighted result as active and selects it with Enter', async () => {
const user = userEvent.setup();
server.use(searchRoute([GARE, LYON]));
const onPick = vi.fn();
render(<LocationSelect value={null} onChange={onPick} ariaLabel="Search map location" showSearchStatus />);
const input = screen.getByRole('combobox', { name: 'Search map location' });
await user.type(input, 'Gare');
const firstOption = await screen.findByRole('option', { name: /Gare du Nord/ });
await user.keyboard('{ArrowDown}');
expect(firstOption).toHaveAttribute('aria-selected', 'true');
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id);
await user.keyboard('{Enter}');
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Gare du Nord' }));
});
it('shows a localized no-result status after an empty successful search', async () => {
const user = userEvent.setup();
server.use(searchRoute([]));
render(<LocationSelect value={null} onChange={vi.fn()} ariaLabel="Search map location" showSearchStatus />);
await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Nowhere');
expect(await screen.findByText(/Nothing found for/)).toBeInTheDocument();
});
it('shows a non-mutating error status when the provider request fails', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/search', () => HttpResponse.json({ error: 'unavailable' }, { status: 503 })));
const onChange = vi.fn();
render(<LocationSelect value={null} onChange={onChange} ariaLabel="Search map location" showSearchStatus />);
await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Kyoto');
expect(await screen.findByRole('alert')).toHaveTextContent('Error');
expect(onChange).not.toHaveBeenCalled();
});
});
+138 -13
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'
import { MapPin } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useSuggestionDropdown } from './useSuggestionDropdown'
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
import { SoftPill } from './planParts'
export interface LocationPoint {
name: string
@@ -18,6 +20,11 @@ interface Props {
value: LocationPoint | null
onChange: (loc: LocationPoint | null) => void
placeholder?: string
ariaLabel?: string
/** Announces the field as a combobox and says "nothing found" or an error in its list. */
showSearchStatus?: boolean
/** Floats over a map as a frosted pill, like the map controls. */
floating?: boolean
style?: React.CSSProperties
/**
* The trip's own places, offered while the field is empty or holds fewer than three
@@ -25,9 +32,11 @@ interface Props {
* name; from the third character on the map search answers instead.
*/
places?: LocationPoint[]
/** Marks each search result with where it came from (OSM, Google, TREK, a plugin), as the place form does. */
showSource?: boolean
}
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places, showSource = false }: Props) {
const { t, locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
@@ -35,7 +44,14 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
const [query, setQuery] = useState(value?.name || '')
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
const [results, setResults] = useState<any[]>([])
// What answered the last search, for the rows that do not name their own source.
const [listSource, setListSource] = useState('')
const { sourceLabel } = usePlaceSuggestions()
const [loading, setLoading] = useState(false)
const [searchError, setSearchError] = useState(false)
// The text the last answer was for, so a status never describes older text.
const [searchedQuery, setSearchedQuery] = useState<string | null>(null)
const listId = `${useId()}-location-results`
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
@@ -47,6 +63,9 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
const trimmed = query.trim()
if (trimmed.length < 3 || (value && trimmed === value.name)) {
setResults([])
setLoading(false)
setSearchError(false)
setSearchedQuery(null)
return
}
// Clearing the timer does nothing to a request already out: an answer for
@@ -54,13 +73,21 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
let stale = false
debounceRef.current = setTimeout(async () => {
setLoading(true)
setSearchError(false)
setSearchedQuery(null)
try {
const data = await mapsApi.search(trimmed, placeLang, locationBias)
if (stale) return
setResults(data.places || [])
setListSource(data.source || '')
setHighlight(-1)
setSearchedQuery(trimmed)
} catch {
if (!stale) setResults([])
if (!stale) {
setResults([])
setSearchError(true)
setSearchedQuery(trimmed)
}
} finally {
if (!stale) setLoading(false)
}
@@ -79,38 +106,68 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
onChange(loc)
setQuery(loc.name)
setOpen(false)
setResults([])
resetSearch()
}
const clear = () => {
onChange(null)
setQuery('')
resetSearch()
}
function resetSearch() {
if (debounceRef.current) clearTimeout(debounceRef.current)
setResults([])
setLoading(false)
setSearchError(false)
setSearchedQuery(null)
}
// The trip's places stand in for the search below three characters. Derived rather
// than written into `results`, so a search answer that lands after the field was
// shortened cannot replace them, and a place already picked does not reopen them.
const typed = query.trim()
const showPicks = !value && typed.length < 3 && !!places?.length
const rows = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
const { showPicks, rows } = pickRows(value, typed, places, results)
const { showNoResults, showError, listOpen } = searchStatus({
showSearchStatus, showPicks, searchedQuery, typed, rowCount: rows.length, loading, searchError, open,
})
const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation')
const statusLine = renderStatusLine(showError, showNoResults, typed, t)
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Escape closes the list even while it only shows a status line.
if (e.key === 'Escape' && open) {
e.preventDefault()
setOpen(false)
return
}
handleKey(e, rows, pick)
}
return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
<SuggestionInputBox
icon={<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
icon={floating
? <Search size={16} strokeWidth={2} className="text-content-muted" style={{ flexShrink: 0 }} />
: <MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
floating={floating}
query={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); setSearchError(false); setSearchedQuery(null); if (value) onChange(null) }}
onOpen={() => setOpen(true)}
onKeyDown={(e) => handleKey(e, rows, pick)}
onKeyDown={onKeyDown}
onClear={value ? clear : undefined}
inputAria={showSearchStatus ? comboboxAria(accessibleLabel, listOpen, listId, highlight) : undefined}
/>
{open && (
<SuggestionList loading={loading && !showPicks} rowCount={rows.length}>
<SuggestionList
loading={loading && !showPicks}
rowCount={rows.length}
status={statusLine}
listbox={showSearchStatus ? { id: listId, label: accessibleLabel } : undefined}
floating={floating}
>
{rows.map((r, i) => (
<SuggestionRow
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
@@ -118,8 +175,10 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
onPick={() => pick(r)}
onHover={() => setHighlight(i)}
align="flex-start"
optionId={showSearchStatus ? `${listId}-option-${i}` : undefined}
>
<PlaceSuggestion name={r.name} address={r.address} />
{showSource && !showPicks && <SourceMark label={sourceLabel(r, listSource)} />}
</SuggestionRow>
))}
</SuggestionList>
@@ -127,3 +186,69 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
</div>
)
}
/** Where a result came from, quiet on purpose, beside its name. */
function SourceMark({ label }: { label: string | null }) {
if (!label) return null
return <span className="flex-none self-center"><SoftPill>{label}</SoftPill></span>
}
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
return { showPicks, rows }
}
interface SearchStatusInput {
showSearchStatus: boolean
showPicks: boolean
searchedQuery: string | null
typed: string
rowCount: number
loading: boolean
searchError: boolean
open: boolean
}
function searchStatus({ showSearchStatus, showPicks, searchedQuery, typed, rowCount, loading, searchError, open }: SearchStatusInput) {
const showNoResults =
showSearchStatus && !showPicks && searchedQuery === typed && rowCount === 0 && !loading && !searchError
const showError = showSearchStatus && searchError && searchedQuery === typed
const listOpen = open && ((loading && !showPicks) || rowCount > 0 || showNoResults || showError)
return { showNoResults, showError, listOpen }
}
function renderStatusLine(
showError: boolean,
showNoResults: boolean,
typed: string,
t: ReturnType<typeof useTranslation>['t'],
) {
return showError ? (
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('common.error')}
</div>
) : showNoResults ? (
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('help.center.searchEmpty', { query: typed })}
</div>
) : null
}
function comboboxAria(
accessibleLabel: string,
listOpen: boolean,
listId: string,
highlight: number,
): React.AriaAttributes & { role?: React.AriaRole } {
return {
role: 'combobox',
'aria-label': accessibleLabel,
'aria-autocomplete': 'list',
'aria-expanded': listOpen,
'aria-controls': listOpen ? listId : undefined,
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
}
}
@@ -73,5 +73,19 @@ describe('PlaceContactFields (#2472)', () => {
await user.click(screen.getByRole('button', { name: 'Take over from the place details' }))
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
})
it('FE-PLANNER-CONTACT-005: the place\'s phone and e-mail keep password managers out', () => {
render(<Harness onChange={vi.fn()} />)
// A place's address is not the user's login; 1Password filled it in (5.0.0-pre.1 feedback).
for (const label of ['Phone', 'E-mail']) {
const field = screen.getByLabelText(label)
expect(field).toHaveAttribute('data-1p-ignore', 'true')
expect(field).toHaveAttribute('data-lpignore', 'true')
expect(field).toHaveAttribute('data-bwignore', 'true')
expect(field).toHaveAttribute('autocomplete', 'off')
}
// Still an e-mail field, so the keyboard and the browser's check stay.
expect(screen.getByLabelText('E-mail')).toHaveAttribute('type', 'email')
})
})
@@ -7,6 +7,7 @@ import { DialogSection, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { cleanWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
import { useTranslation } from '../../i18n'
import { NO_AUTOFILL } from '../../utils/noAutofill'
interface Props {
/** Anchor for the "add by hand" jump from an empty search. */
@@ -80,14 +81,14 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
<div className="relative">
<Phone size={13} strokeWidth={2} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input id={`${id}-phone`} type="tel" value={phone} onChange={e => onChange('phone', e.target.value)}
maxLength={50} placeholder="+49 …" className={`${INPUT} ps-8`} />
maxLength={50} placeholder="+49 …" className={`${INPUT} ps-8`} {...NO_AUTOFILL} />
</div>
</EditorField>
<EditorField label={t('places.formEmail')} htmlFor={`${id}-email`}>
<div className="relative">
<Mail size={13} strokeWidth={2} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input id={`${id}-email`} type="email" value={email} onChange={e => onChange('email', e.target.value)}
maxLength={254} placeholder="info@…" className={`${INPUT} ps-8`} />
maxLength={254} placeholder="info@…" className={`${INPUT} ps-8`} {...NO_AUTOFILL} />
</div>
</EditorField>
</div>
@@ -901,6 +901,13 @@ describe('PlaceInspector', () => {
expect(container.querySelector('input[type="file"]')).toBeNull();
});
it('FE-PLANNER-INSPECTOR-044b: with upload but no files, the files row does not open to nothing', () => {
render(<PlaceInspector {...defaultProps} files={[]} />);
const row = screen.getByRole('button', { name: 'Files' });
expect(row).toBeDisabled();
expect(row).toHaveAttribute('aria-expanded', 'false');
});
// ── Participants section hidden when tripMembers <= 1 ─────────────────────
it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => {
@@ -1189,12 +1189,12 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
>
<div className={BOX}>
<button type="button" onClick={() => setFilesExpanded(f => !f)} aria-expanded={filesExpanded}
className="flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors hover:bg-surface-hover">
disabled={!placeFiles.length} className="group flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors enabled:hover:bg-surface-hover">
<FileText size={13} strokeWidth={2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint" /> : <ChevronDown size={13} className="flex-none text-content-faint" />}
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint group-disabled:hidden" /> : <ChevronDown size={13} className="flex-none text-content-faint group-disabled:hidden" />}
</button>
{filesExpanded && placeFiles.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-edge-faint p-1">
@@ -497,6 +497,20 @@ describe('Category filter dropdown', () => {
expect(screen.getByRole('button', { name: 'Museum' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-PLANNER-SIDEBAR-023b: the list opens across the whole filter row, not off its icon', async () => {
const user = userEvent.setup();
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
await openCategories(user);
// Hung off an icon near the end of the row, the list ran out of the sidebar.
const list = screen.getByRole('button', { name: 'Museum' }).closest('.trek-popover-enter') as HTMLElement;
expect(list).toHaveClass('inset-x-0');
// Its positioning ancestor is the row that holds every filter.
let anchor = list.parentElement;
while (anchor && !anchor.classList.contains('relative')) anchor = anchor.parentElement;
expect(anchor).toContainElement(screen.getByRole('button', { name: 'Show' }));
});
it('FE-PLANNER-SIDEBAR-024: selecting a category filters places', async () => {
const user = userEvent.setup();
const cat = buildCategory({ name: 'Park', color: '#22c55e' });
@@ -87,7 +87,8 @@ export function PlacesHeader(S: SidebarState) {
</BandButton>
)}
</div>
<div className="flex items-center gap-1.5">
{/* The filters' lists open across this whole row (see FilterDropdown). */}
<div className="relative flex items-center gap-1.5">
<ShowDropdown {...S} />
<CategoryFilter {...S} />
<RatingFilter {...S} />
@@ -172,9 +173,10 @@ function ShowDropdown(S: SidebarState) {
}
/**
* A filter as one icon on the tabs row, opening a small list under it; closes on a
* A filter as one icon on the tabs row, opening a list under the row; closes on a
* click outside or Escape. A badge marks it while it is narrowing the list, and the
* tooltip says to what.
* tooltip says to what. The list spans the whole row rather than hanging off its
* trigger: hung off an icon near the end, it ran out of the sidebar and was cut off.
*/
function FilterDropdown({ label, active, badge, icon, open, setOpen, name, children, wide = false, count }: {
/** What is in force: on the trigger when `wide`, else in the tooltip ("All categories", "4+"). */
@@ -210,7 +212,7 @@ function FilterDropdown({ label, active, badge, icon, open, setOpen, name, child
}
}, [open, setOpen])
return (
<div ref={wrapRef} className={`relative ${wide ? 'min-w-0 flex-1' : 'flex-none'}`}>
<div ref={wrapRef} className={wide ? 'min-w-0 flex-1' : 'flex-none'}>
{wide ? (
<button type="button" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox" aria-label={name}
className={`${CONTROL} flex w-full min-w-0 items-center gap-1.5 bg-surface-card px-2.5 font-semibold transition-colors ${active || open ? 'text-content' : 'text-content-secondary hover:text-content'}`}
@@ -233,7 +235,7 @@ function FilterDropdown({ label, active, badge, icon, open, setOpen, name, child
</Tooltip>
)}
{open && (
<div className={`trek-popover-enter absolute ${wide ? 'start-0' : 'end-0'} top-full z-50 mt-1.5 flex max-h-[min(60vh,360px)] flex-col overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover`} style={{ width: 'max-content', minWidth: 180, maxWidth: 260 }}>
<div className="trek-popover-enter absolute inset-x-0 top-full z-50 mt-1.5 flex max-h-[min(60vh,360px)] flex-col overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{children}
</div>
)}
@@ -1,6 +1,16 @@
import { MapPin, X } from 'lucide-react';
import type React from 'react';
import { useTranslation } from '../../i18n';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
// Over a map the field and its list take the map controls' frosted glass instead of a form box.
const FLOATING_GLASS: React.CSSProperties = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
border: 'none',
};
// The markup the planner's search fields share: the boxed input with its icon and
// clear button (AirportSelect, LocationSelect), the floating list (all three) and
@@ -15,6 +25,10 @@ interface SuggestionInputBoxProps {
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** Shown as the clear button while a value is picked. */
onClear?: () => void;
/** Floats over a map: a frosted pill like the map controls. */
floating?: boolean;
/** Combobox wiring for a field that announces its list (role, aria-expanded, ...). */
inputAria?: React.AriaAttributes & { role?: React.AriaRole };
}
export function SuggestionInputBox({
@@ -25,22 +39,37 @@ export function SuggestionInputBox({
onOpen,
onKeyDown,
onClear,
inputAria,
floating = false,
}: SuggestionInputBoxProps) {
return (
<div
className="bg-surface-tertiary"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 10px',
borderRadius: 10,
border: '1px solid var(--border-primary)',
}}
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
style={
floating
? {
...FLOATING_GLASS,
display: 'flex',
alignItems: 'center',
gap: 8,
height: 42,
padding: '0 8px 0 14px',
borderRadius: 999,
}
: {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 10px',
borderRadius: 10,
border: '1px solid var(--border-primary)',
}
}
>
{icon}
<input
type="text"
{...inputAria}
value={query}
placeholder={placeholder}
onChange={(e) => onType(e.target.value)}
@@ -76,19 +105,32 @@ interface SuggestionListProps {
/** Shows the loading line while there are no rows yet. */
loading: boolean;
rowCount: number;
/** A line above the rows, such as "nothing found"; keeps the list open without rows. */
status?: React.ReactNode;
/** Wraps the rows in a listbox when the field is a combobox. */
listbox?: { id: string; label: string };
/** Floats over a map: the same frosted glass as the field above it. */
floating?: boolean;
children: React.ReactNode;
}
/** The floating list under the field. Rendered only while it is loading or has rows; the caller checks `open`. */
export function SuggestionList({ loading, rowCount, children }: SuggestionListProps) {
/** The floating list under the field. Rendered only while it is loading, has rows or a status; the caller checks `open`. */
export function SuggestionList({
loading,
rowCount,
status,
listbox,
floating = false,
children,
}: SuggestionListProps) {
const { t } = useTranslation();
if (!loading && rowCount === 0) return null;
if (!loading && rowCount === 0 && !status) return null;
return (
<div
className="bg-surface-card"
className={floating ? undefined : 'bg-surface-card'}
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
top: floating ? 'calc(100% + 6px)' : 'calc(100% + 4px)',
insetInline: 0,
border: '1px solid var(--border-primary)',
borderRadius: 10,
@@ -96,14 +138,26 @@ export function SuggestionList({ loading, rowCount, children }: SuggestionListPr
maxHeight: 260,
overflowY: 'auto',
zIndex: 1000,
...(floating ? { ...FLOATING_GLASS, borderRadius: 16, padding: 4 } : {}),
}}
>
{loading && rowCount === 0 && (
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
<div
role={listbox ? 'status' : undefined}
className="text-content-faint"
style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
>
{t('common.loading')}
</div>
)}
{children}
{status}
{listbox ? (
<div id={listbox.id} role="listbox" aria-label={listbox.label}>
{children}
</div>
) : (
children
)}
</div>
);
}
@@ -114,13 +168,18 @@ interface SuggestionRowProps {
onHover: () => void;
/** How the row lines up its icon and text. */
align: 'center' | 'flex-start';
/** Set inside a listbox: the row becomes an option the input can point at. */
optionId?: string;
children: React.ReactNode;
}
export function SuggestionRow({ active, onPick, onHover, align, children }: SuggestionRowProps) {
export function SuggestionRow({ active, onPick, onHover, align, optionId, children }: SuggestionRowProps) {
return (
<button
type="button"
id={optionId}
role={optionId ? 'option' : undefined}
aria-selected={optionId ? active : undefined}
onClick={onPick}
onMouseEnter={onHover}
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
@@ -0,0 +1,69 @@
/* Day plan rows: the controls that only show on hover take no room at rest
either, so a row's text runs to the edge of its card. A place gives way while
they are out (its one line truncates). A note's text wraps over several
lines, so its controls fade in over the end of the text instead of
reflowing it. Hover, keyboard focus and an open menu bring them out, the
focus a click leaves on a row does not; on touch they stay in the row, like
the reorder arrows (index.css). */
@media (hover: hover) and (pointer: fine) {
.dp-row:not(:hover):not(:focus-visible):not(:has(:focus-visible)):not(:has([data-menu-open])) .dp-row-actions {
max-width: 0;
overflow: hidden;
padding-inline-start: 0;
}
/* The fade is the note's own surface: its tint over the day's tint (a plugin
may set one) over the card, drawn as layers because a tint alone is
see-through. All come from theme variables, so it matches in light and
dark and for every note colour. */
.dp-note-actions {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
padding-inline: 32px 4px;
border-start-end-radius: 11px;
border-end-end-radius: 11px;
background:
linear-gradient(to left, var(--dp-note-tint) 60%, transparent),
linear-gradient(to left, var(--dp-day-tint, transparent) 60%, transparent),
linear-gradient(to left, var(--bg-card) 60%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
[dir='rtl'] .dp-note-actions {
background:
linear-gradient(to right, var(--dp-note-tint) 60%, transparent),
linear-gradient(to right, var(--dp-day-tint, transparent) 60%, transparent),
linear-gradient(to right, var(--bg-card) 60%, transparent);
}
.dp-row:hover .dp-note-actions,
.dp-row:focus-visible .dp-note-actions,
.dp-row:has(:focus-visible) .dp-note-actions,
.dp-row:has([data-menu-open]) .dp-note-actions {
opacity: 1;
pointer-events: auto;
}
}
/* The add-booking ticket beside a place's arrows and "...": out with the row's
hover in CSS like them. A React hover state re-rendered the whole day list
first, so it always came out a beat behind the others. A picked place shows
no controls of its own; on touch, with no hover, the ticket stays out. */
.dp-add-booking {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.dp-row:hover .dp-add-booking,
.dp-row:focus-visible .dp-add-booking,
.dp-row:has(:focus-visible) .dp-add-booking,
.dp-row:has([data-menu-open]) .dp-add-booking {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.dp-add-booking {
opacity: 1;
pointer-events: auto;
}
}
@@ -1,3 +1,5 @@
import type { CSSProperties } from 'react'
/**
* The four surfaces a note colour produces (#1629).
*
@@ -34,3 +36,12 @@ export function noteSurface(color: string | null | undefined): NoteSurface {
iconColor: color,
}
}
/**
* A day's activity area in the plan: its tint as the background, and named as
* a variable for the note fades drawn over it (dayPlanRows.css), so a fade
* matches a day a plugin has tinted too.
*/
export function dayBodyStyle(tint: string | null | undefined): CSSProperties {
return { background: tint ?? undefined, '--dp-day-tint': tint ?? 'transparent' } as CSSProperties
}
+6 -4
View File
@@ -49,7 +49,9 @@ export interface MenuEntry {
/**
* The "…" of a row or card: the actions a right-click also offers, for the
* mouse that doesn't right-click and for the keyboard. Quiet at rest, it comes
* forward with its row's hover or focus (the row carries the `group` class).
* forward with its row's hover or keyboard focus (the row carries the `group`
* class). Keyboard focus only: a row a click selected keeps the focus, and the
* button stayed out on it after the mouse had left.
*/
export function MoreButton({ label, items, size = 26, alwaysVisible = false, className = '' }: {
label: string
@@ -66,8 +68,8 @@ export function MoreButton({ label, items, size = 26, alwaysVisible = false, cla
<>
<Tooltip label={label} disabled={!!menu.menu}>
<button type="button" aria-label={label} aria-haspopup="menu"
onClick={e => menu.open(e, entries, true)}
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
onClick={e => menu.open(e, entries, true)} data-menu-open={menu.menu ? '' : undefined}
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 group-has-[:focus-visible]:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
style={{ width: size, height: size }}>
<MoreHorizontal size={15} strokeWidth={2} />
</button>
@@ -98,7 +100,7 @@ export function TimePill({ children, className = '', quiet = false }: { children
return <WhiteBadge className={className} quiet={quiet} icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{children}</WhiteBadge>
}
export type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
// White badges on whatever tint the row has; the tone only colours the words and the icon.
const PILL_TONES: Record<PillTone, string> = {
@@ -0,0 +1,76 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
import { tourDayRowIcon } from './tourDayRowIcon';
const tour: TourListItem = {
place_id: 42,
name: 'Ridge',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: 53,
break_additional_minutes: 10,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: true,
caution: false,
};
describe('RC-01 Tour day-row facts', () => {
it('selects controlled type icons without enabling any types', () => {
expect(tourDayRowIcon('hike')).toBe(Mountain);
expect(tourDayRowIcon('bike')).toBe(Bike);
expect(tourDayRowIcon('walk')).toBe(Footprints);
expect(tourDayRowIcon('unknown')).toBe(Route);
render(<TourDayRowIcon type="hike" />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
});
it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
render(<TourDayRowFacts tour={tour} mobile={mobile} />);
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveAccessibleName('Breaks / additional time: 10 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
expect(screen.queryByText('80 m')).not.toBeInTheDocument();
});
it.each([null, 0])('omits missing metrics and empty breaks (%s)', (breaks) => {
render(
<TourDayRowFacts tour={{ ...tour, distance: null, elevation_gain: null, break_additional_minutes: breaks }} />
);
expect(screen.queryByTestId('plan-tour-distance')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-ascent')).not.toBeInTheDocument();
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('53 min');
});
it('supports hover, keyboard focus and touch help without opening the containing row', async () => {
const openRow = vi.fn();
render(
<div onClick={openRow}>
<TourDayRowFacts tour={tour} />
</div>
);
const walking = screen.getByTestId('trip-plan-tour-walking-time');
fireEvent.mouseEnter(walking);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
fireEvent.mouseLeave(walking);
const matches = vi.spyOn(walking, 'matches').mockImplementation((selector) => selector === ':focus-visible');
fireEvent.focus(walking);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
fireEvent.blur(walking);
matches.mockRestore();
fireEvent.click(walking);
expect(screen.getByRole('note')).toHaveTextContent('Walking time: 53 min');
expect(openRow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { Tooltip } from '../shared/Tooltip';
import { tourDayRowIcon } from './tourDayRowIcon';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
return (
<span
data-testid="plan-tour-icon"
aria-label={known ? t(`tourTypes.${type}`) : t('tours.mode.tours')}
className="grid flex-none place-items-center rounded-full"
style={{ width: size, height: size }}
>
{createElement(tourDayRowIcon(type), { size: Math.round(size * 0.6), 'aria-hidden': 'true' })}
</span>
);
}
function Fact({
icon: Icon,
value,
label,
testId,
mobile,
}: {
icon: LucideIcon;
value: string;
label: string;
testId: string;
mobile: boolean;
}) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<span className="relative inline-flex max-w-full">
<Tooltip label={label}>
<button
type="button"
data-testid={testId}
aria-label={label}
aria-expanded={open}
aria-controls={id}
onClick={(event) => {
event.stopPropagation();
setOpen((current) => !current);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') setOpen(false);
}}
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-[6px] px-1.5 py-0.5 text-caption tabular-nums focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${mobile ? 'bg-[color:var(--m-ic)] text-m-muted' : 'bg-surface-card text-content-secondary'}`}
>
<Icon size={11} aria-hidden="true" />
{value}
</button>
</Tooltip>
<span
id={id}
hidden={!open}
role="note"
className={`absolute start-0 top-full z-30 mt-1 w-40 whitespace-normal rounded-[6px] border p-2 text-caption shadow-sm ${mobile ? 'bg-[color:var(--m-ic)] text-m-ink' : 'border-edge bg-surface-card text-content'}`}
>
{label}
</span>
</span>
);
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const times = tourPlannedTimes(tour);
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
facts.push({ icon, value, label, testId });
if (tour.distance != null)
add(Ruler, formatDistance(tour.distance, unit), t('tours.detail.distance'), 'plan-tour-distance');
if (tour.elevation_gain != null)
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
Footprints,
formatPlannedTourDuration(times.walkingMinutes),
t('tours.planner.inspector.duration'),
'trip-plan-tour-walking-time'
);
if (times.breakMinutes != null && times.breakMinutes > 0)
add(
Pause,
formatPlannedTourDuration(times.breakMinutes),
t('tours.planner.breaksAdditional'),
'trip-plan-tour-breaks'
);
if (times.plannedTotalMinutes != null)
add(
Clock,
formatPlannedTourDuration(times.plannedTotalMinutes),
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
'trip-plan-tour-planned-duration'
);
return (
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
{facts.map((fact) => (
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
))}
</span>
);
}
@@ -104,6 +104,43 @@ describe('TourDetailDialog', () => {
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
render(
<TourDetailDialog
{...defaultProps}
tour={{ ...tour, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
place={{ ...place, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
/>
)
expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
it('RS-02: labels calculated walking time and planned total as separate values', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
})
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
render(
<TourDetailDialog
{...defaultProps}
tour={{ ...tour, website: 'javascript:alert(1)' }}
place={{ ...place, website: 'javascript:alert(1)' }}
/>
)
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, Check, ChevronDown, ChevronUp, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
import { AlertTriangle, Check, ChevronDown, ChevronUp, ExternalLink, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
@@ -22,7 +22,7 @@ import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import { filesForPlace } from '../../utils/placeFiles'
import { openFile } from '../../utils/fileDownload'
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
import { TourMetricFields, TourNotice, TourTile } from './tourParts'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
@@ -137,6 +137,8 @@ export default function TourDetailDialog({
const navigationTargets = getNavigationTargets(place)
const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')
const source = tourSource(tour)
const description = tour.description !== undefined ? tour.description : place.description
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website)
const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto')
const saveName = async () => {
@@ -217,7 +219,7 @@ export default function TourDetailDialog({
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>{t(`tourTypes.${tour.tour_type}`)}</SoftPill>
{tour.difficulty && <SoftPill>{t('tours.detail.difficulty')}: {tour.difficulty}</SoftPill>}
{tour.duration != null && <TimePill>{t('tours.durationMinutes', { count: tour.duration })}</TimePill>}
<TourTimePills tour={tour} />
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
@@ -313,7 +315,15 @@ export default function TourDetailDialog({
)}
</DialogSection>
{place.description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{place.description}</MarkdownBox></DialogSection>}
{description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{description}</MarkdownBox></DialogSection>}
{website && (
<DialogSection className="flex-none" label={t('places.formWebsite')}>
<a href={website.href} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1.5 text-content hover:underline">
<ExternalLink size={13} className="flex-none" />
<span className="truncate">{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}</span>
</a>
</DialogSection>
)}
{place.notes && <DialogSection className="flex-none" label={t('places.formNotes')}><MarkdownBox>{place.notes}</MarkdownBox></DialogSection>}
{assignment?.notes && <DialogSection className="flex-none" label={t('places.assignmentNotes')}><MarkdownBox>{assignment.notes}</MarkdownBox></DialogSection>}
@@ -365,6 +375,37 @@ export default function TourDetailDialog({
)
}
/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */
function TourTimePills({ tour }: { tour: TourListItem }) {
const { t } = useTranslation()
const tourTimes = tourPlannedTimes(tour)
return (
<>
{tourTimes.walkingMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{tourTimes.breakMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
</>
)
}
/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */
function MarkdownBox({ children }: { children: string }) {
return (
+25 -8
View File
@@ -1,7 +1,10 @@
import { useId, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { Link2 } from 'lucide-react'
import { fs } from '../shared/DialogShell'
import { TourFactPills, TourTile } from './tourParts'
import { SoftPill } from '../Planner/planParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
interface TourListRowProps {
tour: TourListItem
@@ -16,6 +19,7 @@ interface TourListRowProps {
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
const nameId = useId()
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
const website = tourWebsitePresentation(tour.website)
return (
<li
role="option"
@@ -33,18 +37,31 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}
}}
>
<div className="flex items-start gap-2.5">
<TourTile />
<div className="min-w-0 flex-1 pt-px">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
<TourFactPills tour={tour} className="mt-1.5" />
</div>
{/* The tile, the name and the day actions share the head line; the rest runs the
full width under them, below the tile too. */}
<div className="flex items-center gap-2.5">
<TourGradeTile tour={tour} />
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5 pt-1" onClick={event => event.stopPropagation()}>
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
{action}
</div>
)}
</div>
{tour.description && (
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</span>
</div>
)}
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
{website && (
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
</SoftPill>
)}
</TourFactPills>
{children}
</li>
)
@@ -56,6 +56,17 @@ describe('ToursSidebar', () => {
expect(row).toHaveClass('bg-surface-selected')
})
it('RS-01: previews the Tour description and classified information-link host', () => {
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
expect(row).toHaveTextContent('A quiet path above the lake')
expect(row).toHaveTextContent('Komoot')
expect(row).not.toHaveTextContent('komoot.com')
expect(row).toHaveTextContent('1 h 35 min')
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
})
it('selects the tour from keyboard activation', () => {
const onSelectTour = vi.fn()
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={null} onAssignToDay={vi.fn()} onSelectTour={onSelectTour} />)
@@ -92,7 +103,7 @@ describe('ToursSidebar', () => {
expect(labels[0]).toContain('1.9 km')
expect(labels[1]).toContain('13 m')
expect(labels[2]).toContain('11 m')
expect(screen.getByText('TREK')).toBeInTheDocument()
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
@@ -102,6 +113,16 @@ describe('ToursSidebar', () => {
expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()
})
it('shows no T-badge for an imported tour that carries no difficulty', () => {
const imported = { ...tour, name: 'Imported GPX walk', has_waypoints: false, max_hiking_difficulty: null }
render(<ToursSidebar tripId={1} days={[]} tours={[imported]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const metrics = within(screen.getByRole('option', { name: /Imported GPX walk/ })).getByTestId('tour-metrics')
expect(metrics).not.toHaveTextContent(/T\d/)
expect(metrics).not.toHaveTextContent('Tnull')
expect(within(metrics).queryByLabelText(/^T\d/)).not.toBeInTheDocument()
})
it('leaves the post-write refresh to the shared assignment handler', async () => {
let resolveAssignment!: () => void
const assignment = new Promise<void>(resolve => { resolveAssignment = resolve })
@@ -0,0 +1,19 @@
// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466.
import { describe, expect, it } from 'vitest';
import { hikingTimeMinutes } from './hikingTime';
describe('hikingTimeMinutes', () => {
it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => {
expect(hikingTimeMinutes(8, 0, 0)).toBe(120);
});
it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => {
// 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance.
expect(hikingTimeMinutes(2, 450, 0)).toBe(105);
});
it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => {
// 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height.
expect(hikingTimeMinutes(12, 300, 500)).toBe(240);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Walking time the way hiking signposts in the Alps state it (DIN 33466, as the
* DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of
* climb and 500 m of descent an hour, and of the time for the distance and the
* time for the height only the larger counts in full, the smaller by half.
*
* The router's own estimate is a walking pace along the line and barely sees a
* slope, so a short steep climb came out far too quick.
*/
const FLAT_KM_PER_HOUR = 4;
const CLIMB_M_PER_HOUR = 300;
const DESCENT_M_PER_HOUR = 500;
export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number {
const flat = distanceKm / FLAT_KM_PER_HOUR;
const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR;
const hours = Math.max(flat, height) + Math.min(flat, height) / 2;
return Math.round(hours * 60);
}
@@ -0,0 +1,358 @@
import { AlertTriangle } from 'lucide-react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import CustomSelect from '../../shared/CustomSelect';
import { INPUT, LABEL } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
interface TourFieldProps {
planner: TourPlannerController;
/** True while the viewer may not edit or a save is in flight. */
locked: boolean;
}
/** The tallest the description grows before it scrolls, about ten lines. */
const DESCRIPTION_MAX_HEIGHT = 240;
const DETAILS_OPEN_KEY = 'trek.tours.planner.detailsOpen';
function readDetailsOpen(): boolean {
try {
return localStorage.getItem(DETAILS_OPEN_KEY) !== 'false';
} catch {
// Storage blocked (private window, preview): the section starts open.
return true;
}
}
function writeDetailsOpen(open: boolean) {
try {
localStorage.setItem(DETAILS_OPEN_KEY, String(open));
} catch {
// Storage blocked: the choice lasts as long as the panel does.
}
}
/** A field label with what belongs beside it (an info dot, a badge) on the same line. */
function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) {
return (
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor={htmlFor}>
{children}
</label>
{aside}
</div>
);
}
function FieldError({ id, children }: { id: string; children: ReactNode }) {
return (
<span id={id} role="alert" className="mt-1 block text-danger" style={fs(11)}>
{children}
</span>
);
}
export function TourNameField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">
{t('tours.planner.name')}
</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={(event) => {
if (!locked) planner.setName(event.target.value);
}}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
);
}
/**
* A duration typed as "2:56", "2 h 56" or "176" minutes and shown as "2 h 56 min".
* A readable value reaches the planner while it is typed; text that is no
* duration reaches it as NaN on leaving the field, so the planner marks it
* invalid and holds Save back, and the text stays for the user to fix.
*/
function TourDurationInput({
id,
label,
value,
invalid,
errorId,
locked,
onCommit,
}: {
id: string;
label: string;
value: number | null;
invalid: boolean;
errorId: string;
locked: boolean;
onCommit: (minutes: number | null) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const showDraft = draft !== null && (focused || (value !== null && Number.isNaN(value)));
return (
<input
id={id}
aria-label={label}
type="text"
inputMode="text"
autoComplete="off"
maxLength={TOUR_DURATION_INPUT_MAX_LENGTH}
placeholder="0:00"
disabled={locked}
value={showDraft ? draft : formatTourDuration(value)}
onFocus={() => {
setFocused(true);
setDraft(formatTourDuration(value));
}}
onChange={(event) => {
if (locked) return;
const text = event.target.value;
setDraft(text);
const parsed = parseTourDuration(text);
if (parsed !== 'invalid') onCommit(parsed);
}}
onBlur={() => {
setFocused(false);
if (draft === null) return;
if (!locked && parseTourDuration(draft) === 'invalid') onCommit(Number.NaN);
else setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? errorId : undefined}
className={`${INPUT} tabular-nums`}
/>
);
}
/** Breaks on top of the walking time, and the planned total that defaults to their sum, side by side. */
function TourTimeFields({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const manual = planner.plannedDurationMinutes != null;
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-planned-duration"
aside={
<>
<InfoDot
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
{manual && (
<span data-testid="tour-planner-planned-manual" className="ms-auto inline-flex min-w-0">
<SoftPill tone="info">{t('tours.planner.plannedTotalManual')}</SoftPill>
</span>
)}
</>
}
>
{t('tours.planner.plannedShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
/>
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
</div>
);
}
/** The description, growing with its text up to about ten lines before it scrolls. */
function TourDescriptionField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const ref = useRef<HTMLTextAreaElement>(null);
const value = planner.description;
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const borders = element.offsetHeight - element.clientHeight;
element.style.height = 'auto';
const wanted = element.scrollHeight + borders;
element.style.height = `${Math.min(wanted, DESCRIPTION_MAX_HEIGHT)}px`;
element.style.overflowY = wanted > DESCRIPTION_MAX_HEIGHT ? 'auto' : 'hidden';
}, [value]);
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">
{t('places.formDescription')}
</label>
<textarea
ref={ref}
id="tour-planner-description"
data-testid="tour-planner-description"
disabled={locked}
value={value}
onChange={(event) => {
if (!locked) planner.setDescription(event.target.value);
}}
maxLength={2000}
rows={2}
className={`${INPUT} resize-none`}
/>
</div>
);
}
function TourWebsiteField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">
{t('places.formWebsite')}
</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={(event) => {
if (!locked) planner.setWebsite(event.target.value);
}}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<FieldError id="tour-planner-website-error">{t('dawarich.error.invalid_url')}</FieldError>
)}
</div>
);
}
/** The difficulty cap, with the routing safety note behind the info dot beside its label. */
function TourDifficultyField({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
return (
<>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-difficulty"
aside={<InfoDot label={t('tours.planner.safetyInfoLabel')} description={t('tours.planner.safetyNote')} />}
>
{t('tours.planner.maxDifficulty')}
</FieldLabel>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={(nextValue) => {
if (!locked) onDifficultyChange(Number(nextValue) as Difficulty);
}}
options={[1, 2, 3, 4, 5, 6].map((value) => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.planner.difficulty.t3Warning')}
</TourNotice>
)}
</>
);
}
/**
* Description, website, the difficulty cap and the time, folded away with the
* chevron. The section opens by itself while one of its fields holds a value
* the planner refuses, so the reason Save is held back is never out of sight.
*/
export function TourDetailsSection({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
setOpen(next);
writeDetailsOpen(next);
};
return (
<TourSection
label={<span className="text-content">{t('tours.planner.details')}</span>}
action={
<FoldButton
expanded={expanded}
onToggle={toggle}
controls="tour-planner-details"
label={t(expanded ? 'tours.planner.collapseDetails' : 'tours.planner.expandDetails')}
/>
}
>
<div id="tour-planner-details" hidden={!expanded} className="flex flex-col gap-3">
{expanded && (
<>
<TourDescriptionField planner={planner} locked={locked} />
<TourWebsiteField planner={planner} locked={locked} />
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
<TourTimeFields planner={planner} locked={locked} />
</>
)}
</div>
</TourSection>
);
}
@@ -1,5 +1,5 @@
import { act, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../../store/settingsStore'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import type { TourPlannerController } from './useTourPlanner'
import type { Day } from '../../../types'
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
@@ -48,6 +49,20 @@ function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerCon
maxHikingDifficulty: 2,
setMaxHikingDifficulty: vi.fn(),
name: '',
description: '',
setDescription: vi.fn(),
website: '',
setWebsite: vi.fn(),
websiteInvalid: false,
plannedDurationMinutes: null,
setPlannedDurationMinutes: vi.fn(),
plannedDurationInvalid: false,
breakAdditionalMinutes: null,
setBreakAdditionalMinutes: vi.fn(),
breakAdditionalInvalid: false,
walkingDurationMinutes: null,
plannedTotalMinutes: null,
plannedTotalInvalid: false,
startNewTour: vi.fn(),
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
@@ -65,6 +80,9 @@ function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerCon
editingPlaceId: null,
openingTourId: null,
openTour: vi.fn().mockResolvedValue(true),
reorderWaypoint: vi.fn(),
moveWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
saveOutcome: null,
hasUnsavedChanges: false,
discard: vi.fn(),
@@ -92,7 +110,17 @@ beforeEach(() => {
})
describe('TourPlannerToursRail', () => {
it('disables saved-tour switching and Plan another while a Save is pending', () => {
it('RS-02: keeps calculated Walking time visible in the right Tour metrics rail', () => {
render(<TourPlannerToursRail planner={planner({
mode: { type: 'new-draft' }, durationSeconds: 4800,
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
})} tours={[tour]} {...railProps} />)
expect(screen.getByText(/^(Estimated walking time|Est. walking time)$/)).toBeInTheDocument()
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
})
it('disables saved-tour switching while a Save is pending', () => {
const controller = planner({
isSaving: true,
saveOutcome: tour,
@@ -107,14 +135,12 @@ describe('TourPlannerToursRail', () => {
expect(row).toHaveAttribute('tabindex', '-1')
fireEvent.click(row)
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Plan another' })).toBeDisabled()
})
it('TOUR-PLANNER-RAIL-001: shows saved Tours rows and route stats without hiding the safety note in navigation', () => {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('TREK')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
@@ -122,6 +148,19 @@ describe('TourPlannerToursRail', () => {
expect(screen.queryByText('Routing is planning assistance, not a safety guarantee.')).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-020: warns over the overview when the route climbs a via ferrata, with the stretch in the tooltip', () => {
publishTourFerrataCrossings([{ id: 'group/way/1', name: 'Alpspitz Ferrata', grade: 'K2', fromM: 1000, toM: 1600, afterWaypoint: null }])
try {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
const badges = screen.getByTestId('tour-ferrata-crossings')
expect(badges).toHaveTextContent('Via ferrata K2')
expect(within(badges).getByLabelText(/climbs 600 m of “Alpspitz Ferrata”/)).toBeInTheDocument()
expect(document.querySelectorAll('[data-profile-band]').length).toBeGreaterThan(0)
} finally {
publishTourFerrataCrossings([])
}
})
it('exposes permanent deletion in the visible TOUR-PLANNER Tours rail', () => {
const onDeleteTour = vi.fn()
render(<TourPlannerToursRail planner={planner()} tours={[tour]} {...railProps} canEdit onDeleteTour={onDeleteTour} />)
@@ -149,11 +188,9 @@ describe('TourPlannerToursRail', () => {
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan another' })).not.toBeInTheDocument()
expect(onAssignToDay).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }))
expect(controller.startNewTour).toHaveBeenCalledOnce()
expect(controller.discard).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' }))
const dayOption = screen.getByRole('button', { name: /Summit day/ })
@@ -172,7 +209,6 @@ describe('TourPlannerToursRail', () => {
fireEvent.click(screen.getByRole('button', { name: /Summit day/ }))
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled()
})
@@ -183,7 +219,7 @@ describe('TourPlannerToursRail', () => {
const onViewGpxTour = vi.fn()
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX import')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX')
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
expect(onViewGpxTour).not.toHaveBeenCalled()
expect(controller.openTour).not.toHaveBeenCalled()
@@ -289,6 +325,223 @@ describe('TourPlannerToursRail', () => {
})
describe('TourPlannerRail', () => {
it('RS-01: edits Tour description and HTTPS information link without exposing editable fields in GPX mode', () => {
const setDescription = vi.fn()
const setWebsite = vi.fn()
const { rerender } = render(
<TourPlannerRail
planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, setDescription, setWebsite })}
/>
)
const description = screen.getByLabelText('Description')
const website = screen.getByLabelText('Website')
fireEvent.change(description, { target: { value: 'A quiet ridge route' } })
fireEvent.change(website, { target: { value: 'https://www.komoot.com/tour/42' } })
expect(setDescription).toHaveBeenCalledWith('A quiet ridge route')
expect(setWebsite).toHaveBeenCalledWith('https://www.komoot.com/tour/42')
rerender(
<TourPlannerRail
planner={planner({
mode: { type: 'view-gpx', placeId: 42, tour },
readOnlyGpxTour: { tour, routeGeometry: null },
waypoints: [],
websiteInvalid: true,
})}
/>
)
expect(screen.queryByLabelText('Description')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Website')).not.toBeInTheDocument()
})
it('RS-02: edits breaks and planned total directly, and clearing total returns it to automatic', () => {
const setPlannedDurationMinutes = vi.fn()
const setBreakAdditionalMinutes = vi.fn()
const controller = planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, durationSeconds: 3600,
walkingDurationMinutes: 60, breakAdditionalMinutes: 35, setBreakAdditionalMinutes,
plannedDurationMinutes: null, plannedTotalMinutes: 95, setPlannedDurationMinutes,
})
const { rerender } = render(<TourPlannerRail planner={controller} />)
expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-2')
const breaks = screen.getByLabelText('Breaks / additional time')
// A duration field shows "1 h 35 min" and reads minutes, a clock or units back.
expect(breaks).toHaveValue('35 min')
fireEvent.change(breaks, { target: { value: '50' } })
expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
fireEvent.change(breaks, { target: { value: '' } })
expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
fireEvent.change(breaks, { target: { value: '50' } })
const input = screen.getByLabelText('Planned total duration')
expect(input).toHaveValue('1 h 35 min')
expect(controller.durationSeconds).toBe(3600)
fireEvent.change(input, { target: { value: '2:05' } })
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
rerender(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 125,
setPlannedDurationMinutes,
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
})} />)
const overriddenInput = screen.getByLabelText(/Planned total duration/)
expect(overriddenInput).toHaveValue('2 h 5 min')
expect(screen.getByTestId('tour-planner-planned-manual')).toHaveTextContent('Manual override')
fireEvent.change(overriddenInput, { target: { value: '' } })
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
rerender(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 1441,
plannedDurationInvalid: true,
})} />)
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
rerender(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, breakAdditionalMinutes: 1441,
breakAdditionalInvalid: true,
})} />)
expect(screen.getByLabelText('Breaks / additional time')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
})
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
})} />)
const breaksInfo = screen.getByRole('button', { name: 'About breaks and additional time' })
expect(breaksInfo.tabIndex).toBe(0)
expect(breaksInfo).not.toHaveAttribute('aria-expanded')
expect(breaksInfo).toHaveAccessibleDescription('Optional whole minutes added to calculated walking time.')
expect(breaksInfo).toHaveClass('focus-visible:outline')
fireEvent.mouseEnter(breaksInfo)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Optional whole minutes added to calculated walking time.')
fireEvent.mouseLeave(breaksInfo)
// A click shows nothing beyond the tooltip: no explanation unfolds in the form.
fireEvent.click(breaksInfo)
expect(screen.queryByRole('note')).not.toBeInTheDocument()
const totalInfo = screen.getByRole('button', { name: 'About planned total duration' })
expect(totalInfo).toHaveAccessibleDescription('Calculated planned total duration.')
})
it('RS-01: marks unsafe website values invalid and does not offer Save', () => {
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, website: 'http://example.org', websiteInvalid: true })} />)
expect(screen.getByLabelText('Website')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent('TREK cannot use that address.')
})
const reorderWaypoints = [
{ id: 'a', lat: 48, lng: 11, role: 'start' as const },
{ id: 'b', lat: 48.1, lng: 11.1, role: 'via' as const },
{ id: 'c', lat: 48.2, lng: 11.2, role: 'via' as const },
{ id: 'd', lat: 48.3, lng: 11.3, role: 'end' as const },
];
it('RC-07: dragging before and after rows commits only on drop by stable waypoint ids', () => {
const reorderWaypoint = vi.fn();
render(
<TourPlannerRail
planner={planner({
mode: { type: 'new-draft' },
hasUnsavedChanges: true,
waypoints: reorderWaypoints,
reorderWaypoint,
})}
/>
);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
const dispatchDrag = (type: string, target: HTMLElement, dataTransfer: typeof transfer, clientY = 0) => {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
Object.defineProperty(event, 'clientY', { value: clientY });
fireEvent(target, event);
};
for (const row of [rows[1], rows[2]]) {
Object.defineProperty(row, 'getBoundingClientRect', {
configurable: true,
value: () => ({
top: 0,
bottom: 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: 0,
toJSON: () => ({}),
}),
});
}
dispatchDrag('dragstart', within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), transfer);
dispatchDrag('dragover', rows[2], transfer, 1);
expect(reorderWaypoint).not.toHaveBeenCalled();
dispatchDrag('drop', rows[2], transfer, 1);
expect(reorderWaypoint).toHaveBeenCalledOnce();
expect(reorderWaypoint).toHaveBeenLastCalledWith('a', 'c', 'before');
reorderWaypoint.mockClear();
const lastTransfer = { ...transfer, getData: vi.fn(() => 'd') };
dispatchDrag('dragstart', within(rows[3]).getByTestId('tour-planner-waypoint-drag-handle'), lastTransfer);
dispatchDrag('dragover', rows[1], lastTransfer, 15);
expect(reorderWaypoint).not.toHaveBeenCalled();
dispatchDrag('drop', rows[1], lastTransfer, 15);
expect(reorderWaypoint).toHaveBeenCalledOnce();
expect(reorderWaypoint).toHaveBeenLastCalledWith('d', 'b', 'after');
});
it('RC-07: cancelled drags do not reorder and keyboard arrow controls remain buttons', async () => {
const user = userEvent.setup();
const reorderWaypoint = vi.fn();
const moveWaypoint = vi.fn();
render(
<TourPlannerRail
planner={planner({
mode: { type: 'new-draft' },
hasUnsavedChanges: true,
waypoints: reorderWaypoints,
reorderWaypoint,
moveWaypoint,
})}
/>
);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
Object.defineProperty(rows[2], 'getBoundingClientRect', {
configurable: true,
value: () => ({
top: 0,
bottom: 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: 0,
toJSON: () => ({}),
}),
});
fireEvent.dragStart(within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), { dataTransfer: transfer });
fireEvent.dragOver(rows[2], { dataTransfer: transfer, clientY: -1 });
fireEvent.dragEnd(rows[0], { dataTransfer: transfer });
expect(reorderWaypoint).not.toHaveBeenCalled();
const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0];
expect(moveDown).toBeEnabled();
moveDown.focus();
await user.keyboard(' ');
expect(moveWaypoint).toHaveBeenCalledWith('a', 1);
expect(reorderWaypoint).not.toHaveBeenCalled();
});
it('disables draft mutation and replacement controls while saving', () => {
const controller = planner({
mode: { type: 'new-draft' },
@@ -316,13 +569,13 @@ describe('TourPlannerRail', () => {
expect(screen.getByLabelText('Tour name')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Maximum trail difficulty' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save tour' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Back to Tours' })).toBeDisabled()
expect(screen.getAllByLabelText('Move waypoint up')).toHaveLength(2)
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent('Saving…')
expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => {
@@ -347,8 +600,9 @@ describe('TourPlannerRail', () => {
})
render(<TourPlannerRail planner={controller} />)
const save = screen.getByRole('button', { name: 'Save tour' })
expect(save.parentElement?.parentElement).toHaveTextContent('Routing is planning assistance, not a safety guarantee.')
const save = screen.getByRole('button', { name: 'Save' })
// The safety note lives in the info dot beside the hiking difficulty, as its description.
expect(screen.getByRole('button', { name: 'About safety' })).toHaveAccessibleDescription('Routing is planning assistance, not a safety guarantee.')
expect(save).toHaveClass('bg-accent', 'text-accent-text')
expect(save).toHaveClass('hover:opacity-90')
const firstNumber = screen.getByLabelText('Waypoint 1')
@@ -428,8 +682,8 @@ describe('TourPlannerRail', () => {
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Click the map to add your starting point.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Add another point to calculate a route.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Turn on Add point and click the map.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('From two points on, TREK works out the route.')
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, waypoints: [{ id: 'first', lat: 48, lng: 11, role: 'start' }] })} />)
expect(screen.queryByTestId('tour-planner-empty-state')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
@@ -555,7 +809,7 @@ describe('TourPlannerRail', () => {
expect(dirty.returnToNeutral).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
fireEvent.click(screen.getByRole('button', { name: 'Discard unsaved changes' }))
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }))
expect(dirty.returnToNeutral).toHaveBeenCalledOnce()
})
@@ -572,11 +826,11 @@ describe('TourPlannerRail', () => {
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
@@ -1,14 +1,13 @@
import { useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Route, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
import { formatDistance, formatElevation } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import { INPUT, LABEL } from '../../shared/dialogParts'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { Tooltip } from '../../shared/Tooltip'
@@ -16,8 +15,10 @@ import { BOX } from '../../Planner/bookings/bookingParts'
import { BarButton, SoftPill } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -28,9 +29,7 @@ const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
'routing-failed': 'tours.planner.status.routingFailed',
'enriching-elevation': 'tours.planner.status.elevation',
'elevation-failed': 'tours.planner.status.elevationFailed',
ready: 'tours.planner.status.ready',
saving: 'tours.planner.status.saving',
saved: 'tours.planner.saved',
}
/** The tinted head band both rails open with, like every panel head in the planner. */
@@ -38,10 +37,6 @@ const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint p
type Translate = (key: string, params?: Record<string, string | number | null>) => string
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
return t(`tours.planner.${role}`)
}
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
@@ -57,11 +52,106 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
<h2 className="m-0 min-w-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(14, 'body')}>{title}</h2>
{aside}
</div>
{sub && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>}
{typeof sub === 'string'
? <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>
: sub}
</div>
)
}
/**
* The via ferratas the tour climbs, one warning badge each over the overview,
* the stretch and what it asks of a hiker in its tooltip. The router takes a
* ferrata like any trail once the difficulty allows alpine routes, so this is
* the one place that says so.
*/
function FerrataCrossingBadges() {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const crossings = useTourFerrataCrossingStore(state => state.crossings)
if (crossings.length === 0) return null
const byFerrata = new Map<string, { name: string; grade: string | null; metres: number }>()
for (const crossing of crossings) {
const entry = byFerrata.get(crossing.id) ?? { name: crossing.name, grade: crossing.grade, metres: 0 }
entry.metres += crossing.toM - crossing.fromM
byFerrata.set(crossing.id, entry)
}
return (
<div data-testid="tour-ferrata-crossings" className="mb-2.5 flex min-w-0 flex-wrap gap-1">
{[...byFerrata].map(([id, ferrata]) => {
const info = t('tours.ferrata.crossingInfo', { length: formatDistance(ferrata.metres / 1000, unit ?? 'metric'), name: ferrata.name })
return (
<Tooltip key={id} label={info}>
<span aria-label={info} className="inline-flex min-w-0">
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>
<span className="min-w-0 truncate">{ferrata.grade ? t('tours.ferrata.badge', { grade: ferrata.grade }) : t('tours.ferrata.badgeNoGrade')}</span>
</SoftPill>
</span>
</Tooltip>
)
})}
</div>
)
}
/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */
function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) {
const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted'
return (
<Tooltip label={label}>
<span aria-label={label} className={`grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint ${look}`}>
{children}
</span>
</Tooltip>
)
}
/**
* What the editor is doing right now, as icon badges beside the head's title:
* a restored draft, routing or saving, and a failure with its retry. Nothing
* when there is nothing to say.
*/
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation()
const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const restored = planner.draftRestored && planner.hasUnsavedChanges
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
const failed = planner.error === 'save'
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed && !onFerrata) return null
const warning = planner.error != null || canRetry
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
return (
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
{restored && (
<StatusDot label={t('tours.planner.restored')} tone="info">
<RotateCcw size={11} strokeWidth={2.2} />
</StatusDot>
)}
{onFerrata && (
<StatusDot label={t('tours.ferrata.crossingShort')} tone="warning">
<Route size={11} strokeWidth={2.2} />
</StatusDot>
)}
{statusLabel && (
<StatusDot label={statusLabel} tone={warning ? 'warning' : 'neutral'}>
{warning
? <AlertTriangle size={11} strokeWidth={2.2} />
: busy ? <Loader2 size={11} strokeWidth={2.2} className="animate-spin" /> : <Circle size={7} strokeWidth={3} />}
</StatusDot>
)}
{canRetry && (
<button type="button" onClick={planner.retry}
className="inline-flex h-[20px] items-center rounded-full bg-accent px-2 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(10.5)}>
{t('tours.planner.retry')}
</button>
)}
</span>
)
}
export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) {
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
@@ -70,13 +160,15 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
const requestClose = () => {
if (isSaving) return
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
else planner.returnToNeutral()
}
const changeDifficulty = (value: 1 | 2 | 3 | 4 | 5 | 6) => {
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
else planner.setMaxHikingDifficulty(value)
}
if (planner.mode.type === 'neutral') {
return (
@@ -143,7 +235,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
}
const isSavedEdit = planner.mode.type === 'edit-saved'
const lastIndex = planner.waypoints.length - 1
return (
<>
@@ -152,7 +243,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={t('tours.planner.mapHint')} />
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
<Undo2 size={15} strokeWidth={2} />
</BarButton>
@@ -162,124 +253,20 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{planner.draftRestored && planner.hasUnsavedChanges && (
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
)}
<TourNameField planner={planner} locked={!canEdit || isSaving} />
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
<input
id="tour-planner-name"
disabled={!canEdit || isSaving}
value={planner.name}
onChange={event => { if (canEdit && !isSaving) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={!canEdit || isSaving}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
if (!canEdit || isSaving) return
const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
else planner.setMaxHikingDifficulty(value)
}}
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
)}
</div>
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id
return (
<li key={point.id}>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className={`group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5 text-start outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
aria-current={selected ? 'true' : undefined}
>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>{roleLabel(point.role, t)}</span>
<span className="mt-0.5 block truncate font-geist tabular-nums text-content-faint" style={fs(10.5)}>{point.lat.toFixed(5)}, {point.lng.toFixed(5)}</span>
</span>
{canEdit && (
<span className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100'}`}>
<WaypointAction label={t('tours.planner.moveUp')} disabled={isSaving || index === 0} onAct={() => planner.moveWaypoint(point.id, -1)}>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction label={t('tours.planner.moveDown')} disabled={isSaving || index === lastIndex} onAct={() => planner.moveWaypoint(point.id, 1)}>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction label={t('tours.planner.remove')} disabled={isSaving} danger onAct={() => planner.removeWaypoint(point.id)}>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</button>
</li>
)
})}
</ol>
)}
</TourSection>
{(statusKey || planner.error) && (
<TourNotice
role="status"
tone={planner.error ? 'warning' : 'neutral'}
icon={planner.error ? <AlertTriangle size={13} strokeWidth={2} /> : <Info size={13} strokeWidth={2} className="text-content-faint" />}
>
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
{canRetry && (
<button type="button" onClick={planner.retry}
className="ms-2 inline-flex items-center rounded-full bg-surface-card px-2 py-[1px] font-semibold text-content shadow-sm hover:bg-surface-secondary"
style={fs(11)}>
{t('tours.planner.retry')}
</button>
)}
</TourNotice>
)}
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
</div>
<div className="flex flex-none flex-col gap-2.5 border-t border-edge-faint px-3 py-3">
<p className="m-0 flex items-start gap-1.5 leading-snug text-content-faint" style={fs(11)}>
<ShieldAlert size={13} strokeWidth={2} className="mt-px flex-none" />
<span>{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}</span>
</p>
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')}
{t('common.discard')}
</DialogButton>
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}>
{t(isSavedEdit ? 'tours.planner.saveChanges' : 'tours.planner.save')}
{t('common.save')}
</DialogButton>
</div>
</div>
@@ -290,7 +277,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
onConfirm={planner.returnToNeutral}
title={t('tours.planner.discardTitle')}
message={t('tours.planner.discardBody')}
confirmLabel={t('tours.planner.discardUnsaved')}
confirmLabel={t('tours.planner.discardChanges')}
/>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
<ConfirmDialog
@@ -312,25 +299,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
)
}
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
return (
<Tooltip label={label}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-disabled={disabled}
onClick={event => { event.stopPropagation(); if (!disabled) onAct() }}
onKeyDown={event => { if (!disabled && event.key === 'Enter') { event.stopPropagation(); onAct() } }}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</span>
</Tooltip>
)
}
/** The elevation profile under its label, folded away with the chevron beside it. */
function ElevationSection({ id, samples, gradientId, planner }: {
id: string
@@ -340,6 +308,8 @@ function ElevationSection({ id, samples, gradientId, planner }: {
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
// The stretches that climb a via ferrata, banded behind the line in the ferrata violet.
const crossings = useTourFerrataCrossingStore(state => state.crossings)
if (samples.length < 2) {
return (
<TourSection label={t('tours.planner.inspector.elevation')}>
@@ -362,6 +332,7 @@ function ElevationSection({ id, samples, gradientId, planner }: {
focus={planner.routeProfileFocus}
onFocusChange={planner.setRouteProfileFocus}
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
bands={crossings}
/>}
</div>
</TourSection>
@@ -402,6 +373,17 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
// A finished save is said once, as the app says it everywhere: a toast.
// Once per save: the toast helper and t are fresh objects on every render, so the
// effect reruns often; only a save it has not announced yet gets the toast.
const savedTour = planner.saveOutcome
const announced = useRef<typeof savedTour>(null)
useEffect(() => {
if (!savedTour || announced.current === savedTour) return
announced.current = savedTour
toast.success(t('tours.planner.saved'))
}, [savedTour, t, toast])
const openTour = async (tour: TourListItem) => {
const opened = await planner.openTour(tour)
if (!opened) toast.error(t('tours.planner.openFailed'))
@@ -421,19 +403,46 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
selectTour(tour)
}
const walkingTime = { label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }) }
const tourTimeFields = [
{
label: t('tours.planner.walkingEstimated'),
shortLabel: t('tours.planner.walkingEstimatedShort'),
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} sub={t('tours.subtitle')} />
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && planner.mode.type !== 'neutral' && (
<Tooltip label={t('tours.planner.newTour')}>
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
disabled={isSaving} onClick={() => { if (!isSaving) planner.startNewTour() }}
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-accent text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40">
<Plus size={14} strokeWidth={2.4} />
</button>
</Tooltip>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{hasRoute && (
<>
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={[walkingTime]} columns={2} />
<FerrataCrossingBadges />
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
{routeAnalysis && (
<ElevationSection
@@ -443,18 +452,6 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
planner={planner}
/>
)}
</>
)}
{planner.saveOutcome && (
<div className={`${BOX} flex flex-col gap-3 p-3`} role="status">
<div className="flex items-center gap-2 font-semibold text-content" style={fs(13, 'body')}>
<CheckCircle2 size={16} strokeWidth={2} className="flex-none text-success" />
{t('tours.planner.saved')}
</div>
<DialogButton onClick={() => { if (canEdit && !isSaving) planner.startNewTour() }} disabled={!canEdit || isSaving}>
{t('tours.planner.planAnother')}
</DialogButton>
</div>
)}
@@ -0,0 +1,340 @@
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
import { Fragment, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import {
useTourFerrataCrossingStore,
type TourFerrataCrossing,
type TourWaypointFerrata,
} from '../../../store/tourFerrataCrossingStore';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourSection } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
type DropTarget = { id: string; placement: 'before' | 'after' };
const ROLE_KEY = {
start: 'tours.planner.start',
via: 'tours.planner.via',
end: 'tours.planner.end',
} as const;
/** A quiet icon control on a waypoint row; it stops the click so the row is not selected too. */
function WaypointAction({
label,
disabled,
danger = false,
onAct,
children,
}: {
label: string;
disabled: boolean;
danger?: boolean;
onAct: () => void;
children: ReactNode;
}) {
return (
<Tooltip label={label}>
<button
type="button"
disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={(event) => {
event.stopPropagation();
if (!disabled) onAct();
}}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</button>
</Tooltip>
);
}
/** The quiet hint and first-use guidance before a route exists. */
function WaypointEmptyState() {
const { t } = useTranslation();
return (
<div
data-testid="tour-planner-empty-state"
className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted"
style={fs(12, 'body')}
>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.emptyHint')}</p>
</div>
);
}
/**
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
* the ladder, its name, and its grade and length as chips inside, in the
* ferrata violet the map draws it in.
*/
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
return (
<li
data-testid="tour-planner-ferrata-leg"
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
className="flex min-w-0 py-0.5 ps-[34px]"
>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
<FerrataGlyph size={18} className="flex-none" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{crossing.name}
</span>
{crossing.grade && (
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{crossing.grade}
</span>
)}
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{length}
</span>
</span>
</li>
);
}
/**
* A waypoint's number, as the map pins show it. At the foot or the top of a via
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
* by the tooltip.
*/
function WaypointNumber({
index,
selected,
ferrata,
}: {
index: number;
selected: boolean;
ferrata: TourWaypointFerrata | null;
}) {
const { t } = useTranslation();
const label = t('tours.planner.waypointLabel', { n: index + 1 });
const number = (
<span
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
data-waypoint-number={index + 1}
data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
style={fs(11.5)}
>
{index + 1}
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
</span>
);
if (!ferrata) return number;
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
}
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
* is on, the row says where to click and a second click turns it off again.
*/
function AddPointRow({ disabled }: { disabled: boolean }) {
const { t } = useTranslation();
const active = useTourPlaceToolStore((state) => state.placing[state.mode]);
return (
<button
type="button"
data-testid="tour-planner-add-point"
aria-pressed={active}
disabled={disabled}
onClick={() => toggleTourPlacing()}
className={`mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-[12px] border border-dashed px-3 py-2.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'border-accent bg-surface-card text-content' : 'border-edge text-content-muted enabled:hover:border-content-faint enabled:hover:text-content'}`}
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2} className="flex-none" />
<span className="min-w-0 truncate">{t(active ? 'tours.planner.addPointActive' : 'tours.planner.addPoint')}</span>
</button>
);
}
/**
* The ordered waypoints, each under its place name with its role as a badge,
* reorderable by drag or by the arrow controls on each row. The coordinates
* stand in for the name while it loads, offline, or when the lookup has none.
*/
export default function TourWaypointList({
planner,
canEdit,
isSaving,
}: {
planner: TourPlannerController;
canEdit: boolean;
isSaving: boolean;
}) {
const { t } = useTranslation();
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const placeNames = useWaypointPlaceNames(planner.waypoints);
const ferrataEnds = useTourFerrataCrossingStore((state) => state.waypoints);
const crossings = useTourFerrataCrossingStore((state) => state.crossings);
const legCrossings = new Map<string, TourFerrataCrossing[]>();
for (const crossing of crossings) {
if (!crossing.afterWaypoint) continue;
legCrossings.set(crossing.afterWaypoint, [...(legCrossings.get(crossing.afterWaypoint) ?? []), crossing]);
}
const lastIndex = planner.waypoints.length - 1;
const movable = canEdit && !isSaving;
const endDrag = () => {
setDraggedWaypointId(null);
setDropTarget(null);
};
if (planner.waypoints.length === 0) {
return (
<TourSection label={t('tours.planner.waypoints')}>
<WaypointEmptyState />
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
return (
<TourSection label={t('tours.planner.waypoints')}>
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
const placeName = placeNames[point.id];
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
return (
<Fragment key={point.id}>
<li
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!movable) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<WaypointNumber index={index} selected={selected} ferrata={ferrataEnds[point.id] ?? null} />
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<span
data-testid="tour-planner-waypoint-name"
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
style={fs(12.5, 'body')}
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
</span>
{placeName && (
<span
data-testid="tour-planner-waypoint-coordinates"
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
style={fs(10.5)}
>
{coordinates}
</span>
)}
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
{(legCrossings.get(point.id) ?? []).map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
</Fragment>
);
})}
</ol>
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { formatTourDuration, parseTourDuration } from './tourDurationInput';
describe('parseTourDuration', () => {
it.each([
['176', 176],
['0', 0],
[' 45 ', 45],
['2:56', 176],
['0:05', 5],
['2:5', 125],
['2 h 56', 176],
['2h56', 176],
['2 h 56 min', 176],
['2H 56MIN', 176],
['2 h', 120],
['3h', 180],
['1,5 h', 90],
['1.5h', 90],
['2 Std 10', 130],
['2 Std. 10 Min.', 130],
['45 min', 45],
['45m', 45],
['2 h 5', 125],
])('reads %j as %d minutes', (text, minutes) => {
expect(parseTourDuration(text)).toBe(minutes);
});
it('clears the value for an empty field', () => {
expect(parseTourDuration('')).toBeNull();
expect(parseTourDuration(' ')).toBeNull();
});
it.each(['h', 'min', 'abc', '2:60', '2:', ':30', '-5', '2 56', '2,5', '1 h 2 h', '12345678901234567'])(
'rejects %j',
(text) => {
expect(parseTourDuration(text)).toBe('invalid');
}
);
it('reads back everything it formats', () => {
for (const minutes of [0, 5, 59, 60, 61, 176, 1440]) {
expect(parseTourDuration(formatTourDuration(minutes))).toBe(minutes);
}
});
});
describe('formatTourDuration', () => {
it('writes hours and minutes the way the planner shows durations', () => {
expect(formatTourDuration(176)).toBe('2 h 56 min');
expect(formatTourDuration(120)).toBe('2 h');
expect(formatTourDuration(45)).toBe('45 min');
expect(formatTourDuration(0)).toBe('0 min');
});
it('writes nothing for an empty or unreadable value', () => {
expect(formatTourDuration(null)).toBe('');
expect(formatTourDuration(Number.NaN)).toBe('');
});
});
@@ -0,0 +1,46 @@
import { formatPlannedTourDuration } from '../tourPresentation';
/** What a typed duration means: nothing (cleared), a number of minutes, or no duration at all. */
export type ParsedTourDuration = number | null | 'invalid';
/** The longest text a duration field takes; nobody types more for a day's walk. */
export const TOUR_DURATION_INPUT_MAX_LENGTH = 16;
const PLAIN_MINUTES = /^\d+$/;
const CLOCK = /^(\d+):(\d{1,2})$/;
// Hours with a unit, then minutes with an optional unit, or minutes alone with a unit:
// "2 h 56", "2h56min", "1,5 h", "45 min". Each part is anchored on its unit, so the
// pattern cannot match one text two ways.
const WITH_UNITS =
/^(?:(\d+(?:[.,]\d+)?) ?(?:h|hr|hrs|hours?|std|stunden?)\.?)? ?(?:(\d+) ?(?:m|min|mins|minutes?|minuten?)?\.?)?$/;
/**
* Reads what somebody typed into a duration field as whole minutes.
*
* "176" is minutes, "2:56" is hours and minutes, and "2 h 56", "2h 56 min" or
* "1,5 h" spell the units out. An empty field clears the value. Anything else,
* a clock with 60 minutes or more included, is invalid. The range is the
* planner's to check, not this one's.
*/
export function parseTourDuration(text: string): ParsedTourDuration {
const value = text.trim().toLowerCase().replace(/\s+/g, ' ');
if (value === '') return null;
if (value.length > TOUR_DURATION_INPUT_MAX_LENGTH) return 'invalid';
if (PLAIN_MINUTES.test(value)) return Number(value);
const clock = CLOCK.exec(value);
if (clock) {
const minutes = Number(clock[2]);
return minutes < 60 ? Number(clock[1]) * 60 + minutes : 'invalid';
}
const units = WITH_UNITS.exec(value);
if (!units || (units[1] === undefined && units[2] === undefined)) return 'invalid';
const hours = units[1] === undefined ? 0 : Number(units[1].replace(',', '.'));
const minutes = units[2] === undefined ? 0 : Number(units[2]);
return Math.round(hours * 60 + minutes);
}
/** "2 h 56 min" for a duration in minutes, and nothing for an empty or unreadable one. */
export function formatTourDuration(minutes: number | null): string {
if (minutes === null || !Number.isFinite(minutes)) return '';
return formatPlannedTourDuration(minutes);
}
@@ -0,0 +1,43 @@
// FE-TOURS-FIXEDLEG-001 to -002: a fixed leg holds only while it joins its two waypoints.
import { describe, expect, it } from 'vitest';
import { keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
const leg = {
kind: 'ferrata' as const,
ref: 'way/1',
name: 'Via A',
geometry: [
[47.4, 11.0],
[47.401, 11.0],
] as [number, number][],
};
describe('keepJoinedFixedLegs', () => {
it('FE-TOURS-FIXEDLEG-001: keeps a leg that joins its waypoints and drops one that a move, a removal or a bad row broke', () => {
const joined = [
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
{ lat: 47.401, lng: 11.0 },
];
expect(keepJoinedFixedLegs(joined)[0]?.fixedLeg).toBe(leg);
const moved = [
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
{ lat: 47.41, lng: 11.0 },
];
expect(keepJoinedFixedLegs(moved)[0]).not.toHaveProperty('fixedLeg');
const last = [{ lat: 47.401, lng: 11.0, fixedLeg: leg }];
expect(keepJoinedFixedLegs(last)[0]).not.toHaveProperty('fixedLeg');
const broken = [
{ lat: 47.4, lng: 11.0, fixedLeg: { ...leg, kind: 'boat' } as never },
{ lat: 47.401, lng: 11.0 },
];
expect(keepJoinedFixedLegs(broken)[0]).not.toHaveProperty('fixedLeg');
});
it('FE-TOURS-FIXEDLEG-002: measures a line in metres', () => {
expect(lineLengthM(leg.geometry)).toBeCloseTo(111.2, 0);
expect(lineLengthM([])).toBe(0);
});
});
@@ -0,0 +1,52 @@
import { TOUR_FIXED_LEG_END_TOLERANCE_M, tourFixedLegSchema, type TourFixedLeg } from '@trek/shared';
import { haversineKm } from '../../../utils/geo';
/**
* Legs the router does not plan: a via ferrata put onto the tour, whose own
* line from OpenStreetMap the tour follows from one waypoint to the next. A
* fixed leg only holds while it still joins its two waypoints, so moving,
* removing or reordering either of them turns the leg back into a routed one.
*/
export interface FixedLegPoint {
lat: number;
lng: number;
fixedLeg?: TourFixedLeg | null;
}
/** Read as a plain array: this workspace compiles without strict mode, where the schema's tuples are "maybe two numbers". */
function gapM(a: { lat: number; lng: number }, end: readonly unknown[]): number {
return haversineKm(a, { lat: Number(end[0]), lng: Number(end[1]) }) * 1000;
}
/** Whether a leg still runs from this waypoint to the next one. */
function fixedLegJoins(point: FixedLegPoint, next: FixedLegPoint | undefined, leg: TourFixedLeg): boolean {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
return (
!!next &&
!!first &&
!!last &&
gapM(point, first) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
gapM(next, last) <= TOUR_FIXED_LEG_END_TOLERANCE_M
);
}
/** The points with every leg that no longer joins its waypoints (or does not parse) dropped. */
export function keepJoinedFixedLegs<T extends FixedLegPoint>(points: readonly T[]): T[] {
return points.map((point, index) => {
if (!point.fixedLeg) return point;
const parsed = tourFixedLegSchema.safeParse(point.fixedLeg);
if (parsed.success && fixedLegJoins(point, points[index + 1], parsed.data)) return point;
const { fixedLeg: _dropped, ...rest } = point;
return rest as T;
});
}
/** The length of a line, in metres. */
export function lineLengthM(line: readonly (readonly [number, number])[]): number {
let total = 0;
for (let i = 1; i < line.length; i += 1) {
total += haversineKm({ lat: line[i - 1][0], lng: line[i - 1][1] }, { lat: line[i][0], lng: line[i][1] }) * 1000;
}
return total;
}
@@ -35,6 +35,41 @@ describe('routeWalkingTour', () => {
})
})
it('TOUR-ROUTING-020: takes a fixed leg as its own line and routes only the runs around it', async () => {
const leg = {
kind: 'ferrata' as const,
ref: 'way/1',
name: 'Via A',
geometry: [[47.39, 11.01], [47.391, 11.011], [47.392, 11.012]] as [number, number][],
}
valhallaRun
.mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 } })
.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 } })
const routed = await routeWalkingTour([
{ lat: 47.38, lng: 11.0 },
{ lat: 47.39, lng: 11.01, fixedLeg: leg },
{ lat: 47.392, lng: 11.012 },
{ lat: 47.4, lng: 11.02 },
])
expect(valhallaRun).toHaveBeenCalledTimes(2)
expect(valhallaRun.mock.calls[0]?.[0]).toEqual([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01 }])
expect(valhallaRun.mock.calls[1]?.[0]).toEqual([{ lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
expect(routed?.coordinates).toEqual([[47.38, 11.0], [47.39, 11.01], [47.391, 11.011], [47.392, 11.012], [47.4, 11.02]])
expect(routed?.distanceMeters).toBeGreaterThan(2500)
})
it('TOUR-ROUTING-021: a tour that is all fixed leg asks the router nothing, and a failed run fails the whole route', async () => {
const leg = { kind: 'ferrata' as const, ref: 'way/1', name: null, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
const fixedOnly = await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
expect(valhallaRun).not.toHaveBeenCalled()
expect(fixedOnly?.coordinates).toEqual([[47.39, 11.01], [47.392, 11.012]])
valhallaRun.mockResolvedValueOnce(null)
expect(await routeWalkingTour([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])).toBeNull()
})
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
await routeWalkingTour(waypoints, undefined, difficulty)
@@ -1,5 +1,7 @@
import type { TourFixedLeg } from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
import { lineLengthM } from './tourFixedLegs'
export interface TourRouteResult {
coordinates: [number, number][]
@@ -13,21 +15,55 @@ export interface TourRouteResult {
*/
const TOURS_MAX_HIKING_DIFFICULTY = 2
/** Tours deliberately bypasses the OSRM-first RouteCalculator. */
/** How fast a fixed leg is reckoned until the heights are in (a via ferrata climbs slowly). */
const FIXED_LEG_KM_PER_HOUR = 1.5
/**
* Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
* leg is fixed (a via ferrata on the tour) hands its own line over for that
* leg; the router only plans the runs between fixed legs, one request each.
*/
export async function routeWalkingTour(
waypoints: Pick<Waypoint, 'lat' | 'lng'>[],
waypoints: Array<Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }>,
signal?: AbortSignal,
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
): Promise<TourRouteResult | null> {
const run = await valhallaRun(waypoints, 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
})
if (!run) return null
return {
coordinates: run.total.coordinates,
distanceMeters: run.total.distance,
durationSeconds: run.total.duration,
const lines: [number, number][][] = []
let distanceMeters = 0
let durationSeconds = 0
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
if (run.length < 2) return true
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
})
if (!routed) return false
lines.push(routed.total.coordinates)
distanceMeters += routed.total.distance
durationSeconds += routed.total.duration
return true
}
let run: Array<Pick<Waypoint, 'lat' | 'lng'>> = waypoints.slice(0, 1)
for (let i = 0; i < waypoints.length - 1; i += 1) {
const leg = waypoints[i].fixedLeg
if (!leg) {
run.push(waypoints[i + 1])
continue
}
if (!(await routeRun(run))) return null
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
const length = lineLengthM(line)
lines.push(line)
distanceMeters += length
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR) * 3600
run = [waypoints[i + 1]]
}
if (!(await routeRun(run))) return null
if (lines.length === 0) return null
// One line: each part after the first starts where the one before it ended, so its first point goes.
const coordinates = lines.flatMap((line, index) => (index === 0 ? line : line.slice(1)))
return { coordinates, distanceMeters, durationSeconds }
}
const HEIGHT_CHUNK_SIZE = 500

Some files were not shown because too many files have changed in this diff Show More