From 6e3eb591b5ad022052f5c0c95093cf17001adece Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 18 Oct 2026 01:13:24 +0200 Subject: [PATCH] feat(tours): route paddle tours on the water, shaping points and auto-loaded regions Paddle tours are planned on the water by TREK's own router: a water network per paddle region, built from the same Geofabrik extract as the obstacles and searched in a worker thread of the server (no sidecar). Rivers, canals and lakes (direct or along the shore), portages round weirs, locks by policy, whitewater by boat, gaps marked. Found portages and locks become points of their own; an unplannable leg says why and offers a way out. - Shaping points: a click on the line sets a point that steers the route without being a stop (hikes and paddle tours); recompute a single leg. - Lake style direct/shore, suggested from the wind forecast. - Regions load by themselves where a paddler plans (admin setting: automatic, on request, off; size cap and a daily count), with requests for the admins, a notification, and the load followed on the leg. - Portage from the last point, portages follow footways via the walking router (or stay straight), "put in here" on slipways and put-ins. - MCP plan_paddle_route with the REST route's rules. --- client/scripts/eslint-baseline.json | 2 +- client/scripts/size-baseline.json | 2 +- client/src/api/paddleRegionLoad.ts | 13 + client/src/api/paddleRegions.ts | 13 +- client/src/api/paddleRoute.ts | 10 + .../Admin/paddle/PaddleAutoLoadParts.tsx | 167 +++++ .../Admin/paddle/PaddleRegionsPanel.test.tsx | 93 ++- .../Admin/paddle/PaddleRegionsPanel.tsx | 46 +- .../Admin/paddle/usePaddleAutoLoad.test.ts | 50 ++ .../Admin/paddle/usePaddleAutoLoad.ts | 59 ++ .../paddle/usePaddleRegionsAdmin.test.ts | 8 +- .../Admin/paddle/usePaddleRegionsAdmin.ts | 28 +- client/src/components/Map/MapView.tsx | 15 +- .../components/Map/PlannerWaypointMarker.tsx | 21 +- client/src/components/Map/RouteHitBand.tsx | 35 + client/src/components/Map/mapViewContract.ts | 10 +- .../components/Map/plannerPinShape.test.ts | 12 + client/src/components/Map/plannerPinShape.ts | 16 + .../Map/usePlannerWaypointMarkersGL.ts | 5 +- .../Tours/paddle/PaddleLakeStyleField.tsx | 36 + .../Tours/paddle/PaddleLayerLeaflet.test.tsx | 2 + .../Tours/paddle/PaddleLegParts.tsx | 152 +++++ .../paddle/PaddleObstaclePlaceButton.test.tsx | 72 ++ .../paddle/PaddleObstaclePlaceButton.tsx | 66 ++ .../Tours/paddle/PaddleRegionLoadRow.test.tsx | 77 +++ .../Tours/paddle/PaddleRegionLoadRow.tsx | 130 ++++ .../Tours/paddle/PaddleTimeCard.test.tsx | 3 +- .../Tours/paddle/PaddleWaypointParts.test.tsx | 53 ++ .../Tours/paddle/PaddleWaypointParts.tsx | 100 ++- .../Tours/paddle/PaddleWindCard.test.tsx | 26 +- .../Tours/paddle/PaddleWindCard.tsx | 54 +- .../Tours/paddle/lakeStyleAdvice.test.ts | 51 ++ .../Tours/paddle/lakeStyleAdvice.ts | 32 + .../Tours/paddle/paddleMapLayer.test.ts | 2 + .../components/Tours/paddle/paddlePaint.ts | 2 + .../Tours/paddle/portageRoute.test.ts | 54 ++ .../components/Tours/paddle/portageRoute.ts | 36 + .../Tours/paddle/usePaddleChecklist.test.tsx | 3 +- .../Tours/paddle/usePaddleLayerGL.test.ts | 2 + .../Tours/paddle/usePaddleRegionLoad.test.ts | 86 +++ .../Tours/paddle/usePaddleRegionLoad.ts | 108 +++ .../Tours/paddle/usePortageRouting.test.ts | 90 +++ .../Tours/paddle/usePortageRouting.ts | 194 ++++++ .../Tours/planner/TourPlannerPanels.test.tsx | 18 + .../Tours/planner/TourPlannerPanels.tsx | 23 +- .../Tours/planner/TourTypeFields.test.tsx | 30 + .../Tours/planner/TourTypeFields.tsx | 5 + .../Tours/planner/TourWaypointList.test.tsx | 167 +++++ .../Tours/planner/TourWaypointList.tsx | 396 ++++++++--- .../components/Tours/planner/paddlePlanOf.ts | 10 +- .../Tours/planner/paddleRouting.test.ts | 207 ++++++ .../components/Tours/planner/paddleRouting.ts | 262 +++++++ .../Tours/planner/routeLegAt.test.ts | 34 + .../components/Tours/planner/routeLegAt.ts | 55 ++ .../Tours/planner/tourPlannerDraft.ts | 6 +- .../planner/tourPlannerWaypoints.test.ts | 83 +++ .../Tours/planner/tourPlannerWaypoints.ts | 119 ++++ .../Tours/planner/tourRouteActions.test.ts | 47 +- .../Tours/planner/tourRouteActions.ts | 79 ++- .../Tours/planner/tourRouteStyle.test.ts | 47 ++ .../Tours/planner/tourRouteStyle.ts | 39 +- .../components/Tours/planner/tourRouting.ts | 6 + .../Tours/planner/usePaddleFields.ts | 16 +- .../Tours/planner/useRouteShaping.ts | 71 ++ .../Tours/planner/useShapingClick.ts | 26 + .../useTourPlanner.routeTools.test.tsx | 196 +++++- .../Tours/planner/useTourPlanner.test.tsx | 2 +- .../Tours/planner/useTourPlanner.ts | 168 +++-- .../Tours/terrain/TourTerrainMap.test.tsx | 14 + .../Tours/terrain/TourTerrainMap.tsx | 31 +- .../Tours/terrain/terrainLayers.test.ts | 4 +- .../components/Tours/terrain/terrainLayers.ts | 12 +- .../Tours/terrain/useTerrainPins.ts | 12 +- .../Tours/terrain/useTourTerrain.ts | 6 +- client/src/pages/admin/AdminPage.constants.ts | 1 + .../tripPlanner/TourPlannerMapStage.test.tsx | 50 +- .../pages/tripPlanner/TourPlannerMapStage.tsx | 33 +- .../src/pages/tripPlanner/useTourMapPoints.ts | 15 +- .../pages/tripPlanner/useTripPlannerPage.ts | 4 +- client/src/repo/paddleRegionLoadRepo.test.ts | 46 ++ client/src/repo/paddleRegionLoadRepo.ts | 20 + client/src/repo/paddleRegionRepo.test.ts | 12 +- client/src/repo/paddleRegionRepo.ts | 3 + client/src/repo/paddleRouteRepo.test.ts | 192 ++++++ client/src/repo/paddleRouteRepo.ts | 95 +++ client/src/styles/paddle.css | 8 +- client/tests/helpers/tourPlannerStub.ts | 9 + scripts/ci/knip-baseline.json | 2 +- server/scripts/repository-owners.json | 2 + .../entities/PaddleRegionRequests.entity.ts | 37 + .../src/db/entities/PaddleRegions.entity.ts | 14 +- .../db/entities/PaddleWaterGraphs.entity.ts | 45 ++ .../src/db/entities/TourWaypoints.entity.ts | 5 + server/src/db/entities/Tours.entity.ts | 9 + server/src/db/entities/index.ts | 4 + server/src/db/json-columns.ts | 9 + .../db/kysely/PaddleRegionRequests.table.ts | 13 + server/src/db/kysely/PaddleRegions.table.ts | 1 + .../src/db/kysely/PaddleWaterGraphs.table.ts | 15 + server/src/db/kysely/TourWaypoints.table.ts | 1 + server/src/db/kysely/Tours.table.ts | 1 + server/src/db/kysely/db.ts | 4 + ..._paddle_region_builds_its_water_network.ts | 34 + ...500_a_tour_waypoint_can_shape_the_route.ts | 14 + ...dle_tour_remembers_how_it_crosses_lakes.ts | 16 + ..._water_data_loads_where_a_paddler_plans.ts | 35 + .../PaddleRegionRequests.repository.ts | 80 +++ .../repositories/PaddleRegions.repository.ts | 54 +- .../PaddleWaterGraphs.repository.ts | 86 +++ .../repositories/TourWaypoints.repository.ts | 23 +- .../src/db/repositories/Tours.repository.ts | 10 +- server/src/nest/app.module.ts | 2 + .../src/nest/common/app-settings.registry.ts | 3 + .../nest/notifications/notification-events.ts | 8 +- .../notifications/notifications.service.ts | 7 + .../notifications/transports/ntfy.service.ts | 1 + .../nest/paddle-obstacles/geofabrik.client.ts | 31 +- .../nest/paddle-obstacles/import-runner.ts | 97 +++ .../src/nest/paddle-obstacles/node-store.ts | 79 +++ .../nest/paddle-obstacles/osm-pbf.reader.ts | 86 ++- .../nest/paddle-obstacles/paddle-extract.ts | 102 ++- .../nest/paddle-obstacles/paddle-import.ts | 33 + .../paddle-obstacles/paddle-import.worker.ts | 12 + .../paddle-obstacles.controller.ts | 70 +- .../paddle-obstacles/paddle-obstacles.dto.ts | 10 +- .../paddle-obstacles.module.ts | 31 +- .../paddle-region-load.service.ts | 266 ++++++++ .../paddle-regions.service.ts | 79 ++- .../nest/paddle-routing/build/build-graph.ts | 146 ++++ .../nest/paddle-routing/build/build-state.ts | 241 +++++++ .../src/nest/paddle-routing/build/collect.ts | 201 ++++++ server/src/nest/paddle-routing/build/lakes.ts | 332 +++++++++ server/src/nest/paddle-routing/build/lines.ts | 284 ++++++++ .../src/nest/paddle-routing/build/portages.ts | 177 +++++ server/src/nest/paddle-routing/engine/geo.ts | 71 ++ .../nest/paddle-routing/engine/lake-field.ts | 355 ++++++++++ .../nest/paddle-routing/engine/min-heap.ts | 75 ++ .../src/nest/paddle-routing/engine/router.ts | 644 ++++++++++++++++++ .../paddle-routing/paddle-router.worker.ts | 19 + .../paddle-routing.controller.ts | 29 + .../nest/paddle-routing/paddle-routing.dto.ts | 5 + .../nest/paddle-routing/paddle-routing.mcp.ts | 50 ++ .../paddle-routing/paddle-routing.module.ts | 34 + .../paddle-routing/paddle-routing.service.ts | 212 ++++++ .../src/nest/paddle-routing/router-runner.ts | 74 ++ .../paddle-routing/water-graph.contract.ts | 53 ++ server/src/nest/paddle-routing/water-graph.ts | 206 ++++++ .../paddle-routing/worker-router-runner.ts | 102 +++ server/src/nest/tours/tours.service.ts | 6 + server/tests/e2e/paddle-obstacles.e2e.test.ts | 2 +- server/tests/e2e/paddle-routing.e2e.test.ts | 200 ++++++ server/tests/e2e/tours.e2e.test.ts | 36 + server/tests/helpers/osm-pbf-fixture.ts | 50 +- server/tests/helpers/water-world.ts | 101 +++ server/tests/unit/db/entities-index.test.ts | 4 +- .../tests/unit/db/entity-class-fields.test.ts | 4 +- server/tests/unit/db/legacy-baseline.test.ts | 4 + .../TourWaypoints.repository.test.ts | 12 + .../db/repositories/Tours.repository.test.ts | 5 + .../tests/unit/db/tour-paddle-columns.test.ts | 11 +- .../paddle-obstacles/import-runner.test.ts | 173 +++++ .../paddle-obstacles/osm-pbf.reader.test.ts | 58 +- .../paddle-obstacles/paddle-extract.test.ts | 73 +- .../paddle-obstacles.controller.test.ts | 73 +- .../paddle-obstacles.service.test.ts | 3 +- .../paddle-region-load.service.test.ts | 264 +++++++ .../paddle-regions.service.test.ts | 60 +- .../paddle-routing.controller.test.ts | 149 ++++ .../paddle-routing.service.test.ts | 232 +++++++ .../nest/paddle-routing/router-runner.test.ts | 208 ++++++ .../nest/paddle-routing/water-routing.test.ts | 392 +++++++++++ .../nest/tours.service.permissions.test.ts | 2 + server/vitest.config.ts | 2 + shared/src/i18n/ar/admin.ts | 56 ++ shared/src/i18n/ar/externalNotifications.ts | 4 + shared/src/i18n/ar/notif.ts | 2 + shared/src/i18n/ar/settings.ts | 1 + shared/src/i18n/ar/tours.ts | 68 +- shared/src/i18n/az/admin.ts | 37 + shared/src/i18n/az/externalNotifications.ts | 4 + shared/src/i18n/az/notif.ts | 2 + shared/src/i18n/az/settings.ts | 1 + shared/src/i18n/az/tours.ts | 69 +- shared/src/i18n/br/admin.ts | 37 + shared/src/i18n/br/externalNotifications.ts | 4 + shared/src/i18n/br/notif.ts | 2 + shared/src/i18n/br/settings.ts | 1 + shared/src/i18n/br/tours.ts | 72 +- shared/src/i18n/ca/admin.ts | 39 ++ shared/src/i18n/ca/externalNotifications.ts | 4 + shared/src/i18n/ca/notif.ts | 2 + shared/src/i18n/ca/settings.ts | 1 + shared/src/i18n/ca/tours.ts | 73 +- shared/src/i18n/cs/admin.ts | 41 ++ shared/src/i18n/cs/externalNotifications.ts | 4 + shared/src/i18n/cs/notif.ts | 2 + shared/src/i18n/cs/settings.ts | 1 + shared/src/i18n/cs/tours.ts | 67 +- shared/src/i18n/de/admin.ts | 38 ++ shared/src/i18n/de/externalNotifications.ts | 4 + shared/src/i18n/de/notif.ts | 2 + shared/src/i18n/de/settings.ts | 1 + shared/src/i18n/de/tours.ts | 74 +- shared/src/i18n/en/admin.ts | 37 + shared/src/i18n/en/externalNotifications.ts | 4 + shared/src/i18n/en/notif.ts | 2 + shared/src/i18n/en/settings.ts | 1 + shared/src/i18n/en/tours.ts | 66 +- shared/src/i18n/es/admin.ts | 38 ++ shared/src/i18n/es/externalNotifications.ts | 4 + shared/src/i18n/es/notif.ts | 2 + shared/src/i18n/es/settings.ts | 1 + shared/src/i18n/es/tours.ts | 72 +- shared/src/i18n/et/admin.ts | 37 + shared/src/i18n/et/externalNotifications.ts | 4 + shared/src/i18n/et/notif.ts | 2 + shared/src/i18n/et/settings.ts | 1 + shared/src/i18n/et/tours.ts | 69 +- .../src/i18n/externalNotifications/types.ts | 1 + shared/src/i18n/fr/admin.ts | 39 ++ shared/src/i18n/fr/externalNotifications.ts | 4 + shared/src/i18n/fr/notif.ts | 2 + shared/src/i18n/fr/settings.ts | 1 + shared/src/i18n/fr/tours.ts | 74 +- shared/src/i18n/gr/admin.ts | 39 ++ shared/src/i18n/gr/externalNotifications.ts | 4 + shared/src/i18n/gr/notif.ts | 2 + shared/src/i18n/gr/settings.ts | 1 + shared/src/i18n/gr/tours.ts | 74 +- shared/src/i18n/hu/admin.ts | 38 ++ shared/src/i18n/hu/externalNotifications.ts | 4 + shared/src/i18n/hu/notif.ts | 2 + shared/src/i18n/hu/settings.ts | 1 + shared/src/i18n/hu/tours.ts | 70 +- shared/src/i18n/id/admin.ts | 32 + shared/src/i18n/id/externalNotifications.ts | 4 + shared/src/i18n/id/notif.ts | 2 + shared/src/i18n/id/settings.ts | 1 + shared/src/i18n/id/tours.ts | 70 +- shared/src/i18n/it/admin.ts | 38 ++ shared/src/i18n/it/externalNotifications.ts | 4 + shared/src/i18n/it/notif.ts | 2 + shared/src/i18n/it/settings.ts | 1 + shared/src/i18n/it/tours.ts | 74 +- shared/src/i18n/ja/admin.ts | 33 + shared/src/i18n/ja/externalNotifications.ts | 4 + shared/src/i18n/ja/notif.ts | 2 + shared/src/i18n/ja/settings.ts | 1 + shared/src/i18n/ja/tours.ts | 65 +- shared/src/i18n/ko/admin.ts | 32 + shared/src/i18n/ko/externalNotifications.ts | 4 + shared/src/i18n/ko/notif.ts | 2 + shared/src/i18n/ko/settings.ts | 1 + shared/src/i18n/ko/tours.ts | 65 +- shared/src/i18n/nl/admin.ts | 39 ++ shared/src/i18n/nl/externalNotifications.ts | 4 + shared/src/i18n/nl/notif.ts | 2 + shared/src/i18n/nl/settings.ts | 1 + shared/src/i18n/nl/tours.ts | 72 +- shared/src/i18n/pl/admin.ts | 48 ++ shared/src/i18n/pl/externalNotifications.ts | 4 + shared/src/i18n/pl/notif.ts | 2 + shared/src/i18n/pl/settings.ts | 1 + shared/src/i18n/pl/tours.ts | 71 +- shared/src/i18n/ru/admin.ts | 48 ++ shared/src/i18n/ru/externalNotifications.ts | 4 + shared/src/i18n/ru/notif.ts | 2 + shared/src/i18n/ru/settings.ts | 1 + shared/src/i18n/ru/tours.ts | 73 +- shared/src/i18n/sk/admin.ts | 42 ++ shared/src/i18n/sk/externalNotifications.ts | 4 + shared/src/i18n/sk/notif.ts | 2 + shared/src/i18n/sk/settings.ts | 1 + shared/src/i18n/sk/tours.ts | 67 +- shared/src/i18n/sv/admin.ts | 38 ++ shared/src/i18n/sv/externalNotifications.ts | 4 + shared/src/i18n/sv/notif.ts | 2 + shared/src/i18n/sv/settings.ts | 1 + shared/src/i18n/sv/tours.ts | 68 +- shared/src/i18n/th/admin.ts | 31 + shared/src/i18n/th/externalNotifications.ts | 4 + shared/src/i18n/th/notif.ts | 2 + shared/src/i18n/th/settings.ts | 1 + shared/src/i18n/th/tours.ts | 64 +- shared/src/i18n/tours-locales.spec.ts | 4 +- shared/src/i18n/tr/admin.ts | 37 + shared/src/i18n/tr/externalNotifications.ts | 4 + shared/src/i18n/tr/notif.ts | 2 + shared/src/i18n/tr/settings.ts | 1 + shared/src/i18n/tr/tours.ts | 66 +- shared/src/i18n/uk/admin.ts | 48 ++ shared/src/i18n/uk/externalNotifications.ts | 4 + shared/src/i18n/uk/notif.ts | 2 + shared/src/i18n/uk/settings.ts | 1 + shared/src/i18n/uk/tours.ts | 69 +- shared/src/i18n/vi/admin.ts | 32 + shared/src/i18n/vi/externalNotifications.ts | 4 + shared/src/i18n/vi/notif.ts | 2 + shared/src/i18n/vi/settings.ts | 1 + shared/src/i18n/vi/tours.ts | 69 +- shared/src/i18n/zh-TW/admin.ts | 29 + .../src/i18n/zh-TW/externalNotifications.ts | 4 + shared/src/i18n/zh-TW/notif.ts | 2 + shared/src/i18n/zh-TW/settings.ts | 1 + shared/src/i18n/zh-TW/tours.ts | 60 +- shared/src/i18n/zh/admin.ts | 29 + shared/src/i18n/zh/externalNotifications.ts | 4 + shared/src/i18n/zh/notif.ts | 2 + shared/src/i18n/zh/settings.ts | 1 + shared/src/i18n/zh/tours.ts | 60 +- shared/src/index.ts | 1 + .../paddle/paddle-obstacles.schema.spec.ts | 21 + .../paddle/paddle-region-load.schema.spec.ts | 74 ++ shared/src/paddle/paddle-regions.schema.ts | 138 +++- shared/src/paddle/paddle-route.schema.spec.ts | 51 ++ shared/src/paddle/paddle-route.schema.ts | 109 +++ shared/src/tours/paddle-plan.spec.ts | 37 + shared/src/tours/paddle-plan.ts | 16 +- shared/src/tours/tours.schema.ts | 21 +- wiki/Admin-Addons.md | 6 +- wiki/MCP-Addon-Tools.md | 5 +- 321 files changed, 14675 insertions(+), 471 deletions(-) create mode 100644 client/src/api/paddleRegionLoad.ts create mode 100644 client/src/api/paddleRoute.ts create mode 100644 client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx create mode 100644 client/src/components/Admin/paddle/usePaddleAutoLoad.test.ts create mode 100644 client/src/components/Admin/paddle/usePaddleAutoLoad.ts create mode 100644 client/src/components/Map/RouteHitBand.tsx create mode 100644 client/src/components/Map/plannerPinShape.test.ts create mode 100644 client/src/components/Map/plannerPinShape.ts create mode 100644 client/src/components/Tours/paddle/PaddleLakeStyleField.tsx create mode 100644 client/src/components/Tours/paddle/PaddleLegParts.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx create mode 100644 client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx create mode 100644 client/src/components/Tours/paddle/PaddleWaypointParts.test.tsx create mode 100644 client/src/components/Tours/paddle/lakeStyleAdvice.test.ts create mode 100644 client/src/components/Tours/paddle/lakeStyleAdvice.ts create mode 100644 client/src/components/Tours/paddle/portageRoute.test.ts create mode 100644 client/src/components/Tours/paddle/portageRoute.ts create mode 100644 client/src/components/Tours/paddle/usePaddleRegionLoad.test.ts create mode 100644 client/src/components/Tours/paddle/usePaddleRegionLoad.ts create mode 100644 client/src/components/Tours/paddle/usePortageRouting.test.ts create mode 100644 client/src/components/Tours/paddle/usePortageRouting.ts create mode 100644 client/src/components/Tours/planner/TourWaypointList.test.tsx create mode 100644 client/src/components/Tours/planner/paddleRouting.test.ts create mode 100644 client/src/components/Tours/planner/paddleRouting.ts create mode 100644 client/src/components/Tours/planner/routeLegAt.test.ts create mode 100644 client/src/components/Tours/planner/routeLegAt.ts create mode 100644 client/src/components/Tours/planner/tourPlannerWaypoints.test.ts create mode 100644 client/src/components/Tours/planner/tourPlannerWaypoints.ts create mode 100644 client/src/components/Tours/planner/tourRouteStyle.test.ts create mode 100644 client/src/components/Tours/planner/useRouteShaping.ts create mode 100644 client/src/components/Tours/planner/useShapingClick.ts create mode 100644 client/src/repo/paddleRegionLoadRepo.test.ts create mode 100644 client/src/repo/paddleRegionLoadRepo.ts create mode 100644 client/src/repo/paddleRouteRepo.test.ts create mode 100644 client/src/repo/paddleRouteRepo.ts create mode 100644 server/src/db/entities/PaddleRegionRequests.entity.ts create mode 100644 server/src/db/entities/PaddleWaterGraphs.entity.ts create mode 100644 server/src/db/kysely/PaddleRegionRequests.table.ts create mode 100644 server/src/db/kysely/PaddleWaterGraphs.table.ts create mode 100644 server/src/db/migrations/Migration20200101044400_a_paddle_region_builds_its_water_network.ts create mode 100644 server/src/db/migrations/Migration20200101044500_a_tour_waypoint_can_shape_the_route.ts create mode 100644 server/src/db/migrations/Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes.ts create mode 100644 server/src/db/migrations/Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans.ts create mode 100644 server/src/db/repositories/PaddleRegionRequests.repository.ts create mode 100644 server/src/db/repositories/PaddleWaterGraphs.repository.ts create mode 100644 server/src/nest/paddle-obstacles/import-runner.ts create mode 100644 server/src/nest/paddle-obstacles/node-store.ts create mode 100644 server/src/nest/paddle-obstacles/paddle-import.ts create mode 100644 server/src/nest/paddle-obstacles/paddle-import.worker.ts create mode 100644 server/src/nest/paddle-obstacles/paddle-region-load.service.ts create mode 100644 server/src/nest/paddle-routing/build/build-graph.ts create mode 100644 server/src/nest/paddle-routing/build/build-state.ts create mode 100644 server/src/nest/paddle-routing/build/collect.ts create mode 100644 server/src/nest/paddle-routing/build/lakes.ts create mode 100644 server/src/nest/paddle-routing/build/lines.ts create mode 100644 server/src/nest/paddle-routing/build/portages.ts create mode 100644 server/src/nest/paddle-routing/engine/geo.ts create mode 100644 server/src/nest/paddle-routing/engine/lake-field.ts create mode 100644 server/src/nest/paddle-routing/engine/min-heap.ts create mode 100644 server/src/nest/paddle-routing/engine/router.ts create mode 100644 server/src/nest/paddle-routing/paddle-router.worker.ts create mode 100644 server/src/nest/paddle-routing/paddle-routing.controller.ts create mode 100644 server/src/nest/paddle-routing/paddle-routing.dto.ts create mode 100644 server/src/nest/paddle-routing/paddle-routing.mcp.ts create mode 100644 server/src/nest/paddle-routing/paddle-routing.module.ts create mode 100644 server/src/nest/paddle-routing/paddle-routing.service.ts create mode 100644 server/src/nest/paddle-routing/router-runner.ts create mode 100644 server/src/nest/paddle-routing/water-graph.contract.ts create mode 100644 server/src/nest/paddle-routing/water-graph.ts create mode 100644 server/src/nest/paddle-routing/worker-router-runner.ts create mode 100644 server/tests/e2e/paddle-routing.e2e.test.ts create mode 100644 server/tests/helpers/water-world.ts create mode 100644 server/tests/unit/nest/paddle-obstacles/import-runner.test.ts create mode 100644 server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts create mode 100644 server/tests/unit/nest/paddle-routing/paddle-routing.controller.test.ts create mode 100644 server/tests/unit/nest/paddle-routing/paddle-routing.service.test.ts create mode 100644 server/tests/unit/nest/paddle-routing/router-runner.test.ts create mode 100644 server/tests/unit/nest/paddle-routing/water-routing.test.ts create mode 100644 shared/src/paddle/paddle-region-load.schema.spec.ts create mode 100644 shared/src/paddle/paddle-route.schema.spec.ts create mode 100644 shared/src/paddle/paddle-route.schema.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index af2c39824..3731ded6b 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,7 +1,7 @@ { "src": { "@typescript-eslint/no-non-null-assertion": 404, - "@typescript-eslint/no-misused-promises": 875, + "@typescript-eslint/no-misused-promises": 874, "no-console": 88, "complexity": 230, "@typescript-eslint/no-explicit-any": 147, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 423ad10d4..002aa3dbe 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -5,7 +5,7 @@ "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.tsx": 1816, - "src/components/Map/MapView.tsx": 1409, + "src/components/Map/MapView.tsx": 1402, "src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.tsx": 2299, "src/components/Map/RouteCalculator.ts": 1195, diff --git a/client/src/api/paddleRegionLoad.ts b/client/src/api/paddleRegionLoad.ts new file mode 100644 index 000000000..ec91d0400 --- /dev/null +++ b/client/src/api/paddleRegionLoad.ts @@ -0,0 +1,13 @@ +import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared'; +import apiClient from './client'; + +/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */ +const GEOFABRIK = { timeout: 30_000 }; + +/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */ +export const paddleRegionLoadApi = { + load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise => + apiClient + .post('/paddle/regions/load', request, { signal, ...GEOFABRIK }) + .then((r) => paddleRegionLoadResponseSchema.parse(r.data).points), +}; diff --git a/client/src/api/paddleRegions.ts b/client/src/api/paddleRegions.ts index 9b17019d3..b4b7f3f4e 100644 --- a/client/src/api/paddleRegions.ts +++ b/client/src/api/paddleRegions.ts @@ -2,17 +2,19 @@ import { paddleRegionCatalogResponseSchema, paddleRegionListResponseSchema, paddleRegionSchema, + paddleRegionSettingsSchema, successResponseSchema, type PaddleRegionRequest, + type PaddleRegionSettings, } from '@trek/shared'; import apiClient from './client'; /** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */ const GEOFABRIK = { timeout: 30_000 }; -/** PAD-05A, the admin side: the regions TREK reads paddle data for. */ +/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */ export const paddleRegionsApi = { - list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions), + list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)), catalog: (q: string, signal?: AbortSignal) => apiClient .get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK }) @@ -29,4 +31,11 @@ export const paddleRegionsApi = { apiClient .post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest) .then((r) => successResponseSchema.parse(r.data)), + decline: (id: string) => + apiClient + .post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest) + .then((r) => successResponseSchema.parse(r.data)), + settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)), + saveSettings: (settings: PaddleRegionSettings) => + apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)), }; diff --git a/client/src/api/paddleRoute.ts b/client/src/api/paddleRoute.ts new file mode 100644 index 000000000..eb20d2d24 --- /dev/null +++ b/client/src/api/paddleRoute.ts @@ -0,0 +1,10 @@ +import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared'; +import apiClient from './client'; + +/** PAD-11: legs of a paddle tour planned on the water by the server's router. */ +export const paddleRouteApi = { + plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise => + apiClient + .post(`/trips/${tripId}/paddle/route`, request, { signal }) + .then((r) => paddleRouteResponseSchema.parse(r.data)), +}; diff --git a/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx new file mode 100644 index 000000000..45205245b --- /dev/null +++ b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx @@ -0,0 +1,167 @@ +import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared'; +import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit'; +import { LABEL, Segmented } from '../../shared/dialogParts'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; + +type Admin = ReturnType; + +const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; +const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const; +/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ +const SIZES = [100, 250, 500, 1000, 2000] as const; + +/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */ +function useMegabytes() { + const { language } = useTranslation(); + return (mb: number) => + new Intl.NumberFormat(language, { + style: 'unit', + unit: mb >= 1000 ? 'gigabyte' : 'megabyte', + maximumFractionDigits: mb >= 1000 ? 1 : 0, + }).format(mb >= 1000 ? mb / 1000 : mb); +} + +const MB = 1024 * 1024; + +/** + * PAD-11C: how TREK gets the water data of a place a paddler plans in. The + * mode, and in the automatic one the largest region it loads without asking. + */ +export function AutoLoadSettings({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + const megabytes = useMegabytes(); + const { settings, saving, save } = admin.autoLoad; + if (!settings) return null; + const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number]) + ? SIZES + : [...SIZES, settings.max_mb].sort((a, b) => a - b); + return ( +
+
+ {t('admin.paddleRegions.auto.title')} +
+ + value={settings.mode} + onChange={(mode) => void save({ mode })} + label={t('admin.paddleRegions.auto.title')} + fill + options={MODES.map((mode) => { + const Icon = MODE_ICONS[mode]; + return { + value: mode, + label: t(`admin.paddleRegions.auto.mode.${mode}`), + icon:
+ {t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)} +
+ {settings.mode === 'auto' && ( +
+ {t('admin.paddleRegions.auto.maxSize')} + + value={String(settings.max_mb)} + label={t('admin.paddleRegions.auto.maxSize')} + onChange={(value) => void save({ max_mb: Number(value) })} + options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))} + /> + {t('admin.paddleRegions.auto.maxSizeHint')} +
+ )} +
+ ); +} + +function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) { + const { t, language } = useTranslation(); + const megabytes = useMegabytes(); + const busy = admin.busyId === request.id; + const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' }); + return ( +
+
+ + {request.name} + + + {request.declined ? ( + }> + {t('admin.paddleRegions.requests.declined')} + + ) : ( + {t(`admin.paddleRegions.requests.reason.${request.reason}`)} + )} + {request.bytes !== null && {megabytes(Math.max(1, Math.round(request.bytes / MB)))}} + {request.requested_by && ( + {t('admin.paddleRegions.requests.by', { user: request.requested_by })} + )} + {date} + +
+ + {!request.declined && ( + + )} + + +
+ ); +} + +/** The regions paddlers asked for that wait for an admin, the open ones first. */ +export function RequestList({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.requests.length === 0) return null; + const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined)); + const open = admin.requests.filter((request) => !request.declined).length; + return ( +
+ + {t('admin.paddleRegions.requests.title')} + {open > 0 && {open}} + + + {ordered.map((request) => ( + + ))} + +
+ ); +} + +/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */ +export function AutoOriginPill() { + const { t } = useTranslation(); + return ( + + + }>{t('admin.paddleRegions.origin.auto')} + + + ); +} diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx index 538479878..4bbbd5db7 100644 --- a/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a +// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a // controlled usePaddleRegionsAdmin (the hook has its own test). // FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel. import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; @@ -25,11 +25,13 @@ function region(overrides: Partial = {}): PaddleRegion { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready', + origin: 'admin', source_timestamp: '2026-10-03T12:00:00Z', imported_at: '2026-10-04T08:00:00Z', feature_count: 1234, error: null, progress: null, + network: null, ...overrides, }; } @@ -56,6 +58,8 @@ function adminState(overrides: Partial = {}, search: Partial {}) }, loading: false, error: null, retry: vi.fn(), @@ -63,6 +67,8 @@ function adminState(overrides: Partial = {}, search: Partial {}), refresh: vi.fn(async () => {}), + approve: vi.fn(async () => {}), + decline: vi.fn(async () => {}), removing: null, askRemove: vi.fn(), cancelRemove: vi.fn(), @@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => { }); }); +describe('PaddleRegionsPanel water network (PAD-11)', () => { + it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => { + const network = { + water_km: 1364.5, + lakes: 266, + portages: 14, + inferred_portages: 232, + gaps: 63, + locks: 17, + built_at: '2026-10-17T15:30:20Z', + bytes: 1496816, + }; + show({ regions: [region({ network })] }); + const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network'); + expect(pill).toHaveTextContent('1,365 km of water'); + fireEvent.mouseEnter(pill); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + '266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps' + ); + }); + + it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => { + show({ + regions: [ + region({ id: 'old', name: 'Old read', network: null }), + region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }), + ], + }); + expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument(); + expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => { + const save = vi.fn(async () => {}); + show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } }); + const auto = screen.getByTestId('paddle-auto-load'); + expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true'); + expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument(); + fireEvent.click(within(auto).getByRole('button', { name: /On request/ })); + expect(save).toHaveBeenCalledWith({ mode: 'ask' }); + fireEvent.click(within(auto).getByRole('button', { name: '1 GB' })); + expect(save).toHaveBeenCalledWith({ max_mb: 1000 }); + }); + + it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => { + show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } }); + expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument(); + expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => { + const approve = vi.fn(async () => {}); + const decline = vi.fn(async () => {}); + const base = { + bytes: 734_003_200, + requested_by: 'elmo', + requested_at: '2026-10-18T09:00:00Z', + }; + show({ + approve, + decline, + requests: [ + { ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true }, + { ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false }, + ], + }); + const rows = screen.getAllByTestId('paddle-region-request'); + expect(rows[0]).toHaveTextContent('Brandenburg'); + expect(rows[0]).toHaveTextContent('Daily limit'); + expect(rows[0]).toHaveTextContent('by elmo'); + expect(rows[1]).toHaveTextContent('Declined'); + fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' })); + expect(decline).toHaveBeenCalledWith('brandenburg'); + fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ })); + expect(approve).toHaveBeenCalledWith('norway'); + expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull(); + }); + + it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => { + show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] }); + expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic'); + expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull(); + }); +}); + describe('paddleRegionsModel', () => { it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => { expect(isReading({ status: 'queued' })).toBe(true); diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx index 160b98680..eaf16d784 100644 --- a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx @@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; import ErrorBoundary from '../../shared/ErrorBoundary'; import { Tooltip } from '../../shared/Tooltip'; +import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts'; import { progressPercent, regionStatusTone } from './paddleRegionsModel'; import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; @@ -22,8 +23,10 @@ type Admin = ReturnType; /** * PAD-05A in the admin area: the regions TREK reads paddle obstacles for, - * picked from Geofabrik's list. Both the desktop and the phone admin show - * this one card under the addons, while the Tours addon is on. + * picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a + * place a paddler plans in by itself, with the regions that wait for an admin. + * Both the desktop and the phone admin show this one card under the addons, + * while the Tours addon is on. */ export default function PaddleRegionsPanel() { return ( @@ -67,6 +70,8 @@ function Panel() { )}
+ +
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) { ); } +/** + * PAD-11: the water network the router follows in a region, as one figure + * with the rest in its tooltip. A region read before routing existed says it + * needs reading again; a network is never claimed where none was built. + */ +function RegionNetwork({ region }: { region: PaddleRegion }) { + const { t, language } = useTranslation(); + const { network } = region; + if (!network) { + if (!region.imported_at || region.status === 'importing') return null; + return ( + + + {t('admin.paddleRegions.network.missing')} + + + ); + } + const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km); + const details = [ + t('admin.paddleRegions.network.lakes', { count: network.lakes }), + t('admin.paddleRegions.network.portages', { count: network.portages }), + t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }), + t('admin.paddleRegions.network.locks', { count: network.locks }), + t('admin.paddleRegions.network.gaps', { count: network.gaps }), + ].join(', '); + return ( + + + {t('admin.paddleRegions.network.km', { km })} + + + ); +} + function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { const { t, language } = useTranslation(); const busy = admin.busyId === region.id; @@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { + {region.origin === 'auto' && } {region.imported_at && ( {t('admin.paddleRegions.objects', { count: region.feature_count })} )} {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} + {region.status === 'failed' && region.error && (

diff --git a/client/src/components/Admin/paddle/usePaddleAutoLoad.test.ts b/client/src/components/Admin/paddle/usePaddleAutoLoad.test.ts new file mode 100644 index 000000000..932832eaa --- /dev/null +++ b/client/src/components/Admin/paddle/usePaddleAutoLoad.test.ts @@ -0,0 +1,50 @@ +// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C). +import { act, renderHook, waitFor } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TranslationProvider } from '../../../i18n/TranslationContext'; + +const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad'); + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children); + +describe('usePaddleAutoLoad', () => { + beforeEach(() => { + vi.clearAllMocks(); + repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 }); + repo.saveSettings.mockImplementation(async (settings: unknown) => settings); + }); + + it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => { + const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper }); + await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 })); + renderHook(() => usePaddleAutoLoad(true, true), { wrapper }); + expect(repo.settings).toHaveBeenCalledTimes(1); + }); + + it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => { + const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper }); + await waitFor(() => expect(result.current.settings).not.toBeNull()); + await act(async () => { + await result.current.save({ mode: 'ask' }); + }); + expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 }); + expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 }); + }); + + it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => { + repo.saveSettings.mockRejectedValueOnce(new Error('nope')); + const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper }); + await waitFor(() => expect(result.current.settings).not.toBeNull()); + await act(async () => { + await result.current.save({ max_mb: 2000 }); + }); + expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }); + expect(toast.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Admin/paddle/usePaddleAutoLoad.ts b/client/src/components/Admin/paddle/usePaddleAutoLoad.ts new file mode 100644 index 000000000..90958edbe --- /dev/null +++ b/client/src/components/Admin/paddle/usePaddleAutoLoad.ts @@ -0,0 +1,59 @@ +import type { PaddleRegionSettings } from '@trek/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from '../../../i18n'; +import { paddleRegionRepo } from '../../../repo/paddleRegionRepo'; +import { getApiErrorMessage } from '../../../types'; +import { useToast } from '../../shared/Toast'; + +/** + * PAD-11C in the admin area: how TREK loads the water data of a place a + * paddler plans in, read once the card shows and saved on every change. A + * change shows at once and goes back, with a toast, when the server refuses + * it. While one is saved the controls rest, so two saves never cross. + */ +export function usePaddleAutoLoad(active: boolean, offline: boolean) { + const { t } = useTranslation(); + const toast = useToast(); + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + const pending = useRef(false); + + useEffect(() => { + if (!active || offline) return; + let cancelled = false; + paddleRegionRepo.settings().then( + (loaded) => { + if (!cancelled) setSettings(loaded); + }, + (err: unknown) => { + if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error'))); + } + ); + return () => { + cancelled = true; + }; + }, [active, offline, t, toast]); + + const save = useCallback( + async (change: Partial) => { + if (!settings || pending.current) return; + const before = settings; + const next = { ...settings, ...change }; + pending.current = true; + setSaving(true); + setSettings(next); + try { + setSettings(await paddleRegionRepo.saveSettings(next)); + } catch (err) { + setSettings(before); + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + pending.current = false; + setSaving(false); + } + }, + [settings, t, toast] + ); + + return { settings, saving, save }; +} diff --git a/client/src/components/Admin/paddle/usePaddleRegionsAdmin.test.ts b/client/src/components/Admin/paddle/usePaddleRegionsAdmin.test.ts index beb7917e2..103d03f1c 100644 --- a/client/src/components/Admin/paddle/usePaddleRegionsAdmin.test.ts +++ b/client/src/components/Admin/paddle/usePaddleRegionsAdmin.test.ts @@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({ add: vi.fn<(id: string) => Promise>(), refresh: vi.fn<(id: string) => Promise>(), remove: vi.fn<(id: string) => Promise<{ success: true }>>(), + decline: vi.fn<(id: string) => Promise<{ success: true }>>(), + settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })), + saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings), })); const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({ ...(await importOriginal()), - paddleRegionRepo: repo, + // The list answers its regions and no requests unless a test says otherwise (PAD-11C). + paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) }, })); vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); @@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial([]); + const [requests, setRequests] = useState([]); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); const [asked, setAsked] = useState(0); @@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) { paddleRegionRepo.list().then( (list) => { if (cancelled) return; - setRegions(list); + setRegions(list.regions); + setRequests(list.requests); setError(null); setLoaded(true); }, @@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) { return () => window.clearInterval(timer); }, [active, offline, reading]); const reload = useCallback(() => setAsked((n) => n + 1), []); - return { regions, loading: !loaded && !offline, error, reload }; + return { regions, requests, loading: !loaded && !offline, error, reload }; } /** Geofabrik's list searched as the admin types, the last search winning. */ @@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) { * PAD-05A in the admin area: the regions TREK reads paddle data for, a search * in Geofabrik's list to add one, reading a region again and removing it * after a confirmation. Shown only while the Tours addon is on; the status of - * a read follows without reloading the page. + * a read follows without reloading the page. PAD-11C: how TREK loads regions + * for paddlers by itself, and the regions they asked for, to load or decline. */ export function usePaddleRegionsAdmin() { const { t } = useTranslation(); @@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() { const { offline } = useNetworkMode(); const toursOn = useAddonStore((state) => state.isEnabled('tours')); const list = useRegionList(toursOn, offline, t); + const autoLoad = usePaddleAutoLoad(toursOn, offline); const search = useCatalogSearch(offline, t); const [busyId, setBusyId] = useState(null); const [removing, setRemoving] = useState(null); @@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() { [run, setQuery, t] ); const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]); + /** A paddler's request let through: the region is added like any other. */ + const approve = useCallback( + (id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')), + [run, t] + ); + const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]); const confirmRemove = useCallback(() => { const region = removing; setRemoving(null); @@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() { toursOn, offline, regions: list.regions, + requests: list.requests, + autoLoad, loading: list.loading, error: list.error, retry: reload, @@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() { busyId, add, refresh, + approve, + decline, removing, askRemove: setRemoving, cancelRemove: () => setRemoving(null), diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 205a61f4f..16588f6f8 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL import RoadtripViaMarkers from './RoadtripViaMarkers' import PlannerWaypointMarker from './PlannerWaypointMarker' import HazardLayers from './HazardLayers' +import { RouteHitBand } from './RouteHitBand' import { formatViaDwell } from './routeViaDwell' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' @@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({ {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. - The casing carries the click when the route can be reshaped: it is the wider of - the two, so it is the one a pointer actually lands on. */} + When the route can be reshaped an invisible band wider than both takes the click. */} {route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [ // Walked, so dashed and without the casing: the drive is the solid line (#2532). { - // Stops the map's own click, which would otherwise open the add-place menu - // underneath the new via. - e.originalEvent.stopPropagation() - onRouteClick(e.latlng.lat, e.latlng.lng) - }, - } : undefined} + interactive={false} />, , + ...(onRouteClick ? [] : []), ])} {/* The last bit to a place the road does not reach. diff --git a/client/src/components/Map/PlannerWaypointMarker.tsx b/client/src/components/Map/PlannerWaypointMarker.tsx index cf1ac429a..d620c583e 100644 --- a/client/src/components/Map/PlannerWaypointMarker.tsx +++ b/client/src/components/Map/PlannerWaypointMarker.tsx @@ -2,18 +2,25 @@ 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 { plannerPinShape, type PlannerPinPoint } from './plannerPinShape'; 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; +function plannerWaypointIcon( + pinCss: PinCss, + point: PlannerPinPoint, + index: number, + selected: boolean, + draggable: boolean +): L.DivIcon { + const { size, text } = plannerPinShape(point, index, selected); return L.divIcon({ className: 'tour-planner-waypoint-marker', iconSize: [size, size], iconAnchor: [size / 2, size / 2], - html: `${index + 1}`, + html: `${text}`, }); } @@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({ onMove, pinCss, }: { - point: { id: string; lat: number; lng: number; name?: string; detail?: string }; + point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint; index: number; selected: boolean; onClick?: (id: string) => void; @@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({ 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 { label, shaping } = point; + const icon = useMemo( + () => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove), + [index, label, onMove, pinCss, selected, shaping] + ); const eventHandlers = useMemo( () => ({ click: (event: { originalEvent: MouseEvent }) => { diff --git a/client/src/components/Map/RouteHitBand.tsx b/client/src/components/Map/RouteHitBand.tsx new file mode 100644 index 000000000..772586943 --- /dev/null +++ b/client/src/components/Map/RouteHitBand.tsx @@ -0,0 +1,35 @@ +import { Polyline } from 'react-leaflet'; + +/** + * P2A-03: an invisible band along a route segment on the Leaflet map, wider + * than the drawn line, that takes the click which reshapes the route, as the + * GL renderer's hit layer does: an 8 px line is too thin to aim at. + */ +export function RouteHitBand({ + positions, + onRouteClick, +}: { + positions: [number, number][]; + onRouteClick: (lat: number, lng: number) => void; +}) { + return ( + { + // Stops the map's own click, which would otherwise open the add-place menu underneath the new via. + e.originalEvent.stopPropagation(); + onRouteClick(e.latlng.lat, e.latlng.lng); + }, + }} + /> + ); +} diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index f330d81f3..2ce46717a 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -110,7 +110,15 @@ export interface MapViewProps { onHighlightAlternative?: (index: number | null) => void; /** Generic numbered control points for list-first route editors. */ /** `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 }>; + plannerWaypoints?: Array<{ + id: string; + lat: number; + lng: number; + name?: string; + detail?: string; + label?: string; + shaping?: boolean; + }>; selectedPlannerWaypointId?: string | null; onPlannerWaypointClick?: (id: string) => void; /** Drops a dragged control point; returns false when the move was refused. */ diff --git a/client/src/components/Map/plannerPinShape.test.ts b/client/src/components/Map/plannerPinShape.test.ts new file mode 100644 index 000000000..8e15b4764 --- /dev/null +++ b/client/src/components/Map/plannerPinShape.test.ts @@ -0,0 +1,12 @@ +// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03). +import { describe, expect, it } from 'vitest'; +import { plannerPinShape } from './plannerPinShape'; + +describe('plannerPinShape', () => { + it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => { + expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' }); + expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' }); + expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' }); + expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' }); + }); +}); diff --git a/client/src/components/Map/plannerPinShape.ts b/client/src/components/Map/plannerPinShape.ts new file mode 100644 index 000000000..007db2f28 --- /dev/null +++ b/client/src/components/Map/plannerPinShape.ts @@ -0,0 +1,16 @@ +/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */ +export interface PlannerPinPoint { + /** The stop number to print; shaping points do not count, so stops keep their numbers. */ + label?: string; + shaping?: boolean; +} + +/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */ +export function plannerPinShape( + point: PlannerPinPoint, + index: number, + selected: boolean +): { size: number; text: string } { + if (point.shaping) return { size: selected ? 18 : 14, text: '' }; + return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) }; +} diff --git a/client/src/components/Map/usePlannerWaypointMarkersGL.ts b/client/src/components/Map/usePlannerWaypointMarkersGL.ts index 534de8a55..97213cff7 100644 --- a/client/src/components/Map/usePlannerWaypointMarkersGL.ts +++ b/client/src/components/Map/usePlannerWaypointMarkersGL.ts @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react'; import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel'; import type { MapViewProps } from './mapViewContract'; +import { plannerPinShape } from './plannerPinShape'; import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; /** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */ @@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL({ for (const [index, point] of plannerWaypoints.entries()) { const selected = point.id === selectedPlannerWaypointId; - const size = selected ? 30 : 26; + const { size, text } = plannerPinShape(point, index, selected); const el = document.createElement('button'); el.type = 'button'; - el.textContent = String(index + 1); + el.textContent = text; const name = point.name ?? `Waypoint ${index + 1}`; el.setAttribute('aria-label', name); el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`; diff --git a/client/src/components/Tours/paddle/PaddleLakeStyleField.tsx b/client/src/components/Tours/paddle/PaddleLakeStyleField.tsx new file mode 100644 index 000000000..2d40296b0 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleLakeStyleField.tsx @@ -0,0 +1,36 @@ +import type { PaddleLakeStyle } from '@trek/shared'; +import { useTranslation } from '../../../i18n'; +import { LABEL, Segmented } from '../../shared/dialogParts'; +import { fs } from '../../shared/DialogShell'; + +const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore']; + +/** + * PAD-11: how the route crosses a lake. Straight over is the rule; along the + * shore is for wind, when a capsize near the bank is a short swim. The wind + * card suggests it on a windy day; here it is chosen. + */ +export function PaddleLakeStyleField({ + value, + onChange, +}: { + value: PaddleLakeStyle; + onChange: (style: PaddleLakeStyle) => void; +}) { + const { t } = useTranslation(); + return ( +

+ {t('tours.paddle.lakeStyle')} + + value={value} + onChange={onChange} + label={t('tours.paddle.lakeStyle')} + fill + options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))} + /> + + {t(`tours.paddle.lakeStyle.${value}Hint`)} + +
+ ); +} diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx index 08e051a3b..e06e6b6b2 100644 --- a/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx @@ -33,6 +33,8 @@ const PAINT: PaddlePaint = { access: '#4338ca', landing: '#0f766e', portage: '#9a6233', + rapids: '#d97706', + gap: '#dc2626', disc: '#ffffff', shadow: 'rgba(15, 23, 42, 0.35)', }; diff --git a/client/src/components/Tours/paddle/PaddleLegParts.tsx b/client/src/components/Tours/paddle/PaddleLegParts.tsx new file mode 100644 index 000000000..bb66cf8bd --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleLegParts.tsx @@ -0,0 +1,152 @@ +import type { PaddleRouteLeg } from '@trek/shared'; +import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import type { PaddleLegFailure } from '../planner/paddleRouting'; +import { lineLengthM } from '../planner/tourFixedLegs'; + +/** + * PAD-11: what the waypoint list says about a leg the router planned or + * could not plan, and about a point it set itself. + */ + +/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */ +export function AutoPointPill() { + const { t } = useTranslation(); + // Only an icon: the row keeps its room for the place name, the tooltip says the rest. + return ( + + + + + ); +} + +type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number }; + +/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */ +function notesOf( + leg: Extract, + t: (key: string, params?: Record) => string +): Note[] { + return leg.segments.flatMap((segment, index): Note[] => { + const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]); + if (segment.kind === 'rapids' && segment.grade != null) { + return [ + { key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length }, + ]; + } + if (segment.kind === 'pass') + return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }]; + if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }]; + return []; + }); +} + +/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */ +export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + if (!leg || leg.status !== 'ok') return null; + const notes = notesOf(leg, t); + if (notes.length === 0) return null; + return ( +
  • + {notes.map((note) => ( + + + {note.tone === 'gap' ? ( + + + ))} +
  • + ); +} + +/** + * A leg the router could not plan: why, in plain words, and what to do about + * it. The map draws it straight and red meanwhile, and the tour waits to be + * saved until it is planned or drawn by hand on purpose. Where TREK could not + * fetch the water data of the leg's region (PAD-11C), `message` says why and + * `onLoadRetry` reads the region again. + */ +export function PaddleLegFailureRow({ + code, + disabled, + onDrawStraight, + onRetry, + message, + onLoadRetry, +}: { + code: PaddleLegFailure; + disabled: boolean; + onDrawStraight: () => void; + onRetry: () => void; + message?: string | null; + onLoadRetry?: () => void; +}) { + const { t } = useTranslation(); + const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null); + const action = + 'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40'; + return ( +
  • +
    + + + + + {retry && ( + + )} + +
    +
  • + ); +} diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx new file mode 100644 index 000000000..acd77fef5 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx @@ -0,0 +1,72 @@ +// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B). +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { PaddleObstaclePlaceButton, placeOf } from './PaddleObstaclePlaceButton'; + +describe('placeOf', () => { + it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => { + expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 }); + expect( + placeOf({ + type: 'LineString', + coordinates: [ + [12.9, 53.2], + [12.91, 53.21], + [12.92, 53.22], + ], + }) + ).toEqual({ lat: 53.21, lng: 12.91 }); + const middle = placeOf({ + type: 'Polygon', + coordinates: [ + [ + [12, 53], + [12.02, 53], + [12.02, 53.02], + [12, 53.02], + [12, 53], + ], + ], + }); + expect(middle?.lat).toBeCloseTo(53.01, 9); + expect(middle?.lng).toBeCloseTo(12.01, 9); + }); +}); + +describe('PaddleObstaclePlaceButton', () => { + it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => { + const onPlace = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('paddle-obstacle-place')); + expect(onPlace).toHaveBeenCalledWith(53.2, 12.9); + expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here'); + }); + + it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => { + render( + + ); + expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here'); + }); + + it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => { + render( + + ); + expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx new file mode 100644 index 000000000..bf93c635f --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx @@ -0,0 +1,66 @@ +import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared'; +import { MapPinPlus } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { fs } from '../../shared/DialogShell'; + +/** Where a paddler gets in or out: these places can become a waypoint from their card. */ +const PLACEABLE: Partial> = { + put_in: 'put_in', + slipway: 'put_in', + egress: 'egress', +}; + +/** + * The spot a waypoint goes to: the place itself, the middle of a way (a + * slipway drawn as a ramp), or the middle of an area's outline. + */ +export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null { + const line = + geometry.type === 'Point' + ? [geometry.coordinates] + : geometry.type === 'LineString' + ? geometry.coordinates + : geometry.coordinates[0]; + if (!line?.length) return null; + if (geometry.type === 'Polygon') { + const ring = line.slice(0, -1); + const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; + const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length; + return { lat, lng }; + } + const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? []; + return lat === undefined || lng === undefined ? null : { lat, lng }; +} + +/** + * PAD-05B: a put-in, a slipway or an egress taken over as the tour's next + * waypoint from its card on the map, so the point sits where the paddler + * really gets in or out. Set after a portage asked for at the last point, it + * is where the portage ends. + */ +export function PaddleObstaclePlaceButton({ + kind, + geometry, + onPlace, +}: { + kind: PaddleObstacleKind; + geometry: PaddleGeometry; + onPlace: (lat: number, lng: number) => void; +}) { + const { t } = useTranslation(); + const role = PLACEABLE[kind]; + const spot = role ? placeOf(geometry) : null; + if (!role || !spot) return null; + return ( + + ); +} diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx new file mode 100644 index 000000000..085be1586 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx @@ -0,0 +1,77 @@ +// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C). +import type { PaddleRegionLoadState } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from './PaddleRegionLoadRow'; + +const REF = { id: 'oberpfalz', name: 'Oberpfalz' }; +const t = (key: string, params?: Record) => `${key}${params ? JSON.stringify(params) : ''}`; + +describe('PaddleRegionLoadRow', () => { + it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => { + const onDrawStraight = vi.fn(); + render( + + ); + const row = screen.getByTestId('tour-planner-region-load'); + expect(row).toHaveTextContent('Loading the water data for Oberpfalz'); + expect(row).toHaveTextContent('Downloading 45%'); + fireEvent.click(screen.getByRole('button', { name: /Draw straight/ })); + expect(onDrawStraight).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => { + render( + + ); + const row = screen.getByTestId('tour-planner-region-load'); + expect(row).toHaveTextContent('has been requested'); + expect(row).toHaveTextContent('larger than TREK loads by itself'); + }); + + it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => { + const { unmount } = render( + + ); + expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions'); + unmount(); + render(); + expect(screen.queryByRole('button')).toBeNull(); + }); +}); + +describe('region load states', () => { + it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => { + const pending: PaddleRegionLoadState[] = [ + { state: 'ready', region: REF }, + { state: 'requested', region: REF, reason: 'ask' }, + ]; + for (const state of pending) expect(isRegionLoadPending(state)).toBe(true); + expect(isRegionLoadPending(undefined)).toBe(false); + expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe( + 'tours.paddle.load.declined{"region":"Oberpfalz"}' + ); + expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed'); + expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater'); + expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe( + 'tours.paddle.load.noExtract' + ); + expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe( + 'tours.paddle.load.catalogUnavailable' + ); + expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull(); + expect(regionLoadFailure(undefined, t)).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx new file mode 100644 index 000000000..a44d70913 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx @@ -0,0 +1,130 @@ +import type { PaddleRegionLoadState } from '@trek/shared'; +import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { fs } from '../../shared/DialogShell'; + +type Translate = ReturnType['t']; + +/** The states in which the data is on its way: the leg waits rather than fails. */ +export function isRegionLoadPending( + state: PaddleRegionLoadState | undefined +): state is Extract { + return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested'; +} + +/** + * Why a leg stays unplanned once the data cannot come: declined, failed, no + * water near the place, nothing to load. Null where the leg's own reason says + * it best (loading is off, or nothing was asked yet). + */ +export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null { + if (!state) return null; + const region = state.region?.name ?? ''; + switch (state.state) { + case 'declined': + return t('tours.paddle.load.declined', { region }); + case 'failed': + return t('tours.paddle.load.failed', { region }); + case 'no_water': + return t('tours.paddle.load.noWater', { region }); + case 'unavailable': + if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract'); + if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable'); + return null; + default: + return null; + } +} + +function headline(state: Extract, t: Translate) { + const region = state.region.name; + if (state.state === 'ready') return t('tours.paddle.load.ready', { region }); + if (state.state === 'requested') return t('tours.paddle.load.requested', { region }); + return t('tours.paddle.load.loading', { region }); +} + +/** The phase of a load in words, with its share where it is known. */ +function phaseText(state: Extract, t: Translate, language: string) { + if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download'); + const phase = t(`tours.paddle.load.phase.${state.progress.phase}`); + const share = state.progress.fraction; + return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`; +} + +/** + * PAD-11C: a leg waiting for the water data of its region, which TREK loads + * by itself or an admin has to let through. Calm, not red: nothing is wrong, + * the data is on its way, and the leg is planned on its own once it is there. + * Drawing it straight stays possible for whoever does not want to wait. + */ +export function PaddleRegionLoadRow({ + state, + disabled, + onDrawStraight, +}: { + state: Extract; + disabled: boolean; + onDrawStraight: () => void; +}) { + const { t, language } = useTranslation(); + const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null; + const busy = state.state !== 'requested'; + return ( +
  • +
    + + {busy ? ( + + {state.state === 'loading' && ( + + + + + + + )} + {state.state === 'requested' && state.reason !== 'ask' && ( + + {t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')} + + )} + {state.state !== 'ready' && ( + + + + )} +
    +
  • + ); +} diff --git a/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx b/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx index bc9bac796..4acefc04b 100644 --- a/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx +++ b/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx @@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; import { useSettingsStore } from '../../../store/settingsStore'; import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; import { portageLegBetween } from '../planner/tourRouteActions'; -import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner'; +import type { TourPlannerController } from '../planner/useTourPlanner'; import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard'; /** What the card writes for a time it cannot give: an en dash. */ diff --git a/client/src/components/Tours/paddle/PaddleWaypointParts.test.tsx b/client/src/components/Tours/paddle/PaddleWaypointParts.test.tsx new file mode 100644 index 000000000..3c2ca30a3 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleWaypointParts.test.tsx @@ -0,0 +1,53 @@ +// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03). +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts'; + +const way = (extra: Partial = {}): PortageWay => ({ + routed: false, + noWay: false, + searching: false, + onFollow: vi.fn(), + onStraighten: vi.fn(), + ...extra, +}); + +function openPortage(props: Partial[0]> = {}) { + render(); + fireEvent.click(screen.getByRole('button', { expanded: false })); +} + +describe('PendingPortageRow', () => { + it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => { + const onRemove = vi.fn(); + render(); + expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here'); + fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' })); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => { + render(); + expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull(); + }); +}); + +describe('PortageLegRow way', () => { + it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => { + const straight = way(); + openPortage({ way: straight }); + fireEvent.click(screen.getByRole('button', { name: 'Follow the path' })); + expect(straight.onFollow).toHaveBeenCalledTimes(1); + const routed = way({ routed: true }); + openPortage({ way: routed }); + fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]); + expect(routed.onStraighten).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => { + openPortage({ way: way({ noWay: true }) }); + expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour'); + openPortage({ way: way({ searching: true }) }); + expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path'); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleWaypointParts.tsx b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx index bd96674bb..1012ee0d8 100644 --- a/client/src/components/Tours/paddle/PaddleWaypointParts.tsx +++ b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx @@ -1,9 +1,10 @@ import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared'; -import { ChevronDown, X } from 'lucide-react'; +import { ChevronDown, Loader2, X } from 'lucide-react'; import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDistance } from '../../../utils/units'; +import { Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; import { PaddleGlyph } from './PaddleGlyph'; import { PORTAGE_CSS_COLOR } from './paddlePaint'; @@ -199,6 +200,99 @@ function StopCard({ ); } +/** + * A portage asked for at the last point, before the point it leads to is set: + * dashed in the portage brown, it says where to click next, and can be taken + * back. Once the next point is set it becomes a portage like any other. + */ +export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) { + const { t } = useTranslation(); + return ( +
  • +
    + + + + + + {t('tours.paddle.portagePending')} + + + {t('tours.paddle.portagePendingHint')} + + + {!disabled && ( + + )} +
    +
  • + ); +} + +export interface PortageWay { + /** The portage follows the ways on land (its line has a shape of its own). */ + routed: boolean; + /** The router was asked and found no way without a big detour. */ + noWay: boolean; + /** The router is being asked right now. */ + searching: boolean; + onFollow: () => void; + onStraighten: () => void; +} + +/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */ +function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) { + const { t } = useTranslation(); + const value = way.routed ? 'follow' : 'straight'; + const hint = way.searching + ? t('tours.paddle.portageWay.searching') + : !way.routed && way.noWay + ? t('tours.paddle.portageWay.noWay') + : null; + return ( +
    + + {t('tours.paddle.portageWay')} + +
    + + value={value} + fill + label={t('tours.paddle.portageWay')} + onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())} + options={[ + { + value: 'follow', + label: t('tours.paddle.portageWay.follow'), + icon: way.searching ?
    + {hint && ( + + {hint} + + )} +
    + ); +} + /** * A portage under the point it starts at, in the earth brown the map draws it * in: its length on land and the time for getting out, unloading and back in @@ -211,6 +305,7 @@ export function PortageLegRow({ onExtraChange, position, onRemove, + way, }: { lengthM: number; extraMinutes: number | null; @@ -220,6 +315,8 @@ export function PortageLegRow({ position?: string; /** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */ onRemove?: () => void; + /** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */ + way?: PortageWay; }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit); @@ -242,6 +339,7 @@ export function PortageLegRow({ {t('tours.paddle.portageInfo')}

    {onRemove && !disabled && } + {way && } { }); }); +describe('PaddleWindCard on the lakes (PAD-11)', () => { + it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => { + const onChoose = vi.fn(); + const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] }))); + const { rerender } = render(); + const advice = screen.getByTestId('paddle-wind-shore-advice'); + expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.'); + fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' })); + expect(onChoose).toHaveBeenCalledWith('shore'); + + rerender(); + expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument(); + expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => { + const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] }))); + const { rerender } = render(); + expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument(); + }); +}); + describe('FigureBadge', () => { it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => { const { rerender } = render(); diff --git a/client/src/components/Tours/paddle/PaddleWindCard.tsx b/client/src/components/Tours/paddle/PaddleWindCard.tsx index 65e6e63e9..9cb0eb630 100644 --- a/client/src/components/Tours/paddle/PaddleWindCard.tsx +++ b/client/src/components/Tours/paddle/PaddleWindCard.tsx @@ -1,4 +1,4 @@ -import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared'; +import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared'; import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from '../../../i18n'; @@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { RailCard, TourNotice } from '../tourParts'; import { FigureBadge } from './FigureBadge'; +import { windForShore } from './lakeStyleAdvice'; import { useTourWind, type TourWind } from './useTourWind'; type Translate = (key: string, params?: Record) => string; @@ -209,15 +210,58 @@ function WindHints({ t }: { t: Translate }) { ); } +/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */ +export interface LakeStyleChoice { + value: PaddleLakeStyle; + onChoose: (style: PaddleLakeStyle) => void; +} + +/** + * PAD-11: on a windy day, the offer to keep near the shores on the lakes, + * with the wind that prompts it; once taken, it says the lakes are planned so. + */ +function ShoreAdvice({ + wind, + lakes, + t, +}: { + wind: { meanKmh: number; gustsKmh: number }; + lakes: LakeStyleChoice; + t: Translate; +}) { + if (lakes.value === 'shore') { + return ( + }> + {t('tours.paddle.wind.shoreTaken')} + + ); + } + return ( + }> + + {t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })} + + + + ); +} + /** * PAD-08: the wind on the day a paddle tour is planned for, at its start. The * mean, the gusts and the direction are figures of their own, with the source * and when it was asked; no value is turned into a verdict, and missing data * is never shown as calm. */ -export function PaddleWindCard({ wind }: { wind: TourWind }) { +export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) { const { t, locale } = useTranslation(); const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null; + const shoreWind = ready && lakes ? windForShore(ready) : null; return ( } @@ -249,6 +293,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) { ) : ( )} + {shoreWind && lakes && } @@ -259,10 +304,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) { export function PaddleWindSection({ placeId, start, + lakes, }: { placeId: number | null; start: { lat: number; lng: number } | null; + /** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */ + lakes?: LakeStyleChoice; }) { const wind = useTourWind(placeId, start); - return ; + return ; } diff --git a/client/src/components/Tours/paddle/lakeStyleAdvice.test.ts b/client/src/components/Tours/paddle/lakeStyleAdvice.test.ts new file mode 100644 index 000000000..54ac25ab6 --- /dev/null +++ b/client/src/components/Tours/paddle/lakeStyleAdvice.test.ts @@ -0,0 +1,51 @@ +// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08). +import type { WindForecastResponse, WindHour } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import { crossesLake, windForShore } from './lakeStyleAdvice'; + +function forecast(hours: Array & { hour: number }>): WindForecastResponse { + return { + status: 'forecast', + date: '2026-06-01', + lat: 53.4, + lng: 12.7, + timezone: 'Europe/Berlin', + source: 'Open-Meteo', + fetched_at: '2026-05-30T08:00:00Z', + horizon_days: 16, + day: null, + hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })), + } as WindForecastResponse; +} + +describe('lakeStyleAdvice', () => { + it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => { + expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull(); + expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({ + meanKmh: 21, + gustsKmh: 26, + }); + expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 }); + expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull(); + // An hour without a value is no calm hour, but it is no wind either. + expect(windForShore(forecast([{ hour: 12 }]))).toBeNull(); + }); + + it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => { + const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }; + const leg = (kind: 'lake' | 'water') => ({ + status: 'ok' as const, + geometry: [ + [53.4, 12.7], + [53.5, 12.7], + ] as [number, number][], + segments: [{ ...lake, kind }], + distance_m: 1000, + snapped_m: { from: 0, to: 0 }, + }); + expect(crossesLake(null)).toBe(false); + expect(crossesLake({ legs: [leg('water'), null] })).toBe(false); + expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true); + expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false); + }); +}); diff --git a/client/src/components/Tours/paddle/lakeStyleAdvice.ts b/client/src/components/Tours/paddle/lakeStyleAdvice.ts new file mode 100644 index 000000000..5afee35d3 --- /dev/null +++ b/client/src/components/Tours/paddle/lakeStyleAdvice.ts @@ -0,0 +1,32 @@ +import type { WindForecastResponse } from '@trek/shared'; +import type { PaddleTourRoute } from '../planner/paddleRouting'; + +/** The hours a day tour is out on the water, as the wind strip shows them. */ +const FIRST_HOUR = 6; +const LAST_HOUR = 21; + +/** + * Wind from which open water gets rough for a small boat: a moderate breeze + * (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into + * work and a capsize far from the bank into a long swim. + */ +const MEAN_KMH = 20; +const GUSTS_KMH = 30; + +/** Whether the route as planned crosses a lake, so its lake style changes anything. */ +export function crossesLake(route: Pick | null): boolean { + return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake')); +} + +/** + * PAD-11 with PAD-08: the strongest wind of the paddling hours when it is + * enough to keep near the shores on the lakes, else null. Lakes are crossed + * straight over as a rule and along the shore only in wind; this says when. + */ +export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null { + const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR); + const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0)); + const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0)); + if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null; + return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) }; +} diff --git a/client/src/components/Tours/paddle/paddleMapLayer.test.ts b/client/src/components/Tours/paddle/paddleMapLayer.test.ts index b697e69ab..6914dc9b0 100644 --- a/client/src/components/Tours/paddle/paddleMapLayer.test.ts +++ b/client/src/components/Tours/paddle/paddleMapLayer.test.ts @@ -34,6 +34,8 @@ const PAINT: PaddlePaint = { access: '#4338ca', landing: '#0f766e', portage: '#9a6233', + rapids: '#d97706', + gap: '#dc2626', disc: '#ffffff', shadow: 'rgba(15, 23, 42, 0.35)', }; diff --git a/client/src/components/Tours/paddle/paddlePaint.ts b/client/src/components/Tours/paddle/paddlePaint.ts index 4d78382ca..d189a039d 100644 --- a/client/src/components/Tours/paddle/paddlePaint.ts +++ b/client/src/components/Tours/paddle/paddlePaint.ts @@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = { access: '--map-paddle-access', landing: '--map-paddle-landing', portage: '--map-portage', + rapids: '--map-paddle-rapids', + gap: '--map-paddle-gap', disc: '--map-paddle-disc', shadow: '--map-paddle-shadow', } as const; diff --git a/client/src/components/Tours/paddle/portageRoute.test.ts b/client/src/components/Tours/paddle/portageRoute.test.ts new file mode 100644 index 000000000..f3f39b77c --- /dev/null +++ b/client/src/components/Tours/paddle/portageRoute.test.ts @@ -0,0 +1,54 @@ +// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03). +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const valhallaRun = vi.fn(); +vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) })); + +const { routePortageLine } = await import('./portageRoute'); + +const FROM = { lat: 53.2232, lng: 12.9668 }; +const TO = { lat: 53.2229, lng: 12.9701 }; + +function answer(coordinates: [number, number][]) { + return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } }; +} + +describe('routePortageLine', () => { + beforeEach(() => valhallaRun.mockReset()); + + it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => { + valhallaRun.mockResolvedValue( + answer([ + [53.2233, 12.9672], + [53.2226, 12.9686], + [53.223, 12.9698], + ]) + ); + const line = await routePortageLine(FROM, TO); + expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 }); + expect(line?.[0]).toEqual([FROM.lat, FROM.lng]); + expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]); + expect(line).toHaveLength(5); + }); + + it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => { + valhallaRun.mockResolvedValue( + answer([ + [53.2233, 12.9672], + [53.24, 12.968], + [53.223, 12.9698], + ]) + ); + expect(await routePortageLine(FROM, TO)).toBeNull(); + }); + + it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => { + valhallaRun.mockResolvedValue(null); + expect(await routePortageLine(FROM, TO)).toBeNull(); + }); + + it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => { + valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]])); + expect(await routePortageLine(FROM, TO)).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/paddle/portageRoute.ts b/client/src/components/Tours/paddle/portageRoute.ts new file mode 100644 index 000000000..0ef797e6a --- /dev/null +++ b/client/src/components/Tours/paddle/portageRoute.ts @@ -0,0 +1,36 @@ +import { haversineKm } from '../../../utils/geo'; +import { valhallaRun } from '../../Map/valhallaRoute'; +import { lineLengthM } from '../planner/tourFixedLegs'; + +type Point = { lat: number; lng: number }; + +/** + * How much longer than the straight line a carry along the ways may be before + * it counts as a detour: a way round a fenced lock is fine, a walk round the + * whole lake is not, and then the straight line says more. + */ +const DETOUR = { factor: 2.5, extraM: 300 } as const; + +/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */ +const CARRY_DIFFICULTY = 1; + +/** + * PAD-03: the way a portage takes on land, from where the boat leaves the + * water to where it goes in again, on the ways a walker would take (the + * instance's Valhalla, the router the hikes use). The line starts and ends at + * the two points themselves, so the steps from the bank onto the path are + * part of it. Null when no router answers, or when the way is a detour. + */ +export async function routePortageLine( + from: Point, + to: Point, + signal?: AbortSignal +): Promise<[number, number][] | null> { + // The router answers null, never throws, when it cannot be reached; the carry then stays straight. + const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY }); + if (!run || run.total.coordinates.length < 2) return null; + const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]]; + const straightM = haversineKm(from, to) * 1000; + if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null; + return line; +} diff --git a/client/src/components/Tours/paddle/usePaddleChecklist.test.tsx b/client/src/components/Tours/paddle/usePaddleChecklist.test.tsx index e55eef920..07d26f2d9 100644 --- a/client/src/components/Tours/paddle/usePaddleChecklist.test.tsx +++ b/client/src/components/Tours/paddle/usePaddleChecklist.test.tsx @@ -37,7 +37,8 @@ import { selectPaddleObstacle, usePaddleObstacleStore, } from '../../../store/paddleObstacleStore'; -import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import type { TourPlannerController } from '../planner/useTourPlanner'; import { usePaddleChecklist } from './usePaddleChecklist'; type CheckRead = Awaited>; diff --git a/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts index dae240ce5..42e780807 100644 --- a/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts +++ b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts @@ -34,6 +34,8 @@ const PAINT: PaddlePaint = { access: '#4338ca', landing: '#0f766e', portage: '#9a6233', + rapids: '#d97706', + gap: '#dc2626', disc: '#ffffff', shadow: 'rgba(15, 23, 42, 0.35)', }; diff --git a/client/src/components/Tours/paddle/usePaddleRegionLoad.test.ts b/client/src/components/Tours/paddle/usePaddleRegionLoad.test.ts new file mode 100644 index 000000000..3937b650f --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleRegionLoad.test.ts @@ -0,0 +1,86 @@ +// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C). +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { PaddleRegionLoadState } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const load = vi.fn(); +vi.mock('../../../repo/paddleRegionLoadRepo', () => ({ + paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) }, +})); + +const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad'); + +const REF = { id: 'oberpfalz', name: 'Oberpfalz' }; +const AT = { lat: 49.0123456, lng: 12.0987654 }; +const failures = [ + { index: 0, code: 'no_region' as const, at: AT }, + { index: 1, code: 'no_region' as const, at: AT }, + { index: 2, code: 'no_water_path' as const, at: null }, +]; +const loading = (fraction: number): PaddleRegionLoadState => ({ + state: 'loading', + region: REF, + progress: { phase: 'download', fraction }, + ahead: 0, +}); + +describe('usePaddleRegionLoad', () => { + beforeEach(() => load.mockReset()); + afterEach(() => vi.useRealTimers()); + + it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => { + load.mockResolvedValue([loading(0.4)]); + const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn())); + await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4))); + expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal)); + expect(result.current.byLeg.get(1)).toEqual(loading(0.4)); + expect(result.current.byLeg.has(2)).toBe(false); + }); + + it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => { + vi.useFakeTimers(); + load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]); + const onReady = vi.fn(); + const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady)); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onReady).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + expect(load).toHaveBeenCalledTimes(2); + expect(onReady).toHaveBeenCalledTimes(1); + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => { + renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn())); + renderHook(() => usePaddleRegionLoad(null, vi.fn())); + await Promise.resolve(); + expect(load).not.toHaveBeenCalled(); + }); + + it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => { + load.mockResolvedValue([{ state: 'failed', region: REF }]); + const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn())); + await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed')); + act(() => result.current.retry()); + await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal))); + }); + + it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => { + vi.useFakeTimers(); + load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]); + const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn())); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(load).toHaveBeenCalledTimes(2); + expect(result.current.byLeg.get(0)?.state).toBe('declined'); + }); +}); diff --git a/client/src/components/Tours/paddle/usePaddleRegionLoad.ts b/client/src/components/Tours/paddle/usePaddleRegionLoad.ts new file mode 100644 index 000000000..a3b3d22c4 --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleRegionLoad.ts @@ -0,0 +1,108 @@ +import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared'; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; +import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo'; +import type { PaddleTourRoute } from '../planner/paddleRouting'; + +/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */ +const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const; + +type Failure = PaddleTourRoute['failures'][number]; + +/** A place as the key it is asked and remembered by; five decimals are about a metre. */ +function keyOf(point: PaddleRoutePoint): string { + return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`; +} + +function placeOf(key: string): PaddleRoutePoint { + const [lat, lng] = key.split(',').map(Number); + return { lat, lng }; +} + +/** The places the planner asks about: where its legs ran out of water data, each once. */ +function placeKeysOf(failures: readonly Failure[] | null | undefined): string { + const keys = new Set(); + for (const failure of failures ?? []) { + if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at)); + } + return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|'); +} + +function nextPoll(states: readonly PaddleRegionLoadState[]): number | null { + if (states.some((state) => state.state === 'loading')) return POLL_MS.loading; + if (states.some((state) => state.state === 'requested')) return POLL_MS.requested; + return null; +} + +/** + * PAD-11C: where a paddle leg failed for want of water data, the planner + * asks the server for it right away; the server loads the region around the + * place or asks an admin for it, as the instance is set up. While it loads + * the leg shows how far it is, and once it is there the route is planned + * again on its own. `onReady` plans again; it is called once per place. + */ +export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) { + const placesKey = placeKeysOf(failures); + const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]); + const [states, setStates] = useState>(new Map()); + const [retryToken, setRetryToken] = useState(0); + const readied = useRef(new Set()); + const retrying = useRef(false); + + /** The answers taken in; the route is planned again where a place's data has just arrived. */ + const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => { + setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : [])))); + const fresh = places.filter((place, index) => { + const key = keyOf(place); + if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false; + readied.current.add(key); + return true; + }); + if (fresh.length > 0) onReady(); + }); + + useEffect(() => { + if (places.length === 0) return undefined; + const abort = new AbortController(); + let timer: ReturnType | undefined; + const ask = async () => { + const retry = retrying.current; + retrying.current = false; + let answers: PaddleRegionLoadState[] | null; + try { + answers = await paddleRegionLoadRepo.load(places, retry, abort.signal); + } catch { + // The failure row keeps saying why the leg is unplanned; a later look may get through. + if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error); + return; + } + if (abort.signal.aborted || !answers) return; + take(answers); + const wait = nextPoll(answers); + if (wait !== null) timer = setTimeout(() => void ask(), wait); + }; + void ask(); + return () => { + abort.abort(); + clearTimeout(timer); + }; + }, [places, retryToken]); + + /** Reads a region again whose last read failed. */ + const retry = useCallback(() => { + retrying.current = true; + setRetryToken((token) => token + 1); + }, []); + + /** The state of each failed leg's place, by the leg's index. */ + const byLeg = useMemo(() => { + const result = new Map(); + for (const failure of failures ?? []) { + if (failure.code !== 'no_region' || !failure.at) continue; + const state = states.get(keyOf(failure.at)); + if (state) result.set(failure.index, state); + } + return result; + }, [failures, states]); + + return { byLeg, retry }; +} diff --git a/client/src/components/Tours/paddle/usePortageRouting.test.ts b/client/src/components/Tours/paddle/usePortageRouting.test.ts new file mode 100644 index 000000000..cbf2ac7bf --- /dev/null +++ b/client/src/components/Tours/paddle/usePortageRouting.test.ts @@ -0,0 +1,90 @@ +// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03). +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { portageLegBetween } from '../planner/tourRouteActions'; + +const routePortageLine = vi.fn(); +vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) })); + +const { usePortageRouting } = await import('./usePortageRouting'); + +const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' }; +const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' }; +const WAY: [number, number][] = [ + [A.lat, A.lng], + [53.2226, 12.9686], + [B.lat, B.lng], +]; + +function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) { + const planner = { + waypoints, + isPaddle: true, + hasUnsavedChanges, + amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => { + planner.waypoints = change(planner.waypoints); + }), + editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => { + planner.waypoints = change(planner.waypoints); + }), + }; + return planner; +} + +const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B]; + +describe('usePortageRouting', () => { + beforeEach(() => routePortageLine.mockReset()); + + it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => { + routePortageLine.mockResolvedValue(WAY); + const planner = plannerWith(carried(), true); + renderHook(() => usePortageRouting(planner, true)); + await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled()); + expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY); + }); + + it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => { + const saved = plannerWith(carried(), false); + renderHook(() => usePortageRouting(saved, true)); + const locked = plannerWith(carried(), true); + renderHook(() => usePortageRouting(locked, false)); + await Promise.resolve(); + expect(routePortageLine).not.toHaveBeenCalled(); + }); + + it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => { + routePortageLine.mockResolvedValue(null); + const planner = plannerWith(carried(), true); + const { result } = renderHook(() => usePortageRouting(planner, true)); + await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.noWay(['a'])).toBe(true)); + expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2); + }); + + it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => { + routePortageLine.mockResolvedValue(WAY); + const planner = plannerWith(carried(), false); + const { result } = renderHook(() => usePortageRouting(planner, true)); + let found = false; + await act(async () => { + found = await result.current.follow(['a']); + }); + expect(found).toBe(true); + expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY); + }); + + it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => { + const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true); + const { result, rerender } = renderHook(() => usePortageRouting(planner, true)); + act(() => result.current.straighten(['a'])); + expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([ + [A.lat, A.lng], + [B.lat, B.lng], + ]); + rerender(); + await Promise.resolve(); + expect(routePortageLine).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tours/paddle/usePortageRouting.ts b/client/src/components/Tours/paddle/usePortageRouting.ts new file mode 100644 index 000000000..a91aea6c1 --- /dev/null +++ b/client/src/components/Tours/paddle/usePortageRouting.ts @@ -0,0 +1,194 @@ +import type { TourFixedLeg } from '@trek/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import type { PortageWay } from './PaddleWaypointParts'; +import { routePortageLine } from './portageRoute'; + +type Planner = Pick< + TourPlannerController, + 'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints' +>; +type Point = TourPlannerController['waypoints'][number]; +type Line = [number, number][]; + +/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */ +function keyOf(from: Point, to: Point): string { + return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`; +} + +function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean { + return leg?.kind === 'portage' && leg.geometry.length === 2; +} + +/** The portages still drawn straight, with the point after each. */ +function straightPortages(points: readonly Point[]) { + return points.flatMap((point, index) => { + const next = points[index + 1]; + // A portage the water router found is its own: it already follows the mapped way. + return next && !point.auto && isStraightPortage(point.fixedLeg) + ? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }] + : []; + }); +} + +/** + * PAD-03: a portage follows the ways on land instead of cutting straight + * across them. A portage the paddler sets, or whose ends they move, is asked + * of the router the hikes use and takes its line, unless that line is a + * detour; then it stays straight. A tour opened as saved keeps its straight + * portages until the paddler asks for the way (`follow`), and one they set + * straight on purpose (`straighten`) stays so. + */ +export function usePortageRouting(planner: Planner, editable: boolean) { + const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner; + /** Carries kept straight: as the tour was saved, or as the paddler set them. */ + const kept = useRef(new Set()); + /** What the router answered per carry; null when it found no way worth taking. */ + const lines = useRef(new Map()); + + const layIn = useCallback(() => { + amendWaypoints((current) => { + let changed = false; + const next = current.map((point, index) => { + const after = current[index + 1]; + const leg = point.fixedLeg; + if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point; + const key = keyOf(point, after); + const line = kept.current.has(key) ? null : lines.current.get(key); + if (!line) return point; + changed = true; + return { ...point, fixedLeg: { ...leg, geometry: line } }; + }); + return changed ? next : current; + }); + }, [amendWaypoints]); + + useEffect(() => { + if (!isPaddle || !editable) return undefined; + const legs = straightPortages(waypoints); + if (!hasUnsavedChanges) { + for (const leg of legs) kept.current.add(leg.key); + return undefined; + } + const wanted = legs.filter((leg) => !kept.current.has(leg.key)); + if (wanted.some((leg) => lines.current.get(leg.key))) layIn(); + const missing = wanted.filter((leg) => !lines.current.has(leg.key)); + if (missing.length === 0) return undefined; + const abort = new AbortController(); + // One after the other: the public router allows about one request a second. + void missing + .reduce(async (previous, leg) => { + await previous; + if (abort.signal.aborted) return; + const line = await routePortageLine(leg.from, leg.to, abort.signal); + if (!abort.signal.aborted) lines.current.set(leg.key, line); + }, Promise.resolve()) + .then(() => { + if (!abort.signal.aborted) layIn(); + }); + return () => abort.abort(); + }, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]); + + /** The carries starting at these points, each with the point after it. */ + const legsOf = useCallback( + (ids: readonly string[]) => + waypoints.flatMap((point, index) => { + const next = waypoints[index + 1]; + return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : []; + }), + [waypoints] + ); + + /** + * A portage laid on the ways, after it was straight (as saved, or set so), + * leg by leg: true once one follows them, false when there is no way worth + * taking and it stays straight. + */ + const follow = useCallback( + async (ids: readonly string[]) => { + const legs = legsOf(ids); + const found = await legs.reduce(async (previous, { point, next, key }) => { + const any = await previous; + kept.current.delete(key); + const line = await routePortageLine(point, next); + lines.current.set(key, line); + return any || !!line; + }, Promise.resolve(false)); + if (found) layIn(); + return found; + }, + [layIn, legsOf] + ); + + /** A portage drawn straight, and kept so. */ + const straighten = useCallback( + (ids: readonly string[]) => { + for (const { key } of legsOf(ids)) kept.current.add(key); + editPaddlePoints((current) => + current.map((point, index) => { + const next = current[index + 1]; + const leg = point.fixedLeg; + if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point; + return { + ...point, + fixedLeg: { + ...leg, + geometry: [ + [point.lat, point.lng], + [next.lat, next.lng], + ], + }, + }; + }) + ); + }, + [editPaddlePoints, legsOf] + ); + + /** Whether the router found no way worth taking for every leg of a portage. */ + const noWay = useCallback( + (ids: readonly string[]) => { + const legs = legsOf(ids); + return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null); + }, + [legsOf] + ); + + return { follow, straighten, noWay }; +} + +type PortageRouting = ReturnType; + +/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */ +function portageRunIds(points: readonly Point[], index: number): string[] { + const ids: string[] = []; + for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) { + ids.push(points[at].id); + } + return ids; +} + +/** + * PAD-03: the way of the portage starting at a waypoint, for its card: + * whether it follows the ways on land, why it stayed straight, and the switch + * between the two. Undefined where no portage starts. + */ +export function usePortageWay( + points: readonly Point[], + routing: PortageRouting, + index: number +): PortageWay | undefined { + const [searching, setSearching] = useState(false); + const ids = portageRunIds(points, index); + if (ids.length === 0) return undefined; + return { + routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2), + noWay: routing.noWay(ids), + searching, + onFollow: () => { + setSearching(true); + void routing.follow(ids).finally(() => setSearching(false)); + }, + onStraighten: () => routing.straighten(ids), + }; +} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 15176510d..853e21ee0 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -926,6 +926,24 @@ describe('the paddle checklist in the rails (PAD-06)', () => { expect(safetyRow()).toBeInTheDocument() }) + it('PCR-006: legs the water router could not plan are counted in the head, without the retry the rows already offer', () => { + const failed = paddleEditor({ + status: 'routing-failed', + error: 'paddle-route', + paddleRoute: { coordinates: [], parts: [], distanceMeters: 0, legs: [], legLengths: [], failures: [ + { index: 0, code: 'not_on_water', at: null }, + { index: 1, code: 'busy', at: null }, + ] }, + }) + const { rerender } = render() + expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('2 legs not planned')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + + rerender() + expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('Route calculation failed. The last good route is still shown.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => { const { rerender } = render() diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 4f4440910..74a58d9be 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -19,6 +19,7 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../ import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts' import { TourDetailsSection, TourNameField } from './TourEditorFields' +import { crossesLake } from '../paddle/lakeStyleAdvice' import { PaddleCheckCard } from '../paddle/PaddleCheckCard' import { PaddleSafetyRow } from '../paddle/PaddleSafety' import { PaddleTimeCard } from '../paddle/PaddleTimeCard' @@ -121,6 +122,18 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | ) } +/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */ +function retryable(planner: TourPlannerController): boolean { + if (planner.error === 'paddle-route') return false + return planner.status === 'routing-failed' || planner.status === 'elevation-failed' +} + +/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */ +function statusText(planner: TourPlannerController, key: string, t: Translate): string { + if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 }) + return t(key) +} + /** * What the editor is doing right now, as icon badges beside the head's title: * a restored draft, routing or saving, and a failure with its retry. Nothing @@ -132,10 +145,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) { const restored = planner.draftRestored && planner.hasUnsavedChanges const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0) const failed = planner.error === 'save' - const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed' + const canRetry = retryable(planner) if (!restored && !statusKey && !failed && !onFerrata) return null const warning = planner.error != null || canRetry - const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null + const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation' return ( @@ -326,7 +339,11 @@ function PaddleEditorCards({ planner, locked, checklist }: { <> {checklist && } - + ) } diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx index 04c59b0db..6836f7f1e 100644 --- a/client/src/components/Tours/planner/TourTypeFields.test.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx @@ -75,3 +75,33 @@ describe('TourTypeFields (PAD-01)', () => { expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument(); }); }); + +describe('TourTypeFields lake style (PAD-11)', () => { + it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => { + const river = planner({}, { waterContext: 'river' }); + const { rerender } = render(); + expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument(); + + const lake = planner({}, { waterContext: 'lake' }); + rerender(); + expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Near the shore' })); + expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore'); + + const lakeLeg = { + status: 'ok', + geometry: [ + [53.4, 12.7], + [53.5, 12.7], + ], + segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }], + distance_m: 11000, + snapped_m: { from: 0, to: 0 }, + }; + const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' }); + rerender(); + expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/planner/TourTypeFields.tsx b/client/src/components/Tours/planner/TourTypeFields.tsx index c51eb8baa..f98416d14 100644 --- a/client/src/components/Tours/planner/TourTypeFields.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.tsx @@ -10,6 +10,8 @@ import { useTranslation } from '../../../i18n'; import { SoftPill } from '../../Planner/planParts'; import { INPUT, LABEL, Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; +import { crossesLake } from '../paddle/lakeStyleAdvice'; +import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField'; import { TourNotice } from '../tourParts'; import type { TourPlannerController } from './useTourPlanner'; @@ -126,6 +128,9 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) { options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))} /> + {(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && ( + + )}
    + ); } diff --git a/client/src/components/Tours/terrain/terrainLayers.test.ts b/client/src/components/Tours/terrain/terrainLayers.test.ts index ca736c140..11e2d4112 100644 --- a/client/src/components/Tours/terrain/terrainLayers.test.ts +++ b/client/src/components/Tours/terrain/terrainLayers.test.ts @@ -147,12 +147,12 @@ describe('camera and markers', () => { }); it('numbers a control point and shows whether it can be dragged', () => { - const pin = waypointPinElement(2, true, true); + const pin = waypointPinElement({}, 2, true, true); expect(pin.textContent).toBe('3'); expect(pin.getAttribute('aria-label')).toBe('Waypoint 3'); expect(pin.style.width).toBe('30px'); expect(pin.style.cursor).toBe('grab'); - const fixed = waypointPinElement(0, false, false); + const fixed = waypointPinElement({}, 0, false, false); expect(fixed.style.width).toBe('26px'); expect(fixed.style.cursor).toBe('pointer'); }); diff --git a/client/src/components/Tours/terrain/terrainLayers.ts b/client/src/components/Tours/terrain/terrainLayers.ts index ff8cf5277..dd4c5f8c6 100644 --- a/client/src/components/Tours/terrain/terrainLayers.ts +++ b/client/src/components/Tours/terrain/terrainLayers.ts @@ -3,6 +3,7 @@ import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/map import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry'; import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; +import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape'; import type { TileTemplate } from './terrainPrefetch'; import { gradeBand, gradeSegments } from './tourTerrainModel'; @@ -198,11 +199,16 @@ export function riderElement(trackColor: string): HTMLElement { } /** A numbered control point, the same pin the flat planner map draws. */ -export function waypointPinElement(index: number, selected: boolean, editable: boolean): HTMLButtonElement { - const size = selected ? 30 : 26; +export function waypointPinElement( + point: PlannerPinPoint, + index: number, + selected: boolean, + editable: boolean +): HTMLButtonElement { + const { size, text } = plannerPinShape(point, index, selected); const el = document.createElement('button'); el.type = 'button'; - el.textContent = String(index + 1); + el.textContent = text; 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:${PIN_BLUE};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:${editable ? 'grab' : 'pointer'};padding:0`; return el; diff --git a/client/src/components/Tours/terrain/useTerrainPins.ts b/client/src/components/Tours/terrain/useTerrainPins.ts index 7d22f885e..bfcd1bf2e 100644 --- a/client/src/components/Tours/terrain/useTerrainPins.ts +++ b/client/src/components/Tours/terrain/useTerrainPins.ts @@ -6,7 +6,15 @@ import type { TerrainGl } from './useTerrainMap'; export interface TerrainWaypoints { /** `name` and `detail` (its role) show on hover. */ - points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>; + points: Array<{ + id: string; + lat: number; + lng: number; + name?: string; + detail?: string; + label?: string; + shaping?: boolean; + }>; selectedId: string | null; onSelect: (id: string) => void; /** Absent while the tour cannot be edited; the pins then stay put. */ @@ -38,7 +46,7 @@ export function useTerrainPins( gl.map.on('movestart', hideOnMove); const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)]; pinsRef.current = points.map((point, index) => { - const el = waypointPinElement(index, point.id === selectedId, editable); + const el = waypointPinElement(point, index, point.id === selectedId, editable); const name = point.name ?? String(index + 1); el.setAttribute('aria-label', name); unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null })); diff --git a/client/src/components/Tours/terrain/useTourTerrain.ts b/client/src/components/Tours/terrain/useTourTerrain.ts index 583d217c0..17f5fedd0 100644 --- a/client/src/components/Tours/terrain/useTourTerrain.ts +++ b/client/src/components/Tours/terrain/useTourTerrain.ts @@ -30,13 +30,17 @@ interface Options { tourLayers?: TourLayerOffer; /** The visible area, for the via ferrata layer to ask for. */ onViewportChange?: (bbox: FerrataBbox) => void; + /** A paddle tour: the route is never coloured by grade. */ + water?: boolean; } /** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */ export function useTourTerrain(options: Options) { const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options; const [exaggeration, setExaggeration] = useState(1.5); - const [colorMode, setColorMode] = useState('grade'); + const [chosenColorMode, setColorMode] = useState('grade'); + // Water is flat: a paddle tour keeps its tour colour, whatever was picked for a hike. + const colorMode: TrackColorMode = options.water ? 'tour' : chosenColorMode; const data = useMemo( () => trackData(samples, routes, colorMode, trackColor), [samples, routes, colorMode, trackColor] diff --git a/client/src/pages/admin/AdminPage.constants.ts b/client/src/pages/admin/AdminPage.constants.ts index 41bcc61db..fc2463167 100644 --- a/client/src/pages/admin/AdminPage.constants.ts +++ b/client/src/pages/admin/AdminPage.constants.ts @@ -3,6 +3,7 @@ export const ADMIN_EVENT_LABEL_KEYS: Record = { version_available: 'settings.notifyVersionAvailable', replica_failure: 'settings.notifyReplicaFailure', + paddle_region_requested: 'settings.notifyPaddleRegionRequested', } export const ADMIN_CHANNEL_LABEL_KEYS: Record = { diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 39cad066c..f2b759ec6 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -1,4 +1,4 @@ -// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016 +// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-018 import type { Ferrata, PaddleFeature } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; @@ -486,6 +486,54 @@ describe('TourPlannerMapStage', () => { expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing'); }); + it('FE-PAGE-TOURSTAGE-017: a click on the route being edited sets a shaping point in the leg there and selects it (P2A-03)', () => { + const tourPlanner = planner('new-draft', { + waypoints: [ + { id: 'w1', lat: 46, lng: 7 }, + { id: 'w2', lat: 46.1, lng: 7.1, shaping: true }, + { id: 'w3', lat: 46.2, lng: 7.2 }, + ], + route: [ + [46, 7], + [46.1, 7.1], + [46.2, 7.2], + ], + insertShapingPoint: vi.fn(() => 'shape-1'), + }); + renderStage(tourPlanner); + const props = stage.flatMap.props; + expect(props?.plannerWaypoints?.map((point) => [point.label ?? null, !!point.shaping])).toEqual([ + ['1', false], + [null, true], + ['2', false], + ]); + props?.onRouteClick?.(46.15, 7.15); + expect(tourPlanner.insertShapingPoint).toHaveBeenCalledWith('w2', 46.15, 7.15); + expect(tourPlanner.setSelectedWaypointId).toHaveBeenCalledWith('shape-1'); + }); + + it('FE-PAGE-TOURSTAGE-018: the route takes no shaping clicks outside editing or without edit rights', () => { + renderStage( + planner('view-saved', { + route: [ + [46, 7], + [46.1, 7.1], + ], + }) + ); + expect(stage.flatMap.props?.onRouteClick).toBeUndefined(); + renderStage( + planner('new-draft', { + route: [ + [46, 7], + [46.1, 7.1], + ], + }), + false + ); + expect(stage.flatMap.props?.onRouteClick).toBeUndefined(); + }); + describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => { const flat = () => stage.flatMap.props as MapViewProps; diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index cb008aac8..5c3487320 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -11,9 +11,11 @@ import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto'; import LocationSelect from '../../components/Planner/LocationSelect'; import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel'; import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay'; +import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton'; +import { useShapingClick } from '../../components/Tours/planner/useShapingClick'; import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap'; import { isRtlLanguage, useTranslation } from '../../i18n'; -import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'; +import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore'; import { useSettingsStore } from '../../store/settingsStore'; import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore'; import { useFerrataAdopt } from './useFerrataAdopt'; @@ -137,6 +139,14 @@ export function TourPlannerMapStage({ const closingPress = useOutsidePressGuard(); const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null); const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close); + // PAD-05B: a put-in, slipway or egress taken over from its card as the tour's next point. + const placeFromCard = + canPlaceEdit && tourPlanner.isPaddle && !tourPlanner.isSaving && tourPlanner.mode.type !== 'view-gpx' + ? (lat: number, lng: number) => { + tourPlanner.addWaypoint(lat, lng); + selectPaddleObstacle(null); + } + : undefined; const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]); const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); // The "set points" tool, per mode; only a real click places, never the end of a drag. @@ -150,6 +160,8 @@ export function TourPlannerMapStage({ }); // PAD-03 on a recorded paddle track: with a marking tool on, a click on the line marks the spot on it. const { pickOnLine, onAddOrMark } = useTrackLinePick(tourPlanner, canPlaceEdit, closingPress.guard, waypoints.onAdd); + // P2A-03: a click on the route being edited sets a shaping point there. + const shapeAt = useShapingClick(tourPlanner, { editable: !!waypoints.onMove, picking: pickOnLine }); // What each panel covers of the map, a collapsed one its rail. const leftInset = leftHidden ? 56 : leftPanelPx; const rightInset = rightHidden ? 56 : rightPanelPx; @@ -180,6 +192,7 @@ export function TourPlannerMapStage({ rightWidth={rightInset} waypoints={waypoints} framePoints={tourMap.focusPoints} + water={tourPlanner.isPaddle} ferrataLayer={ferrataLayer} onViewportChange={viewportChanged} onControlsHeightChange={setTerrainControlsPx} @@ -209,6 +222,7 @@ export function TourPlannerMapStage({ selectedPlannerWaypointId={waypoints.selectedId} onPlannerWaypointClick={tourPlanner.setSelectedWaypointId} onPlannerWaypointMove={waypoints.onMove} + onRouteClick={shapeAt} routeProfileFocus={tourPlanner.routeProfileFocus} viewBaseLayer={baseLayer} onViewBaseLayerChange={tourPlanner.setMapBaseLayer} @@ -235,11 +249,18 @@ export function TourPlannerMapStage({ onOutsidePress={closingPress.arm} insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET} {...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)} - extra={(detail) => - paddleChecklist ? ( - - ) : null - } + extra={(detail) => ( + <> + {paddleChecklist && } + {placeFromCard && ( + + )} + + )} /> {/* "Point set", with the way back, above the bottom row and between the panels. */} {placedHint.shown && ( diff --git a/client/src/pages/tripPlanner/useTourMapPoints.ts b/client/src/pages/tripPlanner/useTourMapPoints.ts index e8bca6a6d..11877cd17 100644 --- a/client/src/pages/tripPlanner/useTourMapPoints.ts +++ b/client/src/pages/tripPlanner/useTourMapPoints.ts @@ -76,13 +76,24 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, gu const shown = isClosedLoop(waypoints) ? waypoints.slice(0, -1) : waypoints; // A paddle tour names a pin for the paddler (put-in, take-out, re-entry) and says where a lock is. const roles = isPaddle ? paddleRolesById(waypoints) : null; - return shown.map((point, index) => { + // Stops are numbered on their own: a shaping point (P2A-03) only steers and takes no number. + let stop = 0; + return shown.map((point) => { + if (point.shaping) { + return { + ...point, + name: placeNames[point.id] ?? t('tours.planner.shapingPoint'), + detail: t('tours.planner.shapingPoint'), + }; + } + stop += 1; const role = roles?.get(point.id); const detail = role ? t(`tours.paddle.role.${role}`) : roles ? '' : t(ROLE_KEY[point.role]); const lock = point.lock ? t('tours.paddle.lock') : ''; return { ...point, - name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), + label: String(stop), + name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: stop }), detail: [detail, lock].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]), }; }); diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 62c1b7502..5d5ace520 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -67,12 +67,12 @@ export function useTripPlannerPage() { tourConversion.line, trackParts, ]) const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' - // A hand-drawn stretch of a hike is dotted, a paddle tour's portages dotted and brown. + // What no router planned is dotted; a paddle tour's portages brown, whitewater amber, gaps red. const paddlePaint = usePaddlePaint() const partStyle = tourRouteStyle( !wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null, tourPlanner.isPaddle || !!trackParts, - { line: paddlePaint.portage, casing: paddlePaint.disc }, + { portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc }, ) const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null) diff --git a/client/src/repo/paddleRegionLoadRepo.test.ts b/client/src/repo/paddleRegionLoadRepo.test.ts new file mode 100644 index 000000000..0ec9dc83a --- /dev/null +++ b/client/src/repo/paddleRegionLoadRepo.test.ts @@ -0,0 +1,46 @@ +// FE-REPO-PADDLE-REGION-LOAD-001 to 003: the water data of places asked for online only (PAD-11C). +import { paddleRegionLoadRequestSchema, paddleRegionLoadResponseSchema } from '@trek/shared'; +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, it } from 'vitest'; +import { contractHandler } from '../../tests/helpers/msw/contract'; +import { server } from '../../tests/helpers/msw/server'; +import { setForcedOffline } from '../sync/networkMode'; +import { paddleRegionLoadRepo } from './paddleRegionLoadRepo'; + +const LOAD = '/api/paddle/regions/load'; +const PLACE = { lat: 53.2, lng: 12.9 }; + +describe('paddleRegionLoadRepo', () => { + afterEach(() => setForcedOffline(false)); + + it('FE-REPO-PADDLE-REGION-LOAD-001: asks the server for the places and answers its states in order', async () => { + const bodies: unknown[] = []; + server.use( + contractHandler( + 'post', + LOAD, + { request: paddleRegionLoadRequestSchema, response: paddleRegionLoadResponseSchema }, + async ({ request }) => { + bodies.push(await request.json()); + return { points: [{ state: 'ready', region: { id: 'mv', name: 'Mecklenburg' } }] }; + } + ) + ); + await expect(paddleRegionLoadRepo.load([PLACE], true)).resolves.toEqual([ + { state: 'ready', region: { id: 'mv', name: 'Mecklenburg' } }, + ]); + expect(bodies).toEqual([{ points: [PLACE], retry: true }]); + }); + + it('FE-REPO-PADDLE-REGION-LOAD-002: asks nothing offline or about no place', async () => { + setForcedOffline(true); + await expect(paddleRegionLoadRepo.load([PLACE], false)).resolves.toBeNull(); + setForcedOffline(false); + await expect(paddleRegionLoadRepo.load([], false)).resolves.toBeNull(); + }); + + it('FE-REPO-PADDLE-REGION-LOAD-003: refuses an answer that is not the contract', async () => { + server.use(http.post(LOAD, () => HttpResponse.json({ points: [{ state: 'maybe' }] }))); + await expect(paddleRegionLoadRepo.load([PLACE], false)).rejects.toMatchObject({ name: 'ZodError' }); + }); +}); diff --git a/client/src/repo/paddleRegionLoadRepo.ts b/client/src/repo/paddleRegionLoadRepo.ts new file mode 100644 index 000000000..fc076fced --- /dev/null +++ b/client/src/repo/paddleRegionLoadRepo.ts @@ -0,0 +1,20 @@ +import type { PaddleRegionLoadState, PaddleRoutePoint } from '@trek/shared'; +import { paddleRegionLoadApi } from '../api/paddleRegionLoad'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +/** + * PAD-11C: the water data around places no paddle region covers, online + * only on purpose: the server loads it, and a load asked for offline would + * only start long after the paddler stopped waiting for it. Offline it + * answers null, and the planner keeps saying why the leg is unplanned. + */ +export const paddleRegionLoadRepo = { + async load( + points: readonly PaddleRoutePoint[], + retry: boolean, + signal?: AbortSignal + ): Promise { + if (points.length === 0 || isEffectivelyOffline()) return null; + return await paddleRegionLoadApi.load({ points: [...points], retry }, signal); + }, +}; diff --git a/client/src/repo/paddleRegionRepo.test.ts b/client/src/repo/paddleRegionRepo.test.ts index b14f20142..13cee3c58 100644 --- a/client/src/repo/paddleRegionRepo.test.ts +++ b/client/src/repo/paddleRegionRepo.test.ts @@ -32,15 +32,18 @@ const REGION: PaddleRegion = { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready', + origin: 'admin', source_timestamp: '2026-09-30T20:21:02Z', imported_at: '2026-10-01T03:00:00.000Z', feature_count: 412, error: null, progress: null, + network: null, }; const QUEUED: PaddleRegion = { ...REGION, status: 'queued', + origin: 'admin', source_timestamp: null, imported_at: null, feature_count: 0, @@ -51,7 +54,7 @@ const ENTRY: PaddleRegionCatalogEntry = { id: 'oberpfalz', name: 'Oberpfalz', pa beforeEach(() => { vi.clearAllMocks(); vi.mocked(isEffectivelyOffline).mockReturnValue(false); - vi.mocked(paddleRegionsApi.list).mockResolvedValue([REGION]); + vi.mocked(paddleRegionsApi.list).mockResolvedValue({ regions: [REGION], requests: [] }); vi.mocked(paddleRegionsApi.catalog).mockResolvedValue([ENTRY]); vi.mocked(paddleRegionsApi.add).mockResolvedValue(QUEUED); vi.mocked(paddleRegionsApi.refresh).mockResolvedValue(READING); @@ -61,7 +64,7 @@ beforeEach(() => { describe('paddleRegionRepo online', () => { it('FE-REPO-PADDLEREGION-001: the list and the catalogue search go to the api and answer what it answers', async () => { const signal = new AbortController().signal; - await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]); + await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] }); await expect(paddleRegionRepo.catalog('ober', signal)).resolves.toEqual([ENTRY]); expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1); expect(paddleRegionsApi.catalog).toHaveBeenCalledWith('ober', signal); @@ -113,7 +116,7 @@ describe('paddleRegionRepo offline', () => { it('FE-REPO-PADDLEREGION-006: the connection is read at each call, so the next call once it is back goes through', async () => { vi.mocked(isEffectivelyOffline).mockReturnValueOnce(true); await expect(paddleRegionRepo.list()).rejects.toBeInstanceOf(PaddleRegionsOfflineError); - await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]); + await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] }); expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1); }); }); @@ -159,6 +162,7 @@ describe('paddleRegionsApi', () => { server.use( contractHandler('get', LIST, { response: paddleRegionListResponseSchema }, () => ({ regions: [REGION, READING], + requests: [], })), contractHandler('get', CATALOG, { response: paddleRegionCatalogResponseSchema }, ({ request }) => { searches.push(new URL(request.url).searchParams.get('q')); @@ -166,7 +170,7 @@ describe('paddleRegionsApi', () => { }) ); - await expect(api.list()).resolves.toEqual([REGION, READING]); + await expect(api.list()).resolves.toEqual({ regions: [REGION, READING], requests: [] }); await expect(api.catalog('ober')).resolves.toEqual([ENTRY]); expect(searches).toEqual(['ober']); }); diff --git a/client/src/repo/paddleRegionRepo.ts b/client/src/repo/paddleRegionRepo.ts index 52d0a5a95..e2a70f940 100644 --- a/client/src/repo/paddleRegionRepo.ts +++ b/client/src/repo/paddleRegionRepo.ts @@ -31,4 +31,7 @@ export const paddleRegionRepo = { add: online(paddleRegionsApi.add), refresh: online(paddleRegionsApi.refresh), remove: online(paddleRegionsApi.remove), + decline: online(paddleRegionsApi.decline), + settings: online(paddleRegionsApi.settings), + saveSettings: online(paddleRegionsApi.saveSettings), }; diff --git a/client/src/repo/paddleRouteRepo.test.ts b/client/src/repo/paddleRouteRepo.test.ts new file mode 100644 index 000000000..e376a30d6 --- /dev/null +++ b/client/src/repo/paddleRouteRepo.test.ts @@ -0,0 +1,192 @@ +// FE-REPO-PADDLEROUTE-001 to FE-REPO-PADDLEROUTE-006: paddle legs planned on the water, online only, remembered per leg. +// FE-API-PADDLEROUTE-001 to FE-API-PADDLEROUTE-002: the call behind them, held to the @trek/shared contract. +import { + MAX_PADDLE_ROUTE_LEGS, + paddleRouteRequestSchema, + paddleRouteResponseSchema, + type PaddleRouteLeg, + type PaddleRoutePoint, +} from '@trek/shared'; +import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { contractHandler } from '../../tests/helpers/msw/contract'; +import { server } from '../../tests/helpers/msw/server'; +import { paddleRouteApi } from '../api/paddleRoute'; +import { isEffectivelyOffline } from '../sync/networkMode'; +import { paddleRouteRepo, type PaddleRouteProfile } from './paddleRouteRepo'; + +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) })); +vi.mock('../api/paddleRoute', () => ({ paddleRouteApi: { plan: vi.fn() } })); + +const KAYAK: PaddleRouteProfile = { boat: 'kayak', lakeStyle: 'direct' }; +const A = { lat: 49.1283, lng: 12.1303 }; +const B = { lat: 49.0922, lng: 12.1108 }; +const C = { lat: 49.022, lng: 12.111 }; + +/** The router's answer for a leg: straight on the water, its length the leg's index in metres. */ +function okLeg(from: PaddleRoutePoint, to: PaddleRoutePoint, distance = 1000): PaddleRouteLeg { + return { + status: 'ok', + geometry: [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + segments: [{ kind: 'water', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }], + distance_m: distance, + snapped_m: { from: 0, to: 0 }, + }; +} + +/** Answers every leg it is asked for on the water. */ +function answerAll() { + vi.mocked(paddleRouteApi.plan).mockImplementation(async (_trip, request) => ({ + legs: request.legs.map(({ from, to }) => okLeg(from, to)), + graph_rev: 'rev-1', + })); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isEffectivelyOffline).mockReturnValue(false); + answerAll(); +}); + +describe('paddleRouteRepo', () => { + it('FE-REPO-PADDLEROUTE-001: asks the server for every leg once, with the boat and the lake style', async () => { + const legs = await paddleRouteRepo.plan( + 101, + [ + { from: A, to: B }, + { from: B, to: C }, + ], + KAYAK + ); + expect(legs.map((leg) => leg.status)).toEqual(['ok', 'ok']); + expect(paddleRouteApi.plan).toHaveBeenCalledTimes(1); + expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1]).toEqual({ + legs: [ + { from: A, to: B }, + { from: B, to: C }, + ], + boat: 'kayak', + lake_style: 'direct', + }); + }); + + it('FE-REPO-PADDLEROUTE-002: a leg planned before comes from memory; moving one point asks only for its legs', async () => { + await paddleRouteRepo.plan( + 102, + [ + { from: A, to: B }, + { from: B, to: C }, + ], + KAYAK + ); + vi.mocked(paddleRouteApi.plan).mockClear(); + await paddleRouteRepo.plan( + 102, + [ + { from: A, to: B }, + { from: B, to: C }, + ], + KAYAK + ); + expect(paddleRouteApi.plan).not.toHaveBeenCalled(); + + const moved = { lat: C.lat + 0.001, lng: C.lng }; + await paddleRouteRepo.plan( + 102, + [ + { from: A, to: B }, + { from: B, to: moved }, + ], + KAYAK + ); + expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1].legs).toEqual([{ from: B, to: moved }]); + }); + + it('FE-REPO-PADDLEROUTE-003: another boat or lake style is another leg; a failed leg is asked again next time', async () => { + await paddleRouteRepo.plan(103, [{ from: A, to: B }], KAYAK); + await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'sup', lakeStyle: 'direct' }); + await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'kayak', lakeStyle: 'shore' }); + expect(paddleRouteApi.plan).toHaveBeenCalledTimes(3); + + vi.mocked(paddleRouteApi.plan).mockResolvedValue({ legs: [{ status: 'failed', code: 'busy' }], graph_rev: null }); + await expect(paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK)).resolves.toEqual([ + { status: 'failed', code: 'busy' }, + ]); + answerAll(); + const again = await paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK); + expect(again[0]?.status).toBe('ok'); + }); + + it('FE-REPO-PADDLEROUTE-004: forgetting a leg asks the server again (P2A-05)', async () => { + await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK); + paddleRouteRepo.forget(104, A, B, KAYAK); + await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK); + expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2); + }); + + it('FE-REPO-PADDLEROUTE-005: offline, a leg not planned before comes back failed as offline; a remembered one still answers', async () => { + await paddleRouteRepo.plan(105, [{ from: A, to: B }], KAYAK); + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + vi.mocked(paddleRouteApi.plan).mockClear(); + const legs = await paddleRouteRepo.plan( + 105, + [ + { from: A, to: B }, + { from: B, to: C }, + ], + KAYAK + ); + expect(legs.map((leg) => leg.status)).toEqual(['ok', 'failed']); + expect(legs[1]).toEqual({ status: 'failed', code: 'offline', at: null }); + expect(paddleRouteApi.plan).not.toHaveBeenCalled(); + }); + + it('FE-REPO-PADDLEROUTE-006: more legs than one request takes go in batches; a leg the answer leaves out stays unrouted', async () => { + const many = Array.from({ length: MAX_PADDLE_ROUTE_LEGS + 3 }, (_, index) => ({ + from: { lat: 49 + index * 0.001, lng: 12 }, + to: { lat: 49 + (index + 1) * 0.001, lng: 12 }, + })); + const legs = await paddleRouteRepo.plan(106, many, KAYAK); + expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2); + expect(vi.mocked(paddleRouteApi.plan).mock.calls[1]?.[1].legs).toHaveLength(3); + expect(legs.every((leg) => leg.status === 'ok')).toBe(true); + + vi.mocked(paddleRouteApi.plan).mockResolvedValue({ legs: [], graph_rev: null }); + await expect(paddleRouteRepo.plan(106, [{ from: C, to: A }], KAYAK)).resolves.toEqual([ + { status: 'failed', code: 'no_water_path', at: null }, + ]); + }); +}); + +/** The real call; the repo tests above run against a mock of this module. */ +async function realApi() { + const actual = await vi.importActual('../api/paddleRoute'); + return actual.paddleRouteApi; +} + +describe('paddleRouteApi', () => { + it('FE-API-PADDLEROUTE-001: posts the legs to the trip and answers the parsed reply', async () => { + const api = await realApi(); + server.use( + contractHandler( + 'post', + '/api/trips/7/paddle/route', + { request: paddleRouteRequestSchema, response: paddleRouteResponseSchema }, + ({ body }) => ({ legs: body.legs.map(({ from, to }) => okLeg(from, to, 2400)), graph_rev: 'abc' }) + ) + ); + const answer = await api.plan(7, { legs: [{ from: A, to: B }], boat: 'canoe' }); + expect(answer.legs[0]).toMatchObject({ status: 'ok', distance_m: 2400 }); + expect(answer.graph_rev).toBe('abc'); + }); + + it('FE-API-PADDLEROUTE-002: an answer off the contract is refused instead of handed on', async () => { + const api = await realApi(); + server.use(http.post('/api/trips/7/paddle/route', () => HttpResponse.json({ legs: [{ status: 'maybe' }] }))); + await expect(api.plan(7, { legs: [{ from: A, to: B }] })).rejects.toThrow(); + }); +}); diff --git a/client/src/repo/paddleRouteRepo.ts b/client/src/repo/paddleRouteRepo.ts new file mode 100644 index 000000000..d25064fd9 --- /dev/null +++ b/client/src/repo/paddleRouteRepo.ts @@ -0,0 +1,95 @@ +import { + MAX_PADDLE_ROUTE_LEGS, + type PaddleBoatType, + type PaddleLakeStyle, + type PaddleRouteLeg, + type PaddleRoutePoint, +} from '@trek/shared'; +import { paddleRouteApi } from '../api/paddleRoute'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +export interface PaddleRouteProfile { + boat: PaddleBoatType; + lakeStyle: PaddleLakeStyle; +} + +/** A leg the router could not be asked for: offline, or the answer did not come. */ +export type PaddleLegAnswer = PaddleRouteLeg | { status: 'failed'; code: 'offline'; at: null }; + +const OFFLINE: PaddleLegAnswer = { status: 'failed', code: 'offline', at: null }; +const NO_ANSWER: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null }; + +/** How many planned legs stay remembered; a leg asked again comes from here and costs no request. */ +const REMEMBERED_LEGS = 300; + +const remembered = new Map(); + +function keyOf( + tripId: number | string, + from: PaddleRoutePoint, + to: PaddleRoutePoint, + profile: PaddleRouteProfile +): string { + return [ + tripId, + from.lat.toFixed(6), + from.lng.toFixed(6), + to.lat.toFixed(6), + to.lng.toFixed(6), + profile.boat, + profile.lakeStyle, + ].join('|'); +} + +function remember(key: string, leg: PaddleRouteLeg): void { + remembered.delete(key); + remembered.set(key, leg); + if (remembered.size > REMEMBERED_LEGS) remembered.delete(remembered.keys().next().value as string); +} + +/** + * PAD-11: legs planned on the water, online only on purpose: the network lives + * on the server. A leg planned before comes from memory, so moving one point + * asks only for the two legs at it; a leg that failed is asked again next time. + * Offline every leg comes back failed as `offline`, and the planner keeps the + * stretch visibly unrouted. + */ +export const paddleRouteRepo = { + async plan( + tripId: number | string, + legs: ReadonlyArray<{ from: PaddleRoutePoint; to: PaddleRoutePoint }>, + profile: PaddleRouteProfile, + signal?: AbortSignal + ): Promise { + const keys = legs.map(({ from, to }) => keyOf(tripId, from, to, profile)); + const answers: Array = keys.map((key) => remembered.get(key)); + const missing = answers.flatMap((answer, index) => (answer ? [] : [index])); + if (missing.length > 0 && isEffectivelyOffline()) return answers.map((answer) => answer ?? OFFLINE); + for (let start = 0; start < missing.length; start += MAX_PADDLE_ROUTE_LEGS) { + const batch = missing.slice(start, start + MAX_PADDLE_ROUTE_LEGS); + const asked = batch.flatMap((index) => { + const leg = legs[index]; + return leg ? [{ index, leg }] : []; + }); + const planned = await paddleRouteApi.plan( + tripId, + { legs: asked.map(({ leg }) => leg), boat: profile.boat, lake_style: profile.lakeStyle }, + signal + ); + asked.forEach(({ index }, at) => { + const leg = planned.legs[at]; + const key = keys[index]; + if (!leg || key === undefined) return; + answers[index] = leg; + if (leg.status === 'ok') remember(key, leg); + }); + } + return answers.map((answer) => answer ?? NO_ANSWER); + }, + + /** P2A-05: forget a leg, so the next plan asks the server again (on its current data). */ + forget(tripId: number | string, from: PaddleRoutePoint, to: PaddleRoutePoint, profile: PaddleRouteProfile): void { + const key = keyOf(tripId, from, to, profile); + remembered.delete(key); + }, +}; diff --git a/client/src/styles/paddle.css b/client/src/styles/paddle.css index 2f91ab4fd..d1b86de63 100644 --- a/client/src/styles/paddle.css +++ b/client/src/styles/paddle.css @@ -7,12 +7,16 @@ /* Obstacles in slate, access rules in indigo, places to get in and out in teal, a carried stretch in earth brown; each marker a disc in the map's own - white, which keeps it readable over a satellite tile. */ + white, which keeps it readable over a satellite tile. A routed paddle tour + (PAD-11) marks what the paddler should look at: whitewater and boat passes in + amber, a gap in the data or a stretch no route was found for in red. */ :root { --map-paddle-obstacle: #334155; --map-paddle-access: #4338ca; --map-paddle-landing: #0f766e; --map-portage: #9a6233; + --map-paddle-rapids: #d97706; + --map-paddle-gap: #dc2626; --map-paddle-disc: #ffffff; --map-paddle-shadow: rgba(15, 23, 42, 0.35); } @@ -22,6 +26,8 @@ --map-paddle-access: #a5b4fc; --map-paddle-landing: #5eead4; --map-portage: #d6a06e; + --map-paddle-rapids: #f59e0b; + --map-paddle-gap: #ef4444; --map-paddle-disc: #1e293b; --map-paddle-shadow: rgba(0, 0, 0, 0.55); } diff --git a/client/tests/helpers/tourPlannerStub.ts b/client/tests/helpers/tourPlannerStub.ts index fc051c829..6d3fa2dbb 100644 --- a/client/tests/helpers/tourPlannerStub.ts +++ b/client/tests/helpers/tourPlannerStub.ts @@ -15,12 +15,14 @@ export function hikePlannerDefaults() { waterContext: null, speedKmh: null, portageSpeedKmh: null, + lakeStyle: 'direct' as const, recommendedSpeedKmh: null, effectiveSpeedKmh: null, setBoatType: vi.fn(), setWaterContext: vi.fn(), setSpeedKmh: vi.fn(), setPortageSpeedKmh: vi.fn(), + setLakeStyle: vi.fn(), adoptRecommendedSpeed: vi.fn(), load: vi.fn(), }, @@ -28,10 +30,17 @@ export function hikePlannerDefaults() { paddleIncomplete: false, paddleEstimate: null, editPaddlePoints: vi.fn(), + amendWaypoints: vi.fn(), isLoop: false, reverse: vi.fn(), returnToStart: vi.fn(), toggleWaypointFreeLeg: vi.fn(), + insertShapingPoint: vi.fn(() => null), + setWaypointShaping: vi.fn(), + recomputeLeg: vi.fn(), + routeDrawnLegsOnWater: vi.fn(), + paddleRoute: null, + routeCurrent: false, routeParts: null, mapOverlays: new Set(), toggleMapOverlay: vi.fn(), diff --git a/scripts/ci/knip-baseline.json b/scripts/ci/knip-baseline.json index 53e541730..54aafed22 100644 --- a/scripts/ci/knip-baseline.json +++ b/scripts/ci/knip-baseline.json @@ -46,7 +46,7 @@ "client/src/components/Roadtrip/useFollowTrack.ts": 2, "client/src/components/Roadtrip/useHotelBookends.ts": 1, "client/src/components/Studio/countryShapes.ts": 1, - "client/src/components/Tours/planner/useTourPlanner.ts": 3, + "client/src/components/Tours/planner/useTourPlanner.ts": 1, "client/src/components/shared/ErrorBoundary.tsx": 1, "client/src/components/shared/Toast.tsx": 1, "client/src/constants/mapDefaults.ts": 4, diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index be1d68a8d..8b87d655e 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -61,7 +61,9 @@ "PackingTemplateItems": "packing", "PackingTemplates": "packing", "PaddleFeatures": "paddle-obstacles", + "PaddleRegionRequests": "paddle-obstacles", "PaddleRegions": "paddle-obstacles", + "PaddleWaterGraphs": "paddle-routing", "PasswordResetTokens": "auth", "PhotoProviderFields": "addons", "PhotoProviders": "addons", diff --git a/server/src/db/entities/PaddleRegionRequests.entity.ts b/server/src/db/entities/PaddleRegionRequests.entity.ts new file mode 100644 index 000000000..0f5cd09af --- /dev/null +++ b/server/src/db/entities/PaddleRegionRequests.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PaddleRegionRequestsRepository } from '../repositories/PaddleRegionRequests.repository'; +import { DbTimestampType } from '../types'; + +export class PaddleRegionRequests { + [EntityRepositoryType]?: PaddleRegionRequestsRepository; + [PrimaryKeyProp]?: 'region_id'; + region_id!: string; + name!: string; + reason!: TPaddleRegionRequestsReason; + bytes?: number | null; + requested_by?: string | null; + requested_at!: string & Opt; + declined_at?: string | null; +} + +export const PaddleRegionRequestsReason = { + ASK: 'ask', + TOO_LARGE: 'too_large', + LIMIT: 'limit', +} as const; + +export type TPaddleRegionRequestsReason = (typeof PaddleRegionRequestsReason)[keyof typeof PaddleRegionRequestsReason]; + +export const PaddleRegionRequestsSchema = defineEntity({ + class: PaddleRegionRequests, + repository: () => PaddleRegionRequestsRepository, + properties: { + region_id: p.text().primary(), + name: p.text(), + reason: p.enum(() => PaddleRegionRequestsReason), + bytes: p.integer().nullable(), + requested_by: p.text().nullable(), + requested_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`), + declined_at: p.type(DbTimestampType).nullable(), + }, +}); diff --git a/server/src/db/entities/PaddleRegions.entity.ts b/server/src/db/entities/PaddleRegions.entity.ts index 68b8b1f9a..48f12e0a7 100644 --- a/server/src/db/entities/PaddleRegions.entity.ts +++ b/server/src/db/entities/PaddleRegions.entity.ts @@ -1,7 +1,8 @@ -import { Collection, EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; import { PaddleRegionsRepository } from '../repositories/PaddleRegions.repository'; import { DbTimestampType } from '../types'; import { PaddleFeatures } from './PaddleFeatures.entity'; +import { PaddleWaterGraphs } from './PaddleWaterGraphs.entity'; export class PaddleRegions { [EntityRepositoryType]?: PaddleRegionsRepository; @@ -18,7 +19,9 @@ export class PaddleRegions { feature_count: number & Opt = 0; error?: string | null; created_at?: string | null; + origin: TPaddleRegionsOrigin & Opt = PaddleRegionsOrigin.ADMIN; paddle_features_collection = new Collection(this); + paddle_water_graphs: Ref | null = null; } export const PaddleRegionsStatus = { @@ -30,6 +33,13 @@ export const PaddleRegionsStatus = { export type TPaddleRegionsStatus = (typeof PaddleRegionsStatus)[keyof typeof PaddleRegionsStatus]; +export const PaddleRegionsOrigin = { + ADMIN: 'admin', + AUTO: 'auto', +} as const; + +export type TPaddleRegionsOrigin = (typeof PaddleRegionsOrigin)[keyof typeof PaddleRegionsOrigin]; + export const PaddleRegionsSchema = defineEntity({ class: PaddleRegions, repository: () => PaddleRegionsRepository, @@ -47,6 +57,8 @@ export const PaddleRegionsSchema = defineEntity({ feature_count: p.integer().default(0), error: p.text().nullable(), created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + origin: p.enum(() => PaddleRegionsOrigin).default('admin'), paddle_features_collection: () => p.oneToMany(PaddleFeatures).mappedBy('region').hidden(), + paddle_water_graphs: () => p.oneToOne(PaddleWaterGraphs).ref().mappedBy('region').hidden(), }, }); diff --git a/server/src/db/entities/PaddleWaterGraphs.entity.ts b/server/src/db/entities/PaddleWaterGraphs.entity.ts new file mode 100644 index 000000000..f07f3fb22 --- /dev/null +++ b/server/src/db/entities/PaddleWaterGraphs.entity.ts @@ -0,0 +1,45 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PaddleWaterGraphsRepository } from '../repositories/PaddleWaterGraphs.repository'; +import { DbTimestampType } from '../types'; +import { PaddleRegions } from './PaddleRegions.entity'; + +export class PaddleWaterGraphs { + [EntityRepositoryType]?: PaddleWaterGraphsRepository; + [PrimaryKeyProp]?: 'region'; + region?: Ref | null; + region_id?: string | null; + file!: string; + rev!: string; + bytes!: number; + built_at!: string; + stats!: string; + min_lat!: number; + min_lng!: number; + max_lat!: number; + max_lng!: number; +} + +export const PaddleWaterGraphsSchema = defineEntity({ + class: PaddleWaterGraphs, + repository: () => PaddleWaterGraphsRepository, + indexes: [ + { + name: 'idx_paddle_water_graphs_box', + properties: ['min_lat', 'max_lat', 'min_lng', 'max_lng'], + }, + ], + uniques: [{ properties: ['region'] }], + properties: { + region: () => p.oneToOne(PaddleRegions).primary().ref().deleteRule('cascade').nullable().hidden(), + region_id: p.text().nullable().persist(false), + file: p.text(), + rev: p.text(), + bytes: p.integer(), + built_at: p.type(DbTimestampType), + stats: p.text(), + min_lat: p.double(), + min_lng: p.double(), + max_lat: p.double(), + max_lng: p.double(), + }, +}); diff --git a/server/src/db/entities/TourWaypoints.entity.ts b/server/src/db/entities/TourWaypoints.entity.ts index 57bda5c7d..f7d477058 100644 --- a/server/src/db/entities/TourWaypoints.entity.ts +++ b/server/src/db/entities/TourWaypoints.entity.ts @@ -13,12 +13,16 @@ export class TourWaypoints { sequence!: number; fixed_leg?: string | null; lock?: string | null; + shaping: number & Opt = 0; } export const TourWaypointsSchema = defineEntity({ class: TourWaypoints, repository: () => TourWaypointsRepository, uniques: [{ properties: ['place', 'sequence'] }], + checks: [ + { name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' }, + ], properties: { id: p.integer().primary(), place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(), @@ -29,5 +33,6 @@ export const TourWaypointsSchema = defineEntity({ sequence: p.integer(), fixed_leg: p.text().nullable(), lock: p.text().nullable(), + shaping: p.integer().default(0), }, }); diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 1d5aa24ae..5ed2837de 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -33,6 +33,7 @@ export class Tours { paddle_speed_kmh?: number | null; portage_speed_kmh?: number | null; track_marks?: string | null; + paddle_lake_style?: TToursPaddleLakeStyle | null; tourTypeRef!: Ref; tour_obstacle_reviews_collection = new Collection(this); tour_sources: Ref | null = null; @@ -63,6 +64,13 @@ export const ToursWaterContext = { export type TToursWaterContext = (typeof ToursWaterContext)[keyof typeof ToursWaterContext]; +export const ToursPaddleLakeStyle = { + DIRECT: 'direct', + SHORE: 'shore', +} as const; + +export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle]; + export const ToursSchema = defineEntity({ class: Tours, repository: () => ToursRepository, @@ -98,6 +106,7 @@ export const ToursSchema = defineEntity({ paddle_speed_kmh: p.double().nullable(), portage_speed_kmh: p.double().nullable(), track_marks: p.text().nullable(), + paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(), tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(), tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(), diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index 9c2d23b29..064d508d8 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -62,7 +62,9 @@ import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.ent import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity'; import { PackingTemplatesSchema } from './PackingTemplates.entity'; import { PaddleFeaturesSchema } from './PaddleFeatures.entity'; +import { PaddleRegionRequestsSchema } from './PaddleRegionRequests.entity'; import { PaddleRegionsSchema } from './PaddleRegions.entity'; +import { PaddleWaterGraphsSchema } from './PaddleWaterGraphs.entity'; import { PasswordResetTokensSchema } from './PasswordResetTokens.entity'; import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity'; import { PhotoProvidersSchema } from './PhotoProviders.entity'; @@ -204,7 +206,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ PackingTemplateItemsSchema, PackingTemplatesSchema, PaddleFeaturesSchema, + PaddleRegionRequestsSchema, PaddleRegionsSchema, + PaddleWaterGraphsSchema, PasswordResetTokensSchema, PhotoProviderFieldsSchema, PhotoProvidersSchema, diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 085524d24..779054568 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -6,6 +6,7 @@ import { paddleGeometrySchema, paddleLockSchema, paddleRegionBoundarySchema, + paddleRegionGraphStatsSchema, tourFixedLegSchema, trackMarksSchema, type Ferrata, @@ -13,6 +14,7 @@ import { type PaddleGeometry, type PaddleLock, type PaddleRegionBoundary, + type PaddleRegionGraphStats, type TourFixedLeg, type TrackMarks, } from '@trek/shared'; @@ -194,6 +196,13 @@ export const PADDLE_REGION_BOUNDARY: JsonColumn = { fallback: () => null, }; +/** What a paddle region's water network holds (PAD-11). A broken row has none, and the region shows no network figures. */ +export const PADDLE_REGION_GRAPH_STATS: JsonColumn = { + column: 'paddle_water_graphs.stats', + schema: paddleRegionGraphStatsSchema.nullable(), + fallback: () => null, +}; + /** What a paddle tour's obstacle decision keeps of its obstacle (PAD-06). A broken row has none, and the decision is left out. */ export const TOUR_OBSTACLE_SNAPSHOT: JsonColumn = { column: 'tour_obstacle_reviews.obstacle_snapshot', diff --git a/server/src/db/kysely/PaddleRegionRequests.table.ts b/server/src/db/kysely/PaddleRegionRequests.table.ts new file mode 100644 index 000000000..b9fcbaa04 --- /dev/null +++ b/server/src/db/kysely/PaddleRegionRequests.table.ts @@ -0,0 +1,13 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface PaddleRegionRequestsTable { + region_id: string; + name: string; + reason: string; + bytes: InsertOptional; + requested_by: InsertOptional; + requested_at: InsertOptional; + declined_at: InsertOptional; +} diff --git a/server/src/db/kysely/PaddleRegions.table.ts b/server/src/db/kysely/PaddleRegions.table.ts index 45bff24f3..af327464c 100644 --- a/server/src/db/kysely/PaddleRegions.table.ts +++ b/server/src/db/kysely/PaddleRegions.table.ts @@ -16,4 +16,5 @@ export interface PaddleRegionsTable { feature_count: InsertOptional; error: InsertOptional; created_at: InsertOptional; + origin: InsertOptional; } diff --git a/server/src/db/kysely/PaddleWaterGraphs.table.ts b/server/src/db/kysely/PaddleWaterGraphs.table.ts new file mode 100644 index 000000000..b6adddc64 --- /dev/null +++ b/server/src/db/kysely/PaddleWaterGraphs.table.ts @@ -0,0 +1,15 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. + +export interface PaddleWaterGraphsTable { + region_id: string; + file: string; + rev: string; + bytes: number; + built_at: string; + stats: string; + min_lat: number; + min_lng: number; + max_lat: number; + max_lng: number; +} diff --git a/server/src/db/kysely/TourWaypoints.table.ts b/server/src/db/kysely/TourWaypoints.table.ts index aa3bd4327..9ce349556 100644 --- a/server/src/db/kysely/TourWaypoints.table.ts +++ b/server/src/db/kysely/TourWaypoints.table.ts @@ -11,4 +11,5 @@ export interface TourWaypointsTable { sequence: number; fixed_leg: InsertOptional; lock: InsertOptional; + shaping: InsertOptional; } diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index 0cbae41e7..7be41ce6f 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -25,4 +25,5 @@ export interface ToursTable { paddle_speed_kmh: InsertOptional; portage_speed_kmh: InsertOptional; track_marks: InsertOptional; + paddle_lake_style: InsertOptional; } diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index 0c1412c00..cafcfbcdc 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -67,7 +67,9 @@ import type { PackingTemplateCategoriesTable } from './PackingTemplateCategories import type { PackingTemplateItemsTable } from './PackingTemplateItems.table'; import type { PackingTemplatesTable } from './PackingTemplates.table'; import type { PaddleFeaturesTable } from './PaddleFeatures.table'; +import type { PaddleRegionRequestsTable } from './PaddleRegionRequests.table'; import type { PaddleRegionsTable } from './PaddleRegions.table'; +import type { PaddleWaterGraphsTable } from './PaddleWaterGraphs.table'; import type { PasswordResetTokensTable } from './PasswordResetTokens.table'; import type { PhotoProviderFieldsTable } from './PhotoProviderFields.table'; import type { PhotoProvidersTable } from './PhotoProviders.table'; @@ -215,7 +217,9 @@ export interface DB { packing_template_items: PackingTemplateItemsTable; packing_templates: PackingTemplatesTable; paddle_features: PaddleFeaturesTable; + paddle_region_requests: PaddleRegionRequestsTable; paddle_regions: PaddleRegionsTable; + paddle_water_graphs: PaddleWaterGraphsTable; password_reset_tokens: PasswordResetTokensTable; photo_provider_fields: PhotoProviderFieldsTable; photo_providers: PhotoProvidersTable; diff --git a/server/src/db/migrations/Migration20200101044400_a_paddle_region_builds_its_water_network.ts b/server/src/db/migrations/Migration20200101044400_a_paddle_region_builds_its_water_network.ts new file mode 100644 index 000000000..06ffa58c3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101044400_a_paddle_region_builds_its_water_network.ts @@ -0,0 +1,34 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-11: the water network a paddle region builds for routing, from the same + * read as its obstacles. The network itself is a file in the data folder + * (`file`, named after its revision `rev`, the first hex digits of its + * hash); the row says where it is, how big it came out, when it was built, + * what it holds (`stats`, JSON: kilometres of water, lakes, portages, gaps, + * locks) and the box it covers, so a point finds its network without reading + * the region. One row per region, gone with the region. + */ +export class Migration20200101044400_a_paddle_region_builds_its_water_network extends Migration { + override name = 'Migration20200101044400_a_paddle_region_builds_its_water_network'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS paddle_water_graphs ( + region_id TEXT PRIMARY KEY REFERENCES paddle_regions(id) ON DELETE CASCADE, + file TEXT NOT NULL, + rev TEXT NOT NULL, + bytes INTEGER NOT NULL, + built_at DATETIME NOT NULL, + stats TEXT NOT NULL, + min_lat REAL NOT NULL, + min_lng REAL NOT NULL, + max_lat REAL NOT NULL, + max_lng REAL NOT NULL + ) + `); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_paddle_water_graphs_box ON paddle_water_graphs (min_lat, max_lat, min_lng, max_lng)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101044500_a_tour_waypoint_can_shape_the_route.ts b/server/src/db/migrations/Migration20200101044500_a_tour_waypoint_can_shape_the_route.ts new file mode 100644 index 000000000..4a6255d2b --- /dev/null +++ b/server/src/db/migrations/Migration20200101044500_a_tour_waypoint_can_shape_the_route.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * P2A-03: a waypoint can be a shaping point, there only to steer the route: + * no stop, no place to get in or out, nothing a day plan shows. 0 or 1; + * every waypoint saved before is a stop, as it was. + */ +export class Migration20200101044500_a_tour_waypoint_can_shape_the_route extends Migration { + override name = 'Migration20200101044500_a_tour_waypoint_can_shape_the_route'; + + override up(): void { + this.addSql(`ALTER TABLE tour_waypoints ADD COLUMN shaping INTEGER NOT NULL DEFAULT 0 CHECK (shaping IN (0, 1))`); + } +} diff --git a/server/src/db/migrations/Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes.ts b/server/src/db/migrations/Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes.ts new file mode 100644 index 000000000..e7d8e2ddc --- /dev/null +++ b/server/src/db/migrations/Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-11: how a paddle tour crosses its lakes, as the router planned it: + * `direct` (straight over, also what NULL means) or `shore` (along the + * shore, for wind). Kept with the tour so it routes the same when opened again. + */ +export class Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes extends Migration { + override name = 'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes'; + + override up(): void { + this.addSql( + `ALTER TABLE tours ADD COLUMN paddle_lake_style TEXT CHECK (paddle_lake_style IS NULL OR paddle_lake_style IN ('direct', 'shore'))`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans.ts b/server/src/db/migrations/Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans.ts new file mode 100644 index 000000000..7565c6a90 --- /dev/null +++ b/server/src/db/migrations/Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-11C: TREK fetches the water data of a place a paddler plans in by + * itself. `paddle_regions.origin` says who added a region: an admin by hand + * (`admin`, every region before this) or TREK for a paddler (`auto`), which + * also counts against the few a day it may add. + * + * `paddle_region_requests` holds a region that waits for an admin instead: + * the mode asks for it, it is larger than allowed or the day's few are used + * up (`reason`). It keeps the size Geofabrik announced, the name of whoever + * asked (as it read then; no link to the account, a request outlives it) and + * when, and whether an admin declined it (then it is not asked for again). A + * request goes once its region is added. + */ +export class Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans extends Migration { + override name = 'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans'; + + override up(): void { + this.addSql( + `ALTER TABLE paddle_regions ADD COLUMN origin TEXT NOT NULL DEFAULT 'admin' CHECK (origin IN ('admin', 'auto'))`, + ); + this.addSql(` + CREATE TABLE IF NOT EXISTS paddle_region_requests ( + region_id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + reason TEXT NOT NULL CHECK (reason IN ('ask', 'too_large', 'limit')), + bytes INTEGER, + requested_by TEXT, + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + declined_at DATETIME + ) + `); + } +} diff --git a/server/src/db/repositories/PaddleRegionRequests.repository.ts b/server/src/db/repositories/PaddleRegionRequests.repository.ts new file mode 100644 index 000000000..4795a5dcb --- /dev/null +++ b/server/src/db/repositories/PaddleRegionRequests.repository.ts @@ -0,0 +1,80 @@ +import type { PaddleRegionRequests, TPaddleRegionRequestsReason } from '../entities/PaddleRegionRequests.entity'; +import type { DB } from '../kysely/db'; +import { dbNow } from '../types'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A region a paddler's planning asked for, as stored. */ +export interface PaddleRegionRequestRow { + region_id: string; + name: string; + reason: TPaddleRegionRequestsReason; + bytes: number | null; + requested_by: string | null; + requested_at: string; + declined_at: string | null; +} + +/** `reason` narrowed to the three values the column's CHECK constraint allows. */ +type RequestsDB = { + paddle_region_requests: Omit & { reason: TPaddleRegionRequestsReason }; +}; + +const COLUMNS = ['region_id', 'name', 'reason', 'bytes', 'requested_by', 'requested_at', 'declined_at'] as const; + +/** `paddle_region_requests`: regions that wait for an admin before TREK loads them (PAD-11C). */ +export class PaddleRegionRequestsRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** Every request, the newest first. */ + async list(): Promise { + return await this.db() + .selectFrom('paddle_region_requests') + .select(COLUMNS) + .orderBy('requested_at', 'desc') + .orderBy('region_id', 'asc') + .execute(); + } + + async findByRegion(regionId: string): Promise { + return await this.db() + .selectFrom('paddle_region_requests') + .select(COLUMNS) + .where('region_id', '=', regionId) + .executeTakeFirst(); + } + + /** Files a request; one already there, declined or not, stays as it is. */ + async file(row: { + region_id: string; + name: string; + reason: TPaddleRegionRequestsReason; + bytes: number | null; + requested_by: string; + }): Promise { + const result = await this.db() + .insertInto('paddle_region_requests') + .values({ ...row, requested_at: dbNow() }) + .onConflict((oc) => oc.column('region_id').doNothing()) + .executeTakeFirst(); + return Number(result.numInsertedOrUpdatedRows ?? 0) > 0; + } + + async decline(regionId: string): Promise { + const result = await this.db() + .updateTable('paddle_region_requests') + .set({ declined_at: dbNow() }) + .where('region_id', '=', regionId) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + async remove(regionId: string): Promise { + const result = await this.db() + .deleteFrom('paddle_region_requests') + .where('region_id', '=', regionId) + .executeTakeFirst(); + return Number(result.numDeletedRows ?? 0) > 0; + } +} diff --git a/server/src/db/repositories/PaddleRegions.repository.ts b/server/src/db/repositories/PaddleRegions.repository.ts index 043b2b879..b529ac415 100644 --- a/server/src/db/repositories/PaddleRegions.repository.ts +++ b/server/src/db/repositories/PaddleRegions.repository.ts @@ -1,4 +1,10 @@ -import { type PaddleRegions, PaddleRegionsStatus, type TPaddleRegionsStatus } from '../entities/PaddleRegions.entity'; +import { + type PaddleRegions, + PaddleRegionsOrigin, + PaddleRegionsStatus, + type TPaddleRegionsOrigin, + type TPaddleRegionsStatus, +} from '../entities/PaddleRegions.entity'; import type { DB } from '../kysely/db'; import type { Box } from './PaddleFeatures.repository'; import { TrekRepository } from './_shared/trek-repository'; @@ -17,11 +23,15 @@ export interface PaddleRegionRow { imported_at: string | null; feature_count: number; error: string | null; + origin: TPaddleRegionsOrigin; } -/** `status` narrowed to the four values the column's CHECK constraint allows. */ +/** `status` and `origin` narrowed to the values the columns' CHECK constraints allow. */ type RegionsDB = { - paddle_regions: Omit & { status: TPaddleRegionsStatus }; + paddle_regions: Omit & { + status: TPaddleRegionsStatus; + origin: TPaddleRegionsOrigin; + }; }; const COLUMNS = [ @@ -37,6 +47,7 @@ const COLUMNS = [ 'imported_at', 'feature_count', 'error', + 'origin', ] as const; /** `paddle_regions`: the regional extracts an admin chose for paddle data (PAD-05A). */ @@ -76,6 +87,40 @@ export class PaddleRegionsRepository extends TrekRepository { .execute(); } + /** Every region whose box holds the point, whatever its state; the caller checks the boundary. */ + async aroundPoint(lat: number, lng: number): Promise { + return await this.db() + .selectFrom('paddle_regions') + .select(COLUMNS) + .where('min_lat', '<=', lat) + .where('max_lat', '>=', lat) + .where('min_lng', '<=', lng) + .where('max_lng', '>=', lng) + .orderBy('id', 'asc') + .execute(); + } + + /** The regions waiting for their read, oldest first: the order they are read in. */ + async queued(): Promise>> { + return await this.db() + .selectFrom('paddle_regions') + .select('id') + .where('status', '=', PaddleRegionsStatus.QUEUED) + .orderBy('created_at', 'asc') + .execute(); + } + + /** How many regions TREK added by itself since `since` (a canonical UTC timestamp). */ + async countAddedAutomaticallySince(since: string): Promise { + const row = await this.db() + .selectFrom('paddle_regions') + .select((eb) => eb.fn.countAll().as('count')) + .where('origin', '=', PaddleRegionsOrigin.AUTO) + .where('created_at', '>=', since) + .executeTakeFirst(); + return Number(row?.count ?? 0); + } + /** The first region waiting for its read, oldest first. */ async nextQueued(): Promise { return await this.db() @@ -88,10 +133,11 @@ export class PaddleRegionsRepository extends TrekRepository { async insertRegion( row: Pick, + origin: TPaddleRegionsOrigin = PaddleRegionsOrigin.ADMIN, ): Promise { await this.db() .insertInto('paddle_regions') - .values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0 }) + .values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0, origin }) .onConflict((oc) => oc.column('id').doNothing()) .execute(); } diff --git a/server/src/db/repositories/PaddleWaterGraphs.repository.ts b/server/src/db/repositories/PaddleWaterGraphs.repository.ts new file mode 100644 index 000000000..0fb22d99c --- /dev/null +++ b/server/src/db/repositories/PaddleWaterGraphs.repository.ts @@ -0,0 +1,86 @@ +import type { PaddleWaterGraphs } from '../entities/PaddleWaterGraphs.entity'; +import type { DB } from '../kysely/db'; +import type { DbTimestamp } from '../types'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A region's water network as stored: where its file is, what it holds (`stats` still JSON text), the box it covers. */ +export interface PaddleWaterGraphRow { + region_id: string; + file: string; + rev: string; + bytes: number; + built_at: string; + stats: string; + min_lat: number; + min_lng: number; + max_lat: number; + max_lng: number; +} + +const COLUMNS = [ + 'region_id', + 'file', + 'rev', + 'bytes', + 'built_at', + 'stats', + 'min_lat', + 'min_lng', + 'max_lat', + 'max_lng', +] as const; + +/** `paddle_water_graphs`: the water network each paddle region built for routing (PAD-11). */ +export class PaddleWaterGraphsRepository extends TrekRepository { + private db() { + return this.kysely>(); + } + + async list(): Promise { + return await this.db().selectFrom('paddle_water_graphs').select(COLUMNS).orderBy('region_id', 'asc').execute(); + } + + async findByRegion(regionId: string): Promise { + return await this.db() + .selectFrom('paddle_water_graphs') + .select(COLUMNS) + .where('region_id', '=', regionId) + .executeTakeFirst(); + } + + /** The networks whose box holds the point, smallest box first: the most local data plans the leg. */ + async coveringPoint(lat: number, lng: number): Promise { + const rows = await this.db() + .selectFrom('paddle_water_graphs') + .select(COLUMNS) + .where('min_lat', '<=', lat) + .where('max_lat', '>=', lat) + .where('min_lng', '<=', lng) + .where('max_lng', '>=', lng) + .orderBy('region_id', 'asc') + .execute(); + const area = (row: PaddleWaterGraphRow) => (row.max_lat - row.min_lat) * (row.max_lng - row.min_lng); + return rows.sort((a, b) => area(a) - area(b)); + } + + /** Records a region's new network, replacing the one it had. */ + async saveGraph(row: Omit & { built_at: DbTimestamp }): Promise { + await this.db() + .insertInto('paddle_water_graphs') + .values(row) + .onConflict((oc) => + oc.column('region_id').doUpdateSet({ + file: row.file, + rev: row.rev, + bytes: row.bytes, + built_at: row.built_at, + stats: row.stats, + min_lat: row.min_lat, + min_lng: row.min_lng, + max_lat: row.max_lat, + max_lng: row.max_lng, + }), + ) + .execute(); + } +} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts index 21bb5c61f..87ee058a9 100644 --- a/server/src/db/repositories/TourWaypoints.repository.ts +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -15,6 +15,8 @@ export interface TourWaypointRow { fixed_leg?: TourFixedLeg | null; /** A lock at this point of a paddle tour; absent where there is none. */ lock?: PaddleLock | null; + /** P2A-03: a shaping point, steering the route only; absent for a stop. */ + shaping?: boolean; } /** The row as stored, the fixed leg still JSON text. */ @@ -25,13 +27,19 @@ interface StoredWaypoint { sequence: number; fixed_leg: string | null; lock: string | null; + shaping: number; } -/** A stored row as callers read it: the fixed leg and the lock decoded, each left out entirely where there is none. */ -function readWaypoint({ fixed_leg, lock, ...point }: StoredWaypoint): TourWaypointRow { +/** A stored row as callers read it: the fixed leg, the lock and the shaping flag, each left out entirely where there is none. */ +function readWaypoint({ fixed_leg, lock, shaping, ...point }: StoredWaypoint): TourWaypointRow { const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint'); const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint'); - return { ...point, ...(leg ? { fixed_leg: leg } : {}), ...(stop ? { lock: stop } : {}) }; + return { + ...point, + ...(leg ? { fixed_leg: leg } : {}), + ...(stop ? { lock: stop } : {}), + ...(shaping ? { shaping: true } : {}), + }; } /** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */ @@ -54,11 +62,11 @@ export class TourWaypointsRepository extends TrekRepository { return this.kysely(); } - /** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ + /** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock, shaping FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ async listForPlace(place_id: number): Promise { const rows = await this.db() .selectFrom('tour_waypoints') - .select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock']) + .select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock', 'shaping']) .where('place_id', '=', place_id) .orderBy('sequence', 'asc') .execute(); @@ -70,7 +78,7 @@ export class TourWaypointsRepository extends TrekRepository { const rows = await this.db() .selectFrom('tour_waypoints as tw') .innerJoin('places as p', 'p.id', 'tw.place_id') - .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock']) + .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock', 'tw.shaping']) .where('p.trip_id', '=', trip_id) .orderBy('tw.place_id', 'asc') .orderBy('tw.sequence', 'asc') @@ -79,7 +87,7 @@ export class TourWaypointsRepository extends TrekRepository { } /** - * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock) + * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock, shaping) * VALUES (...)` for each point. The caller owns the transaction: a create * writes the tour first, an edit clears the old points first. */ @@ -94,6 +102,7 @@ export class TourWaypointsRepository extends TrekRepository { sequence: w.sequence, fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null, lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null, + shaping: w.shaping ? 1 : 0, })), ); } diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 6a3a4630a..c98608429 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en import type { DB } from '../kysely/db'; import type { AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; -import type { PaddleBoatType, PaddleWaterContext } from '@trek/shared'; +import type { PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared'; /** * A tour as the Tours list and detail read it: the `tours` facet joined with @@ -32,6 +32,7 @@ export interface TourListRow { water_context: string | null; paddle_speed_kmh: number | null; portage_speed_kmh: number | null; + paddle_lake_style: string | null; /** The marks laid onto a recorded track, still JSON text. */ track_marks: string | null; has_recorded_track: number; @@ -62,6 +63,7 @@ export interface TourRow { water_context: string | null; paddle_speed_kmh: number | null; portage_speed_kmh: number | null; + paddle_lake_style: string | null; track_marks: string | null; } const _tourRowKeys: AssertRowKeys = true; @@ -83,6 +85,7 @@ export interface TourInsert { water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; portage_speed_kmh?: number | null; + paddle_lake_style?: PaddleLakeStyle | null; } /** What a route edit replaces on an existing tour: the type and everything that belongs to it. */ @@ -95,6 +98,7 @@ export type TourUpdate = Omit< | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' + | 'paddle_lake_style' > & { planned_duration_minutes?: number | null; break_additional_minutes?: number | null; @@ -102,6 +106,7 @@ export type TourUpdate = Omit< water_context: PaddleWaterContext | null; paddle_speed_kmh: number | null; portage_speed_kmh: number | null; + paddle_lake_style: PaddleLakeStyle | null; }; /** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */ @@ -193,6 +198,7 @@ export class ToursRepository extends TrekRepository { 't.water_context as water_context', 't.paddle_speed_kmh as paddle_speed_kmh', 't.portage_speed_kmh as portage_speed_kmh', + 't.paddle_lake_style as paddle_lake_style', 't.track_marks as track_marks', eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'), eb @@ -449,6 +455,7 @@ export class ToursRepository extends TrekRepository { water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, portage_speed_kmh: input.portage_speed_kmh ?? null, + paddle_lake_style: input.paddle_lake_style ?? null, track_marks: input.track_marks ?? null, }); } @@ -501,6 +508,7 @@ export class ToursRepository extends TrekRepository { water_context: input.water_context, paddle_speed_kmh: input.paddle_speed_kmh, portage_speed_kmh: input.portage_speed_kmh, + paddle_lake_style: input.paddle_lake_style, }) .where('place_id', '=', place_id) .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e32e3ee6f..294b4c1a2 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -63,6 +63,7 @@ import { OauthModule } from './oauth/oauth.module'; import { OidcModule } from './oidc/oidc.module'; import { PackingModule } from './packing/packing.module'; import { PaddleObstaclesModule } from './paddle-obstacles/paddle-obstacles.module'; +import { PaddleRoutingModule } from './paddle-routing/paddle-routing.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PhotosModule } from './photos/photos.module'; import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module'; @@ -158,6 +159,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; WandererModule, FerratasModule, PaddleObstaclesModule, + PaddleRoutingModule, TripsModule, CollabModule, FilesModule, diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 7dae4bb36..9412ee416 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -109,6 +109,9 @@ export const APP_SETTINGS = { // Off until an admin allows it: every user's wanderer calls go out to an address of their choosing. tours_wanderer_enabled: { schema: flag, default: 'false' }, route_usage_enabled: { schema: flag, default: 'false' }, + // PAD-11C: how TREK gets the water data of a place a paddler plans in (auto, ask, off) and the largest extract it loads by itself. + paddle_auto_load: { schema: z.enum(['auto', 'ask', 'off']), default: 'auto' }, + paddle_auto_max_mb: { schema: intText, default: '500' }, airtrail_sync_enabled: { schema: flag, default: 'true' }, airtrail_poll_interval_minutes: { schema: intText, default: null }, dawarich_poll_interval_minutes: { schema: intText, default: null }, diff --git a/server/src/nest/notifications/notification-events.ts b/server/src/nest/notifications/notification-events.ts index 6847ab2ec..bdaa0f1ab 100644 --- a/server/src/nest/notifications/notification-events.ts +++ b/server/src/nest/notifications/notification-events.ts @@ -40,6 +40,7 @@ export type NotifEventType = | 'packing_tagged' | 'version_available' | 'replica_failure' + | 'paddle_region_requested' | 'synology_session_cleared' | 'plugin_notification'; @@ -57,12 +58,17 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [ 'packing_tagged', 'version_available', 'replica_failure', + 'paddle_region_requested', 'synology_session_cleared', 'plugin_notification', ]; /** Events that target admins only (shown in admin panel, not in user settings). */ -export const ADMIN_SCOPED_EVENTS = new Set(['version_available', 'replica_failure']); +export const ADMIN_SCOPED_EVENTS = new Set([ + 'version_available', + 'replica_failure', + 'paddle_region_requested', +]); // Compile-time guard: shared NotificationEventKey and server NotifEventType must // stay in sync. It sits next to the union it guards — it used to live in the diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 3ac87745f..1e500310f 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -221,6 +221,13 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view', navigateTarget: () => '/admin?tab=storage', }, + paddle_region_requested: { + inAppType: 'navigate', + titleKey: 'notif.paddle_region_requested.title', + textKey: 'notif.paddle_region_requested.text', + navigateTextKey: 'notif.action.view_admin', + navigateTarget: () => '/admin?tab=addons', + }, synology_session_cleared: { inAppType: 'simple', titleKey: 'notifications.synologySessionCleared.title', diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts index cd2e7a571..80dffe5b1 100644 --- a/server/src/nest/notifications/transports/ntfy.service.ts +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -33,6 +33,7 @@ const NTFY_EVENT_META: Partial { - const response = await fetch(url, { signal, redirect: 'manual' }); +async function fetchFromGeofabrik( + url: string, + signal: AbortSignal, + hops = 0, + method: 'GET' | 'HEAD' = 'GET', +): Promise { + const response = await fetch(url, { signal, redirect: 'manual', method }); if (response.status < 300 || response.status >= 400) return response; await response.body?.cancel().catch(() => undefined); const location = response.headers.get('location'); const next = location ? new URL(location, url) : null; if (!next || !isGeofabrikUrl(next)) throw new Error('Geofabrik redirected away from its own servers'); if (hops >= MAX_REDIRECTS) throw new Error('Geofabrik redirected too often'); - return await fetchFromGeofabrik(next.href, signal, hops + 1); + return await fetchFromGeofabrik(next.href, signal, hops + 1, method); } @Injectable() @@ -107,6 +114,24 @@ export class GeofabrikClient { return regions; } + /** + * The size of an extract the list names, in bytes, as Geofabrik announces + * it; null when it does not say or cannot be asked right now. Nothing is + * downloaded: it is asked with HEAD. + */ + async sizeOf(url: string): Promise { + if (!isExtractUrl(url)) throw new Error('Not a Geofabrik extract'); + try { + const response = await fetchFromGeofabrik(url, AbortSignal.timeout(SIZE_TIMEOUT_MS), 0, 'HEAD'); + const size = Number(response.headers.get('content-length')); + return response.ok && Number.isSafeInteger(size) && size > 0 ? size : null; + } catch (err) { + // Not knowing the size is no failure: the caller treats it as unknown and the download still caps it. + logWarn(`[Paddle] size of ${url}: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + } + /** * One region's extract written to `target`. The address must be one the * list named; the body is counted as it streams and cut at `maxBytes` diff --git a/server/src/nest/paddle-obstacles/import-runner.ts b/server/src/nest/paddle-obstacles/import-runner.ts new file mode 100644 index 000000000..e4e63abe8 --- /dev/null +++ b/server/src/nest/paddle-obstacles/import-runner.ts @@ -0,0 +1,97 @@ +import { type ImportOutcome, type ImportPhase, importExtract } from './paddle-import'; + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Worker } from 'node:worker_threads'; + +/** Reads a region's extract; in a thread of its own where the build ships the script, else on the calling thread. */ +export interface ImportRunner { + run( + path: string, + regionId: string, + onProgress: (phase: ImportPhase, fraction: number | null) => void, + signal: AbortSignal, + ): Promise; +} + +export const PADDLE_IMPORT_RUNNER = Symbol('PADDLE_IMPORT_RUNNER'); + +/** The thread's heap: a large region's read builds its network in this much, and fails cleanly when it does not fit. */ +const IMPORT_HEAP_MB = 1536; + +export class InlineImportRunner implements ImportRunner { + async run( + path: string, + regionId: string, + onProgress: (phase: ImportPhase, fraction: number | null) => void, + signal: AbortSignal, + ): Promise { + const outcome = await importExtract(path, regionId, onProgress); + signal.throwIfAborted(); + return outcome; + } +} + +/** + * The read in a worker thread: the server's thread stays free while a large + * region takes minutes, and a read that runs out of memory ends its thread, + * not the server. Calling it off ends the thread at once. + */ +export class WorkerImportRunner implements ImportRunner { + constructor(private readonly entry: string) {} + + async run( + path: string, + regionId: string, + onProgress: (phase: ImportPhase, fraction: number | null) => void, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const execArgv = process.execArgv.filter( + (arg, index, all) => + arg === '--require' || arg === '-r' || all[index - 1] === '--require' || all[index - 1] === '-r', + ); + const worker = new Worker(this.entry, { + workerData: { path, regionId }, + execArgv, + resourceLimits: { maxOldGenerationSizeMb: IMPORT_HEAP_MB }, + }); + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (action: () => void) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', abort); + action(); + void worker.terminate(); + }; + const abort = () => + finish(() => reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))); + signal.addEventListener('abort', abort); + worker.on( + 'message', + (message: { + progress?: { phase: ImportPhase; fraction: number | null }; + outcome?: ImportOutcome; + error?: string; + }) => { + if (message.progress) onProgress(message.progress.phase, message.progress.fraction); + else if (message.outcome) { + const outcome = message.outcome; + if (outcome.graph) outcome.graph = { ...outcome.graph, bytes: new Uint8Array(outcome.graph.bytes) }; + finish(() => resolve(outcome)); + } else finish(() => reject(new Error(message.error ?? 'the read failed'))); + }, + ); + worker.on('error', (err) => finish(() => reject(err))); + worker.on('exit', (code) => + finish(() => reject(new Error(`the read ended early (${code}); a smaller region may fit`))), + ); + }); + } +} + +export function importRunner(): ImportRunner { + const entry = join(__dirname, 'paddle-import.worker.js'); + return existsSync(entry) ? new WorkerImportRunner(entry) : new InlineImportRunner(); +} diff --git a/server/src/nest/paddle-obstacles/node-store.ts b/server/src/nest/paddle-obstacles/node-store.ts new file mode 100644 index 000000000..a45c8161e --- /dev/null +++ b/server/src/nest/paddle-obstacles/node-store.ts @@ -0,0 +1,79 @@ +/** + * The positions of the nodes a read needs, held as sorted typed arrays rather + * than a Set and a Map: a lake-rich region asks for millions of nodes, which + * as objects would cost several times the memory and run into V8's limit on + * the size of a Map. Ids are collected first, then sorted once; the pass over + * the file's nodes fills in their positions at 1e-7 degrees, OSM's own + * resolution. + */ +export class NodeStore { + private ids = new Float64Array(1024); + private count = 0; + private sealed = false; + private lats = new Int32Array(0); + private lons = new Int32Array(0); + private found = new Uint8Array(0); + + get size(): number { + return this.count; + } + + want(id: number): void { + if (this.sealed) throw new Error('NodeStore is sealed'); + if (this.count === this.ids.length) { + const grown = new Float64Array(this.ids.length * 2); + grown.set(this.ids); + this.ids = grown; + } + this.ids[this.count++] = id; + } + + wantAll(ids: readonly number[]): void { + for (const id of ids) this.want(id); + } + + /** Sorts and dedupes the ids; from here on positions can be filled in. */ + seal(): void { + if (this.sealed) return; + const sorted = this.ids.subarray(0, this.count).sort(); + let unique = 0; + for (let i = 0; i < sorted.length; i++) { + if (i === 0 || sorted[i] !== sorted[unique - 1]) sorted[unique++] = sorted[i] as number; + } + this.ids = sorted.slice(0, unique); + this.count = unique; + this.lats = new Int32Array(unique); + this.lons = new Int32Array(unique); + this.found = new Uint8Array(unique); + this.sealed = true; + } + + private indexOf(id: number): number { + let low = 0; + let high = this.count - 1; + while (low <= high) { + const mid = (low + high) >>> 1; + const at = this.ids[mid] as number; + if (at === id) return mid; + if (at < id) low = mid + 1; + else high = mid - 1; + } + return -1; + } + + /** Records a node's position if the read wants it. */ + offer(id: number, lat: number, lon: number): void { + const index = this.indexOf(id); + if (index < 0) return; + this.lats[index] = Math.round(lat * 1e7); + this.lons[index] = Math.round(lon * 1e7); + this.found[index] = 1; + } + + /** [lat, lon] of a node, or null when the file did not hold it. */ + get(id: number): [number, number] | null { + const index = this.indexOf(id); + if (index < 0 || this.found[index] === 0) return null; + return [(this.lats[index] as number) / 1e7, (this.lons[index] as number) / 1e7]; + } +} diff --git a/server/src/nest/paddle-obstacles/osm-pbf.reader.ts b/server/src/nest/paddle-obstacles/osm-pbf.reader.ts index 3028355ef..7356189a8 100644 --- a/server/src/nest/paddle-obstacles/osm-pbf.reader.ts +++ b/server/src/nest/paddle-obstacles/osm-pbf.reader.ts @@ -5,8 +5,9 @@ import { inflateSync } from 'node:zlib'; * A reader for OpenStreetMap's PBF format, as Geofabrik's regional extracts * come: the file is a run of blobs, each a length-prefixed header and a * (usually zlib-compressed) block. A block holds its own string table and - * groups of nodes (packed as "dense" nodes) and ways. Relations are not read: - * every object PAD-05A evaluates is a node or a way. + * groups of nodes (packed as "dense" nodes), ways and relations. A visitor + * names what it wants, and only that is decoded: a pass that needs nothing + * but positions takes them without the nodes' tags. * * Only what the paddle data needs is decoded, and nothing is kept between * blocks, so a region of any size is read in the memory of one block (a few @@ -36,12 +37,25 @@ export interface OsmWay { tags: Record; } +type OsmMemberType = 'node' | 'way' | 'relation'; + +export interface OsmRelation { + id: number; + members: Array<{ type: OsmMemberType; ref: number; role: string }>; + tags: Record; +} + export interface OsmBlockVisitor { /** Every node of the block, tagged or not; `tags` is empty for most. */ node?: (node: OsmNode) => void; + /** Every node's position alone, its tags left undecoded; ignored when `node` is given. */ + coord?: (id: number, lat: number, lon: number) => void; way?: (way: OsmWay) => void; + relation?: (relation: OsmRelation) => void; } +const MEMBER_TYPES: readonly OsmMemberType[] = ['node', 'way', 'relation']; + /** A protobuf field walker over one buffer, enough for the OSM schema. */ class Proto { pos: number; @@ -189,6 +203,28 @@ function tagsOf( return tags; } +function readDenseCoords(buf: Buffer, ctx: BlockContext, visit: NonNullable): void { + const p = new Proto(buf); + let ids: number[] = []; + let lats: number[] = []; + let lons: number[] = []; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1) ids = p.packed(true); + else if (key.field === 8) lats = p.packed(true); + else if (key.field === 9) lons = p.packed(true); + else p.skip(key.wire); + } + let id = 0; + let lat = 0; + let lon = 0; + for (let index = 0; index < ids.length; index++) { + id += ids[index] as number; + lat += lats[index] ?? 0; + lon += lons[index] ?? 0; + visit(id, (ctx.latOffset + ctx.granularity * lat) * 1e-9, (ctx.lonOffset + ctx.granularity * lon) * 1e-9); + } +} + function readDense(buf: Buffer, ctx: BlockContext, visit: (node: OsmNode) => void): void { const p = new Proto(buf); let ids: number[] = []; @@ -267,7 +303,36 @@ function readWay(buf: Buffer, strings: readonly string[]): OsmWay { return { id, refs, tags: tagsOf(keys, values, strings) }; } -/** One data block: its string table, then each group's nodes and ways handed to the visitor. */ +function readRelation(buf: Buffer, strings: readonly string[]): OsmRelation { + const p = new Proto(buf); + let id = 0; + let keys: number[] = []; + let values: number[] = []; + let roles: number[] = []; + let memberDeltas: number[] = []; + let types: number[] = []; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1) id = p.varint(); + else if (key.field === 2) keys = p.packed(false); + else if (key.field === 3) values = p.packed(false); + else if (key.field === 8) roles = p.packed(false); + else if (key.field === 9) memberDeltas = p.packed(true); + else if (key.field === 10) types = p.packed(false); + else p.skip(key.wire); + } + let ref = 0; + const members = memberDeltas.map((delta, index) => { + ref += delta; + return { + type: MEMBER_TYPES[types[index] ?? 0] ?? 'node', + ref, + role: strings[roles[index] ?? 0] ?? '', + }; + }); + return { id, members, tags: tagsOf(keys, values, strings) }; +} + +/** One data block: its string table, then each group's nodes, ways and relations handed to the visitor. */ export function readDataBlock(block: Buffer, visitor: OsmBlockVisitor): void { const p = new Proto(block); const strings: string[] = []; @@ -286,17 +351,26 @@ export function readDataBlock(block: Buffer, visitor: OsmBlockVisitor): void { else if (key.field === 20 && key.wire === 0) ctx.lonOffset = p.int64(); else p.skip(key.wire); } + const { node, coord, way, relation } = visitor; for (const group of groups) { const g = new Proto(group); for (let key = g.key(); key; key = g.key()) { - if (key.field === 1 && key.wire === 2 && visitor.node) visitor.node(readNode(g.bytes(), ctx)); - else if (key.field === 2 && key.wire === 2 && visitor.node) readDense(g.bytes(), ctx, visitor.node); - else if (key.field === 3 && key.wire === 2 && visitor.way) visitor.way(readWay(g.bytes(), strings)); + if (key.wire !== 2) g.skip(key.wire); + else if (key.field === 1 && (node || coord)) visitNode(readNode(g.bytes(), ctx), visitor); + else if (key.field === 2 && node) readDense(g.bytes(), ctx, node); + else if (key.field === 2 && coord) readDenseCoords(g.bytes(), ctx, coord); + else if (key.field === 3 && way) way(readWay(g.bytes(), strings)); + else if (key.field === 4 && relation) relation(readRelation(g.bytes(), strings)); else g.skip(key.wire); } } } +function visitNode(read: OsmNode, visitor: OsmBlockVisitor): void { + if (visitor.node) visitor.node(read); + else visitor.coord?.(read.id, read.lat, read.lon); +} + /** * The bytes read from a file and not yet taken as a blob, kept as the chunks * they arrived in and joined only when a blob reaches across chunks. diff --git a/server/src/nest/paddle-obstacles/paddle-extract.ts b/server/src/nest/paddle-obstacles/paddle-extract.ts index aa191349d..2911b05bb 100644 --- a/server/src/nest/paddle-obstacles/paddle-extract.ts +++ b/server/src/nest/paddle-obstacles/paddle-extract.ts @@ -1,3 +1,4 @@ +import { NodeStore } from './node-store'; import { readPbfFile, type OsmWay } from './osm-pbf.reader'; import { keptTags, paddleKindOf, paddleNameOf } from './paddle-tags'; import { compareCodeUnits, type PaddleGeometry, type PaddleObstacleKind } from '@trek/shared'; @@ -6,10 +7,11 @@ import { createHash } from 'node:crypto'; import { stat } from 'node:fs/promises'; /** - * PAD-05A: the paddle objects of one regional extract, read in two passes. - * The first keeps every tagged node that is one, and the ways that are, with - * the ids of their nodes; the second looks those nodes' positions up. Only - * the nodes a kept way needs are held, so a region is read in bounded memory. + * PAD-05A: the paddle objects of one regional extract. The first pass keeps + * every tagged node that is one, and the ways that are, with the ids of their + * nodes; the last looks those nodes' positions up. Only the nodes a kept way + * needs are held, as sorted typed arrays, so a region is read in bounded + * memory. */ /** A way longer than this keeps every n-th node so its line stays readable; its ends are always kept. */ @@ -91,13 +93,10 @@ function build( }; } -function wayFeature( - pending: PendingWay, - coords: ReadonlyMap, -): ExtractedFeature | null { - const points = pending.way.refs.flatMap((ref) => { - const at = coords.get(ref); - return at ? [at] : []; +function wayFeature(pending: PendingWay, coordOf: ExtractResult['coordOf']): ExtractedFeature | null { + const points = pending.way.refs.flatMap((ref): Array => { + const at = coordOf(ref); + return at ? [[at[1], at[0]]] : []; }); if (points.length < 2) return null; const first = points[0] as readonly [number, number]; @@ -153,57 +152,88 @@ export function groupLockParts(features: ExtractedFeature[]): void { } /** - * Every paddle object of the extract at `path`, and the date its data is - * current to. `onProgress` hears the share of the work done: the first pass - * is the first half, the lookup of the ways' nodes the second. + * What else a read collects from the same passes: the water network's lines, + * areas and barriers (PAD-11). It names the node ids it needs through `want` + * and the member ways of its multipolygons through `neededWays`; the read + * fetches both and hands back a lookup of the positions. + */ +export interface ExtractSink { + node(id: number, tags: Readonly>): void; + way(id: number, refs: readonly number[], tags: Readonly>): void; + relation( + id: number, + members: ReadonlyArray<{ type: string; ref: number; role: string }>, + tags: Readonly>, + ): void; + memberWay(id: number, refs: readonly number[]): void; + readonly neededWays: ReadonlySet; +} + +export interface ExtractResult { + timestamp: number | null; + features: ExtractedFeature[]; + /** [lat, lon] of every node a kept way or the sink asked for. */ + coordOf: (nodeId: number) => [number, number] | null; +} + +/** + * Every paddle object of the extract at `path`, the date its data is current + * to, and whatever `sinkFor` collects alongside. The first pass reads every + * tagged element; a second, only when the sink kept multipolygons, reads the + * ways that make them up; the last fetches the positions of every node + * asked for. `onProgress` hears the share of the passes done. */ export async function extractPaddleFeatures( path: string, onProgress?: (fraction: number) => void, -): Promise<{ timestamp: number | null; features: ExtractedFeature[] }> { + sinkFor?: (want: (nodeId: number) => void) => ExtractSink, +): Promise { const { size } = await stat(path); - const progressOf = (pass: number) => (bytesRead: number) => - onProgress?.(size > 0 ? (pass + Math.min(1, bytesRead / size)) / 2 : 1); + const store = new NodeStore(); + const sink = sinkFor?.((id) => store.want(id)); + // With a sink there may be a pass over the member ways between the two. + const passes = sink ? 3 : 2; + let pass = 0; + const progressOf = () => (bytesRead: number) => + onProgress?.(size > 0 ? (pass + Math.min(1, bytesRead / size)) / passes : 1); const features: ExtractedFeature[] = []; const pending: PendingWay[] = []; - const needed = new Set(); const { timestamp } = await readPbfFile( path, { node: (node) => { if (Object.keys(node.tags).length === 0) return; + sink?.node(node.id, node.tags); const kind = paddleKindOf(node.tags); if (!kind) return; const point: [number, number] = [node.lon, node.lat]; features.push(build(`node/${node.id}`, kind, node.tags, { type: 'Point', coordinates: point }, [point])); }, way: (way) => { + sink?.way(way.id, way.refs, way.tags); const kind = paddleKindOf(way.tags); if (!kind) return; const refs = thinned(way.refs); pending.push({ way: { ...way, refs }, kind }); - for (const ref of refs) needed.add(ref); + store.wantAll(refs); }, + relation: sink ? (relation) => sink.relation(relation.id, relation.members, relation.tags) : undefined, }, - progressOf(0), + progressOf(), ); - if (pending.length > 0) { - const coords = new Map(); - await readPbfFile( - path, - { - node: (node) => { - if (needed.has(node.id)) coords.set(node.id, [node.lon, node.lat]); - }, - }, - progressOf(1), - ); - for (const way of pending) { - const feature = wayFeature(way, coords); - if (feature) features.push(feature); - } + if (sink && sink.neededWays.size > 0) { + pass = 1; + await readPbfFile(path, { way: (way) => sink.memberWay(way.id, way.refs) }, progressOf()); + } + pass = passes - 1; + store.seal(); + if (store.size > 0) await readPbfFile(path, { coord: (id, lat, lon) => store.offer(id, lat, lon) }, progressOf()); + const coordOf = (id: number): [number, number] | null => store.get(id); + for (const way of pending) { + const feature = wayFeature(way, coordOf); + if (feature) features.push(feature); } onProgress?.(1); groupLockParts(features); - return { timestamp, features }; + return { timestamp, features, coordOf }; } diff --git a/server/src/nest/paddle-obstacles/paddle-import.ts b/server/src/nest/paddle-obstacles/paddle-import.ts new file mode 100644 index 000000000..e35f469ee --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-import.ts @@ -0,0 +1,33 @@ +import { buildRegionGraph, type BuiltWaterGraph, WaterCollector } from '../paddle-routing/water-graph.contract'; +import { extractPaddleFeatures, type ExtractedFeature } from './paddle-extract'; + +export type ImportPhase = 'read' | 'build'; + +export interface ImportOutcome { + timestamp: number | null; + features: ExtractedFeature[]; + /** The region's water network for routing (PAD-11); null when the extract holds no water it routes on. */ + graph: BuiltWaterGraph | null; +} + +/** + * One region's read: its paddle obstacles and, from the same passes, the + * water network the router plans on. Pure work on a file, so it runs in a + * thread of its own and the server keeps answering meanwhile. + */ +export async function importExtract( + path: string, + regionId: string, + onProgress: (phase: ImportPhase, fraction: number | null) => void, +): Promise { + let collector: WaterCollector | null = null; + const { timestamp, features, coordOf } = await extractPaddleFeatures( + path, + (fraction) => onProgress('read', fraction), + (want) => (collector = new WaterCollector(want)), + ); + onProgress('build', null); + const sourceTimestamp = timestamp === null ? null : new Date(timestamp * 1000).toISOString(); + const graph = collector ? buildRegionGraph(collector, coordOf, { regionId, sourceTimestamp }) : null; + return { timestamp, features, graph }; +} diff --git a/server/src/nest/paddle-obstacles/paddle-import.worker.ts b/server/src/nest/paddle-obstacles/paddle-import.worker.ts new file mode 100644 index 000000000..6844f42aa --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-import.worker.ts @@ -0,0 +1,12 @@ +import { importExtract } from './paddle-import'; + +import { parentPort, workerData } from 'node:worker_threads'; + +/** One region's read in a thread of its own; it reports its progress and hands the outcome back, then ends. */ +const { path, regionId } = workerData as { path: string; regionId: string }; + +importExtract(path, regionId, (phase, fraction) => parentPort?.postMessage({ progress: { phase, fraction } })).then( + // Copied, not transferred: a small Buffer shares Node's pool, which a transfer would take from this thread. + (outcome) => parentPort?.postMessage({ outcome }), + (err: unknown) => parentPort?.postMessage({ error: err instanceof Error ? err.message : String(err) }), +); diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts index 322dad6c4..9419985ca 100644 --- a/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts @@ -8,15 +8,24 @@ import { AdminGuard } from '../auth-core/admin.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { ResponseContract } from '../common/response-contract'; -import { PaddleObstaclesQueryDto, PaddleRegionCatalogQueryDto, PaddleRegionRequestDto } from './paddle-obstacles.dto'; +import { + PaddleObstaclesQueryDto, + PaddleRegionCatalogQueryDto, + PaddleRegionLoadRequestDto, + PaddleRegionRequestDto, + PaddleRegionSettingsDto, +} from './paddle-obstacles.dto'; import { PaddleObstaclesService } from './paddle-obstacles.service'; +import { PaddleRegionLoadService } from './paddle-region-load.service'; import { PaddleRegionsService } from './paddle-regions.service'; -import { Body, Controller, Get, HttpCode, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Post, Put, Query, Req, UseGuards } from '@nestjs/common'; import { paddleObstaclesResponseSchema, paddleRegionCatalogResponseSchema, paddleRegionListResponseSchema, + paddleRegionLoadResponseSchema, paddleRegionSchema, + paddleRegionSettingsSchema, successResponseSchema, } from '@trek/shared'; @@ -26,39 +35,84 @@ import type { Request } from 'express'; * GET /api/paddle/obstacles?bbox=: the paddle objects of a map area and its * coverage (PAD-05A), for anyone signed in while Tours is on. The data is * OpenStreetMap's, read from regional extracts, not trip data. + * + * POST /api/paddle/regions/load: the water data around places no region + * covers yet (PAD-11C), loaded or asked for as the admin's setting says. + * Asking again follows the load; it never starts a second one. */ @Controller('api/paddle') @UseGuards(AddonGuard, JwtAuthGuard) @RequireAddon(ADDON_IDS.TOURS, 'Tours') export class PaddleObstaclesController { - constructor(private readonly obstacles: PaddleObstaclesService) {} + constructor( + private readonly obstacles: PaddleObstaclesService, + private readonly load: PaddleRegionLoadService, + ) {} @Get('obstacles') @ResponseContract(paddleObstaclesResponseSchema) async inBox(@Query() query: PaddleObstaclesQueryDto) { return await this.obstacles.inBox(query.bbox); } + + @Post('regions/load') + @HttpCode(200) + @ResponseContract(paddleRegionLoadResponseSchema) + async loadRegions(@CurrentUser() user: User, @Body() body: PaddleRegionLoadRequestDto) { + return { points: await this.load.load(user, body) }; + } } /** * /api/admin/paddle-regions: which regions TREK reads paddle data for. The - * list, Geofabrik's catalogue to pick from, adding (which starts the first - * read), reading again and removing, each change with its audit row. Ids - * travel in the body or the query: some of Geofabrik's hold a slash, and a - * route segment is no place for a choice that names an outside source. + * list (with the regions paddlers asked for), Geofabrik's catalogue to pick + * from, adding (which starts the first read and settles a request), reading + * again, removing, declining a request, and how TREK loads regions by itself, + * each change with its audit row. Ids travel in the body or the query: some + * of Geofabrik's hold a slash, and a route segment is no place for a choice + * that names an outside source. */ @Controller('api/admin/paddle-regions') @UseGuards(JwtAuthGuard, AdminGuard) export class PaddleRegionsController { constructor( private readonly regions: PaddleRegionsService, + private readonly load: PaddleRegionLoadService, private readonly audit: AuditService, ) {} @Get() @ResponseContract(paddleRegionListResponseSchema) async list() { - return { regions: await this.regions.list() }; + return { regions: await this.regions.list(), requests: await this.load.pending() }; + } + + @Get('settings') + @ResponseContract(paddleRegionSettingsSchema) + async settings() { + return await this.load.settings(); + } + + @Put('settings') + @ResponseContract(paddleRegionSettingsSchema) + async updateSettings(@CurrentUser() user: User, @Body() body: PaddleRegionSettingsDto, @Req() req: Request) { + const settings = await this.load.updateSettings(body); + await this.audit.writeAudit({ + userId: user.id, + action: 'admin.paddle_region_settings', + ip: getClientIp(req), + details: { ...settings }, + }); + return settings; + } + + @Post('decline') + @HttpCode(200) + @ResponseContract(successResponseSchema) + async decline(@CurrentUser() user: User, @Body() body: PaddleRegionRequestDto, @Req() req: Request) { + await this.load.decline(body.id); + await this.record(user, 'admin.paddle_region_decline', body.id, req); + return { success: true as const }; } @Get('catalog') diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts index c0c3ca8f7..4becd5def 100644 --- a/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts @@ -1,7 +1,15 @@ -import { paddleObstaclesQuerySchema, paddleRegionCatalogQuerySchema, paddleRegionRequestSchema } from '@trek/shared'; +import { + paddleObstaclesQuerySchema, + paddleRegionCatalogQuerySchema, + paddleRegionLoadRequestSchema, + paddleRegionRequestSchema, + paddleRegionSettingsSchema, +} from '@trek/shared'; import { createZodDto } from 'nestjs-zod'; export class PaddleObstaclesQueryDto extends createZodDto(paddleObstaclesQuerySchema) {} export class PaddleRegionCatalogQueryDto extends createZodDto(paddleRegionCatalogQuerySchema) {} export class PaddleRegionRequestDto extends createZodDto(paddleRegionRequestSchema) {} +export class PaddleRegionLoadRequestDto extends createZodDto(paddleRegionLoadRequestSchema) {} +export class PaddleRegionSettingsDto extends createZodDto(paddleRegionSettingsSchema) {} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts index 7307c48c7..1c18b1808 100644 --- a/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts @@ -1,11 +1,17 @@ +import { AppSettings } from '../../db/entities/AppSettings.entity'; import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity'; +import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity'; import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; import { AddonsModule } from '../addons/addons.module'; import { AuditModule } from '../audit/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PaddleRoutingModule } from '../paddle-routing/paddle-routing.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; import { GeofabrikClient } from './geofabrik.client'; +import { PADDLE_IMPORT_RUNNER, importRunner } from './import-runner'; import { PaddleObstaclesController, PaddleRegionsController } from './paddle-obstacles.controller'; import { PaddleObstaclesService } from './paddle-obstacles.service'; +import { PaddleRegionLoadService } from './paddle-region-load.service'; import { PaddleRegionsJob } from './paddle-regions.job'; import { PaddleRegionsService } from './paddle-regions.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; @@ -14,13 +20,30 @@ import { Module } from '@nestjs/common'; /** * PAD-05A: paddle obstacles from OpenStreetMap. The regions an admin chose, * their background reads from Geofabrik's extracts, and the objects of a map - * area for the planner. PaddleObstaclesService is exported for the tours - * domain's route checks (PAD-06, PAD-09). + * area for the planner. The same read builds the region's water network, + * which it hands to the paddle routing domain (PAD-11). Where a paddler plans + * outside every region, the region around the place is loaded or asked for by + * itself (PAD-11C). PaddleObstaclesService is exported for the tours domain's + * route checks (PAD-06, PAD-09). */ @Module({ - imports: [AddonsModule, AuditModule, SchedulingModule, MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures])], + imports: [ + AddonsModule, + AuditModule, + NotificationsModule, + SchedulingModule, + PaddleRoutingModule, + MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures, PaddleRegionRequests, AppSettings]), + ], controllers: [PaddleObstaclesController, PaddleRegionsController], - providers: [GeofabrikClient, PaddleObstaclesService, PaddleRegionsService, PaddleRegionsJob], + providers: [ + GeofabrikClient, + PaddleObstaclesService, + PaddleRegionsService, + PaddleRegionLoadService, + PaddleRegionsJob, + { provide: PADDLE_IMPORT_RUNNER, useFactory: importRunner }, + ], exports: [PaddleObstaclesService], }) export class PaddleObstaclesModule {} diff --git a/server/src/nest/paddle-obstacles/paddle-region-load.service.ts b/server/src/nest/paddle-obstacles/paddle-region-load.service.ts new file mode 100644 index 000000000..3ccbe65b1 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-region-load.service.ts @@ -0,0 +1,266 @@ +import { AppSettings } from '../../db/entities/AppSettings.entity'; +import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity'; +import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; +import { PADDLE_REGION_BOUNDARY } from '../../db/json-columns'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import type { PaddleRegionRequestsRepository } from '../../db/repositories/PaddleRegionRequests.repository'; +import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository'; +import { dbNow, utcSuffix } from '../../db/types'; +import type { User } from '../../types'; +import { decodeJson } from '../../utils/json-column'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { readAppSetting } from '../common/app-settings.registry'; +import { DemoService } from '../common/demo.service'; +import { DomainError } from '../common/domain-error'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service'; +import { GeofabrikClient, type GeofabrikRegion } from './geofabrik.client'; +import { boundaryContains, boxOfBoundary } from './paddle-geo'; +import { PaddleRegionsService, REGION_NOT_FOUND_MESSAGE } from './paddle-regions.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + PADDLE_AUTO_MAX_MB, + PADDLE_AUTO_REGIONS_PER_DAY, + paddleRegionAutoModeSchema, + type PaddleRegionLoadRequest, + type PaddleRegionLoadState, + type PaddleRegionPending, + type PaddleRegionSettings, + type PaddleRegionWaitReason, + type PaddleRoutePoint, +} from '@trek/shared'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MB = 1024 * 1024; + +type RegionRef = { id: string; name: string }; + +/** Where a place stands before anything is decided: covered, in a region TREK holds, in one of Geofabrik's, or nowhere. */ +type Place = + | { kind: 'covered'; region: RegionRef } + | { kind: 'held'; row: PaddleRegionRow } + | { kind: 'catalogue'; region: GeofabrikRegion } + | { kind: 'none'; reason: 'no_extract' | 'catalog_unavailable' }; + +function areaOf(box: { south: number; west: number; north: number; east: number }): number { + return (box.north - box.south) * (box.east - box.west); +} + +/** The smallest of the regions around a place, by the area of its box: the most local extract is the cheapest to read. */ +function smallest(candidates: readonly T[], area: (candidate: T) => number): T | null { + return candidates.reduce( + (best, candidate) => (best && area(best) <= area(candidate) ? best : candidate), + null, + ); +} + +function rowArea(row: PaddleRegionRow): number { + if (row.min_lat === null || row.max_lat === null || row.min_lng === null || row.max_lng === null) return Infinity; + return areaOf({ south: row.min_lat, west: row.min_lng, north: row.max_lat, east: row.max_lng }); +} + +function toPending(row: Awaited>[number]): PaddleRegionPending { + return { + id: row.region_id, + name: row.name, + reason: row.reason, + bytes: row.bytes, + requested_by: row.requested_by, + requested_at: utcSuffix(row.requested_at) ?? row.requested_at, + declined: row.declined_at !== null, + }; +} + +/** + * PAD-11C: the water data of a place a paddler plans in, fetched without an + * admin's round trip. The planner asks for the places its unplanned legs end + * at; for each this finds the region that covers it or would, the smallest of + * Geofabrik's regions around it, and per the admin's setting loads it right + * away (within a size and a few a day), files it as a request for the admins, + * or does neither. Asking again is how the planner follows a load, so an + * answer never starts a second one and a request is filed and announced once. + * Only the region's id ever leaves the server, never the place. + */ +@Injectable() +export class PaddleRegionLoadService { + constructor( + private readonly regionsService: PaddleRegionsService, + private readonly geofabrik: GeofabrikClient, + private readonly routing: PaddleRoutingService, + private readonly notifications: NotificationsService, + private readonly demo: DemoService, + @InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository, + @InjectRepository(PaddleRegionRequests) private readonly requests: PaddleRegionRequestsRepository, + @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, + ) {} + + async settings(): Promise { + const mode = paddleRegionAutoModeSchema.safeParse(await readAppSetting(this.appSettings, 'paddle_auto_load')); + const maxMb = Number.parseInt((await readAppSetting(this.appSettings, 'paddle_auto_max_mb')) ?? '', 10); + return { + mode: mode.success ? mode.data : 'auto', + max_mb: + Number.isSafeInteger(maxMb) && maxMb >= PADDLE_AUTO_MAX_MB.min && maxMb <= PADDLE_AUTO_MAX_MB.max + ? maxMb + : PADDLE_AUTO_MAX_MB.default, + }; + } + + async updateSettings(settings: PaddleRegionSettings): Promise { + await this.appSettings.setValue('paddle_auto_load', settings.mode); + await this.appSettings.setValue('paddle_auto_max_mb', String(settings.max_mb)); + return await this.settings(); + } + + async pending(): Promise { + return (await this.requests.list()).map(toPending); + } + + /** An admin declines a request: it stays, marked, so the planner says so and does not ask for it again. */ + async decline(id: string): Promise { + if (!(await this.requests.decline(id))) throw new DomainError(404, REGION_NOT_FOUND_MESSAGE); + } + + /** + * Where the water data around each place stands, in the order asked, after + * starting what the setting allows. Places in one region share one decision. + */ + async load(user: User, request: PaddleRegionLoadRequest): Promise { + const places = await Promise.all(request.points.map((point) => this.placeOf(point))); + const decided = new Map(); + // One after the other: two places in one region must not both add it or file it. + const answers: PaddleRegionLoadState[] = []; + await places.reduce(async (previous, place) => { + await previous; + const key = place.kind === 'none' ? null : place.kind === 'held' ? place.row.id : place.region.id; + const known = key === null ? undefined : decided.get(key); + const answer = known ?? (await this.decide(place, user, request.retry ?? false)); + if (key !== null) decided.set(key, answer); + answers.push(answer); + }, Promise.resolve()); + return answers; + } + + private async placeOf(point: PaddleRoutePoint): Promise { + const covering = await this.routing.regionsCovering(point.lat, point.lng); + const held = (await this.regions.aroundPoint(point.lat, point.lng)).filter((row) => { + const boundary = decodeJson(PADDLE_REGION_BOUNDARY, row.boundary, row.id); + return boundary === null || boundaryContains(boundary, point.lat, point.lng); + }); + // A network reaching the place plans it, whichever region's boundary it lies in. + const coveringRow = + held.find((row) => covering.includes(row.id)) ?? + (covering[0] === undefined ? undefined : await this.regions.findById(covering[0])); + if (coveringRow) return { kind: 'covered', region: { id: coveringRow.id, name: coveringRow.name } }; + const row = smallest(held, rowArea); + if (row) return { kind: 'held', row }; + let catalogue: Map; + try { + catalogue = await this.geofabrik.regions(); + } catch (err) { + logError(`[Paddle] Geofabrik index: ${err instanceof Error ? err.message : String(err)}`); + return { kind: 'none', reason: 'catalog_unavailable' }; + } + const around = [...catalogue.values()].filter( + (region) => region.boundary !== null && boundaryContains(region.boundary, point.lat, point.lng), + ); + // Every region in the list has a boundary of its own; one without is never picked for a place. + const region = smallest(around, (candidate) => + candidate.boundary ? areaOf(boxOfBoundary(candidate.boundary)) : Infinity, + ); + return region ? { kind: 'catalogue', region } : { kind: 'none', reason: 'no_extract' }; + } + + private async decide(place: Place, user: User, retry: boolean): Promise { + switch (place.kind) { + case 'covered': + return { state: 'ready', region: place.region }; + case 'none': + return { state: 'unavailable', region: null, reason: place.reason }; + case 'held': + return await this.decideHeld(place.row, retry); + case 'catalogue': + return await this.decideNew(place.region, user); + } + } + + /** A region TREK holds already: being read, failed, or read and still without water at the place. */ + private async decideHeld(row: PaddleRegionRow, retry: boolean): Promise { + const region = { id: row.id, name: row.name }; + if (row.status === 'queued' || row.status === 'importing') return await this.loading(region); + if (row.status === 'failed' && !retry) return { state: 'failed', region }; + // Read before networks were built (or failed and asked again): the next read builds one. + const networks = await this.routing.networks(); + if (row.status === 'failed' || !networks.has(row.id)) { + await this.regionsService.refresh(row.id); + return await this.loading(region); + } + return { state: 'no_water', region }; + } + + /** A region TREK does not hold: loaded, filed as a request, or neither, as the setting says. */ + private async decideNew(region: GeofabrikRegion, user: User): Promise { + const ref = { id: region.id, name: region.name }; + const { mode, max_mb } = await this.settings(); + // A demo account plans on what is there; it never sets a download going. + if (mode === 'off' || (await this.demo.isDemoUserId(user.id))) + return { state: 'unavailable', region: ref, reason: 'off' }; + const filed = await this.requests.findByRegion(region.id); + if (filed?.declined_at) return { state: 'declined', region: ref }; + // An admin planning is an admin deciding: their region loads at once, whatever its size. + if (user.role === 'admin') return await this.add(ref, 'admin'); + if (filed) return { state: 'requested', region: ref, reason: filed.reason }; + if (mode === 'ask') return await this.request(ref, 'ask', null, user); + const bytes = await this.geofabrik.sizeOf(region.pbfUrl); + if (bytes !== null && bytes > max_mb * MB) return await this.request(ref, 'too_large', bytes, user); + const since = dbNow(new Date(Date.now() - DAY_MS)); + if ((await this.regions.countAddedAutomaticallySince(since)) >= PADDLE_AUTO_REGIONS_PER_DAY) { + return await this.request(ref, 'limit', bytes, user); + } + return await this.add(ref, 'auto'); + } + + private async add(region: RegionRef, origin: 'admin' | 'auto'): Promise { + await this.regionsService.add(region.id, origin); + logInfo(`[Paddle] ${region.id}: added for a paddler's planning (${origin})`); + return await this.loading(region); + } + + private async loading(region: RegionRef): Promise { + return { state: 'loading', region, ...(await this.regionsService.readState(region.id)) }; + } + + /** Files a request and tells the admins, once per region. */ + private async request( + region: RegionRef, + reason: PaddleRegionWaitReason, + bytes: number | null, + user: User, + ): Promise { + const filed = await this.requests.file({ + region_id: region.id, + name: region.name, + reason, + bytes, + requested_by: user.username, + }); + if (filed) await this.announce(region, user); + return { state: 'requested', region, reason }; + } + + /** @txStandalone the notice to the admins stands on its own; a failed one never undoes the request */ + private async announce(region: RegionRef, user: User): Promise { + try { + await this.notifications.send({ + event: 'paddle_region_requested', + actorId: user.id, + scope: 'admin', + targetId: 0, + params: { region: region.name, actor: user.username }, + }); + } catch (err) { + logError(`[Paddle] telling the admins about ${region.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/server/src/nest/paddle-obstacles/paddle-regions.service.ts b/server/src/nest/paddle-obstacles/paddle-regions.service.ts index acc79656c..6c0554c6b 100644 --- a/server/src/nest/paddle-obstacles/paddle-regions.service.ts +++ b/server/src/nest/paddle-obstacles/paddle-regions.service.ts @@ -1,7 +1,9 @@ import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity'; -import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; +import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity'; +import { PaddleRegions, type TPaddleRegionsOrigin } from '../../db/entities/PaddleRegions.entity'; import { PADDLE_FEATURE_GEOMETRY, PADDLE_FEATURE_TAGS, PADDLE_REGION_BOUNDARY } from '../../db/json-columns'; import type { PaddleFeatureRow, PaddleFeaturesRepository } from '../../db/repositories/PaddleFeatures.repository'; +import type { PaddleRegionRequestsRepository } from '../../db/repositories/PaddleRegionRequests.repository'; import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository'; import { dbNow, utcSuffix } from '../../db/types'; import { encodeJson } from '../../utils/json-column'; @@ -10,12 +12,14 @@ import { logError, logInfo } from '../audit/audit-log.logger'; import { DomainError } from '../common/domain-error'; import { withRequestContext } from '../database/request-context'; import { UnitOfWork } from '../database/unit-of-work'; +import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service'; import { GeofabrikClient, type GeofabrikRegion } from './geofabrik.client'; -import { extractPaddleFeatures, type ExtractedFeature } from './paddle-extract'; +import { PADDLE_IMPORT_RUNNER, type ImportRunner } from './import-runner'; +import type { ExtractedFeature } from './paddle-extract'; import { boxOfBoundary } from './paddle-geo'; import { MikroORM } from '@mikro-orm/core'; import { InjectRepository } from '@mikro-orm/nestjs'; -import { Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common'; import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; import { mkdir, rm, unlink } from 'node:fs/promises'; @@ -32,16 +36,22 @@ const STOPPED_AT_SHUTDOWN = 'stopped by the shutdown; read again on the next sta /** What the region being read is doing, for the admin list; held in memory, as one process reads. */ type ReadProgress = NonNullable; -function toRegion(row: PaddleRegionRow, progress: ReadProgress | null = null): PaddleRegion { +function toRegion( + row: PaddleRegionRow, + progress: ReadProgress | null = null, + network: PaddleRegion['network'] = null, +): PaddleRegion { return { id: row.id, name: row.name, status: row.status, + origin: row.origin, source_timestamp: row.source_timestamp, imported_at: utcSuffix(row.imported_at), feature_count: row.feature_count, error: row.error, progress: row.status === 'importing' ? progress : null, + network, }; } @@ -105,10 +115,16 @@ export class PaddleRegionsService implements OnApplicationShutdown { private readonly orm: MikroORM, @InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository, @InjectRepository(PaddleFeatures) private readonly features: PaddleFeaturesRepository, + @InjectRepository(PaddleRegionRequests) private readonly requests: PaddleRegionRequestsRepository, + private readonly routing: PaddleRoutingService, + @Inject(PADDLE_IMPORT_RUNNER) private readonly importer: ImportRunner, ) {} async list(): Promise { - return (await this.regions.list()).map((row) => toRegion(row, this.progressOf(row.id))); + const networks = await this.routing.networks(); + return (await this.regions.list()).map((row) => + toRegion(row, this.progressOf(row.id), networks.get(row.id) ?? null), + ); } /** Regions of Geofabrik's list whose name or id matches, with the regions they lie in. */ @@ -129,8 +145,11 @@ export class PaddleRegionsService implements OnApplicationShutdown { .map((region) => ({ id: region.id, name: region.name, path: path(region.parent) })); } - /** Adds a region and queues its first read; a region already there is read again, as `refresh` does. */ - async add(id: string): Promise { + /** + * Adds a region and queues its first read; a region already there is read + * again, as `refresh` does. A paddler's request for it is settled by adding it. + */ + async add(id: string, origin: TPaddleRegionsOrigin = 'admin'): Promise { if (await this.regions.findById(id)) return await this.refresh(id); const region = (await this.catalogue()).get(id); if (!region) @@ -140,15 +159,19 @@ export class PaddleRegionsService implements OnApplicationShutdown { }); const box = region.boundary ? boxOfBoundary(region.boundary) : null; await this.uow.transactional(async () => { - await this.regions.insertRegion({ - id, - name: region.name, - boundary: region.boundary ? encodeJson(PADDLE_REGION_BOUNDARY, region.boundary) : null, - min_lat: box?.south ?? null, - min_lng: box?.west ?? null, - max_lat: box?.north ?? null, - max_lng: box?.east ?? null, - }); + await this.requests.remove(id); + await this.regions.insertRegion( + { + id, + name: region.name, + boundary: region.boundary ? encodeJson(PADDLE_REGION_BOUNDARY, region.boundary) : null, + min_lat: box?.south ?? null, + min_lng: box?.west ?? null, + max_lat: box?.north ?? null, + max_lng: box?.east ?? null, + }, + origin, + ); await this.regions.updateRegion(id, { status: 'queued', error: null }); }); this.kick(); @@ -224,6 +247,13 @@ export class PaddleRegionsService implements OnApplicationShutdown { return join(this.paths.tmpDir, 'paddle'); } + /** How far a queued or importing region is: its read's progress, and how many regions are read before it. */ + async readState(id: string): Promise<{ progress: ReadProgress | null; ahead: number }> { + if (this.current?.id === id) return { progress: this.progressOf(id), ahead: 0 }; + const waiting = (await this.regions.queued()).findIndex((row) => row.id === id); + return { progress: null, ahead: Math.max(0, waiting) + (this.current ? 1 : 0) }; + } + private progressOf(id: string): ReadProgress | null { return this.current?.id === id ? { ...this.current.progress } : null; } @@ -267,6 +297,8 @@ export class PaddleRegionsService implements OnApplicationShutdown { const dir = this.downloadDir(); // Some of Geofabrik's ids are nested (`us/texas`); the file stays in the one folder. const file = join(dir, `${region.id.replaceAll('/', '_')}-${Date.now()}.osm.pbf`); + // A network written but never recorded (the read failed or was called off after it) goes again. + let graphFile: string | null = null; try { const known = (await this.geofabrik.regions()).get(region.id); if (!known) throw new Error(REGION_UNKNOWN_MESSAGE); @@ -277,10 +309,15 @@ export class PaddleRegionsService implements OnApplicationShutdown { onProgress: (fraction) => (current.progress = { phase: 'download', fraction }), }); current.progress = { phase: 'read', fraction: 0 }; - const { timestamp, features } = await extractPaddleFeatures(file, (fraction) => { - current.progress = { phase: 'read', fraction }; - }); + const { timestamp, features, graph } = await this.importer.run( + file, + region.id, + (phase, fraction) => (current.progress = { phase, fraction }), + abort.signal, + ); abort.signal.throwIfAborted(); + graphFile = graph ? await this.routing.writeGraphFile(region.id, graph) : null; + let previousGraph: string | null = null; const kept = await this.uow.transactional(async () => { if (!(await this.regions.findById(region.id))) return false; await this.features.replaceRegion(region.id, features.map(toRow)); @@ -293,9 +330,12 @@ export class PaddleRegionsService implements OnApplicationShutdown { // Without a boundary the region covers what its objects span, so the map can still tell it is covered. ...(known.boundary ? {} : extentOf(features)), }); + if (graph && graphFile) previousGraph = await this.routing.recordGraph(region.id, graphFile, graph); return true; }); if (!kept) throw new Error(REMOVED_WHILE_READ); + if (graphFile) await this.routing.replaced(previousGraph, graphFile); + graphFile = null; logInfo( `[Paddle] ${region.id}: ${features.length} objects read in ${Math.round((Date.now() - started) / 1000)} s`, ); @@ -311,6 +351,7 @@ export class PaddleRegionsService implements OnApplicationShutdown { } finally { if (this.current === current) this.current = null; await unlink(file).catch(() => undefined); + if (graphFile) await this.routing.discard(graphFile); } } } diff --git a/server/src/nest/paddle-routing/build/build-graph.ts b/server/src/nest/paddle-routing/build/build-graph.ts new file mode 100644 index 000000000..bee4f7e67 --- /dev/null +++ b/server/src/nest/paddle-routing/build/build-graph.ts @@ -0,0 +1,146 @@ +import { EdgeKind, PortageSource, type WaterGraphData } from '../water-graph'; +import { BuildState } from './build-state'; +import type { CollectedWater } from './collect'; +import { attachLakes, rasterizeLakes } from './lakes'; +import { buildLines, joinDanglingEnds, type CoordOf } from './lines'; +import { buildPortages } from './portages'; + +/** + * PAD-11: the water network of a region from what the collector kept. The + * order matters: lines first (with their barriers), then the lakes cut them + * where they enter, then line ends left short are joined, and last the + * barriers get their carries, on the network as it finally runs. + */ +export function buildWaterGraph( + water: CollectedWater, + coordOf: CoordOf, + meta: { regionId: string; sourceTimestamp: string | null }, +): WaterGraphData { + const state = new BuildState(); + buildLines(water, coordOf, state); + const lakes = attachLakes(state, rasterizeLakes(water, coordOf)); + joinDanglingEnds(state); + buildPortages(state, water, coordOf); + return freeze(state, lakes, meta); +} + +function freeze( + state: BuildState, + lakes: WaterGraphData['lakes'], + meta: { regionId: string; sourceTimestamp: string | null }, +): WaterGraphData { + const live = state.live().map(({ edge }) => edge); + // Only nodes an edge or a lake still uses are kept, renumbered in the order they were made. + const used = new Uint8Array(state.nodes.length); + for (const edge of live) { + used[edge.a] = 1; + used[edge.b] = 1; + } + state.nodes.forEach((node, at) => { + if (node.lake >= 0) used[at] = 1; + }); + const renumber = new Int32Array(state.nodes.length).fill(-1); + let count = 0; + used.forEach((flag, at) => { + if (flag) renumber[at] = count++; + }); + + const nodeLat = new Float64Array(count); + const nodeLng = new Float64Array(count); + const nodeLake = new Int32Array(count).fill(-1); + state.nodes.forEach((node, at) => { + const index = renumber[at] as number; + if (index < 0) return; + nodeLat[index] = node.lat; + nodeLng[index] = node.lng; + nodeLake[index] = node.lake; + }); + + const strings: string[] = []; + const stringIndex = new Map(); + const intern = (text: string | null): number => { + if (text === null || text === '') return -1; + const known = stringIndex.get(text); + if (known !== undefined) return known; + strings.push(text); + stringIndex.set(text, strings.length - 1); + return strings.length - 1; + }; + + const n = live.length; + const edgeA = new Uint32Array(n); + const edgeB = new Uint32Array(n); + const edgeLength = new Float32Array(n); + const edgeKind = new Uint8Array(n); + const edgeFlow = new Uint8Array(n); + const edgeCurrent = new Float32Array(n); + const edgeAccess = new Uint8Array(n); + const edgeOneway = new Uint8Array(n); + const edgeGrade = new Uint8Array(n); + const edgeGeomStart = new Uint32Array(n); + const edgeGeomCount = new Uint32Array(n); + const edgeName = new Int32Array(n); + const edgeRef = new Int32Array(n); + const edgeSource = new Uint8Array(n); + const pool = new Float64Array(live.reduce((sum, edge) => sum + edge.line.length * 2, 0)); + const stats = { water_km: 0, lakes: lakes.length, portages: 0, inferred_portages: 0, gaps: 0, locks: 0 }; + // The straight carry beside a lock is an option, not a barrier without a portage; it stays out of the count. + const lockRefs = new Set(live.filter((edge) => edge.kind === EdgeKind.lock).map((edge) => edge.ref)); + let cursor = 0; + live.forEach((edge, index) => { + edgeA[index] = renumber[edge.a] as number; + edgeB[index] = renumber[edge.b] as number; + const length = state.lengthOf(edge); + edgeLength[index] = length; + edgeKind[index] = edge.kind; + edgeFlow[index] = edge.flow; + edgeCurrent[index] = edge.current; + edgeAccess[index] = edge.access; + edgeOneway[index] = edge.oneway; + edgeGrade[index] = edge.grade; + edgeGeomStart[index] = cursor / 2; + edgeGeomCount[index] = edge.line.length; + for (const [lat, lng] of edge.line) { + pool[cursor++] = lat; + pool[cursor++] = lng; + } + edgeName[index] = intern(edge.name); + edgeRef[index] = intern(edge.ref); + edgeSource[index] = edge.source; + if (edge.kind === EdgeKind.water || edge.kind === EdgeKind.connector || edge.kind === EdgeKind.pass) + stats.water_km += length / 1000; + else if (edge.kind === EdgeKind.gap) stats.gaps++; + else if (edge.kind === EdgeKind.portage && !lockRefs.has(edge.ref)) { + if (edge.source === PortageSource.inferred) stats.inferred_portages++; + else stats.portages++; + } + }); + stats.water_km = Math.round(stats.water_km * 10) / 10; + stats.locks = lockRefs.size; + + return { + regionId: meta.regionId, + sourceTimestamp: meta.sourceTimestamp, + nodeLat, + nodeLng, + nodeLake, + edgeA, + edgeB, + edgeLength, + edgeKind, + edgeFlow, + edgeCurrent, + edgeAccess, + edgeOneway, + edgeGrade, + edgeGeomStart, + edgeGeomCount, + edgeName, + edgeRef, + edgeSource, + pool, + strings, + lakes: lakes.map((lake) => ({ ...lake, portals: lake.portals.map((node) => renumber[node] as number) })), + stats, + }; +} diff --git a/server/src/nest/paddle-routing/build/build-state.ts b/server/src/nest/paddle-routing/build/build-state.ts new file mode 100644 index 000000000..4b327e037 --- /dev/null +++ b/server/src/nest/paddle-routing/build/build-state.ts @@ -0,0 +1,241 @@ +import { lineLength, metresBetween, projectOnSegment } from '../engine/geo'; +import { Access, EdgeKind, type EdgeKindCode, PortageSource } from '../water-graph'; + +/** A node while the network is built: where it is, and the lake it opens into (-1 for none). */ +export interface BuildNode { + lat: number; + lng: number; + lake: number; + /** Set on the sides of a weir, dam or waterfall: such a node never opens into a lake. */ + barrier: boolean; +} + +export interface BuildEdge { + a: number; + b: number; + /** [lat, lng] from a to b. */ + line: [number, number][]; + kind: EdgeKindCode; + flow: 0 | 1; + current: number; + access: number; + oneway: 0 | 1; + /** The whitewater grade of the stretch (0 when none is given). */ + grade: number; + name: string | null; + ref: string | null; + source: number; + /** Dropped edges stay in the list as holes, so indices handed out earlier keep meaning the same edge. */ + dead?: boolean; +} + +export interface BarrierSite { + lat: number; + lng: number; + name: string | null; + ref: string; + /** The nodes on its sides, one per water line that meets it. */ + sides: number[]; +} + +/** The network under construction: nodes, edges, and the barriers to carry round. */ +export class BuildState { + readonly nodes: BuildNode[] = []; + readonly edges: BuildEdge[] = []; + readonly barriers: BarrierSite[] = []; + /** The edges at each node, kept up to date as edges are added and cut; dead ones are filtered on reading. */ + private readonly at: number[][] = []; + + addNode(lat: number, lng: number, barrier = false): number { + this.nodes.push({ lat, lng, lake: -1, barrier }); + this.at.push([]); + return this.nodes.length - 1; + } + + addEdge(edge: Omit & { dead?: boolean }): number { + // A copy of a dropped edge comes in alive. + const { dead: _dead, ...alive } = edge; + this.edges.push(alive); + const index = this.edges.length - 1; + this.at[edge.a]?.push(index); + if (edge.b !== edge.a) this.at[edge.b]?.push(index); + return index; + } + + /** The live edges at a node. */ + edgesAt(node: number): number[] { + return (this.at[node] ?? []).filter((edge) => !this.edges[edge]?.dead); + } + + /** A land edge carrying the boat between two nodes along `line`. */ + addPortage( + a: number, + b: number, + line: [number, number][], + source: number, + name: string | null, + ref: string | null, + ): number { + return this.addEdge({ + a, + b, + line, + kind: EdgeKind.portage, + flow: 0, + current: 0, + access: Access.open, + oneway: 0, + grade: 0, + name, + ref, + source, + }); + } + + /** A straight piece of water joining two nodes the data leaves apart: a connector when short, a gap beyond. */ + addLink(a: number, b: number, kind: EdgeKindCode): number { + const from = this.nodes[a] as BuildNode; + const to = this.nodes[b] as BuildNode; + return this.addEdge({ + a, + b, + line: [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + kind, + flow: 0, + current: 0, + access: Access.open, + oneway: 0, + grade: 0, + name: null, + ref: null, + source: PortageSource.tagged, + }); + } + + /** + * Cuts an edge at one of its inner points, which becomes a node; the first + * part keeps the edge's index. Returns the node at the cut (an end node when + * the point is an end). + */ + splitAt(edgeIndex: number, pointIndex: number): number { + const edge = this.edges[edgeIndex] as BuildEdge; + if (pointIndex <= 0) return edge.a; + if (pointIndex >= edge.line.length - 1) return edge.b; + const [lat, lng] = edge.line[pointIndex] as [number, number]; + const node = this.addNode(lat, lng); + const tail: Omit = { ...edge, a: node, line: edge.line.slice(pointIndex) }; + const oldB = edge.b; + edge.line = edge.line.slice(0, pointIndex + 1); + edge.b = node; + this.at[node]?.push(edgeIndex); + if (oldB !== edge.a) this.at[oldB] = (this.at[oldB] ?? []).filter((other) => other !== edgeIndex); + this.addEdge(tail); + return node; + } + + /** + * The node at the point of an edge nearest to a spot, cutting the point into + * the line where it falls between two of its points. + */ + nodeNearest(edgeIndex: number, lat: number, lng: number): number { + const edge = this.edges[edgeIndex] as BuildEdge; + let best = { seg: 0, t: 0, distance: Infinity }; + for (let seg = 0; seg + 1 < edge.line.length; seg++) { + const a = edge.line[seg] as [number, number]; + const b = edge.line[seg + 1] as [number, number]; + const { t, distance } = projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]); + if (distance < best.distance) best = { seg, t, distance }; + } + if (best.t <= 0.02) return this.splitAt(edgeIndex, best.seg); + if (best.t >= 0.98) return this.splitAt(edgeIndex, best.seg + 1); + const a = edge.line[best.seg] as [number, number]; + const b = edge.line[best.seg + 1] as [number, number]; + edge.line.splice(best.seg + 1, 0, [a[0] + (b[0] - a[0]) * best.t, a[1] + (b[1] - a[1]) * best.t]); + return this.splitAt(edgeIndex, best.seg + 1); + } + + /** + * The node `offset` metres along an edge from its end at `node`, cutting a + * point into the line where none stands; the far end when the edge is shorter. + */ + nodeAlong(edgeIndex: number, node: number, offset: number): number { + const edge = this.edges[edgeIndex] as BuildEdge; + const forward = edge.a === node; + const last = edge.line.length - 1; + let travelled = 0; + for (let step = 1; step <= last; step++) { + const fromAt = forward ? step - 1 : last - step + 1; + const toAt = forward ? step : last - step; + const from = edge.line[fromAt] as [number, number]; + const to = edge.line[toAt] as [number, number]; + const length = metresBetween(from[0], from[1], to[0], to[1]); + if (travelled + length >= offset && length > 0) { + const share = (offset - travelled) / length; + if (share >= 0.999) return this.splitAt(edgeIndex, toAt); + const point: [number, number] = [from[0] + (to[0] - from[0]) * share, from[1] + (to[1] - from[1]) * share]; + const insertAt = forward ? step : last - step + 1; + edge.line.splice(insertAt, 0, point); + return this.splitAt(edgeIndex, insertAt); + } + travelled += length; + } + return forward ? edge.b : edge.a; + } + + live(): Array<{ edge: BuildEdge; index: number }> { + return this.edges.flatMap((edge, index) => (edge.dead ? [] : [{ edge, index }])); + } + + /** The live edges at each node, by index. */ + incidence(): number[][] { + return this.nodes.map((_, node) => this.edgesAt(node)); + } + + lengthOf(edge: BuildEdge): number { + return lineLength(edge.line); + } + + distance(a: number, b: number): number { + const p = this.nodes[a] as BuildNode; + const q = this.nodes[b] as BuildNode; + return metresBetween(p.lat, p.lng, q.lat, q.lng); + } +} + +/** Points of a set of lines bucketed on a grid of about 100 m, for the nearest point within a few hundred metres. */ +export class PointGrid { + private readonly cells = new Map>(); + private static readonly DEG = 0.001; + + private keyOf(lat: number, lng: number): string { + return `${Math.floor(lat / PointGrid.DEG)}:${Math.floor(lng / PointGrid.DEG)}`; + } + + add(lat: number, lng: number, item: T): void { + const key = this.keyOf(lat, lng); + const cell = this.cells.get(key); + if (cell) cell.push({ lat, lng, item }); + else this.cells.set(key, [{ lat, lng, item }]); + } + + /** The points within `radiusM`, nearest first. */ + near(lat: number, lng: number, radiusM: number): Array<{ item: T; distance: number; lat: number; lng: number }> { + const reachLat = Math.ceil(radiusM / 111_195 / PointGrid.DEG); + const reachLng = Math.ceil(radiusM / (111_195 * Math.cos((lat * Math.PI) / 180)) / PointGrid.DEG); + const row = Math.floor(lat / PointGrid.DEG); + const col = Math.floor(lng / PointGrid.DEG); + const found: Array<{ item: T; distance: number; lat: number; lng: number }> = []; + for (let r = row - reachLat; r <= row + reachLat; r++) { + for (let c = col - reachLng; c <= col + reachLng; c++) { + for (const point of this.cells.get(`${r}:${c}`) ?? []) { + const distance = metresBetween(lat, lng, point.lat, point.lng); + if (distance <= radiusM) found.push({ item: point.item, distance, lat: point.lat, lng: point.lng }); + } + } + } + return found.sort((x, y) => x.distance - y.distance); + } +} diff --git a/server/src/nest/paddle-routing/build/collect.ts b/server/src/nest/paddle-routing/build/collect.ts new file mode 100644 index 000000000..a52fd4f63 --- /dev/null +++ b/server/src/nest/paddle-routing/build/collect.ts @@ -0,0 +1,201 @@ +/** + * PAD-11: what of an OpenStreetMap extract goes into the water network, read + * in the same pass as the paddle obstacles. The elements arrive as the reader + * gives them; the collector keeps a lean copy of the ones it wants and the + * ids of the nodes and member ways it will need positions and lines for. + * + * - Centre lines: river, canal, fairway, tidal channel, flowline, link and + * canoe pass; a stream only where a boat tag allows it or it is 4 m wide or + * more; a ditch or drain only with canoe=yes. A fish pass never. + * - Lakes: closed ways and multipolygons of natural=water (not the river and + * canal areas, which the centre lines already carry) and of + * landuse=reservoir. + * - Barriers: weirs, dams and waterfalls, as nodes or as lines across a river. + * - Portage ways, for carrying round a barrier. + */ + +export interface CollectedWay { + id: number; + refs: number[]; + tags: Record; +} + +export interface CollectedBarrier { + osmRef: string; + kind: 'weir' | 'dam' | 'waterfall'; + name: string | null; +} + +export interface CollectedArea { + osmRef: string; + name: string | null; + /** A closed way's nodes, or a multipolygon's member ways with their roles. */ + ring?: number[]; + members?: Array<{ way: number; role: string }>; +} + +export interface CollectedWater { + centrelines: CollectedWay[]; + barrierNodes: Map; + barrierWays: Array; + portageWays: CollectedWay[]; + areas: CollectedArea[]; + /** Member ways of the multipolygons, filled in by the second pass. */ + memberWays: Map; +} + +const CENTRELINES = new Set(['river', 'canal', 'fairway', 'tidal_channel', 'flowline', 'link', 'canoe_pass']); +const SMALL = new Set(['stream']); +const DITCHES = new Set(['ditch', 'drain']); +const BOAT_YES = new Set(['yes', 'designated', 'permissive']); +const BARRIERS = new Set(['weir', 'dam', 'waterfall']); +/** Water areas a centre line already carries, or that no one paddles. */ +const NOT_LAKES = new Set(['river', 'canal', 'stream', 'ditch', 'drain', 'wastewater', 'stream_pool', 'moat']); +/** A centre line keeps these tags; the rest of OSM's stays behind. */ +const LINE_TAGS = [ + 'waterway', + 'whitewater:section_grade', + 'name', + 'canoe', + 'kayak', + 'boat', + 'access', + 'oneway:canoe', + 'oneway:boat', + 'intermittent', + 'tunnel', + 'lock', + 'lock_name', +]; + +function boatAllows(tags: Readonly>): boolean { + return ['canoe', 'kayak', 'boat'].some((key) => BOAT_YES.has(tags[key] ?? '')); +} + +function widthOf(tags: Readonly>): number { + const parsed = Number.parseFloat((tags.width ?? '').replace(',', '.')); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** The race of a mill or a power plant: water led to and from its wheel or turbine, nothing to paddle. */ +const RACES = new Set(['headrace', 'tailrace', 'spillway']); + +/** Whether a waterway line belongs in the network. */ +function isNetworkLine(tags: Readonly>): boolean { + const waterway = tags.waterway; + if (waterway === undefined) return false; + if ((RACES.has(tags.usage ?? '') || RACES.has(tags.canal ?? '')) && !boatAllows(tags)) return false; + if (CENTRELINES.has(waterway)) return true; + if (SMALL.has(waterway)) return boatAllows(tags) || widthOf(tags) >= 4; + if (DITCHES.has(waterway)) return tags.canoe === 'yes'; + return false; +} + +/** Whether an area is a lake (or pond, reservoir, oxbow) the network may cross. */ +function isLakeArea(tags: Readonly>): boolean { + if (tags.landuse === 'reservoir' || tags.landuse === 'basin') + return tags.water === undefined || !NOT_LAKES.has(tags.water); + return tags.natural === 'water' && !NOT_LAKES.has(tags.water ?? ''); +} + +function barrierOf(tags: Readonly>, osmRef: string): CollectedBarrier | null { + const waterway = tags.waterway ?? ''; + if (!BARRIERS.has(waterway)) return null; + return { osmRef, kind: waterway as CollectedBarrier['kind'], name: tags.name ?? null }; +} + +function isPortageWay(tags: Readonly>): boolean { + return tags.canoe === 'portage' || tags.portage === 'yes' || tags.whitewater === 'portage_way'; +} + +function lineTags(tags: Readonly>): Record { + const kept: Record = {}; + for (const key of LINE_TAGS) { + const value = tags[key]; + if (value !== undefined) kept[key] = value; + } + return kept; +} + +export class WaterCollector { + readonly water: CollectedWater = { + centrelines: [], + barrierNodes: new Map(), + barrierWays: [], + portageWays: [], + areas: [], + memberWays: new Map(), + }; + + /** Member ways of kept multipolygons, read in a second pass over the ways. */ + readonly neededWays = new Set(); + + /** `want` hears every node id a kept element runs through: their positions are needed. */ + constructor(private readonly want: (nodeId: number) => void) {} + + node(id: number, tags: Readonly>): void { + const barrier = barrierOf(tags, `node/${id}`); + if (barrier) { + this.water.barrierNodes.set(id, barrier); + this.want(id); + } + } + + way(id: number, refs: readonly number[], tags: Readonly>): void { + if (isNetworkLine(tags)) { + this.keep(this.water.centrelines, { id, refs: [...refs], tags: lineTags(tags) }); + return; + } + const barrier = barrierOf(tags, `way/${id}`); + if (barrier) { + this.water.barrierWays.push({ id, refs: [...refs], tags: {}, barrier }); + this.wantAll(refs); + return; + } + if (isPortageWay(tags)) { + this.keep(this.water.portageWays, { + id, + refs: [...refs], + tags: { access: tags.access ?? '', name: tags.name ?? '' }, + }); + return; + } + const closed = refs.length >= 4 && refs[0] === refs[refs.length - 1]; + if (closed && isLakeArea(tags)) { + this.water.areas.push({ osmRef: `way/${id}`, name: tags.name ?? null, ring: [...refs] }); + this.wantAll(refs); + } + } + + relation( + id: number, + members: ReadonlyArray<{ type: string; ref: number; role: string }>, + tags: Readonly>, + ): void { + if (tags.type !== 'multipolygon' || !isLakeArea(tags)) return; + const ways = members.filter((member) => member.type === 'way'); + if (ways.length === 0) return; + this.water.areas.push({ + osmRef: `relation/${id}`, + name: tags.name ?? null, + members: ways.map((member) => ({ way: member.ref, role: member.role })), + }); + for (const member of ways) this.neededWays.add(member.ref); + } + + /** The second pass: a member way's nodes, kept whatever the way's own tags say. */ + memberWay(id: number, refs: readonly number[]): void { + if (!this.neededWays.has(id)) return; + this.water.memberWays.set(id, [...refs]); + this.wantAll(refs); + } + + private keep(list: CollectedWay[], way: CollectedWay): void { + list.push(way); + this.wantAll(way.refs); + } + + private wantAll(refs: readonly number[]): void { + for (const ref of refs) this.want(ref); + } +} diff --git a/server/src/nest/paddle-routing/build/lakes.ts b/server/src/nest/paddle-routing/build/lakes.ts new file mode 100644 index 000000000..da7c54580 --- /dev/null +++ b/server/src/nest/paddle-routing/build/lakes.ts @@ -0,0 +1,332 @@ +import { LocalFrame } from '../engine/geo'; +import type { LakeGrid } from '../water-graph'; +import { type BuildEdge, type BuildState, PointGrid } from './build-state'; +import type { CollectedArea, CollectedWater } from './collect'; +import type { CoordOf } from './lines'; + +/** Ponds below this stay out; above FREE_LAKE_HA a lake stays even without water flowing in. */ +const MIN_LAKE_HA = 0.5; +const FREE_LAKE_HA = 5; +/** The finest grid and the most cells a lake may take; a large lake gets coarser cells. */ +const MIN_CELL_M = 5; +const MAX_CELLS = 800_000; +/** Line points this close to a barrier never count as in a lake, so a dam is never crossed through its reservoir. */ +const BARRIER_CLEARANCE_M = 40; +/** A line that ends this close to a lake's water opens onto the lake. */ +const SHORE_REACH_M = 30; + +type Ring = Array<[number, number]>; + +/** + * The rings of a multipolygon from its member ways, joined end to end by + * their node ids as OSM splits long shores into many ways. A ring that does + * not close is broken data and left out; the others are kept. + */ +function assembleRings( + members: ReadonlyArray<{ refs: number[]; role: string }>, +): Array<{ refs: number[]; inner: boolean }> { + const rings: Array<{ refs: number[]; inner: boolean }> = []; + for (const inner of [false, true]) { + const pieces = members + .filter((member) => (member.role === 'inner') === inner && member.refs.length >= 2) + .map((member) => [...member.refs]); + const byEnd = new Map(); + const index = (piece: number[]) => { + for (const end of [piece[0] as number, piece[piece.length - 1] as number]) + byEnd.set(end, [...(byEnd.get(end) ?? []), piece]); + }; + const unindex = (piece: number[]) => { + for (const end of [piece[0] as number, piece[piece.length - 1] as number]) { + byEnd.set( + end, + (byEnd.get(end) ?? []).filter((other) => other !== piece), + ); + } + }; + pieces.forEach(index); + const used = new Set(); + for (const start of pieces) { + if (used.has(start)) continue; + used.add(start); + unindex(start); + const ring = [...start]; + while (ring[0] !== ring[ring.length - 1]) { + const end = ring[ring.length - 1] as number; + const next = (byEnd.get(end) ?? []).find((piece) => !used.has(piece)); + if (!next) break; + used.add(next); + unindex(next); + ring.push(...(next[0] === end ? next.slice(1) : [...next].reverse().slice(1))); + } + if (ring.length >= 4 && ring[0] === ring[ring.length - 1]) rings.push({ refs: ring, inner }); + } + } + return rings; +} + +function ringsOf( + area: CollectedArea, + water: CollectedWater, + coordOf: CoordOf, +): Array<{ points: Ring; inner: boolean }> { + const refRings = area.ring + ? [{ refs: area.ring, inner: false }] + : assembleRings( + (area.members ?? []).map((member) => ({ refs: water.memberWays.get(member.way) ?? [], role: member.role })), + ); + return refRings.flatMap(({ refs, inner }) => { + const points = refs.flatMap((ref) => { + const at = coordOf(ref); + return at ? [at] : []; + }); + return points.length >= 4 ? [{ points, inner }] : []; + }); +} + +function setBit(mask: Uint8Array, index: number): void { + mask[index >> 3] = (mask[index >> 3] as number) | (1 << (index & 7)); +} + +export function maskHas(lake: Pick, col: number, row: number): boolean { + if (col < 0 || row < 0 || col >= lake.width || row >= lake.height) return false; + const index = row * lake.width + col; + return ((lake.mask[index >> 3] as number) & (1 << (index & 7))) !== 0; +} + +/** + * A lake as a grid of water cells. A cell is water when its centre lies in + * the lake (outer rings minus islands), and also when a shore line passes + * through it, so a narrow channel a cell wide stays open; the router keeps + * its distance from the shore, not the grid. + */ +function rasterizeLake(rings: Array<{ points: Ring; inner: boolean }>, name: string | null): LakeGrid | null { + const outer = rings.filter((ring) => !ring.inner); + if (outer.length === 0) return null; + let south = 90; + let west = 180; + let north = -90; + let east = -180; + for (const ring of outer) { + for (const [lat, lng] of ring.points) { + south = Math.min(south, lat); + north = Math.max(north, lat); + west = Math.min(west, lng); + east = Math.max(east, lng); + } + } + const frame = new LocalFrame(south, west); + const spanX = frame.x(east); + const spanY = frame.y(north); + const cell = Math.max(MIN_CELL_M, Math.sqrt((spanX * spanY) / MAX_CELLS)); + const width = Math.ceil(spanX / cell) + 1; + const height = Math.ceil(spanY / cell) + 1; + const mask = new Uint8Array(Math.ceil((width * height) / 8)); + const local = rings.map((ring) => ring.points.map(([lat, lng]) => [frame.x(lng), frame.y(lat)] as [number, number])); + + // Scanlines: every ring crossing of a row's centre line, filled pairwise (even-odd, so islands stay land). + const crossings: number[][] = Array.from({ length: height }, () => []); + let area = 0; + local.forEach((points, index) => { + let ringArea = 0; + for (let i = 1; i < points.length; i++) { + const [x1, y1] = points[i - 1] as [number, number]; + const [x2, y2] = points[i] as [number, number]; + ringArea += x1 * y2 - x2 * y1; + if (y1 === y2) continue; + const low = Math.min(y1, y2); + const high = Math.max(y1, y2); + for (let row = Math.max(0, Math.ceil(low / cell - 0.5)); row < height; row++) { + const y = (row + 0.5) * cell; + if (y >= high) break; + if (y < low) continue; + (crossings[row] as number[]).push(x1 + ((y - y1) * (x2 - x1)) / (y2 - y1)); + } + } + area += (rings[index]?.inner ? -1 : 1) * Math.abs(ringArea / 2); + }); + const hectares = area / 10_000; + if (hectares < MIN_LAKE_HA) return null; + crossings.forEach((xs, row) => { + xs.sort((a, b) => a - b); + for (let i = 0; i + 1 < xs.length; i += 2) { + const from = Math.max(0, Math.ceil((xs[i] as number) / cell - 0.5)); + const to = Math.min(width - 1, Math.floor((xs[i + 1] as number) / cell - 0.5)); + for (let col = from; col <= to; col++) setBit(mask, row * width + col); + } + }); + // Shore lines mark the cells they pass through. + for (const points of local) { + for (let i = 1; i < points.length; i++) { + const [x1, y1] = points[i - 1] as [number, number]; + const [x2, y2] = points[i] as [number, number]; + const steps = Math.max(1, Math.ceil(Math.hypot(x2 - x1, y2 - y1) / (cell / 2))); + for (let step = 0; step <= steps; step++) { + const col = Math.floor((x1 + ((x2 - x1) * step) / steps) / cell); + const row = Math.floor((y1 + ((y2 - y1) * step) / steps) / cell); + if (col >= 0 && row >= 0 && col < width && row < height) setBit(mask, row * width + col); + } + } + } + return { name, lat0: south, lng0: west, cell, width, height, mask, portals: new Int32Array(0), hectares }; +} + +/** The lakes on a coarse grid of their boxes, for asking which lake a point lies in. */ +export class LakeIndex { + private readonly cells = new Map(); + private static readonly DEG = 0.02; + + constructor(private readonly lakes: readonly LakeGrid[]) { + lakes.forEach((lake, index) => { + const frame = new LocalFrame(lake.lat0, lake.lng0); + const north = frame.lat(lake.height * lake.cell); + const east = frame.lng(lake.width * lake.cell); + for (let r = Math.floor(lake.lat0 / LakeIndex.DEG); r <= Math.floor(north / LakeIndex.DEG); r++) { + for (let c = Math.floor(lake.lng0 / LakeIndex.DEG); c <= Math.floor(east / LakeIndex.DEG); c++) { + const key = `${r}:${c}`; + const cell = this.cells.get(key); + if (cell) cell.push(index); + else this.cells.set(key, [index]); + } + } + }); + } + + /** The first lake whose water cell holds the point, or -1. */ + at(lat: number, lng: number): number { + for (const index of this.cells.get(`${Math.floor(lat / LakeIndex.DEG)}:${Math.floor(lng / LakeIndex.DEG)}`) ?? []) { + const lake = this.lakes[index] as LakeGrid; + const frame = new LocalFrame(lake.lat0, lake.lng0); + if (maskHas(lake, Math.floor(frame.x(lng) / lake.cell), Math.floor(frame.y(lat) / lake.cell))) return index; + } + return -1; + } +} + +export function rasterizeLakes(water: CollectedWater, coordOf: CoordOf): LakeGrid[] { + return water.areas.flatMap((area) => { + const lake = rasterizeLake(ringsOf(area, water, coordOf), area.name); + return lake ? [lake] : []; + }); +} + +/** + * Cuts the lines where they enter and leave a lake: what runs on land stays, + * reaching one point into the lake, where a portal node opens onto it; what + * runs inside is dropped, since the lake's own grid routes across it. Then + * lakes nothing opens into and smaller than FREE_LAKE_HA go. + */ +export function attachLakes(state: BuildState, lakes: LakeGrid[]): LakeGrid[] { + const index = new LakeIndex(lakes); + const barrierGrid = new PointGrid(); + state.barriers.forEach((site, at) => barrierGrid.add(site.lat, site.lng, at)); + const nearBarrier = (lat: number, lng: number) => barrierGrid.near(lat, lng, BARRIER_CLEARANCE_M).length > 0; + const lakeOfPoint = (lat: number, lng: number) => (nearBarrier(lat, lng) ? -1 : index.at(lat, lng)); + + const count = state.edges.length; + for (let e = 0; e < count; e++) { + const edge = state.edges[e] as BuildEdge; + if (edge.dead) continue; + const inside = edge.line.map(([lat, lng], at) => { + const endNode = at === 0 ? edge.a : at === edge.line.length - 1 ? edge.b : -1; + if (endNode >= 0 && state.nodes[endNode]?.barrier) return -1; + return lakeOfPoint(lat, lng); + }); + if (inside.every((lake) => lake < 0)) continue; + edge.dead = true; + // One node per point, so a point that both ends a run and starts a crossing into the next lake is one portal. + const portals = new Map(); + const portalAt = (point: number): number => { + const known = portals.get(point); + if (known !== undefined) return known; + let node: number; + if (point === 0) node = markPortal(state, edge.a, inside[0] as number); + else if (point === edge.line.length - 1) node = markPortal(state, edge.b, inside[point] as number); + else { + const [lat, lng] = edge.line[point] as [number, number]; + node = markPortal(state, state.addNode(lat, lng), inside[point] as number); + } + portals.set(point, node); + return node; + }; + let runStart = -1; + for (let point = 0; point <= edge.line.length; point++) { + const outside = point < edge.line.length && (inside[point] as number) < 0; + if (outside && runStart < 0) runStart = point; + if (!outside && runStart >= 0) { + // The run reaches one point into the water on each side it touches a lake. + const first = runStart > 0 ? runStart - 1 : 0; + const last = point < edge.line.length ? point : point - 1; + if (last > first) { + const a = first === 0 && runStart === 0 ? edge.a : portalAt(first); + const b = last === edge.line.length - 1 && point === edge.line.length ? edge.b : portalAt(last); + state.addEdge({ ...edge, a, b, line: edge.line.slice(first, last + 1) }); + } + runStart = -1; + } + } + // Where the line passes straight from one lake into the next (two waters mapped edge to edge), + // the step between them joins the two lakes' portals. + for (let point = 1; point < edge.line.length; point++) { + const from = inside[point - 1] as number; + const to = inside[point] as number; + if (from < 0 || to < 0 || from === to) continue; + state.addEdge({ + ...edge, + a: portalAt(point - 1), + b: portalAt(point), + line: edge.line.slice(point - 1, point + 1), + }); + } + // Ends inside a lake open onto it even when nothing of the edge stays. + if ((inside[0] as number) >= 0) markPortal(state, edge.a, inside[0] as number); + const lastPoint = edge.line.length - 1; + if ((inside[lastPoint] as number) >= 0) markPortal(state, edge.b, inside[lastPoint] as number); + } + + // Line ends that stop just short of a lake's water open onto it too. + const incidence = state.incidence(); + state.nodes.forEach((node, at) => { + if (node.lake >= 0 || node.barrier || (incidence[at] as number[]).length !== 1) return; + const lake = lakeNear(index, node.lat, node.lng); + if (lake >= 0) markPortal(state, at, lake); + }); + + const portals = lakes.map(() => [] as number[]); + state.nodes.forEach((node, at) => { + if (node.lake >= 0) portals[node.lake]?.push(at); + }); + const kept: LakeGrid[] = []; + const remap = new Map(); + lakes.forEach((lake, at) => { + const lakePortals = portals[at] as number[]; + if (lakePortals.length === 0 && lake.hectares < FREE_LAKE_HA) return; + remap.set(at, kept.length); + kept.push({ ...lake, portals: Int32Array.from(lakePortals) }); + }); + for (const node of state.nodes) if (node.lake >= 0) node.lake = remap.get(node.lake) ?? -1; + return kept; +} + +/** The lake at a point, or the nearest one within SHORE_REACH_M of it (searched on two rings of eight bearings). */ +function lakeNear(index: LakeIndex, lat: number, lng: number): number { + const here = index.at(lat, lng); + if (here >= 0) return here; + const cos = Math.cos((lat * Math.PI) / 180); + for (const reach of [SHORE_REACH_M / 2, SHORE_REACH_M]) { + for (let bearing = 0; bearing < 8; bearing++) { + const angle = (bearing * Math.PI) / 4; + const lake = index.at( + lat + (reach * Math.cos(angle)) / 111_195, + lng + (reach * Math.sin(angle)) / (111_195 * cos), + ); + if (lake >= 0) return lake; + } + } + return -1; +} + +function markPortal(state: BuildState, node: number, lake: number): number { + const at = state.nodes[node]; + if (at && at.lake < 0 && !at.barrier && lake >= 0) at.lake = lake; + return node; +} diff --git a/server/src/nest/paddle-routing/build/lines.ts b/server/src/nest/paddle-routing/build/lines.ts new file mode 100644 index 000000000..59bcbe2d1 --- /dev/null +++ b/server/src/nest/paddle-routing/build/lines.ts @@ -0,0 +1,284 @@ +import { Access, EdgeKind } from '../water-graph'; +import { BuildState, PointGrid } from './build-state'; +import type { CollectedBarrier, CollectedWater, CollectedWay } from './collect'; + +export type CoordOf = (nodeId: number) => [number, number] | null; + +/** How fast the water runs, km/h, by the kind of line; still water has none. */ +const CURRENT_KMH: Record = { + river: 1.5, + stream: 2, + canoe_pass: 1.5, + flowline: 0.5, +}; + +/** A dangling end this close to other water is joined to it as water, farther up to GAP_M as a marked gap. */ +const CONNECT_M = 30; +const GAP_M = 150; + +interface LinePoint { + ref: number; + lat: number; + lng: number; + barrier: CollectedBarrier | null; +} + +function closedTo(value: string | undefined): boolean { + return value === 'no' || value === 'private'; +} + +/** A stretch of this whitewater grade or harder is no touring water; the router leaves it out. */ +const CLOSED_GRADE = 3; + +/** The whitewater grade a line gives (`whitewater:section_grade`, 0 to 6), 0 when none. */ +function gradeOf(tags: Readonly>): number { + const grade = Number.parseInt(tags['whitewater:section_grade'] ?? '', 10); + return Number.isFinite(grade) && grade > 0 ? Math.min(6, grade) : 0; +} + +/** Who may paddle a line: the most specific boat tag decides (canoe, kayak, then boat, then access). */ +function accessOf(tags: Readonly>): number { + const rule = tags.canoe ?? tags.kayak ?? tags.boat ?? tags.access; + if (closedTo(rule)) return Access.closed; + if (gradeOf(tags) >= CLOSED_GRADE) return Access.closed; + const tunnel = tags.tunnel === 'yes' || tags.tunnel === 'culvert'; + if (tunnel && !['yes', 'designated', 'permissive'].includes(tags.canoe ?? tags.boat ?? '')) return Access.closed; + if (rule === 'discouraged' || tags.intermittent === 'yes') return Access.discouraged; + return Access.open; +} + +interface Segment { + aLat: number; + aLng: number; + bLat: number; + bLng: number; + barrier: CollectedBarrier; + refs: Set; +} + +/** Where two segments cross, as the share along the first, or null. */ +function crossing( + aLat: number, + aLng: number, + bLat: number, + bLng: number, + s: Pick, +): number | null { + const rLat = bLat - aLat; + const rLng = bLng - aLng; + const qLat = s.bLat - s.aLat; + const qLng = s.bLng - s.aLng; + const denom = rLat * qLng - rLng * qLat; + if (denom === 0) return null; + const t = ((s.aLat - aLat) * qLng - (s.aLng - aLng) * qLat) / denom; + const u = ((s.aLat - aLat) * rLng - (s.aLng - aLng) * rLat) / denom; + return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : null; +} + +/** A crossing this close to an end of a river segment is on that end's node, in shares of the segment. */ +const AT_VERTEX = 1e-9; + +/** Barrier lines on a coarse grid, for the river segments that might cross them. */ +function barrierSegments(water: CollectedWater, coordOf: CoordOf): Map { + const grid = new Map(); + for (const way of water.barrierWays) { + const refs = new Set(way.refs); + for (let i = 1; i < way.refs.length; i++) { + const a = coordOf(way.refs[i - 1] as number); + const b = coordOf(way.refs[i] as number); + if (!a || !b) continue; + const segment: Segment = { aLat: a[0], aLng: a[1], bLat: b[0], bLng: b[1], barrier: way.barrier, refs }; + for (const key of cellsOf(a[0], a[1], b[0], b[1])) { + const cell = grid.get(key); + if (cell) cell.push(segment); + else grid.set(key, [segment]); + } + } + } + return grid; +} + +const SEG_CELL = 0.005; + +function cellsOf(aLat: number, aLng: number, bLat: number, bLng: number): string[] { + const keys: string[] = []; + for (let r = Math.floor(Math.min(aLat, bLat) / SEG_CELL); r <= Math.floor(Math.max(aLat, bLat) / SEG_CELL); r++) { + for (let c = Math.floor(Math.min(aLng, bLng) / SEG_CELL); c <= Math.floor(Math.max(aLng, bLng) / SEG_CELL); c++) { + keys.push(`${r}:${c}`); + } + } + return keys; +} + +/** + * A centre line's points, with a barrier marked where a weir or dam stands on + * it: on a shared node, or where a barrier line crosses it without one, which + * is cut in as a point of its own. Missing nodes (outside the extract) drop out. + */ +function linePoints( + way: CollectedWay, + barrierAt: ReadonlyMap, + coordOf: CoordOf, + barriers: Map, + nextSynthetic: () => number, +): LinePoint[] { + const points: LinePoint[] = []; + for (const ref of way.refs) { + const at = coordOf(ref); + if (!at) continue; + const previous = points[points.length - 1]; + if (previous && previous.lat === at[0] && previous.lng === at[1]) continue; + // A barrier line crossing exactly through a node of the river bars that node rather than a point beside it. + let onVertex: CollectedBarrier | null = null; + if (previous) { + const crossings: Array<{ t: number; barrier: CollectedBarrier }> = []; + for (const key of new Set(cellsOf(previous.lat, previous.lng, at[0], at[1]))) { + for (const segment of barriers.get(key) ?? []) { + if (segment.refs.has(previous.ref) || segment.refs.has(ref)) continue; + const t = crossing(previous.lat, previous.lng, at[0], at[1], segment); + if (t !== null && !crossings.some((seen) => seen.barrier === segment.barrier)) + crossings.push({ t, barrier: segment.barrier }); + } + } + crossings.sort((x, y) => x.t - y.t); + for (const { t, barrier } of crossings) { + if (t <= AT_VERTEX) previous.barrier ??= barrier; + else if (t >= 1 - AT_VERTEX) onVertex ??= barrier; + else + points.push({ + ref: nextSynthetic(), + lat: previous.lat + (at[0] - previous.lat) * t, + lng: previous.lng + (at[1] - previous.lng) * t, + barrier, + }); + } + } + points.push({ ref, lat: at[0], lng: at[1], barrier: barrierAt.get(ref) ?? onVertex }); + } + return points; +} + +/** + * The rivers and canals as edges between the points where they meet, end or + * are barred. A barrier is no node at all but one node per side, so nothing + * paddles through it; the portage builder joins the sides later. + */ +export function buildLines(water: CollectedWater, coordOf: CoordOf, state: BuildState): void { + const barrierGrid = barrierSegments(water, coordOf); + const barrierAt = new Map(); + for (const barrierWay of water.barrierWays) for (const ref of barrierWay.refs) barrierAt.set(ref, barrierWay.barrier); + for (const [ref, barrier] of water.barrierNodes) barrierAt.set(ref, barrier); + let synthetic = 0; + const nextSynthetic = () => --synthetic; + const lines = water.centrelines.map((way) => ({ + way, + points: linePoints(way, barrierAt, coordOf, barrierGrid, nextSynthetic), + })); + const uses = new Map(); + for (const { points } of lines) for (const point of points) uses.set(point.ref, (uses.get(point.ref) ?? 0) + 1); + + const nodeOfRef = new Map(); + const sites = new Map(); + const nodeFor = (point: LinePoint): number => { + if (point.barrier) { + // Each line meeting a barrier gets its own side node. + const side = state.addNode(point.lat, point.lng, true); + let site = sites.get(point.ref); + if (site === undefined) { + site = state.barriers.length; + state.barriers.push({ + lat: point.lat, + lng: point.lng, + name: point.barrier.name, + ref: point.barrier.osmRef, + sides: [], + }); + sites.set(point.ref, site); + } + state.barriers[site]?.sides.push(side); + return side; + } + const known = nodeOfRef.get(point.ref); + if (known !== undefined) return known; + const node = state.addNode(point.lat, point.lng); + nodeOfRef.set(point.ref, node); + return node; + }; + + for (const { way, points } of lines) { + if (points.length < 2) continue; + const tags = way.tags; + const current = CURRENT_KMH[tags.waterway ?? ''] ?? 0; + const attrs = { + kind: tags.lock === 'yes' ? EdgeKind.lock : tags.waterway === 'canoe_pass' ? EdgeKind.pass : EdgeKind.water, + flow: current > 0 ? (1 as const) : (0 as const), + current, + access: accessOf(tags), + oneway: tags['oneway:canoe'] === 'yes' || tags['oneway:boat'] === 'yes' ? (1 as const) : (0 as const), + grade: gradeOf(tags), + // A lock goes by its own name (Schleuse Diemitz), not the waterway it lies on. + name: (tags.lock === 'yes' ? tags.lock_name : undefined) ?? tags.name ?? tags.lock_name ?? null, + ref: `way/${way.id}`, + source: 0, + }; + let start = 0; + let startNode = nodeFor(points[0] as LinePoint); + for (let i = 1; i < points.length; i++) { + const point = points[i] as LinePoint; + const isLast = i === points.length - 1; + if (!isLast && !point.barrier && (uses.get(point.ref) ?? 0) < 2) continue; + const endNode = nodeFor(point); + state.addEdge({ + a: startNode, + b: endNode, + line: points.slice(start, i + 1).map((p) => [p.lat, p.lng]), + ...attrs, + }); + // Past a barrier the line goes on from a node of its own, the other side. + startNode = point.barrier && !isLast ? nodeFor(point) : endNode; + start = i; + } + } +} + +/** + * Joins line ends that stop short of other water, as OSM often leaves a side + * arm a few metres off the river: to the nearest point of another line within + * CONNECT_M as water, within GAP_M as a gap the route marks. Barrier sides are + * left alone (a barrier is meant to stop the line), and so are ends that open + * into a lake, which the lake joins. + */ +export function joinDanglingEnds(state: BuildState): void { + const incidence = state.incidence(); + const grid = new PointGrid<{ edge: number; point: number }>(); + state.edges.forEach((edge, index) => { + if (edge.dead) return; + edge.line.forEach(([lat, lng], point) => grid.add(lat, lng, { edge: index, point })); + }); + const requests: Array<{ node: number; edge: number; point: number; distance: number }> = []; + state.nodes.forEach((node, index) => { + const at = incidence[index] as number[]; + if (node.barrier || node.lake >= 0 || at.length !== 1) return; + const own = new Set(at); + const target = grid.near(node.lat, node.lng, GAP_M).find(({ item }) => !own.has(item.edge)); + if (target) + requests.push({ node: index, edge: target.item.edge, point: target.item.point, distance: target.distance }); + }); + // Cut every target edge once per point, from its far end back, so earlier point indices still hold. + const byEdge = new Map(); + for (const request of requests) byEdge.set(request.edge, [...(byEdge.get(request.edge) ?? []), request.point]); + const nodeAt = new Map(); + for (const [edge, points] of byEdge) { + for (const point of [...new Set(points)].sort((x, y) => y - x)) + nodeAt.set(`${edge}:${point}`, state.splitAt(edge, point)); + } + const joined = new Set(); + for (const request of requests) { + const target = nodeAt.get(`${request.edge}:${request.point}`) as number; + if (target === request.node) continue; + const key = request.node < target ? `${request.node}-${target}` : `${target}-${request.node}`; + if (joined.has(key)) continue; + joined.add(key); + state.addLink(request.node, target, request.distance <= CONNECT_M ? EdgeKind.connector : EdgeKind.gap); + } +} diff --git a/server/src/nest/paddle-routing/build/portages.ts b/server/src/nest/paddle-routing/build/portages.ts new file mode 100644 index 000000000..0f60e7d1f --- /dev/null +++ b/server/src/nest/paddle-routing/build/portages.ts @@ -0,0 +1,177 @@ +import { projectOnSegment } from '../engine/geo'; +import { Access, EdgeKind, PortageSource } from '../water-graph'; +import { type BarrierSite, type BuildEdge, type BuildState, PointGrid } from './build-state'; +import type { CollectedWater } from './collect'; +import type { CoordOf } from './lines'; + +/** A mapped portage way counts for a barrier when it starts and ends this close to the water on two of its sides. */ +const PORTAGE_REACH_M = 80; +/** How far around a barrier its mapped portage ways are looked for. */ +const PORTAGE_SEARCH_M = 250; +/** Without a mapped way the carry starts and ends this far up and down the water from the barrier... */ +const INFERRED_OFFSET_M = 25; +/** ...and is not drawn when it would come out longer than this. */ +const INFERRED_MAX_M = 150; +/** A lock longer than this gets no straight carry beside it. */ +const LOCK_CARRY_MAX_M = 400; + +interface PortageWay { + points: Array<[number, number]>; + name: string | null; +} + +/** The single live edge at a barrier side, or null when the lake builder took it. */ +function sideEdge(state: BuildState, side: number): number | null { + const at = state.edgesAt(side); + return at.length === 1 ? (at[0] as number) : null; +} + +/** How far an edge passes from a spot, in metres. */ +function distanceTo(edge: BuildEdge, lat: number, lng: number): number { + let best = Infinity; + for (let seg = 0; seg + 1 < edge.line.length; seg++) { + const a = edge.line[seg] as [number, number]; + const b = edge.line[seg + 1] as [number, number]; + best = Math.min(best, projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]).distance); + } + return best; +} + +/** + * Joins the sides of every barrier by a carry: along a portage way mapped in + * OSM where one reaches the water on two sides, else straight between points + * a little way up and down the water, marked as inferred. A lock open to boats + * also gets a straight carry beside it, for a paddler who would rather carry + * than wait; a lock closed to them (a ship lock with canoe=no) gets only a + * mapped portage way, never a guessed carry across its grounds. + */ +export function buildPortages(state: BuildState, water: CollectedWater, coordOf: CoordOf): void { + const ways: PortageWay[] = water.portageWays.flatMap((way) => { + if (way.tags.access === 'private' || way.tags.access === 'no') return []; + const points = way.refs.flatMap((ref) => { + const at = coordOf(ref); + return at ? [at] : []; + }); + return points.length >= 2 ? [{ points, name: way.tags.name || null }] : []; + }); + const wayGrid = new PointGrid(); + for (const way of ways) for (const [lat, lng] of way.points) wayGrid.add(lat, lng, way); + + for (const site of state.barriers) { + const sides = site.sides.flatMap((side) => { + const edge = sideEdge(state, side); + return edge === null ? [] : [{ side, edge }]; + }); + if (sides.length < 2) continue; + const candidates = [...new Set(wayGrid.near(site.lat, site.lng, PORTAGE_SEARCH_M).map(({ item }) => item))]; + let carried = false; + for (const way of candidates) carried = carryAlong(state, site, sides, way) || carried; + if (!carried) inferCarries(state, site, sides); + } + + for (const { edge, index } of state.live()) { + if (edge.kind !== EdgeKind.lock) continue; + if (edge.access === Access.closed) { + const sides = [edge.a, edge.b].flatMap((node) => { + const other = state.edgesAt(node).find((at) => at !== index && state.edges[at]?.kind !== EdgeKind.portage); + return other === undefined ? [] : [{ side: node, edge: other }]; + }); + if (sides.length < 2) continue; + const middle = edge.line[Math.floor(edge.line.length / 2)] as [number, number]; + const site: BarrierSite = { + lat: middle[0], + lng: middle[1], + name: edge.name, + ref: edge.ref ?? '', + sides: [edge.a, edge.b], + }; + for (const way of new Set(wayGrid.near(site.lat, site.lng, PORTAGE_SEARCH_M).map(({ item }) => item))) + carryAlong(state, site, sides, way); + continue; + } + if (state.distance(edge.a, edge.b) > LOCK_CARRY_MAX_M) continue; + const from = state.nodes[edge.a]; + const to = state.nodes[edge.b]; + if (!from || !to) continue; + state.addPortage( + edge.a, + edge.b, + [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + PortageSource.inferred, + edge.name, + edge.ref, + ); + } +} + +/** + * A mapped portage way as the carry round a barrier: its two ends paired with + * two different sides, the pairing whose farther end lies nearest the water, + * both within PORTAGE_REACH_M. + */ +function carryAlong( + state: BuildState, + site: BarrierSite, + sides: Array<{ side: number; edge: number }>, + way: PortageWay, +): boolean { + const first = way.points[0] as [number, number]; + const last = way.points[way.points.length - 1] as [number, number]; + const reach = sides.map(({ edge }) => ({ + edge, + first: distanceTo(state.edges[edge] as BuildEdge, first[0], first[1]), + last: distanceTo(state.edges[edge] as BuildEdge, last[0], last[1]), + })); + let best: { start: (typeof reach)[number]; end: (typeof reach)[number]; worst: number } | null = null; + for (const start of reach) { + for (const end of reach) { + if (start.edge === end.edge) continue; + const worst = Math.max(start.first, end.last); + if (worst <= PORTAGE_REACH_M && (!best || worst < best.worst)) best = { start, end, worst }; + } + } + if (!best) return false; + // Out and back in where the portage way reaches the water, not at the barrier itself. + const a = state.nodeNearest(best.start.edge, first[0], first[1]); + const b = state.nodeNearest(best.end.edge, last[0], last[1]); + const nodeA = state.nodes[a]; + const nodeB = state.nodes[b]; + if (!nodeA || !nodeB) return false; + state.addPortage( + a, + b, + [[nodeA.lat, nodeA.lng], ...way.points, [nodeB.lat, nodeB.lng]], + PortageSource.tagged, + way.name ?? site.name, + site.ref, + ); + return true; +} + +function inferCarries(state: BuildState, site: BarrierSite, sides: Array<{ side: number; edge: number }>): void { + const ends = sides.map(({ side, edge }) => state.nodeAlong(edge, side, INFERRED_OFFSET_M)); + for (let i = 0; i < ends.length; i++) { + for (let j = i + 1; j < ends.length; j++) { + const a = ends[i] as number; + const b = ends[j] as number; + if (state.distance(a, b) > INFERRED_MAX_M) continue; + const from = state.nodes[a]; + const to = state.nodes[b]; + if (!from || !to) continue; + state.addPortage( + a, + b, + [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + PortageSource.inferred, + site.name, + site.ref, + ); + } + } +} diff --git a/server/src/nest/paddle-routing/engine/geo.ts b/server/src/nest/paddle-routing/engine/geo.ts new file mode 100644 index 000000000..8b5f8d619 --- /dev/null +++ b/server/src/nest/paddle-routing/engine/geo.ts @@ -0,0 +1,71 @@ +/** Metres per degree of latitude, the figure the paddle code reckons with throughout. */ +const M_PER_DEG = 111_195; + +export function metresBetween(aLat: number, aLng: number, bLat: number, bLng: number): number { + const cos = Math.cos((((aLat + bLat) / 2) * Math.PI) / 180); + return Math.hypot((bLat - aLat) * M_PER_DEG, (bLng - aLng) * M_PER_DEG * cos); +} + +/** + * A flat frame in metres around one spot, good for the few kilometres a lake + * or a stretch of river spans: x east, y north. + */ +export class LocalFrame { + readonly cos: number; + + constructor( + readonly lat0: number, + readonly lng0: number, + ) { + this.cos = Math.cos((lat0 * Math.PI) / 180); + } + + x(lng: number): number { + return (lng - this.lng0) * M_PER_DEG * this.cos; + } + + y(lat: number): number { + return (lat - this.lat0) * M_PER_DEG; + } + + lat(y: number): number { + return this.lat0 + y / M_PER_DEG; + } + + lng(x: number): number { + return this.lng0 + x / (M_PER_DEG * this.cos); + } +} + +/** + * Where a point falls on a segment: the share along it (clamped to the + * segment) and the distance to it, in metres. + */ +export function projectOnSegment( + lat: number, + lng: number, + aLat: number, + aLng: number, + bLat: number, + bLng: number, +): { t: number; distance: number } { + const cos = Math.cos((lat * Math.PI) / 180); + const ax = (aLng - lng) * cos; + const ay = aLat - lat; + const dx = (bLng - aLng) * cos; + const dy = bLat - aLat; + const length2 = dx * dx + dy * dy; + const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / length2)); + return { t, distance: Math.hypot(ax + t * dx, ay + t * dy) * M_PER_DEG }; +} + +/** The length of a [lat, lng] line in metres. */ +export function lineLength(points: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1] as readonly [number, number]; + const b = points[i] as readonly [number, number]; + total += metresBetween(a[0], a[1], b[0], b[1]); + } + return total; +} diff --git a/server/src/nest/paddle-routing/engine/lake-field.ts b/server/src/nest/paddle-routing/engine/lake-field.ts new file mode 100644 index 000000000..a02b32e2f --- /dev/null +++ b/server/src/nest/paddle-routing/engine/lake-field.ts @@ -0,0 +1,355 @@ +import { maskHas } from '../build/lakes'; +import type { LakeGrid } from '../water-graph'; +import { LocalFrame } from './geo'; +import { MinHeap } from './min-heap'; +import type { PaddleLakeStyle } from '@trek/shared'; + +/** + * Routing across one lake on its grid of water cells. Every cell knows how + * far it lies from the shore; a crossing costs its length weighed by that: + * straight over is cheapest with the default style, staying near the shore + * is with `shore`, and water farther out than half a boat's open-water limit + * costs so much that the route takes it only when there is no other way. + * + * Two levels keep a large lake quick: the costs between the lake's portals + * come from a coarse copy of the grid (a few ten thousand cells, however big + * the lake), and only the line actually taken is searched on the fine grid, + * by A* towards its end. + */ + +/** Keep this far off the shore where the lake is wide enough, in metres. */ +const SHORE_MARGIN_M = 12; +/** With `shore`, open water costs more from here out, up to SHORE_FAR_M. */ +const SHORE_NEAR_M = 40; +const SHORE_FAR_M = 200; +const SHORE_WEIGHT = 1.5; +/** Water beyond a boat's open-water reach costs this many times its length. */ +const OVER_LIMIT_WEIGHT = 20; +/** How far a point off the grid may be from water cells to count as on the lake, in cells. */ +const SNAP_CELLS = 6; +/** The coarse grid holds at most about this many cells. */ +const COARSE_CELLS = 60_000; + +const NEIGHBOURS: ReadonlyArray = [ + [1, 0, 1], + [-1, 0, 1], + [0, 1, 1], + [0, -1, 1], + [1, 1, Math.SQRT2], + [1, -1, Math.SQRT2], + [-1, 1, Math.SQRT2], + [-1, -1, Math.SQRT2], +]; + +export interface LakeProfile { + style: PaddleLakeStyle; + /** The widest open water the boat should cross, in metres. */ + maxCrossingM: number; +} + +/** What a search needs of a grid: its size, which cells are water and what a cell costs per metre. */ +interface Grid { + width: number; + height: number; + cell: number; + water: (cell: number) => boolean; + weight: (cell: number) => number; +} + +function weightOf(shore: number, profile: LakeProfile): number { + let weight = 1; + if (shore < SHORE_MARGIN_M) weight += 0.5; + if (profile.style === 'shore') { + weight += SHORE_WEIGHT * Math.min(1, Math.max(0, (shore - SHORE_NEAR_M) / (SHORE_FAR_M - SHORE_NEAR_M))); + } + if (shore > profile.maxCrossingM / 2) weight *= OVER_LIMIT_WEIGHT; + return weight; +} + +function step(grid: Grid, at: number, each: (next: number, length: number) => void): void { + const { width, height, cell } = grid; + const col = at % width; + const row = (at - col) / width; + for (const [dc, dr, factor] of NEIGHBOURS) { + const c = col + dc; + const r = row + dr; + if (c < 0 || r < 0 || c >= width || r >= height) continue; + const next = r * width + c; + if (!grid.water(next)) continue; + // A diagonal step may not cut a corner of land. + if (dc !== 0 && dr !== 0 && (!grid.water(row * width + c) || !grid.water(r * width + col))) continue; + each(next, factor * cell); + } +} + +/** Weighted metres from one cell to every cell, stopping once all `targets` are settled. */ +function dijkstra(grid: Grid, source: number, targets: ReadonlySet): Float64Array { + const cost = new Float64Array(grid.width * grid.height).fill(Infinity); + const heap = new MinHeap(4096); + cost[source] = 0; + heap.push(source, 0); + let remaining = targets.size; + while (heap.size > 0 && remaining > 0) { + const key = heap.peekKey(); + const at = heap.pop(); + if (key > (cost[at] as number)) continue; + if (targets.has(at)) remaining--; + const here = grid.weight(at); + step(grid, at, (next, length) => { + const via = (cost[at] as number) + ((here + grid.weight(next)) / 2) * length; + if (via < (cost[next] as number)) { + cost[next] = via; + heap.push(next, via); + } + }); + } + return cost; +} + +/** The cheapest cell path between two cells, by A* with the straight distance as its guide; null when none. */ +function astar(grid: Grid, from: number, to: number): number[] | null { + const { width } = grid; + const toCol = to % width; + const toRow = (to - toCol) / width; + const h = (cell: number) => { + const dc = Math.abs((cell % width) - toCol); + const dr = Math.abs(Math.floor(cell / width) - toRow); + return (Math.max(dc, dr) + (Math.SQRT2 - 1) * Math.min(dc, dr)) * grid.cell; + }; + const cost = new Float64Array(grid.width * grid.height).fill(Infinity); + const parent = new Int32Array(grid.width * grid.height).fill(-1); + const closed = new Uint8Array(grid.width * grid.height); + const heap = new MinHeap(4096); + cost[from] = 0; + heap.push(from, h(from)); + while (heap.size > 0) { + const at = heap.pop(); + if (closed[at]) continue; + closed[at] = 1; + if (at === to) { + const cells = [to]; + for (let cell = to; cell !== from;) { + cell = parent[cell] as number; + cells.push(cell); + } + return cells.reverse(); + } + const base = cost[at] as number; + const here = grid.weight(at); + step(grid, at, (next, length) => { + const via = base + ((here + grid.weight(next)) / 2) * length; + if (via < (cost[next] as number)) { + cost[next] = via; + parent[next] = at; + heap.push(next, via + h(next)); + } + }); + } + return null; +} + +export class LakeField { + readonly frame: LocalFrame; + private shore: Float32Array | null = null; + private coarse: { factor: number; width: number; height: number; water: Uint8Array; shore: Float32Array } | null = + null; + + constructor(readonly lake: LakeGrid) { + this.frame = new LocalFrame(lake.lat0, lake.lng0); + } + + get cells(): number { + return this.lake.width * this.lake.height; + } + + isWater(cell: number): boolean { + return maskHas(this.lake, cell % this.lake.width, Math.floor(cell / this.lake.width)); + } + + /** The water cell at a point, or the nearest within SNAP_CELLS of it; -1 when none. */ + waterCellNear(lat: number, lng: number): number { + const col = Math.floor(this.frame.x(lng) / this.lake.cell); + const row = Math.floor(this.frame.y(lat) / this.lake.cell); + let best = -1; + let bestDistance = Infinity; + for (let dr = -SNAP_CELLS; dr <= SNAP_CELLS; dr++) { + for (let dc = -SNAP_CELLS; dc <= SNAP_CELLS; dc++) { + const c = col + dc; + const r = row + dr; + if (!maskHas(this.lake, c, r)) continue; + const distance = dr * dr + dc * dc; + if (distance < bestDistance) { + bestDistance = distance; + best = r * this.lake.width + c; + } + } + } + return best; + } + + centre(cell: number): [number, number] { + const col = cell % this.lake.width; + const row = Math.floor(cell / this.lake.width); + return [this.frame.lat((row + 0.5) * this.lake.cell), this.frame.lng((col + 0.5) * this.lake.cell)]; + } + + /** Every cell's distance to the nearest land cell, in metres (a chamfer transform, 3-4 weights). */ + shoreDistance(): Float32Array { + if (this.shore) return this.shore; + const { width, height, cell } = this.lake; + const far = 1e9; + const d = new Float32Array(width * height); + for (let i = 0; i < d.length; i++) d[i] = this.isWater(i) ? far : 0; + const relax = (i: number, j: number, w: number) => { + const via = (d[j] as number) + w; + if (via < (d[i] as number)) d[i] = via; + }; + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const i = r * width + c; + if (d[i] === 0) continue; + // Outside the grid is land too. + if (r === 0 || c === 0 || r === height - 1 || c === width - 1) d[i] = Math.min(d[i] as number, 3); + if (c > 0) relax(i, i - 1, 3); + if (r > 0) { + relax(i, i - width, 3); + if (c > 0) relax(i, i - width - 1, 4); + if (c < width - 1) relax(i, i - width + 1, 4); + } + } + } + for (let r = height - 1; r >= 0; r--) { + for (let c = width - 1; c >= 0; c--) { + const i = r * width + c; + if (d[i] === 0) continue; + if (c < width - 1) relax(i, i + 1, 3); + if (r < height - 1) { + relax(i, i + width, 3); + if (c < width - 1) relax(i, i + width + 1, 4); + if (c > 0) relax(i, i + width - 1, 4); + } + } + } + const scale = cell / 3; + for (let i = 0; i < d.length; i++) d[i] = (d[i] as number) * scale; + this.shore = d; + return d; + } + + /** What crossing a cell costs per metre, for this profile. */ + weight(cell: number, profile: LakeProfile): number { + return weightOf(this.shoreDistance()[cell] as number, profile); + } + + private fine(profile: LakeProfile): Grid { + const shore = this.shoreDistance(); + return { + width: this.lake.width, + height: this.lake.height, + cell: this.lake.cell, + water: (cell) => this.isWater(cell), + weight: (cell) => weightOf(shore[cell] as number, profile), + }; + } + + /** + * The lake on a grid of blocks of `factor` cells a side: a block is water + * when any of its cells is, and as far from the shore as its farthest cell. + */ + private coarseGrid() { + if (this.coarse) return this.coarse; + const { width, height } = this.lake; + const factor = Math.max(1, Math.ceil(Math.sqrt((width * height) / COARSE_CELLS))); + const cw = Math.ceil(width / factor); + const ch = Math.ceil(height / factor); + const water = new Uint8Array(cw * ch); + const shore = new Float32Array(cw * ch); + const fine = this.shoreDistance(); + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const i = r * width + c; + if (!this.isWater(i)) continue; + const block = Math.floor(r / factor) * cw + Math.floor(c / factor); + water[block] = 1; + shore[block] = Math.max(shore[block] as number, fine[i] as number); + } + } + this.coarse = { factor, width: cw, height: ch, water, shore }; + return this.coarse; + } + + private coarseOf(cell: number): number { + const coarse = this.coarseGrid(); + const col = cell % this.lake.width; + const row = (cell - col) / this.lake.width; + return Math.floor(row / coarse.factor) * coarse.width + Math.floor(col / coarse.factor); + } + + /** + * Weighted metres from one cell to each of `targets`, read off the coarse + * grid: close enough to choose between ways, and never less than the + * straight distance. Infinity where a target cannot be reached. + */ + costsTo(source: number, targets: readonly number[], profile: LakeProfile): number[] { + const coarse = this.coarseGrid(); + const grid: Grid = { + width: coarse.width, + height: coarse.height, + cell: this.lake.cell * coarse.factor, + water: (cell) => coarse.water[cell] === 1, + weight: (cell) => weightOf(coarse.shore[cell] as number, profile), + }; + const blocks = targets.map((cell) => this.coarseOf(cell)); + const field = dijkstra(grid, this.coarseOf(source), new Set(blocks)); + const [sLat, sLng] = this.centre(source); + return targets.map((cell, index) => { + const reached = field[blocks[index] as number] as number; + if (!Number.isFinite(reached)) return Infinity; + const [lat, lng] = this.centre(cell); + const straight = Math.hypot(this.frame.x(lng) - this.frame.x(sLng), this.frame.y(lat) - this.frame.y(sLat)); + return Math.max(reached, straight); + }); + } + + /** + * The line from one cell to another as a paddler would take it: the + * cheapest cell path, then pulled straight wherever a straight line stays on + * water no more costly than the cells it joins. + */ + path(from: number, to: number, profile: LakeProfile): Array<[number, number]> | null { + const cells = astar(this.fine(profile), from, to); + if (!cells) return null; + return this.pull(cells, profile).map((cell) => this.centre(cell)); + } + + private pull(cells: number[], profile: LakeProfile): number[] { + const kept = [cells[0] as number]; + let anchor = 0; + for (let i = 2; i < cells.length; i++) { + if (!this.clear(cells[anchor] as number, cells[i] as number, profile)) { + kept.push(cells[i - 1] as number); + anchor = i - 1; + } + } + if (cells.length > 1) kept.push(cells[cells.length - 1] as number); + return kept; + } + + /** Whether a straight line between two cells runs on water no more costly than its ends. */ + private clear(a: number, b: number, profile: LakeProfile): boolean { + const { width } = this.lake; + const ac = a % width; + const ar = (a - ac) / width; + const bc = b % width; + const br = (b - bc) / width; + const ceiling = Math.max(this.weight(a, profile), this.weight(b, profile)) * 1.05; + const steps = Math.max(Math.abs(bc - ac), Math.abs(br - ar)) * 2; + for (let s = 1; s < steps; s++) { + const c = Math.round(ac + ((bc - ac) * s) / steps); + const r = Math.round(ar + ((br - ar) * s) / steps); + const cell = r * width + c; + if (!this.isWater(cell) || this.weight(cell, profile) > ceiling) return false; + } + return true; + } +} diff --git a/server/src/nest/paddle-routing/engine/min-heap.ts b/server/src/nest/paddle-routing/engine/min-heap.ts new file mode 100644 index 000000000..27b5b5370 --- /dev/null +++ b/server/src/nest/paddle-routing/engine/min-heap.ts @@ -0,0 +1,75 @@ +/** + * A binary min-heap of integer ids by a float priority, on typed arrays so a + * search over a few hundred thousand cells or nodes makes no garbage. An id + * may be pushed again with a better priority; the stale entry is skipped by + * the caller when it comes up. + */ +export class MinHeap { + private ids: Int32Array; + private keys: Float64Array; + private length = 0; + + constructor(capacity = 1024) { + this.ids = new Int32Array(capacity); + this.keys = new Float64Array(capacity); + } + + get size(): number { + return this.length; + } + + clear(): void { + this.length = 0; + } + + push(id: number, key: number): void { + if (this.length === this.ids.length) this.grow(); + let at = this.length++; + while (at > 0) { + const parent = (at - 1) >> 1; + const parentKey = this.keys[parent] as number; + if (parentKey <= key) break; + this.ids[at] = this.ids[parent] as number; + this.keys[at] = parentKey; + at = parent; + } + this.ids[at] = id; + this.keys[at] = key; + } + + peekKey(): number { + return this.keys[0] as number; + } + + /** The id with the lowest priority, taken off; call only while `size` > 0. */ + pop(): number { + const top = this.ids[0] as number; + const last = --this.length; + if (last > 0) { + const id = this.ids[last] as number; + const key = this.keys[last] as number; + let at = 0; + for (;;) { + let child = at * 2 + 1; + if (child >= last) break; + if (child + 1 < last && (this.keys[child + 1] as number) < (this.keys[child] as number)) child++; + if ((this.keys[child] as number) >= key) break; + this.ids[at] = this.ids[child] as number; + this.keys[at] = this.keys[child] as number; + at = child; + } + this.ids[at] = id; + this.keys[at] = key; + } + return top; + } + + private grow(): void { + const ids = new Int32Array(this.ids.length * 2); + const keys = new Float64Array(this.keys.length * 2); + ids.set(this.ids); + keys.set(this.keys); + this.ids = ids; + this.keys = keys; + } +} diff --git a/server/src/nest/paddle-routing/engine/router.ts b/server/src/nest/paddle-routing/engine/router.ts new file mode 100644 index 000000000..fb7d9e30f --- /dev/null +++ b/server/src/nest/paddle-routing/engine/router.ts @@ -0,0 +1,644 @@ +import { LakeIndex } from '../build/lakes'; +import { Access, EdgeKind, PortageSource, type WaterGraphData } from '../water-graph'; +import { metresBetween, projectOnSegment } from './geo'; +import { LakeField, type LakeProfile } from './lake-field'; +import { MinHeap } from './min-heap'; +import type { + PaddleBoatType, + PaddleLakeStyle, + PaddleLockPolicy, + PaddleRouteLeg, + PaddleRoutePoint, + PaddleRouteSegment, +} from '@trek/shared'; + +/** + * PAD-11: plans one leg at a time on a region's water network. The ends are + * moved onto the water (a lake cell, or the nearest river within SNAP_M); + * an A* search then weighs every way by the time it takes: paddling with or + * against the current, carrying a portage, waiting at a lock, crossing a lake + * on its grid. Edges a boat may not use are not taken at all. + */ + +export interface RouteProfile { + boat: PaddleBoatType; + lakeStyle: PaddleLakeStyle; + lockPolicy: PaddleLockPolicy; +} + +/** + * A point farther than this from any water the network knows is not on the + * water. Generous on purpose: a click on the bank or a slipway of a wide river + * lies 100 m and more from its centre line. + */ +export const SNAP_M = 200; +/** Planning speeds by boat, km/h: they choose between ways; the paddler's own speed times the tour. */ +const BOAT_KMH: Record = { kayak: 7, canoe: 6, sup: 5, other: 6 }; +/** The widest open water each boat should cross, in metres. */ +const OPEN_WATER_M: Record = { kayak: 1500, canoe: 750, sup: 400, other: 1500 }; +const CARRY_KMH = 2.5; +/** Getting out, unloading and back in at a portage, and waiting plus going through at a lock, seconds. */ +const CARRY_EXTRA_S = 8 * 60; +const LOCK_S = 15 * 60; +const DISCOURAGED = 3; +/** Whitewater from this grade is taken only when it saves a lot; a SUP never takes it. */ +const RAPIDS_GRADE = 2; +const RAPIDS_WEIGHT = 2; +const GAP_WEIGHT = 20; +/** Never slower than this against a current, km/h; the river is still paddled, just slowly. */ +const MIN_UPSTREAM_KMH = 1; +const MAX_CURRENT_KMH = 2; +const MAX_SETTLED = 400_000; +/** How many portals' cost lists stay cached between searches. */ +const PORTAL_COST_CACHE = 2000; +const BUDGET_MS = 3000; + +type SnapEdge = { type: 'edge'; edge: number; seg: number; t: number; lat: number; lng: number; distance: number }; +type SnapLake = { type: 'lake'; lake: number; cell: number; lat: number; lng: number; distance: number }; +type Snap = SnapEdge | SnapLake; + +/** How the search came to a node: along an edge (forward or back), across a lake, or from the start. */ +interface Step { + from: number; + kind: 'edge' | 'lake' | 'partial'; + edge: number; + forward: boolean; + lake: number; +} + +interface Piece { + points: Array<[number, number]>; + kind: PaddleRouteSegment['kind']; + name: string | null; + source: PaddleRouteSegment['source']; + osmRef: string | null; + grade: number | null; +} + +export class WaterRouter { + private readonly offsets: Uint32Array; + private readonly arcs: Uint32Array; + private readonly lakeIndex: LakeIndex; + private readonly fields = new Map(); + private readonly portalCells = new Map>(); + private readonly portalCostCache = new Map(); + private readonly lockRefs: Set; + private readonly segmentCells: Float64Array; + private readonly segmentEdge: Int32Array; + private readonly segmentIndex: Int32Array; + private static readonly CELL = 0.004; + + constructor(readonly graph: WaterGraphData) { + const nodes = graph.nodeLat.length; + const degree = new Uint32Array(nodes + 1); + for (let e = 0; e < graph.edgeA.length; e++) { + degree[graph.edgeA[e] as number]++; + degree[graph.edgeB[e] as number]++; + } + this.offsets = new Uint32Array(nodes + 1); + for (let n = 0; n < nodes; n++) this.offsets[n + 1] = (this.offsets[n] as number) + (degree[n] as number); + this.arcs = new Uint32Array(this.offsets[nodes] as number); + const fill = this.offsets.slice(0, nodes); + for (let e = 0; e < graph.edgeA.length; e++) { + this.arcs[fill[graph.edgeA[e] as number]++ as number] = e * 2; + this.arcs[fill[graph.edgeB[e] as number]++ as number] = e * 2 + 1; + } + this.lakeIndex = new LakeIndex(graph.lakes); + this.lockRefs = new Set(); + for (let e = 0; e < graph.edgeKind.length; e++) { + if (graph.edgeKind[e] === EdgeKind.lock && (graph.edgeRef[e] as number) >= 0) + this.lockRefs.add(graph.edgeRef[e] as number); + } + ({ cells: this.segmentCells, edges: this.segmentEdge, segs: this.segmentIndex } = this.indexSegments()); + } + + /** Every segment of every edge filed under the grid cells its box touches, sorted by cell for lookup. */ + private indexSegments(): { cells: Float64Array; edges: Int32Array; segs: Int32Array } { + const g = this.graph; + const cells: number[] = []; + const edges: number[] = []; + const segs: number[] = []; + for (let e = 0; e < g.edgeA.length; e++) { + const start = g.edgeGeomStart[e] as number; + const count = g.edgeGeomCount[e] as number; + for (let s = 0; s + 1 < count; s++) { + const aLat = g.pool[(start + s) * 2] as number; + const aLng = g.pool[(start + s) * 2 + 1] as number; + const bLat = g.pool[(start + s + 1) * 2] as number; + const bLng = g.pool[(start + s + 1) * 2 + 1] as number; + for ( + let r = Math.floor(Math.min(aLat, bLat) / WaterRouter.CELL); + r <= Math.floor(Math.max(aLat, bLat) / WaterRouter.CELL); + r++ + ) { + for ( + let c = Math.floor(Math.min(aLng, bLng) / WaterRouter.CELL); + c <= Math.floor(Math.max(aLng, bLng) / WaterRouter.CELL); + c++ + ) { + cells.push(WaterRouter.key(r, c)); + edges.push(e); + segs.push(s); + } + } + } + } + const order = Array.from(cells.keys()).sort((x, y) => (cells[x] as number) - (cells[y] as number) || x - y); + return { + cells: Float64Array.from(order, (i) => cells[i] as number), + edges: Int32Array.from(order, (i) => edges[i] as number), + segs: Int32Array.from(order, (i) => segs[i] as number), + }; + } + + private static key(row: number, col: number): number { + return (row + 50_000) * 100_000 + (col + 50_000); + } + + private field(lake: number): LakeField { + let field = this.fields.get(lake); + if (!field) { + field = new LakeField(this.graph.lakes[lake] as NonNullable); + this.fields.set(lake, field); + } + return field; + } + + private point(edge: number, index: number): [number, number] { + const at = ((this.graph.edgeGeomStart[edge] as number) + index) * 2; + return [this.graph.pool[at] as number, this.graph.pool[at + 1] as number]; + } + + private edgeLine(edge: number): Array<[number, number]> { + const count = this.graph.edgeGeomCount[edge] as number; + return Array.from({ length: count }, (_, i) => this.point(edge, i)); + } + + /** The nearest open segment within `radius` metres. */ + private nearestSegment(lat: number, lng: number, radius: number): SnapEdge | null { + const reach = Math.ceil(radius / 111_195 / WaterRouter.CELL / Math.cos((lat * Math.PI) / 180)) + 1; + const row = Math.floor(lat / WaterRouter.CELL); + const col = Math.floor(lng / WaterRouter.CELL); + let best: SnapEdge | null = null; + let bestClosed: SnapEdge | null = null; + for (let r = row - reach; r <= row + reach; r++) { + for (let c = col - reach; c <= col + reach; c++) { + const key = WaterRouter.key(r, c); + for (let i = this.lowerBound(key); i < this.segmentCells.length && this.segmentCells[i] === key; i++) { + const edge = this.segmentEdge[i] as number; + const seg = this.segmentIndex[i] as number; + const kind = this.graph.edgeKind[edge]; + if (kind === EdgeKind.portage || kind === EdgeKind.gap) continue; + const a = this.point(edge, seg); + const b = this.point(edge, seg + 1); + const { t, distance } = projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]); + if (distance > radius) continue; + const snap: SnapEdge = { + type: 'edge', + edge, + seg, + t, + lat: a[0] + (b[0] - a[0]) * t, + lng: a[1] + (b[1] - a[1]) * t, + distance, + }; + const closed = this.graph.edgeAccess[edge] === Access.closed; + if (closed) { + if (!bestClosed || distance < bestClosed.distance) bestClosed = snap; + } else if (!best || distance < best.distance) best = snap; + } + } + } + return best ?? bestClosed; + } + + private lowerBound(key: number): number { + let low = 0; + let high = this.segmentCells.length; + while (low < high) { + const mid = (low + high) >>> 1; + if ((this.segmentCells[mid] as number) < key) low = mid + 1; + else high = mid; + } + return low; + } + + /** The point put on the water: inside a lake as it is, else on the nearest river or canal, else at a lake's edge. */ + snap(point: PaddleRoutePoint): Snap | null { + const lake = this.lakeIndex.at(point.lat, point.lng); + if (lake >= 0) { + const cell = this.field(lake).waterCellNear(point.lat, point.lng); + if (cell >= 0) return { type: 'lake', lake, cell, lat: point.lat, lng: point.lng, distance: 0 }; + } + const edge = this.nearestSegment(point.lat, point.lng, SNAP_M); + if (edge) return edge; + for (let i = 0; i < this.graph.lakes.length; i++) { + const field = this.field(i); + const cell = field.waterCellNear(point.lat, point.lng); + if (cell < 0) continue; + const [lat, lng] = field.centre(cell); + const distance = metresBetween(point.lat, point.lng, lat, lng); + if (distance <= SNAP_M) return { type: 'lake', lake: i, cell, lat, lng, distance }; + } + return null; + } + + /** Seconds to travel an edge in a direction, or Infinity where the boat may not. */ + private edgeSeconds(edge: number, forward: boolean, length: number, profile: RouteProfile): number { + const g = this.graph; + const kind = g.edgeKind[edge]; + const speed = BOAT_KMH[profile.boat]; + if (kind === EdgeKind.portage) { + const lockCarry = this.lockRefs.has(g.edgeRef[edge] as number); + if (lockCarry && profile.lockPolicy === 'use') return Infinity; + // A straight carry round an open lock is a guess over its walls: taken only when the paddler wants to carry. + if (lockCarry && g.edgeSource[edge] === PortageSource.inferred && profile.lockPolicy !== 'portage') + return Infinity; + return (length / 1000 / CARRY_KMH) * 3600 + CARRY_EXTRA_S; + } + if (g.edgeAccess[edge] === Access.closed) return Infinity; + if (g.edgeOneway[edge] === 1 && !forward) return Infinity; + if (kind === EdgeKind.lock && profile.lockPolicy === 'portage') return Infinity; + const grade = g.edgeGrade[edge] as number; + if (grade >= RAPIDS_GRADE && profile.boat === 'sup') return Infinity; + let kmh = speed; + const current = g.edgeCurrent[edge] as number; + if (g.edgeFlow[edge] === 1 && current > 0) + kmh = forward ? speed + 0.8 * current : Math.max(MIN_UPSTREAM_KMH, speed - current); + let seconds = (length / 1000 / kmh) * 3600; + if (g.edgeAccess[edge] === Access.discouraged) seconds *= DISCOURAGED; + if (grade >= RAPIDS_GRADE) seconds *= RAPIDS_WEIGHT; + if (kind === EdgeKind.gap) seconds *= GAP_WEIGHT; + if (kind === EdgeKind.lock) seconds += LOCK_S; + return seconds; + } + + private lakeProfile(profile: RouteProfile): LakeProfile { + return { style: profile.lakeStyle, maxCrossingM: OPEN_WATER_M[profile.boat] }; + } + + /** Seconds across a lake for weighted metres. */ + private lakeSeconds(weighted: number, profile: RouteProfile): number { + return (weighted / 1000 / BOAT_KMH[profile.boat]) * 3600; + } + + private portalCell(field: LakeField, node: number): number { + return field.waterCellNear(this.graph.nodeLat[node] as number, this.graph.nodeLng[node] as number); + } + + /** A lake's portal nodes with the water cell each opens onto (-1 when none is near). */ + private lakePortals(lake: number): Array<{ node: number; cell: number }> { + let portals = this.portalCells.get(lake); + if (!portals) { + const field = this.field(lake); + portals = Array.from(this.graph.lakes[lake]?.portals ?? [], (node) => ({ + node, + cell: this.portalCell(field, node), + })); + this.portalCells.set(lake, portals); + } + return portals; + } + + /** Weighted metres from a cell to each of a lake's portals, kept for the next search through the same lake. */ + private portalCosts(lake: number, sourceCell: number, profile: LakeProfile): number[] { + const key = `${lake}:${sourceCell}:${profile.style}:${profile.maxCrossingM}`; + let costs = this.portalCostCache.get(key); + if (costs) { + this.portalCostCache.delete(key); + } else { + const portals = this.lakePortals(lake); + const reachable = portals.filter(({ cell }) => cell >= 0); + const reached = this.field(lake).costsTo( + sourceCell, + reachable.map(({ cell }) => cell), + profile, + ); + const byCell = new Map(reachable.map(({ cell }, index) => [cell, reached[index] as number])); + costs = portals.map(({ cell }) => byCell.get(cell) ?? Infinity); + } + this.portalCostCache.set(key, costs); + if (this.portalCostCache.size > PORTAL_COST_CACHE) + this.portalCostCache.delete(this.portalCostCache.keys().next().value as string); + return costs; + } + + route( + from: PaddleRoutePoint, + to: PaddleRoutePoint, + profile: RouteProfile, + deadline = Date.now() + BUDGET_MS, + ): PaddleRouteLeg { + const start = this.snap(from); + if (!start) return { status: 'failed', code: 'not_on_water', at: from }; + const end = this.snap(to); + if (!end) return { status: 'failed', code: 'not_on_water', at: to }; + const found = this.search(start, end, profile, deadline); + if (found === 'timeout') return { status: 'failed', code: 'timeout', at: null }; + if (!found) return { status: 'failed', code: 'no_water_path', at: null }; + return this.assemble(found, start, end, profile); + } + + private search( + start: Snap, + end: Snap, + profile: RouteProfile, + deadline: number, + ): Map | 'timeout' | null { + const g = this.graph; + const nodes = g.nodeLat.length; + const S = nodes; + const T = nodes + 1; + const cost = new Map(); + const came = new Map(); + const heap = new MinHeap(); + const vmax = BOAT_KMH[profile.boat] + 0.8 * MAX_CURRENT_KMH; + const h = (node: number) => { + const lat = node === S ? start.lat : node === T ? end.lat : (g.nodeLat[node] as number); + const lng = node === S ? start.lng : node === T ? end.lng : (g.nodeLng[node] as number); + return (metresBetween(lat, lng, end.lat, end.lng) / 1000 / vmax) * 3600; + }; + const relax = (node: number, via: number, step: Step) => { + if (via < (cost.get(node) ?? Infinity)) { + cost.set(node, via); + came.set(node, step); + heap.push(node, via + h(node)); + } + }; + const lakeProfile = this.lakeProfile(profile); + const lakeHops = (lake: number, sourceCell: number, base: number, fromNode: number) => { + const field = this.field(lake); + const portals = this.lakePortals(lake); + const costs = this.portalCosts(lake, sourceCell, lakeProfile); + portals.forEach(({ node, cell }, index) => { + const weighted = costs[index] as number; + if (node === fromNode || cell < 0 || !Number.isFinite(weighted)) return; + relax(node, base + this.lakeSeconds(weighted, profile), { + from: fromNode, + kind: 'lake', + edge: -1, + forward: true, + lake, + }); + }); + if (end.type === 'lake' && end.lake === lake) { + const [toEnd] = field.costsTo(sourceCell, [end.cell], lakeProfile); + if (Number.isFinite(toEnd as number)) { + relax(T, base + this.lakeSeconds(toEnd as number, profile), { + from: fromNode, + kind: 'lake', + edge: -1, + forward: true, + lake, + }); + } + } + }; + + cost.set(S, 0); + // From the start: along its edge both ways, or across its lake. + if (start.type === 'edge') { + const length = this.partialLength(start.edge, start, 'toB'); + relax(g.edgeB[start.edge] as number, this.edgeSeconds(start.edge, true, length, profile), { + from: S, + kind: 'partial', + edge: start.edge, + forward: true, + lake: -1, + }); + const back = this.partialLength(start.edge, start, 'toA'); + relax(g.edgeA[start.edge] as number, this.edgeSeconds(start.edge, false, back, profile), { + from: S, + kind: 'partial', + edge: start.edge, + forward: false, + lake: -1, + }); + if (end.type === 'edge' && end.edge === start.edge) { + const forward = start.seg + start.t <= end.seg + end.t; + const between = this.betweenLength(start.edge, start, end); + relax(T, this.edgeSeconds(start.edge, forward, between, profile), { + from: S, + kind: 'partial', + edge: start.edge, + forward, + lake: -1, + }); + } + } else { + lakeHops(start.lake, start.cell, 0, S); + } + + let settled = 0; + while (heap.size > 0) { + const node = heap.pop(); + if (node === T) return came; + if (++settled > MAX_SETTLED) return null; + // Checked on the first node already: a job that waited past its time in the queue gives up at once. + if ((settled & 1023) === 1 && Date.now() > deadline) return 'timeout'; + const base = cost.get(node) as number; + if (node === S) continue; + for (let i = this.offsets[node] as number; i < (this.offsets[node + 1] as number); i++) { + const arc = this.arcs[i] as number; + const edge = arc >> 1; + const forward = (arc & 1) === 0; + const other = forward ? (g.edgeB[edge] as number) : (g.edgeA[edge] as number); + const seconds = this.edgeSeconds(edge, forward, g.edgeLength[edge] as number, profile); + if (Number.isFinite(seconds)) + relax(other, base + seconds, { from: node, kind: 'edge', edge, forward, lake: -1 }); + } + if (end.type === 'edge') { + if (node === g.edgeA[end.edge]) { + const length = this.partialLength(end.edge, end, 'toA'); + relax(T, base + this.edgeSeconds(end.edge, true, length, profile), { + from: node, + kind: 'partial', + edge: end.edge, + forward: true, + lake: -1, + }); + } + if (node === g.edgeB[end.edge]) { + const length = this.partialLength(end.edge, end, 'toB'); + relax(T, base + this.edgeSeconds(end.edge, false, length, profile), { + from: node, + kind: 'partial', + edge: end.edge, + forward: false, + lake: -1, + }); + } + } + const lake = g.nodeLake[node] as number; + if (lake >= 0) { + const cell = this.portalCell(this.field(lake), node); + if (cell >= 0) lakeHops(lake, cell, base, node); + } + } + return null; + } + + /** Metres from a snapped point along its edge to the edge's a or b end. */ + private partialLength(edge: number, snap: SnapEdge, toward: 'toA' | 'toB'): number { + const count = this.graph.edgeGeomCount[edge] as number; + const a = this.point(edge, snap.seg); + const b = this.point(edge, snap.seg + 1); + let length = 0; + if (toward === 'toA') { + length += metresBetween(snap.lat, snap.lng, a[0], a[1]); + for (let i = snap.seg; i > 0; i--) length += this.segmentLength(edge, i - 1); + } else { + length += metresBetween(snap.lat, snap.lng, b[0], b[1]); + for (let i = snap.seg + 1; i < count - 1; i++) length += this.segmentLength(edge, i); + } + return length; + } + + private segmentLength(edge: number, seg: number): number { + const a = this.point(edge, seg); + const b = this.point(edge, seg + 1); + return metresBetween(a[0], a[1], b[0], b[1]); + } + + private betweenLength(edge: number, x: SnapEdge, y: SnapEdge): number { + return Math.abs(this.partialLength(edge, x, 'toA') - this.partialLength(edge, y, 'toA')); + } + + /** The points of an edge between two snapped spots on it (or a snap and an end), in travel order. */ + private partialLine(edge: number, from: SnapEdge | 'a' | 'b', to: SnapEdge | 'a' | 'b'): Array<[number, number]> { + const count = this.graph.edgeGeomCount[edge] as number; + const position = (spot: SnapEdge | 'a' | 'b') => (spot === 'a' ? 0 : spot === 'b' ? count - 1 : spot.seg + spot.t); + const p = position(from); + const q = position(to); + const points: Array<[number, number]> = []; + const at = (spot: SnapEdge | 'a' | 'b'): [number, number] => + spot === 'a' ? this.point(edge, 0) : spot === 'b' ? this.point(edge, count - 1) : [spot.lat, spot.lng]; + points.push(at(from)); + if (p <= q) for (let i = Math.floor(p) + 1; i < q; i++) points.push(this.point(edge, i)); + else for (let i = Math.ceil(p) - 1; i > q; i--) points.push(this.point(edge, i)); + points.push(at(to)); + return points; + } + + private assemble(came: Map, start: Snap, end: Snap, profile: RouteProfile): PaddleRouteLeg { + const g = this.graph; + const nodes = g.nodeLat.length; + const S = nodes; + const T = nodes + 1; + const chain: Array<{ node: number; step: Step }> = []; + for (let node = T; node !== S;) { + const step = came.get(node) as Step; + chain.unshift({ node, step }); + node = step.from; + } + const lakeProfile = this.lakeProfile(profile); + const pieces: Piece[] = []; + const nodePoint = (node: number): [number, number] => + node === S + ? [start.lat, start.lng] + : node === T + ? [end.lat, end.lng] + : [g.nodeLat[node] as number, g.nodeLng[node] as number]; + for (const { node, step } of chain) { + if (step.kind === 'lake') { + const field = this.field(step.lake); + const fromPoint = nodePoint(step.from); + const toPoint = nodePoint(node); + const fromCell = + step.from === S && start.type === 'lake' ? start.cell : field.waterCellNear(fromPoint[0], fromPoint[1]); + const toCell = node === T && end.type === 'lake' ? end.cell : field.waterCellNear(toPoint[0], toPoint[1]); + const middle = field.path(fromCell, toCell, lakeProfile) ?? []; + pieces.push({ + points: [fromPoint, ...middle.slice(1, -1), toPoint], + kind: 'lake', + name: g.lakes[step.lake]?.name ?? null, + source: null, + osmRef: null, + grade: null, + }); + continue; + } + const edge = step.edge; + let points: Array<[number, number]>; + if (step.kind === 'partial') { + const fromSpot = step.from === S && start.type === 'edge' ? start : step.forward ? 'a' : 'b'; + const toSpot = node === T && end.type === 'edge' ? end : step.forward ? 'b' : 'a'; + points = this.partialLine(edge, fromSpot, toSpot); + } else { + const line = this.edgeLine(edge); + points = step.forward ? line : line.reverse(); + } + pieces.push({ points, ...this.describe(edge) }); + } + return this.finish(pieces, start, end); + } + + private describe(edge: number): Omit { + const g = this.graph; + const kind = g.edgeKind[edge]; + const name = (g.edgeName[edge] as number) >= 0 ? (g.strings[g.edgeName[edge] as number] ?? null) : null; + const osmRef = (g.edgeRef[edge] as number) >= 0 ? (g.strings[g.edgeRef[edge] as number] ?? null) : null; + if (kind === EdgeKind.portage) { + const source = + g.edgeSource[edge] === PortageSource.tagged + ? 'tagged' + : g.edgeSource[edge] === PortageSource.footway + ? 'footway' + : 'inferred'; + return { kind: 'portage', name, source, osmRef, grade: null }; + } + if (kind === EdgeKind.lock) return { kind: 'lock', name, source: null, osmRef, grade: null }; + if (kind === EdgeKind.gap) return { kind: 'gap', name: null, source: null, osmRef: null, grade: null }; + if (kind === EdgeKind.pass) return { kind: 'pass', name, source: null, osmRef, grade: null }; + const grade = g.edgeGrade[edge] as number; + if (grade >= RAPIDS_GRADE) return { kind: 'rapids', name, source: null, osmRef, grade }; + return { kind: 'water', name, source: null, osmRef: null, grade: null }; + } + + /** One line from the pieces, with neighbouring pieces of a kind merged into one segment (portages and locks stay apart). */ + private finish(pieces: Piece[], start: Snap, end: Snap): PaddleRouteLeg { + const geometry: Array<[number, number]> = []; + const segments: PaddleRouteSegment[] = []; + for (const piece of pieces) { + const points = piece.points.filter( + (point, i) => i === 0 || point[0] !== piece.points[i - 1]?.[0] || point[1] !== piece.points[i - 1]?.[1], + ); + if (points.length < 2) continue; + const offset = geometry.length === 0 ? 0 : geometry.length - 1; + geometry.push(...(geometry.length === 0 ? points : points.slice(1))); + const to = geometry.length - 1; + const previous = segments[segments.length - 1]; + const mergeable = piece.kind !== 'portage' && piece.kind !== 'lock'; + // One water keeps one segment; where the name changes (Naab into Donau) a new one starts. + const sameWater = !previous?.name || !piece.name || previous.name === piece.name; + if (previous && mergeable && previous.kind === piece.kind && previous.grade === piece.grade && sameWater) { + previous.to = to; + previous.name ??= piece.name; + } else { + segments.push({ + kind: piece.kind, + from: offset, + to, + name: piece.name, + source: piece.source, + osm_ref: piece.osmRef, + grade: piece.grade, + }); + } + } + if (geometry.length < 2 || segments.length === 0) return { status: 'failed', code: 'no_water_path', at: null }; + let distance = 0; + for (let i = 1; i < geometry.length; i++) { + const a = geometry[i - 1] as [number, number]; + const b = geometry[i] as [number, number]; + distance += metresBetween(a[0], a[1], b[0], b[1]); + } + return { + status: 'ok', + geometry, + segments, + distance_m: Math.round(distance), + snapped_m: { from: Math.round(start.distance), to: Math.round(end.distance) }, + }; + } +} diff --git a/server/src/nest/paddle-routing/paddle-router.worker.ts b/server/src/nest/paddle-routing/paddle-router.worker.ts new file mode 100644 index 000000000..3499a54a6 --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-router.worker.ts @@ -0,0 +1,19 @@ +import { GraphCache, runJob, type RouteJob } from './router-runner'; + +import { parentPort } from 'node:worker_threads'; + +/** The router thread: plans the legs it is sent on graphs it keeps loaded, so the server's thread never waits on a search. */ +const cache = new GraphCache(); + +parentPort?.on('message', (message: { id: number; job?: RouteJob; forget?: string }) => { + if (message.forget) { + cache.forget(message.forget); + return; + } + if (!message.job) return; + runJob(cache, message.job).then( + (legs) => parentPort?.postMessage({ id: message.id, legs }), + (err: unknown) => + parentPort?.postMessage({ id: message.id, error: err instanceof Error ? err.message : String(err) }), + ); +}); diff --git a/server/src/nest/paddle-routing/paddle-routing.controller.ts b/server/src/nest/paddle-routing/paddle-routing.controller.ts new file mode 100644 index 000000000..f5d6dfea7 --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-routing.controller.ts @@ -0,0 +1,29 @@ +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { PaddleRouteRequestDto } from './paddle-routing.dto'; +import { PaddleRoutingService } from './paddle-routing.service'; +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { paddleRouteResponseSchema } from '@trek/shared'; + +/** + * POST /api/trips/:tripId/paddle/route: legs of a paddle tour planned on the + * water (PAD-11). It reads and writes nothing of the trip; the trip in the + * path only says whose planner asks, so anyone who can see the trip may. + */ +@Controller('api/trips/:tripId/paddle') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class PaddleRoutingController { + constructor(private readonly routing: PaddleRoutingService) {} + + @Post('route') + @HttpCode(200) + @ResponseContract(paddleRouteResponseSchema) + async route(@Body() body: PaddleRouteRequestDto) { + return await this.routing.route(body); + } +} diff --git a/server/src/nest/paddle-routing/paddle-routing.dto.ts b/server/src/nest/paddle-routing/paddle-routing.dto.ts new file mode 100644 index 000000000..18425debc --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-routing.dto.ts @@ -0,0 +1,5 @@ +import { paddleRouteRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class PaddleRouteRequestDto extends createZodDto(paddleRouteRequestSchema) {} diff --git a/server/src/nest/paddle-routing/paddle-routing.mcp.ts b/server/src/nest/paddle-routing/paddle-routing.mcp.ts new file mode 100644 index 000000000..fda7b5aac --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-routing.mcp.ts @@ -0,0 +1,50 @@ +import { ADDON_IDS } from '../../addons'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { TripAccessService } from '../trip-membership/trip-access.service'; +import { PaddleRoutingService } from './paddle-routing.service'; +import { idSchema, paddleRouteRequestSchema, type PaddleRouteInput, type PaddleRouteLeg } from '@trek/shared'; + +/** A planned leg as a client reads it: its length and its stretches, not its thousands of points. */ +function summary(leg: PaddleRouteLeg) { + if (leg.status === 'failed') return leg; + return { + status: leg.status, + distance_m: leg.distance_m, + snapped_m: leg.snapped_m, + points: leg.geometry.length, + segments: leg.segments.map(({ kind, name, source, osm_ref }) => ({ kind, name, source, osm_ref })), + }; +} + +/** + * PAD-11: the paddle router for a client, read only and with the REST + * route's rules: the caller must see the trip, Tours must be on, and the + * request is the same contract. It plans and answers; nothing is saved. + */ +@McpController() +export class PaddleRoutingMcp { + constructor( + private readonly routing: PaddleRoutingService, + private readonly access: TripAccessService, + // Read by the addon gate. + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'plan_paddle_route', + description: + 'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.', + inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async planPaddleRoute({ tripId, ...request }: PaddleRouteInput & { tripId: number }, ctx: McpContext) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + const planned = await this.routing.route(paddleRouteRequestSchema.parse(request)); + return ok({ legs: planned.legs.map(summary), graph_rev: planned.graph_rev }); + } +} diff --git a/server/src/nest/paddle-routing/paddle-routing.module.ts b/server/src/nest/paddle-routing/paddle-routing.module.ts new file mode 100644 index 000000000..199629a64 --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-routing.module.ts @@ -0,0 +1,34 @@ +import { PaddleWaterGraphs } from '../../db/entities/PaddleWaterGraphs.entity'; +import { AddonsModule } from '../addons/addons.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { PaddleRoutingController } from './paddle-routing.controller'; +import { PaddleRoutingMcp } from './paddle-routing.mcp'; +import { PaddleRoutingService } from './paddle-routing.service'; +import { InlineRouterRunner, PADDLE_ROUTER_RUNNER } from './router-runner'; +import { WorkerRouterRunner } from './worker-router-runner'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The router's own thread where the build ships its script; on the calling thread under the TypeScript sources (the tests). */ +function routerRunner() { + const entry = join(__dirname, 'paddle-router.worker.js'); + return existsSync(entry) ? new WorkerRouterRunner(entry) : new InlineRouterRunner(); +} + +/** + * PAD-11: routing for paddle tours on a water network TREK builds itself, + * from the regions the paddle obstacles domain reads. The search runs in a + * worker thread of the server; PaddleRoutingService is exported for that + * domain to hand over each region's network as it is built. + */ +@Module({ + imports: [AddonsModule, PermissionsModule, TripMembershipModule, MikroOrmModule.forFeature([PaddleWaterGraphs])], + controllers: [PaddleRoutingController], + providers: [PaddleRoutingService, PaddleRoutingMcp, { provide: PADDLE_ROUTER_RUNNER, useFactory: routerRunner }], + exports: [PaddleRoutingService], +}) +export class PaddleRoutingModule {} diff --git a/server/src/nest/paddle-routing/paddle-routing.service.ts b/server/src/nest/paddle-routing/paddle-routing.service.ts new file mode 100644 index 000000000..d5b326c63 --- /dev/null +++ b/server/src/nest/paddle-routing/paddle-routing.service.ts @@ -0,0 +1,212 @@ +import { PaddleWaterGraphs } from '../../db/entities/PaddleWaterGraphs.entity'; +import { PADDLE_REGION_GRAPH_STATS } from '../../db/json-columns'; +import type { + PaddleWaterGraphRow, + PaddleWaterGraphsRepository, +} from '../../db/repositories/PaddleWaterGraphs.repository'; +import { dbNow, utcSuffix } from '../../db/types'; +import { decodeJson, encodeJson } from '../../utils/json-column'; +import { DataPathsService } from '../app-config/data-paths.service'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { withRequestContext } from '../database/request-context'; +import type { RouteProfile } from './engine/router'; +import { PADDLE_ROUTER_RUNNER, type RouterRunner } from './router-runner'; +import type { BuiltWaterGraph } from './water-graph.contract'; +import { MikroORM } from '@mikro-orm/core'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Inject, Injectable, type OnApplicationBootstrap, type OnApplicationShutdown } from '@nestjs/common'; +import type { PaddleRegion, PaddleRouteLeg, PaddleRouteRequest, PaddleRouteResponse } from '@trek/shared'; + +import { mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const GRAPH_SUFFIX = '.wgraph'; + +type Network = NonNullable; + +/** + * PAD-11: routing on the water. Each paddle region's import hands over the + * water network it built; this service keeps the network's file in the data + * folder (written beside, then renamed into place, so a reader never sees half + * a file), records it, and plans legs on it in the router's thread. A leg is + * planned on the smallest region whose network covers both its ends. + */ +@Injectable() +export class PaddleRoutingService implements OnApplicationBootstrap, OnApplicationShutdown { + constructor( + private readonly paths: DataPathsService, + private readonly orm: MikroORM, + @InjectRepository(PaddleWaterGraphs) private readonly graphs: PaddleWaterGraphsRepository, + @Inject(PADDLE_ROUTER_RUNNER) private readonly runner: RouterRunner, + ) {} + + private dir(): string { + return join(this.paths.dataDir, 'paddle', 'graphs'); + } + + private pathOf(file: string): string { + return join(this.dir(), file); + } + + /** Clears network files no region names any more, and halves of a write a crash left. */ + async onApplicationBootstrap(): Promise { + try { + await withRequestContext(this.orm, () => this.sweep()); + } catch (err) { + logError(`[Paddle] sweeping water networks: ${err instanceof Error ? err.message : String(err)}`); + } + } + + async onApplicationShutdown(): Promise { + await this.runner.close(); + } + + async sweep(): Promise { + const kept = new Set((await this.graphs.list()).map((row) => row.file)); + let entries: string[]; + try { + entries = await readdir(this.dir()); + } catch { + return; + } + const stale = entries.filter( + (entry) => !kept.has(entry) && (entry.endsWith(GRAPH_SUFFIX) || entry.endsWith('.tmp')), + ); + await Promise.all(stale.map((entry) => rm(this.pathOf(entry), { force: true }))); + } + + /** Writes a built network beside its final name and renames it into place; returns the file's name. */ + async writeGraphFile(regionId: string, built: BuiltWaterGraph): Promise { + await mkdir(this.dir(), { recursive: true }); + const file = `${regionId.replaceAll('/', '_')}.${built.rev}${GRAPH_SUFFIX}`; + const temp = this.pathOf(`${file}.tmp`); + await writeFile(temp, built.bytes); + await rename(temp, this.pathOf(file)); + return file; + } + + /** Records a region's new network; called inside the import's transaction, beside the swap of its obstacles. */ + async recordGraph(regionId: string, file: string, built: BuiltWaterGraph): Promise { + const previous = (await this.graphs.findByRegion(regionId))?.file ?? null; + await this.graphs.saveGraph({ + region_id: regionId, + file, + rev: built.rev, + bytes: built.bytes.byteLength, + built_at: dbNow(), + stats: encodeJson(PADDLE_REGION_GRAPH_STATS, built.stats), + min_lat: built.box.minLat, + min_lng: built.box.minLng, + max_lat: built.box.maxLat, + max_lng: built.box.maxLng, + }); + return previous; + } + + /** After the import committed: the network it replaced goes, from disk and from the router's memory. */ + async replaced(previous: string | null, current: string): Promise { + if (!previous || previous === current) return; + this.runner.forget(this.pathOf(previous)); + await rm(this.pathOf(previous), { force: true }); + } + + /** A written network whose import did not commit. */ + async discard(file: string): Promise { + await rm(this.pathOf(file), { force: true }); + } + + /** What each region's network holds, by region id, for the admin list. */ + async networks(): Promise> { + const rows = await this.graphs.list(); + return new Map( + rows.flatMap((row) => { + const network = this.networkOf(row); + return network ? [[row.region_id, network] as const] : []; + }), + ); + } + + /** The regions whose network reaches a point, smallest first: where a leg ending there would be planned. */ + async regionsCovering(lat: number, lng: number): Promise { + return (await this.graphs.coveringPoint(lat, lng)).map((row) => row.region_id); + } + + private networkOf(row: PaddleWaterGraphRow): Network | null { + const stats = decodeJson(PADDLE_REGION_GRAPH_STATS, row.stats, row.region_id); + if (!stats) return null; + return { ...stats, built_at: utcSuffix(row.built_at) ?? row.built_at, bytes: row.bytes }; + } + + /** + * Plans every leg of a request: each on the smallest region covering both + * its ends, the legs of one region in one go. A leg no region covers, or + * whose ends lie in regions that do not overlap, fails on its own; the + * others are planned all the same. + */ + async route(request: PaddleRouteRequest): Promise { + const profile: RouteProfile = { + boat: request.boat, + lakeStyle: request.lake_style, + lockPolicy: request.lock_policy, + }; + const legs: PaddleRouteLeg[] = new Array(request.legs.length); + const jobs = new Map(); + const placed = await Promise.all(request.legs.map((leg) => this.regionOf(leg))); + placed.forEach((place, index) => { + if ('status' in place) { + legs[index] = place; + return; + } + const job = jobs.get(place.region_id) ?? { row: place, indices: [] }; + job.indices.push(index); + jobs.set(place.region_id, job); + }); + // Each region's legs in one job; the router thread takes them one after the other. + await Promise.all( + [...jobs.values()].map(async ({ row, indices }) => { + const planned = await this.planOn( + row, + indices.map((index) => request.legs[index] as PaddleRouteRequest['legs'][number]), + profile, + ); + indices.forEach((index, at) => (legs[index] = planned[at] as PaddleRouteLeg)); + }), + ); + const revs = [...jobs.values()].map(({ row }) => row.rev); + return { legs, graph_rev: revs.length > 0 ? revs.join('+') : null }; + } + + /** The smallest region whose network covers both ends of a leg, or why there is none. */ + private async regionOf(leg: PaddleRouteRequest['legs'][number]): Promise { + const [fromRows, toRows] = await Promise.all([ + this.graphs.coveringPoint(leg.from.lat, leg.from.lng), + this.graphs.coveringPoint(leg.to.lat, leg.to.lng), + ]); + if (fromRows.length === 0) return { status: 'failed', code: 'no_region', at: leg.from }; + if (toRows.length === 0) return { status: 'failed', code: 'no_region', at: leg.to }; + const toIds = new Set(toRows.map((row) => row.region_id)); + return ( + fromRows.find((candidate) => toIds.has(candidate.region_id)) ?? { + status: 'failed', + code: 'cross_region', + at: leg.to, + } + ); + } + + /** One region's legs planned in the router's thread; a thread that fails or hangs fails them all, as busy or timeout. */ + private async planOn( + row: PaddleWaterGraphRow, + legs: PaddleRouteRequest['legs'], + profile: RouteProfile, + ): Promise { + try { + return await this.runner.run({ file: this.pathOf(row.file), legs, profile }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logInfo(`[Paddle] routing on ${row.region_id}: ${message}`); + const code = message === 'timeout' ? 'timeout' : 'busy'; + return legs.map((): PaddleRouteLeg => ({ status: 'failed', code, at: null })); + } + } +} diff --git a/server/src/nest/paddle-routing/router-runner.ts b/server/src/nest/paddle-routing/router-runner.ts new file mode 100644 index 000000000..80be7f71b --- /dev/null +++ b/server/src/nest/paddle-routing/router-runner.ts @@ -0,0 +1,74 @@ +import { WaterRouter, type RouteProfile } from './engine/router'; +import { decodeWaterGraph } from './water-graph'; +import type { PaddleRouteLeg, PaddleRoutePoint } from '@trek/shared'; + +import { readFile } from 'node:fs/promises'; + +/** One region's legs to plan, read from the graph file at `file`. */ +export interface RouteJob { + file: string; + legs: Array<{ from: PaddleRoutePoint; to: PaddleRoutePoint }>; + profile: RouteProfile; +} + +/** Plans legs on a region's graph: in a worker thread in production, on the calling thread in tests. */ +export interface RouterRunner { + run(job: RouteJob): Promise; + /** Drops a graph from memory, after a region was built again or removed. */ + forget(file: string): void; + close(): Promise; +} + +export const PADDLE_ROUTER_RUNNER = Symbol('PADDLE_ROUTER_RUNNER'); + +/** How many regions' graphs stay loaded; the least recently used one goes first. */ +const LOADED_REGIONS = 4; + +/** The loaded graphs of a thread, newest use last. */ +export class GraphCache { + private readonly routers = new Map>(); + + get(file: string): Promise { + let router = this.routers.get(file); + if (router !== undefined) { + this.routers.delete(file); + } else { + router = readFile(file).then((bytes) => new WaterRouter(decodeWaterGraph(bytes))); + router.catch(() => this.routers.delete(file)); + } + this.routers.set(file, router); + while (this.routers.size > LOADED_REGIONS) this.routers.delete(this.routers.keys().next().value as string); + return router; + } + + forget(file: string): void { + this.routers.delete(file); + } + + clear(): void { + this.routers.clear(); + } +} + +export async function runJob(cache: GraphCache, job: RouteJob): Promise { + const router = await cache.get(job.file); + return job.legs.map(({ from, to }) => router.route(from, to, job.profile)); +} + +/** The test runner: the same work on the calling thread. */ +export class InlineRouterRunner implements RouterRunner { + private readonly cache = new GraphCache(); + + run(job: RouteJob): Promise { + return runJob(this.cache, job); + } + + forget(file: string): void { + this.cache.forget(file); + } + + close(): Promise { + this.cache.clear(); + return Promise.resolve(); + } +} diff --git a/server/src/nest/paddle-routing/water-graph.contract.ts b/server/src/nest/paddle-routing/water-graph.contract.ts new file mode 100644 index 000000000..404d3be6a --- /dev/null +++ b/server/src/nest/paddle-routing/water-graph.contract.ts @@ -0,0 +1,53 @@ +import { buildWaterGraph } from './build/build-graph'; +import { WaterCollector } from './build/collect'; +import { SNAP_M } from './engine/router'; +import { encodeWaterGraph } from './water-graph'; +import type { PaddleRegionGraphStats } from '@trek/shared'; + +/** + * PAD-11, what the paddle region import uses of routing: a collector that + * rides along the extract's passes, and the build of the region's network + * from what it kept, as the bytes of the network's file. + */ +export { WaterCollector }; + +export interface BuiltWaterGraph { + bytes: Uint8Array; + rev: string; + stats: PaddleRegionGraphStats; + box: { minLat: number; minLng: number; maxLat: number; maxLng: number }; +} + +export function buildRegionGraph( + collector: WaterCollector, + coordOf: (nodeId: number) => [number, number] | null, + meta: { regionId: string; sourceTimestamp: string | null }, +): BuiltWaterGraph | null { + const graph = buildWaterGraph(collector.water, coordOf, meta); + if (graph.nodeLat.length === 0 && graph.lakes.length === 0) return null; + const box = { minLat: 90, minLng: 180, maxLat: -90, maxLng: -180 }; + for (let i = 0; i < graph.nodeLat.length; i++) { + box.minLat = Math.min(box.minLat, graph.nodeLat[i] as number); + box.maxLat = Math.max(box.maxLat, graph.nodeLat[i] as number); + box.minLng = Math.min(box.minLng, graph.nodeLng[i] as number); + box.maxLng = Math.max(box.maxLng, graph.nodeLng[i] as number); + } + for (const lake of graph.lakes) { + box.minLat = Math.min(box.minLat, lake.lat0); + box.minLng = Math.min(box.minLng, lake.lng0); + box.maxLat = Math.max(box.maxLat, lake.lat0 + (lake.height * lake.cell) / 111_195); + box.maxLng = Math.max( + box.maxLng, + lake.lng0 + (lake.width * lake.cell) / (111_195 * Math.cos((lake.lat0 * Math.PI) / 180)), + ); + } + // Widened by the snap distance: a point on the bank beside the outermost water still finds this network. + const padLat = SNAP_M / 111_195; + const padLng = SNAP_M / (111_195 * Math.cos((((box.minLat + box.maxLat) / 2) * Math.PI) / 180)); + box.minLat -= padLat; + box.maxLat += padLat; + box.minLng -= padLng; + box.maxLng += padLng; + const { bytes, rev } = encodeWaterGraph(graph); + return { bytes, rev, stats: graph.stats, box }; +} diff --git a/server/src/nest/paddle-routing/water-graph.ts b/server/src/nest/paddle-routing/water-graph.ts new file mode 100644 index 000000000..b0ddc1c36 --- /dev/null +++ b/server/src/nest/paddle-routing/water-graph.ts @@ -0,0 +1,206 @@ +import type { PaddleRegionGraphStats } from '@trek/shared'; + +import { createHash } from 'node:crypto'; + +/** + * The water network of one region, as the router holds it: nodes where waters + * meet, end or are barred, edges along the rivers and canals between them + * (each with its line in a shared pool of points), the portages and gaps the + * builder added, and every lake as a grid of water cells. Plain typed arrays, + * so a region loads from its file in one read and costs a few bytes a point. + */ + +const GRAPH_FORMAT = 2; + +/** `pass` is a boat pass beside a weir (waterway=canoe_pass): paddled, and worth telling the paddler about. */ +export const EdgeKind = { water: 0, portage: 1, gap: 2, lock: 3, connector: 4, pass: 5 } as const; +export type EdgeKindCode = (typeof EdgeKind)[keyof typeof EdgeKind]; + +/** Who may paddle an edge: anyone, nobody (canoe=no, private), or anyone at a cost (discouraged). */ +export const Access = { open: 0, closed: 1, discouraged: 2 } as const; + +export const PortageSource = { tagged: 0, footway: 1, inferred: 2 } as const; + +export interface LakeGrid { + name: string | null; + /** The grid's south-west corner and its cell size in metres. */ + lat0: number; + lng0: number; + cell: number; + width: number; + height: number; + /** One bit per cell, row by row from the south: set where the cell is water. */ + mask: Uint8Array; + /** The nodes through which the network enters and leaves the lake. */ + portals: Int32Array; + /** Hectares of water, for the stats and for keeping small ponds out. */ + hectares: number; +} + +export interface WaterGraphData { + regionId: string; + sourceTimestamp: string | null; + nodeLat: Float64Array; + nodeLng: Float64Array; + /** The lake a node is a portal of, or -1. */ + nodeLake: Int32Array; + edgeA: Uint32Array; + edgeB: Uint32Array; + edgeLength: Float32Array; + edgeKind: Uint8Array; + /** 1 when the water flows from a to b (a river drawn downstream), 0 on still water. */ + edgeFlow: Uint8Array; + /** The current, km/h, where the water flows. */ + edgeCurrent: Float32Array; + edgeAccess: Uint8Array; + /** 1 when a boat may only go from a to b (oneway:canoe). */ + edgeOneway: Uint8Array; + /** The whitewater grade, 0 when the data gives none. */ + edgeGrade: Uint8Array; + edgeGeomStart: Uint32Array; + edgeGeomCount: Uint32Array; + edgeName: Int32Array; + edgeRef: Int32Array; + edgeSource: Uint8Array; + /** Every edge's points, [lat, lng] pairs one after another, a to b. */ + pool: Float64Array; + strings: string[]; + lakes: LakeGrid[]; + stats: PaddleRegionGraphStats; +} + +const MAGIC = 'TREKWG'; + +type ArrayName = Exclude< + { + [K in keyof WaterGraphData]: WaterGraphData[K] extends ArrayBufferView ? K : never; + }[keyof WaterGraphData], + undefined +>; + +const ARRAYS: ReadonlyArray<[ArrayName, string]> = [ + ['nodeLat', 'f64'], + ['nodeLng', 'f64'], + ['nodeLake', 'i32'], + ['edgeA', 'u32'], + ['edgeB', 'u32'], + ['edgeLength', 'f32'], + ['edgeKind', 'u8'], + ['edgeFlow', 'u8'], + ['edgeCurrent', 'f32'], + ['edgeAccess', 'u8'], + ['edgeOneway', 'u8'], + ['edgeGrade', 'u8'], + ['edgeGeomStart', 'u32'], + ['edgeGeomCount', 'u32'], + ['edgeName', 'i32'], + ['edgeRef', 'i32'], + ['edgeSource', 'u8'], + ['pool', 'f64'], +]; + +const VIEWS = { + f64: Float64Array, + f32: Float32Array, + i32: Int32Array, + u32: Uint32Array, + u8: Uint8Array, +} as const; + +function padTo8(length: number): number { + return (length + 7) & ~7; +} + +/** + * The graph as one file: a magic, the length of a JSON head, the head (sizes, + * offsets, strings, lake frames) and the arrays, each aligned to 8 bytes. + * The same graph always gives the same bytes, so its hash names its revision. + */ +export function encodeWaterGraph(graph: WaterGraphData): { bytes: Buffer; rev: string } { + const chunks: Buffer[] = []; + let offset = 0; + const sections: Array<{ name: string; type: string; offset: number; length: number }> = []; + const add = (name: string, type: string, view: ArrayBufferView) => { + const raw = Buffer.from(view.buffer, view.byteOffset, view.byteLength); + const padded = Buffer.alloc(padTo8(raw.length)); + raw.copy(padded); + sections.push({ name, type, offset, length: view.byteLength }); + chunks.push(padded); + offset += padded.length; + }; + for (const [name, type] of ARRAYS) add(name, type, graph[name]); + graph.lakes.forEach((lake, index) => { + add(`lake${index}.mask`, 'u8', lake.mask); + add(`lake${index}.portals`, 'i32', lake.portals); + }); + const head = Buffer.from( + JSON.stringify({ + format: GRAPH_FORMAT, + regionId: graph.regionId, + sourceTimestamp: graph.sourceTimestamp, + strings: graph.strings, + stats: graph.stats, + lakes: graph.lakes.map(({ name, lat0, lng0, cell, width, height, hectares }) => ({ + name, + lat0, + lng0, + cell, + width, + height, + hectares, + })), + sections, + }), + 'utf8', + ); + const prefix = Buffer.alloc(padTo8(MAGIC.length + 4 + head.length)); + prefix.write(MAGIC, 0, 'latin1'); + prefix.writeUInt32LE(head.length, MAGIC.length); + head.copy(prefix, MAGIC.length + 4); + const bytes = Buffer.concat([prefix, ...chunks]); + return { bytes, rev: createHash('sha256').update(bytes).digest('hex').slice(0, 16) }; +} + +interface Head { + format: number; + regionId: string; + sourceTimestamp: string | null; + strings: string[]; + stats: PaddleRegionGraphStats; + lakes: Array>; + sections: Array<{ name: string; type: keyof typeof VIEWS; offset: number; length: number }>; +} + +export function decodeWaterGraph(bytes: Buffer): WaterGraphData { + if (bytes.toString('latin1', 0, MAGIC.length) !== MAGIC) throw new Error('Not a water graph'); + const headLength = bytes.readUInt32LE(MAGIC.length); + const head = JSON.parse(bytes.toString('utf8', MAGIC.length + 4, MAGIC.length + 4 + headLength)) as Head; + if (head.format !== GRAPH_FORMAT) throw new Error(`Water graph format ${head.format} is not ${GRAPH_FORMAT}`); + const base = padTo8(MAGIC.length + 4 + headLength); + // Copied out of the file buffer, so every view starts aligned whatever Node's pool did with it. + const arrays = new Map(); + for (const section of head.sections) { + const View = VIEWS[section.type]; + const copy = new Uint8Array(section.length); + copy.set(bytes.subarray(base + section.offset, base + section.offset + section.length)); + arrays.set(section.name, new View(copy.buffer, 0, section.length / View.BYTES_PER_ELEMENT)); + } + const take = (name: string): T => { + const view = arrays.get(name); + if (!view) throw new Error(`Water graph lacks ${name}`); + return view as T; + }; + const graph = { + regionId: head.regionId, + sourceTimestamp: head.sourceTimestamp, + strings: head.strings, + stats: head.stats, + lakes: head.lakes.map((lake, index) => ({ + ...lake, + mask: take(`lake${index}.mask`), + portals: take(`lake${index}.portals`), + })), + } as Partial; + for (const [name] of ARRAYS) (graph as Record)[name] = take(name); + return graph as WaterGraphData; +} diff --git a/server/src/nest/paddle-routing/worker-router-runner.ts b/server/src/nest/paddle-routing/worker-router-runner.ts new file mode 100644 index 000000000..71a5b7877 --- /dev/null +++ b/server/src/nest/paddle-routing/worker-router-runner.ts @@ -0,0 +1,102 @@ +import type { RouteJob, RouterRunner } from './router-runner'; +import type { PaddleRouteLeg } from '@trek/shared'; + +import { join } from 'node:path'; +import { Worker } from 'node:worker_threads'; + +/** A request the thread has not answered in this long is given up, and the thread is started over. */ +const ANSWER_MS = 8000; +/** A thread with nothing to do is ended after this long; the next request starts it again. */ +const IDLE_MS = 30 * 60 * 1000; + +interface Pending { + resolve: (legs: PaddleRouteLeg[]) => void; + reject: (err: Error) => void; + timer: NodeJS.Timeout; +} + +/** + * The router's thread, started on the first request and ended when idle. A + * search that hangs or a thread that dies fails its requests and is replaced + * by a fresh thread, so one bad graph never stops routing for good. + */ +export class WorkerRouterRunner implements RouterRunner { + private worker: Worker | null = null; + private nextId = 1; + private readonly pending = new Map(); + private idle: NodeJS.Timeout | null = null; + + constructor(private readonly entry = join(__dirname, 'paddle-router.worker.js')) {} + + run(job: RouteJob): Promise { + const worker = this.thread(); + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error('timeout')); + this.restart(); + }, ANSWER_MS); + this.pending.set(id, { resolve, reject, timer }); + worker.postMessage({ id, job }); + }); + } + + forget(file: string): void { + this.worker?.postMessage({ id: 0, forget: file }); + } + + async close(): Promise { + if (this.idle) clearTimeout(this.idle); + this.failAll(new Error('closed')); + const worker = this.worker; + this.worker = null; + await worker?.terminate(); + } + + private thread(): Worker { + if (this.idle) clearTimeout(this.idle); + this.idle = setTimeout(() => void this.close(), IDLE_MS); + this.idle.unref(); + if (this.worker) return this.worker; + // The thread loads compiled code the way the server does (with the path aliases the server's own start registers). + const execArgv = process.execArgv.filter( + (arg, index, all) => + arg === '--require' || arg === '-r' || all[index - 1] === '--require' || all[index - 1] === '-r', + ); + const worker = new Worker(this.entry, { execArgv }); + worker.on('message', (message: { id: number; legs?: PaddleRouteLeg[]; error?: string }) => { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.legs) pending.resolve(message.legs); + else pending.reject(new Error(message.error ?? 'router failed')); + }); + worker.on('error', (err: Error) => { + if (this.worker === worker) this.worker = null; + this.failAll(err); + }); + worker.on('exit', () => { + if (this.worker === worker) this.worker = null; + this.failAll(new Error('router thread ended')); + }); + worker.unref(); + this.worker = worker; + return worker; + } + + private restart(): void { + const worker = this.worker; + this.worker = null; + void worker?.terminate(); + } + + private failAll(err: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(err); + this.pending.delete(id); + } + } +} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 9669e483b..a7a1655be 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -34,6 +34,7 @@ import { plannedTourDurationMinutesSchema, tourWebsiteSchema, type PaddleBoatType, + type PaddleLakeStyle, type PaddleWaterContext, type TourCreateRequest, type TourCreateResponse, @@ -103,6 +104,7 @@ export class ToursService { water_context: r.water_context as PaddleWaterContext | null, paddle_speed_kmh: r.paddle_speed_kmh, portage_speed_kmh: r.portage_speed_kmh, + paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null, track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'), has_recorded_track: Boolean(r.has_recorded_track), planned: Boolean(r.planned), @@ -135,6 +137,7 @@ export class ToursService { water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, portage_speed_kmh: input.portage_speed_kmh ?? null, + paddle_lake_style: input.paddle_lake_style ?? null, }; } @@ -365,6 +368,7 @@ export class ToursService { water_context: row.water_context as PaddleWaterContext | null, paddle_speed_kmh: row.paddle_speed_kmh, portage_speed_kmh: row.portage_speed_kmh, + paddle_lake_style: row.paddle_lake_style as PaddleLakeStyle | null, }); await this.waypointsRepo.deleteForPlace(pid); await this.toursRepo.clearRecordedTrack(pid); @@ -427,6 +431,7 @@ export class ToursService { water_context: source.water_context as PaddleWaterContext | null, paddle_speed_kmh: source.paddle_speed_kmh, portage_speed_kmh: source.portage_speed_kmh, + paddle_lake_style: source.paddle_lake_style as PaddleLakeStyle | null, track_marks: source.track_marks, wanderer_ref: source.wanderer_ref, difficulty: source.difficulty, @@ -579,6 +584,7 @@ export class ToursService { derived_from_place_id: null, origin: 'gpx', portage_speed_kmh: null, + paddle_lake_style: null, track_marks: null, has_recorded_track: 0, planned: 0, diff --git a/server/tests/e2e/paddle-obstacles.e2e.test.ts b/server/tests/e2e/paddle-obstacles.e2e.test.ts index afeca3463..f8d5d7fe5 100644 --- a/server/tests/e2e/paddle-obstacles.e2e.test.ts +++ b/server/tests/e2e/paddle-obstacles.e2e.test.ts @@ -109,7 +109,7 @@ async function build() { .overrideProvider(GeofabrikClient) .useValue(geofabrik) .overrideProvider(DataPathsService) - .useValue({ tmpDir: dir }) + .useValue({ tmpDir: dir, dataDir: dir }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); diff --git a/server/tests/e2e/paddle-routing.e2e.test.ts b/server/tests/e2e/paddle-routing.e2e.test.ts new file mode 100644 index 000000000..249053feb --- /dev/null +++ b/server/tests/e2e/paddle-routing.e2e.test.ts @@ -0,0 +1,200 @@ +/** + * Paddle routing e2e (PAD-11): `POST /api/trips/:tripId/paddle/route` through + * the real AddonGuard, JwtAuthGuard and TripAccessGuard, the Zod pipe and the + * response contract, over the real service, repository and router on a + * migrated temp SQLite db. The region's network is built from an extract the + * test draws (tests/helpers/water-world.ts) and handed over as the import does. + */ +import { PaddleRegions } from '../../src/db/entities/PaddleRegions.entity'; +import { AppConfigModule } from '../../src/nest/app-config/app-config.module'; +import { DataPathsService } from '../../src/nest/app-config/data-paths.service'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { withRequestContext } from '../../src/nest/database/request-context'; +import { importExtract } from '../../src/nest/paddle-obstacles/paddle-import'; +import { PaddleRoutingModule } from '../../src/nest/paddle-routing/paddle-routing.module'; +import { PaddleRoutingService } from '../../src/nest/paddle-routing/paddle-routing.service'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { insertRow } from '../helpers/factories/rows'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { addTripMember, makeTrip } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { WaterWorld } from '../helpers/water-world'; +import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; +import { Test } from '@nestjs/testing'; +import { paddleRouteResponseSchema } from '@trek/shared'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const db = createSnapshotTestDb(); + +const OWNER = 1; +const MEMBER = 2; +const STRANGER = 3; +let tripId: number; +let orm: TestOrm; +let server: Server; +let dir: string; +let app: Awaited>; + +/** The Regen, 6 km from Regenstauf south, with the Lindenmühle weir across it and no portage mapped. */ +const regen = new WaterWorld({ lat: 49.07, lng: 12.12 }); +const river = regen.line( + [ + [0, 6000], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, +); +regen.tag(river.refs[30] as number, { waterway: 'weir', name: 'Lindenmühle' }); +// A mill canal 1.5 km to the east, so the region spans land too. +regen.line( + [ + [1500, 6000], + [1500, 0], + ], + { waterway: 'canal', name: 'Mühlkanal' }, +); + +async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + AppConfigModule, + PaddleRoutingModule, + ], + }) + .overrideProvider(DataPathsService) + .useValue({ dataDir: dir, tmpDir: dir }) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; +} + +const route = (body: unknown, userId?: number, trip = tripId) => { + const req = request(server) + .post(`/api/trips/${trip}/paddle/route`) + .send(body as object); + return userId === undefined ? req : req.set({ Cookie: sessionCookie(userId) }); +}; +const leg = { from: regen.at(20, 5500), to: regen.at(-30, 500) }; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-paddle-routing-e2e-')); + orm = await createTestOrm(db); + await makeUser(orm, { id: OWNER, email: 'owner@example.test' }); + await makeUser(orm, { id: MEMBER, email: 'member@example.test' }); + await makeUser(orm, { id: STRANGER, email: 'stranger@example.test' }); + tripId = (await makeTrip(orm, OWNER)).id; + await addTripMember(orm, tripId, MEMBER); + app = await build(); + server = app.getHttpServer(); + + // The region's read, as the paddle obstacles import hands its network over. + const extract = join(dir, 'oberpfalz.osm.pbf'); + await writeFile(extract, regen.pbf()); + const { graph } = await importExtract(extract, 'oberpfalz', () => undefined); + if (!graph) throw new Error('no network'); + await insertRow(orm, PaddleRegions, { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready' }); + const routing = app.get(PaddleRoutingService); + const file = await routing.writeGraphFile('oberpfalz', graph); + await withRequestContext(app.get(MikroORM), () => routing.recordGraph('oberpfalz', file, graph)); +}); + +beforeEach(async () => { + await setAddonEnabled(orm, 'tours', true); +}); + +afterAll(async () => { + await app.close(); + await orm.close(); + db.close(); + await rm(dir, { recursive: true, force: true }); +}); + +describe('POST /api/trips/:tripId/paddle/route', () => { + it('PADROUTE-E2E-001: 404 while Tours is off; 401 without a session', async () => { + await setAddonEnabled(orm, 'tours', false); + const off = await route({ legs: [leg] }, OWNER); + expect(off.status).toBe(404); + expect(off.body).toEqual({ error: 'Tours addon is not enabled' }); + await setAddonEnabled(orm, 'tours', true); + const anonymous = await route({ legs: [leg] }); + expect(anonymous.status).toBe(401); + }); + + it('PADROUTE-E2E-002: a trip the caller is not on answers 404, as one that does not exist', async () => { + expect((await route({ legs: [leg] }, STRANGER)).status).toBe(404); + expect((await route({ legs: [leg] }, OWNER, 999_999)).status).toBe(404); + }); + + it('PADROUTE-E2E-003: refuses a request off the contract with 400', async () => { + // One after another: supertest binds the unstarted server per request, and requests at once reset each other. + const answers = [ + await route({}, OWNER), + await route({ legs: [] }, OWNER), + await route({ legs: [leg], boat: 'yacht' }, OWNER), + await route({ legs: [{ from: { lat: 95, lng: 0 }, to: leg.to }] }, OWNER), + ]; + expect(answers.map((res) => res.status)).toEqual([400, 400, 400, 400]); + }); + + it('PADROUTE-E2E-004: owner and member get the leg planned on the water, carried round the weir, on the contract', async () => { + const answers = [ + await route({ legs: [leg], boat: 'canoe' }, OWNER), + await route({ legs: [leg], boat: 'canoe' }, MEMBER), + ]; + for (const res of answers) { + expect(res.status).toBe(200); + const answer = paddleRouteResponseSchema.parse(res.body); + expect(answer.legs[0]).toMatchObject({ status: 'ok' }); + const planned = answer.legs[0]; + if (planned?.status !== 'ok') throw new Error('not planned'); + expect(planned.distance_m).toBeGreaterThan(4900); + expect(planned.segments.find((segment) => segment.kind === 'portage')).toMatchObject({ + name: 'Lindenmühle', + source: 'inferred', + }); + expect(answer.graph_rev).toMatch(/^[0-9a-f]{16}$/); + } + }); + + it('PADROUTE-E2E-005: answers every leg on its own: one off the water, one where no region has data', async () => { + const res = await route( + { + legs: [ + { from: regen.at(0, 5000), to: regen.at(900, 3000) }, + { from: { lat: 53.4, lng: 12.7 }, to: { lat: 53.5, lng: 12.7 } }, + ], + }, + OWNER, + ); + expect(res.status).toBe(200); + expect(res.body.legs).toEqual([ + { status: 'failed', code: 'not_on_water', at: regen.at(900, 3000) }, + { status: 'failed', code: 'no_region', at: { lat: 53.4, lng: 12.7 } }, + ]); + }); +}); diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 775f48815..acc9c3afb 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -714,6 +714,42 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(readAgain(detail.body)).toEqual(readAgain(res.body)); }); + it('saves how a paddle tour crosses lakes and its shaping points, and reads them back (PAD-11, P2A-03)', async () => { + const body = carried({ paddle_lake_style: 'shore' }); + (body.waypoints as Array>)[1] = { + lat: 47.01, + lng: 11.01, + role: 'via', + sequence: 1, + shaping: true, + }; + const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body); + expect(res.status).toBe(201); + expect(res.body.tour).toMatchObject({ paddle_lake_style: 'shore' }); + expect(res.body.waypoints[1]).toMatchObject({ shaping: true }); + expect(res.body.waypoints[0].shaping).toBeUndefined(); + const detail = await request(server) + .get(`/api/trips/5/tours/${res.body.tour.place_id}`) + .set('Cookie', sessionCookie(OWNER)); + expect(detail.body.tour.paddle_lake_style).toBe('shore'); + expect(detail.body.waypoints[1].shaping).toBe(true); + + const onHike = carried({ + tour_type: 'hike', + max_hiking_difficulty: 2, + boat_type: null, + water_context: null, + portage_speed_kmh: null, + paddle_lake_style: 'direct', + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, + ], + }); + const refused = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(onHike); + expect(refused.status).toBe(400); + }); + it('400 for a portage, a lock or a carrying speed on a hike; nothing written', async () => { const onHike = carried({ tour_type: 'hike', max_hiking_difficulty: 2, boat_type: null, water_context: null }); const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(onHike); diff --git a/server/tests/helpers/osm-pbf-fixture.ts b/server/tests/helpers/osm-pbf-fixture.ts index d00bca9ba..fb0eb8c28 100644 --- a/server/tests/helpers/osm-pbf-fixture.ts +++ b/server/tests/helpers/osm-pbf-fixture.ts @@ -1,7 +1,7 @@ /** * A small OpenStreetMap PBF writer for the paddle tests: just enough of the * format (blob framing, raw and zlib blobs, a header block, string tables, - * plain and dense nodes, ways) to build an extract in the test itself, so + * plain and dense nodes, ways, relations) to build an extract in the test itself, so * no binary fixture is checked in. The field numbers are the ones of * https://wiki.openstreetmap.org/wiki/PBF_Format. */ @@ -20,6 +20,19 @@ export interface FixtureWay { tags?: Record; } +export interface FixtureRelation { + id: number; + members: Array<{ type: 'node' | 'way' | 'relation'; ref: number; role?: string }>; + tags?: Record; +} + +type BlockContent = { + dense?: FixtureNode[]; + nodes?: FixtureNode[]; + ways?: FixtureWay[]; + relations?: FixtureRelation[]; +}; + export function varint(value: number): Buffer { const bytes: number[] = []; let rest = value; @@ -54,8 +67,8 @@ function deltas(values: number[]): number[] { return values.map((value, index) => value - (values[index - 1] ?? 0)); } -/** One data block: a string table and one group of dense nodes, plain nodes and ways. */ -export function dataBlock(content: { dense?: FixtureNode[]; nodes?: FixtureNode[]; ways?: FixtureWay[] }): Buffer { +/** One data block: a string table and one group of dense nodes, plain nodes, ways and relations. */ +export function dataBlock(content: BlockContent): Buffer { const strings = ['']; const index = (text: string): number => { const at = strings.indexOf(text); @@ -118,6 +131,35 @@ export function dataBlock(content: { dense?: FixtureNode[]; nodes?: FixtureNode[ groups.push(bytesField(3, body)); } + for (const relation of content.relations ?? []) { + const tags = Object.entries(relation.tags ?? {}); + const body = Buffer.concat([ + varintField(1, relation.id), + packed( + 2, + tags.map(([k]) => index(k)), + false, + ), + packed( + 3, + tags.map(([, v]) => index(v)), + false, + ), + packed( + 8, + relation.members.map((member) => index(member.role ?? '')), + false, + ), + packed(9, deltas(relation.members.map((member) => member.ref)), true), + packed( + 10, + relation.members.map((member) => ['node', 'way', 'relation'].indexOf(member.type)), + false, + ), + ]); + groups.push(bytesField(4, body)); + } + const table = Buffer.concat(strings.map((text) => bytesField(1, Buffer.from(text, 'utf8')))); return Buffer.concat([bytesField(1, table), ...groups.map((group) => bytesField(2, group))]); } @@ -146,7 +188,7 @@ export function framedBlob( /** A whole extract: the header block, then one data block per entry. */ export function pbfFile( - blocks: Array<{ dense?: FixtureNode[]; nodes?: FixtureNode[]; ways?: FixtureWay[] }>, + blocks: BlockContent[], options: { timestamp?: number; compression?: 'zlib' | 'raw' } = {}, ): Buffer { return Buffer.concat([ diff --git a/server/tests/helpers/water-world.ts b/server/tests/helpers/water-world.ts new file mode 100644 index 000000000..b8b104d83 --- /dev/null +++ b/server/tests/helpers/water-world.ts @@ -0,0 +1,101 @@ +/** + * A small piece of the world for the water routing tests (PAD-11), drawn in + * metres east and north of an origin and written as an OpenStreetMap extract + * with the PBF fixture writer: rivers, weirs, portages, lakes, as many as a + * test needs, so no binary map is checked in. + */ +import { pbfFile, type FixtureNode, type FixtureRelation, type FixtureWay } from './osm-pbf-fixture'; + +/** Metres per degree of latitude, as the router's own geometry takes it. */ +const M_PER_DEG = 111_195; + +export type Metres = [east: number, north: number]; + +export class WaterWorld { + private readonly nodes: FixtureNode[] = []; + private readonly ways: FixtureWay[] = []; + private readonly relations: FixtureRelation[] = []; + private nextNode = 1; + private nextWay = 1; + + constructor(private readonly origin = { lat: 49, lng: 12 }) {} + + /** The position `east` and `north` metres from the origin. */ + at(east: number, north: number): { lat: number; lng: number } { + const cos = Math.cos((this.origin.lat * Math.PI) / 180); + return { lat: this.origin.lat + north / M_PER_DEG, lng: this.origin.lng + east / (M_PER_DEG * cos) }; + } + + /** A node there, tagged or not; its id. */ + node([east, north]: Metres, tags?: Record): number { + const { lat, lng } = this.at(east, north); + const id = this.nextNode++; + this.nodes.push({ id, lat, lon: lng, ...(tags ? { tags } : {}) }); + return id; + } + + /** Tags an existing node, as OSM tags a weir on a node of its river. */ + tag(id: number, tags: Record): void { + const node = this.nodes.find((candidate) => candidate.id === id); + if (!node) throw new Error(`no node ${id}`); + node.tags = { ...node.tags, ...tags }; + } + + /** A way through existing nodes; its id. */ + way(refs: number[], tags: Record = {}): number { + const id = this.nextWay++; + this.ways.push({ id, refs, tags }); + return id; + } + + /** A way through new nodes at these spots, every `step` metres along straight runs; its id and nodes. */ + line(points: Metres[], tags: Record, step = 100): { id: number; refs: number[] } { + const refs: number[] = []; + points.forEach((point, index) => { + const next = points[index + 1]; + refs.push(this.node(point)); + if (!next) return; + const length = Math.hypot(next[0] - point[0], next[1] - point[1]); + const parts = Math.max(1, Math.round(length / step)); + for (let i = 1; i < parts; i++) { + const t = i / parts; + refs.push(this.node([point[0] + (next[0] - point[0]) * t, point[1] + (next[1] - point[1]) * t])); + } + }); + return { id: this.way(refs, tags), refs }; + } + + /** A closed way round these corners (the first is repeated at the end); its id. */ + ring(corners: Metres[], tags: Record = {}, step = 100): number { + const { refs } = this.line([...corners, corners[0] as Metres], {}, step); + // The closing node is the first one, as OSM draws a closed way. + refs[refs.length - 1] = refs[0] as number; + const way = this.ways.at(-1) as FixtureWay; + way.refs = refs; + way.tags = tags; + return way.id; + } + + relation(members: FixtureRelation['members'], tags: Record): number { + const id = this.relations.length + 1; + this.relations.push({ id, members, tags }); + return id; + } + + /** The extract: nodes, ways and relations in blocks of their own, as Geofabrik writes them. */ + pbf(timestamp = 1_760_000_000): Buffer { + return pbfFile([{ dense: this.nodes }, { ways: this.ways }, { relations: this.relations }], { timestamp }); + } +} + +/** The length of a line of [lat, lng] in metres, flat earth as the tests' scale allows. */ +export function lineMetres(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i++) { + const [aLat, aLng] = line[i - 1] as [number, number]; + const [bLat, bLng] = line[i] as [number, number]; + const cos = Math.cos((aLat * Math.PI) / 180); + total += Math.hypot((bLat - aLat) * M_PER_DEG, (bLng - aLng) * M_PER_DEG * cos); + } + return total; +} diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index 889e095b0..d5ea1a1e4 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -44,8 +44,8 @@ describe('ALL_ENTITIES', () => { * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 140 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(140); + it('ENT-003: exactly 141 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(141); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index facca121f..b7a49fc40 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -31,7 +31,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at. // + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id, // added_at (a link and its members are always written whole). -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 482; +// + paddle_water_graphs: file, rev, bytes, built_at, stats and its bounding box (a network is only ever written +// whole, from one build of its region). +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 491; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => { diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index a97af20a1..c6ef23fc7 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -55,6 +55,10 @@ const UNNUMBERED = [ 'Migration20200101044100_paddle_obstacles_from_openstreetmap', 'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions', 'Migration20200101044300_a_trip_links_a_journal', + 'Migration20200101044400_a_paddle_region_builds_its_water_network', + 'Migration20200101044500_a_tour_waypoint_can_shape_the_route', + 'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes', + 'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts index 4d24524aa..9494844cc 100644 --- a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts +++ b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts @@ -126,4 +126,16 @@ describe('TourWaypointsRepository', () => { t.clear(); expect((await repo.listForPlace(place.id))[0]).toEqual({ lat: 47, lng: 11, role: 'start', sequence: 0 }); }); + + it('TWREPO-007: a shaping point is stored as such and read back; a stop carries no flag (P2A-03)', async () => { + const { place } = tourInTrip(); + await repo.insertForPlace(place.id, [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.05, lng: 11.05, role: 'via', sequence: 1, shaping: true }, + { lat: 47.1, lng: 11.1, role: 'end', sequence: 2 }, + ]); + const points = await repo.listForPlace(place.id); + expect(points[1]).toEqual({ lat: 47.05, lng: 11.05, role: 'via', sequence: 1, shaping: true }); + expect(points[0]).not.toHaveProperty('shaping'); + }); }); diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 11722b4db..340c4def7 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -96,6 +96,7 @@ describe('ToursRepository reads', () => { water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, track_marks: null, has_recorded_track: 0, }, @@ -123,6 +124,7 @@ describe('ToursRepository reads', () => { water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, track_marks: null, has_recorded_track: 0, }, @@ -206,6 +208,7 @@ describe('ToursRepository writes', () => { water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, track_marks: null, has_recorded_track: 0, }); @@ -218,6 +221,7 @@ describe('ToursRepository writes', () => { water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, }; it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => { @@ -283,6 +287,7 @@ describe('ToursRepository writes', () => { water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, track_marks: null, }, ]); diff --git a/server/tests/unit/db/tour-paddle-columns.test.ts b/server/tests/unit/db/tour-paddle-columns.test.ts index 2f74221d5..0ac733b3c 100644 --- a/server/tests/unit/db/tour-paddle-columns.test.ts +++ b/server/tests/unit/db/tour-paddle-columns.test.ts @@ -1,10 +1,11 @@ -import { ToursBoatType, ToursWaterContext } from '../../../src/db/entities/Tours.entity'; -import { paddleBoatTypeSchema, paddleWaterContextSchema } from '@trek/shared'; +import { ToursBoatType, ToursPaddleLakeStyle, ToursWaterContext } from '../../../src/db/entities/Tours.entity'; +import { paddleBoatTypeSchema, paddleLakeStyleSchema, paddleWaterContextSchema } from '@trek/shared'; import { describe, expect, it } from 'vitest'; /** - * The boat and water columns hold a CHECK list (migration 043900) that the + * The boat, water and lake style columns hold a CHECK list (migrations 043900 + * and 044600) that the * entity carries as its enum, and the contract holds its own list. A value * one side knows and the other refuses would be a tour the planner can save * and the database cannot store, or the other way round. @@ -17,4 +18,8 @@ describe('paddle columns match the contract', () => { it('knows the same waters as the paddle contract', () => { expect(Object.values(ToursWaterContext).sort()).toEqual([...paddleWaterContextSchema.options].sort()); }); + + it('knows the same lake styles as the paddle contract (PAD-11)', () => { + expect(Object.values(ToursPaddleLakeStyle).sort()).toEqual([...paddleLakeStyleSchema.options].sort()); + }); }); diff --git a/server/tests/unit/nest/paddle-obstacles/import-runner.test.ts b/server/tests/unit/nest/paddle-obstacles/import-runner.test.ts new file mode 100644 index 000000000..f41527f25 --- /dev/null +++ b/server/tests/unit/nest/paddle-obstacles/import-runner.test.ts @@ -0,0 +1,173 @@ +/** + * Where a region's read runs (PAD-11): the node positions it keeps, the read + * on the calling thread, the thread's own script, and the runner that starts + * a thread per read, here with small threads written by the test that report + * progress, hand back a network, fail, die or are called off. + */ +import { + importRunner, + InlineImportRunner, + WorkerImportRunner, +} from '../../../../src/nest/paddle-obstacles/import-runner'; +import { NodeStore } from '../../../../src/nest/paddle-obstacles/node-store'; +import { WaterWorld } from '../../../helpers/water-world'; + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +const thread = vi.hoisted(() => ({ + postMessage: vi.fn(), + workerData: { path: '', regionId: 'worker-test' }, +})); +vi.mock('node:worker_threads', async (importOriginal) => ({ + ...(await importOriginal()), + parentPort: { postMessage: thread.postMessage }, + get workerData() { + return thread.workerData; + }, +})); + +let dir: string; +let extract: string; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-import-runner-')); + const world = new WaterWorld(); + world.line( + [ + [0, 2000], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, + ); + extract = join(dir, 'region.osm.pbf'); + await writeFile(extract, world.pbf()); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('NodeStore', () => { + it('PIMP-001: keeps the positions of the nodes asked for, once each, at OSM resolution', () => { + const store = new NodeStore(); + for (let id = 3000; id > 0; id -= 1) store.want(id); + store.wantAll([5, 5, 7]); + expect(store.size).toBe(3003); + store.seal(); + store.seal(); + expect(store.size).toBe(3000); + store.offer(5, 49.123456789, 12.000000049); + store.offer(9999, 1, 1); + expect(store.get(5)).toEqual([49.1234568, 12]); + expect(store.get(6)).toBeNull(); + expect(store.get(9999)).toBeNull(); + expect(() => store.want(1)).toThrow('NodeStore is sealed'); + }); +}); + +describe('the read on the calling thread', () => { + it('PIMP-002: reads the extract into obstacles and a network, and gives up a read called off', async () => { + const runner = new InlineImportRunner(); + const phases: string[] = []; + const outcome = await runner.run(extract, 'regen', (phase) => phases.push(phase), new AbortController().signal); + expect(outcome.graph?.stats.water_km).toBeCloseTo(2, 1); + expect(phases).toContain('build'); + + const calledOff = new AbortController(); + calledOff.abort(new Error('region removed')); + await expect(runner.run(extract, 'regen', () => undefined, calledOff.signal)).rejects.toThrow('region removed'); + }); + + it('PIMP-003: picks the inline read where the build shipped no thread script', () => { + expect(importRunner()).toBeInstanceOf(InlineImportRunner); + }); + + it('PIMP-004: the thread script reads its region, reports progress and hands the outcome back', async () => { + thread.workerData = { path: extract, regionId: 'worker-test' }; + await import('../../../../src/nest/paddle-obstacles/paddle-import.worker'); + await vi.waitFor(() => + expect(thread.postMessage).toHaveBeenCalledWith({ + outcome: expect.objectContaining({ graph: expect.anything() }), + }), + ); + expect(thread.postMessage).toHaveBeenCalledWith({ progress: { phase: 'build', fraction: null } }); + }); + + it('PIMP-005: the thread script reports a read that failed', async () => { + vi.resetModules(); + thread.postMessage.mockClear(); + thread.workerData = { path: join(dir, 'missing.osm.pbf'), regionId: 'worker-test' }; + await import('../../../../src/nest/paddle-obstacles/paddle-import.worker'); + await vi.waitFor(() => + expect(thread.postMessage).toHaveBeenCalledWith({ error: expect.stringContaining('missing.osm.pbf') }), + ); + }); +}); + +/** A thread script the runner can start: plain JS, so it runs without the server's loader. */ +async function script(name: string, body: string): Promise { + const path = join(dir, `${name}.cjs`); + await writeFile(path, `const { parentPort, workerData } = require('node:worker_threads');\n${body}\n`); + return path; +} + +describe('WorkerImportRunner', () => { + it('PIMP-006: passes on the progress and hands back the outcome, its network bytes as bytes', async () => { + const entry = await script( + 'reads', + `parentPort.postMessage({ progress: { phase: 'read', fraction: 0.5 } }); + parentPort.postMessage({ outcome: { timestamp: 1, features: [], graph: { bytes: Buffer.from([1, 2, 3]), rev: 'r', stats: {}, box: {} } } });`, + ); + const progress = vi.fn(); + const outcome = await new WorkerImportRunner(entry).run(extract, 'regen', progress, new AbortController().signal); + expect(progress).toHaveBeenCalledWith('read', 0.5); + expect(outcome.graph?.bytes).toBeInstanceOf(Uint8Array); + expect([...(outcome.graph?.bytes ?? [])]).toEqual([1, 2, 3]); + + const dry = await script( + 'dry', + `parentPort.postMessage({ outcome: { timestamp: null, features: [], graph: null } });`, + ); + await expect( + new WorkerImportRunner(dry).run(extract, 'dry', () => undefined, new AbortController().signal), + ).resolves.toEqual({ + timestamp: null, + features: [], + graph: null, + }); + }); + + it('PIMP-007: a read the thread reports failed, a thread that throws and one that ends early all reject', async () => { + const fails = await script('fails', `parentPort.postMessage({ error: 'not a PBF file' });`); + const silent = await script('silent', `parentPort.postMessage({});`); + const throws = await script('throws', `throw new Error('out of memory');`); + const ends = await script('ends', `process.exit(7);`); + const run = (entry: string) => + new WorkerImportRunner(entry).run(extract, 'x', () => undefined, new AbortController().signal); + await expect(run(fails)).rejects.toThrow('not a PBF file'); + await expect(run(silent)).rejects.toThrow('the read failed'); + await expect(run(throws)).rejects.toThrow('out of memory'); + await expect(run(ends)).rejects.toThrow('the read ended early (7)'); + }); + + it('PIMP-008: a read called off ends its thread at once, and one called off before it starts never starts', async () => { + const slow = await script('slow', `setTimeout(() => parentPort.postMessage({ error: 'too late' }), 60000);`); + const runner = new WorkerImportRunner(slow); + const controller = new AbortController(); + const reading = runner.run(extract, 'x', () => undefined, controller.signal); + controller.abort(new Error('removed')); + await expect(reading).rejects.toThrow('removed'); + + const notError = new AbortController(); + const pending = runner.run(extract, 'x', () => undefined, notError.signal); + notError.abort('gone'); + await expect(pending).rejects.toThrow('gone'); + + const before = new AbortController(); + before.abort(new Error('already off')); + await expect(runner.run(extract, 'x', () => undefined, before.signal)).rejects.toThrow('already off'); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/osm-pbf.reader.test.ts b/server/tests/unit/nest/paddle-obstacles/osm-pbf.reader.test.ts index 29194f3ab..77d815dd0 100644 --- a/server/tests/unit/nest/paddle-obstacles/osm-pbf.reader.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/osm-pbf.reader.test.ts @@ -1,13 +1,15 @@ /** * The PBF reader over extracts the test writes itself (tests/helpers/osm-pbf-fixture.ts): * blob framing across read chunks, raw and zlib blobs, the header timestamp, - * plain and dense nodes, ways, and the ways a broken file is refused. + * plain and dense nodes, ways, relations, node positions alone (PAD-11), and + * the ways a broken file is refused. */ import { readDataBlock, readHeaderTimestamp, readPbfFile, type OsmNode, + type OsmRelation, type OsmWay, } from '../../../../src/nest/paddle-obstacles/osm-pbf.reader'; import { @@ -204,3 +206,57 @@ describe('readDataBlock and readHeaderTimestamp', () => { ); }); }); + +describe('relations and node positions (PAD-11)', () => { + it('PBF-013: reads a relation with its members, their types and roles, and skips a field it does not know', () => { + const block = dataBlock({ + relations: [ + { + id: 7, + members: [ + { type: 'way', ref: 100, role: 'outer' }, + { type: 'way', ref: 90, role: 'inner' }, + { type: 'node', ref: 5 }, + { type: 'relation', ref: 3, role: 'subarea' }, + ], + tags: { type: 'multipolygon', natural: 'water', name: 'Müritz' }, + }, + ], + }); + const relations: OsmRelation[] = []; + readDataBlock(block, { relation: (relation) => relations.push(relation) }); + expect(relations).toEqual([ + { + id: 7, + members: [ + { type: 'way', ref: 100, role: 'outer' }, + { type: 'way', ref: 90, role: 'inner' }, + { type: 'node', ref: 5, role: '' }, + { type: 'relation', ref: 3, role: 'subarea' }, + ], + tags: { type: 'multipolygon', natural: 'water', name: 'Müritz' }, + }, + ]); + // A relation with a field the format added later still reads. + const unknownField = Buffer.concat([varintField(1, 9), varintField(15, 1)]); + const group = bytesField(4, unknownField); + const table = bytesField(1, bytesField(1, Buffer.from(''))); + const read: OsmRelation[] = []; + readDataBlock(Buffer.concat([table, bytesField(2, group)]), { relation: (relation) => read.push(relation) }); + expect(read).toEqual([{ id: 9, members: [], tags: {} }]); + }); + + it('PBF-014: hands a position visitor the plain and the dense nodes, and nothing else', () => { + const block = dataBlock({ + dense: [{ id: 1, lat: 49.1, lon: 12.1, tags: { waterway: 'weir' } }], + nodes: [{ id: 2, lat: 49.2, lon: 12.2 }], + ways: [{ id: 3, refs: [1, 2] }], + }); + const seen: Array<[number, number, number]> = []; + readDataBlock(block, { coord: (id, lat, lon) => seen.push([id, lat, lon]) }); + expect(seen.sort((a, b) => a[0] - b[0])).toEqual([ + [1, expect.closeTo(49.1, 7), expect.closeTo(12.1, 7)], + [2, expect.closeTo(49.2, 7), expect.closeTo(12.2, 7)], + ]); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-extract.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-extract.test.ts index 95160bacd..4ebf7c814 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-extract.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-extract.test.ts @@ -2,19 +2,22 @@ * The two-pass extraction of PAD-05A over extracts written in the test: the * objects kept with their own geometry (a weir across a river stays a line), * their tag hash, and the parts of one lock grouped while separate locks - * stay apart. + * stay apart; and the water sink the same passes feed (PAD-11). */ import { extractPaddleFeatures, groupLockParts, type ExtractedFeature, + type ExtractSink, } from '../../../../src/nest/paddle-obstacles/paddle-extract'; +import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import'; +import { decodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph'; import { pbfFile, type FixtureNode } from '../../../helpers/osm-pbf-fixture'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; let dir: string; let n = 0; @@ -186,3 +189,69 @@ describe('groupLockParts', () => { expect(new Set([a.group_key, b.group_key, c.group_key])).toEqual(new Set(['lock:node/1'])); }); }); + +describe('the water sink (PAD-11)', () => { + /** A sink that records what it hears and asks for the member ways of one relation. */ + function recorder(neededWays: number[]) { + const heard: string[] = []; + let want: (id: number) => void = () => undefined; + const sink: ExtractSink = { + neededWays: new Set(neededWays), + node: (id) => heard.push(`node/${id}`), + way: (id, refs) => { + heard.push(`way/${id}`); + for (const ref of refs) want(ref); + }, + relation: (id) => heard.push(`relation/${id}`), + memberWay: (id) => heard.push(`member/${id}`), + }; + return { + heard, + sinkFor: (asked: (id: number) => void) => { + want = asked; + return sink; + }, + }; + } + + it('PEXT-008: hears every tagged node, way and relation, reads the member ways in a pass of their own, and knows their positions', async () => { + const path = join(dir, `region-${n++}.osm.pbf`); + await writeFile( + path, + pbfFile([ + { dense: [...weirNodes, { id: 4, lat: 49.2, lon: 12.2, tags: { natural: 'water' } }] }, + { + ways: [ + { id: 10, refs: [1, 2, 3], tags: { waterway: 'river' } }, + { id: 11, refs: [1, 3] }, + ], + }, + { relations: [{ id: 5, members: [{ type: 'way', ref: 11, role: 'outer' }], tags: { type: 'multipolygon' } }] }, + ]), + ); + const { heard, sinkFor } = recorder([11]); + const progress: number[] = []; + const { coordOf } = await extractPaddleFeatures(path, (fraction) => progress.push(fraction), sinkFor); + expect(heard).toEqual(['node/4', 'way/10', 'way/11', 'relation/5', 'member/10', 'member/11']); + expect(coordOf(2)).toEqual([49.1236, 12.1309]); + // Three passes: the elements, the member ways, the positions. + expect(progress.some((fraction) => Math.abs(fraction - 2 / 3) < 1e-9)).toBe(true); + expect(progress.at(-1)).toBe(1); + }); + + it('PEXT-009: without member ways to read the pass is left out; an extract without a date builds a network without one', async () => { + const path = join(dir, `region-${n++}.osm.pbf`); + await writeFile( + path, + pbfFile([{ dense: weirNodes }, { ways: [{ id: 10, refs: [1, 2, 3], tags: { waterway: 'river' } }] }]), + ); + const { heard, sinkFor } = recorder([]); + const read = vi.fn(); + await extractPaddleFeatures(path, read, sinkFor); + expect(heard).toEqual(['way/10']); + + const { graph, timestamp } = await importExtract(path, 'undated', () => undefined); + expect(timestamp).toBeNull(); + expect(decodeWaterGraph(Buffer.from(graph?.bytes ?? [])).sourceTimestamp).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.controller.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.controller.test.ts index 0917bdcf2..a80432858 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.controller.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.controller.test.ts @@ -15,6 +15,7 @@ import { PaddleRegionsController, } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.controller'; import type { PaddleObstaclesService } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.service'; +import type { PaddleRegionLoadService } from '../../../../src/nest/paddle-obstacles/paddle-region-load.service'; import type { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service'; import type { User } from '../../../../src/types'; import { RequestMethod } from '@nestjs/common'; @@ -23,7 +24,9 @@ import { paddleObstaclesResponseSchema, paddleRegionCatalogResponseSchema, paddleRegionListResponseSchema, + paddleRegionLoadResponseSchema, paddleRegionSchema, + paddleRegionSettingsSchema, successResponseSchema, type PaddleRegion, } from '@trek/shared'; @@ -35,11 +38,13 @@ const region: PaddleRegion = { id: 'oberpfalz', name: 'Oberpfalz', status: 'queued', + origin: 'admin', source_timestamp: null, imported_at: null, feature_count: 0, error: null, progress: null, + network: null, }; describe('PaddleObstaclesController', () => { @@ -56,7 +61,10 @@ describe('PaddleObstaclesController', () => { it('PADCTL-002: hands the box on and passes a refusal through as it is', async () => { const answer = { features: [], coverage: { state: 'none' as const, regions: [] }, truncated: false }; const inBox = vi.fn().mockResolvedValue(answer); - const controller = new PaddleObstaclesController({ inBox } as unknown as PaddleObstaclesService); + const controller = new PaddleObstaclesController( + { inBox } as unknown as PaddleObstaclesService, + {} as PaddleRegionLoadService, + ); expect(await controller.inBox({ bbox: '49,12,49.1,12.1' })).toBe(answer); expect(inBox).toHaveBeenCalledWith('49,12,49.1,12.1'); @@ -67,6 +75,26 @@ describe('PaddleObstaclesController', () => { }); }); +describe('PaddleObstaclesController.loadRegions (PAD-11C)', () => { + it('PADCTL-007: anyone signed in with Tours on, POST with 200, the places and the user handed on', async () => { + const handler = PaddleObstaclesController.prototype.loadRegions; + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('regions/load'); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200); + expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRegionLoadResponseSchema); + const answer = [{ state: 'ready' as const, region: { id: 'oberpfalz', name: 'Oberpfalz' } }]; + const load = { load: vi.fn().mockResolvedValue(answer) }; + const controller = new PaddleObstaclesController( + {} as PaddleObstaclesService, + load as unknown as PaddleRegionLoadService, + ); + const user = { id: 3, role: 'user' } as User; + const body = { points: [{ lat: 49.3, lng: 12.2 }], retry: false }; + expect(await controller.loadRegions(user, body)).toEqual({ points: answer }); + expect(load.load).toHaveBeenCalledWith(user, body); + }); +}); + describe('PaddleRegionsController', () => { it('PADCTL-003: admin only, with the routes, status codes and contracts of the admin surface', () => { expect(Reflect.getMetadata(PATH_METADATA, PaddleRegionsController)).toBe('api/admin/paddle-regions'); @@ -95,15 +123,17 @@ describe('PaddleRegionsController', () => { refresh: vi.fn().mockResolvedValue(region), remove: vi.fn().mockResolvedValue(undefined), }; + const load = { pending: vi.fn().mockResolvedValue([]) }; const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) }; const controller = new PaddleRegionsController( service as unknown as PaddleRegionsService, + load as unknown as PaddleRegionLoadService, audit as unknown as AuditService, ); const admin = { id: 2 } as User; const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request; - expect(await controller.list()).toEqual({ regions: [region] }); + expect(await controller.list()).toEqual({ regions: [region], requests: [] }); expect(await controller.catalog({ q: 'ober' })).toEqual({ regions: [{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }], }); @@ -127,12 +157,51 @@ describe('PaddleRegionsController', () => { it('PADCTL-005: a refused change leaves no audit row', async () => { const refusal = new DomainError(404, 'Region not found'); const service = { remove: vi.fn().mockRejectedValue(refusal) }; + const load = {}; const audit = { writeAudit: vi.fn() }; const controller = new PaddleRegionsController( service as unknown as PaddleRegionsService, + load as unknown as PaddleRegionLoadService, audit as unknown as AuditService, ); await expect(controller.remove({ id: 2 } as User, { id: 'bayern' }, {} as Request)).rejects.toBe(refusal); expect(audit.writeAudit).not.toHaveBeenCalled(); }); + + it('PADCTL-006: the setting and declining a request, each with its route, contract and audit row (PAD-11C)', async () => { + const p = PaddleRegionsController.prototype; + const routes = [ + [p.settings, RequestMethod.GET, 'settings', undefined, paddleRegionSettingsSchema], + [p.updateSettings, RequestMethod.PUT, 'settings', undefined, paddleRegionSettingsSchema], + [p.decline, RequestMethod.POST, 'decline', 200, successResponseSchema], + ] as const; + for (const [handler, method, path, code, schema] of routes) { + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(code); + expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(schema); + } + const settings = { mode: 'ask' as const, max_mb: 250 }; + const load = { + settings: vi.fn().mockResolvedValue(settings), + updateSettings: vi.fn().mockResolvedValue(settings), + decline: vi.fn().mockResolvedValue(undefined), + }; + const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) }; + const controller = new PaddleRegionsController( + {} as PaddleRegionsService, + load as unknown as PaddleRegionLoadService, + audit as unknown as AuditService, + ); + const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request; + expect(await controller.settings()).toBe(settings); + expect(await controller.updateSettings({ id: 2 } as User, settings, req)).toBe(settings); + expect(load.updateSettings).toHaveBeenCalledWith(settings); + expect(await controller.decline({ id: 2 } as User, { id: 'norway' }, req)).toEqual({ success: true }); + expect(load.decline).toHaveBeenCalledWith('norway'); + expect(audit.writeAudit.mock.calls.map(([entry]) => [entry.action, entry.details])).toEqual([ + ['admin.paddle_region_settings', settings], + ['admin.paddle_region_decline', { region: 'norway' }], + ]); + }); }); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts index e454c6ce4..4bfaa199d 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts @@ -7,6 +7,7 @@ import { PaddleFeatures } from '../../../../src/db/entities/PaddleFeatures.entity'; import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity'; import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module'; +import { DemoModule } from '../../../../src/nest/common/demo.module'; import { DomainError } from '../../../../src/nest/common/domain-error'; import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module'; import { @@ -85,7 +86,7 @@ const weir = (regionId: string, ref: string, lat: number, lng: number) => ({ beforeEach(async () => { resetTestDb(db); await t?.close(); - t = await createTestModule({ db, imports: [AppConfigModule, PaddleObstaclesModule] }); + t = await createTestModule({ db, imports: [AppConfigModule, DemoModule, PaddleObstaclesModule] }); service = t.get(PaddleObstaclesService); }); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts new file mode 100644 index 000000000..cb9198a9f --- /dev/null +++ b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts @@ -0,0 +1,264 @@ +/** + * PAD-11C, PaddleRegionLoadService over a snapshot db with Geofabrik faked: + * a place no region covers gets the smallest region around it loaded, asked + * for or refused, as the admin's setting, the region's size and the day's + * count say; a region TREK holds already answers how far its read is; the + * admins hear of a request once. + */ +import { PaddleRegionRequests } from '../../../../src/db/entities/PaddleRegionRequests.entity'; +import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity'; +import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module'; +import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service'; +import { DemoModule } from '../../../../src/nest/common/demo.module'; +import { DemoService } from '../../../../src/nest/common/demo.service'; +import { DomainError } from '../../../../src/nest/common/domain-error'; +import { NotificationsService } from '../../../../src/nest/notifications/notifications.service'; +import { GeofabrikClient, type GeofabrikRegion } from '../../../../src/nest/paddle-obstacles/geofabrik.client'; +import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module'; +import { PaddleRegionLoadService } from '../../../../src/nest/paddle-obstacles/paddle-region-load.service'; +import { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service'; +import { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service'; +import type { User } from '../../../../src/types'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { makeUser } from '../../../helpers/factories/users'; +import { pbfFile } from '../../../helpers/osm-pbf-fixture'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; +import { PADDLE_AUTO_REGIONS_PER_DAY, paddleRegionLoadStateSchema } from '@trek/shared'; + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() })); +vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({ + ...(await importOriginal()), + ...log, +})); + +const db = createSnapshotTestDb(); + +const box = (west: number, south: number, east: number, north: number) => ({ + type: 'Polygon' as const, + coordinates: [ + [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ] as [number, number][], + ], +}); +const region = (id: string, name: string, boundary: GeofabrikRegion['boundary']): GeofabrikRegion => ({ + id, + name, + parent: null, + pbfUrl: `https://download.geofabrik.de/${id}-latest.osm.pbf`, + boundary, +}); +const catalogue = new Map( + [ + region('germany', 'Germany', box(5, 47, 16, 55)), + region('bayern', 'Bayern', box(9, 47, 14, 51)), + region('oberpfalz', 'Oberpfalz', box(11.5, 48.9, 13, 50)), + region('mecklenburg-vorpommern', 'Mecklenburg-Vorpommern', box(10.5, 53, 14.5, 54.7)), + region('nowhere', 'Nowhere', null), + ].map((entry) => [entry.id, entry]), +); + +const REGENSBURG = { lat: 49.3, lng: 12.2 }; +const MUERITZ = { lat: 53.45, lng: 12.7 }; +const ATLANTIC = { lat: 0, lng: -30 }; + +const extract = pbfFile([{ dense: [{ id: 1, lat: 49.3, lon: 12.2, tags: { canoe: 'put_in' } }] }], { + timestamp: Date.UTC(2026, 9, 1) / 1000, +}); + +const regions = vi.fn<() => Promise>>(); +const download = vi.fn<(url: string, target: string) => Promise>(); +const sizeOf = vi.fn<(url: string) => Promise>(); + +let dir: string; +let t: TestModule | undefined; +let load: PaddleRegionLoadService; +let send: ReturnType; +let paddler: User; +let admin: User; +const orm = () => { + if (!t) throw new Error('No container booted'); + return t.get(MikroORM); +}; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-paddle-load-')); +}); + +beforeEach(async () => { + resetTestDb(db); + regions.mockReset().mockResolvedValue(catalogue); + download.mockReset().mockImplementation((_url, target) => writeFile(target, extract)); + sizeOf.mockReset().mockResolvedValue(80 * 1024 * 1024); + await t?.close(); + t = await createTestModule({ + db, + imports: [AppConfigModule, DemoModule, PaddleObstaclesModule], + overrides: [ + { provide: GeofabrikClient, useValue: { regions, download, sizeOf } }, + { provide: DataPathsService, useValue: { tmpDir: dir, dataDir: dir } }, + ], + }); + load = t.get(PaddleRegionLoadService); + send = vi.spyOn(t.get(NotificationsService), 'send').mockResolvedValue(undefined as never); + paddler = (await makeUser(orm(), { username: 'elmo' })).user as unknown as User; + admin = (await makeUser(orm(), { username: 'maurice', role: 'admin' })).user as unknown as User; +}); + +afterEach(async () => { + await t?.get(PaddleRegionsService).idle(); + vi.restoreAllMocks(); +}); + +afterAll(async () => { + await t?.close(); + db.close(); + await rm(dir, { recursive: true, force: true }); +}); + +const ask = async (user: User, points = [REGENSBURG], retry = false) => { + const answers = await load.load(user, { points, retry }); + for (const answer of answers) expect(paddleRegionLoadStateSchema.safeParse(answer).success).toBe(true); + return answers; +}; + +describe('PaddleRegionLoadService', () => { + it('PLOAD-001: loads the smallest region around a place by itself, and follows it on the next ask', async () => { + const [first] = await ask(paddler); + expect(first).toMatchObject({ state: 'loading', region: { id: 'oberpfalz', name: 'Oberpfalz' } }); + expect(await findRow(orm(), PaddleRegions, { id: 'oberpfalz' })).toMatchObject({ origin: 'auto' }); + expect(sizeOf).toHaveBeenCalledWith('https://download.geofabrik.de/oberpfalz-latest.osm.pbf'); + await ask(paddler); + expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(1); + expect(send).not.toHaveBeenCalled(); + }); + + it('PLOAD-002: answers two places in one region with one decision', async () => { + const answers = await ask(paddler, [REGENSBURG, { lat: 49.31, lng: 12.21 }]); + expect(answers.map((answer) => answer.region?.id)).toEqual(['oberpfalz', 'oberpfalz']); + expect(sizeOf).toHaveBeenCalledTimes(1); + }); + + it('PLOAD-003: sends a region over the size limit to the admins as a request, and tells them once', async () => { + sizeOf.mockResolvedValue(900 * 1024 * 1024); + const [answer] = await ask(paddler); + expect(answer).toEqual({ state: 'requested', region: { id: 'oberpfalz', name: 'Oberpfalz' }, reason: 'too_large' }); + await ask(paddler); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'paddle_region_requested', + scope: 'admin', + params: { region: 'Oberpfalz', actor: 'elmo' }, + }), + ); + expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(0); + expect(await load.pending()).toEqual([ + expect.objectContaining({ id: 'oberpfalz', reason: 'too_large', requested_by: 'elmo', declined: false }), + ]); + }); + + it('PLOAD-004: holds TREK to a few regions a day and asks the admins for the next', async () => { + for (let i = 0; i < PADDLE_AUTO_REGIONS_PER_DAY; i += 1) { + await insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' }); + } + const [answer] = await ask(paddler); + expect(answer).toMatchObject({ state: 'requested', reason: 'limit' }); + }); + + it('PLOAD-005: in the ask mode files every region, while an admin planning loads it at once, whatever its size', async () => { + await load.updateSettings({ mode: 'ask', max_mb: 500 }); + expect((await ask(paddler))[0]).toMatchObject({ state: 'requested', reason: 'ask' }); + sizeOf.mockResolvedValue(3000 * 1024 * 1024); + expect((await ask(admin))[0]).toMatchObject({ state: 'loading', region: { id: 'oberpfalz' } }); + expect(await findRow(orm(), PaddleRegions, { id: 'oberpfalz' })).toMatchObject({ origin: 'admin' }); + expect(await load.pending()).toEqual([]); + }); + + it('PLOAD-006: switched off, or for a demo account, loads nothing and asks nobody', async () => { + await load.updateSettings({ mode: 'off', max_mb: 500 }); + expect((await ask(paddler))[0]).toEqual({ + state: 'unavailable', + region: { id: 'oberpfalz', name: 'Oberpfalz' }, + reason: 'off', + }); + await load.updateSettings({ mode: 'auto', max_mb: 500 }); + vi.spyOn(t?.get(DemoService) as DemoService, 'isDemoUserId').mockResolvedValue(true); + expect((await ask(paddler))[0]).toMatchObject({ state: 'unavailable', reason: 'off' }); + expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(0); + expect(send).not.toHaveBeenCalled(); + }); + + it('PLOAD-007: a declined region is not asked for again; an admin adding it settles the request', async () => { + sizeOf.mockResolvedValue(900 * 1024 * 1024); + await ask(paddler); + await load.decline('oberpfalz'); + expect((await ask(paddler))[0]).toEqual({ state: 'declined', region: { id: 'oberpfalz', name: 'Oberpfalz' } }); + expect(send).toHaveBeenCalledTimes(1); + await t?.get(PaddleRegionsService).add('oberpfalz'); + expect(await findRows(orm(), PaddleRegionRequests, {})).toHaveLength(0); + await expect(load.decline('oberpfalz')).rejects.toBeInstanceOf(DomainError); + }); + + it('PLOAD-008: says ready where a network covers the place, and nothing is loaded', async () => { + await insertRow(orm(), PaddleRegions, { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready' }); + vi.spyOn(t?.get(PaddleRoutingService) as PaddleRoutingService, 'regionsCovering').mockResolvedValue(['oberpfalz']); + expect((await ask(paddler))[0]).toEqual({ state: 'ready', region: { id: 'oberpfalz', name: 'Oberpfalz' } }); + expect(sizeOf).not.toHaveBeenCalled(); + }); + + it('PLOAD-009: a region held answers its read: failed, read again on retry, built anew when it has no network, else no water', async () => { + const held = { id: 'oberpfalz', name: 'Oberpfalz', min_lat: 48.9, min_lng: 11.5, max_lat: 50, max_lng: 13 }; + await insertRow(orm(), PaddleRegions, { ...held, status: 'failed' }); + expect((await ask(paddler))[0]).toMatchObject({ state: 'failed' }); + expect((await ask(paddler, [REGENSBURG], true))[0]).toMatchObject({ state: 'loading' }); + await t?.get(PaddleRegionsService).idle(); + await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { status: 'ready' }); + const routing = t?.get(PaddleRoutingService) as PaddleRoutingService; + vi.spyOn(routing, 'networks').mockResolvedValue(new Map()); + expect((await ask(paddler))[0]).toMatchObject({ state: 'loading' }); + await t?.get(PaddleRegionsService).idle(); + await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { status: 'ready' }); + vi.spyOn(routing, 'networks').mockResolvedValue(new Map([['oberpfalz', {} as never]])); + expect((await ask(paddler))[0]).toEqual({ state: 'no_water', region: { id: 'oberpfalz', name: 'Oberpfalz' } }); + }); + + it('PLOAD-010: says where nothing can be had: no extract around the place, or the list out of reach', async () => { + expect((await ask(paddler, [ATLANTIC]))[0]).toEqual({ state: 'unavailable', region: null, reason: 'no_extract' }); + regions.mockRejectedValue(new Error('timeout')); + expect((await ask(paddler, [MUERITZ]))[0]).toEqual({ + state: 'unavailable', + region: null, + reason: 'catalog_unavailable', + }); + }); + + it('PLOAD-011: the setting starts automatic at 500 MB and falls back there from a broken row', async () => { + expect(await load.settings()).toEqual({ mode: 'auto', max_mb: 500 }); + expect(await load.updateSettings({ mode: 'ask', max_mb: 1000 })).toEqual({ mode: 'ask', max_mb: 1000 }); + const { AppSettings } = await import('../../../../src/db/entities/AppSettings.entity'); + await updateRows(orm(), AppSettings, { key: 'paddle_auto_load' }, { value: 'sometimes' }); + await updateRows(orm(), AppSettings, { key: 'paddle_auto_max_mb' }, { value: '99999' }); + expect(await load.settings()).toEqual({ mode: 'auto', max_mb: 500 }); + }); + + it('PLOAD-012: a size Geofabrik does not announce counts as small enough; the download still caps it', async () => { + sizeOf.mockResolvedValue(null); + expect((await ask(paddler, [MUERITZ]))[0]).toMatchObject({ + state: 'loading', + region: { id: 'mecklenburg-vorpommern' }, + }); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-regions.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-regions.service.test.ts index 54a997680..1d52f42cc 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-regions.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-regions.service.test.ts @@ -2,13 +2,15 @@ * PaddleRegionsService over a snapshot db with Geofabrik faked: a region * added, read in the background from an extract the test writes, its objects * stored; the queue in order; a failed read that keeps the previous data; a - * region removed while it is read; the monthly and the boot requeue. + * region removed while it is read; the monthly and the boot requeue; the + * water network the same read builds for routing (PAD-11). */ import { PaddleFeatures } from '../../../../src/db/entities/PaddleFeatures.entity'; import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity'; import type { PaddleRegionsRepository } from '../../../../src/db/repositories/PaddleRegions.repository'; import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module'; import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service'; +import { DemoModule } from '../../../../src/nest/common/demo.module'; import { DomainError } from '../../../../src/nest/common/domain-error'; import { GeofabrikClient, type GeofabrikRegion } from '../../../../src/nest/paddle-obstacles/geofabrik.client'; import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module'; @@ -23,6 +25,7 @@ import { countRows, deleteRows, findRow, findRows, updateRows } from '../../../h import { pbfFile } from '../../../helpers/osm-pbf-fixture'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { WaterWorld } from '../../../helpers/water-world'; import { MikroORM } from '@mikro-orm/core'; import { getRepositoryToken } from '@mikro-orm/nestjs'; import { paddleRegionSchema } from '@trek/shared'; @@ -112,10 +115,10 @@ beforeEach(async () => { await t?.close(); t = await createTestModule({ db, - imports: [AppConfigModule, PaddleObstaclesModule], + imports: [AppConfigModule, DemoModule, PaddleObstaclesModule], overrides: [ { provide: GeofabrikClient, useValue: { regions, download } }, - { provide: DataPathsService, useValue: { tmpDir: dir } }, + { provide: DataPathsService, useValue: { tmpDir: dir, dataDir: dir } }, ], }); service = t.get(PaddleRegionsService); @@ -407,6 +410,57 @@ describe('PaddleRegionsService: restart and shutdown', () => { }); }); +describe('PaddleRegionsService and the water network (PAD-11)', () => { + /** The Regen past Regenstauf, 3 km of river. */ + const regen = new WaterWorld({ lat: 49.12, lng: 12.13 }); + regen.line( + [ + [0, 3000], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, + ); + const graphs = () => readdir(join(dir, 'paddle', 'graphs')).catch(() => [] as string[]); + + it('PREG-018: a read builds the network beside the objects, lists its counts, and a read again replaces its file', async () => { + download.mockImplementation((_url, target) => writeFile(target, regen.pbf())); + await service.add('oberpfalz'); + await service.idle(); + const [listed] = await service.list(); + expect(paddleRegionSchema.parse(listed).network).toMatchObject({ water_km: expect.closeTo(3, 1), lakes: 0 }); + const first = await graphs(); + expect(first).toHaveLength(1); + + regen.line( + [ + [500, 3000], + [500, 0], + ], + { waterway: 'canal', name: 'Kanal' }, + ); + await service.refresh('oberpfalz'); + await service.idle(); + const second = await graphs(); + expect(second).toHaveLength(1); + expect(second).not.toEqual(first); + }); + + it('PREG-019: a region removed while its network is written keeps no file; a region without water lists no network', async () => { + download.mockImplementationOnce(async (_url, target) => { + await writeFile(target, regen.pbf()); + await deleteRows(orm(), PaddleRegions, { id: 'oberpfalz' }); + }); + await service.add('oberpfalz'); + await service.idle(); + expect(await graphs()).toEqual([]); + + await service.add('oberpfalz'); + await service.idle(); + const [listed] = await service.list(); + expect(listed?.network).toBeNull(); + }); +}); + describe('PaddleRegionsService.catalog', () => { it('PREG-013: finds regions by name or id, names starting with the query first, with the regions they lie in', async () => { expect(await service.catalog('ober')).toEqual([ diff --git a/server/tests/unit/nest/paddle-routing/paddle-routing.controller.test.ts b/server/tests/unit/nest/paddle-routing/paddle-routing.controller.test.ts new file mode 100644 index 000000000..00c357533 --- /dev/null +++ b/server/tests/unit/nest/paddle-routing/paddle-routing.controller.test.ts @@ -0,0 +1,149 @@ +/** + * The REST route and the MCP tool of PAD-11 over a stub service: the route's + * guards, status and contract, the tool's gate, scope and input, and that + * both hand the same request to the same service. The answers through the + * real guards are pinned in the e2e suite. + */ +import { ADDON_IDS } from '../../../../src/addons'; +import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata'; +import { AddonGuard } from '../../../../src/nest/addons/addon.guard'; +import type { AddonsService } from '../../../../src/nest/addons/addons.service'; +import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator'; +import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard'; +import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract'; +import { PaddleRoutingController } from '../../../../src/nest/paddle-routing/paddle-routing.controller'; +import { PaddleRoutingMcp } from '../../../../src/nest/paddle-routing/paddle-routing.mcp'; +import type { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service'; +import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard'; +import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service'; +import { RequestMethod } from '@nestjs/common'; +import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { paddleRouteRequestSchema, paddleRouteResponseSchema, type PaddleRouteResponse } from '@trek/shared'; + +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const A = { lat: 49.1283, lng: 12.1303 }; +const B = { lat: 49.022, lng: 12.111 }; + +const ANSWER: PaddleRouteResponse = { + graph_rev: 'r1', + legs: [ + { + status: 'ok', + geometry: [ + [A.lat, A.lng], + [49.07, 12.12], + [B.lat, B.lng], + ], + segments: [ + { kind: 'water', from: 0, to: 1, name: 'Regen', source: null, osm_ref: null, grade: null }, + { kind: 'portage', from: 1, to: 2, name: 'Wehr', source: 'tagged', osm_ref: 'way/1', grade: null }, + ], + distance_m: 15600, + snapped_m: { from: 12, to: 0 }, + }, + { status: 'failed', code: 'no_region', at: B }, + ], +}; + +function service() { + return { route: vi.fn().mockResolvedValue(ANSWER) }; +} + +describe('PaddleRoutingController', () => { + it('PADROUTE-CTL-001: POST api/trips/:tripId/paddle/route sits behind Tours, the session and the trip, answers 200 on the contract', () => { + expect(Reflect.getMetadata(PATH_METADATA, PaddleRoutingController)).toBe('api/trips/:tripId/paddle'); + expect(Reflect.getMetadata(GUARDS_METADATA, PaddleRoutingController)).toEqual([ + AddonGuard, + JwtAuthGuard, + TripAccessGuard, + ]); + expect(Reflect.getMetadata(REQUIRE_ADDON, PaddleRoutingController)).toEqual({ addonId: 'tours', label: 'Tours' }); + const handler = PaddleRoutingController.prototype.route; + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('route'); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200); + expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRouteResponseSchema); + }); + + it('PADROUTE-CTL-002: hands the parsed request to the service and answers what it planned', async () => { + const routing = service(); + const controller = new PaddleRoutingController(routing as unknown as PaddleRoutingService); + const body = paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }], boat: 'sup' }); + await expect(controller.route(body)).resolves.toBe(ANSWER); + expect(routing.route).toHaveBeenCalledWith(body); + }); +}); + +const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext; + +function toolOptions(): ToolOptions { + const entry = getEntry(PaddleRoutingMcp as unknown as ClassRef, 'planPaddleRoute'); + if (!entry || entry.kind !== 'tool') throw new Error('plan_paddle_route is not registered'); + return entry.options; +} + +function makeMcp(accessible: boolean) { + const routing = service(); + const access = { findAccessible: vi.fn().mockResolvedValue(accessible ? { id: 3 } : null) }; + const mcp = new PaddleRoutingMcp( + routing as unknown as PaddleRoutingService, + access as unknown as TripAccessService, + {} as AddonsService, + ); + return { mcp, routing, access }; +} + +describe('PaddleRoutingMcp', () => { + it('PADROUTE-MCP-001: plan_paddle_route is read only, gated on Tours and scoped to reading trips', () => { + const options = toolOptions(); + expect(options.name).toBe('plan_paddle_route'); + expect(options.annotations?.readOnlyHint).toBe(true); + expect(options.access).toEqual({ group: 'trips', mode: 'read' }); + const isAddonEnabled = vi.fn((id: string) => id === ADDON_IDS.TOURS); + const self = { addons: { isAddonEnabled } } as unknown as PaddleRoutingMcp; + expect(options.when?.(ctx, self)).toBe(true); + expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.TOURS); + }); + + it('PADROUTE-MCP-002: takes the REST contract plus the trip, with the same defaults and limits', () => { + const input = z.object(toolOptions().inputSchema as z.ZodRawShape); + expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }] }).success).toBe(true); + expect(input.safeParse({ tripId: 3, legs: [] }).success).toBe(false); + expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], lake_style: 'zigzag' }).success).toBe(false); + expect(input.safeParse({ legs: [{ from: A, to: B }] }).success).toBe(false); + }); + + it('PADROUTE-MCP-003: plans for a trip the caller sees, the same request as REST, and answers each leg without its points', async () => { + const { mcp, routing, access } = makeMcp(true); + const result = (await mcp.planPaddleRoute({ tripId: 3, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult; + expect(access.findAccessible).toHaveBeenCalledWith(3, 7); + expect(routing.route).toHaveBeenCalledWith(paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }] })); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toEqual({ + graph_rev: 'r1', + legs: [ + { + status: 'ok', + distance_m: 15600, + snapped_m: { from: 12, to: 0 }, + points: 3, + segments: [ + { kind: 'water', name: 'Regen', source: null, osm_ref: null }, + { kind: 'portage', name: 'Wehr', source: 'tagged', osm_ref: 'way/1' }, + ], + }, + { status: 'failed', code: 'no_region', at: B }, + ], + }); + }); + + it('PADROUTE-MCP-004: a trip the caller cannot see is refused before anything is planned', async () => { + const { mcp, routing } = makeMcp(false); + const result = (await mcp.planPaddleRoute({ tripId: 9, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult; + expect(result.isError).toBe(true); + expect(routing.route).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/paddle-routing/paddle-routing.service.test.ts b/server/tests/unit/nest/paddle-routing/paddle-routing.service.test.ts new file mode 100644 index 000000000..50c694916 --- /dev/null +++ b/server/tests/unit/nest/paddle-routing/paddle-routing.service.test.ts @@ -0,0 +1,232 @@ +/** + * PaddleRoutingService over a snapshot db and a temp data folder: a region's + * network written and recorded as the import does it, legs planned on the + * smallest region covering both ends, the failures per leg, the files swept, + * replaced and discarded, and the counts the admin list reads. + */ +import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity'; +import { PaddleWaterGraphs } from '../../../../src/db/entities/PaddleWaterGraphs.entity'; +import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module'; +import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service'; +import { withRequestContext } from '../../../../src/nest/database/request-context'; +import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import'; +import { PaddleRoutingModule } from '../../../../src/nest/paddle-routing/paddle-routing.module'; +import { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service'; +import { PADDLE_ROUTER_RUNNER, type RouterRunner } from '../../../../src/nest/paddle-routing/router-runner'; +import type { BuiltWaterGraph } from '../../../../src/nest/paddle-routing/water-graph.contract'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { countRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { WaterWorld } from '../../../helpers/water-world'; +import { MikroORM } from '@mikro-orm/core'; +import { paddleRouteRequestSchema, type PaddleRouteInput } from '@trek/shared'; + +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() })); +vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({ + ...(await importOriginal()), + ...log, +})); + +const db = createSnapshotTestDb(); + +let dir: string; +let t: TestModule | undefined; +let service: PaddleRoutingService; + +/** Two pieces of water: a long river in the west region, a short one inside it in the east region's box too. */ +const west = new WaterWorld({ lat: 49, lng: 12 }); +west.line( + [ + [0, 6000], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, +); +const east = new WaterWorld({ lat: 49, lng: 12.5 }); +east.line( + [ + [0, 3000], + [0, 0], + ], + { waterway: 'river', name: 'Naab' }, +); + +let westGraph: BuiltWaterGraph; +let eastGraph: BuiltWaterGraph; + +async function graphOf(world: WaterWorld): Promise { + const path = join(dir, `world-${Math.random().toString(36).slice(2)}.osm.pbf`); + await writeFile(path, world.pbf()); + const { graph } = await importExtract(path, 'region', () => undefined); + if (!graph) throw new Error('no network'); + return graph; +} + +const orm = () => { + if (!t) throw new Error('No container booted'); + return t.get(MikroORM); +}; +const inContext = (work: () => Promise) => withRequestContext(orm(), work); +const graphsDir = () => join(dir, 'paddle', 'graphs'); + +/** A region's network as the import hands it over: written, then recorded. */ +async function install(regionId: string, built: BuiltWaterGraph) { + if ((await countRows(orm(), PaddleRegions, { id: regionId })) === 0) { + await insertRow(orm(), PaddleRegions, { id: regionId, name: regionId, status: 'ready' }); + } + const file = await service.writeGraphFile(regionId, built); + const previous = await inContext(() => service.recordGraph(regionId, file, built)); + await service.replaced(previous, file); + return file; +} + +const plan = (input: PaddleRouteInput) => inContext(() => service.route(paddleRouteRequestSchema.parse(input))); + +async function boot(runner?: RouterRunner) { + await t?.close(); + t = await createTestModule({ + db, + imports: [AppConfigModule, PaddleRoutingModule], + overrides: [ + { provide: DataPathsService, useValue: { dataDir: dir, tmpDir: dir } }, + ...(runner ? [{ provide: PADDLE_ROUTER_RUNNER, useValue: runner }] : []), + ], + }); + service = t.get(PaddleRoutingService); +} + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-paddle-routing-')); + westGraph = await graphOf(west); + eastGraph = await graphOf(east); +}); + +beforeEach(async () => { + resetTestDb(db); + vi.clearAllMocks(); + await rm(join(dir, 'paddle'), { recursive: true, force: true }); + await boot(); +}); + +afterEach(async () => { + await t?.close(); + t = undefined; +}); + +afterAll(async () => { + db.close(); + await rm(dir, { recursive: true, force: true }); +}); + +describe('PaddleRoutingService', () => { + it('PADROUTE-SVC-001: plans a leg on the region that covers it, and fails a leg no region covers on its own', async () => { + await install('oberpfalz', westGraph); + const answer = await plan({ + legs: [ + { from: west.at(0, 5000), to: west.at(0, 1000) }, + { from: west.at(0, 5000), to: { lat: 10, lng: 10 } }, + { from: { lat: 10, lng: 10 }, to: west.at(0, 1000) }, + ], + }); + expect(answer.legs[0]).toMatchObject({ status: 'ok' }); + expect(answer.legs[1]).toEqual({ status: 'failed', code: 'no_region', at: { lat: 10, lng: 10 } }); + expect(answer.legs[2]).toEqual({ status: 'failed', code: 'no_region', at: { lat: 10, lng: 10 } }); + expect(answer.graph_rev).toBe(westGraph.rev); + }); + + it('PADROUTE-SVC-002: a leg between two regions that share no box fails as cross region; no region at all answers no rev', async () => { + await install('oberpfalz', westGraph); + await install('niederbayern', eastGraph); + const cross = await plan({ legs: [{ from: west.at(0, 5000), to: east.at(0, 2000) }] }); + expect(cross.legs[0]).toEqual({ status: 'failed', code: 'cross_region', at: east.at(0, 2000) }); + expect(cross.graph_rev).toBeNull(); + }); + + it('PADROUTE-SVC-003: legs of two regions are planned each on its own, the revs joined', async () => { + await install('oberpfalz', westGraph); + await install('niederbayern', eastGraph); + const answer = await plan({ + legs: [ + { from: west.at(0, 5000), to: west.at(0, 1000) }, + { from: east.at(0, 2500), to: east.at(0, 500) }, + ], + }); + expect(answer.legs.map((leg) => leg.status)).toEqual(['ok', 'ok']); + expect(answer.graph_rev?.split('+').sort()).toEqual([westGraph.rev, eastGraph.rev].sort()); + }); + + it('PADROUTE-SVC-004: a runner that fails answers busy for its legs, one past its time timeout', async () => { + const runner: RouterRunner = { + run: vi.fn().mockRejectedValueOnce(new Error('worker exited')).mockRejectedValueOnce(new Error('timeout')), + forget: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + await boot(runner); + await install('oberpfalz', westGraph); + const leg = { from: west.at(0, 5000), to: west.at(0, 1000) }; + expect((await plan({ legs: [leg] })).legs).toEqual([{ status: 'failed', code: 'busy', at: null }]); + expect((await plan({ legs: [leg] })).legs).toEqual([{ status: 'failed', code: 'timeout', at: null }]); + expect(log.logInfo).toHaveBeenCalledWith(expect.stringContaining('worker exited')); + await t?.close(); + t = undefined; + expect(runner.close).toHaveBeenCalled(); + }); + + it('PADROUTE-SVC-005: a rebuilt region replaces its file and the router forgets the old one; the same build keeps it', async () => { + const runner: RouterRunner = { run: vi.fn(), forget: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; + await boot(runner); + const first = await install('oberpfalz', westGraph); + expect(await readdir(graphsDir())).toEqual([first]); + const again = await install('oberpfalz', westGraph); + expect(again).toBe(first); + expect(runner.forget).not.toHaveBeenCalled(); + + const second = await install('oberpfalz', eastGraph); + expect(await readdir(graphsDir())).toEqual([second]); + expect(runner.forget).toHaveBeenCalledWith(join(graphsDir(), first)); + expect(await countRows(orm(), PaddleWaterGraphs)).toBe(1); + }); + + it('PADROUTE-SVC-006: a file whose import did not commit is discarded; the sweep clears files no region names and halves of a write', async () => { + const kept = await install('oberpfalz', westGraph); + const orphan = await service.writeGraphFile('gone/region', eastGraph); + expect(orphan).toMatch(/^gone_region\./); + await writeFile(join(graphsDir(), 'half.wgraph.tmp'), 'x'); + await writeFile(join(graphsDir(), 'notes.txt'), 'kept'); + await inContext(() => service.sweep()); + expect((await readdir(graphsDir())).sort()).toEqual([kept, 'notes.txt'].sort()); + + const unrecorded = await service.writeGraphFile('other', eastGraph); + await service.discard(unrecorded); + expect(existsSync(join(graphsDir(), unrecorded))).toBe(false); + }); + + it('PADROUTE-SVC-007: lists the network of each region with its counts, its build time and its size; a broken row lists none', async () => { + await install('oberpfalz', westGraph); + await install('niederbayern', eastGraph); + await updateRows(orm(), PaddleWaterGraphs, { region_id: 'niederbayern' }, { stats: 'not json' }); + const networks = await inContext(() => service.networks()); + expect([...networks.keys()]).toEqual(['oberpfalz']); + expect(networks.get('oberpfalz')).toMatchObject({ + ...westGraph.stats, + bytes: westGraph.bytes.byteLength, + built_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.*Z$/), + }); + }); + + it('PADROUTE-SVC-008: the boot sweep runs without a folder, and a failing sweep is logged, never thrown', async () => { + await expect(service.onApplicationBootstrap()).resolves.toBeUndefined(); + await mkdir(join(dir, 'paddle'), { recursive: true }); + await writeFile(graphsDir(), 'a file where the folder should be'); + vi.spyOn(service, 'sweep').mockRejectedValueOnce(new Error('disk gone')); + await service.onApplicationBootstrap(); + expect(log.logError).toHaveBeenCalledWith(expect.stringContaining('disk gone')); + }); +}); diff --git a/server/tests/unit/nest/paddle-routing/router-runner.test.ts b/server/tests/unit/nest/paddle-routing/router-runner.test.ts new file mode 100644 index 000000000..971f0ddb9 --- /dev/null +++ b/server/tests/unit/nest/paddle-routing/router-runner.test.ts @@ -0,0 +1,208 @@ +/** + * Where the router runs (PAD-11): the graphs a thread keeps loaded, the + * runner on the calling thread, the router thread's own script, and the + * runner that talks to a thread, here with small threads written by the + * test that answer, fail, hang or die. Also the graph file's format. + */ +import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import'; +import { GraphCache, InlineRouterRunner, type RouteJob } from '../../../../src/nest/paddle-routing/router-runner'; +import { decodeWaterGraph, encodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph'; +import { WorkerRouterRunner } from '../../../../src/nest/paddle-routing/worker-router-runner'; +import { WaterWorld } from '../../../helpers/water-world'; + +import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +/** The router thread's port, as its script sees it: messages in, messages out. */ +const port = vi.hoisted(() => { + const listeners: Array<(message: unknown) => void> = []; + return { + on: (event: string, listener: (message: unknown) => void) => { + if (event === 'message') listeners.push(listener); + }, + emit: (_event: 'message', message: unknown) => listeners.forEach((listener) => listener(message)), + postMessage: vi.fn(), + }; +}); +vi.mock('node:worker_threads', async (importOriginal) => ({ + ...(await importOriginal()), + parentPort: port, +})); + +let dir: string; +let file: string; +const world = new WaterWorld(); +world.line( + [ + [0, 4000], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, +); +const PROFILE = { boat: 'kayak' as const, lakeStyle: 'direct' as const, lockPolicy: 'auto' as const }; +const job = (path: string): RouteJob => ({ + file: path, + legs: [{ from: world.at(0, 3500), to: world.at(0, 500) }], + profile: PROFILE, +}); + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-router-runner-')); + const pbf = join(dir, 'world.osm.pbf'); + await writeFile(pbf, world.pbf()); + const { graph } = await importExtract(pbf, 'runner', () => undefined); + if (!graph) throw new Error('no network'); + file = join(dir, 'region.wgraph'); + await writeFile(file, graph.bytes); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('the water graph file', () => { + it('PADRUN-001: the same graph gives the same bytes and revision; a foreign or newer file is refused', async () => { + const { readFile } = await import('node:fs/promises'); + const bytes = await readFile(file); + const again = encodeWaterGraph(decodeWaterGraph(bytes)); + expect(Buffer.compare(again.bytes, bytes)).toBe(0); + expect(again.rev).toMatch(/^[0-9a-f]{16}$/); + + expect(() => decodeWaterGraph(Buffer.from('PK\u0003\u0004 not a graph at all'))).toThrow('Not a water graph'); + // The magic TREKWG, then the head's length, then the head. + const headLength = bytes.readUInt32LE(6); + const head = bytes.toString('utf8', 10, 10 + headLength); + const newer = Buffer.from(bytes); + newer.write(head.replace(/"format":\d+/, '"format":9'), 10, 'utf8'); + expect(() => decodeWaterGraph(newer)).toThrow(/format 9/); + const lacking = Buffer.from(bytes); + lacking.write(head.replace('"name":"nodeLat"', '"name":"nodeLaX"'), 10, 'utf8'); + expect(() => decodeWaterGraph(lacking)).toThrow('Water graph lacks nodeLat'); + }); +}); + +describe('GraphCache and the runner on the calling thread', () => { + it('PADRUN-002: loads a graph once, keeps four, forgets on request, and retries a file that failed to load', async () => { + const cache = new GraphCache(); + const first = await cache.get(file); + expect(await cache.get(file)).toBe(first); + const copies = await Promise.all( + [1, 2, 3, 4].map(async (index) => { + const copy = join(dir, `copy-${index}.wgraph`); + await writeFile(copy, await (await import('node:fs/promises')).readFile(file)); + await cache.get(copy); + return copy; + }), + ); + // Four others were loaded since: the first went. + expect(await cache.get(file)).not.toBe(first); + const kept = await cache.get(copies[3] as string); + cache.forget(copies[3] as string); + expect(await cache.get(copies[3] as string)).not.toBe(kept); + + const missing = join(dir, 'missing.wgraph'); + await expect(cache.get(missing)).rejects.toThrow(); + await writeFile(missing, await (await import('node:fs/promises')).readFile(file)); + await expect(cache.get(missing)).resolves.toBeDefined(); + await unlink(missing); + cache.clear(); + }); + + it('PADRUN-003: the inline runner plans the job and closes', async () => { + const runner = new InlineRouterRunner(); + const [leg] = await runner.run(job(file)); + expect(leg).toMatchObject({ status: 'ok' }); + runner.forget(file); + await expect(runner.close()).resolves.toBeUndefined(); + }); + + it('PADROUTE-WORKER-001: the router thread plans what it is sent, forgets on request and answers a failure as a message', async () => { + await import('../../../../src/nest/paddle-routing/paddle-router.worker'); + port.emit('message', { id: 1, job: job(file) }); + await vi.waitFor(() => + expect(port.postMessage).toHaveBeenCalledWith({ id: 1, legs: [expect.objectContaining({ status: 'ok' })] }), + ); + port.emit('message', { id: 0, forget: file }); + port.emit('message', { id: 2 }); + port.emit('message', { id: 3, job: job(join(dir, 'nowhere.wgraph')) }); + await vi.waitFor(() => + expect(port.postMessage).toHaveBeenCalledWith({ id: 3, error: expect.stringContaining('nowhere.wgraph') }), + ); + expect(port.postMessage).toHaveBeenCalledTimes(2); + }); +}); + +/** A thread script the runner can start: plain JS, so it runs without the server's loader. */ +async function script(name: string, body: string): Promise { + const path = join(dir, `${name}.cjs`); + await writeFile(path, `const { parentPort } = require('node:worker_threads');\n${body}\n`); + return path; +} + +describe('WorkerRouterRunner', () => { + it('PADRUN-004: hands the job to its thread and answers its legs; a failure the thread reports rejects', async () => { + const entry = await script( + 'answers', + `parentPort.on('message', (m) => { + if (m.forget) return; + if (m.job.file === 'bad') parentPort.postMessage({ id: m.id, error: 'graph broken' }); + else if (m.job.file === 'silent') parentPort.postMessage({ id: m.id }); + else parentPort.postMessage({ id: m.id, legs: [{ status: 'failed', code: 'no_water_path', at: null }] }); + });`, + ); + const runner = new WorkerRouterRunner(entry); + await expect(runner.run(job('good'))).resolves.toEqual([{ status: 'failed', code: 'no_water_path', at: null }]); + await expect(runner.run(job('bad'))).rejects.toThrow('graph broken'); + await expect(runner.run(job('silent'))).rejects.toThrow('router failed'); + runner.forget('good'); + await runner.close(); + }); + + it('PADRUN-005: a thread that dies fails what it held, and the next job gets a fresh thread', async () => { + const entry = await script( + 'dies', + `parentPort.on('message', (m) => { + if (m.job.file === 'crash') process.exit(3); + parentPort.postMessage({ id: m.id, legs: [] }); + });`, + ); + const runner = new WorkerRouterRunner(entry); + await expect(runner.run(job('crash'))).rejects.toThrow('router thread ended'); + await expect(runner.run(job('fine'))).resolves.toEqual([]); + await runner.close(); + }); + + it('PADRUN-006: a thread that throws fails its jobs; closing fails what is still waiting', async () => { + const entry = await script( + 'throws', + `parentPort.on('message', (m) => { + if (m.job.file === 'throw') setTimeout(() => { throw new Error('boom'); }); + });`, + ); + const runner = new WorkerRouterRunner(entry); + await expect(runner.run(job('throw'))).rejects.toThrow('boom'); + const waiting = runner.run(job('never')).catch((err: Error) => err.message); + await runner.close(); + expect(await waiting).toBe('closed'); + }); + + it('PADRUN-007: a job its thread leaves unanswered times out and the thread is started over', async () => { + const entry = await script('hangs', `parentPort.on('message', () => undefined);`); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const runner = new WorkerRouterRunner(entry); + const hanging = runner.run(job('hang')); + const caught = hanging.catch((err: Error) => err.message); + await vi.advanceTimersByTimeAsync(8000); + expect(await caught).toBe('timeout'); + // The idle timer ends a thread nobody asks for long enough. + runner.run(job('again')).catch(() => undefined); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + await runner.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/server/tests/unit/nest/paddle-routing/water-routing.test.ts b/server/tests/unit/nest/paddle-routing/water-routing.test.ts new file mode 100644 index 000000000..49ea77ef5 --- /dev/null +++ b/server/tests/unit/nest/paddle-routing/water-routing.test.ts @@ -0,0 +1,392 @@ +/** + * PAD-11 from the extract to the planned leg: a piece of the world drawn in + * the test, read by the region import, built into the water network and + * routed on, as a region's read and a paddler's leg go in production. + */ +import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import'; +import { WaterRouter, type RouteProfile } from '../../../../src/nest/paddle-routing/engine/router'; +import { decodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph'; +import { lineMetres, WaterWorld } from '../../../helpers/water-world'; +import type { PaddleRouteLeg } from '@trek/shared'; + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +let dir: string; +let n = 0; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'trek-water-routing-')); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const KAYAK: RouteProfile = { boat: 'kayak', lakeStyle: 'direct', lockPolicy: 'auto' }; +const SUP: RouteProfile = { ...KAYAK, boat: 'sup' }; + +/** The world read as a region: its network's stats and a router on it. */ +async function built(world: WaterWorld) { + const path = join(dir, `world-${n++}.osm.pbf`); + await writeFile(path, world.pbf()); + const phases: string[] = []; + const outcome = await importExtract(path, 'test-region', (phase) => { + if (phases.at(-1) !== phase) phases.push(phase); + }); + if (!outcome.graph) throw new Error('no network built'); + return { ...outcome.graph, phases, router: new WaterRouter(decodeWaterGraph(Buffer.from(outcome.graph.bytes))) }; +} + +function ok(leg: PaddleRouteLeg): Extract { + if (leg.status !== 'ok') throw new Error(`leg failed: ${leg.code}`); + return leg; +} + +const kinds = (leg: PaddleRouteLeg) => [...new Set(ok(leg).segments.map((segment) => segment.kind))]; + +/** A river 6 km long, flowing south, its centre line at east 0, a node every 100 m (node `k` at north 6000 - 100k). */ +function river(world: WaterWorld, tags: Record = {}) { + return world.line( + [ + [0, 6000], + [0, 0], + ], + { waterway: 'river', name: 'Regen', ...tags }, + ); +} + +describe('water routing from an extract (PAD-11)', () => { + it('WATER-001: follows a river between two points beside it, snapped onto it, named', async () => { + const world = new WaterWorld(); + river(world); + const { router, stats, phases } = await built(world); + expect(phases).toEqual(['read', 'build']); + expect(stats.water_km).toBeCloseTo(6, 1); + + const leg = ok(router.route(world.at(40, 5000), world.at(-30, 1000), KAYAK)); + expect(leg.distance_m).toBeGreaterThan(3950); + expect(leg.distance_m).toBeLessThan(4050); + expect(leg.snapped_m.from).toBeCloseTo(40, -1); + expect(leg.segments).toEqual([expect.objectContaining({ kind: 'water', name: 'Regen' })]); + expect(lineMetres(leg.geometry as [number, number][])).toBeCloseTo(leg.distance_m, -1); + }); + + it('WATER-002: a point far from the water is off it, two waters that never meet have no way between', async () => { + const world = new WaterWorld(); + river(world); + world.line( + [ + [3000, 6000], + [3000, 0], + ], + { waterway: 'river', name: 'Naab' }, + ); + const { router } = await built(world); + const far = world.at(600, 3000); + expect(router.route(world.at(0, 5000), far, KAYAK)).toEqual({ status: 'failed', code: 'not_on_water', at: far }); + expect(router.route(world.at(0, 5000), world.at(3000, 1000), KAYAK)).toEqual({ + status: 'failed', + code: 'no_water_path', + at: null, + }); + }); + + it('WATER-003: carries round a weir on the mapped portage, and round one without a portage on a short straight carry', async () => { + const world = new WaterWorld(); + const regen = river(world); + // A weir drawn across the river at 4 km, its portage on the west bank. + world.line( + [ + [-30, 4000], + [30, 4000], + ], + { waterway: 'weir', name: 'Lindenmühle' }, + 30, + ); + world.line( + [ + [0, 4060], + [-25, 4050], + [-25, 3950], + [0, 3940], + ], + { canoe: 'portage', name: 'Umtragestelle' }, + 40, + ); + // A weir on the river's node at 2 km, with nothing mapped round it. + world.tag(regen.refs[40] as number, { waterway: 'weir', name: 'Pielmühle' }); + const { router, stats } = await built(world); + expect(stats.portages).toBeGreaterThanOrEqual(1); + expect(stats.inferred_portages).toBeGreaterThanOrEqual(1); + + const leg = ok(router.route(world.at(0, 5500), world.at(0, 500), KAYAK)); + const portages = leg.segments.filter((segment) => segment.kind === 'portage'); + expect(portages.map((segment) => segment.source)).toEqual(['tagged', 'inferred']); + expect(portages[0]?.name).toBe('Umtragestelle'); + }); + + it('WATER-004: paddles grade 2 whitewater in a kayak and marks it, never in a SUP; grade 3 is no touring water', async () => { + const world = new WaterWorld(); + world.line( + [ + [0, 6000], + [0, 3000], + ], + { waterway: 'river', name: 'Regen' }, + ); + world.line( + [ + [0, 3000], + [0, 2500], + ], + { waterway: 'river', name: 'Regen', 'whitewater:section_grade': '2' }, + ); + world.line( + [ + [0, 2500], + [0, 0], + ], + { waterway: 'river', name: 'Regen' }, + ); + world.line( + [ + [5000, 6000], + [5000, 3000], + ], + { waterway: 'river', name: 'Wild', 'whitewater:section_grade': '3' }, + ); + const { router } = await built(world); + const kayak = ok(router.route(world.at(0, 5000), world.at(0, 1000), KAYAK)); + expect(kayak.segments.find((segment) => segment.kind === 'rapids')).toMatchObject({ grade: 2 }); + expect(router.route(world.at(0, 5000), world.at(0, 1000), SUP)).toMatchObject({ status: 'failed' }); + expect(router.route(world.at(5000, 5500), world.at(5000, 3500), KAYAK)).toMatchObject({ status: 'failed' }); + }); + + it('WATER-005: crosses a lake straight over, along its shore when asked, and a SUP keeps to the shore anyway', async () => { + const world = new WaterWorld(); + // In from the north, a lake 1 km wide and 3 km long, out to the south. + world.line( + [ + [0, 6000], + [0, 4000], + ], + { waterway: 'river', name: 'Havel' }, + ); + world.ring( + [ + [-500, 4000], + [500, 4000], + [500, 1000], + [-500, 1000], + ], + { natural: 'water', name: 'Woblitz' }, + ); + world.line( + [ + [0, 1000], + [0, -1000], + ], + { waterway: 'river', name: 'Havel' }, + ); + const { router, stats } = await built(world); + expect(stats.lakes).toBe(1); + + const from = world.at(0, 5000); + const to = world.at(0, 0); + const direct = ok(router.route(from, to, KAYAK)); + expect(kinds(direct)).toEqual(['water', 'lake']); + expect(direct.segments.find((segment) => segment.kind === 'lake')?.name).toBe('Woblitz'); + const shore = ok(router.route(from, to, { ...KAYAK, lakeStyle: 'shore' })); + const sup = ok(router.route(from, to, SUP)); + expect(shore.distance_m).toBeGreaterThan(direct.distance_m); + expect(sup.distance_m).toBeGreaterThanOrEqual(direct.distance_m); + // A point out on the lake is on the water too. + expect(router.route(world.at(200, 2500), to, KAYAK).status).toBe('ok'); + }); + + it('WATER-006: goes round an island of a multipolygon lake, not across it', async () => { + const world = new WaterWorld(); + const outer = world.ring([ + [-1000, 1000], + [1000, 1000], + [1000, -1000], + [-1000, -1000], + ]); + const island = world.ring([ + [-300, 300], + [300, 300], + [300, -300], + [-300, -300], + ]); + world.relation( + [ + { type: 'way', ref: outer, role: 'outer' }, + { type: 'way', ref: island, role: 'inner' }, + ], + { type: 'multipolygon', natural: 'water', name: 'Müritz' }, + ); + const { router, stats } = await built(world); + expect(stats.lakes).toBe(1); + const leg = ok(router.route(world.at(0, 800), world.at(0, -800), KAYAK)); + expect(leg.distance_m).toBeGreaterThan(1600); + const crossesIsland = leg.geometry.some(([lat, lng]) => { + const centre = world.at(0, 0); + const edge = world.at(250, 250); + return Math.abs(lat - centre.lat) < edge.lat - centre.lat && Math.abs(lng - centre.lng) < edge.lng - centre.lng; + }); + expect(crossesIsland).toBe(false); + }); + + it('WATER-007: joins a river end close to other water as water, bridges a wider break as a marked gap', async () => { + const world = new WaterWorld(); + world.line( + [ + [0, 3000], + [0, 2000], + ], + { waterway: 'river' }, + ); + world.line( + [ + [0, 1980], + [0, 1000], + ], + { waterway: 'river' }, + ); + world.line( + [ + [0, 920], + [0, 0], + ], + { waterway: 'river' }, + ); + const { router, stats } = await built(world); + expect(stats.gaps).toBeGreaterThanOrEqual(1); + const leg = ok(router.route(world.at(0, 2900), world.at(0, 100), KAYAK)); + expect(kinds(leg)).toContain('gap'); + }); + + it('WATER-008: takes the canoe pass beside a weir, and leaves a mill race to the mill', async () => { + const world = new WaterWorld(); + const regen = river(world); + world.tag(regen.refs[30] as number, { waterway: 'weir' }); + world.line( + [ + [0, 3200], + [-40, 3100], + [-40, 2900], + [0, 2800], + ], + { waterway: 'canoe_pass', name: 'Bootsgasse' }, + 50, + ); + world.line( + [ + [0, 5000], + [800, 4500], + [800, 1500], + [0, 1000], + ], + { waterway: 'canal', usage: 'headrace' }, + ); + const { router } = await built(world); + const leg = ok(router.route(world.at(0, 5500), world.at(0, 500), KAYAK)); + expect(kinds(leg)).toContain('pass'); + expect(kinds(leg)).not.toContain('portage'); + expect(leg.distance_m).toBeLessThan(5300); + }); + + it('WATER-011: goes through an open lock by its own name, carries round it when told to, and only on a mapped way round a closed one', async () => { + const world = new WaterWorld(); + const canal = (east: number, lock: Record) => { + world.line( + [ + [east, 3000], + [east, 2000], + ], + { waterway: 'canal', name: 'Müritz-Havel-Wasserstraße' }, + ); + world.line( + [ + [east, 2000], + [east, 1900], + ], + { waterway: 'canal', name: 'Müritz-Havel-Wasserstraße', lock: 'yes', ...lock }, + 20, + ); + world.line( + [ + [east, 1900], + [east, 0], + ], + { waterway: 'canal', name: 'Müritz-Havel-Wasserstraße' }, + ); + }; + canal(0, { lock_name: 'Schleuse Diemitz' }); + canal(3000, { lock_name: 'Schleuse Zu', boat: 'no' }); + canal(6000, { lock_name: 'Schleuse Gesperrt', boat: 'no' }); + world.line( + [ + [6000, 2050], + [6030, 2000], + [6030, 1900], + [6000, 1850], + ], + { canoe: 'portage' }, + 30, + ); + const { router, stats } = await built(world); + expect(stats.locks).toBeGreaterThanOrEqual(1); + + const through = ok(router.route(world.at(0, 2800), world.at(0, 200), KAYAK)); + expect(through.segments.find((segment) => segment.kind === 'lock')?.name).toBe('Schleuse Diemitz'); + const carried = ok(router.route(world.at(0, 2800), world.at(0, 200), { ...KAYAK, lockPolicy: 'portage' })); + expect(kinds(carried)).toContain('portage'); + expect(kinds(carried)).not.toContain('lock'); + + expect(router.route(world.at(3000, 2800), world.at(3000, 200), KAYAK).status).toBe('failed'); + const round = ok(router.route(world.at(6000, 2800), world.at(6000, 200), KAYAK)); + expect(round.segments.find((segment) => segment.kind === 'portage')?.source).toBe('tagged'); + }); + + it('WATER-009: a search past its time answers timeout, not a route; a leg along one stretch needs no search', async () => { + const world = new WaterWorld(); + for (const [from, to] of [ + [6000, 3000], + [3000, 0], + ]) { + world.line( + [ + [0, from as number], + [0, to as number], + ], + { waterway: 'river' }, + ); + } + const { router } = await built(world); + expect(router.route(world.at(0, 5000), world.at(0, 4000), KAYAK, Date.now() - 1).status).toBe('ok'); + expect(router.route(world.at(0, 5000), world.at(0, 1000), KAYAK, Date.now() - 1)).toEqual({ + status: 'failed', + code: 'timeout', + at: null, + }); + }); + + it('WATER-010: an extract without water it routes on builds no network', async () => { + const world = new WaterWorld(); + world.line( + [ + [0, 1000], + [0, 0], + ], + { highway: 'path' }, + ); + const path = join(dir, `world-${n++}.osm.pbf`); + await writeFile(path, world.pbf()); + const outcome = await importExtract(path, 'dry', () => undefined); + expect(outcome.graph).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index f14c57787..347fe73ca 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -183,6 +183,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, }); expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0); expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints); @@ -297,6 +298,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, + paddle_lake_style: null, }); expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42); expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 42c6dbd63..6288e3753 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -137,6 +137,8 @@ export default defineConfig({ 'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 }, 'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 99, lines: 97 }, 'src/nest/paddle-obstacles/**/*.ts': { statements: 96, branches: 92, functions: 94, lines: 97 }, + // PAD-11, new in this change: measured by its unit and e2e suites, one point of slack. + 'src/nest/paddle-routing/**/*.ts': { statements: 94, branches: 83, functions: 96, lines: 97 }, 'src/nest/permissions/**/*.ts': { statements: 97, branches: 92, functions: 99, lines: 97 }, 'src/nest/photos/**/*.ts': { statements: 96, branches: 92, functions: 92, lines: 96 }, // Landed on dev without an entry, so it sat on the 80 catch-all while diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 2196d11ab..77f511b9c 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -916,5 +916,61 @@ const admin: TranslationStrings = { 'admin.paddleRegions.objects.two': 'العناصر: {count}', 'admin.paddleRegions.objects.few': 'العناصر: {count}', 'admin.paddleRegions.objects.many': 'العناصر: {count}', + 'admin.paddleRegions.phase.build': 'جارٍ بناء شبكة المياه', + 'admin.paddleRegions.network.km': '{km} km من المياه', + 'admin.paddleRegions.network.lakes': 'البحيرات: {count}', + 'admin.paddleRegions.network.lakes.zero': 'البحيرات: {count}', + 'admin.paddleRegions.network.lakes.one': 'البحيرات: {count}', + 'admin.paddleRegions.network.lakes.two': 'البحيرات: {count}', + 'admin.paddleRegions.network.lakes.few': 'البحيرات: {count}', + 'admin.paddleRegions.network.lakes.many': 'البحيرات: {count}', + 'admin.paddleRegions.network.portages': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.portages.zero': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.portages.one': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.portages.two': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.portages.few': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.portages.many': 'مواضع الحمل المرسومة: {count}', + 'admin.paddleRegions.network.inferred': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.inferred.zero': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.inferred.one': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.inferred.two': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.inferred.few': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.inferred.many': 'مواضع الحمل المفترضة: {count}', + 'admin.paddleRegions.network.locks': 'الأهوسة: {count}', + 'admin.paddleRegions.network.locks.zero': 'الأهوسة: {count}', + 'admin.paddleRegions.network.locks.one': 'الأهوسة: {count}', + 'admin.paddleRegions.network.locks.two': 'الأهوسة: {count}', + 'admin.paddleRegions.network.locks.few': 'الأهوسة: {count}', + 'admin.paddleRegions.network.locks.many': 'الأهوسة: {count}', + 'admin.paddleRegions.network.gaps': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.gaps.zero': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.gaps.one': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.gaps.two': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.gaps.few': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.gaps.many': 'الفجوات الموصولة: {count}', + 'admin.paddleRegions.network.missing': 'لا توجد شبكة مياه', + 'admin.paddleRegions.network.missingHint': 'أعد قراءة المنطقة لبناء الشبكة التي يتبعها مسار التجديف.', + 'admin.paddleRegions.auto.title': 'تحميل المناطق تلقائيًا', + 'admin.paddleRegions.auto.mode.auto': 'تلقائي', + 'admin.paddleRegions.auto.mode.ask': 'عند الطلب', + 'admin.paddleRegions.auto.mode.off': 'إيقاف', + 'admin.paddleRegions.auto.modeHint.auto': + 'عندما يخطط أحد لرحلة تجديف في منطقة بلا بيانات مائية بعد، يحمّل TREK أصغر منطقة مناسبة فورًا، ضمن حد الحجم وعدد قليل في اليوم. ما هو أكبر ينتظر هنا كطلب. يُرسل اسم المنطقة فقط إلى Geofabrik، وليس المكان أبدًا.', + 'admin.paddleRegions.auto.modeHint.ask': + 'تنتظر كل منطقة جديدة هنا حتى يسمح بها مسؤول. عندما يخطط مسؤول بنفسه تُحمّل فورًا.', + 'admin.paddleRegions.auto.modeHint.off': 'لا يحمّل TREK شيئًا من تلقاء نفسه ولا يطلب شيئًا. أضف المناطق هنا يدويًا.', + 'admin.paddleRegions.auto.maxSize': 'أكبر منطقة تُحمّل تلقائيًا', + 'admin.paddleRegions.auto.maxSizeHint': + 'المناطق الأكبر تنتظر هنا كطلب. قراءة منطقة تستغرق بضع دقائق وتستهلك الذاكرة أثناءها.', + 'admin.paddleRegions.requests.title': 'مطلوبة', + 'admin.paddleRegions.requests.reason.ask': 'عند الطلب', + 'admin.paddleRegions.requests.reason.too_large': 'كبيرة جدًا', + 'admin.paddleRegions.requests.reason.limit': 'الحد اليومي', + 'admin.paddleRegions.requests.declined': 'مرفوضة', + 'admin.paddleRegions.requests.by': 'من {user}', + 'admin.paddleRegions.requests.approve': 'تحميل', + 'admin.paddleRegions.requests.decline': 'رفض', + 'admin.paddleRegions.origin.auto': 'تلقائي', + 'admin.paddleRegions.origin.autoHint': 'حمّل TREK هذه المنطقة بنفسه لرحلة تجديف مخطط لها هناك.', }; export default admin; diff --git a/shared/src/i18n/ar/externalNotifications.ts b/shared/src/i18n/ar/externalNotifications.ts index 9751a881b..6cff6e7ad 100644 --- a/shared/src/i18n/ar/externalNotifications.ts +++ b/shared/src/i18n/ar/externalNotifications.ts @@ -82,6 +82,10 @@ const ar: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'طلب منطقة تجديف', + body: `${p.actor} يخطط لرحلة تجديف في ${p.region}. حمّل بياناتها المائية أو ارفضها في لوحة الإدارة ضمن الإضافات.`, + }), synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.', diff --git a/shared/src/i18n/ar/notif.ts b/shared/src/i18n/ar/notif.ts index 27d775876..6e71f6071 100644 --- a/shared/src/i18n/ar/notif.ts +++ b/shared/src/i18n/ar/notif.ts @@ -36,6 +36,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "فشلت الكتابة على النسخة المتماثلة '{backend}': {op} لـ {key} — {error}", 'notif.replica_failure.textSuppressed': "فشلت الكتابة على النسخة المتماثلة '{backend}': {op} لـ {key} — {error}. تم تجاهل {suppressed} فشل إضافي منذ آخر إشعار.", + 'notif.paddle_region_requested.title': 'طلب منطقة تجديف', + 'notif.paddle_region_requested.text': '{actor} يخطط لرحلة تجديف في {region}', 'notif.action.view_trip': 'عرض الرحلة', 'notif.action.view_collab': 'عرض الرسائل', 'notif.action.view_packing': 'عرض التعبئة', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 0ea0a7b9b..0267a090a 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -104,6 +104,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'إشعارات Webhook', 'settings.notifyVersionAvailable': 'إصدار جديد متاح', 'settings.notifyReplicaFailure': 'فشل النسخة المتماثلة للتخزين', + 'settings.notifyPaddleRegionRequested': 'طلب منطقة تجديف', 'settings.notificationPreferences.noChannels': 'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.', 'settings.webhookUrl.label': 'رابط Webhook', diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index e2bc4e915..55fa1c1b7 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -298,8 +298,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'أدخل سرعة بين 0.5 و20 كم/س.', 'tours.paddle.chooseBoat': 'اختر القارب والمسطح المائي لحفظ جولة التجديف هذه.', 'tours.paddle.chooseBoatImport': 'اختر القارب والمسطح المائي للاستيراد كجولة تجديف.', - 'tours.paddle.drawnByHand': - 'تُرسم جولة التجديف يدويًا: يصل TREK نقاطك بخطوط مستقيمة. لا يُحسب أي مسار، ولا شيء يقول إن الماء صالح للتجديف.', 'tours.paddle.timeEstimated': 'وقت التجديف (تقديري)', 'tours.paddle.timeEstimatedShort': 'تجديف', 'tours.paddle.timeEstimatedInfoLabel': 'كيف يُقدَّر وقت التجديف', @@ -356,6 +354,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'حمل القارب', 'tours.paddle.portageOn': 'حمل القارب من هنا', 'tours.paddle.portageOff': 'التجديف في هذا المقطع مجددًا', + 'tours.paddle.portagePending': 'حمل القارب من هنا', + 'tours.paddle.portagePendingHint': 'ضع الآن النقطة التي تعود فيها إلى الماء. يُحمل القارب حتى هناك.', + 'tours.paddle.portagePendingOff': 'إلغاء الحمل', + 'tours.paddle.portageWay': 'المسار', + 'tours.paddle.portageWay.follow': 'اتبع الطريق', + 'tours.paddle.portageWay.straight': 'خط مستقيم', + 'tours.paddle.portageWay.noWay': 'لا يوجد طريق دون التفاف كبير، لذلك يبقى الخط مستقيمًا.', + 'tours.paddle.portageWay.searching': 'جارٍ البحث عن طريق', + 'tours.paddle.placeHere.putIn': 'انطلق من هنا', + 'tours.paddle.placeHere.egress': 'اخرج من الماء هنا', 'tours.paddle.portageInfo': 'يُحمل القارب هنا. يُحتسب المقطع برًّا، مع وقت للخروج والتفريغ والعودة إلى الماء.', 'tours.paddle.portageExtra': 'خروج وعودة', 'tours.paddle.lock': 'هويس', @@ -573,5 +581,61 @@ const tours: TranslationStrings = { 'tours.paddle.time.locksMissing.two': '{count} من الأهوسة بلا أوقات بعد.', 'tours.paddle.time.locksMissing.few': '{count} من الأهوسة بلا أوقات بعد.', 'tours.paddle.time.locksMissing.many': '{count} من الأهوسة بلا أوقات بعد.', + 'tours.planner.shapingPoint': 'نقطة تشكيل', + 'tours.planner.shapingHint': 'توجّه المسار دون أن تكون محطة. اسحبها إلى حيث يجب أن يمر المسار.', + 'tours.planner.makeStop': 'اجعلها محطة', + 'tours.planner.makeShaping': 'اجعلها نقطة تشكيل', + 'tours.planner.recomputeLeg': 'أعد حساب المقطع التالي', + 'tours.planner.shapeOnLine': 'انقر على المسار في الخريطة لإضافة نقطة تشكيل هناك.', + 'tours.paddle.routeOnWater': 'خطّط المقطع التالي على الماء', + 'tours.paddle.routeAllOnWater': 'خطّط على الماء المقاطع المرسومة يدويًا', + 'tours.paddle.auto': 'مكتشفة', + 'tours.paddle.autoHint': + 'وضع TREK هذه النقطة عند موضع حمل للقارب أو هويس وجده في الطريق. إذا نقلتها أو غيّرتها تصبح لك.', + 'tours.paddle.note.rapids': 'مياه بيضاء من الدرجة {grade}', + 'tours.paddle.note.pass': 'ممر للقوارب', + 'tours.paddle.note.gap': 'فجوة في بيانات الخريطة', + 'tours.paddle.note.rapidsHint': 'يمر المسار هنا عبر مياه جارية. استطلع المكان أولًا واحمل القارب عند الشك.', + 'tours.paddle.note.gapHint': 'في بيانات المياه فجوة هنا وُصلت بخط مستقيم. تحقّق منها في الموقع.', + 'tours.paddle.failed.not_on_water': 'أحد طرفي هذا المقطع ليس على ماء يعرفه TREK. انقله إلى النهر أو البحيرة.', + 'tours.paddle.failed.no_region': 'لا توجد بيانات مياه لهذه المنطقة بعد. يمكن للمسؤول إضافة المنطقة.', + 'tours.paddle.failed.building': 'ما زالت بيانات المياه لهذه المنطقة قيد التحضير. حاول مجددًا بعد بضع دقائق.', + 'tours.paddle.failed.no_water_path': 'لم يجد TREK طريقًا على الماء بين هذه النقاط.', + 'tours.paddle.failed.cross_region': 'تقع هذه النقاط في منطقتين لا تشتركان في بيانات المياه.', + 'tours.paddle.failed.busy': 'مخطط المسارات مشغول. حاول مجددًا بعد لحظة.', + 'tours.paddle.failed.timeout': 'استغرق تخطيط هذا المقطع وقتًا طويلًا. أضف نقطة بينهما وحاول مجددًا.', + 'tours.paddle.failed.offline': 'غير متصل. سيُخطَّط هذا المقطع عند عودة الاتصال.', + 'tours.paddle.drawStraight': 'ارسم خطًا مستقيمًا', + 'tours.paddle.retry': 'حاول مجددًا', + 'tours.paddle.load.loading': 'جارٍ تحميل البيانات المائية لـ {region}', + 'tours.paddle.load.ready': 'وصلت البيانات المائية لـ {region}، جارٍ تخطيط المقطع.', + 'tours.paddle.load.requested': 'طُلبت البيانات المائية لـ {region}. سيُخطط المقطع بمجرد أن يسمح بها مسؤول.', + 'tours.paddle.load.waiting': 'بانتظار مناطق أخرى', + 'tours.paddle.load.phase.download': 'تنزيل', + 'tours.paddle.load.phase.read': 'قراءة', + 'tours.paddle.load.phase.build': 'بناء الشبكة المائية', + 'tours.paddle.load.hint': 'عادةً من دقيقة إلى ثلاث دقائق. تابع التخطيط، وسيُخطط المقطع تلقائيًا بعد ذلك.', + 'tours.paddle.load.reason.tooLarge': 'المنطقة أكبر مما يحمّله TREK من تلقاء نفسه.', + 'tours.paddle.load.reason.limit': 'حمّل TREK اليوم ما يكفي من المناطق من تلقاء نفسه.', + 'tours.paddle.load.declined': 'رفض مسؤول البيانات المائية لـ {region}.', + 'tours.paddle.load.failed': 'تعذّر تحميل البيانات المائية لـ {region}.', + 'tours.paddle.load.noWater': 'لا توجد مياه هنا في البيانات المائية لـ {region}. ضع النقطة على النهر أو البحيرة.', + 'tours.paddle.load.noExtract': 'لا يوجد مقتطف خرائط ببيانات مائية لهذه المنطقة.', + 'tours.paddle.load.catalogUnavailable': 'لا يمكن الوصول إلى قائمة المناطق حاليًا. حاول لاحقًا.', + 'tours.paddle.lakeStyle': 'عبر البحيرات', + 'tours.paddle.lakeStyle.direct': 'مباشرةً', + 'tours.paddle.lakeStyle.shore': 'قرب الشاطئ', + 'tours.paddle.lakeStyle.directHint': 'أقصر خط عبر المياه المفتوحة.', + 'tours.paddle.lakeStyle.shoreHint': 'يبقى قرب الشاطئ لأيام الرياح: أطول، لكن اليابسة لا تبعد أبدًا.', + 'tours.paddle.wind.shoreAdvice': + 'في هذا اليوم حتى {mean} km/h، والهبّات {gusts} km/h. على البحيرات قد يكون البقاء قرب الشاطئ هو الخط الأفضل.', + 'tours.paddle.wind.shoreAdopt': 'خطّط قرب الشاطئ', + 'tours.paddle.wind.shoreTaken': 'البحيرات مخطّطة قرب الشاطئ.', + 'tours.paddle.status.unrouted': 'مقاطع غير مخطّطة: {count}', + 'tours.paddle.status.unrouted.zero': 'مقاطع غير مخطّطة: {count}', + 'tours.paddle.status.unrouted.one': 'مقاطع غير مخطّطة: {count}', + 'tours.paddle.status.unrouted.two': 'مقاطع غير مخطّطة: {count}', + 'tours.paddle.status.unrouted.few': 'مقاطع غير مخطّطة: {count}', + 'tours.paddle.status.unrouted.many': 'مقاطع غير مخطّطة: {count}', }; export default tours; diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index eee32ef56..ef9324112 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -910,6 +910,43 @@ const admin: TranslationStrings = { 'Məlumatlar © OpenStreetMap iştirakçıları (ODbL), regional çıxarışlar Geofabrik-dən. Hər ayın 3-də yenidən oxunur.', 'admin.paddleRegions.toast.added': 'Region əlavə edildi. TREK onu arxa planda oxuyur.', 'admin.paddleRegions.toast.removed': 'Region silindi', + 'admin.paddleRegions.phase.build': 'Su şəbəkəsi qurulur', + 'admin.paddleRegions.network.km': '{km} km su', + 'admin.paddleRegions.network.lakes': '{count} göl', + 'admin.paddleRegions.network.lakes.one': '{count} göl', + 'admin.paddleRegions.network.portages': '{count} xəritələnmiş daşıma yeri', + 'admin.paddleRegions.network.portages.one': '{count} xəritələnmiş daşıma yeri', + 'admin.paddleRegions.network.inferred': '{count} ehtimal olunan daşıma', + 'admin.paddleRegions.network.inferred.one': '{count} ehtimal olunan daşıma', + 'admin.paddleRegions.network.locks': '{count} şlüz', + 'admin.paddleRegions.network.locks.one': '{count} şlüz', + 'admin.paddleRegions.network.gaps': '{count} birləşdirilmiş boşluq', + 'admin.paddleRegions.network.gaps.one': '{count} birləşdirilmiş boşluq', + 'admin.paddleRegions.network.missing': 'Su şəbəkəsi yoxdur', + 'admin.paddleRegions.network.missingHint': 'Avarçəkmə marşrutunun izlədiyi şəbəkəni qurmaq üçün regionu yenidən oxu.', + 'admin.paddleRegions.auto.title': 'Regionları avtomatik yüklə', + 'admin.paddleRegions.auto.mode.auto': 'Avtomatik', + 'admin.paddleRegions.auto.mode.ask': 'Sorğu ilə', + 'admin.paddleRegions.auto.mode.off': 'Söndür', + 'admin.paddleRegions.auto.modeHint.auto': + 'Kimsə hələ su məlumatı olmayan yerdə avarçəkmə turu planlaşdırırsa, TREK ən kiçik uyğun regionu dərhal yükləyir, ölçü həddinə qədər və gündə yalnız bir neçəsini. Daha böyükləri burada sorğu kimi gözləyir. Geofabrik-ə yalnız regionun adı gedir, yer heç vaxt.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Hər yeni region admin icazə verənə qədər burada gözləyir. Admin özü planlaşdıranda dərhal yüklənir.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK özbaşına heç nə yükləmir və heç nə soruşmur. Regionları burada əl ilə əlavə et.', + 'admin.paddleRegions.auto.maxSize': 'Avtomatik yüklənən ən böyük region', + 'admin.paddleRegions.auto.maxSizeHint': + 'Daha böyük regionlar burada sorğu kimi gözləyir. Regionun oxunması bir neçə dəqiqə çəkir və bu zaman yaddaş tələb edir.', + 'admin.paddleRegions.requests.title': 'Sorğulanıb', + 'admin.paddleRegions.requests.reason.ask': 'Sorğu ilə', + 'admin.paddleRegions.requests.reason.too_large': 'Çox böyük', + 'admin.paddleRegions.requests.reason.limit': 'Gündəlik hədd', + 'admin.paddleRegions.requests.declined': 'Rədd edildi', + 'admin.paddleRegions.requests.by': '{user} tərəfindən', + 'admin.paddleRegions.requests.approve': 'Yüklə', + 'admin.paddleRegions.requests.decline': 'Rədd et', + 'admin.paddleRegions.origin.auto': 'Avtomatik', + 'admin.paddleRegions.origin.autoHint': 'TREK bu regionu orada planlaşdırılan avarçəkmə turu üçün özü yüklədi.', }; export default admin; diff --git a/shared/src/i18n/az/externalNotifications.ts b/shared/src/i18n/az/externalNotifications.ts index be707f1b8..1d0fbbb4c 100644 --- a/shared/src/i18n/az/externalNotifications.ts +++ b/shared/src/i18n/az/externalNotifications.ts @@ -67,6 +67,10 @@ const az: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Avarçəkmə regionu sorğulanıb', + body: `${p.actor} ${p.region} ərazisində avarçəkmə turu planlaşdırır. Su məlumatını admin bölməsində Əlavələr altında yüklə və ya rədd et.`, + }), synology_session_cleared: () => ({ title: 'Synology sessiyası təmizləndi', body: 'Synology hesabınız və ya URL-iniz dəyişib. Synology Photos sessiyanız bağlanıb.', diff --git a/shared/src/i18n/az/notif.ts b/shared/src/i18n/az/notif.ts index 6d4bae42f..de96728a7 100644 --- a/shared/src/i18n/az/notif.ts +++ b/shared/src/i18n/az/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "'{backend}' replikasına yazmaq mümkün olmadı: {key} üçün {op} — {error}", 'notif.replica_failure.textSuppressed': "'{backend}' replikasına yazmaq mümkün olmadı: {key} üçün {op} — {error}. Son bildirişdən bəri daha {suppressed} xəta üçün bildiriş göstərilmədi.", + 'notif.paddle_region_requested.title': 'Avarçəkmə regionu sorğulanıb', + 'notif.paddle_region_requested.text': '{actor} {region} ərazisində avarçəkmə turu planlaşdırır', 'notif.action.view_trip': 'Səyahətə bax', 'notif.action.view_collab': 'Mesajlara bax', 'notif.action.view_packing': 'Çamadan siyahısına bax', diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index 31d62a67b..af2318aa0 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -137,6 +137,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook bildirişləri', 'settings.notifyVersionAvailable': 'Yeni versiya əlçatandır', 'settings.notifyReplicaFailure': 'Yaddaş replikası xətası', + 'settings.notifyPaddleRegionRequested': 'Avarçəkmə regionu sorğulanıb', 'settings.notificationPreferences.email': 'E-poçt', 'settings.notificationPreferences.webhook': 'Webhook', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index e3c70f26e..fdcee7c92 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -287,8 +287,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '0,5 ilə 20 km/saat arasında sürət daxil et.', 'tours.paddle.chooseBoat': 'Bu avarlı turu yadda saxlamaq üçün qayığı və suyu seç.', 'tours.paddle.chooseBoatImport': 'Avarlı tur kimi idxal etmək üçün qayığı və suyu seç.', - 'tours.paddle.drawnByHand': - 'Avarlı tur əllə çəkilir: TREK nöqtələrini düz xətlərlə birləşdirir. Heç nə marşrutlaşdırılmır və heç nə suyun keçilə bilən olduğunu demir.', 'tours.paddle.timeEstimated': 'Avar vaxtı (təxmini)', 'tours.paddle.timeEstimatedShort': 'Avarçəkmə', 'tours.paddle.timeEstimatedInfoLabel': 'Avar vaxtı necə təxmin edilir', @@ -331,6 +329,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Daşıma', 'tours.paddle.portageOn': 'Qayığı buradan daşı', 'tours.paddle.portageOff': 'Bu hissəni yenidən avarla keç', + 'tours.paddle.portagePending': 'Buradan daşıma', + 'tours.paddle.portagePendingHint': 'İndi yenidən suya düşəcəyin nöqtəni qoy. Qayıq oraya qədər daşınır.', + 'tours.paddle.portagePendingOff': 'Daşımadan imtina et', + 'tours.paddle.portageWay': 'Marşrut', + 'tours.paddle.portageWay.follow': 'Yolu izlə', + 'tours.paddle.portageWay.straight': 'Düz xətt', + 'tours.paddle.portageWay.noWay': 'Böyük dolama olmadan yol tapılmadı, xətt düz qalır.', + 'tours.paddle.portageWay.searching': 'Yol axtarılır', + 'tours.paddle.placeHere.putIn': 'Buradan suya düş', + 'tours.paddle.placeHere.egress': 'Burada sudan çıx', 'tours.paddle.portageInfo': 'Qayıq burada daşınır. Bu hissə quru yol sayılır, sudan çıxmaq, yükü boşaltmaq və yenidən suya salmaq üçün vaxt da əlavə olunur.', 'tours.paddle.portageExtra': 'Çıxış və giriş', @@ -524,5 +532,62 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Avarçəkmənin riskləri var. Suyu, qaydaları və havanı özün yoxla.', 'tours.paddle.safety.text': 'Avarçəkmə risklərlə bağlıdır. Xəritələr, marşrut təklifləri və qeydlər natamam və ya köhnə ola bilər. Turdan əvvəl cari yerli şəraiti, suyun istifadə qaydalarını və havanı yoxla. Uyğun təhlükəsizlik tədbirləri gör. TREK planlaşdırmana kömək edir, öz yoxlamanı və məsuliyyətli qərarını əvəz etmir.', + 'tours.planner.shapingPoint': 'Forma nöqtəsi', + 'tours.planner.shapingHint': + 'Dayanacaq olmadan marşrutu istiqamətləndirir. Onu marşrutun keçməli olduğu yerə sürüşdür.', + 'tours.planner.makeStop': 'Dayanacaq et', + 'tours.planner.makeShaping': 'Forma nöqtəsi et', + 'tours.planner.recomputeLeg': 'Növbəti hissəni yenidən hesabla', + 'tours.planner.shapeOnLine': 'Ora forma nöqtəsi əlavə etmək üçün xəritədəki marşruta klik et.', + 'tours.paddle.routeOnWater': 'Növbəti hissəni su ilə planlaşdır', + 'tours.paddle.routeAllOnWater': 'Əllə çəkilmiş hissələri su ilə planlaşdır', + 'tours.paddle.auto': 'Tapıldı', + 'tours.paddle.autoHint': + 'TREK bu nöqtəni yolda tapdığı daşıma yerinə və ya şlüzə qoydu. Onu köçürsən və ya dəyişsən, sənin olacaq.', + 'tours.paddle.note.rapids': 'Sürətli su {grade}', + 'tours.paddle.note.pass': 'Qayıq keçidi', + 'tours.paddle.note.gap': 'Xəritə məlumatlarında boşluq', + 'tours.paddle.note.rapidsHint': 'Marşrut burada axar sudan keçir. Əvvəlcə yerə bax, şübhə olsa qayığı daşı.', + 'tours.paddle.note.gapHint': 'Su məlumatlarında burada boşluq var, düz xətlə birləşdirilib. Yerində yoxla.', + 'tours.paddle.failed.not_on_water': + 'Bu hissənin bir ucu TREK-in bildiyi heç bir suyun üzərində deyil. Onu çaya və ya gölə köçür.', + 'tours.paddle.failed.no_region': 'Bu ərazi üçün hələ su məlumatı yoxdur. Administrator regionu əlavə edə bilər.', + 'tours.paddle.failed.building': + 'Bu regionun su məlumatları hələ hazırlanır. Bir neçə dəqiqədən sonra yenidən cəhd et.', + 'tours.paddle.failed.no_water_path': 'TREK bu nöqtələr arasında su ilə yol tapmadı.', + 'tours.paddle.failed.cross_region': 'Bu nöqtələr ortaq su məlumatı olmayan iki regiondadır.', + 'tours.paddle.failed.busy': 'Marşrut planlayıcısı məşğuldur. Bir azdan yenidən cəhd et.', + 'tours.paddle.failed.timeout': + 'Bu hissənin planlaşdırılması çox uzun çəkdi. Arada nöqtə əlavə et və yenidən cəhd et.', + 'tours.paddle.failed.offline': 'Bağlantı yoxdur. Bu hissə yenidən qoşulduqda planlaşdırılacaq.', + 'tours.paddle.drawStraight': 'Düz çək', + 'tours.paddle.retry': 'Yenidən cəhd et', + 'tours.paddle.load.loading': '{region} üçün su məlumatı yüklənir', + 'tours.paddle.load.ready': '{region} üçün su məlumatı hazırdır, hissə planlaşdırılır.', + 'tours.paddle.load.requested': '{region} üçün su məlumatı sorğulanıb. Admin icazə verən kimi hissə planlaşdırılacaq.', + 'tours.paddle.load.waiting': 'Digər regionları gözləyir', + 'tours.paddle.load.phase.download': 'Endirilir', + 'tours.paddle.load.phase.read': 'Oxunur', + 'tours.paddle.load.phase.build': 'Su şəbəkəsi qurulur', + 'tours.paddle.load.hint': 'Adətən bir-üç dəqiqə. Planlaşdırmağa davam et, hissə sonra özü planlaşdırılacaq.', + 'tours.paddle.load.reason.tooLarge': 'Region TREK-in özbaşına yüklədiyindən böyükdür.', + 'tours.paddle.load.reason.limit': 'TREK bu gün artıq kifayət qədər regionu özü yükləyib.', + 'tours.paddle.load.declined': 'Admin {region} üçün su məlumatını rədd etdi.', + 'tours.paddle.load.failed': '{region} üçün su məlumatı yüklənə bilmədi.', + 'tours.paddle.load.noWater': '{region} üçün su məlumatında burada su yoxdur. Nöqtəni çaya və ya gölə qoy.', + 'tours.paddle.load.noExtract': 'Bu ərazi üçün su məlumatı olan xəritə çıxarışı yoxdur.', + 'tours.paddle.load.catalogUnavailable': 'Regionların siyahısına hazırda çatmaq olmur. Sonra yenidən cəhd et.', + 'tours.paddle.lakeStyle': 'Göllərdə', + 'tours.paddle.lakeStyle.direct': 'Düz keçid', + 'tours.paddle.lakeStyle.shore': 'Sahilə yaxın', + 'tours.paddle.lakeStyle.directHint': 'Açıq suda ən qısa xətt.', + 'tours.paddle.lakeStyle.shoreHint': + 'Sahilə yaxın qalır, küləkli günlər üçün: daha uzun, amma quru heç vaxt uzaq deyil.', + 'tours.paddle.wind.shoreAdvice': + 'Bu gün {mean} km/saata qədər, ani əsmələr {gusts} km/saat. Göllərdə sahilə yaxın qalmaq daha yaxşı ola bilər.', + 'tours.paddle.wind.shoreAdopt': 'Sahilə yaxın planlaşdır', + 'tours.paddle.wind.shoreTaken': 'Göllər sahilə yaxın planlaşdırılıb.', + 'tours.paddle.status.unrouted': '{count} hissə planlaşdırılmayıb', + 'tours.paddle.status.unrouted.one': '{count} hissə planlaşdırılmayıb', }; export default tours; diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 5a18dde2e..06a0750c4 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -883,5 +883,42 @@ const admin: TranslationStrings = { 'Dados © colaboradores do OpenStreetMap (ODbL), extratos regionais do Geofabrik. Lidos de novo no dia 3 de cada mês.', 'admin.paddleRegions.toast.added': 'Região adicionada. O TREK a lê em segundo plano.', 'admin.paddleRegions.toast.removed': 'Região removida', + 'admin.paddleRegions.phase.build': 'Construindo a rede de água', + 'admin.paddleRegions.network.km': '{km} km de água', + 'admin.paddleRegions.network.lakes': '{count} lagos', + 'admin.paddleRegions.network.lakes.one': '{count} lago', + 'admin.paddleRegions.network.portages': '{count} varadouros mapeados', + 'admin.paddleRegions.network.portages.one': '{count} varadouro mapeado', + 'admin.paddleRegions.network.inferred': '{count} varadouros presumidos', + 'admin.paddleRegions.network.inferred.one': '{count} varadouro presumido', + 'admin.paddleRegions.network.locks': '{count} eclusas', + 'admin.paddleRegions.network.locks.one': '{count} eclusa', + 'admin.paddleRegions.network.gaps': '{count} lacunas cobertas', + 'admin.paddleRegions.network.gaps.one': '{count} lacuna coberta', + 'admin.paddleRegions.network.missing': 'Sem rede de água', + 'admin.paddleRegions.network.missingHint': 'Leia a região novamente para construir a rede que a rota de remo segue.', + 'admin.paddleRegions.auto.title': 'Carregar regiões automaticamente', + 'admin.paddleRegions.auto.mode.auto': 'Automático', + 'admin.paddleRegions.auto.mode.ask': 'Sob pedido', + 'admin.paddleRegions.auto.mode.off': 'Desligado', + 'admin.paddleRegions.auto.modeHint.auto': + 'Quando alguém planeja um passeio de remo onde ainda não há dados de água, o TREK carrega na hora a menor região que serve, até o limite de tamanho e só algumas por dia. As maiores esperam aqui como pedido. Só o nome da região vai para a Geofabrik, nunca o lugar.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Toda região nova espera aqui até um admin liberar. Quando um admin planeja, ela carrega na hora.', + 'admin.paddleRegions.auto.modeHint.off': + 'O TREK não carrega nada sozinho e não pede nada. Adicione regiões aqui manualmente.', + 'admin.paddleRegions.auto.maxSize': 'Maior região carregada automaticamente', + 'admin.paddleRegions.auto.maxSizeHint': + 'Regiões maiores esperam aqui como pedido. Ler uma região leva alguns minutos e usa memória enquanto roda.', + 'admin.paddleRegions.requests.title': 'Pedidas', + 'admin.paddleRegions.requests.reason.ask': 'Sob pedido', + 'admin.paddleRegions.requests.reason.too_large': 'Grande demais', + 'admin.paddleRegions.requests.reason.limit': 'Limite diário', + 'admin.paddleRegions.requests.declined': 'Recusada', + 'admin.paddleRegions.requests.by': 'por {user}', + 'admin.paddleRegions.requests.approve': 'Carregar', + 'admin.paddleRegions.requests.decline': 'Recusar', + 'admin.paddleRegions.origin.auto': 'Automático', + 'admin.paddleRegions.origin.autoHint': 'O TREK carregou esta região sozinho para um passeio de remo planejado ali.', }; export default admin; diff --git a/shared/src/i18n/br/externalNotifications.ts b/shared/src/i18n/br/externalNotifications.ts index c2a728ed3..f84a1e820 100644 --- a/shared/src/i18n/br/externalNotifications.ts +++ b/shared/src/i18n/br/externalNotifications.ts @@ -70,6 +70,10 @@ const br: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Região de remo pedida', + body: `${p.actor} está planejando um passeio de remo em ${p.region}. Carregue ou recuse os dados de água na administração, em Complementos.`, + }), synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.', diff --git a/shared/src/i18n/br/notif.ts b/shared/src/i18n/br/notif.ts index 7349ed755..90037962c 100644 --- a/shared/src/i18n/br/notif.ts +++ b/shared/src/i18n/br/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Falha ao gravar na réplica '{backend}': {op} de {key} — {error}", 'notif.replica_failure.textSuppressed': "Falha ao gravar na réplica '{backend}': {op} de {key} — {error}. Mais {suppressed} falha(s) foram suprimidas desde a última notificação.", + 'notif.paddle_region_requested.title': 'Região de remo pedida', + 'notif.paddle_region_requested.text': '{actor} está planejando um passeio de remo em {region}', 'notif.action.view_trip': 'Ver viagem', 'notif.action.view_collab': 'Ver mensagens', 'notif.action.view_packing': 'Ver bagagem', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 12094eb3c..1b0b4a41d 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -299,6 +299,7 @@ const settings: TranslationStrings = { 'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.', 'settings.notifyVersionAvailable': 'Nova versão disponível', 'settings.notifyReplicaFailure': 'Falha na réplica de armazenamento', + 'settings.notifyPaddleRegionRequested': 'Região de remo pedida', 'settings.notificationPreferences.noChannels': 'Nenhum canal de notificação configurado. Peça a um administrador para configurar notificações por e-mail ou webhook.', 'settings.webhookUrl.label': 'URL do webhook', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index ec845ab79..f5b5ba0cb 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -293,8 +293,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Informe uma velocidade de 0,5 a 20 km/h.', 'tours.paddle.chooseBoat': 'Escolha a embarcação e a água para salvar este passeio.', 'tours.paddle.chooseBoatImport': 'Escolha a embarcação e a água para importar como passeio de remo.', - 'tours.paddle.drawnByHand': - 'Um passeio de remo é desenhado à mão: o TREK liga seus pontos com linhas retas. Nada é roteado, e nada diz que a água é navegável.', 'tours.paddle.timeEstimated': 'Tempo de remada (estimado)', 'tours.paddle.timeEstimatedShort': 'Remada', 'tours.paddle.timeEstimatedInfoLabel': 'Como o tempo de remada é estimado', @@ -337,6 +335,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Transbordo', 'tours.paddle.portageOn': 'Carregar o barco a partir daqui', 'tours.paddle.portageOff': 'Remar este trecho de novo', + 'tours.paddle.portagePending': 'Carregar a partir daqui', + 'tours.paddle.portagePendingHint': 'Agora marque o ponto onde você volta para a água. O barco é carregado até lá.', + 'tours.paddle.portagePendingOff': 'Não carregar mais', + 'tours.paddle.portageWay': 'Trajeto', + 'tours.paddle.portageWay.follow': 'Seguir o caminho', + 'tours.paddle.portageWay.straight': 'Linha reta', + 'tours.paddle.portageWay.noWay': 'Nenhum caminho sem um grande desvio, a linha continua reta.', + 'tours.paddle.portageWay.searching': 'Procurando um caminho', + 'tours.paddle.placeHere.putIn': 'Entrar na água aqui', + 'tours.paddle.placeHere.egress': 'Sair da água aqui', 'tours.paddle.portageInfo': 'Aqui o barco é carregado. O trecho conta como terra, com tempo para desembarcar, descarregar e embarcar de novo.', 'tours.paddle.portageExtra': 'Saída e entrada', @@ -533,5 +541,65 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Remar tem riscos. Confira você mesmo a água, as regras e a previsão do tempo.', 'tours.paddle.safety.text': 'Remar envolve riscos. Mapas, sugestões de rota e indicações podem estar incompletos ou desatualizados. Antes do passeio, confira as condições locais atuais, as regras de navegação dessas águas e a previsão do tempo. Tome medidas de segurança adequadas. O TREK apoia o seu planejamento e não substitui a sua própria verificação nem a sua decisão responsável.', + 'tours.planner.shapingPoint': 'Ponto de forma', + 'tours.planner.shapingHint': 'Orienta a rota sem ser uma parada. Arraste-o para onde a rota deve passar.', + 'tours.planner.makeStop': 'Tornar uma parada', + 'tours.planner.makeShaping': 'Tornar um ponto de forma', + 'tours.planner.recomputeLeg': 'Recalcular o próximo trecho', + 'tours.planner.shapeOnLine': 'Clique na rota no mapa para adicionar ali um ponto de forma.', + 'tours.paddle.routeOnWater': 'Planejar o próximo trecho pela água', + 'tours.paddle.routeAllOnWater': 'Planejar pela água os trechos desenhados à mão', + 'tours.paddle.auto': 'Detectado', + 'tours.paddle.autoHint': + 'O TREK colocou este ponto num varadouro ou eclusa que encontrou no caminho. Se você movê-lo ou alterá-lo, ele passa a ser seu.', + 'tours.paddle.note.rapids': 'Corredeira {grade}', + 'tours.paddle.note.pass': 'Passagem para canoas', + 'tours.paddle.note.gap': 'Lacuna nos dados do mapa', + 'tours.paddle.note.rapidsHint': + 'A rota passa aqui por águas agitadas. Observe antes e carregue o barco em caso de dúvida.', + 'tours.paddle.note.gapHint': 'Os dados da água têm uma lacuna aqui, coberta por uma linha reta. Confira no local.', + 'tours.paddle.failed.not_on_water': + 'Uma ponta deste trecho não está em nenhuma água que o TREK conheça. Mova-a para o rio ou o lago.', + 'tours.paddle.failed.no_region': + 'Ainda não há dados de água para esta área. Um administrador pode adicionar a região.', + 'tours.paddle.failed.building': + 'Os dados de água desta região ainda estão sendo preparados. Tente novamente em alguns minutos.', + 'tours.paddle.failed.no_water_path': 'O TREK não encontrou caminho pela água entre estes pontos.', + 'tours.paddle.failed.cross_region': 'Estes pontos ficam em duas regiões sem dados de água em comum.', + 'tours.paddle.failed.busy': 'O planejador de rotas está ocupado. Tente novamente em instantes.', + 'tours.paddle.failed.timeout': + 'Planejar este trecho demorou demais. Adicione um ponto intermediário e tente novamente.', + 'tours.paddle.failed.offline': 'Sem conexão. Este trecho será planejado quando você se conectar novamente.', + 'tours.paddle.drawStraight': 'Desenhar reto', + 'tours.paddle.retry': 'Tentar novamente', + 'tours.paddle.load.loading': 'Carregando os dados de água de {region}', + 'tours.paddle.load.ready': 'Os dados de água de {region} chegaram, planejando o trecho.', + 'tours.paddle.load.requested': + 'Os dados de água de {region} foram pedidos. O trecho é planejado assim que um admin liberar.', + 'tours.paddle.load.waiting': 'Esperando outras regiões', + 'tours.paddle.load.phase.download': 'Baixando', + 'tours.paddle.load.phase.read': 'Lendo', + 'tours.paddle.load.phase.build': 'Montando a rede de água', + 'tours.paddle.load.hint': + 'Normalmente de um a três minutos. Continue planejando, o trecho é planejado sozinho depois.', + 'tours.paddle.load.reason.tooLarge': 'A região é maior do que o TREK carrega sozinho.', + 'tours.paddle.load.reason.limit': 'O TREK já carregou regiões suficientes sozinho hoje.', + 'tours.paddle.load.declined': 'Um admin recusou os dados de água de {region}.', + 'tours.paddle.load.failed': 'Não foi possível carregar os dados de água de {region}.', + 'tours.paddle.load.noWater': 'Os dados de água de {region} não têm água aqui. Coloque o ponto no rio ou lago.', + 'tours.paddle.load.noExtract': 'Não há recorte de mapa com dados de água para esta área.', + 'tours.paddle.load.catalogUnavailable': 'A lista de regiões não está acessível agora. Tente de novo mais tarde.', + 'tours.paddle.lakeStyle': 'Nos lagos', + 'tours.paddle.lakeStyle.direct': 'Em linha reta', + 'tours.paddle.lakeStyle.shore': 'Perto da margem', + 'tours.paddle.lakeStyle.directHint': 'A linha mais curta em águas abertas.', + 'tours.paddle.lakeStyle.shoreHint': + 'Fica perto da margem, para dias de vento: mais longo, mas a terra nunca fica longe.', + 'tours.paddle.wind.shoreAdvice': + 'Até {mean} km/h, rajadas de {gusts} km/h neste dia. Nos lagos, ficar perto da margem pode ser a melhor linha.', + 'tours.paddle.wind.shoreAdopt': 'Planejar perto da margem', + 'tours.paddle.wind.shoreTaken': 'Os lagos estão planejados perto da margem.', + 'tours.paddle.status.unrouted': '{count} trechos não planejados', + 'tours.paddle.status.unrouted.one': '{count} trecho não planejado', }; export default tours; diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 8519dc3f6..70a994725 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -896,5 +896,44 @@ const admin: TranslationStrings = { 'Dades © col·laboradors d’OpenStreetMap (ODbL), extractes regionals de Geofabrik. Es tornen a llegir el dia 3 de cada mes.', 'admin.paddleRegions.toast.added': 'Regió afegida. TREK la llegeix en segon pla.', 'admin.paddleRegions.toast.removed': 'Regió eliminada', + 'admin.paddleRegions.phase.build': 'Construint la xarxa d’aigua', + 'admin.paddleRegions.network.km': '{km} km d’aigua', + 'admin.paddleRegions.network.lakes': '{count} llacs', + 'admin.paddleRegions.network.lakes.one': '{count} llac', + 'admin.paddleRegions.network.portages': '{count} transports cartografiats', + 'admin.paddleRegions.network.portages.one': '{count} transport cartografiat', + 'admin.paddleRegions.network.inferred': '{count} transports suposats', + 'admin.paddleRegions.network.inferred.one': '{count} transport suposat', + 'admin.paddleRegions.network.locks': '{count} rescloses', + 'admin.paddleRegions.network.locks.one': '{count} resclosa', + 'admin.paddleRegions.network.gaps': '{count} buits salvats', + 'admin.paddleRegions.network.gaps.one': '{count} buit salvat', + 'admin.paddleRegions.network.missing': 'Sense xarxa d’aigua', + 'admin.paddleRegions.network.missingHint': + 'Torna a llegir la regió per construir la xarxa que segueix la ruta en caiac.', + 'admin.paddleRegions.auto.title': 'Carregar regions automàticament', + 'admin.paddleRegions.auto.mode.auto': 'Automàtic', + 'admin.paddleRegions.auto.mode.ask': 'A petició', + 'admin.paddleRegions.auto.mode.off': 'Desactivat', + 'admin.paddleRegions.auto.modeHint.auto': + "Quan algú planifica una ruta en caiac on encara no hi ha dades d'aigua, TREK carrega de seguida la regió més petita que hi encaixa, fins al límit de mida i només unes poques al dia. Les més grans esperen aquí com a petició. Només el nom de la regió va a Geofabrik, mai el lloc.", + 'admin.paddleRegions.auto.modeHint.ask': + 'Cada regió nova espera aquí fins que un administrador la deixa passar. Quan planifica un administrador, es carrega de seguida.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK no carrega res pel seu compte ni demana res. Afegeix les regions aquí a mà.', + 'admin.paddleRegions.auto.maxSize': 'Regió més gran que es carrega automàticament', + 'admin.paddleRegions.auto.maxSizeHint': + 'Les regions més grans esperen aquí com a petició. Llegir una regió triga uns minuts i ocupa memòria mentre dura.', + 'admin.paddleRegions.requests.title': 'Demanades', + 'admin.paddleRegions.requests.reason.ask': 'A petició', + 'admin.paddleRegions.requests.reason.too_large': 'Massa gran', + 'admin.paddleRegions.requests.reason.limit': 'Límit diari', + 'admin.paddleRegions.requests.declined': 'Rebutjada', + 'admin.paddleRegions.requests.by': 'de {user}', + 'admin.paddleRegions.requests.approve': 'Carregar', + 'admin.paddleRegions.requests.decline': 'Rebutjar', + 'admin.paddleRegions.origin.auto': 'Automàtic', + 'admin.paddleRegions.origin.autoHint': + 'TREK ha carregat aquesta regió pel seu compte per a una ruta en caiac planificada allà.', }; export default admin; diff --git a/shared/src/i18n/ca/externalNotifications.ts b/shared/src/i18n/ca/externalNotifications.ts index 6451fe04d..38b576add 100644 --- a/shared/src/i18n/ca/externalNotifications.ts +++ b/shared/src/i18n/ca/externalNotifications.ts @@ -63,6 +63,10 @@ const ca: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Regió de caiac demanada', + body: `${p.actor} planifica una ruta en caiac a ${p.region}. Carrega o rebutja les seves dades d'aigua a l'administració, a Complements.`, + }), synology_session_cleared: () => ({ title: 'Sessió de Synology tancada', body: 'El teu compte o URL de Synology ha canviat. Has tancat la sessió a Synology Photos.', diff --git a/shared/src/i18n/ca/notif.ts b/shared/src/i18n/ca/notif.ts index 31b392b79..73d473421 100644 --- a/shared/src/i18n/ca/notif.ts +++ b/shared/src/i18n/ca/notif.ts @@ -30,6 +30,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "L'escriptura a la rèplica '{backend}' ha fallat: {op} de {key} — {error}", 'notif.replica_failure.textSuppressed': "L'escriptura a la rèplica '{backend}' ha fallat: {op} de {key} — {error}. S'han suprimit {suppressed} errors més des de l'última notificació.", + 'notif.paddle_region_requested.title': 'Regió de caiac demanada', + 'notif.paddle_region_requested.text': '{actor} planifica una ruta en caiac a {region}', 'notif.action.view_trip': 'Mostra el viatge', 'notif.action.view_collab': 'Mostra els missatges', 'notif.action.view_packing': "Mostra l'equipatge", diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 80eb3094a..9f48850a6 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -283,6 +283,7 @@ const settings: TranslationStrings = { 'settings.passwordRule.special': 'Un caràcter especial', 'settings.notifyVersionAvailable': 'Versió nova disponible', 'settings.notifyReplicaFailure': "Error de rèplica d'emmagatzematge", + 'settings.notifyPaddleRegionRequested': 'Regió de caiac demanada', 'settings.notificationPreferences.noChannels': 'No hi ha canals de notificació configurats. Demana a un administrador que configuri notificacions per correu o webhook.', 'settings.webhookUrl.label': 'URL del webhook', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index c95b3cd6c..f10846e4a 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -293,8 +293,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Introdueix una velocitat de 0,5 a 20 km/h.', 'tours.paddle.chooseBoat': 'Tria l’embarcació i l’aigua per desar aquesta ruta.', 'tours.paddle.chooseBoatImport': 'Tria l’embarcació i l’aigua per importar com a ruta en piragua.', - 'tours.paddle.drawnByHand': - 'Una ruta en piragua es dibuixa a mà: TREK uneix els punts amb línies rectes. No es calcula res i res no diu que l’aigua sigui navegable.', 'tours.paddle.timeEstimated': 'Temps de rem (estimat)', 'tours.paddle.timeEstimatedShort': 'Rem', 'tours.paddle.timeEstimatedInfoLabel': 'Com s’estima el temps de rem', @@ -337,6 +335,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Transport per terra', 'tours.paddle.portageOn': 'Porta l’embarcació per terra des d’aquí', 'tours.paddle.portageOff': 'Torna a remar aquest tram', + 'tours.paddle.portagePending': "Portejar des d'aquí", + 'tours.paddle.portagePendingHint': "Ara posa el punt on tornes a entrar a l'aigua. La barca es porta fins allà.", + 'tours.paddle.portagePendingOff': 'Al final no portejar', + 'tours.paddle.portageWay': 'Traçat', + 'tours.paddle.portageWay.follow': 'Seguir el camí', + 'tours.paddle.portageWay.straight': 'Línia recta', + 'tours.paddle.portageWay.noWay': 'No hi ha cap camí sense una gran volta, la línia es queda recta.', + 'tours.paddle.portageWay.searching': 'Cercant un camí', + 'tours.paddle.placeHere.putIn': "Entrar a l'aigua aquí", + 'tours.paddle.placeHere.egress': "Sortir de l'aigua aquí", 'tours.paddle.portageInfo': 'Aquí l’embarcació es porta per terra. El tram compta com a terra, amb temps per sortir, descarregar i tornar a embarcar.', 'tours.paddle.portageExtra': 'Sortida i entrada', @@ -533,5 +541,66 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Remar comporta riscos. Comprova pel teu compte l’aigua, les normes i la meteorologia.', 'tours.paddle.safety.text': 'Remar comporta riscos. Els mapes, les propostes de ruta i les indicacions poden ser incomplets o estar desactualitzats. Abans de l’excursió, comprova les condicions locals actuals, les normes de navegació d’aquestes aigües i la meteorologia. Pren les mesures de seguretat adequades. TREK dona suport a la teva planificació i no substitueix la teva pròpia comprovació ni la teva decisió responsable.', + 'tours.planner.shapingPoint': 'Punt de forma', + 'tours.planner.shapingHint': 'Guia la ruta sense ser una parada. Arrossega’l on ha de passar la ruta.', + 'tours.planner.makeStop': 'Converteix-lo en parada', + 'tours.planner.makeShaping': 'Converteix-lo en punt de forma', + 'tours.planner.recomputeLeg': 'Torna a calcular el tram següent', + 'tours.planner.shapeOnLine': 'Fes clic a la ruta del mapa per afegir-hi un punt de forma.', + 'tours.paddle.routeOnWater': 'Planifica el tram següent per l’aigua', + 'tours.paddle.routeAllOnWater': 'Planifica per l’aigua els trams dibuixats a mà', + 'tours.paddle.auto': 'Detectat', + 'tours.paddle.autoHint': + 'TREK ha posat aquest punt en un transport o una resclosa que ha trobat pel camí. Si el mous o el canvies, passa a ser teu.', + 'tours.paddle.note.rapids': 'Aigües braves {grade}', + 'tours.paddle.note.pass': 'Pas per a canoes', + 'tours.paddle.note.gap': 'Buit a les dades del mapa', + 'tours.paddle.note.rapidsHint': + 'Aquí la ruta passa per aigua moguda. Explora-la abans i porta la barca si tens dubtes.', + 'tours.paddle.note.gapHint': + 'Les dades de l’aigua tenen un buit aquí, salvat amb una línia recta. Comprova-ho al lloc.', + 'tours.paddle.failed.not_on_water': + 'Un extrem d’aquest tram no és en cap aigua que TREK conegui. Mou-lo al riu o al llac.', + 'tours.paddle.failed.no_region': + 'Encara no hi ha dades d’aigua per a aquesta zona. Un administrador pot afegir la regió.', + 'tours.paddle.failed.building': + 'Les dades d’aigua d’aquesta regió encara s’estan preparant. Torna-ho a provar d’aquí a uns minuts.', + 'tours.paddle.failed.no_water_path': 'TREK no ha trobat cap camí per l’aigua entre aquests punts.', + 'tours.paddle.failed.cross_region': 'Aquests punts són en dues regions que no comparteixen dades d’aigua.', + 'tours.paddle.failed.busy': 'El planificador de rutes està ocupat. Torna-ho a provar d’aquí a un moment.', + 'tours.paddle.failed.timeout': + 'Planificar aquest tram ha trigat massa. Afegeix un punt entremig i torna-ho a provar.', + 'tours.paddle.failed.offline': 'Sense connexió. Aquest tram es planificarà quan tornis a estar connectat.', + 'tours.paddle.drawStraight': 'Dibuixa recte', + 'tours.paddle.retry': 'Torna-ho a provar', + 'tours.paddle.load.loading': "Carregant les dades d'aigua de {region}", + 'tours.paddle.load.ready': "Les dades d'aigua de {region} ja hi són, es planifica el tram.", + 'tours.paddle.load.requested': + "S'han demanat les dades d'aigua de {region}. El tram es planificarà tan aviat com un administrador les deixi passar.", + 'tours.paddle.load.waiting': 'Esperant altres regions', + 'tours.paddle.load.phase.download': 'Baixant', + 'tours.paddle.load.phase.read': 'Llegint', + 'tours.paddle.load.phase.build': "Construint la xarxa d'aigua", + 'tours.paddle.load.hint': "Normalment d'un a tres minuts. Continua planificant, el tram es planificarà sol després.", + 'tours.paddle.load.reason.tooLarge': 'La regió és més gran del que TREK carrega pel seu compte.', + 'tours.paddle.load.reason.limit': 'Avui TREK ja ha carregat prou regions pel seu compte.', + 'tours.paddle.load.declined': "Un administrador ha rebutjat les dades d'aigua de {region}.", + 'tours.paddle.load.failed': "No s'han pogut carregar les dades d'aigua de {region}.", + 'tours.paddle.load.noWater': "A les dades d'aigua de {region} aquí no hi ha aigua. Posa el punt al riu o al llac.", + 'tours.paddle.load.noExtract': "No hi ha cap extracte de mapa amb dades d'aigua per a aquesta zona.", + 'tours.paddle.load.catalogUnavailable': + 'Ara mateix no es pot accedir a la llista de regions. Torna-ho a provar més tard.', + 'tours.paddle.lakeStyle': 'Pels llacs', + 'tours.paddle.lakeStyle.direct': 'En línia recta', + 'tours.paddle.lakeStyle.shore': 'A prop de la riba', + 'tours.paddle.lakeStyle.directHint': 'La línia més curta per aigües obertes.', + 'tours.paddle.lakeStyle.shoreHint': + 'Es manté a prop de la riba, per als dies de vent: més llarg, però la terra mai és lluny.', + 'tours.paddle.wind.shoreAdvice': + 'Fins a {mean} km/h, ràfegues de {gusts} km/h aquest dia. Als llacs, anar a prop de la riba pot ser la millor línia.', + 'tours.paddle.wind.shoreAdopt': 'Planifica a prop de la riba', + 'tours.paddle.wind.shoreTaken': 'Els llacs estan planificats a prop de la riba.', + 'tours.paddle.status.unrouted': '{count} trams sense planificar', + 'tours.paddle.status.unrouted.one': '{count} tram sense planificar', }; export default tours; diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 02aa28021..bd0c644ed 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -889,5 +889,46 @@ const admin: TranslationStrings = { 'admin.paddleRegions.toast.added': 'Region přidán. TREK ho načítá na pozadí.', 'admin.paddleRegions.toast.removed': 'Region odstraněn', 'admin.paddleRegions.objects.few': '{count} objekty', + 'admin.paddleRegions.phase.build': 'Vytváření vodní sítě', + 'admin.paddleRegions.network.km': '{km} km vody', + 'admin.paddleRegions.network.lakes': '{count} jezer', + 'admin.paddleRegions.network.lakes.one': '{count} jezero', + 'admin.paddleRegions.network.lakes.few': '{count} jezera', + 'admin.paddleRegions.network.portages': '{count} zmapovaných přenášek', + 'admin.paddleRegions.network.portages.one': '{count} zmapovaná přenáška', + 'admin.paddleRegions.network.portages.few': '{count} zmapované přenášky', + 'admin.paddleRegions.network.inferred': '{count} předpokládaných přenesení', + 'admin.paddleRegions.network.inferred.one': '{count} předpokládané přenesení', + 'admin.paddleRegions.network.inferred.few': '{count} předpokládaná přenesení', + 'admin.paddleRegions.network.locks': '{count} zdymadel', + 'admin.paddleRegions.network.locks.one': '{count} zdymadlo', + 'admin.paddleRegions.network.locks.few': '{count} zdymadla', + 'admin.paddleRegions.network.gaps': '{count} překlenutých mezer', + 'admin.paddleRegions.network.gaps.one': '{count} překlenutá mezera', + 'admin.paddleRegions.network.gaps.few': '{count} překlenuté mezery', + 'admin.paddleRegions.network.missing': 'Žádná vodní síť', + 'admin.paddleRegions.network.missingHint': 'Načti region znovu, aby se vytvořila síť, po které vede vodácká trasa.', + 'admin.paddleRegions.auto.title': 'Načítat regiony automaticky', + 'admin.paddleRegions.auto.mode.auto': 'Automaticky', + 'admin.paddleRegions.auto.mode.ask': 'Na žádost', + 'admin.paddleRegions.auto.mode.off': 'Vypnuto', + 'admin.paddleRegions.auto.modeHint.auto': + 'Když někdo plánuje vodáckou trasu tam, kde ještě nejsou vodní data, TREK hned načte nejmenší vhodný region, do limitu velikosti a jen několik za den. Větší čekají tady jako žádost. Geofabrik dostane jen název regionu, nikdy místo.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Každý nový region čeká tady, dokud ho správce nepustí. Když plánuje správce sám, načte se hned.', + 'admin.paddleRegions.auto.modeHint.off': 'TREK nic nenačítá sám a na nic se neptá. Regiony přidávej tady ručně.', + 'admin.paddleRegions.auto.maxSize': 'Největší region načítaný automaticky', + 'admin.paddleRegions.auto.maxSizeHint': + 'Větší regiony čekají tady jako žádost. Načtení regionu trvá pár minut a mezitím zabírá paměť.', + 'admin.paddleRegions.requests.title': 'Vyžádané', + 'admin.paddleRegions.requests.reason.ask': 'Na žádost', + 'admin.paddleRegions.requests.reason.too_large': 'Příliš velký', + 'admin.paddleRegions.requests.reason.limit': 'Denní limit', + 'admin.paddleRegions.requests.declined': 'Zamítnuto', + 'admin.paddleRegions.requests.by': 'od {user}', + 'admin.paddleRegions.requests.approve': 'Načíst', + 'admin.paddleRegions.requests.decline': 'Zamítnout', + 'admin.paddleRegions.origin.auto': 'Automaticky', + 'admin.paddleRegions.origin.autoHint': 'TREK tento region načetl sám pro vodáckou trasu, která se tam plánuje.', }; export default admin; diff --git a/shared/src/i18n/cs/externalNotifications.ts b/shared/src/i18n/cs/externalNotifications.ts index 5fe7ced73..ff4f07630 100644 --- a/shared/src/i18n/cs/externalNotifications.ts +++ b/shared/src/i18n/cs/externalNotifications.ts @@ -73,6 +73,10 @@ const cs: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Vyžádán vodácký region', + body: `${p.actor} plánuje vodáckou trasu v regionu ${p.region}. Načti nebo zamítni jeho vodní data v administraci v části Doplňky.`, + }), synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.', diff --git a/shared/src/i18n/cs/notif.ts b/shared/src/i18n/cs/notif.ts index e25cc3209..151d33ca8 100644 --- a/shared/src/i18n/cs/notif.ts +++ b/shared/src/i18n/cs/notif.ts @@ -33,6 +33,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Zápis do repliky '{backend}' selhal: {op} u {key} — {error}", 'notif.replica_failure.textSuppressed': "Zápis do repliky '{backend}' selhal: {op} u {key} — {error}. Od poslední notifikace bylo potlačeno {suppressed} dalších selhání.", + 'notif.paddle_region_requested.title': 'Vyžádán vodácký region', + 'notif.paddle_region_requested.text': '{actor} plánuje vodáckou trasu v regionu {region}', 'notif.action.view_trip': 'Zobrazit výlet', 'notif.action.view_collab': 'Zobrazit zprávy', 'notif.action.view_packing': 'Zobrazit balení', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 5fdabd1e7..2d3d2f160 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -294,6 +294,7 @@ const settings: TranslationStrings = { 'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.', 'settings.notifyVersionAvailable': 'Nová verze k dispozici', 'settings.notifyReplicaFailure': 'Selhání repliky úložiště', + 'settings.notifyPaddleRegionRequested': 'Vyžádán vodácký region', 'settings.notificationPreferences.noChannels': 'Nejsou nakonfigurovány žádné kanály oznámení. Požádejte správce o nastavení e-mailových nebo webhook oznámení.', 'settings.webhookUrl.label': 'URL webhooku', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 8202fd80f..8206d6a98 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -287,8 +287,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Zadejte rychlost od 0,5 do 20 km/h.', 'tours.paddle.chooseBoat': 'Vyberte loď a vodu, abyste mohli tuto túru uložit.', 'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu pro import jako vodácké túry.', - 'tours.paddle.drawnByHand': - 'Vodácká túra se kreslí ručně: TREK spojí vaše body přímými čarami. Nic se nepočítá a nic neříká, že je voda sjízdná.', 'tours.paddle.timeEstimated': 'Čas na vodě (odhad)', 'tours.paddle.timeEstimatedShort': 'Pádlování', 'tours.paddle.timeEstimatedInfoLabel': 'Jak se odhaduje čas na vodě', @@ -334,6 +332,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Přenáška', 'tours.paddle.portageOn': 'Odtud loď přenášet', 'tours.paddle.portageOff': 'Tento úsek opět pádlovat', + 'tours.paddle.portagePending': 'Odsud přenášet', + 'tours.paddle.portagePendingHint': 'Teď nastav bod, kde se znovu nalodíš. Loď se přenáší až tam.', + 'tours.paddle.portagePendingOff': 'Nakonec nepřenášet', + 'tours.paddle.portageWay': 'Průběh', + 'tours.paddle.portageWay.follow': 'Sledovat cestu', + 'tours.paddle.portageWay.straight': 'Přímá čára', + 'tours.paddle.portageWay.noWay': 'Žádná cesta bez velké zajížďky, čára zůstává přímá.', + 'tours.paddle.portageWay.searching': 'Hledá se cesta', + 'tours.paddle.placeHere.putIn': 'Tady se nalodit', + 'tours.paddle.placeHere.egress': 'Tady vystoupit', 'tours.paddle.portageInfo': 'Loď se tu přenáší. Úsek se počítá jako souš, včetně času na vystoupení, vyložení a opětovné spuštění na vodu.', 'tours.paddle.portageExtra': 'Výstup a nástup', @@ -533,5 +541,60 @@ const tours: TranslationStrings = { 'tours.paddle.obstacles.covered.few': '{count} překážky a přístupová místa podél trasy z OpenStreetMap, stav k {date}. Údaje mohou být neúplné, situaci na vodě si ověřte sami.', 'tours.paddle.time.locksMissing.few': '{count} zdymadla ještě nemají zadané časy.', + 'tours.planner.shapingPoint': 'Tvarový bod', + 'tours.planner.shapingHint': 'Usměrňuje trasu, aniž by byl zastávkou. Přetáhni ho tam, kudy má trasa vést.', + 'tours.planner.makeStop': 'Udělat zastávkou', + 'tours.planner.makeShaping': 'Udělat tvarovým bodem', + 'tours.planner.recomputeLeg': 'Znovu vypočítat další úsek', + 'tours.planner.shapeOnLine': 'Kliknutím na trasu v mapě tam přidáš tvarový bod.', + 'tours.paddle.routeOnWater': 'Naplánovat další úsek po vodě', + 'tours.paddle.routeAllOnWater': 'Naplánovat ručně kreslené úseky po vodě', + 'tours.paddle.auto': 'Nalezeno', + 'tours.paddle.autoHint': + 'TREK umístil tento bod k přenášce nebo zdymadlu, které našel po cestě. Když ho přesuneš nebo změníš, bude tvůj.', + 'tours.paddle.note.rapids': 'Divoká voda {grade}', + 'tours.paddle.note.pass': 'Vodácká propust', + 'tours.paddle.note.gap': 'Mezera v mapových datech', + 'tours.paddle.note.rapidsHint': + 'Trasa zde vede proudící vodou. Nejdřív si místo prohlédni a v případě pochybností přenášej.', + 'tours.paddle.note.gapHint': 'Data o vodě tu mají mezeru, překlenutou přímou čarou. Ověř si to na místě.', + 'tours.paddle.failed.not_on_water': + 'Jeden konec tohoto úseku neleží na žádné vodě, kterou TREK zná. Přesuň ho na řeku nebo jezero.', + 'tours.paddle.failed.no_region': 'Pro tuto oblast zatím nejsou data o vodě. Administrátor může region přidat.', + 'tours.paddle.failed.building': 'Data o vodě pro tento region se ještě připravují. Zkus to znovu za pár minut.', + 'tours.paddle.failed.no_water_path': 'TREK mezi těmito body nenašel cestu po vodě.', + 'tours.paddle.failed.cross_region': 'Tyto body leží ve dvou regionech bez společných dat o vodě.', + 'tours.paddle.failed.busy': 'Plánovač tras je vytížený. Zkus to za chvíli znovu.', + 'tours.paddle.failed.timeout': 'Plánování tohoto úseku trvalo příliš dlouho. Přidej mezi ně bod a zkus to znovu.', + 'tours.paddle.failed.offline': 'Bez připojení. Úsek se naplánuje, jakmile budeš znovu připojen.', + 'tours.paddle.drawStraight': 'Nakreslit rovně', + 'tours.paddle.retry': 'Zkusit znovu', + 'tours.paddle.load.loading': 'Načítají se vodní data pro {region}', + 'tours.paddle.load.ready': 'Vodní data pro {region} jsou tu, úsek se plánuje.', + 'tours.paddle.load.requested': 'Vodní data pro {region} jsou vyžádána. Úsek se naplánuje, jakmile je správce pustí.', + 'tours.paddle.load.waiting': 'Čeká na jiné regiony', + 'tours.paddle.load.phase.download': 'Stahování', + 'tours.paddle.load.phase.read': 'Čtení', + 'tours.paddle.load.phase.build': 'Staví se vodní síť', + 'tours.paddle.load.hint': 'Obvykle jedna až tři minuty. Klidně plánuj dál, úsek se pak naplánuje sám.', + 'tours.paddle.load.reason.tooLarge': 'Region je větší, než TREK sám načítá.', + 'tours.paddle.load.reason.limit': 'TREK dnes už sám načetl dost regionů.', + 'tours.paddle.load.declined': 'Správce zamítl vodní data pro {region}.', + 'tours.paddle.load.failed': 'Vodní data pro {region} se nepodařilo načíst.', + 'tours.paddle.load.noWater': 'Ve vodních datech pro {region} tu žádná voda není. Dej bod na řeku nebo jezero.', + 'tours.paddle.load.noExtract': 'Pro tuto oblast neexistuje výřez mapy s vodními daty.', + 'tours.paddle.load.catalogUnavailable': 'Seznam regionů teď není dostupný. Zkus to později.', + 'tours.paddle.lakeStyle': 'Přes jezera', + 'tours.paddle.lakeStyle.direct': 'Přímo', + 'tours.paddle.lakeStyle.shore': 'Podél břehu', + 'tours.paddle.lakeStyle.directHint': 'Nejkratší čára přes otevřenou vodu.', + 'tours.paddle.lakeStyle.shoreHint': 'Drží se u břehu, pro větrné dny: delší, ale pevnina není nikdy daleko.', + 'tours.paddle.wind.shoreAdvice': + 'V tento den až {mean} km/h, nárazy {gusts} km/h. Na jezerech může být lepší držet se u břehu.', + 'tours.paddle.wind.shoreAdopt': 'Naplánovat podél břehu', + 'tours.paddle.wind.shoreTaken': 'Jezera jsou naplánována podél břehu.', + 'tours.paddle.status.unrouted': '{count} úseků nenaplánováno', + 'tours.paddle.status.unrouted.one': '{count} úsek nenaplánován', + 'tours.paddle.status.unrouted.few': '{count} úseky nenaplánovány', }; export default tours; diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 8cc63126b..7454bd5d9 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -891,5 +891,43 @@ const admin: TranslationStrings = { 'Daten © OpenStreetMap-Mitwirkende (ODbL), regionale Auszüge von Geofabrik. Neu gelesen am 3. jedes Monats.', 'admin.paddleRegions.toast.added': 'Region hinzugefügt. TREK liest sie im Hintergrund.', 'admin.paddleRegions.toast.removed': 'Region entfernt', + 'admin.paddleRegions.phase.build': 'Wassernetz wird gebaut', + 'admin.paddleRegions.network.km': '{km} km Gewässer', + 'admin.paddleRegions.network.lakes': '{count} Seen', + 'admin.paddleRegions.network.lakes.one': '{count} See', + 'admin.paddleRegions.network.portages': '{count} kartierte Umtragestellen', + 'admin.paddleRegions.network.portages.one': '{count} kartierte Umtragestelle', + 'admin.paddleRegions.network.inferred': '{count} angenommene Umtragungen', + 'admin.paddleRegions.network.inferred.one': '{count} angenommene Umtragung', + 'admin.paddleRegions.network.locks': '{count} Schleusen', + 'admin.paddleRegions.network.locks.one': '{count} Schleuse', + 'admin.paddleRegions.network.gaps': '{count} überbrückte Lücken', + 'admin.paddleRegions.network.gaps.one': '{count} überbrückte Lücke', + 'admin.paddleRegions.network.missing': 'Kein Wassernetz', + 'admin.paddleRegions.network.missingHint': 'Lies die Region erneut ein, um das Netz für das Paddel-Routing zu bauen.', + 'admin.paddleRegions.auto.title': 'Regionen automatisch laden', + 'admin.paddleRegions.auto.mode.auto': 'Automatisch', + 'admin.paddleRegions.auto.mode.ask': 'Auf Anfrage', + 'admin.paddleRegions.auto.mode.off': 'Aus', + 'admin.paddleRegions.auto.modeHint.auto': + 'Plant jemand eine Paddeltour in einer Gegend ohne Gewässerdaten, lädt TREK die kleinste passende Region sofort, bis zur Größengrenze und nur wenige am Tag. Größere warten hier als Anfrage. Nur der Name der Region geht an Geofabrik, nie der Ort.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Jede neue Region wartet hier, bis ein Admin sie freigibt. Plant ein Admin selbst, lädt sie sofort.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK lädt nichts von selbst und fragt nicht nach. Regionen fügst du hier von Hand hinzu.', + 'admin.paddleRegions.auto.maxSize': 'Größte Region, die automatisch lädt', + 'admin.paddleRegions.auto.maxSizeHint': + 'Größere Regionen warten hier als Anfrage. Das Einlesen einer Region dauert einige Minuten und braucht dabei Arbeitsspeicher.', + 'admin.paddleRegions.requests.title': 'Angefragt', + 'admin.paddleRegions.requests.reason.ask': 'Auf Anfrage', + 'admin.paddleRegions.requests.reason.too_large': 'Zu groß', + 'admin.paddleRegions.requests.reason.limit': 'Tageslimit', + 'admin.paddleRegions.requests.declined': 'Abgelehnt', + 'admin.paddleRegions.requests.by': 'von {user}', + 'admin.paddleRegions.requests.approve': 'Laden', + 'admin.paddleRegions.requests.decline': 'Ablehnen', + 'admin.paddleRegions.origin.auto': 'Automatisch', + 'admin.paddleRegions.origin.autoHint': + 'TREK hat diese Region selbst geladen, für eine Paddeltour, die dort geplant wird.', }; export default admin; diff --git a/shared/src/i18n/de/externalNotifications.ts b/shared/src/i18n/de/externalNotifications.ts index aef63e3ff..7b61e842a 100644 --- a/shared/src/i18n/de/externalNotifications.ts +++ b/shared/src/i18n/de/externalNotifications.ts @@ -67,6 +67,10 @@ const de: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Paddel-Region angefragt', + body: `${p.actor} plant eine Paddeltour in ${p.region}. Im Admin-Bereich unter Addons kannst du die Gewässerdaten laden oder ablehnen.`, + }), synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.', diff --git a/shared/src/i18n/de/notif.ts b/shared/src/i18n/de/notif.ts index 9552a6541..ef91c87b7 100644 --- a/shared/src/i18n/de/notif.ts +++ b/shared/src/i18n/de/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Schreibvorgang auf Replik '{backend}' fehlgeschlagen: {op} von {key} — {error}", 'notif.replica_failure.textSuppressed': "Schreibvorgang auf Replik '{backend}' fehlgeschlagen: {op} von {key} — {error}. Seit der letzten Benachrichtigung wurden {suppressed} weitere Fehler unterdrückt.", + 'notif.paddle_region_requested.title': 'Paddel-Region angefragt', + 'notif.paddle_region_requested.text': '{actor} plant eine Paddeltour in {region}', 'notif.action.view_trip': 'Reise ansehen', 'notif.action.view_collab': 'Nachrichten ansehen', 'notif.action.view_packing': 'Packliste ansehen', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 10f8b720b..a7e116512 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -300,6 +300,7 @@ const settings: TranslationStrings = { 'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar', 'settings.notifyVersionAvailable': 'Neue Version verfügbar', 'settings.notifyReplicaFailure': 'Speicher-Replik-Fehler', + 'settings.notifyPaddleRegionRequested': 'Paddel-Region angefragt', 'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.', 'settings.webhookUrl.label': 'Webhook-URL', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 760556a29..d32d773a5 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -295,8 +295,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Gib ein Tempo von 0,5 bis 20 km/h ein.', 'tours.paddle.chooseBoat': 'Wähle Boot und Gewässer, um diese Paddeltour zu speichern.', 'tours.paddle.chooseBoatImport': 'Wähle Boot und Gewässer, um als Paddeltour zu importieren.', - 'tours.paddle.drawnByHand': - 'Eine Paddeltour wird von Hand gezeichnet: TREK verbindet deine Punkte mit geraden Linien. Nichts wird geroutet, und nichts sagt, dass das Gewässer befahrbar ist.', 'tours.paddle.timeEstimated': 'Paddelzeit (geschätzt)', 'tours.paddle.timeEstimatedShort': 'Paddeln', 'tours.paddle.timeEstimatedInfoLabel': 'Wie die Paddelzeit geschätzt wird', @@ -339,6 +337,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Umtragen', 'tours.paddle.portageOn': 'Ab hier umtragen', 'tours.paddle.portageOff': 'Diese Strecke wieder paddeln', + 'tours.paddle.portagePending': 'Umtragen ab hier', + 'tours.paddle.portagePendingHint': 'Setz jetzt den Punkt, an dem du wieder einsetzt. Bis dorthin wird umgetragen.', + 'tours.paddle.portagePendingOff': 'Doch nicht umtragen', + 'tours.paddle.portageWay': 'Verlauf', + 'tours.paddle.portageWay.follow': 'Dem Weg folgen', + 'tours.paddle.portageWay.straight': 'Gerade Linie', + 'tours.paddle.portageWay.noWay': 'Kein Weg ohne großen Umweg gefunden, die Linie bleibt gerade.', + 'tours.paddle.portageWay.searching': 'Weg wird gesucht', + 'tours.paddle.placeHere.putIn': 'Hier einsetzen', + 'tours.paddle.placeHere.egress': 'Hier aussteigen', 'tours.paddle.portageInfo': 'Hier wird das Boot getragen. Die Strecke zählt als Land, mit Zeit für Aussteigen, Ausladen und Wiedereinsetzen.', 'tours.paddle.portageExtra': 'Aus und ein', @@ -534,6 +542,68 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Paddeln birgt Risiken. Prüfe Gewässer, Regeln und Wetter selbst.', 'tours.paddle.safety.text': 'Paddeln ist mit Risiken verbunden. Karten, Routenvorschläge und Hinweise können unvollständig oder veraltet sein. Prüfe vor der Tour die aktuellen örtlichen Bedingungen, Befahrungsregeln und das Wetter. Beachte geeignete Sicherheitsmaßnahmen. TREK unterstützt deine Planung und ersetzt keine eigene Prüfung und verantwortliche Entscheidung.', + 'tours.planner.shapingPoint': 'Formpunkt', + 'tours.planner.shapingHint': 'Lenkt die Route, ohne ein Halt zu sein. Zieh ihn dorthin, wo die Route verlaufen soll.', + 'tours.planner.makeStop': 'Zum Halt machen', + 'tours.planner.makeShaping': 'Zum Formpunkt machen', + 'tours.planner.recomputeLeg': 'Nächsten Abschnitt neu berechnen', + 'tours.planner.shapeOnLine': 'Ein Klick auf die Route in der Karte setzt dort einen Formpunkt.', + 'tours.paddle.routeOnWater': 'Nächsten Abschnitt auf dem Wasser planen', + 'tours.paddle.routeAllOnWater': 'Von Hand gezeichnete Abschnitte auf dem Wasser planen', + 'tours.paddle.auto': 'Erkannt', + 'tours.paddle.autoHint': + 'TREK hat diesen Punkt an eine Umtragestelle oder Schleuse auf dem Weg gesetzt. Verschiebst oder änderst du ihn, gehört er dir.', + 'tours.paddle.note.rapids': 'Wildwasser {grade}', + 'tours.paddle.note.pass': 'Bootsgasse', + 'tours.paddle.note.gap': 'Lücke in den Kartendaten', + 'tours.paddle.note.rapidsHint': + 'Die Route führt hier durch bewegtes Wasser. Erst besichtigen und im Zweifel umtragen.', + 'tours.paddle.note.gapHint': + 'Die Gewässerdaten haben hier eine Lücke, überbrückt mit einer geraden Linie. Prüf das vor Ort.', + 'tours.paddle.failed.not_on_water': + 'Ein Ende dieses Abschnitts liegt auf keinem Gewässer, das TREK kennt. Setz es auf den Fluss oder See.', + 'tours.paddle.failed.no_region': + 'Für diese Gegend gibt es noch keine Gewässerdaten. Ein Admin kann die Region hinzufügen.', + 'tours.paddle.failed.building': + 'Die Gewässerdaten dieser Region werden noch aufbereitet. Versuch es in ein paar Minuten erneut.', + 'tours.paddle.failed.no_water_path': 'TREK hat zwischen diesen Punkten keinen Weg auf dem Wasser gefunden.', + 'tours.paddle.failed.cross_region': 'Diese Punkte liegen in zwei Regionen ohne gemeinsame Gewässerdaten.', + 'tours.paddle.failed.busy': 'Der Routenplaner ist ausgelastet. Versuch es gleich noch einmal.', + 'tours.paddle.failed.timeout': + 'Die Planung dieses Abschnitts hat zu lange gedauert. Setz einen Punkt dazwischen und versuch es erneut.', + 'tours.paddle.failed.offline': 'Offline. Der Abschnitt wird geplant, sobald du wieder verbunden bist.', + 'tours.paddle.drawStraight': 'Gerade zeichnen', + 'tours.paddle.retry': 'Erneut versuchen', + 'tours.paddle.load.loading': 'Gewässerdaten für {region} werden geladen', + 'tours.paddle.load.ready': 'Die Gewässerdaten für {region} sind da, der Abschnitt wird geplant.', + 'tours.paddle.load.requested': + 'Die Gewässerdaten für {region} sind angefragt. Sobald ein Admin sie freigibt, wird der Abschnitt geplant.', + 'tours.paddle.load.waiting': 'Wartet auf andere Regionen', + 'tours.paddle.load.phase.download': 'Herunterladen', + 'tours.paddle.load.phase.read': 'Auslesen', + 'tours.paddle.load.phase.build': 'Wassernetz wird gebaut', + 'tours.paddle.load.hint': + 'Meist ein bis drei Minuten. Plan ruhig weiter, der Abschnitt wird danach von selbst geplant.', + 'tours.paddle.load.reason.tooLarge': 'Die Region ist größer, als TREK von selbst lädt.', + 'tours.paddle.load.reason.limit': 'TREK hat heute schon genug Regionen von selbst geladen.', + 'tours.paddle.load.declined': 'Ein Admin hat die Gewässerdaten für {region} abgelehnt.', + 'tours.paddle.load.failed': 'Die Gewässerdaten für {region} konnten nicht geladen werden.', + 'tours.paddle.load.noWater': + 'In den Gewässerdaten für {region} liegt hier kein Gewässer. Setz den Punkt auf den Fluss oder See.', + 'tours.paddle.load.noExtract': 'Für diese Gegend gibt es keinen Kartenauszug mit Gewässerdaten.', + 'tours.paddle.load.catalogUnavailable': + 'Die Liste der Regionen ist gerade nicht erreichbar. Versuch es später erneut.', + 'tours.paddle.lakeStyle': 'Über Seen', + 'tours.paddle.lakeStyle.direct': 'Direkt', + 'tours.paddle.lakeStyle.shore': 'Ufernah', + 'tours.paddle.lakeStyle.directHint': 'Die kürzeste Linie über offenes Wasser.', + 'tours.paddle.lakeStyle.shoreHint': 'Bleibt in Ufernähe, für windige Tage: länger, aber Land ist nie weit.', + 'tours.paddle.wind.shoreAdvice': + 'An diesem Tag bis {mean} km/h, Böen {gusts} km/h. Auf den Seen ist ufernah vielleicht die bessere Linie.', + 'tours.paddle.wind.shoreAdopt': 'Ufernah planen', + 'tours.paddle.wind.shoreTaken': 'Die Seen sind ufernah geplant.', + 'tours.paddle.status.unrouted': '{count} Abschnitte nicht geplant', + 'tours.paddle.status.unrouted.one': '{count} Abschnitt nicht geplant', }; export default tours; diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 13b47acd8..77d4b0fef 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -862,5 +862,42 @@ const admin: TranslationStrings = { 'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.', 'admin.paddleRegions.toast.added': 'Region added. TREK reads it in the background.', 'admin.paddleRegions.toast.removed': 'Region removed', + 'admin.paddleRegions.phase.build': 'Building the water network', + 'admin.paddleRegions.network.km': '{km} km of water', + 'admin.paddleRegions.network.lakes': '{count} lakes', + 'admin.paddleRegions.network.lakes.one': '{count} lake', + 'admin.paddleRegions.network.portages': '{count} mapped portages', + 'admin.paddleRegions.network.portages.one': '{count} mapped portage', + 'admin.paddleRegions.network.inferred': '{count} assumed carries', + 'admin.paddleRegions.network.inferred.one': '{count} assumed carry', + 'admin.paddleRegions.network.locks': '{count} locks', + 'admin.paddleRegions.network.locks.one': '{count} lock', + 'admin.paddleRegions.network.gaps': '{count} bridged gaps', + 'admin.paddleRegions.network.gaps.one': '{count} bridged gap', + 'admin.paddleRegions.network.missing': 'No water network', + 'admin.paddleRegions.network.missingHint': 'Read the region again to build the network paddle routing follows.', + 'admin.paddleRegions.auto.title': 'Load regions automatically', + 'admin.paddleRegions.auto.mode.auto': 'Automatic', + 'admin.paddleRegions.auto.mode.ask': 'On request', + 'admin.paddleRegions.auto.mode.off': 'Off', + 'admin.paddleRegions.auto.modeHint.auto': + "When someone plans a paddle tour where there is no water data yet, TREK loads the smallest matching region right away, up to the size limit and only a few a day. Anything larger waits here as a request. Only the region's name goes to Geofabrik, never the place.", + 'admin.paddleRegions.auto.modeHint.ask': + 'Every new region waits here until an admin lets it through. When an admin plans, it loads right away.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK loads nothing by itself and asks for nothing. Add regions here by hand.', + 'admin.paddleRegions.auto.maxSize': 'Largest region loaded automatically', + 'admin.paddleRegions.auto.maxSizeHint': + 'Larger regions wait here as a request. Reading a region takes a few minutes and memory while it runs.', + 'admin.paddleRegions.requests.title': 'Requested', + 'admin.paddleRegions.requests.reason.ask': 'On request', + 'admin.paddleRegions.requests.reason.too_large': 'Too large', + 'admin.paddleRegions.requests.reason.limit': 'Daily limit', + 'admin.paddleRegions.requests.declined': 'Declined', + 'admin.paddleRegions.requests.by': 'by {user}', + 'admin.paddleRegions.requests.approve': 'Load', + 'admin.paddleRegions.requests.decline': 'Decline', + 'admin.paddleRegions.origin.auto': 'Automatic', + 'admin.paddleRegions.origin.autoHint': 'TREK loaded this region by itself for a paddle tour planned there.', }; export default admin; diff --git a/shared/src/i18n/en/externalNotifications.ts b/shared/src/i18n/en/externalNotifications.ts index 5f3546d39..cc51f83eb 100644 --- a/shared/src/i18n/en/externalNotifications.ts +++ b/shared/src/i18n/en/externalNotifications.ts @@ -67,6 +67,10 @@ const en: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Paddle region requested', + body: `${p.actor} is planning a paddle tour in ${p.region}. Load or decline its water data in the admin area under Addons.`, + }), synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.', diff --git a/shared/src/i18n/en/notif.ts b/shared/src/i18n/en/notif.ts index d4bbecf03..8ccc82ff1 100644 --- a/shared/src/i18n/en/notif.ts +++ b/shared/src/i18n/en/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}", 'notif.replica_failure.textSuppressed': "Replica write failed on '{backend}': {op} of {key} — {error}. {suppressed} more failures were suppressed since the last notification.", + 'notif.paddle_region_requested.title': 'Paddle region requested', + 'notif.paddle_region_requested.text': '{actor} is planning a paddle tour in {region}', 'notif.action.view_trip': 'View Trip', 'notif.action.view_collab': 'View Messages', 'notif.action.view_packing': 'View Packing', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 624091a58..f9a1e80c5 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -118,6 +118,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook notifications', 'settings.notifyVersionAvailable': 'New version available', 'settings.notifyReplicaFailure': 'Storage replica failure', + 'settings.notifyPaddleRegionRequested': 'Paddle region requested', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index f4d48b0a9..6f8e8081f 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -286,8 +286,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Enter a speed from 0.5 to 20 km/h.', 'tours.paddle.chooseBoat': 'Choose the boat and the water to save this paddle tour.', 'tours.paddle.chooseBoatImport': 'Choose the boat and the water to import as a paddle tour.', - 'tours.paddle.drawnByHand': - 'A paddle tour is drawn by hand: TREK joins your points with straight lines. Nothing is routed, and nothing says the water can be paddled.', 'tours.paddle.timeEstimated': 'Paddling time (estimated)', 'tours.paddle.timeEstimatedShort': 'Paddling', 'tours.paddle.timeEstimatedInfoLabel': 'How the paddling time is estimated', @@ -330,6 +328,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Portage', 'tours.paddle.portageOn': 'Carry the boat from here', 'tours.paddle.portageOff': 'Paddle this stretch again', + 'tours.paddle.portagePending': 'Portage from here', + 'tours.paddle.portagePendingHint': 'Now set the point where you put in again. The boat is carried up to there.', + 'tours.paddle.portagePendingOff': 'Do not carry after all', + 'tours.paddle.portageWay': 'Route', + 'tours.paddle.portageWay.follow': 'Follow the path', + 'tours.paddle.portageWay.straight': 'Straight line', + 'tours.paddle.portageWay.noWay': 'No path without a long detour, so the line stays straight.', + 'tours.paddle.portageWay.searching': 'Looking for a path', + 'tours.paddle.placeHere.putIn': 'Put in here', + 'tours.paddle.placeHere.egress': 'Take out here', 'tours.paddle.portageInfo': 'The boat is carried here. The stretch counts as land, with time for getting out, unloading and putting back in.', 'tours.paddle.portageExtra': 'Out and in', @@ -521,5 +529,59 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Paddling has risks. Check water, rules and weather yourself.', 'tours.paddle.safety.text': 'Paddling carries risks. Maps, route suggestions and notes can be incomplete or out of date. Before the tour, check the current local conditions, the rules for using the water and the weather. Take suitable safety measures. TREK supports your planning and does not replace your own check and responsible decision.', + 'tours.planner.shapingPoint': 'Shaping point', + 'tours.planner.shapingHint': 'Steers the route without being a stop. Drag it to where the route should run.', + 'tours.planner.makeStop': 'Make it a stop', + 'tours.planner.makeShaping': 'Make it a shaping point', + 'tours.planner.recomputeLeg': 'Plan the next leg again', + 'tours.planner.shapeOnLine': 'Click the route on the map to add a shaping point there.', + 'tours.paddle.routeOnWater': 'Plan the next leg on the water', + 'tours.paddle.routeAllOnWater': 'Plan the legs drawn by hand on the water', + 'tours.paddle.auto': 'Found', + 'tours.paddle.autoHint': + 'TREK set this point at a portage or lock it found on the way. Move or change it and it is yours.', + 'tours.paddle.note.rapids': 'Whitewater {grade}', + 'tours.paddle.note.pass': 'Boat chute', + 'tours.paddle.note.gap': 'Gap in the map data', + 'tours.paddle.note.rapidsHint': + 'The route runs through moving water here. Scout it first and carry around if in doubt.', + 'tours.paddle.note.gapHint': 'The water data has a gap here, bridged with a straight line. Check it on site.', + 'tours.paddle.failed.not_on_water': 'One end of this leg is not on water TREK knows. Move it onto the river or lake.', + 'tours.paddle.failed.no_region': 'No water data for this area yet. An administrator can add the region.', + 'tours.paddle.failed.building': 'The water data for this region is still being prepared. Try again in a few minutes.', + 'tours.paddle.failed.no_water_path': 'TREK found no way on the water between these points.', + 'tours.paddle.failed.cross_region': 'These points lie in two regions that share no water data.', + 'tours.paddle.failed.busy': 'The route planner is busy. Try again in a moment.', + 'tours.paddle.failed.timeout': 'Planning this leg took too long. Add a point in between and try again.', + 'tours.paddle.failed.offline': 'Offline. This leg is planned once you are connected again.', + 'tours.paddle.drawStraight': 'Draw straight', + 'tours.paddle.retry': 'Try again', + 'tours.paddle.load.loading': 'Loading the water data for {region}', + 'tours.paddle.load.ready': 'The water data for {region} is here, planning the stretch.', + 'tours.paddle.load.requested': + 'The water data for {region} has been requested. The stretch is planned as soon as an admin lets it through.', + 'tours.paddle.load.waiting': 'Waiting for other regions', + 'tours.paddle.load.phase.download': 'Downloading', + 'tours.paddle.load.phase.read': 'Reading', + 'tours.paddle.load.phase.build': 'Building the water network', + 'tours.paddle.load.hint': 'Usually one to three minutes. Keep planning, the stretch is planned by itself afterwards.', + 'tours.paddle.load.reason.tooLarge': 'The region is larger than TREK loads by itself.', + 'tours.paddle.load.reason.limit': 'TREK has already loaded enough regions by itself today.', + 'tours.paddle.load.declined': 'An admin declined the water data for {region}.', + 'tours.paddle.load.failed': 'The water data for {region} could not be loaded.', + 'tours.paddle.load.noWater': 'The water data for {region} has no water here. Put the point on the river or lake.', + 'tours.paddle.load.noExtract': 'There is no map extract with water data for this area.', + 'tours.paddle.load.catalogUnavailable': 'The list of regions cannot be reached right now. Try again later.', + 'tours.paddle.lakeStyle': 'Across lakes', + 'tours.paddle.lakeStyle.direct': 'Straight over', + 'tours.paddle.lakeStyle.shore': 'Near the shore', + 'tours.paddle.lakeStyle.directHint': 'The shortest line across open water.', + 'tours.paddle.lakeStyle.shoreHint': 'Stays near the bank, for windy days: longer, but land is never far.', + 'tours.paddle.wind.shoreAdvice': + 'Up to {mean} km/h, gusts {gusts} km/h on this day. On the lakes, staying near the shore may be the better line.', + 'tours.paddle.wind.shoreAdopt': 'Plan near the shore', + 'tours.paddle.wind.shoreTaken': 'The lakes are planned near the shore.', + 'tours.paddle.status.unrouted': '{count} legs not planned', + 'tours.paddle.status.unrouted.one': '{count} leg not planned', }; export default tours; diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 7462f136c..ed3d5cdd5 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -901,5 +901,43 @@ const admin: TranslationStrings = { 'Datos © colaboradores de OpenStreetMap (ODbL), extractos regionales de Geofabrik. Se vuelven a leer el día 3 de cada mes.', 'admin.paddleRegions.toast.added': 'Región añadida. TREK la lee en segundo plano.', 'admin.paddleRegions.toast.removed': 'Región eliminada', + 'admin.paddleRegions.phase.build': 'Construyendo la red de agua', + 'admin.paddleRegions.network.km': '{km} km de agua', + 'admin.paddleRegions.network.lakes': '{count} lagos', + 'admin.paddleRegions.network.lakes.one': '{count} lago', + 'admin.paddleRegions.network.portages': '{count} porteos cartografiados', + 'admin.paddleRegions.network.portages.one': '{count} porteo cartografiado', + 'admin.paddleRegions.network.inferred': '{count} porteos supuestos', + 'admin.paddleRegions.network.inferred.one': '{count} porteo supuesto', + 'admin.paddleRegions.network.locks': '{count} esclusas', + 'admin.paddleRegions.network.locks.one': '{count} esclusa', + 'admin.paddleRegions.network.gaps': '{count} huecos salvados', + 'admin.paddleRegions.network.gaps.one': '{count} hueco salvado', + 'admin.paddleRegions.network.missing': 'Sin red de agua', + 'admin.paddleRegions.network.missingHint': + 'Vuelve a leer la región para construir la red que sigue la ruta en piragua.', + 'admin.paddleRegions.auto.title': 'Cargar regiones automáticamente', + 'admin.paddleRegions.auto.mode.auto': 'Automático', + 'admin.paddleRegions.auto.mode.ask': 'Bajo petición', + 'admin.paddleRegions.auto.mode.off': 'Desactivado', + 'admin.paddleRegions.auto.modeHint.auto': + 'Cuando alguien planifica una ruta en kayak donde aún no hay datos de agua, TREK carga al momento la región más pequeña que encaja, hasta el límite de tamaño y solo unas pocas al día. Las más grandes esperan aquí como petición. A Geofabrik solo le llega el nombre de la región, nunca el lugar.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Cada región nueva espera aquí hasta que un administrador la deje pasar. Si planifica un administrador, se carga al momento.', + 'admin.paddleRegions.auto.modeHint.off': 'TREK no carga nada por su cuenta ni pide nada. Añade regiones aquí a mano.', + 'admin.paddleRegions.auto.maxSize': 'Región más grande que se carga automáticamente', + 'admin.paddleRegions.auto.maxSizeHint': + 'Las regiones más grandes esperan aquí como petición. Leer una región lleva unos minutos y ocupa memoria mientras tanto.', + 'admin.paddleRegions.requests.title': 'Pedidas', + 'admin.paddleRegions.requests.reason.ask': 'Bajo petición', + 'admin.paddleRegions.requests.reason.too_large': 'Demasiado grande', + 'admin.paddleRegions.requests.reason.limit': 'Límite diario', + 'admin.paddleRegions.requests.declined': 'Rechazada', + 'admin.paddleRegions.requests.by': 'de {user}', + 'admin.paddleRegions.requests.approve': 'Cargar', + 'admin.paddleRegions.requests.decline': 'Rechazar', + 'admin.paddleRegions.origin.auto': 'Automático', + 'admin.paddleRegions.origin.autoHint': + 'TREK cargó esta región por su cuenta para una ruta en kayak planificada allí.', }; export default admin; diff --git a/shared/src/i18n/es/externalNotifications.ts b/shared/src/i18n/es/externalNotifications.ts index 895a273ac..84fad9936 100644 --- a/shared/src/i18n/es/externalNotifications.ts +++ b/shared/src/i18n/es/externalNotifications.ts @@ -67,6 +67,10 @@ const es: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Región de kayak pedida', + body: `${p.actor} está planificando una ruta en kayak en ${p.region}. Carga o rechaza sus datos de agua en la administración, en Complementos.`, + }), synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.', diff --git a/shared/src/i18n/es/notif.ts b/shared/src/i18n/es/notif.ts index 9aa4d15dc..566186ef0 100644 --- a/shared/src/i18n/es/notif.ts +++ b/shared/src/i18n/es/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Error al escribir en la réplica '{backend}': {op} de {key} — {error}", 'notif.replica_failure.textSuppressed': "Error al escribir en la réplica '{backend}': {op} de {key} — {error}. Se suprimieron {suppressed} errores más desde la última notificación.", + 'notif.paddle_region_requested.title': 'Región de kayak pedida', + 'notif.paddle_region_requested.text': '{actor} está planificando una ruta en kayak en {region}', 'notif.action.view_trip': 'Ver viaje', 'notif.action.view_collab': 'Ver mensajes', 'notif.action.view_packing': 'Ver equipaje', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 392b70238..8ac3e0218 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -301,6 +301,7 @@ const settings: TranslationStrings = { 'settings.passwordRule.special': 'Un carácter especial', 'settings.notifyVersionAvailable': 'Nueva versión disponible', 'settings.notifyReplicaFailure': 'Fallo de réplica de almacenamiento', + 'settings.notifyPaddleRegionRequested': 'Región de kayak pedida', 'settings.notificationPreferences.noChannels': 'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.', 'settings.webhookUrl.label': 'URL del webhook', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 18958edf0..dae5fbcb3 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -292,8 +292,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Introduce una velocidad de 0,5 a 20 km/h.', 'tours.paddle.chooseBoat': 'Elige la embarcación y el agua para guardar esta ruta.', 'tours.paddle.chooseBoatImport': 'Elige la embarcación y el agua para importar como ruta en piragua.', - 'tours.paddle.drawnByHand': - 'Una ruta en piragua se dibuja a mano: TREK une tus puntos con líneas rectas. Nada se calcula y nada indica que el agua sea navegable.', 'tours.paddle.timeEstimated': 'Tiempo de remo (estimado)', 'tours.paddle.timeEstimatedShort': 'Remo', 'tours.paddle.timeEstimatedInfoLabel': 'Cómo se estima el tiempo de remo', @@ -336,6 +334,17 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Porteo', 'tours.paddle.portageOn': 'Portear la embarcación desde aquí', 'tours.paddle.portageOff': 'Volver a remar este tramo', + 'tours.paddle.portagePending': 'Porteo desde aquí', + 'tours.paddle.portagePendingHint': + 'Ahora marca el punto donde vuelves a entrar al agua. El bote se carga hasta allí.', + 'tours.paddle.portagePendingOff': 'Al final no portear', + 'tours.paddle.portageWay': 'Recorrido', + 'tours.paddle.portageWay.follow': 'Seguir el camino', + 'tours.paddle.portageWay.straight': 'Línea recta', + 'tours.paddle.portageWay.noWay': 'No hay camino sin un gran rodeo, la línea queda recta.', + 'tours.paddle.portageWay.searching': 'Buscando un camino', + 'tours.paddle.placeHere.putIn': 'Entrar al agua aquí', + 'tours.paddle.placeHere.egress': 'Salir del agua aquí', 'tours.paddle.portageInfo': 'Aquí se lleva la embarcación a pie. El tramo cuenta como tierra, con tiempo para desembarcar, descargar y volver a embarcar.', 'tours.paddle.portageExtra': 'Desembarcar y embarcar', @@ -530,5 +539,64 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Remar conlleva riesgos. Comprueba por tu cuenta el agua, las normas y la meteorología.', 'tours.paddle.safety.text': 'Remar conlleva riesgos. Los mapas, las propuestas de ruta y las indicaciones pueden estar incompletos o desactualizados. Antes de la ruta, comprueba las condiciones locales actuales, las normas de navegación de esas aguas y la meteorología. Toma medidas de seguridad adecuadas. TREK apoya tu planificación y no sustituye tu propia comprobación ni tu decisión responsable.', + 'tours.planner.shapingPoint': 'Punto de forma', + 'tours.planner.shapingHint': 'Guía la ruta sin ser una parada. Arrástralo a donde deba pasar la ruta.', + 'tours.planner.makeStop': 'Convertir en parada', + 'tours.planner.makeShaping': 'Convertir en punto de forma', + 'tours.planner.recomputeLeg': 'Volver a calcular el siguiente tramo', + 'tours.planner.shapeOnLine': 'Haz clic en la ruta del mapa para añadir ahí un punto de forma.', + 'tours.paddle.routeOnWater': 'Planificar el siguiente tramo por el agua', + 'tours.paddle.routeAllOnWater': 'Planificar por el agua los tramos dibujados a mano', + 'tours.paddle.auto': 'Detectado', + 'tours.paddle.autoHint': + 'TREK colocó este punto en un porteo o una esclusa que encontró en el camino. Si lo mueves o cambias, pasa a ser tuyo.', + 'tours.paddle.note.rapids': 'Aguas bravas {grade}', + 'tours.paddle.note.pass': 'Paso para canoas', + 'tours.paddle.note.gap': 'Hueco en los datos del mapa', + 'tours.paddle.note.rapidsHint': 'La ruta pasa aquí por aguas movidas. Explóralo antes y portea si tienes dudas.', + 'tours.paddle.note.gapHint': + 'Los datos del agua tienen un hueco aquí, salvado con una línea recta. Compruébalo en el lugar.', + 'tours.paddle.failed.not_on_water': + 'Un extremo de este tramo no está en ningún agua que TREK conozca. Muévelo al río o al lago.', + 'tours.paddle.failed.no_region': 'Aún no hay datos de agua para esta zona. Un administrador puede añadir la región.', + 'tours.paddle.failed.building': + 'Los datos de agua de esta región aún se están preparando. Inténtalo de nuevo en unos minutos.', + 'tours.paddle.failed.no_water_path': 'TREK no encontró ningún camino por el agua entre estos puntos.', + 'tours.paddle.failed.cross_region': 'Estos puntos están en dos regiones que no comparten datos de agua.', + 'tours.paddle.failed.busy': 'El planificador de rutas está ocupado. Inténtalo de nuevo en un momento.', + 'tours.paddle.failed.timeout': + 'Planificar este tramo tardó demasiado. Añade un punto intermedio e inténtalo de nuevo.', + 'tours.paddle.failed.offline': 'Sin conexión. Este tramo se planificará cuando vuelvas a estar conectado.', + 'tours.paddle.drawStraight': 'Dibujar recto', + 'tours.paddle.retry': 'Reintentar', + 'tours.paddle.load.loading': 'Cargando los datos de agua de {region}', + 'tours.paddle.load.ready': 'Los datos de agua de {region} ya están, se planifica el tramo.', + 'tours.paddle.load.requested': + 'Se han pedido los datos de agua de {region}. El tramo se planifica en cuanto un administrador los deje pasar.', + 'tours.paddle.load.waiting': 'Esperando a otras regiones', + 'tours.paddle.load.phase.download': 'Descargando', + 'tours.paddle.load.phase.read': 'Leyendo', + 'tours.paddle.load.phase.build': 'Construyendo la red de agua', + 'tours.paddle.load.hint': + 'Suele tardar de uno a tres minutos. Sigue planificando, el tramo se planifica solo después.', + 'tours.paddle.load.reason.tooLarge': 'La región es más grande de lo que TREK carga por su cuenta.', + 'tours.paddle.load.reason.limit': 'TREK ya ha cargado hoy suficientes regiones por su cuenta.', + 'tours.paddle.load.declined': 'Un administrador rechazó los datos de agua de {region}.', + 'tours.paddle.load.failed': 'No se pudieron cargar los datos de agua de {region}.', + 'tours.paddle.load.noWater': 'En los datos de agua de {region} aquí no hay agua. Pon el punto en el río o el lago.', + 'tours.paddle.load.noExtract': 'No hay ningún extracto de mapa con datos de agua para esta zona.', + 'tours.paddle.load.catalogUnavailable': 'La lista de regiones no está disponible ahora mismo. Inténtalo más tarde.', + 'tours.paddle.lakeStyle': 'Por los lagos', + 'tours.paddle.lakeStyle.direct': 'En línea recta', + 'tours.paddle.lakeStyle.shore': 'Cerca de la orilla', + 'tours.paddle.lakeStyle.directHint': 'La línea más corta por aguas abiertas.', + 'tours.paddle.lakeStyle.shoreHint': + 'Se mantiene cerca de la orilla, para días de viento: más largo, pero la tierra nunca queda lejos.', + 'tours.paddle.wind.shoreAdvice': + 'Hasta {mean} km/h, rachas de {gusts} km/h ese día. En los lagos, ir cerca de la orilla puede ser la mejor línea.', + 'tours.paddle.wind.shoreAdopt': 'Planificar cerca de la orilla', + 'tours.paddle.wind.shoreTaken': 'Los lagos están planificados cerca de la orilla.', + 'tours.paddle.status.unrouted': '{count} tramos sin planificar', + 'tours.paddle.status.unrouted.one': '{count} tramo sin planificar', }; export default tours; diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 930736897..954b919fa 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -885,5 +885,42 @@ const admin: TranslationStrings = { 'Andmed © OpenStreetMapi kaastöötajad (ODbL), piirkondlikud väljavõtted Geofabrikult. Loetakse uuesti iga kuu 3. kuupäeval.', 'admin.paddleRegions.toast.added': 'Piirkond lisatud. TREK loeb selle taustal sisse.', 'admin.paddleRegions.toast.removed': 'Piirkond eemaldatud', + 'admin.paddleRegions.phase.build': 'Veevõrgu ehitamine', + 'admin.paddleRegions.network.km': '{km} km vett', + 'admin.paddleRegions.network.lakes': '{count} järve', + 'admin.paddleRegions.network.lakes.one': '{count} järv', + 'admin.paddleRegions.network.portages': '{count} kaardistatud ülevedamiskohta', + 'admin.paddleRegions.network.portages.one': '{count} kaardistatud ülevedamiskoht', + 'admin.paddleRegions.network.inferred': '{count} oletatud ülekandmist', + 'admin.paddleRegions.network.inferred.one': '{count} oletatud ülekandmine', + 'admin.paddleRegions.network.locks': '{count} lüüsi', + 'admin.paddleRegions.network.locks.one': '{count} lüüs', + 'admin.paddleRegions.network.gaps': '{count} ühendatud lünka', + 'admin.paddleRegions.network.gaps.one': '{count} ühendatud lünk', + 'admin.paddleRegions.network.missing': 'Veevõrk puudub', + 'admin.paddleRegions.network.missingHint': + 'Loe piirkond uuesti sisse, et ehitada võrk, mida aerutamise marsruut järgib.', + 'admin.paddleRegions.auto.title': 'Laadi piirkonnad automaatselt', + 'admin.paddleRegions.auto.mode.auto': 'Automaatne', + 'admin.paddleRegions.auto.mode.ask': 'Taotluse peale', + 'admin.paddleRegions.auto.mode.off': 'Väljas', + 'admin.paddleRegions.auto.modeHint.auto': + 'Kui keegi kavandab aerutamismatka kohta, kus veeandmeid veel pole, laadib TREK kohe väikseima sobiva piirkonna, suuruspiirini ja ainult mõne päevas. Suuremad ootavad siin taotlusena. Geofabrikule läheb ainult piirkonna nimi, mitte kunagi koht.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Iga uus piirkond ootab siin, kuni admin selle lubab. Kui admin ise kavandab, laaditakse see kohe.', + 'admin.paddleRegions.auto.modeHint.off': 'TREK ei laadi ise midagi ega küsi midagi. Lisa piirkonnad siin käsitsi.', + 'admin.paddleRegions.auto.maxSize': 'Suurim automaatselt laaditav piirkond', + 'admin.paddleRegions.auto.maxSizeHint': + 'Suuremad piirkonnad ootavad siin taotlusena. Piirkonna lugemine võtab mõne minuti ja selle ajal mälu.', + 'admin.paddleRegions.requests.title': 'Taotletud', + 'admin.paddleRegions.requests.reason.ask': 'Taotluse peale', + 'admin.paddleRegions.requests.reason.too_large': 'Liiga suur', + 'admin.paddleRegions.requests.reason.limit': 'Päevapiir', + 'admin.paddleRegions.requests.declined': 'Tagasi lükatud', + 'admin.paddleRegions.requests.by': 'kasutajalt {user}', + 'admin.paddleRegions.requests.approve': 'Laadi', + 'admin.paddleRegions.requests.decline': 'Lükka tagasi', + 'admin.paddleRegions.origin.auto': 'Automaatne', + 'admin.paddleRegions.origin.autoHint': 'TREK laadis selle piirkonna ise seal kavandatava aerutamismatka jaoks.', }; export default admin; diff --git a/shared/src/i18n/et/externalNotifications.ts b/shared/src/i18n/et/externalNotifications.ts index a1610978b..4abde3b0d 100644 --- a/shared/src/i18n/et/externalNotifications.ts +++ b/shared/src/i18n/et/externalNotifications.ts @@ -68,6 +68,10 @@ const et: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Aerutamispiirkonda taotleti', + body: `${p.actor} kavandab aerutamismatka piirkonnas ${p.region}. Laadi või lükka selle veeandmed tagasi halduses jaotises Lisad.`, + }), synology_session_cleared: () => ({ title: 'Synology seanss tühjendatud', body: 'Sinu Synology konto või URL muutus. Sind on Synology Photosist välja logitud.', diff --git a/shared/src/i18n/et/notif.ts b/shared/src/i18n/et/notif.ts index dad029b70..08cf6d20b 100644 --- a/shared/src/i18n/et/notif.ts +++ b/shared/src/i18n/et/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Koopia kirjutamine taustsüsteemi '{backend}' ebaõnnestus: {op}, {key} — {error}", 'notif.replica_failure.textSuppressed': "Koopia kirjutamine taustsüsteemi '{backend}' ebaõnnestus: {op}, {key} — {error}. Pärast viimast teavitust jäeti veel {suppressed} tõrget teavitamata.", + 'notif.paddle_region_requested.title': 'Aerutamispiirkonda taotleti', + 'notif.paddle_region_requested.text': '{actor} kavandab aerutamismatka piirkonnas {region}', 'notif.action.view_trip': 'Vaata reisi', 'notif.action.view_collab': 'Vaata sõnumeid', 'notif.action.view_packing': 'Vaata pakkimisnimekirja', diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index 1c8390873..0c949264f 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -123,6 +123,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Veebihaagi teavitused', 'settings.notifyVersionAvailable': 'Uus versioon saadaval', 'settings.notifyReplicaFailure': 'Salvestuskoopia tõrge', + 'settings.notifyPaddleRegionRequested': 'Aerutamispiirkonda taotleti', 'settings.notificationPreferences.email': 'E-post', 'settings.notificationPreferences.webhook': 'Veebihaak', 'settings.notificationPreferences.inapp': 'Rakenduses', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 1657c16f8..ca884db71 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -289,8 +289,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Sisesta kiirus vahemikus 0,5 kuni 20 km/h.', 'tours.paddle.chooseBoat': 'Vali paat ja veekogu, et see matk salvestada.', 'tours.paddle.chooseBoatImport': 'Vali paat ja veekogu, et importida aerutamismatkana.', - 'tours.paddle.drawnByHand': - 'Aerutamismatk joonistatakse käsitsi: TREK ühendab sinu punktid sirgjoontega. Midagi ei marsruudita ja miski ei ütle, et vesi on läbitav.', 'tours.paddle.timeEstimated': 'Aerutamisaeg (hinnanguline)', 'tours.paddle.timeEstimatedShort': 'Aerutamine', 'tours.paddle.timeEstimatedInfoLabel': 'Kuidas aerutamisaega hinnatakse', @@ -333,6 +331,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Ülekanne', 'tours.paddle.portageOn': 'Siit alates kanna paati', 'tours.paddle.portageOff': 'Aeruta see lõik jälle', + 'tours.paddle.portagePending': 'Siit alates kanda', + 'tours.paddle.portagePendingHint': 'Nüüd määra punkt, kus lähed uuesti vette. Paati kantakse sinna välja.', + 'tours.paddle.portagePendingOff': 'Siiski mitte kanda', + 'tours.paddle.portageWay': 'Kulg', + 'tours.paddle.portageWay.follow': 'Järgi rada', + 'tours.paddle.portageWay.straight': 'Sirge joon', + 'tours.paddle.portageWay.noWay': 'Suure ringita teed ei leitud, joon jääb sirgeks.', + 'tours.paddle.portageWay.searching': 'Teed otsitakse', + 'tours.paddle.placeHere.putIn': 'Lähe siit vette', + 'tours.paddle.placeHere.egress': 'Tule siit veest välja', 'tours.paddle.portageInfo': 'Siin kantakse paati. Lõik loetakse maismaaks, koos ajaga väljumiseks, mahalaadimiseks ja uuesti vette minekuks.', 'tours.paddle.portageExtra': 'Välja ja vette', @@ -525,5 +533,62 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Aerutamisega kaasnevad riskid. Kontrolli ise veekogu, reegleid ja ilma.', 'tours.paddle.safety.text': 'Aerutamisega kaasnevad riskid. Kaardid, marsruudisoovitused ja märkused võivad olla puudulikud või aegunud. Kontrolli enne matka praeguseid kohalikke olusid, veekogu kasutamise reegleid ja ilma. Rakenda sobivaid ohutusmeetmeid. TREK toetab sinu planeerimist, kuid ei asenda sinu enda kontrolli ega vastutustundlikku otsust.', + 'tours.planner.shapingPoint': 'Kujunduspunkt', + 'tours.planner.shapingHint': 'Suunab marsruuti, ilma et oleks peatus. Lohista see sinna, kust marsruut peab minema.', + 'tours.planner.makeStop': 'Tee peatuseks', + 'tours.planner.makeShaping': 'Tee kujunduspunktiks', + 'tours.planner.recomputeLeg': 'Arvuta järgmine lõik uuesti', + 'tours.planner.shapeOnLine': 'Klõpsa kaardil marsruudil, et lisada sinna kujunduspunkt.', + 'tours.paddle.routeOnWater': 'Planeeri järgmine lõik vett mööda', + 'tours.paddle.routeAllOnWater': 'Planeeri käsitsi joonistatud lõigud vett mööda', + 'tours.paddle.auto': 'Leitud', + 'tours.paddle.autoHint': + 'TREK pani selle punkti teel leitud ülevedamiskohta või lüüsi juurde. Kui liigutad või muudad seda, on see sinu oma.', + 'tours.paddle.note.rapids': 'Kärestik {grade}', + 'tours.paddle.note.pass': 'Paadirenn', + 'tours.paddle.note.gap': 'Lünk kaardiandmetes', + 'tours.paddle.note.rapidsHint': + 'Marsruut läbib siin voolavat vett. Vaata see enne üle ja kahtluse korral kanna paat ümber.', + 'tours.paddle.note.gapHint': 'Veeandmetes on siin lünk, mis on ühendatud sirgjoonega. Kontrolli kohapeal.', + 'tours.paddle.failed.not_on_water': + 'Selle lõigu üks ots ei asu ühelgi TREKile teadaoleval veekogul. Liiguta see jõele või järvele.', + 'tours.paddle.failed.no_region': 'Selle piirkonna veeandmeid veel pole. Administraator saab piirkonna lisada.', + 'tours.paddle.failed.building': + 'Selle piirkonna veeandmeid alles valmistatakse ette. Proovi mõne minuti pärast uuesti.', + 'tours.paddle.failed.no_water_path': 'TREK ei leidnud nende punktide vahel teed vett mööda.', + 'tours.paddle.failed.cross_region': 'Need punktid asuvad kahes piirkonnas, millel pole ühiseid veeandmeid.', + 'tours.paddle.failed.busy': 'Marsruudiplaneerija on hõivatud. Proovi kohe uuesti.', + 'tours.paddle.failed.timeout': 'Selle lõigu planeerimine võttis liiga kaua. Lisa vahele punkt ja proovi uuesti.', + 'tours.paddle.failed.offline': 'Võrguühendus puudub. Lõik planeeritakse, kui oled taas ühendatud.', + 'tours.paddle.drawStraight': 'Joonista sirgelt', + 'tours.paddle.retry': 'Proovi uuesti', + 'tours.paddle.load.loading': 'Piirkonna {region} veeandmeid laaditakse', + 'tours.paddle.load.ready': 'Piirkonna {region} veeandmed on olemas, lõiku kavandatakse.', + 'tours.paddle.load.requested': + 'Piirkonna {region} veeandmed on taotletud. Lõik kavandatakse kohe, kui admin need lubab.', + 'tours.paddle.load.waiting': 'Ootab teisi piirkondi', + 'tours.paddle.load.phase.download': 'Allalaadimine', + 'tours.paddle.load.phase.read': 'Lugemine', + 'tours.paddle.load.phase.build': 'Veevõrgu ehitamine', + 'tours.paddle.load.hint': 'Tavaliselt üks kuni kolm minutit. Kavanda rahulikult edasi, lõik kavandatakse pärast ise.', + 'tours.paddle.load.reason.tooLarge': 'Piirkond on suurem, kui TREK ise laadib.', + 'tours.paddle.load.reason.limit': 'TREK on täna juba piisavalt piirkondi ise laadinud.', + 'tours.paddle.load.declined': 'Admin lükkas piirkonna {region} veeandmed tagasi.', + 'tours.paddle.load.failed': 'Piirkonna {region} veeandmeid ei õnnestunud laadida.', + 'tours.paddle.load.noWater': 'Piirkonna {region} veeandmetes pole siin vett. Pane punkt jõele või järvele.', + 'tours.paddle.load.noExtract': 'Selle ala jaoks pole veeandmetega kaardiväljavõtet.', + 'tours.paddle.load.catalogUnavailable': 'Piirkondade loend pole praegu kättesaadav. Proovi hiljem uuesti.', + 'tours.paddle.lakeStyle': 'Üle järvede', + 'tours.paddle.lakeStyle.direct': 'Otse üle', + 'tours.paddle.lakeStyle.shore': 'Kalda lähedal', + 'tours.paddle.lakeStyle.directHint': 'Lühim joon üle avavee.', + 'tours.paddle.lakeStyle.shoreHint': + 'Püsib kalda lähedal, tuuliste päevade jaoks: pikem, kuid maa pole kunagi kaugel.', + 'tours.paddle.wind.shoreAdvice': + 'Sel päeval kuni {mean} km/h, puhanguid {gusts} km/h. Järvedel võib kalda lähedal püsimine olla parem joon.', + 'tours.paddle.wind.shoreAdopt': 'Planeeri kalda lähedale', + 'tours.paddle.wind.shoreTaken': 'Järved on planeeritud kalda lähedale.', + 'tours.paddle.status.unrouted': '{count} lõiku planeerimata', + 'tours.paddle.status.unrouted.one': '{count} lõik planeerimata', }; export default tours; diff --git a/shared/src/i18n/externalNotifications/types.ts b/shared/src/i18n/externalNotifications/types.ts index 534a4f58f..8471ba8d5 100644 --- a/shared/src/i18n/externalNotifications/types.ts +++ b/shared/src/i18n/externalNotifications/types.ts @@ -34,6 +34,7 @@ export type NotificationEventKey = | 'packing_tagged' | 'version_available' | 'replica_failure' + | 'paddle_region_requested' | 'synology_session_cleared' | 'plugin_notification'; diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index da5daf747..2884fa2eb 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -899,5 +899,44 @@ const admin: TranslationStrings = { 'Données © les contributeurs d’OpenStreetMap (ODbL), extraits régionaux de Geofabrik. Relues le 3 de chaque mois.', 'admin.paddleRegions.toast.added': 'Région ajoutée. TREK la lit en arrière-plan.', 'admin.paddleRegions.toast.removed': 'Région supprimée', + 'admin.paddleRegions.phase.build': 'Construction du réseau d’eau', + 'admin.paddleRegions.network.km': '{km} km de cours d’eau', + 'admin.paddleRegions.network.lakes': '{count} lacs', + 'admin.paddleRegions.network.lakes.one': '{count} lac', + 'admin.paddleRegions.network.portages': '{count} portages cartographiés', + 'admin.paddleRegions.network.portages.one': '{count} portage cartographié', + 'admin.paddleRegions.network.inferred': '{count} portages supposés', + 'admin.paddleRegions.network.inferred.one': '{count} portage supposé', + 'admin.paddleRegions.network.locks': '{count} écluses', + 'admin.paddleRegions.network.locks.one': '{count} écluse', + 'admin.paddleRegions.network.gaps': '{count} lacunes comblées', + 'admin.paddleRegions.network.gaps.one': '{count} lacune comblée', + 'admin.paddleRegions.network.missing': 'Aucun réseau d’eau', + 'admin.paddleRegions.network.missingHint': + 'Relisez la région pour construire le réseau que suit l’itinéraire en pagaie.', + 'admin.paddleRegions.auto.title': 'Charger les régions automatiquement', + 'admin.paddleRegions.auto.mode.auto': 'Automatique', + 'admin.paddleRegions.auto.mode.ask': 'Sur demande', + 'admin.paddleRegions.auto.mode.off': 'Désactivé', + 'admin.paddleRegions.auto.modeHint.auto': + "Quand quelqu'un planifie une sortie en canoë là où il n'y a pas encore de données d'eau, TREK charge tout de suite la plus petite région qui convient, jusqu'à la taille limite et seulement quelques-unes par jour. Les plus grandes attendent ici comme demande. Seul le nom de la région part chez Geofabrik, jamais le lieu.", + 'admin.paddleRegions.auto.modeHint.ask': + "Chaque nouvelle région attend ici qu'un administrateur la valide. Quand un administrateur planifie, elle se charge tout de suite.", + 'admin.paddleRegions.auto.modeHint.off': + 'TREK ne charge rien de lui-même et ne demande rien. Ajoute les régions ici à la main.', + 'admin.paddleRegions.auto.maxSize': 'Plus grande région chargée automatiquement', + 'admin.paddleRegions.auto.maxSizeHint': + 'Les régions plus grandes attendent ici comme demande. Lire une région prend quelques minutes et occupe de la mémoire pendant ce temps.', + 'admin.paddleRegions.requests.title': 'Demandées', + 'admin.paddleRegions.requests.reason.ask': 'Sur demande', + 'admin.paddleRegions.requests.reason.too_large': 'Trop grande', + 'admin.paddleRegions.requests.reason.limit': 'Limite du jour', + 'admin.paddleRegions.requests.declined': 'Refusée', + 'admin.paddleRegions.requests.by': 'par {user}', + 'admin.paddleRegions.requests.approve': 'Charger', + 'admin.paddleRegions.requests.decline': 'Refuser', + 'admin.paddleRegions.origin.auto': 'Automatique', + 'admin.paddleRegions.origin.autoHint': + 'TREK a chargé cette région de lui-même pour une sortie en canoë planifiée là-bas.', }; export default admin; diff --git a/shared/src/i18n/fr/externalNotifications.ts b/shared/src/i18n/fr/externalNotifications.ts index 372c31c3d..16bc2828a 100644 --- a/shared/src/i18n/fr/externalNotifications.ts +++ b/shared/src/i18n/fr/externalNotifications.ts @@ -67,6 +67,10 @@ const fr: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Région de pagaie demandée', + body: `${p.actor} planifie une sortie en canoë dans ${p.region}. Charge ou refuse ses données d'eau dans l'administration, sous Modules.`, + }), synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.', diff --git a/shared/src/i18n/fr/notif.ts b/shared/src/i18n/fr/notif.ts index c08491cf4..1a4b1f4fb 100644 --- a/shared/src/i18n/fr/notif.ts +++ b/shared/src/i18n/fr/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Échec de l'écriture sur la réplique '{backend}' : {op} de {key} — {error}", 'notif.replica_failure.textSuppressed': "Échec de l'écriture sur la réplique '{backend}' : {op} de {key} — {error}. {suppressed} erreurs supplémentaires ont été supprimées depuis la dernière notification.", + 'notif.paddle_region_requested.title': 'Région de pagaie demandée', + 'notif.paddle_region_requested.text': '{actor} planifie une sortie en canoë dans {region}', 'notif.action.view_trip': 'Voir le voyage', 'notif.action.view_collab': 'Voir les messages', 'notif.action.view_packing': 'Voir les bagages', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 32dd38198..e23ea0d1a 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -305,6 +305,7 @@ const settings: TranslationStrings = { "Affiche les noms des gares / aéroports sur la carte. Si désactivé, seule l'icône est affichée.", 'settings.notifyVersionAvailable': 'Nouvelle version disponible', 'settings.notifyReplicaFailure': 'Échec de réplique de stockage', + 'settings.notifyPaddleRegionRequested': 'Région de pagaie demandée', 'settings.notificationPreferences.noChannels': "Aucun canal de notification n'est configuré. Demandez à un administrateur de configurer les notifications par e-mail ou webhook.", 'settings.webhookUrl.label': 'URL du webhook', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 9e5ecfa29..b1faa9f4a 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -298,8 +298,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Saisissez une vitesse de 0,5 à 20 km/h.', 'tours.paddle.chooseBoat': 'Choisissez l’embarcation et le plan d’eau pour enregistrer cette sortie.', 'tours.paddle.chooseBoatImport': 'Choisissez l’embarcation et le plan d’eau pour importer en sortie en pagaie.', - 'tours.paddle.drawnByHand': - 'Une sortie en pagaie se trace à la main : TREK relie vos points en lignes droites. Rien n’est calculé, et rien ne dit que l’eau est navigable.', 'tours.paddle.timeEstimated': 'Temps de pagaie (estimé)', 'tours.paddle.timeEstimatedShort': 'Pagaie', 'tours.paddle.timeEstimatedInfoLabel': 'Comment le temps de pagaie est estimé', @@ -343,6 +341,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Portage', // same-as-en 'tours.paddle.portageOn': 'Porter l’embarcation à partir d’ici', 'tours.paddle.portageOff': 'Pagayer de nouveau ce tronçon', + 'tours.paddle.portagePending': "Portage à partir d'ici", + 'tours.paddle.portagePendingHint': "Place maintenant le point où tu remets à l'eau. Le bateau est porté jusque-là.", + 'tours.paddle.portagePendingOff': 'Finalement ne pas porter', + 'tours.paddle.portageWay': 'Tracé', + 'tours.paddle.portageWay.follow': 'Suivre le chemin', + 'tours.paddle.portageWay.straight': 'Ligne droite', + 'tours.paddle.portageWay.noWay': 'Aucun chemin sans grand détour, la ligne reste droite.', + 'tours.paddle.portageWay.searching': "Recherche d'un chemin", + 'tours.paddle.placeHere.putIn': "Mettre à l'eau ici", + 'tours.paddle.placeHere.egress': "Sortir de l'eau ici", 'tours.paddle.portageInfo': 'Ici, l’embarcation est portée. Le tronçon compte comme parcours à terre, avec du temps pour débarquer, décharger et rembarquer.', 'tours.paddle.portageExtra': 'Débarquer et rembarquer', @@ -542,5 +550,67 @@ const tours: TranslationStrings = { 'La pagaie comporte des risques. Vérifiez vous-même le plan d’eau, les règles et la météo.', 'tours.paddle.safety.text': 'La pratique de la pagaie comporte des risques. Les cartes, les propositions d’itinéraire et les indications peuvent être incomplètes ou obsolètes. Avant la sortie, vérifiez les conditions locales actuelles, les règles de navigation sur le plan d’eau et la météo. Prenez des mesures de sécurité adaptées. TREK accompagne votre planification et ne remplace ni votre propre vérification ni votre décision responsable.', + 'tours.planner.shapingPoint': 'Point de guidage', + 'tours.planner.shapingHint': + 'Oriente l’itinéraire sans être une étape. Faites-le glisser là où l’itinéraire doit passer.', + 'tours.planner.makeStop': 'En faire une étape', + 'tours.planner.makeShaping': 'En faire un point de guidage', + 'tours.planner.recomputeLeg': 'Recalculer le tronçon suivant', + 'tours.planner.shapeOnLine': 'Cliquez sur l’itinéraire dans la carte pour y ajouter un point de guidage.', + 'tours.paddle.routeOnWater': 'Planifier le tronçon suivant sur l’eau', + 'tours.paddle.routeAllOnWater': 'Planifier sur l’eau les tronçons tracés à la main', + 'tours.paddle.auto': 'Détecté', + 'tours.paddle.autoHint': + 'TREK a placé ce point à un portage ou une écluse trouvé en chemin. Déplacez-le ou modifiez-le et il devient le vôtre.', + 'tours.paddle.note.rapids': 'Eaux vives {grade}', + 'tours.paddle.note.pass': 'Passe à canoës', + 'tours.paddle.note.gap': 'Lacune dans les données cartographiques', + 'tours.paddle.note.rapidsHint': 'L’itinéraire traverse ici une eau vive. Repérez d’abord et portez en cas de doute.', + 'tours.paddle.note.gapHint': + 'Les données des cours d’eau ont une lacune ici, comblée par une ligne droite. Vérifiez sur place.', + 'tours.paddle.failed.not_on_water': + 'Une extrémité de ce tronçon n’est sur aucune eau connue de TREK. Placez-la sur la rivière ou le lac.', + 'tours.paddle.failed.no_region': + 'Pas encore de données sur les eaux de cette zone. Un administrateur peut ajouter la région.', + 'tours.paddle.failed.building': + 'Les données des eaux de cette région sont encore en préparation. Réessayez dans quelques minutes.', + 'tours.paddle.failed.no_water_path': 'TREK n’a trouvé aucun chemin sur l’eau entre ces points.', + 'tours.paddle.failed.cross_region': 'Ces points se trouvent dans deux régions sans données d’eau communes.', + 'tours.paddle.failed.busy': 'Le planificateur d’itinéraire est occupé. Réessayez dans un instant.', + 'tours.paddle.failed.timeout': + 'La planification de ce tronçon a pris trop de temps. Ajoutez un point intermédiaire et réessayez.', + 'tours.paddle.failed.offline': 'Hors ligne. Ce tronçon sera planifié dès que vous serez reconnecté.', + 'tours.paddle.drawStraight': 'Tracer en ligne droite', + 'tours.paddle.retry': 'Réessayer', + 'tours.paddle.load.loading': "Chargement des données d'eau de {region}", + 'tours.paddle.load.ready': "Les données d'eau de {region} sont là, le tronçon est planifié.", + 'tours.paddle.load.requested': + "Les données d'eau de {region} ont été demandées. Le tronçon sera planifié dès qu'un administrateur les valide.", + 'tours.paddle.load.waiting': "En attente d'autres régions", + 'tours.paddle.load.phase.download': 'Téléchargement', + 'tours.paddle.load.phase.read': 'Lecture', + 'tours.paddle.load.phase.build': "Construction du réseau d'eau", + 'tours.paddle.load.hint': + 'En général une à trois minutes. Continue à planifier, le tronçon se planifiera ensuite tout seul.', + 'tours.paddle.load.reason.tooLarge': 'La région est plus grande que ce que TREK charge de lui-même.', + 'tours.paddle.load.reason.limit': "TREK a déjà chargé assez de régions de lui-même aujourd'hui.", + 'tours.paddle.load.declined': "Un administrateur a refusé les données d'eau de {region}.", + 'tours.paddle.load.failed': "Impossible de charger les données d'eau de {region}.", + 'tours.paddle.load.noWater': + "Les données d'eau de {region} n'ont pas d'eau ici. Place le point sur la rivière ou le lac.", + 'tours.paddle.load.noExtract': "Il n'existe pas d'extrait de carte avec des données d'eau pour cette zone.", + 'tours.paddle.load.catalogUnavailable': 'La liste des régions est injoignable pour le moment. Réessaie plus tard.', + 'tours.paddle.lakeStyle': 'Sur les lacs', + 'tours.paddle.lakeStyle.direct': 'Tout droit', + 'tours.paddle.lakeStyle.shore': 'Près de la rive', + 'tours.paddle.lakeStyle.directHint': 'La ligne la plus courte en eau libre.', + 'tours.paddle.lakeStyle.shoreHint': + 'Reste près de la rive, pour les jours de vent : plus long, mais la terre n’est jamais loin.', + 'tours.paddle.wind.shoreAdvice': + 'Jusqu’à {mean} km/h, rafales à {gusts} km/h ce jour-là. Sur les lacs, longer la rive est peut-être la meilleure ligne.', + 'tours.paddle.wind.shoreAdopt': 'Planifier près de la rive', + 'tours.paddle.wind.shoreTaken': 'Les lacs sont planifiés près de la rive.', + 'tours.paddle.status.unrouted': '{count} tronçons non planifiés', + 'tours.paddle.status.unrouted.one': '{count} tronçon non planifié', }; export default tours; diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 1b5f1476d..fdc39db01 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -909,5 +909,44 @@ const admin: TranslationStrings = { 'Δεδομένα © συντελεστές του OpenStreetMap (ODbL), τοπικά αποσπάσματα από το Geofabrik. Νέα ανάγνωση στις 3 κάθε μήνα.', 'admin.paddleRegions.toast.added': 'Η περιοχή προστέθηκε. Το TREK τη διαβάζει στο παρασκήνιο.', 'admin.paddleRegions.toast.removed': 'Η περιοχή αφαιρέθηκε', + 'admin.paddleRegions.phase.build': 'Κατασκευή δικτύου νερού', + 'admin.paddleRegions.network.km': '{km} km νερού', + 'admin.paddleRegions.network.lakes': '{count} λίμνες', + 'admin.paddleRegions.network.lakes.one': '{count} λίμνη', + 'admin.paddleRegions.network.portages': '{count} χαρτογραφημένα σημεία μεταφοράς', + 'admin.paddleRegions.network.portages.one': '{count} χαρτογραφημένο σημείο μεταφοράς', + 'admin.paddleRegions.network.inferred': '{count} εικαζόμενες μεταφορές', + 'admin.paddleRegions.network.inferred.one': '{count} εικαζόμενη μεταφορά', + 'admin.paddleRegions.network.locks': '{count} υδατοφράκτες', + 'admin.paddleRegions.network.locks.one': '{count} υδατοφράκτης', + 'admin.paddleRegions.network.gaps': '{count} γεφυρωμένα κενά', + 'admin.paddleRegions.network.gaps.one': '{count} γεφυρωμένο κενό', + 'admin.paddleRegions.network.missing': 'Χωρίς δίκτυο νερού', + 'admin.paddleRegions.network.missingHint': + 'Διάβασε ξανά την περιοχή για να δημιουργηθεί το δίκτυο που ακολουθεί η διαδρομή κουπιού.', + 'admin.paddleRegions.auto.title': 'Αυτόματη φόρτωση περιοχών', + 'admin.paddleRegions.auto.mode.auto': 'Αυτόματα', + 'admin.paddleRegions.auto.mode.ask': 'Κατόπιν αιτήματος', + 'admin.paddleRegions.auto.mode.off': 'Ανενεργό', + 'admin.paddleRegions.auto.modeHint.auto': + 'Όταν κάποιος σχεδιάζει διαδρομή κανό εκεί όπου δεν υπάρχουν ακόμη δεδομένα νερού, το TREK φορτώνει αμέσως τη μικρότερη κατάλληλη περιοχή, μέχρι το όριο μεγέθους και μόνο λίγες την ημέρα. Οι μεγαλύτερες περιμένουν εδώ ως αίτημα. Στη Geofabrik πηγαίνει μόνο το όνομα της περιοχής, ποτέ το σημείο.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Κάθε νέα περιοχή περιμένει εδώ μέχρι να την εγκρίνει ένας διαχειριστής. Όταν σχεδιάζει διαχειριστής, φορτώνεται αμέσως.', + 'admin.paddleRegions.auto.modeHint.off': + 'Το TREK δεν φορτώνει τίποτα μόνο του και δεν ζητά τίποτα. Πρόσθεσε περιοχές εδώ χειροκίνητα.', + 'admin.paddleRegions.auto.maxSize': 'Μεγαλύτερη περιοχή που φορτώνεται αυτόματα', + 'admin.paddleRegions.auto.maxSizeHint': + 'Οι μεγαλύτερες περιοχές περιμένουν εδώ ως αίτημα. Η ανάγνωση μιας περιοχής διαρκεί λίγα λεπτά και χρειάζεται μνήμη στο μεταξύ.', + 'admin.paddleRegions.requests.title': 'Αιτήματα', + 'admin.paddleRegions.requests.reason.ask': 'Κατόπιν αιτήματος', + 'admin.paddleRegions.requests.reason.too_large': 'Πολύ μεγάλη', + 'admin.paddleRegions.requests.reason.limit': 'Ημερήσιο όριο', + 'admin.paddleRegions.requests.declined': 'Απορρίφθηκε', + 'admin.paddleRegions.requests.by': 'από {user}', + 'admin.paddleRegions.requests.approve': 'Φόρτωση', + 'admin.paddleRegions.requests.decline': 'Απόρριψη', + 'admin.paddleRegions.origin.auto': 'Αυτόματα', + 'admin.paddleRegions.origin.autoHint': + 'Το TREK φόρτωσε μόνο του αυτή την περιοχή για μια διαδρομή κανό που σχεδιάζεται εκεί.', }; export default admin; diff --git a/shared/src/i18n/gr/externalNotifications.ts b/shared/src/i18n/gr/externalNotifications.ts index 97575c1db..a6d0f0b80 100644 --- a/shared/src/i18n/gr/externalNotifications.ts +++ b/shared/src/i18n/gr/externalNotifications.ts @@ -70,6 +70,10 @@ const gr: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Αίτημα περιοχής κανό', + body: `${p.actor} σχεδιάζει διαδρομή κανό στην περιοχή ${p.region}. Φόρτωσε ή απόρριψε τα δεδομένα νερού στη διαχείριση, στα Πρόσθετα.`, + }), synology_session_cleared: () => ({ title: 'Η σύνδεση Synology τερματίστηκε', body: 'Ο λογαριασμός σας Synology ή το URL άλλαξε. Έχετε αποσυνδεθεί από το Synology Photos.', diff --git a/shared/src/i18n/gr/notif.ts b/shared/src/i18n/gr/notif.ts index c8c72d202..dddd00d53 100644 --- a/shared/src/i18n/gr/notif.ts +++ b/shared/src/i18n/gr/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Η εγγραφή στο αντίγραφο '{backend}' απέτυχε: {op} του {key} — {error}", 'notif.replica_failure.textSuppressed': "Η εγγραφή στο αντίγραφο '{backend}' απέτυχε: {op} του {key} — {error}. {suppressed} επιπλέον αποτυχίες αποκρύφθηκαν από την τελευταία ειδοποίηση.", + 'notif.paddle_region_requested.title': 'Αίτημα περιοχής κανό', + 'notif.paddle_region_requested.text': '{actor} σχεδιάζει διαδρομή κανό στην περιοχή {region}', 'notif.action.view_trip': 'Προβολή Ταξιδιού', 'notif.action.view_collab': 'Προβολή Μηνυμάτων', 'notif.action.view_packing': 'Προβολή Αποσκευών', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 32edf9e5b..e915c334b 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -114,6 +114,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Ειδοποιήσεις Webhook', 'settings.notifyVersionAvailable': 'Διαθέσιμη νέα έκδοση', 'settings.notifyReplicaFailure': 'Αποτυχία αντιγράφου αποθήκευσης', + 'settings.notifyPaddleRegionRequested': 'Αίτημα περιοχής κανό', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'Εντός εφαρμογής', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index cc7a2bfd8..bd2218935 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -299,8 +299,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Δώστε ταχύτητα από 0,5 έως 20 km/h.', 'tours.paddle.chooseBoat': 'Επιλέξτε σκάφος και νερό για να αποθηκεύσετε τη διαδρομή.', 'tours.paddle.chooseBoatImport': 'Επιλέξτε σκάφος και νερό για εισαγωγή ως διαδρομή με κουπί.', - 'tours.paddle.drawnByHand': - 'Μια διαδρομή με κουπί σχεδιάζεται με το χέρι: το TREK ενώνει τα σημεία σας με ευθείες. Τίποτα δεν δρομολογείται και τίποτα δεν λέει ότι το νερό είναι πλωτό.', 'tours.paddle.timeEstimated': 'Χρόνος κωπηλασίας (εκτίμηση)', 'tours.paddle.timeEstimatedShort': 'Κωπηλασία', 'tours.paddle.timeEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος κωπηλασίας', @@ -343,6 +341,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Μεταφορά σκάφους', 'tours.paddle.portageOn': 'Μεταφορά σκάφους από εδώ', 'tours.paddle.portageOff': 'Κωπηλασία ξανά σε αυτό το τμήμα', + 'tours.paddle.portagePending': 'Μεταφορά από εδώ', + 'tours.paddle.portagePendingHint': 'Όρισε τώρα το σημείο όπου μπαίνεις ξανά στο νερό. Το σκάφος μεταφέρεται ως εκεί.', + 'tours.paddle.portagePendingOff': 'Τελικά χωρίς μεταφορά', + 'tours.paddle.portageWay': 'Διαδρομή', + 'tours.paddle.portageWay.follow': 'Ακολούθησε το μονοπάτι', + 'tours.paddle.portageWay.straight': 'Ευθεία γραμμή', + 'tours.paddle.portageWay.noWay': 'Δεν βρέθηκε δρόμος χωρίς μεγάλη παράκαμψη, η γραμμή μένει ευθεία.', + 'tours.paddle.portageWay.searching': 'Αναζήτηση δρόμου', + 'tours.paddle.placeHere.putIn': 'Μπες στο νερό εδώ', + 'tours.paddle.placeHere.egress': 'Βγες από το νερό εδώ', 'tours.paddle.portageInfo': 'Εδώ το σκάφος μεταφέρεται με τα χέρια. Το τμήμα μετράει ως στεριά, με χρόνο για έξοδο, ξεφόρτωμα και επανείσοδο στο νερό.', 'tours.paddle.portageExtra': 'Έξοδος και είσοδος', @@ -541,5 +549,67 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Η κωπηλασία ενέχει κινδύνους. Ελέγξτε μόνοι σας το νερό, τους κανόνες και τον καιρό.', 'tours.paddle.safety.text': 'Η κωπηλασία ενέχει κινδύνους. Οι χάρτες, οι προτάσεις διαδρομών και οι σημειώσεις μπορεί να είναι ελλιπείς ή παρωχημένες. Πριν από την εκδρομή, ελέγξτε τις τρέχουσες τοπικές συνθήκες, τους κανόνες χρήσης του νερού και τον καιρό. Λάβετε κατάλληλα μέτρα ασφάλειας. Το TREK υποστηρίζει τον σχεδιασμό σας και δεν αντικαθιστά τον δικό σας έλεγχο και την υπεύθυνη απόφασή σας.', + 'tours.planner.shapingPoint': 'Σημείο διαμόρφωσης', + 'tours.planner.shapingHint': + 'Κατευθύνει τη διαδρομή χωρίς να είναι στάση. Σύρε το εκεί όπου πρέπει να περνά η διαδρομή.', + 'tours.planner.makeStop': 'Κάνε το στάση', + 'tours.planner.makeShaping': 'Κάνε το σημείο διαμόρφωσης', + 'tours.planner.recomputeLeg': 'Επανυπολογισμός επόμενου τμήματος', + 'tours.planner.shapeOnLine': 'Κάνε κλικ στη διαδρομή στον χάρτη για να προσθέσεις εκεί σημείο διαμόρφωσης.', + 'tours.paddle.routeOnWater': 'Σχεδίασε το επόμενο τμήμα στο νερό', + 'tours.paddle.routeAllOnWater': 'Σχεδίασε στο νερό τα τμήματα που ζωγραφίστηκαν με το χέρι', + 'tours.paddle.auto': 'Εντοπίστηκε', + 'tours.paddle.autoHint': + 'Το TREK έβαλε αυτό το σημείο σε σημείο μεταφοράς ή υδατοφράκτη που βρήκε στον δρόμο. Αν το μετακινήσεις ή το αλλάξεις, γίνεται δικό σου.', + 'tours.paddle.note.rapids': 'Ορμητικά νερά {grade}', + 'tours.paddle.note.pass': 'Δίοδος για κανό', + 'tours.paddle.note.gap': 'Κενό στα δεδομένα του χάρτη', + 'tours.paddle.note.rapidsHint': + 'Η διαδρομή περνά εδώ από ορμητικό νερό. Εξέτασέ το πρώτα και, αν έχεις αμφιβολίες, μετέφερε το σκάφος.', + 'tours.paddle.note.gapHint': + 'Τα δεδομένα νερού έχουν εδώ κενό, που γεφυρώνεται με ευθεία γραμμή. Έλεγξέ το επί τόπου.', + 'tours.paddle.failed.not_on_water': + 'Το ένα άκρο αυτού του τμήματος δεν βρίσκεται σε νερό που γνωρίζει το TREK. Μετακίνησέ το στο ποτάμι ή στη λίμνη.', + 'tours.paddle.failed.no_region': + 'Δεν υπάρχουν ακόμη δεδομένα νερού για αυτή την περιοχή. Ένας διαχειριστής μπορεί να προσθέσει την περιοχή.', + 'tours.paddle.failed.building': + 'Τα δεδομένα νερού αυτής της περιοχής προετοιμάζονται ακόμη. Δοκίμασε ξανά σε λίγα λεπτά.', + 'tours.paddle.failed.no_water_path': 'Το TREK δεν βρήκε διαδρομή στο νερό ανάμεσα σε αυτά τα σημεία.', + 'tours.paddle.failed.cross_region': 'Αυτά τα σημεία βρίσκονται σε δύο περιοχές χωρίς κοινά δεδομένα νερού.', + 'tours.paddle.failed.busy': 'Ο σχεδιαστής διαδρομών είναι απασχολημένος. Δοκίμασε ξανά σε λίγο.', + 'tours.paddle.failed.timeout': + 'Ο σχεδιασμός αυτού του τμήματος άργησε πολύ. Πρόσθεσε ένα ενδιάμεσο σημείο και δοκίμασε ξανά.', + 'tours.paddle.failed.offline': 'Εκτός σύνδεσης. Το τμήμα θα σχεδιαστεί μόλις συνδεθείς ξανά.', + 'tours.paddle.drawStraight': 'Ευθεία γραμμή', + 'tours.paddle.retry': 'Δοκίμασε ξανά', + 'tours.paddle.load.loading': 'Φόρτωση δεδομένων νερού για {region}', + 'tours.paddle.load.ready': 'Τα δεδομένα νερού για {region} είναι εδώ, το τμήμα σχεδιάζεται.', + 'tours.paddle.load.requested': + 'Ζητήθηκαν τα δεδομένα νερού για {region}. Το τμήμα θα σχεδιαστεί μόλις τα εγκρίνει διαχειριστής.', + 'tours.paddle.load.waiting': 'Αναμονή για άλλες περιοχές', + 'tours.paddle.load.phase.download': 'Λήψη', + 'tours.paddle.load.phase.read': 'Ανάγνωση', + 'tours.paddle.load.phase.build': 'Κατασκευή δικτύου νερού', + 'tours.paddle.load.hint': 'Συνήθως ένα έως τρία λεπτά. Συνέχισε τον σχεδιασμό, το τμήμα θα σχεδιαστεί μόνο του μετά.', + 'tours.paddle.load.reason.tooLarge': 'Η περιοχή είναι μεγαλύτερη από όσο φορτώνει μόνο του το TREK.', + 'tours.paddle.load.reason.limit': 'Το TREK έχει ήδη φορτώσει αρκετές περιοχές μόνο του σήμερα.', + 'tours.paddle.load.declined': 'Ένας διαχειριστής απέρριψε τα δεδομένα νερού για {region}.', + 'tours.paddle.load.failed': 'Δεν ήταν δυνατή η φόρτωση των δεδομένων νερού για {region}.', + 'tours.paddle.load.noWater': + 'Στα δεδομένα νερού για {region} δεν υπάρχει νερό εδώ. Βάλε το σημείο στο ποτάμι ή στη λίμνη.', + 'tours.paddle.load.noExtract': 'Δεν υπάρχει απόσπασμα χάρτη με δεδομένα νερού για αυτή την περιοχή.', + 'tours.paddle.load.catalogUnavailable': 'Η λίστα περιοχών δεν είναι προσβάσιμη τώρα. Δοκίμασε ξανά αργότερα.', + 'tours.paddle.lakeStyle': 'Στις λίμνες', + 'tours.paddle.lakeStyle.direct': 'Ευθεία', + 'tours.paddle.lakeStyle.shore': 'Κοντά στην όχθη', + 'tours.paddle.lakeStyle.directHint': 'Η συντομότερη γραμμή σε ανοιχτό νερό.', + 'tours.paddle.lakeStyle.shoreHint': + 'Μένει κοντά στην όχθη, για μέρες με αέρα: μεγαλύτερη, αλλά η στεριά δεν είναι ποτέ μακριά.', + 'tours.paddle.wind.shoreAdvice': + 'Έως {mean} km/h, ριπές {gusts} km/h αυτή τη μέρα. Στις λίμνες ίσως είναι καλύτερη η γραμμή κοντά στην όχθη.', + 'tours.paddle.wind.shoreAdopt': 'Σχεδίαση κοντά στην όχθη', + 'tours.paddle.wind.shoreTaken': 'Οι λίμνες σχεδιάζονται κοντά στην όχθη.', + 'tours.paddle.status.unrouted': '{count} τμήματα χωρίς σχεδιασμό', + 'tours.paddle.status.unrouted.one': '{count} τμήμα χωρίς σχεδιασμό', }; export default tours; diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 0b38fbac6..b77897fe6 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -900,5 +900,43 @@ const admin: TranslationStrings = { 'Adatok © OpenStreetMap-közreműködők (ODbL), regionális kivonatok: Geofabrik. Újraolvasás minden hónap 3-án.', 'admin.paddleRegions.toast.added': 'Régió hozzáadva. A TREK a háttérben olvassa be.', 'admin.paddleRegions.toast.removed': 'Régió eltávolítva', + 'admin.paddleRegions.phase.build': 'Vízhálózat építése', + 'admin.paddleRegions.network.km': '{km} km víz', + 'admin.paddleRegions.network.lakes': '{count} tó', + 'admin.paddleRegions.network.lakes.one': '{count} tó', + 'admin.paddleRegions.network.portages': '{count} térképezett átemelőhely', + 'admin.paddleRegions.network.portages.one': '{count} térképezett átemelőhely', + 'admin.paddleRegions.network.inferred': '{count} feltételezett átemelés', + 'admin.paddleRegions.network.inferred.one': '{count} feltételezett átemelés', + 'admin.paddleRegions.network.locks': '{count} zsilip', + 'admin.paddleRegions.network.locks.one': '{count} zsilip', + 'admin.paddleRegions.network.gaps': '{count} áthidalt hiány', + 'admin.paddleRegions.network.gaps.one': '{count} áthidalt hiány', + 'admin.paddleRegions.network.missing': 'Nincs vízhálózat', + 'admin.paddleRegions.network.missingHint': + 'Olvasd be újra a régiót, hogy felépüljön a hálózat, amelyet az evezős útvonal követ.', + 'admin.paddleRegions.auto.title': 'Régiók automatikus betöltése', + 'admin.paddleRegions.auto.mode.auto': 'Automatikus', + 'admin.paddleRegions.auto.mode.ask': 'Kérésre', + 'admin.paddleRegions.auto.mode.off': 'Ki', + 'admin.paddleRegions.auto.modeHint.auto': + 'Ha valaki olyan helyen tervez evezős túrát, ahol még nincs vízadat, a TREK azonnal betölti a legkisebb megfelelő régiót, a méretkorlátig és naponta csak néhányat. A nagyobbak itt várnak kérésként. A Geofabrik csak a régió nevét kapja meg, a helyet soha.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Minden új régió itt vár, amíg egy admin jóvá nem hagyja. Ha admin tervez, azonnal betöltődik.', + 'admin.paddleRegions.auto.modeHint.off': + 'A TREK magától semmit nem tölt be és nem kér. A régiókat itt add hozzá kézzel.', + 'admin.paddleRegions.auto.maxSize': 'Az automatikusan betöltött legnagyobb régió', + 'admin.paddleRegions.auto.maxSizeHint': + 'A nagyobb régiók itt várnak kérésként. Egy régió beolvasása néhány percig tart, és közben memóriát foglal.', + 'admin.paddleRegions.requests.title': 'Kért', + 'admin.paddleRegions.requests.reason.ask': 'Kérésre', + 'admin.paddleRegions.requests.reason.too_large': 'Túl nagy', + 'admin.paddleRegions.requests.reason.limit': 'Napi korlát', + 'admin.paddleRegions.requests.declined': 'Elutasítva', + 'admin.paddleRegions.requests.by': '{user} kérte', + 'admin.paddleRegions.requests.approve': 'Betöltés', + 'admin.paddleRegions.requests.decline': 'Elutasítás', + 'admin.paddleRegions.origin.auto': 'Automatikus', + 'admin.paddleRegions.origin.autoHint': 'A TREK magától töltötte be ezt a régiót egy ott tervezett evezős túrához.', }; export default admin; diff --git a/shared/src/i18n/hu/externalNotifications.ts b/shared/src/i18n/hu/externalNotifications.ts index 8ea5c57cf..f01c008ac 100644 --- a/shared/src/i18n/hu/externalNotifications.ts +++ b/shared/src/i18n/hu/externalNotifications.ts @@ -67,6 +67,10 @@ const hu: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Evezős régiót kértek', + body: `${p.actor} evezős túrát tervez itt: ${p.region}. Töltsd be vagy utasítsd el a vízadatait az adminfelületen a Bővítmények alatt.`, + }), synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.', diff --git a/shared/src/i18n/hu/notif.ts b/shared/src/i18n/hu/notif.ts index 98799b314..47963a202 100644 --- a/shared/src/i18n/hu/notif.ts +++ b/shared/src/i18n/hu/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Sikertelen írás a(z) '{backend}' replikán: {op} / {key} — {error}", 'notif.replica_failure.textSuppressed': "Sikertelen írás a(z) '{backend}' replikán: {op} / {key} — {error}. Az utolsó értesítés óta {suppressed} további hiba lett elnyomva.", + 'notif.paddle_region_requested.title': 'Evezős régiót kértek', + 'notif.paddle_region_requested.text': '{actor} evezős túrát tervez itt: {region}', 'notif.action.view_trip': 'Utazás megtekintése', 'notif.action.view_collab': 'Üzenetek megtekintése', 'notif.action.view_packing': 'Csomagolás megtekintése', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 561a62de4..4ea56c596 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -302,6 +302,7 @@ const settings: TranslationStrings = { 'Állomás- / repülőtér-nevek megjelenítése a térképen. Ha ki van kapcsolva, csak az ikon látszik.', 'settings.notifyVersionAvailable': 'Új verzió elérhető', 'settings.notifyReplicaFailure': 'Tárhely-replika hiba', + 'settings.notifyPaddleRegionRequested': 'Evezős régiót kértek', 'settings.notificationPreferences.noChannels': 'Nincsenek értesítési csatornák beállítva. Kérd meg a rendszergazdát, hogy állítson be e-mail vagy webhook értesítéseket.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index d49664618..a94e09624 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -293,8 +293,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Adj meg egy tempót 0,5 és 20 km/h között.', 'tours.paddle.chooseBoat': 'Válaszd ki a hajót és a vizet a túra mentéséhez.', 'tours.paddle.chooseBoatImport': 'Válaszd ki a hajót és a vizet az evezős túraként való importáláshoz.', - 'tours.paddle.drawnByHand': - 'Az evezős túrát kézzel rajzolod: a TREK egyenes vonalakkal köti össze a pontjaidat. Semmi sincs útvonalra tervezve, és semmi sem mondja, hogy a víz járható.', 'tours.paddle.timeEstimated': 'Evezési idő (becslés)', 'tours.paddle.timeEstimatedShort': 'Evezés', 'tours.paddle.timeEstimatedInfoLabel': 'Így becsüljük az evezési időt', @@ -337,6 +335,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Átemelés', 'tours.paddle.portageOn': 'Átemelés innentől', 'tours.paddle.portageOff': 'Újra evezés ezen a szakaszon', + 'tours.paddle.portagePending': 'Innen átemelés', + 'tours.paddle.portagePendingHint': 'Most tedd le a pontot, ahol újra vízre szállsz. A hajót addig viszed.', + 'tours.paddle.portagePendingOff': 'Mégse emeld át', + 'tours.paddle.portageWay': 'Nyomvonal', + 'tours.paddle.portageWay.follow': 'Kövesd az utat', + 'tours.paddle.portageWay.straight': 'Egyenes vonal', + 'tours.paddle.portageWay.noWay': 'Nincs út nagy kerülő nélkül, a vonal egyenes marad.', + 'tours.paddle.portageWay.searching': 'Út keresése', + 'tours.paddle.placeHere.putIn': 'Itt szállj vízre', + 'tours.paddle.placeHere.egress': 'Itt szállj ki', 'tours.paddle.portageInfo': 'Itt a hajót a parton viszed. A szakasz szárazföldnek számít, a kiszállás, a kipakolás és a vízre tétel idejével együtt.', 'tours.paddle.portageExtra': 'Ki- és beszállás', @@ -533,5 +541,63 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Az evezés kockázatokkal jár. Ellenőrizd magad a vizet, a szabályokat és az időjárást.', 'tours.paddle.safety.text': 'Az evezés kockázatokkal jár. A térképek, útvonaljavaslatok és megjegyzések hiányosak vagy elavultak lehetnek. A túra előtt ellenőrizd az aktuális helyi körülményeket, a vízhasználati szabályokat és az időjárást. Tegyél megfelelő biztonsági intézkedéseket. A TREK támogatja a tervezésedet, de nem helyettesíti a saját ellenőrzésedet és felelős döntésedet.', + 'tours.planner.shapingPoint': 'Alakítópont', + 'tours.planner.shapingHint': + 'Irányítja az útvonalat anélkül, hogy megálló lenne. Húzd oda, ahol az útvonalnak haladnia kell.', + 'tours.planner.makeStop': 'Legyen megálló', + 'tours.planner.makeShaping': 'Legyen alakítópont', + 'tours.planner.recomputeLeg': 'Következő szakasz újratervezése', + 'tours.planner.shapeOnLine': 'Kattints az útvonalra a térképen, hogy oda alakítópontot tegyél.', + 'tours.paddle.routeOnWater': 'Következő szakasz tervezése a vízen', + 'tours.paddle.routeAllOnWater': 'Kézzel rajzolt szakaszok tervezése a vízen', + 'tours.paddle.auto': 'Felismert', + 'tours.paddle.autoHint': + 'A TREK egy útközben talált átemelőhelyhez vagy zsiliphez tette ezt a pontot. Ha áthelyezed vagy módosítod, a tiéd lesz.', + 'tours.paddle.note.rapids': 'Vadvíz {grade}', + 'tours.paddle.note.pass': 'Csónakcsúszda', + 'tours.paddle.note.gap': 'Hiány a térképadatokban', + 'tours.paddle.note.rapidsHint': + 'Az útvonal itt mozgó vízen halad át. Előbb nézd meg, és kétség esetén emeld át a hajót.', + 'tours.paddle.note.gapHint': 'A vízadatokban itt hiány van, egyenes vonallal áthidalva. Ellenőrizd a helyszínen.', + 'tours.paddle.failed.not_on_water': + 'A szakasz egyik vége nincs a TREK által ismert vízen. Helyezd a folyóra vagy a tóra.', + 'tours.paddle.failed.no_region': + 'Ehhez a területhez még nincsenek vízadatok. Egy rendszergazda hozzáadhatja a régiót.', + 'tours.paddle.failed.building': 'A régió vízadatai még előkészítés alatt állnak. Próbáld újra néhány perc múlva.', + 'tours.paddle.failed.no_water_path': 'A TREK nem talált vízi utat a pontok között.', + 'tours.paddle.failed.cross_region': 'Ezek a pontok két olyan régióban vannak, amelyeknek nincsenek közös vízadataik.', + 'tours.paddle.failed.busy': 'Az útvonaltervező foglalt. Próbáld újra egy pillanat múlva.', + 'tours.paddle.failed.timeout': 'A szakasz tervezése túl sokáig tartott. Tegyél közé egy pontot, és próbáld újra.', + 'tours.paddle.failed.offline': 'Nincs kapcsolat. A szakaszt akkor tervezzük meg, amikor újra csatlakozol.', + 'tours.paddle.drawStraight': 'Egyenes vonal', + 'tours.paddle.retry': 'Újra', + 'tours.paddle.load.loading': '{region} vízadatainak betöltése', + 'tours.paddle.load.ready': '{region} vízadatai megérkeztek, a szakasz tervezése folyik.', + 'tours.paddle.load.requested': + '{region} vízadatait kérték. A szakaszt akkor tervezi meg, amint egy admin jóváhagyja.', + 'tours.paddle.load.waiting': 'Más régiókra vár', + 'tours.paddle.load.phase.download': 'Letöltés', + 'tours.paddle.load.phase.read': 'Beolvasás', + 'tours.paddle.load.phase.build': 'Vízhálózat építése', + 'tours.paddle.load.hint': 'Általában egy-három perc. Tervezz nyugodtan tovább, a szakasz utána magától elkészül.', + 'tours.paddle.load.reason.tooLarge': 'A régió nagyobb annál, amit a TREK magától betölt.', + 'tours.paddle.load.reason.limit': 'A TREK ma már elég régiót töltött be magától.', + 'tours.paddle.load.declined': 'Egy admin elutasította {region} vízadatait.', + 'tours.paddle.load.failed': '{region} vízadatait nem sikerült betölteni.', + 'tours.paddle.load.noWater': '{region} vízadataiban itt nincs víz. Tedd a pontot a folyóra vagy a tóra.', + 'tours.paddle.load.noExtract': 'Ehhez a területhez nincs vízadatokat tartalmazó térképkivonat.', + 'tours.paddle.load.catalogUnavailable': 'A régiók listája most nem érhető el. Próbáld újra később.', + 'tours.paddle.lakeStyle': 'Tavakon át', + 'tours.paddle.lakeStyle.direct': 'Egyenesen', + 'tours.paddle.lakeStyle.shore': 'Part mentén', + 'tours.paddle.lakeStyle.directHint': 'A legrövidebb vonal nyílt vízen.', + 'tours.paddle.lakeStyle.shoreHint': + 'A part közelében marad, szeles napokra: hosszabb, de a szárazföld sosincs messze.', + 'tours.paddle.wind.shoreAdvice': + 'Ezen a napon akár {mean} km/h, széllökések {gusts} km/h. A tavakon a part menti vonal lehet a jobb.', + 'tours.paddle.wind.shoreAdopt': 'Tervezés a part mentén', + 'tours.paddle.wind.shoreTaken': 'A tavak a part mentén vannak megtervezve.', + 'tours.paddle.status.unrouted': '{count} szakasz nincs megtervezve', + 'tours.paddle.status.unrouted.one': '{count} szakasz nincs megtervezve', }; export default tours; diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 78adfd959..de6758918 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -877,5 +877,37 @@ const admin: TranslationStrings = { 'Data © kontributor OpenStreetMap (ODbL), ekstrak regional dari Geofabrik. Dibaca ulang setiap tanggal 3 tiap bulan.', 'admin.paddleRegions.toast.added': 'Wilayah ditambahkan. TREK membacanya di latar belakang.', 'admin.paddleRegions.toast.removed': 'Wilayah dihapus', + 'admin.paddleRegions.phase.build': 'Membangun jaringan perairan', + 'admin.paddleRegions.network.km': '{km} km perairan', + 'admin.paddleRegions.network.lakes': '{count} danau', + 'admin.paddleRegions.network.portages': '{count} tempat angkut terpetakan', + 'admin.paddleRegions.network.inferred': '{count} angkutan perkiraan', + 'admin.paddleRegions.network.locks': '{count} pintu air', + 'admin.paddleRegions.network.gaps': '{count} celah dijembatani', + 'admin.paddleRegions.network.missing': 'Tanpa jaringan perairan', + 'admin.paddleRegions.network.missingHint': 'Baca ulang wilayah untuk membangun jaringan yang diikuti rute dayung.', + 'admin.paddleRegions.auto.title': 'Muat wilayah otomatis', + 'admin.paddleRegions.auto.mode.auto': 'Otomatis', + 'admin.paddleRegions.auto.mode.ask': 'Atas permintaan', + 'admin.paddleRegions.auto.mode.off': 'Mati', + 'admin.paddleRegions.auto.modeHint.auto': + 'Saat seseorang merencanakan tur dayung di tempat yang belum punya data air, TREK langsung memuat wilayah terkecil yang cocok, sampai batas ukuran dan hanya beberapa per hari. Yang lebih besar menunggu di sini sebagai permintaan. Hanya nama wilayah yang dikirim ke Geofabrik, tidak pernah lokasinya.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Setiap wilayah baru menunggu di sini sampai admin mengizinkannya. Saat admin sendiri merencanakan, wilayah langsung dimuat.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK tidak memuat apa pun sendiri dan tidak meminta apa pun. Tambahkan wilayah di sini secara manual.', + 'admin.paddleRegions.auto.maxSize': 'Wilayah terbesar yang dimuat otomatis', + 'admin.paddleRegions.auto.maxSizeHint': + 'Wilayah yang lebih besar menunggu di sini sebagai permintaan. Membaca wilayah butuh beberapa menit dan memakai memori selama berjalan.', + 'admin.paddleRegions.requests.title': 'Diminta', + 'admin.paddleRegions.requests.reason.ask': 'Atas permintaan', + 'admin.paddleRegions.requests.reason.too_large': 'Terlalu besar', + 'admin.paddleRegions.requests.reason.limit': 'Batas harian', + 'admin.paddleRegions.requests.declined': 'Ditolak', + 'admin.paddleRegions.requests.by': 'oleh {user}', + 'admin.paddleRegions.requests.approve': 'Muat', + 'admin.paddleRegions.requests.decline': 'Tolak', + 'admin.paddleRegions.origin.auto': 'Otomatis', + 'admin.paddleRegions.origin.autoHint': 'TREK memuat wilayah ini sendiri untuk tur dayung yang direncanakan di sana.', }; export default admin; diff --git a/shared/src/i18n/id/externalNotifications.ts b/shared/src/i18n/id/externalNotifications.ts index a5b23c622..dc0c8850e 100644 --- a/shared/src/i18n/id/externalNotifications.ts +++ b/shared/src/i18n/id/externalNotifications.ts @@ -58,6 +58,10 @@ const id: NotificationLocale = { `Penulisan replika gagal pada '${p.backend}': ${p.op} dari ${p.key} — ${p.error}.` + (p.suppressed !== '0' ? ` ${p.suppressed} kegagalan lainnya tidak dilaporkan sejak notifikasi terakhir.` : ''), }), + paddle_region_requested: (p) => ({ + title: 'Wilayah dayung diminta', + body: `${p.actor} merencanakan tur dayung di ${p.region}. Muat atau tolak data airnya di area admin pada Add-on.`, + }), synology_session_cleared: () => ({ title: 'Sesi Synology dihapus', body: 'Akun atau URL Synology Anda berubah. Anda telah keluar dari Synology Photos.', diff --git a/shared/src/i18n/id/notif.ts b/shared/src/i18n/id/notif.ts index 30ac73986..439706c51 100644 --- a/shared/src/i18n/id/notif.ts +++ b/shared/src/i18n/id/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Penulisan replika gagal pada '{backend}': {op} dari {key} — {error}", 'notif.replica_failure.textSuppressed': "Penulisan replika gagal pada '{backend}': {op} dari {key} — {error}. {suppressed} kegagalan lainnya diabaikan sejak notifikasi terakhir.", + 'notif.paddle_region_requested.title': 'Wilayah dayung diminta', + 'notif.paddle_region_requested.text': '{actor} merencanakan tur dayung di {region}', 'notif.action.view_trip': 'Lihat Perjalanan', 'notif.action.view_collab': 'Lihat Pesan', 'notif.action.view_packing': 'Lihat Perlengkapan', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 8d6a98a3f..5deae9cc6 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -105,6 +105,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Notifikasi webhook', 'settings.notifyVersionAvailable': 'Versi baru tersedia', 'settings.notifyReplicaFailure': 'Kegagalan replika penyimpanan', + 'settings.notifyPaddleRegionRequested': 'Wilayah dayung diminta', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index f9ee7ef3f..e816a04ef 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -286,8 +286,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Masukkan kecepatan dari 0,5 hingga 20 km/jam.', 'tours.paddle.chooseBoat': 'Pilih perahu dan perairan untuk menyimpan tur dayung ini.', 'tours.paddle.chooseBoatImport': 'Pilih perahu dan perairan untuk mengimpor sebagai tur dayung.', - 'tours.paddle.drawnByHand': - 'Tur dayung digambar dengan tangan: TREK menghubungkan titik-titikmu dengan garis lurus. Tidak ada yang dirutekan, dan tidak ada yang menyatakan perairan bisa didayung.', 'tours.paddle.timeEstimated': 'Waktu mendayung (perkiraan)', 'tours.paddle.timeEstimatedShort': 'Mendayung', 'tours.paddle.timeEstimatedInfoLabel': 'Cara memperkirakan waktu mendayung', @@ -326,6 +324,17 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Angkut perahu', 'tours.paddle.portageOn': 'Angkut perahu dari sini', 'tours.paddle.portageOff': 'Dayung lagi ruas ini', + 'tours.paddle.portagePending': 'Pikul dari sini', + 'tours.paddle.portagePendingHint': + 'Sekarang tetapkan titik tempat kamu turun ke air lagi. Perahu dipikul sampai sana.', + 'tours.paddle.portagePendingOff': 'Batal memikul', + 'tours.paddle.portageWay': 'Jalur', + 'tours.paddle.portageWay.follow': 'Ikuti jalan setapak', + 'tours.paddle.portageWay.straight': 'Garis lurus', + 'tours.paddle.portageWay.noWay': 'Tidak ada jalan tanpa memutar jauh, garis tetap lurus.', + 'tours.paddle.portageWay.searching': 'Mencari jalan', + 'tours.paddle.placeHere.putIn': 'Turun ke air di sini', + 'tours.paddle.placeHere.egress': 'Keluar dari air di sini', 'tours.paddle.portageInfo': 'Perahu diangkut di sini. Ruas ini dihitung sebagai darat, dengan waktu untuk keluar, menurunkan muatan, dan masuk kembali ke air.', 'tours.paddle.portageExtra': 'Keluar dan masuk', @@ -515,5 +524,62 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Mendayung punya risiko. Periksa sendiri perairan, aturan, dan cuaca.', 'tours.paddle.safety.text': 'Mendayung memiliki risiko. Peta, saran rute, dan catatan bisa tidak lengkap atau sudah usang. Sebelum tur, periksa kondisi setempat terkini, aturan penggunaan perairan, dan cuaca. Ambil langkah keselamatan yang sesuai. TREK mendukung perencanaanmu dan tidak menggantikan pemeriksaanmu sendiri serta keputusanmu yang bertanggung jawab.', + 'tours.planner.shapingPoint': 'Titik bentuk', + 'tours.planner.shapingHint': 'Mengarahkan rute tanpa menjadi perhentian. Seret ke tempat rute harus lewat.', + 'tours.planner.makeStop': 'Jadikan perhentian', + 'tours.planner.makeShaping': 'Jadikan titik bentuk', + 'tours.planner.recomputeLeg': 'Hitung ulang bagian berikutnya', + 'tours.planner.shapeOnLine': 'Klik rute di peta untuk menambahkan titik bentuk di sana.', + 'tours.paddle.routeOnWater': 'Rencanakan bagian berikutnya di air', + 'tours.paddle.routeAllOnWater': 'Rencanakan di air bagian yang digambar tangan', + 'tours.paddle.auto': 'Ditemukan', + 'tours.paddle.autoHint': + 'TREK menaruh titik ini di tempat angkut perahu atau pintu air yang ditemukan di jalan. Jika kamu memindahkan atau mengubahnya, titik ini jadi milikmu.', + 'tours.paddle.note.rapids': 'Jeram {grade}', + 'tours.paddle.note.pass': 'Jalur perahu', + 'tours.paddle.note.gap': 'Celah di data peta', + 'tours.paddle.note.rapidsHint': 'Rute melewati air deras di sini. Periksa dulu dan angkut perahu jika ragu.', + 'tours.paddle.note.gapHint': 'Data perairan punya celah di sini, dijembatani garis lurus. Periksa di lokasi.', + 'tours.paddle.failed.not_on_water': + 'Salah satu ujung bagian ini tidak berada di perairan yang dikenal TREK. Pindahkan ke sungai atau danau.', + 'tours.paddle.failed.no_region': + 'Belum ada data perairan untuk area ini. Administrator dapat menambahkan wilayahnya.', + 'tours.paddle.failed.building': 'Data perairan wilayah ini masih disiapkan. Coba lagi dalam beberapa menit.', + 'tours.paddle.failed.no_water_path': 'TREK tidak menemukan jalan di air antara titik-titik ini.', + 'tours.paddle.failed.cross_region': 'Titik-titik ini berada di dua wilayah tanpa data perairan bersama.', + 'tours.paddle.failed.busy': 'Perencana rute sedang sibuk. Coba lagi sebentar lagi.', + 'tours.paddle.failed.timeout': 'Merencanakan bagian ini terlalu lama. Tambahkan titik di antaranya dan coba lagi.', + 'tours.paddle.failed.offline': 'Luring. Bagian ini akan direncanakan saat kamu tersambung lagi.', + 'tours.paddle.drawStraight': 'Gambar lurus', + 'tours.paddle.retry': 'Coba lagi', + 'tours.paddle.load.loading': 'Memuat data air untuk {region}', + 'tours.paddle.load.ready': 'Data air untuk {region} sudah ada, ruas sedang direncanakan.', + 'tours.paddle.load.requested': + 'Data air untuk {region} sudah diminta. Ruas direncanakan begitu admin mengizinkannya.', + 'tours.paddle.load.waiting': 'Menunggu wilayah lain', + 'tours.paddle.load.phase.download': 'Mengunduh', + 'tours.paddle.load.phase.read': 'Membaca', + 'tours.paddle.load.phase.build': 'Membangun jaringan air', + 'tours.paddle.load.hint': + 'Biasanya satu sampai tiga menit. Lanjutkan merencanakan, ruas akan direncanakan sendiri setelahnya.', + 'tours.paddle.load.reason.tooLarge': 'Wilayah ini lebih besar dari yang dimuat TREK sendiri.', + 'tours.paddle.load.reason.limit': 'TREK sudah memuat cukup banyak wilayah sendiri hari ini.', + 'tours.paddle.load.declined': 'Admin menolak data air untuk {region}.', + 'tours.paddle.load.failed': 'Data air untuk {region} tidak dapat dimuat.', + 'tours.paddle.load.noWater': + 'Data air untuk {region} tidak punya perairan di sini. Letakkan titik di sungai atau danau.', + 'tours.paddle.load.noExtract': 'Tidak ada potongan peta dengan data air untuk area ini.', + 'tours.paddle.load.catalogUnavailable': 'Daftar wilayah tidak dapat dijangkau saat ini. Coba lagi nanti.', + 'tours.paddle.lakeStyle': 'Melintasi danau', + 'tours.paddle.lakeStyle.direct': 'Lurus', + 'tours.paddle.lakeStyle.shore': 'Dekat tepi', + 'tours.paddle.lakeStyle.directHint': 'Garis terpendek melintasi perairan terbuka.', + 'tours.paddle.lakeStyle.shoreHint': + 'Tetap dekat tepi, untuk hari berangin: lebih panjang, tetapi daratan tak pernah jauh.', + 'tours.paddle.wind.shoreAdvice': + 'Hingga {mean} km/h, embusan {gusts} km/h pada hari ini. Di danau, tetap dekat tepi mungkin jalur yang lebih baik.', + 'tours.paddle.wind.shoreAdopt': 'Rencanakan dekat tepi', + 'tours.paddle.wind.shoreTaken': 'Danau direncanakan dekat tepi.', + 'tours.paddle.status.unrouted': '{count} bagian belum direncanakan', }; export default tours; diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index ae497289b..51eeb5e14 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -894,5 +894,43 @@ const admin: TranslationStrings = { 'Dati © contributori di OpenStreetMap (ODbL), estratti regionali di Geofabrik. Riletti il 3 di ogni mese.', 'admin.paddleRegions.toast.added': 'Regione aggiunta. TREK la legge in background.', 'admin.paddleRegions.toast.removed': 'Regione rimossa', + 'admin.paddleRegions.phase.build': 'Costruzione della rete delle acque', + 'admin.paddleRegions.network.km': '{km} km di acque', + 'admin.paddleRegions.network.lakes': '{count} laghi', + 'admin.paddleRegions.network.lakes.one': '{count} lago', + 'admin.paddleRegions.network.portages': '{count} trasbordi mappati', + 'admin.paddleRegions.network.portages.one': '{count} trasbordo mappato', + 'admin.paddleRegions.network.inferred': '{count} trasbordi presunti', + 'admin.paddleRegions.network.inferred.one': '{count} trasbordo presunto', + 'admin.paddleRegions.network.locks': '{count} chiuse', + 'admin.paddleRegions.network.locks.one': '{count} chiusa', + 'admin.paddleRegions.network.gaps': '{count} lacune colmate', + 'admin.paddleRegions.network.gaps.one': '{count} lacuna colmata', + 'admin.paddleRegions.network.missing': 'Nessuna rete delle acque', + 'admin.paddleRegions.network.missingHint': + 'Rileggi la regione per costruire la rete che segue il percorso in pagaia.', + 'admin.paddleRegions.auto.title': 'Carica le regioni automaticamente', + 'admin.paddleRegions.auto.mode.auto': 'Automatico', + 'admin.paddleRegions.auto.mode.ask': 'Su richiesta', + 'admin.paddleRegions.auto.mode.off': 'Spento', + 'admin.paddleRegions.auto.modeHint.auto': + "Quando qualcuno pianifica un giro in canoa dove non ci sono ancora dati sull'acqua, TREK carica subito la regione più piccola adatta, fino al limite di dimensione e solo poche al giorno. Quelle più grandi aspettano qui come richiesta. A Geofabrik arriva solo il nome della regione, mai il luogo.", + 'admin.paddleRegions.auto.modeHint.ask': + 'Ogni nuova regione aspetta qui finché un amministratore non la approva. Se pianifica un amministratore, si carica subito.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK non carica nulla da solo e non chiede nulla. Aggiungi le regioni qui a mano.', + 'admin.paddleRegions.auto.maxSize': 'Regione più grande caricata automaticamente', + 'admin.paddleRegions.auto.maxSizeHint': + 'Le regioni più grandi aspettano qui come richiesta. Leggere una regione richiede qualche minuto e occupa memoria nel frattempo.', + 'admin.paddleRegions.requests.title': 'Richieste', + 'admin.paddleRegions.requests.reason.ask': 'Su richiesta', + 'admin.paddleRegions.requests.reason.too_large': 'Troppo grande', + 'admin.paddleRegions.requests.reason.limit': 'Limite giornaliero', + 'admin.paddleRegions.requests.declined': 'Rifiutata', + 'admin.paddleRegions.requests.by': 'da {user}', + 'admin.paddleRegions.requests.approve': 'Carica', + 'admin.paddleRegions.requests.decline': 'Rifiuta', + 'admin.paddleRegions.origin.auto': 'Automatico', + 'admin.paddleRegions.origin.autoHint': 'TREK ha caricato questa regione da solo per un giro in canoa pianificato lì.', }; export default admin; diff --git a/shared/src/i18n/it/externalNotifications.ts b/shared/src/i18n/it/externalNotifications.ts index f2a9021f1..1bcc6940b 100644 --- a/shared/src/i18n/it/externalNotifications.ts +++ b/shared/src/i18n/it/externalNotifications.ts @@ -64,6 +64,10 @@ const it: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Regione canoa richiesta', + body: `${p.actor} sta pianificando un giro in canoa in ${p.region}. Carica o rifiuta i suoi dati sull'acqua nell'area amministrazione, sotto Componenti.`, + }), synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.', diff --git a/shared/src/i18n/it/notif.ts b/shared/src/i18n/it/notif.ts index 917e627bb..5bdab4356 100644 --- a/shared/src/i18n/it/notif.ts +++ b/shared/src/i18n/it/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Scrittura sulla replica non riuscita su '{backend}': {op} di {key} — {error}", 'notif.replica_failure.textSuppressed': "Scrittura sulla replica non riuscita su '{backend}': {op} di {key} — {error}. Altri {suppressed} errori sono stati soppressi dall'ultima notifica.", + 'notif.paddle_region_requested.title': 'Regione canoa richiesta', + 'notif.paddle_region_requested.text': '{actor} sta pianificando un giro in canoa in {region}', 'notif.action.view_trip': 'Vedi viaggio', 'notif.action.view_collab': 'Vedi messaggi', 'notif.action.view_packing': 'Vedi bagagli', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 7c3fb089c..61db47482 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -300,6 +300,7 @@ const settings: TranslationStrings = { "Mostra i nomi di stazioni / aeroporti sulla mappa. Se disattivato, viene mostrata solo l'icona.", 'settings.notifyVersionAvailable': 'Nuova versione disponibile', 'settings.notifyReplicaFailure': "Errore di replica dell'archiviazione", + 'settings.notifyPaddleRegionRequested': 'Regione canoa richiesta', 'settings.notificationPreferences.noChannels': 'Nessun canale di notifica configurato. Chiedi a un amministratore di configurare notifiche via e-mail o webhook.', 'settings.webhookUrl.label': 'URL webhook', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index bf2419549..bf0ec5cc2 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -293,8 +293,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Inserisci una velocità da 0,5 a 20 km/h.', 'tours.paddle.chooseBoat': 'Scegli imbarcazione e acqua per salvare questa uscita.', 'tours.paddle.chooseBoatImport': 'Scegli imbarcazione e acqua per importare come uscita in pagaia.', - 'tours.paddle.drawnByHand': - 'Un’uscita in pagaia si disegna a mano: TREK collega i tuoi punti con linee rette. Niente viene calcolato e niente dice che l’acqua sia navigabile.', 'tours.paddle.timeEstimated': 'Tempo di pagaiata (stimato)', 'tours.paddle.timeEstimatedShort': 'Pagaiata', 'tours.paddle.timeEstimatedInfoLabel': 'Come si stima il tempo di pagaiata', @@ -337,6 +335,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Trasbordo', 'tours.paddle.portageOn': 'Trasporta l’imbarcazione da qui', 'tours.paddle.portageOff': 'Torna a pagaiare su questo tratto', + 'tours.paddle.portagePending': 'Trasbordo da qui', + 'tours.paddle.portagePendingHint': 'Ora imposta il punto in cui rientri in acqua. La barca viene portata fin lì.', + 'tours.paddle.portagePendingOff': 'Niente trasbordo', + 'tours.paddle.portageWay': 'Percorso', + 'tours.paddle.portageWay.follow': 'Segui il sentiero', + 'tours.paddle.portageWay.straight': 'Linea retta', + 'tours.paddle.portageWay.noWay': 'Nessun sentiero senza una lunga deviazione, la linea resta retta.', + 'tours.paddle.portageWay.searching': 'Ricerca di un sentiero', + 'tours.paddle.placeHere.putIn': 'Entra in acqua qui', + 'tours.paddle.placeHere.egress': "Esci dall'acqua qui", 'tours.paddle.portageInfo': 'Qui l’imbarcazione viene trasportata. Il tratto conta come terraferma, con il tempo per sbarcare, scaricare e reimbarcarsi.', 'tours.paddle.portageExtra': 'Sbarco e reimbarco', @@ -535,5 +543,67 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Pagaiare comporta dei rischi. Controlla di persona acqua, regole e meteo.', 'tours.paddle.safety.text': 'Pagaiare comporta dei rischi. Mappe, proposte di percorso e indicazioni possono essere incomplete o non aggiornate. Prima dell’uscita controlla le condizioni locali attuali, le regole di navigazione dello specchio d’acqua e il meteo. Adotta misure di sicurezza adeguate. TREK supporta la tua pianificazione e non sostituisce il tuo controllo personale né la tua decisione responsabile.', + 'tours.planner.shapingPoint': 'Punto di forma', + 'tours.planner.shapingHint': 'Guida il percorso senza essere una tappa. Trascinalo dove deve passare il percorso.', + 'tours.planner.makeStop': 'Rendilo una tappa', + 'tours.planner.makeShaping': 'Rendilo un punto di forma', + 'tours.planner.recomputeLeg': 'Ricalcola il tratto successivo', + 'tours.planner.shapeOnLine': 'Fai clic sul percorso nella mappa per aggiungervi un punto di forma.', + 'tours.paddle.routeOnWater': 'Pianifica il tratto successivo sull’acqua', + 'tours.paddle.routeAllOnWater': 'Pianifica sull’acqua i tratti disegnati a mano', + 'tours.paddle.auto': 'Rilevato', + 'tours.paddle.autoHint': + 'TREK ha messo questo punto a un trasbordo o a una chiusa trovati lungo il percorso. Se lo sposti o lo modifichi, diventa tuo.', + 'tours.paddle.note.rapids': 'Acque bianche {grade}', + 'tours.paddle.note.pass': 'Scivolo per canoe', + 'tours.paddle.note.gap': 'Lacuna nei dati della mappa', + 'tours.paddle.note.rapidsHint': + 'Qui il percorso attraversa acque mosse. Ispeziona prima e trasborda in caso di dubbio.', + 'tours.paddle.note.gapHint': + 'I dati sulle acque hanno qui una lacuna, colmata con una linea retta. Verifica sul posto.', + 'tours.paddle.failed.not_on_water': + 'Un estremo di questo tratto non è su nessuna acqua nota a TREK. Spostalo sul fiume o sul lago.', + 'tours.paddle.failed.no_region': + 'Non ci sono ancora dati sulle acque per questa zona. Un amministratore può aggiungere la regione.', + 'tours.paddle.failed.building': + 'I dati sulle acque di questa regione sono ancora in preparazione. Riprova tra qualche minuto.', + 'tours.paddle.failed.no_water_path': 'TREK non ha trovato un percorso sull’acqua tra questi punti.', + 'tours.paddle.failed.cross_region': 'Questi punti si trovano in due regioni senza dati sulle acque in comune.', + 'tours.paddle.failed.busy': 'Il pianificatore di percorsi è occupato. Riprova tra un momento.', + 'tours.paddle.failed.timeout': + 'La pianificazione di questo tratto ha richiesto troppo tempo. Aggiungi un punto intermedio e riprova.', + 'tours.paddle.failed.offline': 'Nessuna connessione. Questo tratto verrà pianificato appena sarai di nuovo connesso.', + 'tours.paddle.drawStraight': 'Traccia dritto', + 'tours.paddle.retry': 'Riprova', + 'tours.paddle.load.loading': "Caricamento dei dati sull'acqua di {region}", + 'tours.paddle.load.ready': "I dati sull'acqua di {region} sono arrivati, il tratto viene pianificato.", + 'tours.paddle.load.requested': + "I dati sull'acqua di {region} sono stati richiesti. Il tratto viene pianificato appena un amministratore li approva.", + 'tours.paddle.load.waiting': 'In attesa di altre regioni', + 'tours.paddle.load.phase.download': 'Download', + 'tours.paddle.load.phase.read': 'Lettura', + 'tours.paddle.load.phase.build': "Costruzione della rete d'acqua", + 'tours.paddle.load.hint': + 'Di solito da uno a tre minuti. Continua a pianificare, il tratto si pianifica da solo dopo.', + 'tours.paddle.load.reason.tooLarge': 'La regione è più grande di quanto TREK carica da solo.', + 'tours.paddle.load.reason.limit': 'Oggi TREK ha già caricato abbastanza regioni da solo.', + 'tours.paddle.load.declined': "Un amministratore ha rifiutato i dati sull'acqua di {region}.", + 'tours.paddle.load.failed': "Impossibile caricare i dati sull'acqua di {region}.", + 'tours.paddle.load.noWater': + "Nei dati sull'acqua di {region} qui non c'è acqua. Metti il punto sul fiume o sul lago.", + 'tours.paddle.load.noExtract': "Non esiste un estratto di mappa con dati sull'acqua per questa zona.", + 'tours.paddle.load.catalogUnavailable': "L'elenco delle regioni non è raggiungibile al momento. Riprova più tardi.", + 'tours.paddle.lakeStyle': 'Sui laghi', + 'tours.paddle.lakeStyle.direct': 'In linea retta', + 'tours.paddle.lakeStyle.shore': 'Vicino alla riva', + 'tours.paddle.lakeStyle.directHint': 'La linea più breve in acque aperte.', + 'tours.paddle.lakeStyle.shoreHint': + 'Resta vicino alla riva, per le giornate ventose: più lungo, ma la terra non è mai lontana.', + 'tours.paddle.wind.shoreAdvice': + 'Fino a {mean} km/h, raffiche a {gusts} km/h in questo giorno. Sui laghi restare vicino alla riva potrebbe essere la linea migliore.', + 'tours.paddle.wind.shoreAdopt': 'Pianifica vicino alla riva', + 'tours.paddle.wind.shoreTaken': 'I laghi sono pianificati vicino alla riva.', + 'tours.paddle.status.unrouted': '{count} tratti non pianificati', + 'tours.paddle.status.unrouted.one': '{count} tratto non pianificato', }; export default tours; diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 701ffc7db..44cf1a07a 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -836,5 +836,38 @@ const admin: TranslationStrings = { 'データ © OpenStreetMap 貢献者(ODbL)、地域別の抽出データは Geofabrik によるものです。毎月 3 日に読み直します。', 'admin.paddleRegions.toast.added': '地域を追加しました。TREK がバックグラウンドで読み込みます。', 'admin.paddleRegions.toast.removed': '地域を削除しました', + 'admin.paddleRegions.phase.build': '水路ネットワークを構築中', + 'admin.paddleRegions.network.km': '水域 {km} km', + 'admin.paddleRegions.network.lakes': '湖 {count} 件', + 'admin.paddleRegions.network.portages': '地図上のポーテージ {count} 件', + 'admin.paddleRegions.network.inferred': '推定の担ぎ {count} 件', + 'admin.paddleRegions.network.locks': '閘門 {count} 件', + 'admin.paddleRegions.network.gaps': 'つないだ欠落 {count} 件', + 'admin.paddleRegions.network.missing': '水路ネットワークなし', + 'admin.paddleRegions.network.missingHint': + '地域を読み込み直すと、パドリングのルートが使うネットワークが構築されます。', + 'admin.paddleRegions.auto.title': '地域を自動で読み込む', + 'admin.paddleRegions.auto.mode.auto': '自動', + 'admin.paddleRegions.auto.mode.ask': 'リクエスト制', + 'admin.paddleRegions.auto.mode.off': 'オフ', + 'admin.paddleRegions.auto.modeHint.auto': + '水域データのない場所でパドルツアーが計画されると、TREKは該当する最小の地域をすぐに読み込みます。サイズ上限まで、1日に数件だけです。それより大きいものはここでリクエストとして待ちます。Geofabrikに送られるのは地域名だけで、場所は送られません。', + 'admin.paddleRegions.auto.modeHint.ask': + '新しい地域は管理者が許可するまでここで待ちます。管理者自身が計画した場合はすぐに読み込みます。', + 'admin.paddleRegions.auto.modeHint.off': + 'TREKは自分では何も読み込まず、リクエストも出しません。地域はここで手動で追加してください。', + 'admin.paddleRegions.auto.maxSize': '自動で読み込む最大の地域', + 'admin.paddleRegions.auto.maxSizeHint': + 'より大きい地域はここでリクエストとして待ちます。地域の読み込みには数分かかり、その間メモリを使います。', + 'admin.paddleRegions.requests.title': 'リクエスト', + 'admin.paddleRegions.requests.reason.ask': 'リクエスト制', + 'admin.paddleRegions.requests.reason.too_large': '大きすぎ', + 'admin.paddleRegions.requests.reason.limit': '1日の上限', + 'admin.paddleRegions.requests.declined': '却下', + 'admin.paddleRegions.requests.by': '{user}より', + 'admin.paddleRegions.requests.approve': '読み込む', + 'admin.paddleRegions.requests.decline': '却下する', + 'admin.paddleRegions.origin.auto': '自動', + 'admin.paddleRegions.origin.autoHint': 'この地域は、そこで計画されたパドルツアーのためにTREKが自動で読み込みました。', }; export default admin; diff --git a/shared/src/i18n/ja/externalNotifications.ts b/shared/src/i18n/ja/externalNotifications.ts index ffc93d14d..a6583aac9 100644 --- a/shared/src/i18n/ja/externalNotifications.ts +++ b/shared/src/i18n/ja/externalNotifications.ts @@ -58,6 +58,10 @@ const ja: NotificationLocale = { `レプリカ '${p.backend}' への書き込みに失敗しました:${p.op} / ${p.key} — ${p.error}。` + (p.suppressed !== '0' ? `前回の通知以降、${p.suppressed}件の追加の失敗が抑制されました。` : ''), }), + paddle_region_requested: (p) => ({ + title: 'パドル地域のリクエスト', + body: `${p.actor}が${p.region}でパドルツアーを計画しています。管理画面のアドオンで水域データを読み込むか却下してください。`, + }), synology_session_cleared: () => ({ title: 'Synologyセッションがクリアされました', body: 'SynologyアカウントまたはURLが変更されました。Synology Photosからログアウトされました。', diff --git a/shared/src/i18n/ja/notif.ts b/shared/src/i18n/ja/notif.ts index 9869f22dd..2a732d635 100644 --- a/shared/src/i18n/ja/notif.ts +++ b/shared/src/i18n/ja/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "レプリカ '{backend}' への書き込みに失敗しました:{op} / {key} — {error}", 'notif.replica_failure.textSuppressed': "レプリカ '{backend}' への書き込みに失敗しました:{op} / {key} — {error}。前回の通知以降、{suppressed}件の追加の失敗が抑制されました。", + 'notif.paddle_region_requested.title': 'パドル地域のリクエスト', + 'notif.paddle_region_requested.text': '{actor}が{region}でパドルツアーを計画しています', 'notif.action.view_trip': '旅行を見る', 'notif.action.view_collab': 'メッセージを見る', 'notif.action.view_packing': '持ち物を見る', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 0a6a133ac..bb38c19de 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -106,6 +106,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook通知', 'settings.notifyVersionAvailable': '新しいバージョン', 'settings.notifyReplicaFailure': 'ストレージレプリカの障害', + 'settings.notifyPaddleRegionRequested': 'パドル地域のリクエスト', 'settings.notificationPreferences.email': 'メール', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'アプリ内', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 9c1988874..6b7a5aa16 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -280,8 +280,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '0.5〜20 km/h の速度を入力してください。', 'tours.paddle.chooseBoat': 'このパドルツアーを保存するには、ボートと水域を選んでください。', 'tours.paddle.chooseBoatImport': 'パドルツアーとして取り込むには、ボートと水域を選んでください。', - 'tours.paddle.drawnByHand': - 'パドルツアーは手で描きます。TREK はポイントを直線で結ぶだけで、ルート計算はせず、その水域を漕げることも示しません。', 'tours.paddle.timeEstimated': 'パドル時間(推定)', 'tours.paddle.timeEstimatedShort': 'パドル', 'tours.paddle.timeEstimatedInfoLabel': 'パドル時間の見積もり方', @@ -320,6 +318,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': '陸上運搬', 'tours.paddle.portageOn': 'ここからボートを運ぶ', 'tours.paddle.portageOff': 'この区間を再び漕ぐ', + 'tours.paddle.portagePending': 'ここから陸上運搬', + 'tours.paddle.portagePendingHint': '再び水に出る地点を設定してください。そこまで艇を運びます。', + 'tours.paddle.portagePendingOff': '陸上運搬をやめる', + 'tours.paddle.portageWay': '経路', + 'tours.paddle.portageWay.follow': '道に沿う', + 'tours.paddle.portageWay.straight': '直線', + 'tours.paddle.portageWay.noWay': '大きく遠回りしない道が見つからないため、直線のままです。', + 'tours.paddle.portageWay.searching': '道を探しています', + 'tours.paddle.placeHere.putIn': 'ここから出艇', + 'tours.paddle.placeHere.egress': 'ここで上陸', 'tours.paddle.portageInfo': 'ここではボートを運びます。この区間は陸路として扱い、上陸、荷降ろし、再出艇の時間を加えます。', 'tours.paddle.portageExtra': '上陸と再出艇', @@ -507,5 +515,58 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'パドリングには危険が伴います。水域、ルール、天候は自分で確認してください。', 'tours.paddle.safety.text': 'パドリングには危険が伴います。地図、ルートの提案、注意事項は不完全だったり古くなっていたりすることがあります。ツアーの前に、現地の最新の状況、水域の利用ルール、天候を確認してください。適切な安全対策をとってください。TREK はあなたの計画を支援するものであり、自分自身での確認と責任ある判断に代わるものではありません。', + 'tours.planner.shapingPoint': '形状ポイント', + 'tours.planner.shapingHint': '停留地点にならずにルートを導きます。ルートを通したい場所へドラッグしてください。', + 'tours.planner.makeStop': '停留地点にする', + 'tours.planner.makeShaping': '形状ポイントにする', + 'tours.planner.recomputeLeg': '次の区間を再計算', + 'tours.planner.shapeOnLine': '地図上のルートをクリックすると、そこに形状ポイントを追加できます。', + 'tours.paddle.routeOnWater': '次の区間を水上で計画', + 'tours.paddle.routeAllOnWater': '手描きの区間を水上で計画', + 'tours.paddle.auto': '検出', + 'tours.paddle.autoHint': + 'TREK が途中で見つけたポーテージまたは閘門にこの地点を置きました。移動や変更をすると、あなたの地点になります。', + 'tours.paddle.note.rapids': '急流 {grade} 級', + 'tours.paddle.note.pass': 'カヌー水路', + 'tours.paddle.note.gap': '地図データの欠落', + 'tours.paddle.note.rapidsHint': + 'ここでルートは流れのある水域を通ります。先に下見をし、迷ったら担いで迂回してください。', + 'tours.paddle.note.gapHint': 'ここは水域データに欠落があり、直線でつないでいます。現地で確認してください。', + 'tours.paddle.failed.not_on_water': + 'この区間の端の一方が、TREK が知る水域上にありません。川か湖の上に移動してください。', + 'tours.paddle.failed.no_region': 'この地域の水域データはまだありません。管理者が地域を追加できます。', + 'tours.paddle.failed.building': 'この地域の水域データはまだ準備中です。数分後にもう一度お試しください。', + 'tours.paddle.failed.no_water_path': 'TREK はこれらの地点の間に水上の経路を見つけられませんでした。', + 'tours.paddle.failed.cross_region': 'これらの地点は、水域データを共有しない 2 つの地域にあります。', + 'tours.paddle.failed.busy': 'ルートプランナーが混み合っています。少ししてからもう一度お試しください。', + 'tours.paddle.failed.timeout': 'この区間の計画に時間がかかりすぎました。間に地点を追加して、もう一度お試しください。', + 'tours.paddle.failed.offline': 'オフラインです。再接続するとこの区間が計画されます。', + 'tours.paddle.drawStraight': '直線で描く', + 'tours.paddle.retry': '再試行', + 'tours.paddle.load.loading': '{region}の水域データを読み込み中', + 'tours.paddle.load.ready': '{region}の水域データが届きました。区間を計画しています。', + 'tours.paddle.load.requested': '{region}の水域データをリクエストしました。管理者が許可するとすぐに区間を計画します。', + 'tours.paddle.load.waiting': '他の地域を待っています', + 'tours.paddle.load.phase.download': 'ダウンロード中', + 'tours.paddle.load.phase.read': '読み取り中', + 'tours.paddle.load.phase.build': '水域ネットワークを構築中', + 'tours.paddle.load.hint': '通常1〜3分です。そのまま計画を続けてください。区間は後で自動的に計画されます。', + 'tours.paddle.load.reason.tooLarge': 'この地域はTREKが自動で読み込む大きさを超えています。', + 'tours.paddle.load.reason.limit': 'TREKは今日すでに十分な数の地域を自動で読み込みました。', + 'tours.paddle.load.declined': '管理者が{region}の水域データを却下しました。', + 'tours.paddle.load.failed': '{region}の水域データを読み込めませんでした。', + 'tours.paddle.load.noWater': '{region}の水域データでは、ここに水域がありません。地点を川か湖の上に置いてください。', + 'tours.paddle.load.noExtract': 'この地域の水域データを含む地図抽出はありません。', + 'tours.paddle.load.catalogUnavailable': '地域の一覧に今は接続できません。後でもう一度お試しください。', + 'tours.paddle.lakeStyle': '湖の横断', + 'tours.paddle.lakeStyle.direct': 'まっすぐ', + 'tours.paddle.lakeStyle.shore': '岸沿い', + 'tours.paddle.lakeStyle.directHint': '開けた水面を最短で横切る線。', + 'tours.paddle.lakeStyle.shoreHint': '岸の近くを進みます。風の強い日向けで、長くなりますが陸から離れません。', + 'tours.paddle.wind.shoreAdvice': + 'この日は最大 {mean} km/h、突風 {gusts} km/h。湖では岸沿いのほうがよいかもしれません。', + 'tours.paddle.wind.shoreAdopt': '岸沿いで計画', + 'tours.paddle.wind.shoreTaken': '湖は岸沿いで計画されています。', + 'tours.paddle.status.unrouted': '未計画の区間 {count} 件', }; export default tours; diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 8a4bd4901..2ef9aca99 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -831,5 +831,37 @@ const admin: TranslationStrings = { '데이터 © OpenStreetMap 기여자 (ODbL), 지역별 추출본은 Geofabrik 제공. 매달 3일에 다시 읽습니다.', 'admin.paddleRegions.toast.added': '지역을 추가했습니다. TREK이 백그라운드에서 읽습니다.', 'admin.paddleRegions.toast.removed': '지역을 삭제했습니다', + 'admin.paddleRegions.phase.build': '수로망 구축 중', + 'admin.paddleRegions.network.km': '수역 {km} km', + 'admin.paddleRegions.network.lakes': '호수 {count}개', + 'admin.paddleRegions.network.portages': '지도에 있는 육로 운반 지점 {count}개', + 'admin.paddleRegions.network.inferred': '추정 운반 {count}개', + 'admin.paddleRegions.network.locks': '갑문 {count}개', + 'admin.paddleRegions.network.gaps': '이은 공백 {count}개', + 'admin.paddleRegions.network.missing': '수로망 없음', + 'admin.paddleRegions.network.missingHint': '지역을 다시 읽어 패들링 경로가 따르는 수로망을 구축하세요.', + 'admin.paddleRegions.auto.title': '지역 자동으로 불러오기', + 'admin.paddleRegions.auto.mode.auto': '자동', + 'admin.paddleRegions.auto.mode.ask': '요청 시', + 'admin.paddleRegions.auto.mode.off': '끄기', + 'admin.paddleRegions.auto.modeHint.auto': + '아직 수역 데이터가 없는 곳에서 누군가 패들 투어를 계획하면 TREK이 알맞은 가장 작은 지역을 바로 불러옵니다. 크기 한도까지, 하루에 몇 개만입니다. 더 큰 지역은 여기에서 요청으로 기다립니다. Geofabrik에는 지역 이름만 전달되며 위치는 절대 전달되지 않습니다.', + 'admin.paddleRegions.auto.modeHint.ask': + '새 지역은 관리자가 허용할 때까지 여기에서 기다립니다. 관리자가 직접 계획하면 바로 불러옵니다.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK은 스스로 아무것도 불러오지 않고 요청도 하지 않습니다. 지역은 여기에서 직접 추가하세요.', + 'admin.paddleRegions.auto.maxSize': '자동으로 불러오는 최대 지역', + 'admin.paddleRegions.auto.maxSizeHint': + '더 큰 지역은 여기에서 요청으로 기다립니다. 지역을 읽는 데 몇 분이 걸리며 그동안 메모리를 사용합니다.', + 'admin.paddleRegions.requests.title': '요청됨', + 'admin.paddleRegions.requests.reason.ask': '요청 시', + 'admin.paddleRegions.requests.reason.too_large': '너무 큼', + 'admin.paddleRegions.requests.reason.limit': '일일 한도', + 'admin.paddleRegions.requests.declined': '거절됨', + 'admin.paddleRegions.requests.by': '{user} 요청', + 'admin.paddleRegions.requests.approve': '불러오기', + 'admin.paddleRegions.requests.decline': '거절', + 'admin.paddleRegions.origin.auto': '자동', + 'admin.paddleRegions.origin.autoHint': 'TREK이 그곳에서 계획된 패들 투어를 위해 이 지역을 스스로 불러왔습니다.', }; export default admin; diff --git a/shared/src/i18n/ko/externalNotifications.ts b/shared/src/i18n/ko/externalNotifications.ts index 4be6c9868..68357f298 100644 --- a/shared/src/i18n/ko/externalNotifications.ts +++ b/shared/src/i18n/ko/externalNotifications.ts @@ -58,6 +58,10 @@ const ko: NotificationLocale = { `복제본 '${p.backend}'에 쓰기가 실패했습니다: ${p.op} / ${p.key} — ${p.error}.` + (p.suppressed !== '0' ? ` 마지막 알림 이후 실패가 ${p.suppressed}건 더 있었지만 따로 알리지 않았습니다.` : ''), }), + paddle_region_requested: (p) => ({ + title: '패들 지역 요청', + body: `${p.actor}님이 ${p.region}에서 패들 투어를 계획하고 있습니다. 관리 화면의 애드온에서 수역 데이터를 불러오거나 거절하세요.`, + }), synology_session_cleared: () => ({ title: 'Synology 세션이 초기화되었습니다', body: 'Synology 계정 또는 URL이 변경되었습니다. Synology Photos에서 로그아웃되었습니다.', diff --git a/shared/src/i18n/ko/notif.ts b/shared/src/i18n/ko/notif.ts index 48c81e797..aecaf16ef 100644 --- a/shared/src/i18n/ko/notif.ts +++ b/shared/src/i18n/ko/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "복제본 '{backend}'에 쓰기가 실패했습니다: {op} / {key} — {error}", 'notif.replica_failure.textSuppressed': "복제본 '{backend}'에 쓰기가 실패했습니다: {op} / {key} — {error}.마지막 알림 이후 {suppressed}개의 추가 실패가 억제되었습니다.", + 'notif.paddle_region_requested.title': '패들 지역 요청', + 'notif.paddle_region_requested.text': '{actor}님이 {region}에서 패들 투어를 계획하고 있습니다', 'notif.action.view_trip': '여행 보기', 'notif.action.view_collab': '메시지 보기', 'notif.action.view_packing': '짐 목록 보기', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 6c2202a0a..6bc44fdcd 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -106,6 +106,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': '웹훅 알림', 'settings.notifyVersionAvailable': '새 버전 사용 가능', 'settings.notifyReplicaFailure': '스토리지 복제본 오류', + 'settings.notifyPaddleRegionRequested': '패들 지역 요청', 'settings.notificationPreferences.email': '이메일', 'settings.notificationPreferences.webhook': '웹훅', 'settings.notificationPreferences.inapp': '앱 내', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 7e2a1a7fe..dce82fb1a 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -278,8 +278,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '0.5~20 km/h 사이의 속도를 입력하세요.', 'tours.paddle.chooseBoat': '이 패들 투어를 저장하려면 보트와 수역을 선택하세요.', 'tours.paddle.chooseBoatImport': '패들 투어로 가져오려면 보트와 수역을 선택하세요.', - 'tours.paddle.drawnByHand': - '패들 투어는 손으로 그립니다. TREK은 지점들을 직선으로 이을 뿐 경로를 계산하지 않으며, 그 물길을 지날 수 있다고 알려주지도 않습니다.', 'tours.paddle.timeEstimated': '패들 시간(추정)', 'tours.paddle.timeEstimatedShort': '패들', 'tours.paddle.timeEstimatedInfoLabel': '패들 시간을 추정하는 방법', @@ -317,6 +315,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': '육상 운반', 'tours.paddle.portageOn': '여기부터 육상 운반', 'tours.paddle.portageOff': '이 구간 다시 패들링', + 'tours.paddle.portagePending': '여기서부터 운반', + 'tours.paddle.portagePendingHint': '이제 다시 물에 들어갈 지점을 정하세요. 그곳까지 배를 운반합니다.', + 'tours.paddle.portagePendingOff': '운반 취소', + 'tours.paddle.portageWay': '경로', + 'tours.paddle.portageWay.follow': '길 따라가기', + 'tours.paddle.portageWay.straight': '직선', + 'tours.paddle.portageWay.noWay': '크게 돌아가지 않는 길이 없어 직선으로 유지됩니다.', + 'tours.paddle.portageWay.searching': '길을 찾는 중', + 'tours.paddle.placeHere.putIn': '여기서 입수', + 'tours.paddle.placeHere.egress': '여기서 출수', 'tours.paddle.portageInfo': '여기서는 보트를 들고 옮깁니다. 이 구간은 육상으로 계산되며, 물에서 나오고 짐을 내리고 다시 물에 띄우는 시간이 포함됩니다.', 'tours.paddle.portageExtra': '출수와 입수', @@ -504,5 +512,58 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': '패들링에는 위험이 따릅니다. 물길, 규칙, 날씨를 직접 확인하세요.', 'tours.paddle.safety.text': '패들링에는 위험이 따릅니다. 지도, 경로 제안, 안내 사항은 불완전하거나 오래되었을 수 있습니다. 투어 전에 현재 현지 상황, 수역 이용 규칙, 날씨를 확인하세요. 적절한 안전 조치를 취하세요. TREK은 계획을 돕지만, 직접 확인하고 책임 있게 판단하는 일을 대신하지 않습니다.', + 'tours.planner.shapingPoint': '형태 지점', + 'tours.planner.shapingHint': '정류 지점이 아니면서 경로를 이끕니다. 경로가 지나야 할 곳으로 끌어 놓으세요.', + 'tours.planner.makeStop': '정류 지점으로 바꾸기', + 'tours.planner.makeShaping': '형태 지점으로 바꾸기', + 'tours.planner.recomputeLeg': '다음 구간 다시 계산', + 'tours.planner.shapeOnLine': '지도에서 경로를 클릭하면 그곳에 형태 지점을 추가합니다.', + 'tours.paddle.routeOnWater': '다음 구간을 물 위로 계획', + 'tours.paddle.routeAllOnWater': '손으로 그린 구간을 물 위로 계획', + 'tours.paddle.auto': '감지됨', + 'tours.paddle.autoHint': + 'TREK이 가는 길에 찾은 육로 운반 지점이나 갑문에 이 지점을 두었습니다. 옮기거나 바꾸면 내 지점이 됩니다.', + 'tours.paddle.note.rapids': '급류 {grade}급', + 'tours.paddle.note.pass': '카누 수로', + 'tours.paddle.note.gap': '지도 데이터 공백', + 'tours.paddle.note.rapidsHint': + '여기서 경로가 흐르는 물을 지납니다. 먼저 살펴보고 확신이 없으면 배를 들고 우회하세요.', + 'tours.paddle.note.gapHint': '여기는 수역 데이터에 공백이 있어 직선으로 이었습니다. 현장에서 확인하세요.', + 'tours.paddle.failed.not_on_water': + '이 구간의 한쪽 끝이 TREK이 아는 수역 위에 있지 않습니다. 강이나 호수 위로 옮기세요.', + 'tours.paddle.failed.no_region': '이 지역의 수역 데이터가 아직 없습니다. 관리자가 지역을 추가할 수 있습니다.', + 'tours.paddle.failed.building': '이 지역의 수역 데이터를 아직 준비 중입니다. 몇 분 후 다시 시도하세요.', + 'tours.paddle.failed.no_water_path': 'TREK이 이 지점들 사이에서 물길을 찾지 못했습니다.', + 'tours.paddle.failed.cross_region': '이 지점들은 수역 데이터를 공유하지 않는 두 지역에 있습니다.', + 'tours.paddle.failed.busy': '경로 계획기가 바쁩니다. 잠시 후 다시 시도하세요.', + 'tours.paddle.failed.timeout': '이 구간 계획에 시간이 너무 오래 걸렸습니다. 사이에 지점을 추가하고 다시 시도하세요.', + 'tours.paddle.failed.offline': '오프라인입니다. 다시 연결되면 이 구간을 계획합니다.', + 'tours.paddle.drawStraight': '직선으로 그리기', + 'tours.paddle.retry': '다시 시도', + 'tours.paddle.load.loading': '{region}의 수역 데이터를 불러오는 중', + 'tours.paddle.load.ready': '{region}의 수역 데이터가 준비되어 구간을 계획하는 중입니다.', + 'tours.paddle.load.requested': '{region}의 수역 데이터를 요청했습니다. 관리자가 허용하면 바로 구간을 계획합니다.', + 'tours.paddle.load.waiting': '다른 지역을 기다리는 중', + 'tours.paddle.load.phase.download': '다운로드 중', + 'tours.paddle.load.phase.read': '읽는 중', + 'tours.paddle.load.phase.build': '수역 네트워크 구성 중', + 'tours.paddle.load.hint': '보통 1~3분 걸립니다. 계속 계획하세요. 구간은 그 후 자동으로 계획됩니다.', + 'tours.paddle.load.reason.tooLarge': '이 지역은 TREK이 스스로 불러오는 크기보다 큽니다.', + 'tours.paddle.load.reason.limit': 'TREK이 오늘 이미 충분한 지역을 스스로 불러왔습니다.', + 'tours.paddle.load.declined': '관리자가 {region}의 수역 데이터를 거절했습니다.', + 'tours.paddle.load.failed': '{region}의 수역 데이터를 불러올 수 없습니다.', + 'tours.paddle.load.noWater': '{region}의 수역 데이터에는 이곳에 물이 없습니다. 지점을 강이나 호수 위에 두세요.', + 'tours.paddle.load.noExtract': '이 지역에 대한 수역 데이터가 있는 지도 추출본이 없습니다.', + 'tours.paddle.load.catalogUnavailable': '지금은 지역 목록에 연결할 수 없습니다. 나중에 다시 시도하세요.', + 'tours.paddle.lakeStyle': '호수 건너기', + 'tours.paddle.lakeStyle.direct': '곧장', + 'tours.paddle.lakeStyle.shore': '물가 가까이', + 'tours.paddle.lakeStyle.directHint': '탁 트인 수면을 가로지르는 가장 짧은 선.', + 'tours.paddle.lakeStyle.shoreHint': '물가 가까이 갑니다. 바람 부는 날에 알맞으며, 더 길지만 땅이 늘 가깝습니다.', + 'tours.paddle.wind.shoreAdvice': + '이날 최대 {mean} km/h, 돌풍 {gusts} km/h. 호수에서는 물가 가까이 가는 편이 나을 수 있습니다.', + 'tours.paddle.wind.shoreAdopt': '물가 가까이 계획', + 'tours.paddle.wind.shoreTaken': '호수는 물가 가까이 계획되어 있습니다.', + 'tours.paddle.status.unrouted': '계획되지 않은 구간 {count}개', }; export default tours; diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 0e4a2a538..c48709807 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -891,5 +891,44 @@ const admin: TranslationStrings = { 'Gegevens © OpenStreetMap-bijdragers (ODbL), regionale extracten van Geofabrik. Elke maand op de 3e opnieuw ingelezen.', 'admin.paddleRegions.toast.added': 'Regio toegevoegd. TREK leest de regio op de achtergrond in.', 'admin.paddleRegions.toast.removed': 'Regio verwijderd', + 'admin.paddleRegions.phase.build': 'Waternetwerk wordt gebouwd', + 'admin.paddleRegions.network.km': '{km} km water', + 'admin.paddleRegions.network.lakes': '{count} meren', + 'admin.paddleRegions.network.lakes.one': '{count} meer', + 'admin.paddleRegions.network.portages': '{count} gekarteerde overdraagplaatsen', + 'admin.paddleRegions.network.portages.one': '{count} gekarteerde overdraagplaats', + 'admin.paddleRegions.network.inferred': '{count} aangenomen overdrachten', + 'admin.paddleRegions.network.inferred.one': '{count} aangenomen overdracht', + 'admin.paddleRegions.network.locks': '{count} sluizen', + 'admin.paddleRegions.network.locks.one': '{count} sluis', + 'admin.paddleRegions.network.gaps': '{count} overbrugde gaten', + 'admin.paddleRegions.network.gaps.one': '{count} overbrugd gat', + 'admin.paddleRegions.network.missing': 'Geen waternetwerk', + 'admin.paddleRegions.network.missingHint': + 'Lees de regio opnieuw in om het netwerk te bouwen dat de peddelroute volgt.', + 'admin.paddleRegions.auto.title': "Regio's automatisch laden", + 'admin.paddleRegions.auto.mode.auto': 'Automatisch', + 'admin.paddleRegions.auto.mode.ask': 'Op aanvraag', + 'admin.paddleRegions.auto.mode.off': 'Uit', + 'admin.paddleRegions.auto.modeHint.auto': + 'Plant iemand een peddeltocht waar nog geen watergegevens zijn, dan laadt TREK meteen de kleinste passende regio, tot de groottegrens en maar een paar per dag. Grotere wachten hier als aanvraag. Alleen de naam van de regio gaat naar Geofabrik, nooit de plek.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Elke nieuwe regio wacht hier tot een beheerder hem doorlaat. Plant een beheerder zelf, dan laadt hij meteen.', + 'admin.paddleRegions.auto.modeHint.off': + "TREK laadt niets uit zichzelf en vraagt niets aan. Voeg regio's hier met de hand toe.", + 'admin.paddleRegions.auto.maxSize': 'Grootste regio die automatisch laadt', + 'admin.paddleRegions.auto.maxSizeHint': + "Grotere regio's wachten hier als aanvraag. Het inlezen van een regio duurt een paar minuten en kost dan geheugen.", + 'admin.paddleRegions.requests.title': 'Aangevraagd', + 'admin.paddleRegions.requests.reason.ask': 'Op aanvraag', + 'admin.paddleRegions.requests.reason.too_large': 'Te groot', + 'admin.paddleRegions.requests.reason.limit': 'Daglimiet', + 'admin.paddleRegions.requests.declined': 'Afgewezen', + 'admin.paddleRegions.requests.by': 'door {user}', + 'admin.paddleRegions.requests.approve': 'Laden', + 'admin.paddleRegions.requests.decline': 'Afwijzen', + 'admin.paddleRegions.origin.auto': 'Automatisch', + 'admin.paddleRegions.origin.autoHint': + 'TREK heeft deze regio zelf geladen voor een peddeltocht die daar gepland wordt.', }; export default admin; diff --git a/shared/src/i18n/nl/externalNotifications.ts b/shared/src/i18n/nl/externalNotifications.ts index 6c9fca08b..a7477f5ae 100644 --- a/shared/src/i18n/nl/externalNotifications.ts +++ b/shared/src/i18n/nl/externalNotifications.ts @@ -67,6 +67,10 @@ const nl: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Peddelregio aangevraagd', + body: `${p.actor} plant een peddeltocht in ${p.region}. Laad of wijs de watergegevens af in het beheer onder Add-ons.`, + }), synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.', diff --git a/shared/src/i18n/nl/notif.ts b/shared/src/i18n/nl/notif.ts index e108eefaa..3c7cf4a69 100644 --- a/shared/src/i18n/nl/notif.ts +++ b/shared/src/i18n/nl/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Schrijven naar replica '{backend}' is mislukt: {op} van {key} — {error}", 'notif.replica_failure.textSuppressed': "Schrijven naar replica '{backend}' is mislukt: {op} van {key} — {error}. {suppressed} extra fout(en) zijn onderdrukt sinds de laatste melding.", + 'notif.paddle_region_requested.title': 'Peddelregio aangevraagd', + 'notif.paddle_region_requested.text': '{actor} plant een peddeltocht in {region}', 'notif.action.view_trip': 'Reis bekijken', 'notif.action.view_collab': 'Berichten bekijken', 'notif.action.view_packing': 'Paklijst bekijken', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 8fac3861d..9b91dafbb 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -300,6 +300,7 @@ const settings: TranslationStrings = { 'settings.bookingLabelsHint': 'Toon station- / luchthavennamen op de kaart. Indien uit, alleen het icoon.', 'settings.notifyVersionAvailable': 'Nieuwe versie beschikbaar', 'settings.notifyReplicaFailure': 'Opslagreplica mislukt', + 'settings.notifyPaddleRegionRequested': 'Peddelregio aangevraagd', 'settings.notificationPreferences.noChannels': 'Er zijn geen meldingskanalen geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te stellen.', 'settings.webhookUrl.label': 'Webhook-URL', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index e9501abaf..83ac72aec 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -294,8 +294,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Vul een tempo van 0,5 tot 20 km/h in.', 'tours.paddle.chooseBoat': 'Kies boot en water om deze peddeltocht op te slaan.', 'tours.paddle.chooseBoatImport': 'Kies boot en water om als peddeltocht te importeren.', - 'tours.paddle.drawnByHand': - 'Een peddeltocht teken je met de hand: TREK verbindt je punten met rechte lijnen. Er wordt niets gerouteerd en niets zegt dat het water bevaarbaar is.', 'tours.paddle.timeEstimated': 'Peddeltijd (geschat)', 'tours.paddle.timeEstimatedShort': 'Peddelen', 'tours.paddle.timeEstimatedInfoLabel': 'Zo wordt de peddeltijd geschat', @@ -338,6 +336,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Overdragen', 'tours.paddle.portageOn': 'Boot vanaf hier dragen', 'tours.paddle.portageOff': 'Dit stuk weer peddelen', + 'tours.paddle.portagePending': 'Vanaf hier overdragen', + 'tours.paddle.portagePendingHint': 'Zet nu het punt waar je weer te water gaat. Tot daar wordt de boot gedragen.', + 'tours.paddle.portagePendingOff': 'Toch niet overdragen', + 'tours.paddle.portageWay': 'Verloop', + 'tours.paddle.portageWay.follow': 'Het pad volgen', + 'tours.paddle.portageWay.straight': 'Rechte lijn', + 'tours.paddle.portageWay.noWay': 'Geen pad zonder grote omweg, de lijn blijft recht.', + 'tours.paddle.portageWay.searching': 'Pad wordt gezocht', + 'tours.paddle.placeHere.putIn': 'Hier te water gaan', + 'tours.paddle.placeHere.egress': 'Hier uit het water gaan', 'tours.paddle.portageInfo': 'Hier wordt de boot gedragen. Het stuk telt als land, met tijd voor uitstappen, uitladen en weer te water laten.', 'tours.paddle.portageExtra': 'Uit en in', @@ -533,5 +541,65 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Peddelen is niet zonder risico. Controleer zelf het water, de regels en het weer.', 'tours.paddle.safety.text': 'Peddelen is niet zonder risico. Kaarten, routevoorstellen en aanwijzingen kunnen onvolledig of verouderd zijn. Controleer voor de tocht de actuele plaatselijke omstandigheden, de regels voor het bevaren van het water en het weer. Neem passende veiligheidsmaatregelen. TREK ondersteunt je planning en vervangt je eigen controle en verantwoorde beslissing niet.', + 'tours.planner.shapingPoint': 'Vormpunt', + 'tours.planner.shapingHint': 'Stuurt de route zonder een stop te zijn. Sleep het naar waar de route moet lopen.', + 'tours.planner.makeStop': 'Maak er een stop van', + 'tours.planner.makeShaping': 'Maak er een vormpunt van', + 'tours.planner.recomputeLeg': 'Volgend deel opnieuw berekenen', + 'tours.planner.shapeOnLine': 'Klik op de route op de kaart om daar een vormpunt toe te voegen.', + 'tours.paddle.routeOnWater': 'Volgend deel over het water plannen', + 'tours.paddle.routeAllOnWater': 'Met de hand getekende delen over het water plannen', + 'tours.paddle.auto': 'Gevonden', + 'tours.paddle.autoHint': + 'TREK zette dit punt bij een overdraagplaats of sluis die het onderweg vond. Verplaats of wijzig je het, dan is het van jou.', + 'tours.paddle.note.rapids': 'Wildwater {grade}', + 'tours.paddle.note.pass': 'Kanogoot', + 'tours.paddle.note.gap': 'Gat in de kaartgegevens', + 'tours.paddle.note.rapidsHint': 'De route loopt hier door stromend water. Verken het eerst en draag om bij twijfel.', + 'tours.paddle.note.gapHint': + 'De watergegevens hebben hier een gat, overbrugd met een rechte lijn. Controleer het ter plaatse.', + 'tours.paddle.failed.not_on_water': + 'Eén uiteinde van dit deel ligt niet op water dat TREK kent. Verplaats het naar de rivier of het meer.', + 'tours.paddle.failed.no_region': 'Nog geen watergegevens voor dit gebied. Een beheerder kan de regio toevoegen.', + 'tours.paddle.failed.building': + 'De watergegevens van deze regio worden nog voorbereid. Probeer het over een paar minuten opnieuw.', + 'tours.paddle.failed.no_water_path': 'TREK vond geen weg over het water tussen deze punten.', + 'tours.paddle.failed.cross_region': 'Deze punten liggen in twee regio’s zonder gedeelde watergegevens.', + 'tours.paddle.failed.busy': 'De routeplanner is bezet. Probeer het zo meteen opnieuw.', + 'tours.paddle.failed.timeout': + 'Het plannen van dit deel duurde te lang. Voeg een punt ertussen toe en probeer het opnieuw.', + 'tours.paddle.failed.offline': 'Geen verbinding. Dit deel wordt gepland zodra je weer verbonden bent.', + 'tours.paddle.drawStraight': 'Recht tekenen', + 'tours.paddle.retry': 'Opnieuw proberen', + 'tours.paddle.load.loading': 'Watergegevens voor {region} worden geladen', + 'tours.paddle.load.ready': 'De watergegevens voor {region} zijn er, het traject wordt gepland.', + 'tours.paddle.load.requested': + 'De watergegevens voor {region} zijn aangevraagd. Het traject wordt gepland zodra een beheerder ze doorlaat.', + 'tours.paddle.load.waiting': "Wacht op andere regio's", + 'tours.paddle.load.phase.download': 'Downloaden', + 'tours.paddle.load.phase.read': 'Inlezen', + 'tours.paddle.load.phase.build': 'Waternet wordt gebouwd', + 'tours.paddle.load.hint': + 'Meestal een tot drie minuten. Plan gerust verder, het traject wordt daarna vanzelf gepland.', + 'tours.paddle.load.reason.tooLarge': 'De regio is groter dan TREK uit zichzelf laadt.', + 'tours.paddle.load.reason.limit': "TREK heeft vandaag al genoeg regio's uit zichzelf geladen.", + 'tours.paddle.load.declined': 'Een beheerder heeft de watergegevens voor {region} afgewezen.', + 'tours.paddle.load.failed': 'De watergegevens voor {region} konden niet worden geladen.', + 'tours.paddle.load.noWater': + 'In de watergegevens voor {region} ligt hier geen water. Zet het punt op de rivier of het meer.', + 'tours.paddle.load.noExtract': 'Voor dit gebied is er geen kaartuitsnede met watergegevens.', + 'tours.paddle.load.catalogUnavailable': "De lijst met regio's is nu niet bereikbaar. Probeer het later opnieuw.", + 'tours.paddle.lakeStyle': 'Over meren', + 'tours.paddle.lakeStyle.direct': 'Rechtdoor', + 'tours.paddle.lakeStyle.shore': 'Dicht bij de oever', + 'tours.paddle.lakeStyle.directHint': 'De kortste lijn over open water.', + 'tours.paddle.lakeStyle.shoreHint': + 'Blijft dicht bij de oever, voor winderige dagen: langer, maar land is nooit ver.', + 'tours.paddle.wind.shoreAdvice': + 'Tot {mean} km/h, windstoten van {gusts} km/h op deze dag. Op de meren is dicht bij de oever misschien de betere lijn.', + 'tours.paddle.wind.shoreAdopt': 'Dicht bij de oever plannen', + 'tours.paddle.wind.shoreTaken': 'De meren zijn dicht bij de oever gepland.', + 'tours.paddle.status.unrouted': '{count} delen niet gepland', + 'tours.paddle.status.unrouted.one': '{count} deel niet gepland', }; export default tours; diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index c4c6f8c8c..1b94aa37d 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -928,5 +928,53 @@ const admin: TranslationStrings = { 'admin.paddleRegions.toast.removed': 'Usunięto region', 'admin.paddleRegions.objects.few': '{count} obiekty', 'admin.paddleRegions.objects.many': '{count} obiektów', + 'admin.paddleRegions.phase.build': 'Budowanie sieci wodnej', + 'admin.paddleRegions.network.km': '{km} km wód', + 'admin.paddleRegions.network.lakes': '{count} jeziora', + 'admin.paddleRegions.network.lakes.one': '{count} jezioro', + 'admin.paddleRegions.network.lakes.few': '{count} jeziora', + 'admin.paddleRegions.network.lakes.many': '{count} jezior', + 'admin.paddleRegions.network.portages': '{count} zmapowanej przenoski', + 'admin.paddleRegions.network.portages.one': '{count} zmapowana przenoska', + 'admin.paddleRegions.network.portages.few': '{count} zmapowane przenoski', + 'admin.paddleRegions.network.portages.many': '{count} zmapowanych przenosek', + 'admin.paddleRegions.network.inferred': '{count} przyjętego przeniesienia', + 'admin.paddleRegions.network.inferred.one': '{count} przyjęte przeniesienie', + 'admin.paddleRegions.network.inferred.few': '{count} przyjęte przeniesienia', + 'admin.paddleRegions.network.inferred.many': '{count} przyjętych przeniesień', + 'admin.paddleRegions.network.locks': '{count} śluzy', + 'admin.paddleRegions.network.locks.one': '{count} śluza', + 'admin.paddleRegions.network.locks.few': '{count} śluzy', + 'admin.paddleRegions.network.locks.many': '{count} śluz', + 'admin.paddleRegions.network.gaps': '{count} połączonej luki', + 'admin.paddleRegions.network.gaps.one': '{count} połączona luka', + 'admin.paddleRegions.network.gaps.few': '{count} połączone luki', + 'admin.paddleRegions.network.gaps.many': '{count} połączonych luk', + 'admin.paddleRegions.network.missing': 'Brak sieci wodnej', + 'admin.paddleRegions.network.missingHint': + 'Wczytaj region ponownie, aby zbudować sieć, po której prowadzi trasa kajakowa.', + 'admin.paddleRegions.auto.title': 'Wczytuj regiony automatycznie', + 'admin.paddleRegions.auto.mode.auto': 'Automatycznie', + 'admin.paddleRegions.auto.mode.ask': 'Na prośbę', + 'admin.paddleRegions.auto.mode.off': 'Wyłączone', + 'admin.paddleRegions.auto.modeHint.auto': + 'Gdy ktoś planuje spływ tam, gdzie nie ma jeszcze danych wodnych, TREK od razu wczytuje najmniejszy pasujący region, do limitu rozmiaru i tylko kilka dziennie. Większe czekają tutaj jako prośba. Do Geofabrik trafia tylko nazwa regionu, nigdy miejsce.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Każdy nowy region czeka tutaj, aż administrator go zatwierdzi. Gdy planuje administrator, wczytuje się od razu.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK niczego nie wczytuje sam i o nic nie prosi. Dodawaj regiony tutaj ręcznie.', + 'admin.paddleRegions.auto.maxSize': 'Największy region wczytywany automatycznie', + 'admin.paddleRegions.auto.maxSizeHint': + 'Większe regiony czekają tutaj jako prośba. Wczytanie regionu trwa kilka minut i w tym czasie zajmuje pamięć.', + 'admin.paddleRegions.requests.title': 'Prośby', + 'admin.paddleRegions.requests.reason.ask': 'Na prośbę', + 'admin.paddleRegions.requests.reason.too_large': 'Za duży', + 'admin.paddleRegions.requests.reason.limit': 'Limit dzienny', + 'admin.paddleRegions.requests.declined': 'Odrzucony', + 'admin.paddleRegions.requests.by': 'od {user}', + 'admin.paddleRegions.requests.approve': 'Wczytaj', + 'admin.paddleRegions.requests.decline': 'Odrzuć', + 'admin.paddleRegions.origin.auto': 'Automatycznie', + 'admin.paddleRegions.origin.autoHint': 'TREK wczytał ten region sam dla planowanego tam spływu.', }; export default admin; diff --git a/shared/src/i18n/pl/externalNotifications.ts b/shared/src/i18n/pl/externalNotifications.ts index 33b7a92ec..c964e86ef 100644 --- a/shared/src/i18n/pl/externalNotifications.ts +++ b/shared/src/i18n/pl/externalNotifications.ts @@ -76,6 +76,10 @@ const pl: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Prośba o region wodny', + body: `${p.actor} planuje spływ w regionie ${p.region}. Wczytaj lub odrzuć jego dane wodne w panelu administratora w sekcji Dodatki.`, + }), synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.', diff --git a/shared/src/i18n/pl/notif.ts b/shared/src/i18n/pl/notif.ts index 769420589..b4c585d35 100644 --- a/shared/src/i18n/pl/notif.ts +++ b/shared/src/i18n/pl/notif.ts @@ -34,6 +34,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Zapis do repliki '{backend}' nie powiódł się: {op} dla {key} — {error}", 'notif.replica_failure.textSuppressed': "Zapis do repliki '{backend}' nie powiódł się: {op} dla {key} — {error}. Od ostatniego powiadomienia ukryto {suppressed} kolejnych błędów.", + 'notif.paddle_region_requested.title': 'Prośba o region wodny', + 'notif.paddle_region_requested.text': '{actor} planuje spływ w regionie {region}', 'notif.action.view_trip': 'Zobacz podróż', 'notif.action.view_collab': 'Zobacz wiadomości', 'notif.action.view_packing': 'Zobacz pakowanie', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index b6d2dad73..5d1fbbcaf 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -105,6 +105,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Powiadomienia Webhook', 'settings.notifyVersionAvailable': 'Nowa wersja dostępna', 'settings.notifyReplicaFailure': 'Awaria repliki magazynu', + 'settings.notifyPaddleRegionRequested': 'Prośba o region wodny', 'settings.on': 'Włączone', 'settings.off': 'Wyłączone', 'settings.mcp.title': 'Konfiguracja MCP', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 7d9468325..bc18bc98a 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -300,8 +300,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Podaj tempo od 0,5 do 20 km/h.', 'tours.paddle.chooseBoat': 'Wybierz łódź i wodę, aby zapisać ten spływ.', 'tours.paddle.chooseBoatImport': 'Wybierz łódź i wodę, aby zaimportować jako spływ.', - 'tours.paddle.drawnByHand': - 'Spływ rysuje się ręcznie: TREK łączy Twoje punkty prostymi liniami. Nic nie jest wyznaczane i nic nie mówi, że woda jest spławna.', 'tours.paddle.timeEstimated': 'Czas na wodzie (szacunkowo)', 'tours.paddle.timeEstimatedShort': 'Wiosłowanie', 'tours.paddle.timeEstimatedInfoLabel': 'Jak szacowany jest czas na wodzie', @@ -352,6 +350,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Przenoska', 'tours.paddle.portageOn': 'Przenieś łódź od tego miejsca', 'tours.paddle.portageOff': 'Znów płyń tym odcinkiem', + 'tours.paddle.portagePending': 'Przenoszenie od tego miejsca', + 'tours.paddle.portagePendingHint': 'Teraz ustaw punkt, w którym znów wodujesz. Łódź jest niesiona aż tam.', + 'tours.paddle.portagePendingOff': 'Jednak nie przenosić', + 'tours.paddle.portageWay': 'Przebieg', + 'tours.paddle.portageWay.follow': 'Podążaj ścieżką', + 'tours.paddle.portageWay.straight': 'Linia prosta', + 'tours.paddle.portageWay.noWay': 'Brak ścieżki bez dużego objazdu, linia pozostaje prosta.', + 'tours.paddle.portageWay.searching': 'Szukanie ścieżki', + 'tours.paddle.placeHere.putIn': 'Wodowanie tutaj', + 'tours.paddle.placeHere.egress': 'Wyjście z wody tutaj', 'tours.paddle.portageInfo': 'Tutaj łódź jest przenoszona. Odcinek liczy się jako ląd, z czasem na wyjście z wody, rozładunek i ponowne wodowanie.', 'tours.paddle.portageExtra': 'Wyjście i wodowanie', @@ -559,5 +567,64 @@ const tours: TranslationStrings = { 'Wzdłuż trasy: {count} przeszkód i miejsc dostępu z OpenStreetMap, stan na {date}. Dane mogą mieć luki, więc sprawdź akwen samodzielnie.', 'tours.paddle.time.locksMissing.few': '{count} śluzy nie mają jeszcze czasów.', 'tours.paddle.time.locksMissing.many': '{count} śluz nie ma jeszcze czasów.', + 'tours.planner.shapingPoint': 'Punkt kształtujący', + 'tours.planner.shapingHint': 'Kieruje trasą, nie będąc przystankiem. Przeciągnij go tam, gdzie ma biec trasa.', + 'tours.planner.makeStop': 'Zrób z niego przystanek', + 'tours.planner.makeShaping': 'Zrób z niego punkt kształtujący', + 'tours.planner.recomputeLeg': 'Przelicz ponownie następny odcinek', + 'tours.planner.shapeOnLine': 'Kliknij trasę na mapie, aby dodać tam punkt kształtujący.', + 'tours.paddle.routeOnWater': 'Zaplanuj następny odcinek po wodzie', + 'tours.paddle.routeAllOnWater': 'Zaplanuj po wodzie odcinki narysowane ręcznie', + 'tours.paddle.auto': 'Wykryty', + 'tours.paddle.autoHint': + 'TREK umieścił ten punkt przy przenosce lub śluzie znalezionej po drodze. Gdy go przesuniesz lub zmienisz, stanie się twój.', + 'tours.paddle.note.rapids': 'Woda górska {grade}', + 'tours.paddle.note.pass': 'Przepust kajakowy', + 'tours.paddle.note.gap': 'Luka w danych mapy', + 'tours.paddle.note.rapidsHint': + 'Trasa prowadzi tu przez wartką wodę. Najpierw obejrzyj miejsce, a w razie wątpliwości przenieś łódź.', + 'tours.paddle.note.gapHint': 'Dane o wodach mają tu lukę, połączoną linią prostą. Sprawdź to na miejscu.', + 'tours.paddle.failed.not_on_water': + 'Jeden koniec tego odcinka nie leży na żadnej wodzie znanej TREK. Przesuń go na rzekę lub jezioro.', + 'tours.paddle.failed.no_region': 'Brak jeszcze danych o wodach dla tego obszaru. Administrator może dodać region.', + 'tours.paddle.failed.building': + 'Dane o wodach dla tego regionu są jeszcze przygotowywane. Spróbuj ponownie za kilka minut.', + 'tours.paddle.failed.no_water_path': 'TREK nie znalazł drogi po wodzie między tymi punktami.', + 'tours.paddle.failed.cross_region': 'Te punkty leżą w dwóch regionach bez wspólnych danych o wodach.', + 'tours.paddle.failed.busy': 'Planer tras jest zajęty. Spróbuj ponownie za chwilę.', + 'tours.paddle.failed.timeout': 'Planowanie tego odcinka trwało zbyt długo. Dodaj punkt pośredni i spróbuj ponownie.', + 'tours.paddle.failed.offline': 'Brak połączenia. Odcinek zostanie zaplanowany po ponownym połączeniu.', + 'tours.paddle.drawStraight': 'Rysuj prosto', + 'tours.paddle.retry': 'Spróbuj ponownie', + 'tours.paddle.load.loading': 'Wczytywanie danych wodnych dla {region}', + 'tours.paddle.load.ready': 'Dane wodne dla {region} są już dostępne, odcinek jest planowany.', + 'tours.paddle.load.requested': + 'Poproszono o dane wodne dla {region}. Odcinek zostanie zaplanowany, gdy tylko administrator je zatwierdzi.', + 'tours.paddle.load.waiting': 'Czeka na inne regiony', + 'tours.paddle.load.phase.download': 'Pobieranie', + 'tours.paddle.load.phase.read': 'Odczyt', + 'tours.paddle.load.phase.build': 'Budowa sieci wodnej', + 'tours.paddle.load.hint': 'Zwykle od jednej do trzech minut. Planuj dalej, odcinek zaplanuje się potem sam.', + 'tours.paddle.load.reason.tooLarge': 'Region jest większy, niż TREK wczytuje sam.', + 'tours.paddle.load.reason.limit': 'TREK wczytał już dziś sam wystarczająco dużo regionów.', + 'tours.paddle.load.declined': 'Administrator odrzucił dane wodne dla {region}.', + 'tours.paddle.load.failed': 'Nie udało się wczytać danych wodnych dla {region}.', + 'tours.paddle.load.noWater': 'W danych wodnych dla {region} nie ma tu wody. Postaw punkt na rzece lub jeziorze.', + 'tours.paddle.load.noExtract': 'Dla tego obszaru nie ma wycinka mapy z danymi wodnymi.', + 'tours.paddle.load.catalogUnavailable': 'Lista regionów jest teraz niedostępna. Spróbuj ponownie później.', + 'tours.paddle.lakeStyle': 'Przez jeziora', + 'tours.paddle.lakeStyle.direct': 'Na wprost', + 'tours.paddle.lakeStyle.shore': 'Blisko brzegu', + 'tours.paddle.lakeStyle.directHint': 'Najkrótsza linia przez otwartą wodę.', + 'tours.paddle.lakeStyle.shoreHint': + 'Trzyma się blisko brzegu, na wietrzne dni: dłużej, ale ląd nigdy nie jest daleko.', + 'tours.paddle.wind.shoreAdvice': + 'Tego dnia do {mean} km/h, porywy {gusts} km/h. Na jeziorach lepszą linią może być trzymanie się brzegu.', + 'tours.paddle.wind.shoreAdopt': 'Zaplanuj blisko brzegu', + 'tours.paddle.wind.shoreTaken': 'Jeziora są zaplanowane blisko brzegu.', + 'tours.paddle.status.unrouted': '{count} odcinka bez planu', + 'tours.paddle.status.unrouted.one': '{count} odcinek bez planu', + 'tours.paddle.status.unrouted.few': '{count} odcinki bez planu', + 'tours.paddle.status.unrouted.many': '{count} odcinków bez planu', }; export default tours; diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index bb0ac61a1..0847ff2eb 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -919,5 +919,53 @@ const admin: TranslationStrings = { 'admin.paddleRegions.toast.removed': 'Регион удалён', 'admin.paddleRegions.objects.few': '{count} объекта', 'admin.paddleRegions.objects.many': '{count} объектов', + 'admin.paddleRegions.phase.build': 'Строится водная сеть', + 'admin.paddleRegions.network.km': '{km} км воды', + 'admin.paddleRegions.network.lakes': '{count} озера', + 'admin.paddleRegions.network.lakes.one': '{count} озеро', + 'admin.paddleRegions.network.lakes.few': '{count} озера', + 'admin.paddleRegions.network.lakes.many': '{count} озёр', + 'admin.paddleRegions.network.portages': '{count} отмеченного волока', + 'admin.paddleRegions.network.portages.one': '{count} отмеченный волок', + 'admin.paddleRegions.network.portages.few': '{count} отмеченных волока', + 'admin.paddleRegions.network.portages.many': '{count} отмеченных волоков', + 'admin.paddleRegions.network.inferred': '{count} предполагаемого обноса', + 'admin.paddleRegions.network.inferred.one': '{count} предполагаемый обнос', + 'admin.paddleRegions.network.inferred.few': '{count} предполагаемых обноса', + 'admin.paddleRegions.network.inferred.many': '{count} предполагаемых обносов', + 'admin.paddleRegions.network.locks': '{count} шлюза', + 'admin.paddleRegions.network.locks.one': '{count} шлюз', + 'admin.paddleRegions.network.locks.few': '{count} шлюза', + 'admin.paddleRegions.network.locks.many': '{count} шлюзов', + 'admin.paddleRegions.network.gaps': '{count} соединённого пробела', + 'admin.paddleRegions.network.gaps.one': '{count} соединённый пробел', + 'admin.paddleRegions.network.gaps.few': '{count} соединённых пробела', + 'admin.paddleRegions.network.gaps.many': '{count} соединённых пробелов', + 'admin.paddleRegions.network.missing': 'Нет водной сети', + 'admin.paddleRegions.network.missingHint': + 'Загрузите регион заново, чтобы построить сеть, по которой прокладывается маршрут на воде.', + 'admin.paddleRegions.auto.title': 'Загружать регионы автоматически', + 'admin.paddleRegions.auto.mode.auto': 'Автоматически', + 'admin.paddleRegions.auto.mode.ask': 'По запросу', + 'admin.paddleRegions.auto.mode.off': 'Выключено', + 'admin.paddleRegions.auto.modeHint.auto': + 'Когда кто-то планирует сплав там, где ещё нет данных о воде, TREK сразу загружает наименьший подходящий регион, в пределах лимита размера и лишь несколько в день. Более крупные ждут здесь как запрос. В Geofabrik уходит только название региона, но никогда не место.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Каждый новый регион ждёт здесь, пока администратор его не разрешит. Если планирует администратор, регион загружается сразу.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK ничего не загружает сам и ничего не запрашивает. Добавляйте регионы здесь вручную.', + 'admin.paddleRegions.auto.maxSize': 'Самый большой регион для автозагрузки', + 'admin.paddleRegions.auto.maxSizeHint': + 'Более крупные регионы ждут здесь как запрос. Чтение региона занимает несколько минут и всё это время требует памяти.', + 'admin.paddleRegions.requests.title': 'Запрошенные', + 'admin.paddleRegions.requests.reason.ask': 'По запросу', + 'admin.paddleRegions.requests.reason.too_large': 'Слишком большой', + 'admin.paddleRegions.requests.reason.limit': 'Дневной лимит', + 'admin.paddleRegions.requests.declined': 'Отклонён', + 'admin.paddleRegions.requests.by': 'от {user}', + 'admin.paddleRegions.requests.approve': 'Загрузить', + 'admin.paddleRegions.requests.decline': 'Отклонить', + 'admin.paddleRegions.origin.auto': 'Автоматически', + 'admin.paddleRegions.origin.autoHint': 'TREK загрузил этот регион сам для сплава, который там планируется.', }; export default admin; diff --git a/shared/src/i18n/ru/externalNotifications.ts b/shared/src/i18n/ru/externalNotifications.ts index 11e380aff..c36384e4a 100644 --- a/shared/src/i18n/ru/externalNotifications.ts +++ b/shared/src/i18n/ru/externalNotifications.ts @@ -71,6 +71,10 @@ const ru: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Запрошен регион для сплава', + body: `${p.actor} планирует сплав в регионе ${p.region}. Загрузите или отклоните его данные о воде в администрировании в разделе «Дополнения».`, + }), synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.', diff --git a/shared/src/i18n/ru/notif.ts b/shared/src/i18n/ru/notif.ts index 4fff295c0..852cdc351 100644 --- a/shared/src/i18n/ru/notif.ts +++ b/shared/src/i18n/ru/notif.ts @@ -34,6 +34,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Ошибка записи в реплику '{backend}': {op} для {key} — {error}", 'notif.replica_failure.textSuppressed': "Ошибка записи в реплику '{backend}': {op} для {key} — {error}. С момента последнего уведомления подавлено ещё {suppressed} ошибок.", + 'notif.paddle_region_requested.title': 'Запрошен регион для сплава', + 'notif.paddle_region_requested.text': '{actor} планирует сплав в регионе {region}', 'notif.action.view_trip': 'Открыть поездку', 'notif.action.view_collab': 'Открыть сообщения', 'notif.action.view_packing': 'Открыть упаковку', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 6df3198ee..8fec3d742 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -300,6 +300,7 @@ const settings: TranslationStrings = { 'Отображает названия станций / аэропортов на карте. Если выключено, показывается только значок.', 'settings.notifyVersionAvailable': 'Доступна новая версия', 'settings.notifyReplicaFailure': 'Сбой реплики хранилища', + 'settings.notifyPaddleRegionRequested': 'Запрошен регион для сплава', 'settings.notificationPreferences.noChannels': 'Каналы уведомлений не настроены. Попросите администратора настроить уведомления по электронной почте или через webhook.', 'settings.webhookUrl.label': 'URL вебхука', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 063a6e8e1..f7b03fcb2 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -303,8 +303,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Введите скорость от 0,5 до 20 км/ч.', 'tours.paddle.chooseBoat': 'Выберите лодку и водоём, чтобы сохранить этот сплав.', 'tours.paddle.chooseBoatImport': 'Выберите лодку и водоём, чтобы импортировать как сплав.', - 'tours.paddle.drawnByHand': - 'Сплав рисуется вручную: TREK соединяет ваши точки прямыми линиями. Ничего не прокладывается, и ничто не говорит, что по воде можно пройти.', 'tours.paddle.timeEstimated': 'Время на воде (оценка)', 'tours.paddle.timeEstimatedShort': 'Гребля', 'tours.paddle.timeEstimatedInfoLabel': 'Как оценивается время на воде', @@ -355,6 +353,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Обнос', 'tours.paddle.portageOn': 'Обносить отсюда', 'tours.paddle.portageOff': 'Снова пройти участок по воде', + 'tours.paddle.portagePending': 'Обнос отсюда', + 'tours.paddle.portagePendingHint': 'Теперь поставь точку, где снова спускаешься на воду. До неё лодку несут.', + 'tours.paddle.portagePendingOff': 'Не обносить', + 'tours.paddle.portageWay': 'Путь', + 'tours.paddle.portageWay.follow': 'По тропе', + 'tours.paddle.portageWay.straight': 'Прямая линия', + 'tours.paddle.portageWay.noWay': 'Тропы без большого крюка нет, линия остаётся прямой.', + 'tours.paddle.portageWay.searching': 'Поиск тропы', + 'tours.paddle.placeHere.putIn': 'Спуск на воду здесь', + 'tours.paddle.placeHere.egress': 'Выход из воды здесь', 'tours.paddle.portageInfo': 'Здесь лодку несут по суше. Участок считается сухопутным, с временем на выход, разгрузку и повторный спуск на воду.', 'tours.paddle.portageExtra': 'Выход и посадка', @@ -561,5 +569,66 @@ const tours: TranslationStrings = { '{count} препятствий и точек доступа к воде вдоль маршрута, из OpenStreetMap по состоянию на {date}. В данных могут быть пробелы, поэтому проверьте водоём сами.', 'tours.paddle.time.locksMissing.few': '{count} шлюза ещё без времени.', 'tours.paddle.time.locksMissing.many': '{count} шлюзов ещё без времени.', + 'tours.planner.shapingPoint': 'Точка формы', + 'tours.planner.shapingHint': + 'Задаёт ход маршрута, не будучи остановкой. Перетащите её туда, где должен пройти маршрут.', + 'tours.planner.makeStop': 'Сделать остановкой', + 'tours.planner.makeShaping': 'Сделать точкой формы', + 'tours.planner.recomputeLeg': 'Пересчитать следующий участок', + 'tours.planner.shapeOnLine': 'Нажмите на маршрут на карте, чтобы добавить там точку формы.', + 'tours.paddle.routeOnWater': 'Проложить следующий участок по воде', + 'tours.paddle.routeAllOnWater': 'Проложить по воде участки, нарисованные вручную', + 'tours.paddle.auto': 'Найдено', + 'tours.paddle.autoHint': + 'TREK поставил эту точку у волока или шлюза, найденного по пути. Если вы её переместите или измените, она станет вашей.', + 'tours.paddle.note.rapids': 'Порог, категория {grade}', + 'tours.paddle.note.pass': 'Лодочный проход', + 'tours.paddle.note.gap': 'Пробел в данных карты', + 'tours.paddle.note.rapidsHint': + 'Здесь маршрут идёт по быстрой воде. Сначала просмотрите участок и при сомнениях обнесите.', + 'tours.paddle.note.gapHint': 'В данных о водоёмах здесь пробел, соединённый прямой линией. Проверьте на месте.', + 'tours.paddle.failed.not_on_water': + 'Один конец этого участка не на воде, известной TREK. Переместите его на реку или озеро.', + 'tours.paddle.failed.no_region': + 'Для этой местности пока нет данных о водоёмах. Администратор может добавить регион.', + 'tours.paddle.failed.building': + 'Данные о водоёмах этого региона ещё готовятся. Попробуйте снова через несколько минут.', + 'tours.paddle.failed.no_water_path': 'TREK не нашёл пути по воде между этими точками.', + 'tours.paddle.failed.cross_region': 'Эти точки лежат в двух регионах без общих данных о водоёмах.', + 'tours.paddle.failed.busy': 'Планировщик маршрутов занят. Попробуйте снова через мгновение.', + 'tours.paddle.failed.timeout': + 'Расчёт этого участка занял слишком много времени. Добавьте промежуточную точку и попробуйте снова.', + 'tours.paddle.failed.offline': 'Нет подключения. Участок будет проложен, когда вы снова подключитесь.', + 'tours.paddle.drawStraight': 'Провести прямо', + 'tours.paddle.retry': 'Повторить', + 'tours.paddle.load.loading': 'Загружаются данные о воде для {region}', + 'tours.paddle.load.ready': 'Данные о воде для {region} получены, участок планируется.', + 'tours.paddle.load.requested': + 'Данные о воде для {region} запрошены. Участок спланируется, как только администратор их разрешит.', + 'tours.paddle.load.waiting': 'Ожидание других регионов', + 'tours.paddle.load.phase.download': 'Скачивание', + 'tours.paddle.load.phase.read': 'Чтение', + 'tours.paddle.load.phase.build': 'Построение водной сети', + 'tours.paddle.load.hint': 'Обычно от одной до трёх минут. Продолжайте планировать, участок потом спланируется сам.', + 'tours.paddle.load.reason.tooLarge': 'Регион больше, чем TREK загружает сам.', + 'tours.paddle.load.reason.limit': 'TREK уже загрузил сегодня достаточно регионов сам.', + 'tours.paddle.load.declined': 'Администратор отклонил данные о воде для {region}.', + 'tours.paddle.load.failed': 'Не удалось загрузить данные о воде для {region}.', + 'tours.paddle.load.noWater': 'В данных о воде для {region} здесь нет воды. Поставьте точку на реку или озеро.', + 'tours.paddle.load.noExtract': 'Для этой местности нет выгрузки карты с данными о воде.', + 'tours.paddle.load.catalogUnavailable': 'Список регионов сейчас недоступен. Попробуйте позже.', + 'tours.paddle.lakeStyle': 'Через озёра', + 'tours.paddle.lakeStyle.direct': 'Напрямик', + 'tours.paddle.lakeStyle.shore': 'Вдоль берега', + 'tours.paddle.lakeStyle.directHint': 'Кратчайшая линия по открытой воде.', + 'tours.paddle.lakeStyle.shoreHint': 'Держится у берега, для ветреных дней: длиннее, зато суша всегда рядом.', + 'tours.paddle.wind.shoreAdvice': + 'В этот день до {mean} км/ч, порывы {gusts} км/ч. На озёрах, возможно, лучше держаться берега.', + 'tours.paddle.wind.shoreAdopt': 'Проложить вдоль берега', + 'tours.paddle.wind.shoreTaken': 'Озёра проложены вдоль берега.', + 'tours.paddle.status.unrouted': '{count} участка не проложено', + 'tours.paddle.status.unrouted.one': '{count} участок не проложен', + 'tours.paddle.status.unrouted.few': '{count} участка не проложены', + 'tours.paddle.status.unrouted.many': '{count} участков не проложено', }; export default tours; diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index d5d0b5020..af16e655e 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -897,5 +897,47 @@ const admin: TranslationStrings = { 'admin.paddleRegions.toast.added': 'Región pridaný. TREK ho načíta na pozadí.', 'admin.paddleRegions.toast.removed': 'Región odstránený', 'admin.paddleRegions.objects.few': '{count} objekty', + 'admin.paddleRegions.phase.build': 'Vytváranie vodnej siete', + 'admin.paddleRegions.network.km': '{km} km vody', + 'admin.paddleRegions.network.lakes': '{count} jazier', + 'admin.paddleRegions.network.lakes.one': '{count} jazero', + 'admin.paddleRegions.network.lakes.few': '{count} jazerá', + 'admin.paddleRegions.network.portages': '{count} zmapovaných prenášok', + 'admin.paddleRegions.network.portages.one': '{count} zmapovaná prenáška', + 'admin.paddleRegions.network.portages.few': '{count} zmapované prenášky', + 'admin.paddleRegions.network.inferred': '{count} predpokladaných prenesení', + 'admin.paddleRegions.network.inferred.one': '{count} predpokladané prenesenie', + 'admin.paddleRegions.network.inferred.few': '{count} predpokladané prenesenia', + 'admin.paddleRegions.network.locks': '{count} plavebných komôr', + 'admin.paddleRegions.network.locks.one': '{count} plavebná komora', + 'admin.paddleRegions.network.locks.few': '{count} plavebné komory', + 'admin.paddleRegions.network.gaps': '{count} preklenutých medzier', + 'admin.paddleRegions.network.gaps.one': '{count} preklenutá medzera', + 'admin.paddleRegions.network.gaps.few': '{count} preklenuté medzery', + 'admin.paddleRegions.network.missing': 'Žiadna vodná sieť', + 'admin.paddleRegions.network.missingHint': + 'Načítaj región znova, aby sa vytvorila sieť, po ktorej vedie vodácka trasa.', + 'admin.paddleRegions.auto.title': 'Načítavať regióny automaticky', + 'admin.paddleRegions.auto.mode.auto': 'Automaticky', + 'admin.paddleRegions.auto.mode.ask': 'Na žiadosť', + 'admin.paddleRegions.auto.mode.off': 'Vypnuté', + 'admin.paddleRegions.auto.modeHint.auto': + 'Keď niekto plánuje vodácku trasu tam, kde ešte nie sú vodné dáta, TREK hneď načíta najmenší vhodný región, do limitu veľkosti a len niekoľko za deň. Väčšie čakajú tu ako žiadosť. Geofabrik dostane len názov regiónu, nikdy miesto.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Každý nový región čaká tu, kým ho správca nepustí. Keď plánuje správca sám, načíta sa hneď.', + 'admin.paddleRegions.auto.modeHint.off': 'TREK nič nenačítava sám a o nič nežiada. Regióny pridávaj tu ručne.', + 'admin.paddleRegions.auto.maxSize': 'Najväčší región načítavaný automaticky', + 'admin.paddleRegions.auto.maxSizeHint': + 'Väčšie regióny čakajú tu ako žiadosť. Načítanie regiónu trvá pár minút a medzitým zaberá pamäť.', + 'admin.paddleRegions.requests.title': 'Vyžiadané', + 'admin.paddleRegions.requests.reason.ask': 'Na žiadosť', + 'admin.paddleRegions.requests.reason.too_large': 'Príliš veľký', + 'admin.paddleRegions.requests.reason.limit': 'Denný limit', + 'admin.paddleRegions.requests.declined': 'Zamietnutý', + 'admin.paddleRegions.requests.by': 'od {user}', + 'admin.paddleRegions.requests.approve': 'Načítať', + 'admin.paddleRegions.requests.decline': 'Zamietnuť', + 'admin.paddleRegions.origin.auto': 'Automaticky', + 'admin.paddleRegions.origin.autoHint': 'TREK načítal tento región sám pre vodácku trasu, ktorá sa tam plánuje.', }; export default admin; diff --git a/shared/src/i18n/sk/externalNotifications.ts b/shared/src/i18n/sk/externalNotifications.ts index 33e60d4c7..c8109f0d2 100644 --- a/shared/src/i18n/sk/externalNotifications.ts +++ b/shared/src/i18n/sk/externalNotifications.ts @@ -73,6 +73,10 @@ const sk: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Vyžiadaný vodácky región', + body: `${p.actor} plánuje vodácku trasu v regióne ${p.region}. Načítaj alebo zamietni jeho vodné dáta v administrácii v časti Doplnky.`, + }), synology_session_cleared: () => ({ title: 'Relácia Synology bola zrušená', body: 'Váš účet alebo URL Synology sa zmenili. Boli ste odhlásení zo Synology Photos.', diff --git a/shared/src/i18n/sk/notif.ts b/shared/src/i18n/sk/notif.ts index 24dc51d6a..4f570ccba 100644 --- a/shared/src/i18n/sk/notif.ts +++ b/shared/src/i18n/sk/notif.ts @@ -33,6 +33,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Zápis do repliky na '{backend}' zlyhal: {op} položky {key} — {error}", 'notif.replica_failure.textSuppressed': "Zápis do repliky na '{backend}' zlyhal: {op} položky {key} — {error}. Od posledného oznámenia bolo potlačených {suppressed} ďalších zlyhaní.", + 'notif.paddle_region_requested.title': 'Vyžiadaný vodácky región', + 'notif.paddle_region_requested.text': '{actor} plánuje vodácku trasu v regióne {region}', 'notif.action.view_trip': 'Zobraziť výlet', 'notif.action.view_collab': 'Zobraziť správy', 'notif.action.view_packing': 'Zobraziť balenie', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index ade427f74..62cc8dc8e 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -119,6 +119,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook oznámenia', 'settings.notifyVersionAvailable': 'Nová verzia k dispozícii', 'settings.notifyReplicaFailure': 'Zlyhanie repliky úložiska', + 'settings.notifyPaddleRegionRequested': 'Vyžiadaný vodácky región', 'settings.notificationPreferences.email': 'E-mail', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'V aplikácii', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index fc6783655..85ca31d96 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -288,8 +288,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Zadajte rýchlosť od 0,5 do 20 km/h.', 'tours.paddle.chooseBoat': 'Vyberte loď a vodu, aby ste mohli túto túru uložiť.', 'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu na import ako vodáckej túry.', - 'tours.paddle.drawnByHand': - 'Vodácka túra sa kreslí ručne: TREK spojí vaše body priamymi čiarami. Nič sa nepočíta a nič nehovorí, že je voda splavná.', 'tours.paddle.timeEstimated': 'Čas na vode (odhad)', 'tours.paddle.timeEstimatedShort': 'Pádlovanie', 'tours.paddle.timeEstimatedInfoLabel': 'Ako sa odhaduje čas na vode', @@ -336,6 +334,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Prenáška', 'tours.paddle.portageOn': 'Odtiaľto loď preniesť', 'tours.paddle.portageOff': 'Tento úsek znova pádlovať', + 'tours.paddle.portagePending': 'Odtiaľto prenášať', + 'tours.paddle.portagePendingHint': 'Teraz nastav bod, kde sa znova nalodíš. Loď sa prenáša až tam.', + 'tours.paddle.portagePendingOff': 'Nakoniec neprenášať', + 'tours.paddle.portageWay': 'Priebeh', + 'tours.paddle.portageWay.follow': 'Sledovať cestu', + 'tours.paddle.portageWay.straight': 'Priama čiara', + 'tours.paddle.portageWay.noWay': 'Žiadna cesta bez veľkej obchádzky, čiara zostáva priama.', + 'tours.paddle.portageWay.searching': 'Hľadá sa cesta', + 'tours.paddle.placeHere.putIn': 'Tu sa nalodiť', + 'tours.paddle.placeHere.egress': 'Tu vystúpiť', 'tours.paddle.portageInfo': 'Tu sa loď prenáša. Úsek sa počíta ako súš, s časom na vystúpenie, vyloženie a opätovné spustenie na vodu.', 'tours.paddle.portageExtra': 'Výstup a nástup', @@ -535,5 +543,60 @@ const tours: TranslationStrings = { 'tours.paddle.obstacles.covered.few': '{count} prekážky a prístupové miesta pozdĺž trasy, z OpenStreetMap, stav k {date}. Údaje môžu byť neúplné, preto vodu skontrolujte sami.', 'tours.paddle.time.locksMissing.few': '{count} plavebné komory ešte nemajú časy.', + 'tours.planner.shapingPoint': 'Tvarovací bod', + 'tours.planner.shapingHint': 'Usmerňuje trasu bez toho, aby bol zastávkou. Potiahni ho tam, kadiaľ má trasa viesť.', + 'tours.planner.makeStop': 'Urobiť zastávkou', + 'tours.planner.makeShaping': 'Urobiť tvarovacím bodom', + 'tours.planner.recomputeLeg': 'Znova vypočítať ďalší úsek', + 'tours.planner.shapeOnLine': 'Kliknutím na trasu v mape tam pridáš tvarovací bod.', + 'tours.paddle.routeOnWater': 'Naplánovať ďalší úsek po vode', + 'tours.paddle.routeAllOnWater': 'Naplánovať ručne kreslené úseky po vode', + 'tours.paddle.auto': 'Nájdené', + 'tours.paddle.autoHint': + 'TREK umiestnil tento bod k prenáške alebo plavebnej komore, ktorú našiel po ceste. Keď ho presunieš alebo zmeníš, bude tvoj.', + 'tours.paddle.note.rapids': 'Divoká voda {grade}', + 'tours.paddle.note.pass': 'Vodácky priepust', + 'tours.paddle.note.gap': 'Medzera v mapových údajoch', + 'tours.paddle.note.rapidsHint': + 'Trasa tu vedie prúdiacou vodou. Najprv si miesto obzri a v prípade pochybností prenášaj.', + 'tours.paddle.note.gapHint': 'Údaje o vode tu majú medzeru, preklenutú priamou čiarou. Over si to na mieste.', + 'tours.paddle.failed.not_on_water': + 'Jeden koniec tohto úseku neleží na žiadnej vode, ktorú TREK pozná. Presuň ho na rieku alebo jazero.', + 'tours.paddle.failed.no_region': 'Pre túto oblasť zatiaľ nie sú údaje o vode. Administrátor môže región pridať.', + 'tours.paddle.failed.building': 'Údaje o vode pre tento región sa ešte pripravujú. Skús to znova o pár minút.', + 'tours.paddle.failed.no_water_path': 'TREK medzi týmito bodmi nenašiel cestu po vode.', + 'tours.paddle.failed.cross_region': 'Tieto body ležia v dvoch regiónoch bez spoločných údajov o vode.', + 'tours.paddle.failed.busy': 'Plánovač trás je vyťažený. Skús to o chvíľu znova.', + 'tours.paddle.failed.timeout': 'Plánovanie tohto úseku trvalo príliš dlho. Pridaj medzi ne bod a skús to znova.', + 'tours.paddle.failed.offline': 'Bez pripojenia. Úsek sa naplánuje, keď budeš znova pripojený.', + 'tours.paddle.drawStraight': 'Nakresliť rovno', + 'tours.paddle.retry': 'Skúsiť znova', + 'tours.paddle.load.loading': 'Načítavajú sa vodné dáta pre {region}', + 'tours.paddle.load.ready': 'Vodné dáta pre {region} sú tu, úsek sa plánuje.', + 'tours.paddle.load.requested': 'Vodné dáta pre {region} sú vyžiadané. Úsek sa naplánuje, hneď ako ich správca pustí.', + 'tours.paddle.load.waiting': 'Čaká na iné regióny', + 'tours.paddle.load.phase.download': 'Sťahovanie', + 'tours.paddle.load.phase.read': 'Čítanie', + 'tours.paddle.load.phase.build': 'Stavia sa vodná sieť', + 'tours.paddle.load.hint': 'Zvyčajne jedna až tri minúty. Pokojne plánuj ďalej, úsek sa potom naplánuje sám.', + 'tours.paddle.load.reason.tooLarge': 'Región je väčší, než TREK sám načítava.', + 'tours.paddle.load.reason.limit': 'TREK dnes už sám načítal dosť regiónov.', + 'tours.paddle.load.declined': 'Správca zamietol vodné dáta pre {region}.', + 'tours.paddle.load.failed': 'Vodné dáta pre {region} sa nepodarilo načítať.', + 'tours.paddle.load.noWater': 'Vo vodných dátach pre {region} tu nie je voda. Daj bod na rieku alebo jazero.', + 'tours.paddle.load.noExtract': 'Pre túto oblasť neexistuje výrez mapy s vodnými dátami.', + 'tours.paddle.load.catalogUnavailable': 'Zoznam regiónov teraz nie je dostupný. Skús to neskôr.', + 'tours.paddle.lakeStyle': 'Cez jazerá', + 'tours.paddle.lakeStyle.direct': 'Priamo', + 'tours.paddle.lakeStyle.shore': 'Pozdĺž brehu', + 'tours.paddle.lakeStyle.directHint': 'Najkratšia čiara cez otvorenú vodu.', + 'tours.paddle.lakeStyle.shoreHint': 'Drží sa pri brehu, na veterné dni: dlhšie, ale pevnina nie je nikdy ďaleko.', + 'tours.paddle.wind.shoreAdvice': + 'V tento deň až {mean} km/h, nárazy {gusts} km/h. Na jazerách môže byť lepšie držať sa pri brehu.', + 'tours.paddle.wind.shoreAdopt': 'Naplánovať pozdĺž brehu', + 'tours.paddle.wind.shoreTaken': 'Jazerá sú naplánované pozdĺž brehu.', + 'tours.paddle.status.unrouted': '{count} úsekov nenaplánovaných', + 'tours.paddle.status.unrouted.one': '{count} úsek nenaplánovaný', + 'tours.paddle.status.unrouted.few': '{count} úseky nenaplánované', }; export default tours; diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 0e02b5b04..282a1874c 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -886,5 +886,43 @@ const admin: TranslationStrings = { 'Data © OpenStreetMap-bidragsgivare (ODbL), regionala utdrag från Geofabrik. Läses in igen den 3:e varje månad.', 'admin.paddleRegions.toast.added': 'Region tillagd. TREK läser in den i bakgrunden.', 'admin.paddleRegions.toast.removed': 'Region borttagen', + 'admin.paddleRegions.phase.build': 'Bygger vattennätet', + 'admin.paddleRegions.network.km': '{km} km vatten', + 'admin.paddleRegions.network.lakes': '{count} sjöar', + 'admin.paddleRegions.network.lakes.one': '{count} sjö', + 'admin.paddleRegions.network.portages': '{count} kartlagda bärningar', + 'admin.paddleRegions.network.portages.one': '{count} kartlagd bärning', + 'admin.paddleRegions.network.inferred': '{count} antagna bärningar', + 'admin.paddleRegions.network.inferred.one': '{count} antagen bärning', + 'admin.paddleRegions.network.locks': '{count} slussar', + 'admin.paddleRegions.network.locks.one': '{count} sluss', + 'admin.paddleRegions.network.gaps': '{count} överbryggade luckor', + 'admin.paddleRegions.network.gaps.one': '{count} överbryggad lucka', + 'admin.paddleRegions.network.missing': 'Inget vattennät', + 'admin.paddleRegions.network.missingHint': 'Läs in regionen igen för att bygga nätet som paddelrutten följer.', + 'admin.paddleRegions.auto.title': 'Läs in regioner automatiskt', + 'admin.paddleRegions.auto.mode.auto': 'Automatiskt', + 'admin.paddleRegions.auto.mode.ask': 'På begäran', + 'admin.paddleRegions.auto.mode.off': 'Av', + 'admin.paddleRegions.auto.modeHint.auto': + 'När någon planerar en paddeltur där det ännu inte finns vattendata läser TREK direkt in den minsta passande regionen, upp till storleksgränsen och bara några om dagen. Större väntar här som förfrågan. Bara regionens namn går till Geofabrik, aldrig platsen.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Varje ny region väntar här tills en administratör släpper igenom den. När en administratör planerar läses den in direkt.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK läser inte in något av sig själv och frågar inte efter något. Lägg till regioner här för hand.', + 'admin.paddleRegions.auto.maxSize': 'Största region som läses in automatiskt', + 'admin.paddleRegions.auto.maxSizeHint': + 'Större regioner väntar här som förfrågan. Att läsa in en region tar några minuter och tar minne under tiden.', + 'admin.paddleRegions.requests.title': 'Efterfrågade', + 'admin.paddleRegions.requests.reason.ask': 'På begäran', + 'admin.paddleRegions.requests.reason.too_large': 'För stor', + 'admin.paddleRegions.requests.reason.limit': 'Dagsgräns', + 'admin.paddleRegions.requests.declined': 'Avböjd', + 'admin.paddleRegions.requests.by': 'av {user}', + 'admin.paddleRegions.requests.approve': 'Läs in', + 'admin.paddleRegions.requests.decline': 'Avböj', + 'admin.paddleRegions.origin.auto': 'Automatiskt', + 'admin.paddleRegions.origin.autoHint': + 'TREK läste in den här regionen av sig själv för en paddeltur som planeras där.', }; export default admin; diff --git a/shared/src/i18n/sv/externalNotifications.ts b/shared/src/i18n/sv/externalNotifications.ts index 886ced05d..71147b54c 100644 --- a/shared/src/i18n/sv/externalNotifications.ts +++ b/shared/src/i18n/sv/externalNotifications.ts @@ -67,6 +67,10 @@ const en: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Paddelregion efterfrågad', + body: `${p.actor} planerar en paddeltur i ${p.region}. Läs in eller avböj dess vattendata i administrationen under Tillägg.`, + }), synology_session_cleared: () => ({ title: 'Synology session rensad', body: 'Ditt Synology-konto eller din webbadress har ändrats. Du har loggats ut från Synology Photos.', diff --git a/shared/src/i18n/sv/notif.ts b/shared/src/i18n/sv/notif.ts index 07ddfda83..ff41860b1 100644 --- a/shared/src/i18n/sv/notif.ts +++ b/shared/src/i18n/sv/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Skrivning till replik '{backend}' misslyckades: {op} för {key} — {error}", 'notif.replica_failure.textSuppressed': "Skrivning till replik '{backend}' misslyckades: {op} för {key} — {error}. {suppressed} ytterligare fel har undertryckts sedan senaste aviseringen.", + 'notif.paddle_region_requested.title': 'Paddelregion efterfrågad', + 'notif.paddle_region_requested.text': '{actor} planerar en paddeltur i {region}', 'notif.action.view_trip': 'Visa resan', 'notif.action.view_collab': 'Visa meddelanden', 'notif.action.view_packing': 'Visa förpackning', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index f24137ed5..ff07c938e 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -112,6 +112,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook meddelanden', 'settings.notifyVersionAvailable': 'Ny version tillgänglig', 'settings.notifyReplicaFailure': 'Fel i lagringsreplik', + 'settings.notifyPaddleRegionRequested': 'Paddelregion efterfrågad', 'settings.notificationPreferences.email': 'E-post', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'I appen', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index e71a70103..b0088655c 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -285,8 +285,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Ange ett tempo från 0,5 till 20 km/h.', 'tours.paddle.chooseBoat': 'Välj båt och vatten för att spara paddelturen.', 'tours.paddle.chooseBoatImport': 'Välj båt och vatten för att importera som paddeltur.', - 'tours.paddle.drawnByHand': - 'En paddeltur ritas för hand: TREK förbinder dina punkter med raka linjer. Inget ruttas, och inget säger att vattnet går att paddla.', 'tours.paddle.timeEstimated': 'Paddeltid (uppskattad)', 'tours.paddle.timeEstimatedShort': 'Paddling', // same-as-en 'tours.paddle.timeEstimatedInfoLabel': 'Så uppskattas paddeltiden', @@ -329,6 +327,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Lyft', 'tours.paddle.portageOn': 'Bär båten härifrån', 'tours.paddle.portageOff': 'Paddla den här sträckan igen', + 'tours.paddle.portagePending': 'Bär härifrån', + 'tours.paddle.portagePendingHint': 'Sätt nu punkten där du sjösätter igen. Båten bärs dit.', + 'tours.paddle.portagePendingOff': 'Bär inte ändå', + 'tours.paddle.portageWay': 'Sträckning', + 'tours.paddle.portageWay.follow': 'Följ stigen', + 'tours.paddle.portageWay.straight': 'Rak linje', + 'tours.paddle.portageWay.noWay': 'Ingen stig utan stor omväg, linjen förblir rak.', + 'tours.paddle.portageWay.searching': 'Söker en stig', + 'tours.paddle.placeHere.putIn': 'Sjösätt här', + 'tours.paddle.placeHere.egress': 'Ta upp här', 'tours.paddle.portageInfo': 'Här bärs båten. Sträckan räknas som land, med tid för att kliva ur, lasta ur och sätta i igen.', 'tours.paddle.portageExtra': 'Ur och i', @@ -522,5 +530,61 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Paddling innebär risker. Kontrollera själv vattnet, reglerna och vädret.', 'tours.paddle.safety.text': 'Paddling innebär risker. Kartor, ruttförslag och anvisningar kan vara ofullständiga eller inaktuella. Kontrollera före turen de aktuella lokala förhållandena, reglerna för att färdas på vattnet och vädret. Vidta lämpliga säkerhetsåtgärder. TREK stöder din planering och ersätter inte din egen kontroll och ditt ansvarsfulla beslut.', + 'tours.planner.shapingPoint': 'Formpunkt', + 'tours.planner.shapingHint': 'Styr rutten utan att vara ett stopp. Dra den dit rutten ska gå.', + 'tours.planner.makeStop': 'Gör till stopp', + 'tours.planner.makeShaping': 'Gör till formpunkt', + 'tours.planner.recomputeLeg': 'Beräkna nästa etapp igen', + 'tours.planner.shapeOnLine': 'Klicka på rutten på kartan för att lägga till en formpunkt där.', + 'tours.paddle.routeOnWater': 'Planera nästa etapp på vattnet', + 'tours.paddle.routeAllOnWater': 'Planera handritade etapper på vattnet', + 'tours.paddle.auto': 'Hittad', + 'tours.paddle.autoHint': + 'TREK satte den här punkten vid en bärning eller sluss som hittades på vägen. Flyttar eller ändrar du den blir den din.', + 'tours.paddle.note.rapids': 'Forsvatten {grade}', + 'tours.paddle.note.pass': 'Kanotränna', + 'tours.paddle.note.gap': 'Lucka i kartdata', + 'tours.paddle.note.rapidsHint': + 'Rutten går här genom strömmande vatten. Rekognosera först och bär runt om du är osäker.', + 'tours.paddle.note.gapHint': 'Vattendata har en lucka här, överbryggad med en rak linje. Kontrollera på plats.', + 'tours.paddle.failed.not_on_water': + 'Ena änden av etappen ligger inte på något vatten TREK känner till. Flytta den till ån eller sjön.', + 'tours.paddle.failed.no_region': 'Ännu inga vattendata för området. En administratör kan lägga till regionen.', + 'tours.paddle.failed.building': 'Vattendata för regionen förbereds fortfarande. Försök igen om några minuter.', + 'tours.paddle.failed.no_water_path': 'TREK hittade ingen väg på vattnet mellan de här punkterna.', + 'tours.paddle.failed.cross_region': 'Punkterna ligger i två regioner utan gemensamma vattendata.', + 'tours.paddle.failed.busy': 'Ruttplaneraren är upptagen. Försök igen om en stund.', + 'tours.paddle.failed.timeout': 'Planeringen av etappen tog för lång tid. Lägg till en punkt emellan och försök igen.', + 'tours.paddle.failed.offline': 'Ingen anslutning. Etappen planeras när du är ansluten igen.', + 'tours.paddle.drawStraight': 'Rita rakt', + 'tours.paddle.retry': 'Försök igen', + 'tours.paddle.load.loading': 'Vattendata för {region} läses in', + 'tours.paddle.load.ready': 'Vattendata för {region} finns nu, sträckan planeras.', + 'tours.paddle.load.requested': + 'Vattendata för {region} är efterfrågade. Sträckan planeras så snart en administratör släpper igenom dem.', + 'tours.paddle.load.waiting': 'Väntar på andra regioner', + 'tours.paddle.load.phase.download': 'Laddar ner', + 'tours.paddle.load.phase.read': 'Läser', + 'tours.paddle.load.phase.build': 'Bygger vattennätet', + 'tours.paddle.load.hint': 'Oftast en till tre minuter. Planera vidare, sträckan planeras av sig själv efteråt.', + 'tours.paddle.load.reason.tooLarge': 'Regionen är större än vad TREK läser in av sig själv.', + 'tours.paddle.load.reason.limit': 'TREK har redan läst in tillräckligt många regioner av sig själv i dag.', + 'tours.paddle.load.declined': 'En administratör avböjde vattendata för {region}.', + 'tours.paddle.load.failed': 'Vattendata för {region} kunde inte läsas in.', + 'tours.paddle.load.noWater': 'I vattendata för {region} finns inget vatten här. Sätt punkten på floden eller sjön.', + 'tours.paddle.load.noExtract': 'Det finns inget kartutdrag med vattendata för det här området.', + 'tours.paddle.load.catalogUnavailable': 'Listan med regioner går inte att nå just nu. Försök igen senare.', + 'tours.paddle.lakeStyle': 'Över sjöar', + 'tours.paddle.lakeStyle.direct': 'Rakt över', + 'tours.paddle.lakeStyle.shore': 'Nära stranden', + 'tours.paddle.lakeStyle.directHint': 'Den kortaste linjen över öppet vatten.', + 'tours.paddle.lakeStyle.shoreHint': + 'Håller sig nära stranden, för blåsiga dagar: längre, men land är aldrig långt bort.', + 'tours.paddle.wind.shoreAdvice': + 'Upp till {mean} km/h, byar på {gusts} km/h den här dagen. På sjöarna kan det vara bättre att hålla sig nära stranden.', + 'tours.paddle.wind.shoreAdopt': 'Planera nära stranden', + 'tours.paddle.wind.shoreTaken': 'Sjöarna planeras nära stranden.', + 'tours.paddle.status.unrouted': '{count} etapper inte planerade', + 'tours.paddle.status.unrouted.one': '{count} etapp inte planerad', }; export default tours; diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index e90cc07c2..c26c2d934 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -841,5 +841,36 @@ const admin: TranslationStrings = { 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap (ODbL) ชุดข้อมูลรายภูมิภาคจาก Geofabrik อ่านใหม่ทุกวันที่ 3 ของเดือน', 'admin.paddleRegions.toast.added': 'เพิ่มภูมิภาคแล้ว TREK จะอ่านข้อมูลในเบื้องหลัง', 'admin.paddleRegions.toast.removed': 'ลบภูมิภาคแล้ว', + 'admin.paddleRegions.phase.build': 'กำลังสร้างเครือข่ายแหล่งน้ำ', + 'admin.paddleRegions.network.km': 'แหล่งน้ำ {km} km', + 'admin.paddleRegions.network.lakes': 'ทะเลสาบ {count} แห่ง', + 'admin.paddleRegions.network.portages': 'จุดแบกเรือที่ทำแผนที่ไว้ {count} แห่ง', + 'admin.paddleRegions.network.inferred': 'การแบกเรือที่สันนิษฐาน {count} ครั้ง', + 'admin.paddleRegions.network.locks': 'ประตูน้ำ {count} แห่ง', + 'admin.paddleRegions.network.gaps': 'ช่องว่างที่เชื่อมแล้ว {count} แห่ง', + 'admin.paddleRegions.network.missing': 'ไม่มีเครือข่ายแหล่งน้ำ', + 'admin.paddleRegions.network.missingHint': 'อ่านภูมิภาคอีกครั้งเพื่อสร้างเครือข่ายที่เส้นทางพายเรือใช้', + 'admin.paddleRegions.auto.title': 'โหลดภูมิภาคอัตโนมัติ', + 'admin.paddleRegions.auto.mode.auto': 'อัตโนมัติ', + 'admin.paddleRegions.auto.mode.ask': 'ตามคำขอ', + 'admin.paddleRegions.auto.mode.off': 'ปิด', + 'admin.paddleRegions.auto.modeHint.auto': + 'เมื่อมีคนวางแผนทริปพายเรือในที่ที่ยังไม่มีข้อมูลแหล่งน้ำ TREK จะโหลดภูมิภาคที่เล็กที่สุดที่เหมาะสมทันที ไม่เกินขนาดที่กำหนดและเพียงไม่กี่แห่งต่อวัน ภูมิภาคที่ใหญ่กว่าจะรออยู่ที่นี่เป็นคำขอ Geofabrik ได้รับเพียงชื่อภูมิภาค ไม่เคยได้รับตำแหน่ง', + 'admin.paddleRegions.auto.modeHint.ask': + 'ภูมิภาคใหม่ทุกแห่งจะรออยู่ที่นี่จนกว่าผู้ดูแลจะอนุญาต หากผู้ดูแลวางแผนเอง จะโหลดทันที', + 'admin.paddleRegions.auto.modeHint.off': 'TREK จะไม่โหลดอะไรเองและไม่ขออะไร เพิ่มภูมิภาคที่นี่ด้วยตนเอง', + 'admin.paddleRegions.auto.maxSize': 'ภูมิภาคที่ใหญ่ที่สุดที่โหลดอัตโนมัติ', + 'admin.paddleRegions.auto.maxSizeHint': + 'ภูมิภาคที่ใหญ่กว่าจะรออยู่ที่นี่เป็นคำขอ การอ่านภูมิภาคใช้เวลาสองสามนาทีและใช้หน่วยความจำระหว่างนั้น', + 'admin.paddleRegions.requests.title': 'คำขอ', + 'admin.paddleRegions.requests.reason.ask': 'ตามคำขอ', + 'admin.paddleRegions.requests.reason.too_large': 'ใหญ่เกินไป', + 'admin.paddleRegions.requests.reason.limit': 'ขีดจำกัดรายวัน', + 'admin.paddleRegions.requests.declined': 'ถูกปฏิเสธ', + 'admin.paddleRegions.requests.by': 'โดย {user}', + 'admin.paddleRegions.requests.approve': 'โหลด', + 'admin.paddleRegions.requests.decline': 'ปฏิเสธ', + 'admin.paddleRegions.origin.auto': 'อัตโนมัติ', + 'admin.paddleRegions.origin.autoHint': 'TREK โหลดภูมิภาคนี้เองสำหรับทริปพายเรือที่วางแผนไว้ที่นั่น', }; export default admin; diff --git a/shared/src/i18n/th/externalNotifications.ts b/shared/src/i18n/th/externalNotifications.ts index dd6a688a9..c72fa9c51 100644 --- a/shared/src/i18n/th/externalNotifications.ts +++ b/shared/src/i18n/th/externalNotifications.ts @@ -60,6 +60,10 @@ const th: NotificationLocale = { ? ` ตั้งแต่การแจ้งเตือนครั้งก่อน ยังมีความล้มเหลวอีก ${p.suppressed} ครั้งที่ไม่ได้แจ้งเตือนแยก` : ''), }), + paddle_region_requested: (p) => ({ + title: 'มีคำขอภูมิภาคพายเรือ', + body: `${p.actor} กำลังวางแผนทริปพายเรือใน ${p.region} โหลดหรือปฏิเสธข้อมูลแหล่งน้ำได้ในส่วนผู้ดูแลที่ส่วนเสริม`, + }), synology_session_cleared: () => ({ title: 'ล้างเซสชัน Synology แล้ว', body: 'บัญชีหรือ URL ของ Synology เปลี่ยนแปลง คุณจึงออกจากระบบ Synology Photos แล้ว', diff --git a/shared/src/i18n/th/notif.ts b/shared/src/i18n/th/notif.ts index 02978f441..d9cec8916 100644 --- a/shared/src/i18n/th/notif.ts +++ b/shared/src/i18n/th/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "การเขียนแบบจำลองล้มเหลวใน '{backend}': {op} จาก {key} — {error}", 'notif.replica_failure.textSuppressed': "การเขียนแบบจำลองล้มเหลวใน '{backend}': {op} จาก {key} — {error} {suppressed} ความล้มเหลวเพิ่มขึ้นถูกระงับนับตั้งแต่การแจ้งเตือนครั้งล่าสุด", + 'notif.paddle_region_requested.title': 'มีคำขอภูมิภาคพายเรือ', + 'notif.paddle_region_requested.text': '{actor} กำลังวางแผนทริปพายเรือใน {region}', 'notif.action.view_trip': 'ดูการเดินทาง', 'notif.action.view_collab': 'ดูข้อความ', 'notif.action.view_packing': 'ดูการบรรจุ', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index 5ad4a0736..0878e62e4 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -607,6 +607,7 @@ const settings: TranslationStrings = { 'settings.notifySynologySessionCleared': 'ล้างเซสชัน Synology แล้ว', 'settings.notifyPluginNotification': 'การแจ้งเตือนจากปลั๊กอิน', 'settings.notifyReplicaFailure': 'การทำสำเนาที่เก็บข้อมูลล้มเหลว', + 'settings.notifyPaddleRegionRequested': 'มีคำขอภูมิภาคพายเรือ', 'settings.apiScopes.title': 'สิ่งที่คีย์นี้อ่านได้', 'settings.apiScopes.hint': 'เปิดทุกอย่างไว้สำหรับคีย์ที่ควรเห็นข้อมูลทั้งหมด สิ่งที่คุณปิดจะถูกปฏิเสธสำหรับคีย์นี้ ไม่ใช่เพียงไม่แสดงในคำตอบ', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index ac3584765..f2f6cc0fd 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -274,8 +274,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'ใส่ความเร็วระหว่าง 0.5 ถึง 20 กม./ชม.', 'tours.paddle.chooseBoat': 'เลือกเรือและแหล่งน้ำเพื่อบันทึกทริปพายเรือนี้', 'tours.paddle.chooseBoatImport': 'เลือกเรือและแหล่งน้ำเพื่อนำเข้าเป็นทริปพายเรือ', - 'tours.paddle.drawnByHand': - 'ทริปพายเรือวาดด้วยมือ: TREK เชื่อมจุดของคุณด้วยเส้นตรง ไม่มีการคำนวณเส้นทาง และไม่มีอะไรบอกว่าแหล่งน้ำพายผ่านได้', 'tours.paddle.timeEstimated': 'เวลาพาย (โดยประมาณ)', 'tours.paddle.timeEstimatedShort': 'พายเรือ', 'tours.paddle.timeEstimatedInfoLabel': 'วิธีประมาณเวลาพาย', @@ -312,6 +310,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'การแบกเรือ', 'tours.paddle.portageOn': 'แบกเรือจากตรงนี้', 'tours.paddle.portageOff': 'กลับมาพายช่วงนี้', + 'tours.paddle.portagePending': 'แบกเรือจากตรงนี้', + 'tours.paddle.portagePendingHint': 'ตอนนี้ตั้งจุดที่คุณจะลงน้ำอีกครั้ง เรือจะถูกแบกไปจนถึงที่นั่น', + 'tours.paddle.portagePendingOff': 'ไม่แบกเรือแล้ว', + 'tours.paddle.portageWay': 'เส้นทาง', + 'tours.paddle.portageWay.follow': 'ตามทางเดิน', + 'tours.paddle.portageWay.straight': 'เส้นตรง', + 'tours.paddle.portageWay.noWay': 'ไม่พบทางที่ไม่อ้อมไกล จึงคงเป็นเส้นตรง', + 'tours.paddle.portageWay.searching': 'กำลังค้นหาทาง', + 'tours.paddle.placeHere.putIn': 'ลงน้ำที่นี่', + 'tours.paddle.placeHere.egress': 'ขึ้นจากน้ำที่นี่', 'tours.paddle.portageInfo': 'ช่วงนี้ต้องแบกเรือ จึงนับเป็นทางบก โดยรวมเวลาขึ้นจากเรือ ขนของออก และลงเรืออีกครั้ง', 'tours.paddle.portageExtra': 'ขึ้นและลงเรือ', 'tours.paddle.lock': 'ประตูน้ำ', @@ -497,5 +505,57 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'การพายเรือมีความเสี่ยง โปรดตรวจสอบแหล่งน้ำ กฎ และสภาพอากาศด้วยตนเอง', 'tours.paddle.safety.text': 'การพายเรือมีความเสี่ยง แผนที่ เส้นทางที่แนะนำ และหมายเหตุอาจไม่ครบถ้วนหรือล้าสมัย ก่อนออกทัวร์ โปรดตรวจสอบสภาพปัจจุบันในพื้นที่ กฎการใช้แหล่งน้ำ และสภาพอากาศ ใช้มาตรการความปลอดภัยที่เหมาะสม TREK ช่วยสนับสนุนการวางแผนของคุณ แต่ไม่ได้แทนที่การตรวจสอบและการตัดสินใจอย่างรับผิดชอบของคุณเอง', + 'tours.planner.shapingPoint': 'จุดกำหนดเส้นทาง', + 'tours.planner.shapingHint': 'นำทางเส้นทางโดยไม่เป็นจุดแวะ ลากไปยังที่ที่เส้นทางควรผ่าน', + 'tours.planner.makeStop': 'เปลี่ยนเป็นจุดแวะ', + 'tours.planner.makeShaping': 'เปลี่ยนเป็นจุดกำหนดเส้นทาง', + 'tours.planner.recomputeLeg': 'คำนวณช่วงถัดไปใหม่', + 'tours.planner.shapeOnLine': 'คลิกเส้นทางบนแผนที่เพื่อเพิ่มจุดกำหนดเส้นทางที่นั่น', + 'tours.paddle.routeOnWater': 'วางแผนช่วงถัดไปบนผืนน้ำ', + 'tours.paddle.routeAllOnWater': 'วางแผนช่วงที่วาดด้วยมือบนผืนน้ำ', + 'tours.paddle.auto': 'ตรวจพบ', + 'tours.paddle.autoHint': + 'TREK วางจุดนี้ไว้ที่จุดแบกเรือหรือประตูน้ำที่พบระหว่างทาง หากคุณย้ายหรือแก้ไข จุดนี้จะเป็นของคุณ', + 'tours.paddle.note.rapids': 'แก่งน้ำระดับ {grade}', + 'tours.paddle.note.pass': 'ช่องทางเรือแคนู', + 'tours.paddle.note.gap': 'ช่องว่างในข้อมูลแผนที่', + 'tours.paddle.note.rapidsHint': 'เส้นทางผ่านน้ำไหลแรงตรงนี้ สำรวจก่อนและแบกเรืออ้อมหากไม่แน่ใจ', + 'tours.paddle.note.gapHint': 'ข้อมูลแหล่งน้ำมีช่องว่างตรงนี้ เชื่อมด้วยเส้นตรง โปรดตรวจสอบในพื้นที่', + 'tours.paddle.failed.not_on_water': + 'ปลายด้านหนึ่งของช่วงนี้ไม่ได้อยู่บนแหล่งน้ำที่ TREK รู้จัก ย้ายไปไว้บนแม่น้ำหรือทะเลสาบ', + 'tours.paddle.failed.no_region': 'ยังไม่มีข้อมูลแหล่งน้ำสำหรับพื้นที่นี้ ผู้ดูแลระบบสามารถเพิ่มภูมิภาคได้', + 'tours.paddle.failed.building': 'ข้อมูลแหล่งน้ำของภูมิภาคนี้ยังเตรียมอยู่ ลองอีกครั้งในอีกไม่กี่นาที', + 'tours.paddle.failed.no_water_path': 'TREK ไม่พบเส้นทางบนผืนน้ำระหว่างจุดเหล่านี้', + 'tours.paddle.failed.cross_region': 'จุดเหล่านี้อยู่ในสองภูมิภาคที่ไม่มีข้อมูลแหล่งน้ำร่วมกัน', + 'tours.paddle.failed.busy': 'ตัววางแผนเส้นทางไม่ว่าง ลองอีกครั้งในอีกสักครู่', + 'tours.paddle.failed.timeout': 'การวางแผนช่วงนี้ใช้เวลานานเกินไป เพิ่มจุดคั่นกลางแล้วลองอีกครั้ง', + 'tours.paddle.failed.offline': 'ออฟไลน์ ช่วงนี้จะถูกวางแผนเมื่อคุณเชื่อมต่ออีกครั้ง', + 'tours.paddle.drawStraight': 'วาดเป็นเส้นตรง', + 'tours.paddle.retry': 'ลองอีกครั้ง', + 'tours.paddle.load.loading': 'กำลังโหลดข้อมูลแหล่งน้ำของ {region}', + 'tours.paddle.load.ready': 'ข้อมูลแหล่งน้ำของ {region} มาแล้ว กำลังวางแผนช่วงนี้', + 'tours.paddle.load.requested': 'ขอข้อมูลแหล่งน้ำของ {region} แล้ว ช่วงนี้จะถูกวางแผนทันทีที่ผู้ดูแลอนุญาต', + 'tours.paddle.load.waiting': 'กำลังรอภูมิภาคอื่น', + 'tours.paddle.load.phase.download': 'กำลังดาวน์โหลด', + 'tours.paddle.load.phase.read': 'กำลังอ่าน', + 'tours.paddle.load.phase.build': 'กำลังสร้างเครือข่ายแหล่งน้ำ', + 'tours.paddle.load.hint': 'ปกติใช้เวลาหนึ่งถึงสามนาที วางแผนต่อได้เลย ช่วงนี้จะถูกวางแผนเองภายหลัง', + 'tours.paddle.load.reason.tooLarge': 'ภูมิภาคนี้ใหญ่กว่าที่ TREK โหลดเอง', + 'tours.paddle.load.reason.limit': 'วันนี้ TREK โหลดภูมิภาคเองมากพอแล้ว', + 'tours.paddle.load.declined': 'ผู้ดูแลปฏิเสธข้อมูลแหล่งน้ำของ {region}', + 'tours.paddle.load.failed': 'ไม่สามารถโหลดข้อมูลแหล่งน้ำของ {region}', + 'tours.paddle.load.noWater': 'ข้อมูลแหล่งน้ำของ {region} ไม่มีน้ำตรงนี้ วางจุดบนแม่น้ำหรือทะเลสาบ', + 'tours.paddle.load.noExtract': 'ไม่มีแผนที่ส่วนย่อยที่มีข้อมูลแหล่งน้ำสำหรับพื้นที่นี้', + 'tours.paddle.load.catalogUnavailable': 'ไม่สามารถเข้าถึงรายการภูมิภาคได้ในขณะนี้ ลองอีกครั้งภายหลัง', + 'tours.paddle.lakeStyle': 'ข้ามทะเลสาบ', + 'tours.paddle.lakeStyle.direct': 'ตรงไป', + 'tours.paddle.lakeStyle.shore': 'ใกล้ฝั่ง', + 'tours.paddle.lakeStyle.directHint': 'เส้นที่สั้นที่สุดข้ามผืนน้ำเปิด', + 'tours.paddle.lakeStyle.shoreHint': 'อยู่ใกล้ฝั่ง สำหรับวันที่มีลม: ยาวกว่าแต่แผ่นดินไม่เคยไกล', + 'tours.paddle.wind.shoreAdvice': + 'วันนี้ลมสูงสุด {mean} km/h กระโชก {gusts} km/h บนทะเลสาบการอยู่ใกล้ฝั่งอาจเป็นเส้นทางที่ดีกว่า', + 'tours.paddle.wind.shoreAdopt': 'วางแผนใกล้ฝั่ง', + 'tours.paddle.wind.shoreTaken': 'ทะเลสาบถูกวางแผนให้อยู่ใกล้ฝั่ง', + 'tours.paddle.status.unrouted': 'ยังไม่ได้วางแผน {count} ช่วง', }; export default tours; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 0095807a6..d96d7598a 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 470; +const GENERAL_KEY_COUNT = 528; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); @@ -34,7 +34,7 @@ function literalKeys(source: string): string[] { } describe('Tours locale contracts', () => { - it('covers all 27 supported locales with 470 general source keys', () => { + it('covers all 27 supported locales with 528 general source keys', () => { expect(Object.keys(domains)).toHaveLength(27); expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort()); expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT); diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index c9095d5de..7944781e3 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -893,5 +893,42 @@ const admin: TranslationStrings = { 'Veriler © OpenStreetMap katkıda bulunanlar (ODbL), bölgesel kesitler Geofabrik’ten. Her ayın 3’ünde yeniden okunur.', 'admin.paddleRegions.toast.added': 'Bölge eklendi. TREK onu arka planda okuyor.', 'admin.paddleRegions.toast.removed': 'Bölge kaldırıldı', + 'admin.paddleRegions.phase.build': 'Su ağı oluşturuluyor', + 'admin.paddleRegions.network.km': '{km} km su', + 'admin.paddleRegions.network.lakes': '{count} göl', + 'admin.paddleRegions.network.lakes.one': '{count} göl', + 'admin.paddleRegions.network.portages': '{count} haritalanmış taşıma yeri', + 'admin.paddleRegions.network.portages.one': '{count} haritalanmış taşıma yeri', + 'admin.paddleRegions.network.inferred': '{count} varsayılan taşıma', + 'admin.paddleRegions.network.inferred.one': '{count} varsayılan taşıma', + 'admin.paddleRegions.network.locks': '{count} kanal kilidi', + 'admin.paddleRegions.network.locks.one': '{count} kanal kilidi', + 'admin.paddleRegions.network.gaps': '{count} birleştirilmiş boşluk', + 'admin.paddleRegions.network.gaps.one': '{count} birleştirilmiş boşluk', + 'admin.paddleRegions.network.missing': 'Su ağı yok', + 'admin.paddleRegions.network.missingHint': 'Kürek rotasının izlediği ağı oluşturmak için bölgeyi yeniden oku.', + 'admin.paddleRegions.auto.title': 'Bölgeleri otomatik yükle', + 'admin.paddleRegions.auto.mode.auto': 'Otomatik', + 'admin.paddleRegions.auto.mode.ask': 'İstek üzerine', + 'admin.paddleRegions.auto.mode.off': 'Kapalı', + 'admin.paddleRegions.auto.modeHint.auto': + "Biri henüz su verisi olmayan bir yerde kürek turu planladığında TREK uygun en küçük bölgeyi hemen yükler, boyut sınırına kadar ve günde yalnızca birkaç tane. Daha büyükler burada istek olarak bekler. Geofabrik'e yalnızca bölgenin adı gider, konum asla gitmez.", + 'admin.paddleRegions.auto.modeHint.ask': + 'Her yeni bölge bir yönetici izin verene kadar burada bekler. Yönetici kendisi planlarsa hemen yüklenir.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK kendiliğinden hiçbir şey yüklemez ve hiçbir şey istemez. Bölgeleri buradan elle ekle.', + 'admin.paddleRegions.auto.maxSize': 'Otomatik yüklenen en büyük bölge', + 'admin.paddleRegions.auto.maxSizeHint': + 'Daha büyük bölgeler burada istek olarak bekler. Bir bölgeyi okumak birkaç dakika sürer ve bu sırada bellek kullanır.', + 'admin.paddleRegions.requests.title': 'İstenenler', + 'admin.paddleRegions.requests.reason.ask': 'İstek üzerine', + 'admin.paddleRegions.requests.reason.too_large': 'Çok büyük', + 'admin.paddleRegions.requests.reason.limit': 'Günlük sınır', + 'admin.paddleRegions.requests.declined': 'Reddedildi', + 'admin.paddleRegions.requests.by': '{user} tarafından', + 'admin.paddleRegions.requests.approve': 'Yükle', + 'admin.paddleRegions.requests.decline': 'Reddet', + 'admin.paddleRegions.origin.auto': 'Otomatik', + 'admin.paddleRegions.origin.autoHint': 'TREK bu bölgeyi orada planlanan bir kürek turu için kendiliğinden yükledi.', }; export default admin; diff --git a/shared/src/i18n/tr/externalNotifications.ts b/shared/src/i18n/tr/externalNotifications.ts index ebbbc7f80..d2ce837c3 100644 --- a/shared/src/i18n/tr/externalNotifications.ts +++ b/shared/src/i18n/tr/externalNotifications.ts @@ -70,6 +70,10 @@ const tr: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Kürek bölgesi istendi', + body: `${p.actor}, ${p.region} bölgesinde kürek turu planlıyor. Su verisini yönetim alanında Eklentiler altında yükle ya da reddet.`, + }), synology_session_cleared: () => ({ title: 'Synology oturumu temizlendi', body: 'Synology hesabınız veya URL değişti. Synology Photos oturumunuz kapatıldı.', diff --git a/shared/src/i18n/tr/notif.ts b/shared/src/i18n/tr/notif.ts index d634c6bb3..fb4e39085 100644 --- a/shared/src/i18n/tr/notif.ts +++ b/shared/src/i18n/tr/notif.ts @@ -32,6 +32,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "'{backend}' kopyasına yazma başarısız oldu: {op} ({key}) — {error}", 'notif.replica_failure.textSuppressed': "'{backend}' kopyasına yazma başarısız oldu: {op} ({key}) — {error}. Son bildirimden bu yana {suppressed} hata daha bastırıldı.", + 'notif.paddle_region_requested.title': 'Kürek bölgesi istendi', + 'notif.paddle_region_requested.text': '{actor}, {region} bölgesinde kürek turu planlıyor', 'notif.action.view_trip': 'Seyahati Görüntüle', 'notif.action.view_collab': 'Mesajları Görüntüle', 'notif.action.view_packing': 'Paket listesini görüntüle', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 268308e1d..7516b41b5 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -108,6 +108,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook bildirimleri', 'settings.notifyVersionAvailable': 'Yeni sürüm mevcut', 'settings.notifyReplicaFailure': 'Depolama kopyası hatası', + 'settings.notifyPaddleRegionRequested': 'Kürek bölgesi istendi', 'settings.notificationPreferences.email': 'E-posta', 'settings.notificationPreferences.webhook': 'Web kancası', 'settings.notificationPreferences.inapp': 'Uygulama içi', diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 001a2c9a1..79318cd40 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -287,8 +287,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '0,5 ile 20 km/sa arasında bir hız gir.', 'tours.paddle.chooseBoat': 'Bu kürek turunu kaydetmek için tekneyi ve suyu seç.', 'tours.paddle.chooseBoatImport': 'Kürek turu olarak içe aktarmak için tekneyi ve suyu seç.', - 'tours.paddle.drawnByHand': - 'Kürek turu elle çizilir: TREK noktalarını düz çizgilerle birleştirir. Hiçbir şey rotalanmaz ve hiçbir şey suyun geçilebilir olduğunu söylemez.', 'tours.paddle.timeEstimated': 'Kürek süresi (tahmini)', 'tours.paddle.timeEstimatedShort': 'Kürek', 'tours.paddle.timeEstimatedInfoLabel': 'Kürek süresi nasıl tahmin edilir', @@ -331,6 +329,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Taşıma', 'tours.paddle.portageOn': 'Tekneyi buradan taşı', 'tours.paddle.portageOff': 'Bu bölümü yine kürekle geç', + 'tours.paddle.portagePending': 'Buradan taşı', + 'tours.paddle.portagePendingHint': 'Şimdi tekrar suya gireceğin noktayı koy. Tekne oraya kadar taşınır.', + 'tours.paddle.portagePendingOff': 'Taşımaktan vazgeç', + 'tours.paddle.portageWay': 'Güzergâh', + 'tours.paddle.portageWay.follow': 'Patikayı izle', + 'tours.paddle.portageWay.straight': 'Düz çizgi', + 'tours.paddle.portageWay.noWay': 'Büyük bir sapma olmadan yol yok, çizgi düz kalıyor.', + 'tours.paddle.portageWay.searching': 'Yol aranıyor', + 'tours.paddle.placeHere.putIn': 'Buradan suya gir', + 'tours.paddle.placeHere.egress': 'Buradan sudan çık', 'tours.paddle.portageInfo': 'Tekne burada taşınır. Bu bölüm kara sayılır ve sudan çıkma, yükü boşaltma ve yeniden suya inme için süre eklenir.', 'tours.paddle.portageExtra': 'Çıkış ve giriş', @@ -525,5 +533,59 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Kürek çekmenin riskleri vardır. Suyu, kuralları ve havayı kendin kontrol et.', 'tours.paddle.safety.text': 'Kürek çekmek risk taşır. Haritalar, rota önerileri ve notlar eksik ya da güncelliğini yitirmiş olabilir. Turdan önce güncel yerel koşulları, suyun kullanım kurallarını ve hava durumunu kontrol et. Uygun güvenlik önlemlerini al. TREK planlamanı destekler; kendi kontrolünün ve sorumlu kararının yerini tutmaz.', + 'tours.planner.shapingPoint': 'Şekil noktası', + 'tours.planner.shapingHint': 'Durak olmadan rotayı yönlendirir. Rotanın geçmesi gereken yere sürükle.', + 'tours.planner.makeStop': 'Durak yap', + 'tours.planner.makeShaping': 'Şekil noktası yap', + 'tours.planner.recomputeLeg': 'Sonraki bölümü yeniden hesapla', + 'tours.planner.shapeOnLine': 'Oraya bir şekil noktası eklemek için haritadaki rotaya tıkla.', + 'tours.paddle.routeOnWater': 'Sonraki bölümü suda planla', + 'tours.paddle.routeAllOnWater': 'Elle çizilen bölümleri suda planla', + 'tours.paddle.auto': 'Bulundu', + 'tours.paddle.autoHint': + 'TREK bu noktayı yolda bulduğu bir taşıma yerine ya da kanal kilidine koydu. Taşır ya da değiştirirsen senin olur.', + 'tours.paddle.note.rapids': 'Hızlı su {grade}', + 'tours.paddle.note.pass': 'Kano geçidi', + 'tours.paddle.note.gap': 'Harita verilerinde boşluk', + 'tours.paddle.note.rapidsHint': 'Rota burada hareketli sudan geçiyor. Önce yeri incele, emin değilsen tekneyi taşı.', + 'tours.paddle.note.gapHint': + 'Su verilerinde burada bir boşluk var, düz bir çizgiyle birleştirildi. Yerinde kontrol et.', + 'tours.paddle.failed.not_on_water': + 'Bu bölümün bir ucu TREK’in bildiği bir suyun üzerinde değil. Nehre ya da göle taşı.', + 'tours.paddle.failed.no_region': 'Bu bölge için henüz su verisi yok. Bir yönetici bölgeyi ekleyebilir.', + 'tours.paddle.failed.building': 'Bu bölgenin su verileri hâlâ hazırlanıyor. Birkaç dakika sonra yeniden dene.', + 'tours.paddle.failed.no_water_path': 'TREK bu noktalar arasında su üzerinden bir yol bulamadı.', + 'tours.paddle.failed.cross_region': 'Bu noktalar ortak su verisi olmayan iki bölgede.', + 'tours.paddle.failed.busy': 'Rota planlayıcı meşgul. Birazdan yeniden dene.', + 'tours.paddle.failed.timeout': 'Bu bölümü planlamak çok uzun sürdü. Araya bir nokta ekle ve yeniden dene.', + 'tours.paddle.failed.offline': 'Çevrimdışı. Bu bölüm yeniden bağlandığında planlanacak.', + 'tours.paddle.drawStraight': 'Düz çiz', + 'tours.paddle.retry': 'Yeniden dene', + 'tours.paddle.load.loading': '{region} için su verisi yükleniyor', + 'tours.paddle.load.ready': '{region} için su verisi geldi, bölüm planlanıyor.', + 'tours.paddle.load.requested': '{region} için su verisi istendi. Bir yönetici izin verir vermez bölüm planlanır.', + 'tours.paddle.load.waiting': 'Diğer bölgeler bekleniyor', + 'tours.paddle.load.phase.download': 'İndiriliyor', + 'tours.paddle.load.phase.read': 'Okunuyor', + 'tours.paddle.load.phase.build': 'Su ağı oluşturuluyor', + 'tours.paddle.load.hint': 'Genellikle bir ila üç dakika. Planlamaya devam et, bölüm sonra kendiliğinden planlanır.', + 'tours.paddle.load.reason.tooLarge': "Bölge, TREK'in kendiliğinden yüklediğinden büyük.", + 'tours.paddle.load.reason.limit': 'TREK bugün kendiliğinden yeterince bölge yükledi.', + 'tours.paddle.load.declined': 'Bir yönetici {region} için su verisini reddetti.', + 'tours.paddle.load.failed': '{region} için su verisi yüklenemedi.', + 'tours.paddle.load.noWater': '{region} için su verisinde burada su yok. Noktayı nehre veya göle koy.', + 'tours.paddle.load.noExtract': 'Bu alan için su verisi içeren bir harita kesiti yok.', + 'tours.paddle.load.catalogUnavailable': 'Bölge listesine şu anda ulaşılamıyor. Daha sonra yeniden dene.', + 'tours.paddle.lakeStyle': 'Göllerde', + 'tours.paddle.lakeStyle.direct': 'Dümdüz', + 'tours.paddle.lakeStyle.shore': 'Kıyıya yakın', + 'tours.paddle.lakeStyle.directHint': 'Açık suda en kısa çizgi.', + 'tours.paddle.lakeStyle.shoreHint': 'Kıyıya yakın kalır, rüzgârlı günler için: daha uzun ama kara asla uzak değil.', + 'tours.paddle.wind.shoreAdvice': + 'Bu gün {mean} km/sa’e kadar, hamleler {gusts} km/sa. Göllerde kıyıya yakın kalmak daha iyi olabilir.', + 'tours.paddle.wind.shoreAdopt': 'Kıyıya yakın planla', + 'tours.paddle.wind.shoreTaken': 'Göller kıyıya yakın planlandı.', + 'tours.paddle.status.unrouted': '{count} bölüm planlanmadı', + 'tours.paddle.status.unrouted.one': '{count} bölüm planlanmadı', }; export default tours; diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index e2cff359a..54a668a1b 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -916,5 +916,53 @@ const admin: TranslationStrings = { 'admin.paddleRegions.toast.removed': 'Регіон видалено', 'admin.paddleRegions.objects.few': '{count} об’єкти', 'admin.paddleRegions.objects.many': '{count} об’єктів', + 'admin.paddleRegions.phase.build': 'Будується водна мережа', + 'admin.paddleRegions.network.km': '{km} км води', + 'admin.paddleRegions.network.lakes': '{count} озера', + 'admin.paddleRegions.network.lakes.one': '{count} озеро', + 'admin.paddleRegions.network.lakes.few': '{count} озера', + 'admin.paddleRegions.network.lakes.many': '{count} озер', + 'admin.paddleRegions.network.portages': '{count} позначеного волоку', + 'admin.paddleRegions.network.portages.one': '{count} позначений волок', + 'admin.paddleRegions.network.portages.few': '{count} позначені волоки', + 'admin.paddleRegions.network.portages.many': '{count} позначених волоків', + 'admin.paddleRegions.network.inferred': '{count} імовірного обносу', + 'admin.paddleRegions.network.inferred.one': '{count} імовірний обнос', + 'admin.paddleRegions.network.inferred.few': '{count} імовірні обноси', + 'admin.paddleRegions.network.inferred.many': '{count} імовірних обносів', + 'admin.paddleRegions.network.locks': '{count} шлюзу', + 'admin.paddleRegions.network.locks.one': '{count} шлюз', + 'admin.paddleRegions.network.locks.few': '{count} шлюзи', + 'admin.paddleRegions.network.locks.many': '{count} шлюзів', + 'admin.paddleRegions.network.gaps': '{count} з’єднаної прогалини', + 'admin.paddleRegions.network.gaps.one': '{count} з’єднана прогалина', + 'admin.paddleRegions.network.gaps.few': '{count} з’єднані прогалини', + 'admin.paddleRegions.network.gaps.many': '{count} з’єднаних прогалин', + 'admin.paddleRegions.network.missing': 'Немає водної мережі', + 'admin.paddleRegions.network.missingHint': + 'Завантажте регіон знову, щоб побудувати мережу, якою прокладається маршрут на воді.', + 'admin.paddleRegions.auto.title': 'Завантажувати регіони автоматично', + 'admin.paddleRegions.auto.mode.auto': 'Автоматично', + 'admin.paddleRegions.auto.mode.ask': 'За запитом', + 'admin.paddleRegions.auto.mode.off': 'Вимкнено', + 'admin.paddleRegions.auto.modeHint.auto': + 'Коли хтось планує сплав там, де ще немає даних про воду, TREK одразу завантажує найменший відповідний регіон, у межах ліміту розміру й лише кілька на день. Більші чекають тут як запит. До Geofabrik іде лише назва регіону, ніколи не місце.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Кожен новий регіон чекає тут, доки адміністратор його не дозволить. Якщо планує адміністратор, регіон завантажується одразу.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK нічого не завантажує сам і нічого не запитує. Додавайте регіони тут вручну.', + 'admin.paddleRegions.auto.maxSize': 'Найбільший регіон для автозавантаження', + 'admin.paddleRegions.auto.maxSizeHint': + 'Більші регіони чекають тут як запит. Читання регіону триває кілька хвилин і весь цей час потребує пам’яті.', + 'admin.paddleRegions.requests.title': 'Запитані', + 'admin.paddleRegions.requests.reason.ask': 'За запитом', + 'admin.paddleRegions.requests.reason.too_large': 'Завеликий', + 'admin.paddleRegions.requests.reason.limit': 'Денний ліміт', + 'admin.paddleRegions.requests.declined': 'Відхилено', + 'admin.paddleRegions.requests.by': 'від {user}', + 'admin.paddleRegions.requests.approve': 'Завантажити', + 'admin.paddleRegions.requests.decline': 'Відхилити', + 'admin.paddleRegions.origin.auto': 'Автоматично', + 'admin.paddleRegions.origin.autoHint': 'TREK завантажив цей регіон сам для сплаву, який там планується.', }; export default admin; diff --git a/shared/src/i18n/uk/externalNotifications.ts b/shared/src/i18n/uk/externalNotifications.ts index dfb249abf..167610eef 100644 --- a/shared/src/i18n/uk/externalNotifications.ts +++ b/shared/src/i18n/uk/externalNotifications.ts @@ -71,6 +71,10 @@ const uk: NotificationLocale = { }) : ''), }), + paddle_region_requested: (p) => ({ + title: 'Запитано регіон для сплаву', + body: `${p.actor} планує сплав у регіоні ${p.region}. Завантажте або відхиліть його дані про воду в адмініструванні в розділі «Доповнення».`, + }), synology_session_cleared: () => ({ title: 'Сеанс Synology скинуто', body: 'Ваш обліковий запис або URL Synology змінився. Ви вийшли з Synology Photos.', diff --git a/shared/src/i18n/uk/notif.ts b/shared/src/i18n/uk/notif.ts index 52c566a52..9ad5fda4e 100644 --- a/shared/src/i18n/uk/notif.ts +++ b/shared/src/i18n/uk/notif.ts @@ -34,6 +34,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Помилка запису в репліку '{backend}': {op} для {key} — {error}", 'notif.replica_failure.textSuppressed': "Помилка запису в репліку '{backend}': {op} для {key} — {error}. Із моменту останнього сповіщення приховано ще {suppressed} помилок.", + 'notif.paddle_region_requested.title': 'Запитано регіон для сплаву', + 'notif.paddle_region_requested.text': '{actor} планує сплав у регіоні {region}', 'notif.action.view_trip': 'Відкрити поїздку', 'notif.action.view_collab': 'Відкрити повідомлення', 'notif.action.view_packing': 'Відкрити пакування', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 0fffb60a5..73f4d3bae 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -295,6 +295,7 @@ const settings: TranslationStrings = { 'settings.bookingLabelsHint': 'Показує назви станцій / аеропортів на карті. Якщо вимкнено, показується лише значок.', 'settings.notifyVersionAvailable': 'Доступна нова версія', 'settings.notifyReplicaFailure': 'Збій репліки сховища', + 'settings.notifyPaddleRegionRequested': 'Запитано регіон для сплаву', 'settings.notificationPreferences.noChannels': 'Канали сповіщень не налаштовані. Попросіть адміністратора налаштувати сповіщення електронною поштою або через webhook.', 'settings.webhookUrl.label': 'URL вебхука', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 6cdcb5c9e..5d8d713fb 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -300,8 +300,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Введіть швидкість від 0,5 до 20 км/год.', 'tours.paddle.chooseBoat': 'Виберіть човен і водойму, щоб зберегти цей сплав.', 'tours.paddle.chooseBoatImport': 'Виберіть човен і водойму, щоб імпортувати як сплав.', - 'tours.paddle.drawnByHand': - 'Сплав малюється вручну: TREK з’єднує ваші точки прямими лініями. Нічого не прокладається, і ніщо не каже, що водою можна пройти.', 'tours.paddle.timeEstimated': 'Час на воді (оцінка)', 'tours.paddle.timeEstimatedShort': 'Веслування', 'tours.paddle.timeEstimatedInfoLabel': 'Як оцінюється час на воді', @@ -352,6 +350,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Обнос', 'tours.paddle.portageOn': 'Обносити човен звідси', 'tours.paddle.portageOff': 'Знову пройти цю ділянку на веслах', + 'tours.paddle.portagePending': 'Обнесення звідси', + 'tours.paddle.portagePendingHint': 'Тепер постав точку, де знову спускаєшся на воду. До неї човен несуть.', + 'tours.paddle.portagePendingOff': 'Не обносити', + 'tours.paddle.portageWay': 'Шлях', + 'tours.paddle.portageWay.follow': 'Стежкою', + 'tours.paddle.portageWay.straight': 'Пряма лінія', + 'tours.paddle.portageWay.noWay': 'Стежки без великого гаку немає, лінія лишається прямою.', + 'tours.paddle.portageWay.searching': 'Пошук стежки', + 'tours.paddle.placeHere.putIn': 'Спуск на воду тут', + 'tours.paddle.placeHere.egress': 'Вихід з води тут', 'tours.paddle.portageInfo': 'Тут човен несуть берегом. Ділянка рахується як суходіл, з часом на вихід, розвантаження й повторний спуск на воду.', 'tours.paddle.portageExtra': 'Вихід і спуск', @@ -559,5 +567,62 @@ const tours: TranslationStrings = { '{count} перешкод і місць спуску й виходу вздовж маршруту за даними OpenStreetMap станом на {date}. Дані можуть мати прогалини, тож перевірте водойму самостійно.', 'tours.paddle.time.locksMissing.few': '{count} шлюзи ще без часу.', 'tours.paddle.time.locksMissing.many': '{count} шлюзів ще без часу.', + 'tours.planner.shapingPoint': 'Точка форми', + 'tours.planner.shapingHint': 'Задає хід маршруту, не будучи зупинкою. Перетягніть її туди, де має пройти маршрут.', + 'tours.planner.makeStop': 'Зробити зупинкою', + 'tours.planner.makeShaping': 'Зробити точкою форми', + 'tours.planner.recomputeLeg': 'Перерахувати наступну ділянку', + 'tours.planner.shapeOnLine': 'Натисніть на маршрут на мапі, щоб додати там точку форми.', + 'tours.paddle.routeOnWater': 'Прокласти наступну ділянку по воді', + 'tours.paddle.routeAllOnWater': 'Прокласти по воді ділянки, намальовані вручну', + 'tours.paddle.auto': 'Знайдено', + 'tours.paddle.autoHint': + 'TREK поставив цю точку біля волоку чи шлюзу, знайденого дорогою. Якщо ви її перемістите чи зміните, вона стане вашою.', + 'tours.paddle.note.rapids': 'Поріг, категорія {grade}', + 'tours.paddle.note.pass': 'Човновий прохід', + 'tours.paddle.note.gap': 'Прогалина в даних мапи', + 'tours.paddle.note.rapidsHint': 'Тут маршрут іде швидкою водою. Спершу огляньте ділянку і в разі сумніву обнесіть.', + 'tours.paddle.note.gapHint': 'У даних про водойми тут прогалина, з’єднана прямою лінією. Перевірте на місці.', + 'tours.paddle.failed.not_on_water': + 'Один кінець цієї ділянки не на воді, відомій TREK. Перемістіть його на річку чи озеро.', + 'tours.paddle.failed.no_region': 'Для цієї місцевості ще немає даних про водойми. Адміністратор може додати регіон.', + 'tours.paddle.failed.building': 'Дані про водойми цього регіону ще готуються. Спробуйте знову за кілька хвилин.', + 'tours.paddle.failed.no_water_path': 'TREK не знайшов шляху по воді між цими точками.', + 'tours.paddle.failed.cross_region': 'Ці точки лежать у двох регіонах без спільних даних про водойми.', + 'tours.paddle.failed.busy': 'Планувальник маршрутів зайнятий. Спробуйте знову за мить.', + 'tours.paddle.failed.timeout': + 'Розрахунок цієї ділянки тривав надто довго. Додайте проміжну точку і спробуйте знову.', + 'tours.paddle.failed.offline': 'Немає з’єднання. Ділянку буде прокладено, щойно ви знову підключитеся.', + 'tours.paddle.drawStraight': 'Провести прямо', + 'tours.paddle.retry': 'Повторити', + 'tours.paddle.load.loading': 'Завантажуються дані про воду для {region}', + 'tours.paddle.load.ready': 'Дані про воду для {region} отримано, ділянка планується.', + 'tours.paddle.load.requested': + 'Дані про воду для {region} запитано. Ділянку буде сплановано, щойно адміністратор їх дозволить.', + 'tours.paddle.load.waiting': 'Очікування інших регіонів', + 'tours.paddle.load.phase.download': 'Завантаження', + 'tours.paddle.load.phase.read': 'Читання', + 'tours.paddle.load.phase.build': 'Побудова водної мережі', + 'tours.paddle.load.hint': 'Зазвичай від однієї до трьох хвилин. Плануйте далі, ділянка потім спланується сама.', + 'tours.paddle.load.reason.tooLarge': 'Регіон більший, ніж TREK завантажує сам.', + 'tours.paddle.load.reason.limit': 'TREK уже завантажив сьогодні досить регіонів сам.', + 'tours.paddle.load.declined': 'Адміністратор відхилив дані про воду для {region}.', + 'tours.paddle.load.failed': 'Не вдалося завантажити дані про воду для {region}.', + 'tours.paddle.load.noWater': 'У даних про воду для {region} тут немає води. Поставте точку на річку чи озеро.', + 'tours.paddle.load.noExtract': 'Для цієї місцевості немає вивантаження мапи з даними про воду.', + 'tours.paddle.load.catalogUnavailable': 'Список регіонів зараз недоступний. Спробуйте пізніше.', + 'tours.paddle.lakeStyle': 'Через озера', + 'tours.paddle.lakeStyle.direct': 'Навпростець', + 'tours.paddle.lakeStyle.shore': 'Уздовж берега', + 'tours.paddle.lakeStyle.directHint': 'Найкоротша лінія відкритою водою.', + 'tours.paddle.lakeStyle.shoreHint': 'Тримається біля берега, для вітряних днів: довше, зате суша завжди поруч.', + 'tours.paddle.wind.shoreAdvice': + 'Цього дня до {mean} км/год, пориви {gusts} км/год. На озерах, можливо, краще триматися берега.', + 'tours.paddle.wind.shoreAdopt': 'Прокласти вздовж берега', + 'tours.paddle.wind.shoreTaken': 'Озера прокладено вздовж берега.', + 'tours.paddle.status.unrouted': '{count} ділянки не прокладено', + 'tours.paddle.status.unrouted.one': '{count} ділянку не прокладено', + 'tours.paddle.status.unrouted.few': '{count} ділянки не прокладено', + 'tours.paddle.status.unrouted.many': '{count} ділянок не прокладено', }; export default tours; diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index cf99e3835..aa32fe283 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -876,5 +876,37 @@ const admin: TranslationStrings = { 'Dữ liệu © những người đóng góp OpenStreetMap (ODbL), bản trích xuất theo vùng của Geofabrik. Được đọc lại vào ngày 3 hằng tháng.', 'admin.paddleRegions.toast.added': 'Đã thêm vùng. TREK đang đọc vùng này trong nền.', 'admin.paddleRegions.toast.removed': 'Đã xóa vùng', + 'admin.paddleRegions.phase.build': 'Đang xây dựng mạng lưới mặt nước', + 'admin.paddleRegions.network.km': '{km} km mặt nước', + 'admin.paddleRegions.network.lakes': '{count} hồ', + 'admin.paddleRegions.network.portages': '{count} chỗ khiêng thuyền đã lập bản đồ', + 'admin.paddleRegions.network.inferred': '{count} lần khiêng thuyền giả định', + 'admin.paddleRegions.network.locks': '{count} âu thuyền', + 'admin.paddleRegions.network.gaps': '{count} khoảng trống được nối', + 'admin.paddleRegions.network.missing': 'Không có mạng lưới mặt nước', + 'admin.paddleRegions.network.missingHint': 'Đọc lại vùng để xây dựng mạng lưới mà tuyến chèo thuyền đi theo.', + 'admin.paddleRegions.auto.title': 'Tự động tải vùng', + 'admin.paddleRegions.auto.mode.auto': 'Tự động', + 'admin.paddleRegions.auto.mode.ask': 'Theo yêu cầu', + 'admin.paddleRegions.auto.mode.off': 'Tắt', + 'admin.paddleRegions.auto.modeHint.auto': + 'Khi ai đó lên kế hoạch chèo thuyền ở nơi chưa có dữ liệu nước, TREK tải ngay vùng nhỏ nhất phù hợp, trong giới hạn kích thước và chỉ vài vùng mỗi ngày. Vùng lớn hơn sẽ chờ ở đây dưới dạng yêu cầu. Chỉ tên vùng được gửi tới Geofabrik, không bao giờ là vị trí.', + 'admin.paddleRegions.auto.modeHint.ask': + 'Mỗi vùng mới chờ ở đây cho đến khi quản trị viên cho phép. Khi quản trị viên tự lên kế hoạch, vùng được tải ngay.', + 'admin.paddleRegions.auto.modeHint.off': + 'TREK không tự tải gì và không yêu cầu gì. Hãy thêm vùng ở đây theo cách thủ công.', + 'admin.paddleRegions.auto.maxSize': 'Vùng lớn nhất được tải tự động', + 'admin.paddleRegions.auto.maxSizeHint': + 'Vùng lớn hơn chờ ở đây dưới dạng yêu cầu. Đọc một vùng mất vài phút và dùng bộ nhớ trong lúc đó.', + 'admin.paddleRegions.requests.title': 'Được yêu cầu', + 'admin.paddleRegions.requests.reason.ask': 'Theo yêu cầu', + 'admin.paddleRegions.requests.reason.too_large': 'Quá lớn', + 'admin.paddleRegions.requests.reason.limit': 'Giới hạn ngày', + 'admin.paddleRegions.requests.declined': 'Đã từ chối', + 'admin.paddleRegions.requests.by': 'bởi {user}', + 'admin.paddleRegions.requests.approve': 'Tải', + 'admin.paddleRegions.requests.decline': 'Từ chối', + 'admin.paddleRegions.origin.auto': 'Tự động', + 'admin.paddleRegions.origin.autoHint': 'TREK đã tự tải vùng này cho một chuyến chèo thuyền được lên kế hoạch ở đó.', }; export default admin; diff --git a/shared/src/i18n/vi/externalNotifications.ts b/shared/src/i18n/vi/externalNotifications.ts index b75d52df4..e5fecb4e5 100644 --- a/shared/src/i18n/vi/externalNotifications.ts +++ b/shared/src/i18n/vi/externalNotifications.ts @@ -60,6 +60,10 @@ const en: NotificationLocale = { ? ` Kể từ lần thông báo trước, có thêm ${p.suppressed} lỗi nhưng không được thông báo riêng.` : ''), }), + paddle_region_requested: (p) => ({ + title: 'Có yêu cầu vùng chèo thuyền', + body: `${p.actor} đang lên kế hoạch chèo thuyền ở ${p.region}. Tải hoặc từ chối dữ liệu nước trong khu quản trị, mục Tiện ích bổ sung.`, + }), synology_session_cleared: () => ({ title: 'Đã xóa phiên Synology', body: 'Tài khoản Synology của bạn hoặc URL đã thay đổi. Bạn đã đăng xuất khỏi Synology Photos.', diff --git a/shared/src/i18n/vi/notif.ts b/shared/src/i18n/vi/notif.ts index 053f0d3c7..1c22d3709 100644 --- a/shared/src/i18n/vi/notif.ts +++ b/shared/src/i18n/vi/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "Ghi vào bản sao '{backend}' thất bại: {op} của {key} — {error}", 'notif.replica_failure.textSuppressed': "Ghi vào bản sao '{backend}' thất bại: {op} của {key} — {error}. {suppressed} lỗi khác đã bị bỏ qua kể từ thông báo trước đó.", + 'notif.paddle_region_requested.title': 'Có yêu cầu vùng chèo thuyền', + 'notif.paddle_region_requested.text': '{actor} đang lên kế hoạch chèo thuyền ở {region}', 'notif.action.view_trip': 'Xem chuyến đi', 'notif.action.view_collab': 'Xem tin nhắn', 'notif.action.view_packing': 'Xem đóng gói', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index d7997ff7c..9ba571f1b 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -113,6 +113,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Thông báo webhook', 'settings.notifyVersionAvailable': 'Đã có phiên bản mới', 'settings.notifyReplicaFailure': 'Lỗi bản sao lưu trữ', + 'settings.notifyPaddleRegionRequested': 'Có yêu cầu vùng chèo thuyền', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'Trong ứng dụng', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 185e21ec9..4aff525f4 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -287,8 +287,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': 'Nhập tốc độ từ 0,5 đến 20 km/h.', 'tours.paddle.chooseBoat': 'Chọn thuyền và mặt nước để lưu chuyến chèo này.', 'tours.paddle.chooseBoatImport': 'Chọn thuyền và mặt nước để nhập thành chuyến chèo thuyền.', - 'tours.paddle.drawnByHand': - 'Chuyến chèo thuyền được vẽ bằng tay: TREK nối các điểm của bạn bằng đường thẳng. Không có gì được định tuyến, và không gì cho biết mặt nước có chèo được hay không.', 'tours.paddle.timeEstimated': 'Thời gian chèo (ước tính)', 'tours.paddle.timeEstimatedShort': 'Chèo', 'tours.paddle.timeEstimatedInfoLabel': 'Cách ước tính thời gian chèo', @@ -327,6 +325,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': 'Khiêng thuyền', 'tours.paddle.portageOn': 'Khiêng thuyền từ đây', 'tours.paddle.portageOff': 'Đổi đoạn này về chèo', + 'tours.paddle.portagePending': 'Khiêng thuyền từ đây', + 'tours.paddle.portagePendingHint': 'Bây giờ đặt điểm bạn xuống nước lại. Thuyền được khiêng đến đó.', + 'tours.paddle.portagePendingOff': 'Thôi không khiêng', + 'tours.paddle.portageWay': 'Lộ trình', + 'tours.paddle.portageWay.follow': 'Đi theo đường mòn', + 'tours.paddle.portageWay.straight': 'Đường thẳng', + 'tours.paddle.portageWay.noWay': 'Không có đường nào mà không phải đi vòng xa, nên vẫn là đường thẳng.', + 'tours.paddle.portageWay.searching': 'Đang tìm đường', + 'tours.paddle.placeHere.putIn': 'Xuống nước ở đây', + 'tours.paddle.placeHere.egress': 'Lên bờ ở đây', 'tours.paddle.portageInfo': 'Thuyền được khiêng qua đây. Đoạn này tính là đường bộ, kèm thời gian lên bờ, dỡ đồ và xuống nước lại.', 'tours.paddle.portageExtra': 'Lên bờ, xuống lại', @@ -515,5 +523,62 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': 'Chèo thuyền có rủi ro. Hãy tự kiểm tra mặt nước, quy định và thời tiết.', 'tours.paddle.safety.text': 'Chèo thuyền tiềm ẩn rủi ro. Bản đồ, gợi ý tuyến và ghi chú có thể chưa đầy đủ hoặc đã lỗi thời. Trước chuyến đi, hãy kiểm tra điều kiện địa phương hiện tại, quy định sử dụng mặt nước và thời tiết. Thực hiện các biện pháp an toàn phù hợp. TREK hỗ trợ việc lập kế hoạch của bạn và không thay thế việc tự kiểm tra cũng như quyết định có trách nhiệm của bạn.', + 'tours.planner.shapingPoint': 'Điểm định hình', + 'tours.planner.shapingHint': + 'Định hướng tuyến đường mà không phải là điểm dừng. Kéo nó tới nơi tuyến đường cần đi qua.', + 'tours.planner.makeStop': 'Chuyển thành điểm dừng', + 'tours.planner.makeShaping': 'Chuyển thành điểm định hình', + 'tours.planner.recomputeLeg': 'Tính lại chặng tiếp theo', + 'tours.planner.shapeOnLine': 'Nhấp vào tuyến đường trên bản đồ để thêm điểm định hình tại đó.', + 'tours.paddle.routeOnWater': 'Lập chặng tiếp theo trên mặt nước', + 'tours.paddle.routeAllOnWater': 'Lập trên mặt nước các chặng vẽ tay', + 'tours.paddle.auto': 'Đã tìm thấy', + 'tours.paddle.autoHint': + 'TREK đặt điểm này tại một chỗ khiêng thuyền hoặc âu thuyền tìm thấy trên đường. Nếu bạn di chuyển hoặc thay đổi nó, nó sẽ thuộc về bạn.', + 'tours.paddle.note.rapids': 'Nước xiết cấp {grade}', + 'tours.paddle.note.pass': 'Lối cho thuyền', + 'tours.paddle.note.gap': 'Khoảng trống trong dữ liệu bản đồ', + 'tours.paddle.note.rapidsHint': + 'Tuyến đường đi qua dòng nước chảy mạnh ở đây. Hãy khảo sát trước và khiêng thuyền nếu không chắc.', + 'tours.paddle.note.gapHint': + 'Dữ liệu mặt nước có khoảng trống ở đây, được nối bằng đường thẳng. Hãy kiểm tra tại chỗ.', + 'tours.paddle.failed.not_on_water': + 'Một đầu của chặng này không nằm trên vùng nước nào TREK biết. Hãy dời nó lên sông hoặc hồ.', + 'tours.paddle.failed.no_region': 'Chưa có dữ liệu mặt nước cho khu vực này. Quản trị viên có thể thêm vùng.', + 'tours.paddle.failed.building': 'Dữ liệu mặt nước của vùng này vẫn đang được chuẩn bị. Hãy thử lại sau vài phút.', + 'tours.paddle.failed.no_water_path': 'TREK không tìm thấy đường đi trên mặt nước giữa các điểm này.', + 'tours.paddle.failed.cross_region': 'Các điểm này nằm ở hai vùng không có dữ liệu mặt nước chung.', + 'tours.paddle.failed.busy': 'Trình lập tuyến đang bận. Hãy thử lại sau giây lát.', + 'tours.paddle.failed.timeout': 'Việc lập chặng này mất quá lâu. Hãy thêm một điểm ở giữa và thử lại.', + 'tours.paddle.failed.offline': 'Ngoại tuyến. Chặng này sẽ được lập khi bạn kết nối lại.', + 'tours.paddle.drawStraight': 'Vẽ thẳng', + 'tours.paddle.retry': 'Thử lại', + 'tours.paddle.load.loading': 'Đang tải dữ liệu nước cho {region}', + 'tours.paddle.load.ready': 'Dữ liệu nước cho {region} đã có, đang lên kế hoạch đoạn này.', + 'tours.paddle.load.requested': + 'Đã yêu cầu dữ liệu nước cho {region}. Đoạn này sẽ được lên kế hoạch ngay khi quản trị viên cho phép.', + 'tours.paddle.load.waiting': 'Đang chờ các vùng khác', + 'tours.paddle.load.phase.download': 'Đang tải xuống', + 'tours.paddle.load.phase.read': 'Đang đọc', + 'tours.paddle.load.phase.build': 'Đang xây dựng mạng lưới nước', + 'tours.paddle.load.hint': + 'Thường mất một đến ba phút. Cứ tiếp tục lên kế hoạch, đoạn này sẽ tự được lên kế hoạch sau đó.', + 'tours.paddle.load.reason.tooLarge': 'Vùng này lớn hơn mức TREK tự tải.', + 'tours.paddle.load.reason.limit': 'Hôm nay TREK đã tự tải đủ số vùng.', + 'tours.paddle.load.declined': 'Quản trị viên đã từ chối dữ liệu nước cho {region}.', + 'tours.paddle.load.failed': 'Không thể tải dữ liệu nước cho {region}.', + 'tours.paddle.load.noWater': 'Dữ liệu nước cho {region} không có mặt nước ở đây. Hãy đặt điểm trên sông hoặc hồ.', + 'tours.paddle.load.noExtract': 'Không có bản trích bản đồ nào có dữ liệu nước cho khu vực này.', + 'tours.paddle.load.catalogUnavailable': 'Hiện không thể truy cập danh sách vùng. Hãy thử lại sau.', + 'tours.paddle.lakeStyle': 'Qua hồ', + 'tours.paddle.lakeStyle.direct': 'Đi thẳng', + 'tours.paddle.lakeStyle.shore': 'Gần bờ', + 'tours.paddle.lakeStyle.directHint': 'Đường ngắn nhất qua vùng nước mở.', + 'tours.paddle.lakeStyle.shoreHint': 'Đi gần bờ, cho những ngày có gió: dài hơn, nhưng đất liền không bao giờ xa.', + 'tours.paddle.wind.shoreAdvice': + 'Hôm nay gió tới {mean} km/h, giật {gusts} km/h. Trên hồ, đi gần bờ có thể là tuyến tốt hơn.', + 'tours.paddle.wind.shoreAdopt': 'Lập gần bờ', + 'tours.paddle.wind.shoreTaken': 'Các hồ được lập gần bờ.', + 'tours.paddle.status.unrouted': '{count} chặng chưa được lập', }; export default tours; diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 3c935d483..05f8014e0 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -800,5 +800,34 @@ const admin: TranslationStrings = { '資料 © OpenStreetMap 貢獻者(ODbL),區域摘錄由 Geofabrik 提供。每月 3 日重新讀取。', 'admin.paddleRegions.toast.added': '已新增區域。TREK 會在背景讀取。', 'admin.paddleRegions.toast.removed': '已移除區域', + 'admin.paddleRegions.phase.build': '正在建立水路網路', + 'admin.paddleRegions.network.km': '{km} km 水域', + 'admin.paddleRegions.network.lakes': '{count} 個湖泊', + 'admin.paddleRegions.network.portages': '{count} 個已標示搬運點', + 'admin.paddleRegions.network.inferred': '{count} 處推定搬運', + 'admin.paddleRegions.network.locks': '{count} 座船閘', + 'admin.paddleRegions.network.gaps': '{count} 處已連接缺口', + 'admin.paddleRegions.network.missing': '無水路網路', + 'admin.paddleRegions.network.missingHint': '重新讀取該區域以建立划槳路線所用的網路。', + 'admin.paddleRegions.auto.title': '自動載入區域', + 'admin.paddleRegions.auto.mode.auto': '自動', + 'admin.paddleRegions.auto.mode.ask': '依申請', + 'admin.paddleRegions.auto.mode.off': '關閉', + 'admin.paddleRegions.auto.modeHint.auto': + '當有人在還沒有水域資料的地方規劃划槳行程時,TREK 會立即載入最小的合適區域,不超過大小上限,每天只載入幾個。更大的區域會作為申請在這裡等待。傳送給 Geofabrik 的只有區域名稱,從不包括地點。', + 'admin.paddleRegions.auto.modeHint.ask': '每個新區域都在這裡等待,直到管理員放行。管理員自己規劃時會立即載入。', + 'admin.paddleRegions.auto.modeHint.off': 'TREK 不會自行載入任何內容,也不會提出申請。請在這裡手動新增區域。', + 'admin.paddleRegions.auto.maxSize': '自動載入的最大區域', + 'admin.paddleRegions.auto.maxSizeHint': '更大的區域會作為申請在這裡等待。讀取一個區域需要幾分鐘,期間會占用記憶體。', + 'admin.paddleRegions.requests.title': '已申請', + 'admin.paddleRegions.requests.reason.ask': '依申請', + 'admin.paddleRegions.requests.reason.too_large': '過大', + 'admin.paddleRegions.requests.reason.limit': '每日上限', + 'admin.paddleRegions.requests.declined': '已拒絕', + 'admin.paddleRegions.requests.by': '來自 {user}', + 'admin.paddleRegions.requests.approve': '載入', + 'admin.paddleRegions.requests.decline': '拒絕', + 'admin.paddleRegions.origin.auto': '自動', + 'admin.paddleRegions.origin.autoHint': 'TREK 為在此規劃的划槳行程自行載入了這個區域。', }; export default admin; diff --git a/shared/src/i18n/zh-TW/externalNotifications.ts b/shared/src/i18n/zh-TW/externalNotifications.ts index 384aed00f..ef5c29e8f 100644 --- a/shared/src/i18n/zh-TW/externalNotifications.ts +++ b/shared/src/i18n/zh-TW/externalNotifications.ts @@ -61,6 +61,10 @@ const zhTW: NotificationLocale = { ? pluralForm(p.suppressed, 'zh-TW', { other: `自上次通知以來,另有 ${p.suppressed} 次失敗未個別通知。` }) : ''), }), + paddle_region_requested: (p) => ({ + title: '划槳區域申請', + body: `${p.actor} 正在 ${p.region} 規劃划槳行程。請在管理後台的附加元件中載入或拒絕其水域資料。`, + }), synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。', diff --git a/shared/src/i18n/zh-TW/notif.ts b/shared/src/i18n/zh-TW/notif.ts index 3fc576480..7c683fb6d 100644 --- a/shared/src/i18n/zh-TW/notif.ts +++ b/shared/src/i18n/zh-TW/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "寫入複本 '{backend}' 失敗:{op} / {key} — {error}", 'notif.replica_failure.textSuppressed': "寫入複本 '{backend}' 失敗:{op} / {key} — {error}。自上次通知以來,還有 {suppressed} 個失敗已被抑制。", + 'notif.paddle_region_requested.title': '划槳區域申請', + 'notif.paddle_region_requested.text': '{actor} 正在 {region} 規劃划槳行程', 'notif.action.view_trip': '查看旅行', 'notif.action.view_collab': '查看訊息', 'notif.action.view_packing': '查看打包', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 763266b47..ad974e679 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -102,6 +102,7 @@ const settings: TranslationStrings = { 'settings.notifyWebhook': 'Webhook 通知', 'settings.notifyVersionAvailable': '有新版本可用', 'settings.notifyReplicaFailure': '儲存複本故障', + 'settings.notifyPaddleRegionRequested': '划槳區域申請', 'settings.notificationPreferences.email': '電子郵件', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': '應用程式內', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index ed6919886..63b373d2a 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -264,7 +264,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '請輸入 0.5 至 20 km/h 之間的速度。', 'tours.paddle.chooseBoat': '請選擇船隻和水域以儲存此划槳行程。', 'tours.paddle.chooseBoatImport': '請選擇船隻和水域以匯入為划槳行程。', - 'tours.paddle.drawnByHand': '划槳行程需手動繪製:TREK 以直線連接你的點,不進行路線規劃,也不表示該水域可以通行。', 'tours.paddle.timeEstimated': '划槳時間(估算)', 'tours.paddle.timeEstimatedShort': '划槳', 'tours.paddle.timeEstimatedInfoLabel': '划槳時間如何估算', @@ -300,6 +299,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': '陸上搬運', 'tours.paddle.portageOn': '從這裡開始搬運船隻', 'tours.paddle.portageOff': '這一段恢復划槳', + 'tours.paddle.portagePending': '從這裡開始搬運', + 'tours.paddle.portagePendingHint': '現在設定你重新下水的地點。船會一路搬運到那裡。', + 'tours.paddle.portagePendingOff': '不再搬運', + 'tours.paddle.portageWay': '路線', + 'tours.paddle.portageWay.follow': '沿道路', + 'tours.paddle.portageWay.straight': '直線', + 'tours.paddle.portageWay.noWay': '沒有不繞遠的道路,因此維持直線。', + 'tours.paddle.portageWay.searching': '正在尋找道路', + 'tours.paddle.placeHere.putIn': '在此下水', + 'tours.paddle.placeHere.egress': '在此上岸', 'tours.paddle.portageInfo': '船隻在這裡以人力搬運。這一段以陸路計算,並計入上岸、卸載和重新下水的時間。', 'tours.paddle.portageExtra': '上岸與下水', 'tours.paddle.lock': '船閘', @@ -481,5 +490,54 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': '划槳有風險。請自行確認水域、規定與天氣。', 'tours.paddle.safety.text': '划槳伴隨風險。地圖、路線建議與提示可能不完整或已過時。出發前請確認當地目前的狀況、水域使用規定與天氣。請採取適當的安全措施。TREK 協助你規劃,但不能取代你自己的確認與負責任的決定。', + 'tours.planner.shapingPoint': '塑形點', + 'tours.planner.shapingHint': '引導路線但不作為停靠點。把它拖到路線應經過的地方。', + 'tours.planner.makeStop': '設為停靠點', + 'tours.planner.makeShaping': '設為塑形點', + 'tours.planner.recomputeLeg': '重新計算下一段', + 'tours.planner.shapeOnLine': '點選地圖上的路線即可在該處新增塑形點。', + 'tours.paddle.routeOnWater': '沿水路規劃下一段', + 'tours.paddle.routeAllOnWater': '沿水路規劃手繪的路段', + 'tours.paddle.auto': '已偵測', + 'tours.paddle.autoHint': 'TREK 將此點放在途中發現的搬運點或船閘處。移動或修改後,它就歸你所有。', + 'tours.paddle.note.rapids': '激流 {grade} 級', + 'tours.paddle.note.pass': '獨木舟通道', + 'tours.paddle.note.gap': '地圖資料缺口', + 'tours.paddle.note.rapidsHint': '路線在此經過急流。請先察看,如有疑慮請搬運繞行。', + 'tours.paddle.note.gapHint': '此處水域資料有缺口,以直線連接。請到現場確認。', + 'tours.paddle.failed.not_on_water': '此段的一端不在 TREK 已知的水域上。請把它移到河流或湖泊上。', + 'tours.paddle.failed.no_region': '該地區尚無水域資料。管理員可以新增該區域。', + 'tours.paddle.failed.building': '該區域的水域資料仍在準備中。請幾分鐘後再試。', + 'tours.paddle.failed.no_water_path': 'TREK 未在這些點之間找到水路。', + 'tours.paddle.failed.cross_region': '這些點位於兩個沒有共用水域資料的區域。', + 'tours.paddle.failed.busy': '路線規劃器忙碌中。請稍後再試。', + 'tours.paddle.failed.timeout': '規劃此段耗時過長。請在中間新增一個點後再試。', + 'tours.paddle.failed.offline': '離線。重新連線後將規劃此段。', + 'tours.paddle.drawStraight': '畫直線', + 'tours.paddle.retry': '重試', + 'tours.paddle.load.loading': '正在載入 {region} 的水域資料', + 'tours.paddle.load.ready': '{region} 的水域資料已到,正在規劃這一段。', + 'tours.paddle.load.requested': '已申請 {region} 的水域資料。管理員一放行就會規劃這一段。', + 'tours.paddle.load.waiting': '正在等待其他區域', + 'tours.paddle.load.phase.download': '正在下載', + 'tours.paddle.load.phase.read': '正在讀取', + 'tours.paddle.load.phase.build': '正在建構水網', + 'tours.paddle.load.hint': '通常需要一到三分鐘。可以繼續規劃,這一段之後會自動規劃。', + 'tours.paddle.load.reason.tooLarge': '該區域超出了 TREK 自行載入的大小。', + 'tours.paddle.load.reason.limit': 'TREK 今天自行載入的區域已經夠多了。', + 'tours.paddle.load.declined': '管理員拒絕了 {region} 的水域資料。', + 'tours.paddle.load.failed': '無法載入 {region} 的水域資料。', + 'tours.paddle.load.noWater': '{region} 的水域資料在這裡沒有水域。請把點放在河流或湖泊上。', + 'tours.paddle.load.noExtract': '該地區沒有包含水域資料的地圖擷取。', + 'tours.paddle.load.catalogUnavailable': '目前無法存取區域列表。請稍後再試。', + 'tours.paddle.lakeStyle': '穿越湖泊', + 'tours.paddle.lakeStyle.direct': '直線穿越', + 'tours.paddle.lakeStyle.shore': '沿岸', + 'tours.paddle.lakeStyle.directHint': '穿過開闊水面的最短路線。', + 'tours.paddle.lakeStyle.shoreHint': '靠近岸邊,適合有風的日子:較長,但陸地始終不遠。', + 'tours.paddle.wind.shoreAdvice': '當天風速最高 {mean} km/h,陣風 {gusts} km/h。在湖上沿岸可能是較好的路線。', + 'tours.paddle.wind.shoreAdopt': '沿岸規劃', + 'tours.paddle.wind.shoreTaken': '湖泊已依沿岸規劃。', + 'tours.paddle.status.unrouted': '{count} 段未規劃', }; export default tours; diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index b968a52e0..0ccb7bef2 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -798,5 +798,34 @@ const admin: TranslationStrings = { '数据 © OpenStreetMap 贡献者(ODbL),区域提取数据由 Geofabrik 提供。每月 3 日重新读取。', 'admin.paddleRegions.toast.added': '区域已添加。TREK 正在后台读取。', 'admin.paddleRegions.toast.removed': '区域已移除', + 'admin.paddleRegions.phase.build': '正在构建水路网络', + 'admin.paddleRegions.network.km': '{km} km 水域', + 'admin.paddleRegions.network.lakes': '{count} 个湖泊', + 'admin.paddleRegions.network.portages': '{count} 个已标注搬运点', + 'admin.paddleRegions.network.inferred': '{count} 处推定搬运', + 'admin.paddleRegions.network.locks': '{count} 座船闸', + 'admin.paddleRegions.network.gaps': '{count} 处已连接缺口', + 'admin.paddleRegions.network.missing': '无水路网络', + 'admin.paddleRegions.network.missingHint': '重新读取该区域以构建划桨路线所用的网络。', + 'admin.paddleRegions.auto.title': '自动加载区域', + 'admin.paddleRegions.auto.mode.auto': '自动', + 'admin.paddleRegions.auto.mode.ask': '按申请', + 'admin.paddleRegions.auto.mode.off': '关闭', + 'admin.paddleRegions.auto.modeHint.auto': + '当有人在还没有水域数据的地方规划划桨行程时,TREK 会立即加载最小的合适区域,不超过大小上限,每天只加载几个。更大的区域会作为申请在这里等待。发送给 Geofabrik 的只有区域名称,从不包括地点。', + 'admin.paddleRegions.auto.modeHint.ask': '每个新区域都在这里等待,直到管理员放行。管理员自己规划时会立即加载。', + 'admin.paddleRegions.auto.modeHint.off': 'TREK 不会自行加载任何内容,也不会发出申请。请在这里手动添加区域。', + 'admin.paddleRegions.auto.maxSize': '自动加载的最大区域', + 'admin.paddleRegions.auto.maxSizeHint': '更大的区域会作为申请在这里等待。读取一个区域需要几分钟,期间会占用内存。', + 'admin.paddleRegions.requests.title': '已申请', + 'admin.paddleRegions.requests.reason.ask': '按申请', + 'admin.paddleRegions.requests.reason.too_large': '过大', + 'admin.paddleRegions.requests.reason.limit': '每日上限', + 'admin.paddleRegions.requests.declined': '已拒绝', + 'admin.paddleRegions.requests.by': '来自 {user}', + 'admin.paddleRegions.requests.approve': '加载', + 'admin.paddleRegions.requests.decline': '拒绝', + 'admin.paddleRegions.origin.auto': '自动', + 'admin.paddleRegions.origin.autoHint': 'TREK 为在此规划的划桨行程自行加载了这个区域。', }; export default admin; diff --git a/shared/src/i18n/zh/externalNotifications.ts b/shared/src/i18n/zh/externalNotifications.ts index b5b4b3845..9049af8f3 100644 --- a/shared/src/i18n/zh/externalNotifications.ts +++ b/shared/src/i18n/zh/externalNotifications.ts @@ -61,6 +61,10 @@ const zh: NotificationLocale = { ? pluralForm(p.suppressed, 'zh', { other: `自上次通知以来,另有 ${p.suppressed} 次失败未单独通知。` }) : ''), }), + paddle_region_requested: (p) => ({ + title: '划桨区域申请', + body: `${p.actor} 正在 ${p.region} 规划划桨行程。请在管理后台的附加组件中加载或拒绝其水域数据。`, + }), synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。', diff --git a/shared/src/i18n/zh/notif.ts b/shared/src/i18n/zh/notif.ts index 8b70bbc14..97087fac5 100644 --- a/shared/src/i18n/zh/notif.ts +++ b/shared/src/i18n/zh/notif.ts @@ -31,6 +31,8 @@ const notif: TranslationStrings = { 'notif.replica_failure.text': "写入副本 '{backend}' 失败:{op} / {key} — {error}", 'notif.replica_failure.textSuppressed': "写入副本 '{backend}' 失败:{op} / {key} — {error}。自上次通知以来,还有 {suppressed} 个失败被抑制。", + 'notif.paddle_region_requested.title': '划桨区域申请', + 'notif.paddle_region_requested.text': '{actor} 正在 {region} 规划划桨行程', 'notif.action.view_trip': '查看旅行', 'notif.action.view_collab': '查看消息', 'notif.action.view_packing': '查看行李', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 16e694925..b2d22c9be 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -282,6 +282,7 @@ const settings: TranslationStrings = { 'settings.bookingLabelsHint': '在地图上显示车站 / 机场名称。关闭时仅显示图标。', 'settings.notifyVersionAvailable': '有新版本可用', 'settings.notifyReplicaFailure': '存储副本故障', + 'settings.notifyPaddleRegionRequested': '划桨区域申请', 'settings.notificationPreferences.noChannels': '未配置通知渠道。请联系管理员设置电子邮件或 Webhook 通知。', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index d56ff96cf..6db9c886e 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -265,7 +265,6 @@ const tours: TranslationStrings = { 'tours.paddle.speedInvalid': '请输入 0.5 至 20 km/h 之间的速度。', 'tours.paddle.chooseBoat': '请选择船只和水域以保存此划桨路线。', 'tours.paddle.chooseBoatImport': '请选择船只和水域以导入为划桨路线。', - 'tours.paddle.drawnByHand': '划桨路线需手动绘制:TREK 用直线连接你的点,不做路线规划,也不表示该水域可以通行。', 'tours.paddle.timeEstimated': '划桨用时(估算)', 'tours.paddle.timeEstimatedShort': '划桨', 'tours.paddle.timeEstimatedInfoLabel': '划桨用时如何估算', @@ -301,6 +300,16 @@ const tours: TranslationStrings = { 'tours.paddle.portage': '陆上搬运', 'tours.paddle.portageOn': '从这里开始陆上搬运', 'tours.paddle.portageOff': '这一段改回划行', + 'tours.paddle.portagePending': '从这里开始搬运', + 'tours.paddle.portagePendingHint': '现在设定你重新下水的地点。船会一直搬运到那里。', + 'tours.paddle.portagePendingOff': '不再搬运', + 'tours.paddle.portageWay': '路线', + 'tours.paddle.portageWay.follow': '沿道路', + 'tours.paddle.portageWay.straight': '直线', + 'tours.paddle.portageWay.noWay': '没有不绕远的道路,因此保持直线。', + 'tours.paddle.portageWay.searching': '正在寻找道路', + 'tours.paddle.placeHere.putIn': '在此下水', + 'tours.paddle.placeHere.egress': '在此上岸', 'tours.paddle.portageInfo': '此处需要搬运船只。这一段按陆路计算,并计入上岸、卸载和重新下水的时间。', 'tours.paddle.portageExtra': '上岸与下水', 'tours.paddle.lock': '船闸', @@ -482,5 +491,54 @@ const tours: TranslationStrings = { 'tours.paddle.safety.short': '划桨有风险。请自行检查水域、规定和天气。', 'tours.paddle.safety.text': '划桨存在风险。地图、路线建议和提示可能不完整或已过时。出发前,请检查当地当前状况、水域使用规定和天气。请采取适当的安全措施。TREK 为你的规划提供支持,但不能取代你自己的检查和负责任的决定。', + 'tours.planner.shapingPoint': '塑形点', + 'tours.planner.shapingHint': '引导路线但不作为停靠点。把它拖到路线应经过的地方。', + 'tours.planner.makeStop': '设为停靠点', + 'tours.planner.makeShaping': '设为塑形点', + 'tours.planner.recomputeLeg': '重新计算下一段', + 'tours.planner.shapeOnLine': '点击地图上的路线即可在该处添加塑形点。', + 'tours.paddle.routeOnWater': '沿水路规划下一段', + 'tours.paddle.routeAllOnWater': '沿水路规划手绘的路段', + 'tours.paddle.auto': '已识别', + 'tours.paddle.autoHint': 'TREK 将此点放在途中发现的搬运点或船闸处。移动或修改后,它就归你所有。', + 'tours.paddle.note.rapids': '激流 {grade} 级', + 'tours.paddle.note.pass': '皮划艇通道', + 'tours.paddle.note.gap': '地图数据缺口', + 'tours.paddle.note.rapidsHint': '路线在此经过急流。请先察看,如有疑虑请搬运绕行。', + 'tours.paddle.note.gapHint': '此处水域数据有缺口,以直线连接。请到现场确认。', + 'tours.paddle.failed.not_on_water': '此段的一端不在 TREK 已知的水域上。请把它移到河流或湖泊上。', + 'tours.paddle.failed.no_region': '该地区尚无水域数据。管理员可以添加该区域。', + 'tours.paddle.failed.building': '该区域的水域数据仍在准备中。请几分钟后再试。', + 'tours.paddle.failed.no_water_path': 'TREK 未在这些点之间找到水路。', + 'tours.paddle.failed.cross_region': '这些点位于两个没有共享水域数据的区域。', + 'tours.paddle.failed.busy': '路线规划器繁忙。请稍后再试。', + 'tours.paddle.failed.timeout': '规划此段耗时过长。请在中间添加一个点后再试。', + 'tours.paddle.failed.offline': '离线。重新连接后将规划此段。', + 'tours.paddle.drawStraight': '画直线', + 'tours.paddle.retry': '重试', + 'tours.paddle.load.loading': '正在加载 {region} 的水域数据', + 'tours.paddle.load.ready': '{region} 的水域数据已到,正在规划这一段。', + 'tours.paddle.load.requested': '已申请 {region} 的水域数据。管理员一放行就会规划这一段。', + 'tours.paddle.load.waiting': '正在等待其他区域', + 'tours.paddle.load.phase.download': '正在下载', + 'tours.paddle.load.phase.read': '正在读取', + 'tours.paddle.load.phase.build': '正在构建水网', + 'tours.paddle.load.hint': '通常需要一到三分钟。可以继续规划,这一段之后会自动规划。', + 'tours.paddle.load.reason.tooLarge': '该区域超出了 TREK 自行加载的大小。', + 'tours.paddle.load.reason.limit': 'TREK 今天自行加载的区域已经够多了。', + 'tours.paddle.load.declined': '管理员拒绝了 {region} 的水域数据。', + 'tours.paddle.load.failed': '无法加载 {region} 的水域数据。', + 'tours.paddle.load.noWater': '{region} 的水域数据在这里没有水域。请把点放在河流或湖泊上。', + 'tours.paddle.load.noExtract': '该地区没有包含水域数据的地图提取。', + 'tours.paddle.load.catalogUnavailable': '目前无法访问区域列表。请稍后再试。', + 'tours.paddle.lakeStyle': '穿越湖泊', + 'tours.paddle.lakeStyle.direct': '直线穿越', + 'tours.paddle.lakeStyle.shore': '沿岸', + 'tours.paddle.lakeStyle.directHint': '穿过开阔水面的最短线路。', + 'tours.paddle.lakeStyle.shoreHint': '靠近岸边,适合有风的日子:更长,但陆地始终不远。', + 'tours.paddle.wind.shoreAdvice': '当天风速最高 {mean} km/h,阵风 {gusts} km/h。在湖上沿岸可能是更好的路线。', + 'tours.paddle.wind.shoreAdopt': '沿岸规划', + 'tours.paddle.wind.shoreTaken': '湖泊已按沿岸规划。', + 'tours.paddle.status.unrouted': '{count} 段未规划', }; export default tours; diff --git a/shared/src/index.ts b/shared/src/index.ts index e5004433c..94d008a34 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -18,6 +18,7 @@ export * from './weather/wind.schema'; export * from './paddle/paddle-obstacles.schema'; export * from './paddle/paddle-route-relation'; export * from './paddle/paddle-regions.schema'; +export * from './paddle/paddle-route.schema'; export * from './public-api/public-api.schema'; export * from './airport/airport.schema'; export * from './config/config.schema'; diff --git a/shared/src/paddle/paddle-obstacles.schema.spec.ts b/shared/src/paddle/paddle-obstacles.schema.spec.ts index b063df0db..b2bc465f3 100644 --- a/shared/src/paddle/paddle-obstacles.schema.spec.ts +++ b/shared/src/paddle/paddle-obstacles.schema.spec.ts @@ -124,15 +124,36 @@ describe('paddle region contracts (PAD-05A)', () => { id: 'oberpfalz', name: 'Oberpfalz', status: 'importing', + origin: 'admin', source_timestamp: null, imported_at: null, feature_count: 0, error: null, progress: { phase: 'download', fraction: 0.4 }, + network: null, }; expect(paddleRegionSchema.safeParse(region).success).toBe(true); + // PAD-11: the water network built from the read, counted, and the phase that builds it. + const network = { + water_km: 412.5, + lakes: 12, + portages: 3, + inferred_portages: 7, + gaps: 2, + locks: 1, + built_at: '2026-10-17T15:30:20Z', + bytes: 1024, + }; + expect( + paddleRegionSchema.safeParse({ ...region, network, progress: { phase: 'build', fraction: 0.5 } }).success, + ).toBe(true); + expect(paddleRegionSchema.safeParse({ ...region, network: { ...network, lakes: -1 } }).success).toBe(false); + expect(paddleRegionSchema.safeParse({ ...region, network: undefined }).success).toBe(false); expect(paddleRegionSchema.safeParse({ ...region, progress: { phase: 'read', fraction: null } }).success).toBe(true); expect(paddleRegionSchema.safeParse({ ...region, progress: { phase: 'read', fraction: 2 } }).success).toBe(false); + // PAD-11C: who added it, an admin or TREK for a paddler. + expect(paddleRegionSchema.safeParse({ ...region, origin: 'auto' }).success).toBe(true); + expect(paddleRegionSchema.safeParse({ ...region, origin: 'paddler' }).success).toBe(false); expect(paddleRegionSchema.safeParse({ ...region, status: 'done' }).success).toBe(false); }); diff --git a/shared/src/paddle/paddle-region-load.schema.spec.ts b/shared/src/paddle/paddle-region-load.schema.spec.ts new file mode 100644 index 000000000..0ad85ba8c --- /dev/null +++ b/shared/src/paddle/paddle-region-load.schema.spec.ts @@ -0,0 +1,74 @@ +import { + MAX_PADDLE_LOAD_POINTS, + PADDLE_AUTO_MAX_MB, + paddleRegionListResponseSchema, + paddleRegionLoadRequestSchema, + paddleRegionLoadResponseSchema, + paddleRegionLoadStateSchema, + paddleRegionPendingSchema, + paddleRegionSettingsSchema, +} from './paddle-regions.schema'; + +import { describe, expect, it } from 'vitest'; + +const REF = { id: 'oberpfalz', name: 'Oberpfalz' }; +const PLACE = { lat: 49.1, lng: 12.1 }; + +describe('paddle region loading (PAD-11C)', () => { + it('asks about one place up to a handful, retries only when told to', () => { + expect(paddleRegionLoadRequestSchema.parse({ points: [PLACE] })).toEqual({ points: [PLACE], retry: false }); + expect(paddleRegionLoadRequestSchema.safeParse({ points: [] }).success).toBe(false); + const many = Array.from({ length: MAX_PADDLE_LOAD_POINTS + 1 }, () => PLACE); + expect(paddleRegionLoadRequestSchema.safeParse({ points: many }).success).toBe(false); + expect(paddleRegionLoadRequestSchema.safeParse({ points: [{ lat: 91, lng: 0 }] }).success).toBe(false); + }); + + it('answers every state a place can be in, each with what it needs', () => { + const states = [ + { state: 'ready', region: REF }, + { state: 'no_water', region: REF }, + { state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.3 }, ahead: 0 }, + { state: 'loading', region: REF, progress: null, ahead: 2 }, + { state: 'requested', region: REF, reason: 'too_large' }, + { state: 'declined', region: REF }, + { state: 'failed', region: REF }, + { state: 'unavailable', region: null, reason: 'no_extract' }, + { state: 'unavailable', region: REF, reason: 'off' }, + ]; + for (const state of states) expect(paddleRegionLoadStateSchema.safeParse(state).success).toBe(true); + expect(paddleRegionLoadResponseSchema.safeParse({ points: states }).success).toBe(true); + expect(paddleRegionLoadStateSchema.safeParse({ state: 'loading', region: REF, progress: null }).success).toBe( + false, + ); + expect(paddleRegionLoadStateSchema.safeParse({ state: 'requested', region: REF, reason: 'bored' }).success).toBe( + false, + ); + }); + + it('holds the admin setting to its modes and a size between its bounds', () => { + expect(paddleRegionSettingsSchema.safeParse({ mode: 'auto', max_mb: PADDLE_AUTO_MAX_MB.default }).success).toBe( + true, + ); + expect(paddleRegionSettingsSchema.safeParse({ mode: 'always', max_mb: 500 }).success).toBe(false); + expect(paddleRegionSettingsSchema.safeParse({ mode: 'ask', max_mb: PADDLE_AUTO_MAX_MB.max + 1 }).success).toBe( + false, + ); + expect(paddleRegionSettingsSchema.safeParse({ mode: 'off', max_mb: 2.5 }).success).toBe(false); + }); + + it('lists the regions with the requests that wait for an admin', () => { + const request = { + id: 'norway', + name: 'Norway', + reason: 'too_large', + bytes: 1_300_000_000, + requested_by: 'elmo', + requested_at: '2026-10-18T09:00:00Z', + declined: false, + }; + expect(paddleRegionPendingSchema.safeParse(request).success).toBe(true); + expect(paddleRegionPendingSchema.safeParse({ ...request, requested_by: null, bytes: null }).success).toBe(true); + expect(paddleRegionListResponseSchema.safeParse({ regions: [], requests: [request] }).success).toBe(true); + expect(paddleRegionListResponseSchema.safeParse({ regions: [] }).success).toBe(false); + }); +}); diff --git a/shared/src/paddle/paddle-regions.schema.ts b/shared/src/paddle/paddle-regions.schema.ts index 52aecd12a..6337f4bb4 100644 --- a/shared/src/paddle/paddle-regions.schema.ts +++ b/shared/src/paddle/paddle-regions.schema.ts @@ -1,3 +1,5 @@ +import { paddleRoutePointSchema } from './paddle-route.schema'; + import { z } from 'zod'; /** @@ -10,10 +12,36 @@ import { z } from 'zod'; export const paddleRegionStatusSchema = z.enum(['queued', 'importing', 'ready', 'failed']); export type PaddleRegionStatus = z.infer; +/** PAD-11: what a region's water network holds, as the build counted it. */ +export const paddleRegionGraphStatsSchema = z.object({ + /** Kilometres of river and canal the router can follow. */ + water_km: z.number().nonnegative(), + lakes: z.number().int().nonnegative(), + portages: z.number().int().nonnegative(), + /** Barriers the router found no mapped portage for, only a short straight carry. */ + inferred_portages: z.number().int().nonnegative(), + /** Short gaps between waters the router bridges, marked as such. */ + gaps: z.number().int().nonnegative(), + locks: z.number().int().nonnegative(), +}); +export type PaddleRegionGraphStats = z.infer; + +/** How far a read has come: the download, the reading of the file, the build of its network, each with the share done (null while unknown). */ +export const paddleRegionProgressSchema = z.object({ + phase: z.enum(['download', 'read', 'build']), + fraction: z.number().min(0).max(1).nullable(), +}); +export type PaddleRegionProgress = z.infer; + +/** Who added a region: an admin by hand, or TREK itself for a paddler planning there (PAD-11C). */ +export const paddleRegionOriginSchema = z.enum(['admin', 'auto']); +export type PaddleRegionOrigin = z.infer; + export const paddleRegionSchema = z.object({ id: z.string(), name: z.string(), status: paddleRegionStatusSchema, + origin: paddleRegionOriginSchema, /** The date the region's data is current to, ISO 8601; null before the first read. */ source_timestamp: z.string().nullable(), /** When TREK last read it, ISO 8601. */ @@ -23,19 +51,67 @@ export const paddleRegionSchema = z.object({ error: z.string().nullable(), /** * How far the read under way has come: the download, then the reading of - * the file, each with the share done (null while the size is unknown). + * the file and the build of its network, each with the share done. * Null unless the region is being read. */ - progress: z - .object({ - phase: z.enum(['download', 'read']), - fraction: z.number().min(0).max(1).nullable(), + progress: paddleRegionProgressSchema.nullable(), + /** + * PAD-11: the water network built from the same read, for routing: how big + * it came out and what the data left open. Null before the first build. + */ + network: paddleRegionGraphStatsSchema + .extend({ + /** When the network was built, ISO 8601. */ + built_at: z.string(), + /** The size of its file. */ + bytes: z.number().int().nonnegative(), }) .nullable(), }); export type PaddleRegion = z.infer; -export const paddleRegionListResponseSchema = z.object({ regions: z.array(paddleRegionSchema) }); +/** + * PAD-11C: how TREK gets the water data of a place a paddler plans in, when + * no region covers it yet. `auto` adds the smallest of Geofabrik's regions + * around it by itself, up to `max_mb` and a few a day; what is larger, or past + * the day's few, waits for an admin as a request. `ask` sends every such + * place to an admin as a request, `off` neither loads nor asks. + */ +export const paddleRegionAutoModeSchema = z.enum(['auto', 'ask', 'off']); +export type PaddleRegionAutoMode = z.infer; + +export const PADDLE_AUTO_MAX_MB = { min: 10, max: 4096, default: 500 } as const; +/** How many regions TREK adds by itself in one day (UTC); more wait for an admin. */ +export const PADDLE_AUTO_REGIONS_PER_DAY = 5; + +export const paddleRegionSettingsSchema = z.object({ + mode: paddleRegionAutoModeSchema, + max_mb: z.number().int().min(PADDLE_AUTO_MAX_MB.min).max(PADDLE_AUTO_MAX_MB.max), +}); +export type PaddleRegionSettings = z.infer; + +/** Why a region waits for an admin: the mode asks for it, it is larger than allowed, or the day's few are used up. */ +export const paddleRegionWaitReasonSchema = z.enum(['ask', 'too_large', 'limit']); +export type PaddleRegionWaitReason = z.infer; + +/** A region a paddler's planning asked for and an admin has yet to allow (or declined). */ +export const paddleRegionPendingSchema = z.object({ + id: z.string(), + name: z.string(), + reason: paddleRegionWaitReasonSchema, + /** The extract's size as Geofabrik announced it, bytes; null when it did not say. */ + bytes: z.number().int().nonnegative().nullable(), + /** The user whose planning asked for it, by name; null once that account is gone. */ + requested_by: z.string().nullable(), + requested_at: z.string(), + declined: z.boolean(), +}); +export type PaddleRegionPending = z.infer; + +export const paddleRegionListResponseSchema = z.object({ + regions: z.array(paddleRegionSchema), + requests: z.array(paddleRegionPendingSchema), +}); export type PaddleRegionListResponse = z.infer; export const paddleRegionCatalogEntrySchema = z.object({ @@ -68,3 +144,53 @@ export type PaddleRegionBoundary = z.infer; export const paddleRegionRequestSchema = z.object({ id: paddleRegionIdSchema }); export type PaddleRegionRequest = z.infer; + +/** The most places one look for water data asks about; the planner asks for the ends of its unplanned legs. */ +export const MAX_PADDLE_LOAD_POINTS = 10; + +/** + * PAD-11C: the planner asks for the water data around places no region + * covers. Asking again is how it follows a load: an answer never starts a + * second one, and a request already with the admins is not sent twice. + */ +export const paddleRegionLoadRequestSchema = z.object({ + points: z.array(paddleRoutePointSchema).min(1).max(MAX_PADDLE_LOAD_POINTS), + /** Read a region again whose last read failed. */ + retry: z.boolean().default(false), +}); +export type PaddleRegionLoadRequest = z.input; + +const regionRefSchema = z.object({ id: z.string(), name: z.string() }); + +/** + * Where the water data around one place stands. `ready`: a network covers it, + * plan again. `no_water`: its region is read and holds no water near it. + * `loading`: its region is downloaded or read now, `ahead` others before it. + * `requested` and `declined`: it waits for an admin, or an admin said no. + * `failed`: the last read of its region failed. `unavailable`: nothing to be + * done here (loading is off, Geofabrik has no extract for the place, or its + * list cannot be fetched right now). + */ +export const paddleRegionLoadStateSchema = z.discriminatedUnion('state', [ + z.object({ state: z.literal('ready'), region: regionRefSchema }), + z.object({ state: z.literal('no_water'), region: regionRefSchema }), + z.object({ + state: z.literal('loading'), + region: regionRefSchema, + progress: paddleRegionProgressSchema.nullable(), + ahead: z.number().int().nonnegative(), + }), + z.object({ state: z.literal('requested'), region: regionRefSchema, reason: paddleRegionWaitReasonSchema }), + z.object({ state: z.literal('declined'), region: regionRefSchema }), + z.object({ state: z.literal('failed'), region: regionRefSchema }), + z.object({ + state: z.literal('unavailable'), + region: regionRefSchema.nullable(), + reason: z.enum(['off', 'no_extract', 'catalog_unavailable']), + }), +]); +export type PaddleRegionLoadState = z.infer; + +/** One answer per place asked about, in the order asked. */ +export const paddleRegionLoadResponseSchema = z.object({ points: z.array(paddleRegionLoadStateSchema) }); +export type PaddleRegionLoadResponse = z.infer; diff --git a/shared/src/paddle/paddle-route.schema.spec.ts b/shared/src/paddle/paddle-route.schema.spec.ts new file mode 100644 index 000000000..ca0a5141f --- /dev/null +++ b/shared/src/paddle/paddle-route.schema.spec.ts @@ -0,0 +1,51 @@ +import { MAX_PADDLE_ROUTE_LEGS, paddleRouteRequestSchema, paddleRouteResponseSchema } from './paddle-route.schema'; + +import { describe, expect, it } from 'vitest'; + +const A = { lat: 49.1, lng: 12.1 }; +const B = { lat: 49.0, lng: 12.1 }; + +describe('paddle route contract (PAD-11)', () => { + it('fills a request with a kayak, straight over the lakes and locks as the data says', () => { + expect(paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }] })).toEqual({ + legs: [{ from: A, to: B }], + boat: 'kayak', + lake_style: 'direct', + lock_policy: 'auto', + }); + }); + + it('refuses no legs, too many legs, a point off the globe and an unknown boat', () => { + const many = Array.from({ length: MAX_PADDLE_ROUTE_LEGS + 1 }, () => ({ from: A, to: B })); + expect(paddleRouteRequestSchema.safeParse({ legs: [] }).success).toBe(false); + expect(paddleRouteRequestSchema.safeParse({ legs: many }).success).toBe(false); + expect(paddleRouteRequestSchema.safeParse({ legs: [{ from: { lat: 91, lng: 0 }, to: B }] }).success).toBe(false); + expect(paddleRouteRequestSchema.safeParse({ legs: [{ from: A, to: B }], boat: 'yacht' }).success).toBe(false); + }); + + it('answers each leg planned with its line and stretches, or failed with a code', () => { + const answer = { + graph_rev: 'r1', + legs: [ + { + status: 'ok', + geometry: [ + [A.lat, A.lng], + [B.lat, B.lng], + ], + segments: [{ kind: 'rapids', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: 2 }], + distance_m: 11100, + snapped_m: { from: 4, to: 0 }, + }, + { status: 'failed', code: 'no_region', at: A }, + ], + }; + expect(paddleRouteResponseSchema.parse(answer)).toEqual(answer); + const badGrade = structuredClone(answer); + badGrade.legs[0]!.segments![0]!.grade = 7; + expect(paddleRouteResponseSchema.safeParse(badGrade).success).toBe(false); + const oneVertex = structuredClone(answer); + oneVertex.legs[0]!.geometry = [[A.lat, A.lng]]; + expect(paddleRouteResponseSchema.safeParse(oneVertex).success).toBe(false); + }); +}); diff --git a/shared/src/paddle/paddle-route.schema.ts b/shared/src/paddle/paddle-route.schema.ts new file mode 100644 index 000000000..7f4d0984c --- /dev/null +++ b/shared/src/paddle/paddle-route.schema.ts @@ -0,0 +1,109 @@ +import { paddleBoatTypeSchema, paddleLakeStyleSchema } from '../tours/tours.schema'; + +import { z } from 'zod'; + +/** + * PAD-11: routing on the water. TREK builds a water network of its own from + * the same OpenStreetMap extracts the paddle obstacles come from, and plans a + * paddle tour's legs on it: rivers and canals along their centre lines, lakes + * across the water with a margin to the shore, and short portages where a + * weir, a dam or a closed stretch bars the way. Nothing is joined by a + * straight line in secret: a stretch the data cannot connect fails with the + * place it fails at, and a short gap the router bridges says so. + */ + +export const paddleRoutePointSchema = z.object({ + lat: z.number().finite().min(-90).max(90), + lng: z.number().finite().min(-180).max(180), +}); +export type PaddleRoutePoint = z.infer; + +/** What a lock on the way means: going through it, carrying round it, or whichever is quicker. */ +export const paddleLockPolicySchema = z.enum(['auto', 'use', 'portage']); +export type PaddleLockPolicy = z.infer; + +/** The most legs one request plans; a tour with more asks again. */ +export const MAX_PADDLE_ROUTE_LEGS = 40; + +export const paddleRouteRequestSchema = z.object({ + legs: z + .array(z.object({ from: paddleRoutePointSchema, to: paddleRoutePointSchema })) + .min(1) + .max(MAX_PADDLE_ROUTE_LEGS), + boat: paddleBoatTypeSchema.default('kayak'), + lake_style: paddleLakeStyleSchema.default('direct'), + lock_policy: paddleLockPolicySchema.default('auto'), +}); +export type PaddleRouteRequest = z.infer; +export type PaddleRouteInput = z.input; + +/** + * The stretches of a routed leg, by index into its geometry: paddled on a + * river or canal, across a lake, down whitewater (a stretch OSM grades), through + * a boat pass beside a weir, carried as a portage, through a lock, or a short + * gap in the data the router bridged. + */ +export const paddleRouteSegmentKindSchema = z.enum(['water', 'lake', 'rapids', 'pass', 'portage', 'lock', 'gap']); +export type PaddleRouteSegmentKind = z.infer; + +/** Where a portage's line came from: a portage way mapped in OSM, a footpath, or drawn straight by the router. */ +export const paddlePortageSourceSchema = z.enum(['tagged', 'footway', 'inferred']); +export type PaddlePortageSource = z.infer; + +export const paddleRouteSegmentSchema = z.object({ + kind: paddleRouteSegmentKindSchema, + /** First and last index into the leg's geometry, both included. */ + from: z.number().int().nonnegative(), + to: z.number().int().nonnegative(), + /** The water's, the lock's or the barrier's name, where OSM gives one. */ + name: z.string().nullable(), + /** A portage's origin; null for every other kind. */ + source: paddlePortageSourceSchema.nullable(), + /** The OSM object behind a portage's barrier or a lock, `way/123` style. */ + osm_ref: z.string().nullable(), + /** A whitewater stretch's grade (1 to 6); null for every other kind. */ + grade: z.number().int().min(1).max(6).nullable(), +}); +export type PaddleRouteSegment = z.infer; + +export const paddleRouteFailureCodeSchema = z.enum([ + /** A point lies farther from any water the network knows than the router reaches. */ + 'not_on_water', + /** No region with a water network covers the point. */ + 'no_region', + /** The region's network is being built; asked again shortly it may answer. */ + 'building', + /** Both ends are on water, but no way over water (and short portages) joins them. */ + 'no_water_path', + /** The ends lie in two regions that do not share the stretch between them. */ + 'cross_region', + 'busy', + 'timeout', +]); +export type PaddleRouteFailureCode = z.infer; + +export const paddleRouteLegSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('ok'), + /** [lat, lng] from the leg's start to its end, on the water. */ + geometry: z.array(z.tuple([z.number(), z.number()])).min(2), + segments: z.array(paddleRouteSegmentSchema).min(1), + distance_m: z.number().nonnegative(), + /** How far the ends were moved onto the water, in metres. */ + snapped_m: z.object({ from: z.number().nonnegative(), to: z.number().nonnegative() }), + }), + z.object({ + status: z.literal('failed'), + code: paddleRouteFailureCodeSchema, + /** Where it failed: the point off the water, or the nearest water to it. */ + at: paddleRoutePointSchema.nullable(), + }), +]); +export type PaddleRouteLeg = z.infer; + +export const paddleRouteResponseSchema = z.object({ + legs: z.array(paddleRouteLegSchema), + /** The data the legs were planned on, per region used; a newer one may plan them differently. */ + graph_rev: z.string().nullable(), +}); +export type PaddleRouteResponse = z.infer; diff --git a/shared/src/tours/paddle-plan.spec.ts b/shared/src/tours/paddle-plan.spec.ts index 274b69aea..726a1db99 100644 --- a/shared/src/tours/paddle-plan.spec.ts +++ b/shared/src/tours/paddle-plan.spec.ts @@ -185,3 +185,40 @@ describe('paddle plan (PAD-03, PAD-07)', () => { expect(paddle.success).toBe(true); }); }); + +describe('a routed paddle tour (PAD-11, P2A-03)', () => { + it('times each leg by the length the router found where it is known, straight where not', () => { + const [a, b, c] = [east(0), east(2), east(4)]; + const straight = paddleLayoutOfPoints([a, b, c]); + expect(straight.stretches[0]?.toM).toBeCloseTo(4000, -1); + const routed = paddleLayoutOfPoints([a, b, c], [3500, null]); + expect(routed.stretches[0]?.toM).toBeCloseTo(5500, -1); + }); + + it('keeps the lake style and shaping points to paddle tours and to plans', () => { + const base = { + name: 'Across the lake', + route_geometry: [ + [53, 13, 0], + [53, 13.01, 0], + ] as [number, number, number][], + waypoints: [ + { lat: 53, lng: 13, role: 'start', sequence: 0 }, + { lat: 53, lng: 13.005, role: 'via', sequence: 1, shaping: true }, + { lat: 53, lng: 13.01, role: 'end', sequence: 2 }, + ], + }; + const hike = tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike', paddle_lake_style: 'shore' }); + expect(hike.success).toBe(false); + expect(hike.error?.issues.map((issue) => issue.path.join('.'))).toContain('paddle_lake_style'); + const paddle = tourCreateRequestSchema.safeParse({ + ...base, + tour_type: 'kayak', + boat_type: 'kayak', + water_context: 'lake', + paddle_lake_style: 'shore', + }); + expect(paddle.success).toBe(true); + expect(tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike' }).success).toBe(true); + }); +}); diff --git a/shared/src/tours/paddle-plan.ts b/shared/src/tours/paddle-plan.ts index e91568743..8bfd47b21 100644 --- a/shared/src/tours/paddle-plan.ts +++ b/shared/src/tours/paddle-plan.ts @@ -90,8 +90,16 @@ function bypassedBy(stretches: readonly PaddleStretch[], atM: number): boolean { return stretches.some((s) => s.kind === 'portage' && atM >= s.fromM - 0.5 && atM <= s.toM + 0.5); } -/** The stretches and locks of a planned tour, measured along the legs between its control points. */ -export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): PaddleLayout { +/** + * The stretches and locks of a planned tour, measured along the legs between + * its control points. `legLengths` gives a leg's length where the planner + * knows it better than the points do (a leg routed on the water, PAD-11); + * without it a leg is as long as its line, or the straight distance. + */ +export function paddleLayoutOfPoints( + points: readonly PaddlePlanPoint[], + legLengths?: ReadonlyArray, +): PaddleLayout { const stretches: PaddleStretch[] = []; const at: number[] = []; let distance = 0; @@ -100,7 +108,9 @@ export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): Paddle const next = points[index + 1]; if (!next) return; const leg = point.fixed_leg; - const length = leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng); + const length = + legLengths?.[index] ?? + (leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng)); const kind = isPortage(leg) ? 'portage' : 'water'; const last = stretches[stretches.length - 1]; if (last && last.kind === kind) last.toM = distance + length; diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index b326b3eac..e97419b09 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -40,6 +40,10 @@ export type PaddleBoatType = z.infer; export const paddleWaterContextSchema = z.enum(['river', 'lake']); export type PaddleWaterContext = z.infer; +/** PAD-11: how the router crosses lakes: straight over (the default), or along the shore, which a paddler picks for wind. */ +export const paddleLakeStyleSchema = z.enum(['direct', 'shore']); +export type PaddleLakeStyle = z.infer; + /** The highest planning speed a paddler can enter, in km/h. */ export const MAX_PADDLE_SPEED_KMH = 20; export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH); @@ -123,6 +127,8 @@ export const tourSchema = z.object({ paddle_speed_kmh: z.number().nullable().optional(), /** PAD-07: the carrying speed of a paddle tour's portages; null while the starting value applies. */ portage_speed_kmh: z.number().nullable().optional(), + /** PAD-11: how its lakes were routed; null (or absent) means straight over. */ + paddle_lake_style: paddleLakeStyleSchema.nullable().optional(), /** PAD-03: portages and locks laid onto a recorded track; null for a planned tour and a track without any. */ track_marks: trackMarksSchema.nullable().optional(), }); @@ -222,6 +228,11 @@ export const tourWaypointSchema = z.object({ fixed_leg: tourFixedLegSchema.nullable().optional(), /** PAD-07: a lock at this point of a paddle tour, with the times the paddler expects there. */ lock: paddleLockSchema.nullable().optional(), + /** + * P2A-03: a shaping point, there only to steer the route: no stop, no place + * to get in or out. Absent means a stop, as every point saved before was. + */ + shaping: z.boolean().optional(), }); export type TourWaypoint = z.infer; @@ -256,6 +267,7 @@ export const tourCreateRequestSchema = z water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), portage_speed_kmh: portageSpeedKmhSchema.nullable().optional(), + paddle_lake_style: paddleLakeStyleSchema.nullable().optional(), duration_seconds: z.number().finite().nonnegative().nullable().optional(), planned_duration_minutes: plannedTourDurationMinutesSchema.optional(), break_additional_minutes: plannedTourDurationMinutesSchema.optional(), @@ -332,6 +344,7 @@ function tourTypeIssues(value: { water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; portage_speed_kmh?: number | null; + paddle_lake_style?: PaddleLakeStyle | null; waypoints?: ReadonlyArray<{ fixed_leg?: { kind: string } | null; lock?: unknown }>; }): Array<{ path: Array; message: string }> { const issues: Array<{ path: Array; message: string }> = []; @@ -345,7 +358,13 @@ function tourTypeIssues(value: { } if (value.max_hiking_difficulty === null) issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' }); - for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh', 'portage_speed_kmh'] as const) { + for (const field of [ + 'boat_type', + 'water_context', + 'paddle_speed_kmh', + 'portage_speed_kmh', + 'paddle_lake_style', + ] as const) { if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` }); } // A portage and a lock are a paddler's: a hike carries no boat. diff --git a/wiki/Admin-Addons.md b/wiki/Admin-Addons.md index 1967af119..62388399a 100644 --- a/wiki/Admin-Addons.md +++ b/wiki/Admin-Addons.md @@ -40,11 +40,13 @@ Each sub-toggle can be disabled independently while the parent addon remains ena While **Tours** is on, a **Paddle water data** card appears under the addons. It holds the regions TREK knows weirs, dams, locks, rapids, put-ins, take-outs, portage ways and paddling rules for. TREK reads them from OpenStreetMap, from the regional extracts Geofabrik publishes, with a reader of its own; no other service is asked. 1. Search the card for a region, for example *Oberpfalz*. Each hit shows the regions it lies in, so two places of one name tell apart. -2. Click **Add**. TREK downloads the extract in the background, reads the paddle objects out of it and deletes the file. The card shows the download and the reading with their progress, without reloading the page. A large region takes a few minutes; regions are read one after another. -3. Once the region is **Ready**, the card shows how many objects it holds and the date its data is current to. +2. Click **Add**. TREK downloads the extract in the background, reads the paddle objects out of it, builds the region's water network from the same read and deletes the file. The card shows the download, the reading and the building with their progress, without reloading the page. A large region takes a few minutes; regions are read one after another. +3. Once the region is **Ready**, the card shows how many objects it holds, the date its data is current to and how many kilometres of water its network holds (lakes, mapped and assumed portages, locks and bridged gaps in its tooltip). A region read with an older TREK shows **No water network** until it is read again. Paddle tours inside a region show its obstacles on the planner map, each with a card that says how it stands to the route, what paddling rule its tags give and how current the data is. Outside every region a paddle tour says that no obstacle data is known there; TREK never shows the water as free of obstacles. +**Routing on the water.** Inside a region the planner plans a paddle tour along rivers and canals and across lakes, the way it routes a hike on paths: set the put-in and the take-out, and the route follows the water. Where a weir, dam or waterfall bars the way it carries round it, on a mapped portage way where there is one and on a short straight carry where there is none, and it goes through open locks. A click on the route sets a shaping point that steers it, for example to one side of an island. Lakes are crossed straight over or near the shore, as each tour chooses; on a windy day the wind card suggests the shore. A leg TREK cannot plan, because a point lies off the water or between two regions, stays a straight red line until it is moved or drawn by hand on purpose. The router runs in a thread of the server itself; no other service is asked. Each region's network is a file in the data folder (a large region takes a few megabytes) and is built again with every read. + **Monthly refresh.** Every region with data is read again on the 3rd of every month at 04:20 (server time); **Read again** does it at once. A read that fails keeps the data read before and shows the reason. A restart during a read queues it again. **Remove region** deletes a region and its objects after a confirmation. **Data and licence.** The objects are OpenStreetMap data, © OpenStreetMap contributors, under the Open Database License (ODbL); TREK shows the attribution next to them. A record in the data is no check on site: weirs, locks and rules change, and the data can have gaps. The download needs outbound access to Geofabrik's server and, while a region is read, room for its extract in the data folder's temporary space (a whole country takes a few gigabytes). diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index cef5a7ecd..08d7ed4be 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -233,13 +233,14 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T ### Tours _(Tours addon required)_ -Read only: the saved tours of a trip, as the Tours list and the tour detail show them. Tours someone is still drawing in the planner are not saved yet and do not show here. +Read only: the saved tours of a trip, as the Tours list and the tour detail show them, and paddle legs planned on the water. Tours someone is still drawing in the planner are not saved yet and do not show here. | Tool | Description | Scope | |---|---|---| | `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale, the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), and the days of the trip each tour is on | `trips:read` | -| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` | +| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` | | `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | +| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` | ### Road trip _(Road trip addon required)_