Compare commits

...

73 Commits

Author SHA1 Message Date
Maurice 52ed63715c Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
2026-10-15 22:34:33 +00:00
Maurice 1d7758b819 Tours: the trip copy keeps its copied tours in one map, so trips.service stays at its size baseline; unused TourMapVisibility type removed 2026-10-15 21:40:19 +00:00
Maurice b33727ec75 Tours: no non-null assertion, useless escape or awaited loop in the tour and trip copy paths 2026-10-15 23:25:16 +02:00
Maurice 97b5f36ef0 Tours: the list filter names a tour planned here TREK, as its badge does 2026-10-15 23:11:39 +02:00
Maurice 845979902c Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green 2026-10-15 23:08:46 +02:00
Maurice a0884685ef Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale 2026-10-15 22:25:34 +02:00
Maurice 485e360c64 Tours: save a tour as a new, independent tour (P2C-07) 2026-10-15 22:18:41 +02:00
Maurice 356df90095 Tours: read the saved tours of a trip over MCP (P2D-MCP-01) 2026-10-15 22:08:39 +02:00
Maurice cd1b1410bd Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) 2026-10-15 22:02:41 +02:00
Maurice 3d9095ccde Tours: provenance contract, source check, one assignment path and the map overview
- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
2026-10-15 21:51:22 +02:00
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00
Maurice cab742e641 Tours: bring the pinned counts and test doubles up to the new tables and switch
- tours locale key count, entity count and NOT NULL field count follow
  the wanderer tables and strings
- the shared MSW admin handlers answer /api/admin/tours-wanderer
- the mobile tour detail test checks the dialog, not the dropped heading
- the refused-origin e2e sends one request per case instead of four at once
2026-10-15 19:25:22 +02:00
Maurice 11f23997e8 Tours: copy a planned tour's import link in the repository, and teach the tests the new columns 2026-10-15 18:38:44 +02:00
Maurice f1c437b8b8 Keep api/client.ts and trips.service.ts within their size baselines
The admin switch for wanderer moves to api/wanderer.ts and the recorded
track restore to api/recordedTrack.ts; trips.service.ts keeps dev's
formatting with only the derived-from remap on copy.
2026-10-15 18:16:25 +02:00
Maurice f690065de8 Tours: resolve the distance unit once where tour facts are built 2026-10-15 17:51:19 +02:00
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00
Maurice 31ffcf1228 fix(ferratas): sort route tile keys with localeCompare 2026-10-15 12:09:48 +02:00
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
549 changed files with 41161 additions and 2548 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.
+7 -7
View File
@@ -1,25 +1,25 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 876,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 231,
"@typescript-eslint/no-explicit-any": 148,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 561,
"react-hooks/refs": 560,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 155,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 42,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1949,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+6 -1
View File
@@ -717,12 +717,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
-1
View File
@@ -1028,7 +1028,6 @@
"src/components/Todo/useTodoList.ts",
"src/components/Tours/PlacesToursModeSwitch.tsx",
"src/components/Tours/TourDetailDialog.test.tsx",
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/TourListRow.tsx",
"src/components/Tours/ToursSidebar.test.tsx",
"src/components/Tours/ToursSidebar.tsx",
-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,
-1
View File
@@ -130,7 +130,6 @@
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/TripPlannerPage.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
+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": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2425,
"src/components/Map/MapViewGL.tsx": 2371,
"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": 1147,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
-1
View File
@@ -89,7 +89,6 @@
"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/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+2 -2
View File
@@ -55,14 +55,14 @@
"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,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 10,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
+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),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** An import that was planned over in place keeps its recorded track; this brings it back. */
export const recordedTrackApi = {
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
import apiClient from './client';
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
export const tourCopiesApi = {
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
};
+53
View File
@@ -0,0 +1,53 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
WandererSaveResponse,
WandererSearchRequest,
WandererSearchResponse,
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* How long the browser waits for a call that makes the server ask wanderer.
* A preview reads the trail's index entry, its record and its GPX one after
* another, each allowed 15 s on the server, so the shared 8 s would give up
* while the server carries on.
*/
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
export const wandererApi = {
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
apiClient
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
export const wandererAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
};
@@ -1,4 +1,3 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -72,7 +77,8 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -646,6 +659,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();
});
@@ -837,4 +852,89 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
onToggleCollabFeature={vi.fn()}
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
});
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
])
);
render(<AddonManager />);
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
});
+24 -9
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
@@ -14,16 +14,20 @@ 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'
import WandererMark from '../Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
@@ -71,6 +75,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
} = useAddonManager()
if (loading) {
@@ -147,6 +153,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
if (addon.id === 'tours' && addon.enabled) {
return (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
@@ -265,7 +282,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 +305,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>
+50 -3
View File
@@ -1,6 +1,13 @@
import type { ReactNode } from 'react'
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
*/
export default function AddonTile({
icon,
@@ -31,6 +42,13 @@ export default function AddonTile({
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
@@ -62,11 +80,40 @@ export default function AddonTile({
</div>
</div>
{children && (
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
</article>
@@ -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. */
@@ -1,6 +1,7 @@
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
@@ -18,6 +19,7 @@ export function useAddonManager() {
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const loadAddons = async () => {
try {
@@ -30,6 +32,10 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
wandererAdminApi
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -57,6 +63,21 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** wanderer under Tours: flipped at once, put back when the save fails. */
const toggleToursWanderer = async () => {
const next = !toursWanderer;
setToursWanderer(next);
try {
await wandererAdminApi.update(next);
} catch {
setToursWanderer(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
label: provider.name,
@@ -83,5 +104,7 @@ export function useAddonManager() {
integrationAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
};
}
@@ -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,182 @@
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((a, b) => a.localeCompare(b))
.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(
+25 -78
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'
@@ -58,6 +61,7 @@ import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
@@ -108,12 +112,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 +447,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 +755,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 +829,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)
@@ -1042,55 +1035,11 @@ export const MapView = memo(function MapView({
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
const markerClickRef = useRef(handleMarkerClick)
markerClickRef.current = handleMarkerClick
const gpxPolylines = useMemo(() => (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{gpxTracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
pathOptions={{ color: '#ffffff', weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{gpxTracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{gpxTracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: '#000', weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => markerClickRef.current(place.id) }}
/>
))}
</>
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
@@ -1306,26 +1255,22 @@ 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 */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
<TrackLines tracks={gpxTracks} casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined} selectRef={markerClickRef} />
<SelectedTrackLine tracks={gpxTracks} selectedPlaceId={selectedPlaceId} />
{routeProfileFocus && (
<CircleMarker
@@ -1346,6 +1291,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 +1338,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 });
});
});
+35 -90
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'
@@ -38,8 +41,7 @@ import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
@@ -63,7 +65,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
@@ -418,12 +419,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 +563,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 +622,9 @@ export function MapViewGL({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -656,6 +656,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 +683,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 +741,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()
@@ -1360,40 +1343,8 @@ export function MapViewGL({
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Casing under the tracks that carry a picked colour (#776) — keeps them
// legible on satellite and dark styles. Untouched tracks are filtered
// out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: 'trip-gpx',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// Invisible fat line that catches the click — 3.5px is not a target,
// and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: 'trip-gpx',
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
if (!hasGpxTrackLayers(map)) {
addGpxTrackLayers(map)
const selectTrack = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
// A click on a cluster bubble sitting over a track belongs to the
// cluster (zoom-to-expand), not to the line underneath it.
@@ -1500,6 +1451,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 +1500,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 +1950,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)
@@ -2088,24 +2041,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = places.flatMap(place => {
const coords = parseRenderableRouteGeometry(place.route_geometry)
if (!coords) return []
return [{
type: 'Feature' as const,
properties: {
color: resolveTrackColor(place),
cased: hasManualTrackColor(place),
place_id: place.id,
},
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
})
src.setData({ type: 'FeatureCollection', features })
setGpxTracks(map, places)
}, [places, mapReady])
// The picked tour's track stands out (PR1-08).
useEffect(() => {
const map = mapRef.current
if (map && mapReady) selectGpxTrack(map, selectedPlaceId)
}, [selectedPlaceId, mapReady])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
@@ -2366,7 +2310,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>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { memo, type RefObject } from 'react';
import { Polyline } from 'react-leaflet';
import type { Place } from '../../types';
import { TRACK_CASING_COLOR } from './trackColors';
/** A place's track as the Leaflet map draws it: its points, its colour, and whether it carries a rim. */
export interface TrackLine {
place: Place;
coords: [number, number][];
cased: boolean;
color: string;
}
interface TrackLinesProps {
tracks: readonly TrackLine[];
/** The pane the rims go into, below the lines; none while the map has not made it yet. */
casingPane?: string;
/** Read on click, so a new selection handler never redraws every track. */
selectRef: RefObject<(placeId: number) => void>;
}
/**
* Every track of the map: the rims, the lines and an invisible fat line on top
* that takes the click. Memoised on the tracks alone; a selection changes
* nothing here, the picked track is drawn by {@link SelectedTrackLine}.
*/
export const TrackLines = memo(function TrackLines({ tracks, casingPane, selectRef }: TrackLinesProps) {
return (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{tracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={casingPane}
pathOptions={{
color: TRACK_CASING_COLOR,
weight: 6.5,
opacity: cased ? 0.7 : 0,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{tracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{tracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => selectRef.current?.(place.id) }}
/>
))}
</>
);
});
/**
* PR1-08: the track of the picked tour, wider and with a light rim on top of
* the others, so the line and the list's row read as one. It takes no click:
* the hit line below still does.
*/
export function SelectedTrackLine({
tracks,
selectedPlaceId,
}: {
tracks: readonly TrackLine[];
selectedPlaceId: number | null;
}) {
const track = selectedPlaceId == null ? null : tracks.find((item) => item.place.id === selectedPlaceId);
if (!track) return null;
return (
<>
<Polyline
key={`gpx-selected-${track.place.id}-casing`}
positions={track.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${track.place.id}`}
positions={track.coords}
pathOptions={{ color: track.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
);
}
@@ -0,0 +1,66 @@
import type mapboxgl from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Place } from '../../types';
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
function fakeMap() {
const layers = new Map<string, { filter?: unknown }>();
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const map = {
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
getSource: (id: string) => sources.get(id),
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
getLayer: (id: string) => layers.get(id),
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
};
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
}
describe('gpxTrackLayers (PR1-08)', () => {
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
const { map, layers } = fakeMap();
expect(hasGpxTrackLayers(map)).toBe(false);
addGpxTrackLayers(map);
expect(hasGpxTrackLayers(map)).toBe(true);
expect([...layers.keys()]).toEqual([
'trip-gpx-casing',
'trip-gpx-line',
'trip-gpx-selected-casing',
'trip-gpx-selected',
GPX_HIT_LAYER_ID,
]);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('puts only places with a drawable track into the source, with their colour and rim', () => {
const { map, sources } = fakeMap();
addGpxTrackLayers(map);
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
const plain = { id: 6, route_geometry: null } as Place;
setGpxTracks(map, [track, plain]);
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
expect(data.features).toHaveLength(1);
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
expect(data.features[0].geometry.coordinates).toEqual([
[11, 47],
[11.1, 47.1],
]);
});
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
const { map, layers } = fakeMap();
addGpxTrackLayers(map);
selectGpxTrack(map, 5);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
selectGpxTrack(map, null);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('does nothing before the style has its layers', () => {
const { map, raw } = fakeMap();
selectGpxTrack(map, 5);
setGpxTracks(map, []);
expect(raw.setFilter).not.toHaveBeenCalled();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared';
import type mapboxgl from 'mapbox-gl';
import type { Place } from '../../types';
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
import { hasManualTrackColor, resolveTrackColor, TRACK_CASING_COLOR } from './trackColors';
const SOURCE_ID = 'trip-gpx';
/** The invisible fat line that takes a click on a track. */
export const GPX_HIT_LAYER_ID = 'trip-gpx-hit';
const SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing';
const SELECTED_LAYER_ID = 'trip-gpx-selected';
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
const NO_TRACK = ['==', ['get', 'place_id'], -1] as mapboxgl.FilterSpecification;
/**
* The tracks of the vector map, as the Leaflet map draws them (TrackLines): a
* rim under the tracks that carry a picked colour, the lines, the picked
* track on top wider with a light rim (PR1-08), and an invisible fat line that
* takes the click. Added once with the style; whoever adds it wires the click.
*/
export function addGpxTrackLayers(map: mapboxgl.Map): void {
map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Casing under the tracks that carry a picked colour (#776): legible on satellite and dark styles.
// Untouched tracks are filtered out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: SOURCE_ID,
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 6.5, 'line-opacity': 0.7 },
layout: ROUND,
});
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: SOURCE_ID,
paint: {
'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: ROUND,
});
// The filter follows the selection (selectGpxTrack); until something is picked it matches no track.
map.addLayer({
id: SELECTED_CASING_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
layout: ROUND,
});
map.addLayer({
id: SELECTED_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK], 'line-width': 5.5 },
layout: ROUND,
});
// 3.5px is not a target, and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: SOURCE_ID,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 14, 'line-opacity': 0 },
layout: ROUND,
});
}
/** Whether the track layers are there yet (they come with the style). */
export function hasGpxTrackLayers(map: mapboxgl.Map): boolean {
return !!map.getSource(SOURCE_ID);
}
/** The places' tracks into the source, each with its colour and whether it carries a rim. */
export function setGpxTracks(map: mapboxgl.Map, places: readonly Place[]): void {
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (!src) return;
const features = places.flatMap((place) => {
const coords = parseRenderableRouteGeometry(place.route_geometry);
if (!coords) return [];
return [
{
type: 'Feature' as const,
properties: { color: resolveTrackColor(place), cased: hasManualTrackColor(place), place_id: place.id },
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
},
];
});
src.setData({ type: 'FeatureCollection', features });
}
/** PR1-08: the picked place's track stands out; none picked, none does. */
export function selectGpxTrack(map: mapboxgl.Map, placeId: number | null): void {
if (typeof map.getLayer !== 'function') return;
const filter = ['==', ['get', 'place_id'], placeId ?? -1] as mapboxgl.FilterSpecification;
for (const id of [SELECTED_CASING_LAYER_ID, SELECTED_LAYER_ID]) {
if (map.getLayer(id)) map.setFilter(id, filter);
}
}
+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.
*
+18 -4
View File
@@ -18,18 +18,32 @@
* both end up as a real DOM node, which is all HTML5 drag needs.
*/
/**
* Start dragging `placeId` towards the day plan, from any row or marker that holds a
* place (a tour is one too). Every source goes through here, so the day plan cannot
* tell them apart.
*/
export function startPlaceDrag(transfer: DataTransfer | null, placeId: number): void {
transfer?.setData('placeId', String(placeId))
if (transfer) transfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
}
/** The drag `startPlaceDrag` began has ended, dropped or not. */
export function endPlaceDrag(): void {
window.__dragData = null
}
/** Wire a marker element up as a drag source for `placeId`. */
export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('placeId', String(placeId))
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
startPlaceDrag(e.dataTransfer, placeId)
el.classList.add('marker-dragging')
}
const onDragEnd = () => {
window.__dragData = null
endPlaceDrag()
el.classList.remove('marker-dragging')
}
// The map pans on mousedown anywhere on its surface, and a marker sits on that
@@ -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`;
}
+3
View File
@@ -1,6 +1,9 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared'
import type { Place } from '../../types'
/** The light rim under a track that carries a picked colour, and under the track picked on the map. */
export const TRACK_CASING_COLOR = '#ffffff'
/**
* The colour a track's polyline is drawn in (#776): a manually picked colour
* wins, then the category colour, then the blue every imported track used to
@@ -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,110 @@
import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import '../../styles/fadeEnd.css';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } 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, one line like every stop's.
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: 'nowrap',
lineHeight: 1.3,
maxHeight: '1.3em',
}}
>
<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 (
// One line like the row's name: when the hover controls come out, the description
// gives way and the pills keep their place instead of wrapping onto a second line.
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowPills 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,307 @@
// 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 a Tour description on one line like every stop, marked 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);
// One line like the row's name, so hover controls make the description give way instead of wrapping.
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
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} />);
// The tile carries the grade, as in the tour lists; the facts sit in the plan's white badges.
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
const time = screen.getByTestId('trip-plan-tour-time');
expect(time).toHaveTextContent('1 h 3 min');
expect(time).toHaveAccessibleName(
'Walking time: 53 min, Breaks / additional time: 10 min, Planned total duration: 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();
});
it('RC-01: an imported tour without a grade shows its type on the tile', () => {
const place = buildPlace({ id: 46, name: 'Recorded loop', tour_place_id: 46 });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignment = buildAssignment({ id: 102, day_id: 10, order_index: 0, place });
const tour = {
place_id: 46,
name: place.name,
tour_type: 'hike' as const,
distance: 6,
elevation_gain: 300,
elevation_loss: 300,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
has_waypoints: false,
};
const props = makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([46]),
assignments: { '10': [assignment] },
plannedTourDurations: new Map([
[46, { tour, walkingMinutes: null, breakMinutes: null, plannedTotalMinutes: null, manuallyOverridden: false }],
]),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument();
});
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
const place = buildPlace({ id: 45, name: 'Ridge walk', tour_place_id: 45 });
const cafe = buildPlace({ id: 47, name: 'Lakeside cafe' });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignments = {
'10': [
buildAssignment({ id: 101, day_id: 10, order_index: 0, place }),
buildAssignment({ id: 103, day_id: 10, order_index: 1, place: cafe }),
],
};
const props = makeDefaultProps({
days: [day],
places: [place, cafe],
tourPlaceIds: new Set([45]),
assignments,
onAddBookingToAssignment: vi.fn(),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
// The cafe can be booked; the tour beside it cannot.
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
});
});
@@ -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 { TourDayRowAvatar } 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 ? <TourDayRowAvatar tour={dayRowTour} 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 && !isTourPlace && (
<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>
)}
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { resolveTrackColor } from '../Map/trackColors'
import { MoreButton, SoftPill, type MenuEntry } from './planParts'
import type { Place, Category } from '../../types'
import { startPlaceDrag } from '../Map/markerDrag'
interface MemoPlaceRowProps {
place: Place
@@ -73,9 +74,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
draggable={!selectMode && !dragDisabled}
onDragStart={e => {
if (dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
startPlaceDrag(e.dataTransfer, place.id)
}}
onClick={activate}
onKeyDown={e => {
@@ -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> = {
@@ -16,6 +16,7 @@ import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
@@ -28,6 +29,7 @@ export default function IntegrationsTab(): React.ReactElement {
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{S.wandererEnabled && !managed && <WandererConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -45,11 +47,14 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed, ...mcp }
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
}
type IntegrationsState = ReturnType<typeof useIntegrations>
@@ -0,0 +1,131 @@
import userEvent from '@testing-library/user-event';
import type { WandererConnection } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import WandererConnectionSection from './WandererConnectionSection';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const disconnected: WandererConnection = {
url: '',
tokenMasked: '',
allowInsecureTls: false,
connected: false,
lastCheckedAt: null,
lastCheckOk: null,
lastCheckError: null,
};
const connected: WandererConnection = {
...disconnected,
url: 'https://wanderer.test',
tokenMasked: '••••••••',
connected: true,
};
function settingsRoute(state: { current: typeof disconnected }) {
return http.get('/api/integrations/wanderer/settings', () => HttpResponse.json(state.current));
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
describe('WandererConnectionSection', () => {
it('RS-03: saves an address and a token, and never fills the token back in', async () => {
const state = { current: disconnected };
const saved: unknown[] = [];
server.use(
settingsRoute(state),
http.put('/api/integrations/wanderer/settings', async ({ request }) => {
saved.push(await request.json());
state.current = connected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Not connected')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(saved).toEqual([{ url: 'https://wanderer.test', token: 'wanderer_key_abc', allowInsecureTls: false }])
);
expect(await screen.findByText('Connected')).toBeInTheDocument();
expect(screen.getByLabelText('API token')).toHaveValue('');
expect(toast.success).toHaveBeenCalled();
});
it('RS-03: says a token that is not a wanderer API token before sending it', async () => {
server.use(settingsRoute({ current: disconnected }));
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'my-password');
expect(screen.getByLabelText('API token')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it('RS-03: a test names how many own trails there are, or why it failed', async () => {
const answers = [
{ ok: true, trailCount: 5 },
{ ok: false, code: 'unauthorized' },
];
server.use(
settingsRoute({ current: connected }),
http.post('/api/integrations/wanderer/test', () => HttpResponse.json(answers.shift()))
);
render(<WandererConnectionSection />);
await screen.findByText('Connected');
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByText('Connected. You can import 5 of your trails.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByRole('status')).not.toHaveTextContent('Connected. You can');
});
it('RS-03: shows what the last check said when it failed', async () => {
server.use(
settingsRoute({
current: { ...connected, lastCheckedAt: '2026-10-15 10:00:00', lastCheckOk: false, lastCheckError: 'disabled' },
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Your admin has not enabled wanderer for Tours.')).toBeInTheDocument();
});
it('RS-03: disconnecting forgets the connection', async () => {
const state = { current: connected };
server.use(
settingsRoute(state),
http.delete('/api/integrations/wanderer/settings', () => {
state.current = disconnected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
await userEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
expect(await screen.findByText('Not connected')).toBeInTheDocument();
});
it('RS-03: a refused save is said as its reason', async () => {
server.use(
settingsRoute({ current: disconnected }),
http.put('/api/integrations/wanderer/settings', () =>
HttpResponse.json({ error: 'x', code: 'blocked_address' }, { status: 400 })
)
);
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'http://10.0.0.5');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
});
});
@@ -0,0 +1,141 @@
import { Loader2, Plug, Save, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import WandererMark from '../Tours/wanderer/WandererMark';
import Section from './Section';
import {
SETTINGS_BUTTON,
SETTINGS_BUTTON_DANGER,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsHint,
StatusPill,
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererConnection } from './useWandererConnection';
/**
* Settings → Integrations → wanderer: the reader's own trail database, from
* which the Tours workspace imports trails. Laid out like the Dawarich and
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
*/
export default function WandererConnectionSection(): React.ReactElement {
const { t } = useTranslation();
const S = useWandererConnection();
return (
<Section
title={t('wanderer.title')}
icon={WandererMark}
badge={
<StatusPill
tone={S.connected ? 'success' : 'neutral'}
icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}
>
{S.connected ? t('wanderer.connected') : t('wanderer.notConnected')}
</StatusPill>
}
>
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
<div className={GRID_2}>
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
<input
id="wanderer-url"
type="url"
value={S.url}
onChange={(event) => S.setUrl(event.target.value)}
placeholder="https://wanderer.example.com"
className={INPUT}
/>
</EditorField>
<EditorField
label={t('wanderer.token')}
htmlFor="wanderer-token"
hint={S.tokenInvalid ? undefined : t('wanderer.tokenHint')}
>
<input
id="wanderer-token"
type="password"
value={S.token}
onChange={(event) => S.setToken(event.target.value)}
autoComplete="off"
aria-invalid={S.tokenInvalid || undefined}
placeholder={S.connected && !S.token ? '••••••••' : 'wanderer_key_…'}
className={INPUT}
/>
{S.tokenInvalid && (
<p className="m-0 mt-1 text-danger" style={fs(11.5, 'body')}>
{t('wanderer.tokenInvalid')}
</p>
)}
</EditorField>
</div>
<SettingRows>
<SettingRow
label={t('wanderer.allowInsecureTls')}
hint={t('wanderer.allowInsecureTlsHint')}
control={
<ToggleSwitch
on={S.allowInsecureTls}
onToggle={S.toggleInsecureTls}
label={t('wanderer.allowInsecureTls')}
/>
}
/>
</SettingRows>
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => void S.save()}
disabled={S.saving || S.loading || !S.canSave}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
<button
type="button"
onClick={() => void S.test()}
disabled={S.testing || S.loading || !S.url.trim() || S.tokenInvalid}
className={SETTINGS_BUTTON}
>
{S.testing ? (
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
) : (
<Plug size={14} strokeWidth={2.2} />
)}
{t('wanderer.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={() => void S.disconnect()}
disabled={S.saving}
className={`${SETTINGS_BUTTON_DANGER} ms-auto`}
>
<Unplug size={14} strokeWidth={2.2} /> {t('wanderer.disconnect')}
</button>
)}
</div>
{(S.probe || S.lastCheck) && (
<div
role="status"
className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3"
style={fs(12, 'body')}
>
{S.probe ? (
<p className={`m-0 font-medium ${S.probe.ok ? 'text-success' : 'text-danger'}`}>{S.probe.message}</p>
) : (
<p className="m-0 text-danger">{S.lastCheck}</p>
)}
</div>
)}
</Section>
);
}
@@ -52,4 +52,11 @@ describe('useIntegrationGates', () => {
renderHook(() => useIntegrationGates({ reloadAddons: true }));
expect(loud).toHaveBeenCalledTimes(1);
});
it('RS-03: offers the wanderer connection only while the admin allows wanderer under Tours', () => {
seedAddons(['tours']);
useAddonStore.setState({ toursWanderer: false });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(false);
useAddonStore.setState({ toursWanderer: true });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(true);
});
});

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