From adcaca3c5f868ee32447d3cbb463ee91545e4ce5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 19 Oct 2026 21:33:40 +0200 Subject: [PATCH] feat(tours): bike tours in the contract and on the server (BIK-01 to BIK-06, RTE-01) A tour can be a bike tour with a routing profile (touring, road, gravel, mtb), stored per tour by a new migration. The server grades the ways of a mountain bike route from OpenStreetMap (mtb:scale) through one shared Overpass client, offers CyclOSM behind its own admin switch, and exports a bike tour's GPX with the activity of its profile. A hike's route choices are now exactly the three the card names: steep climbs, steps and ferries. --- server/src/db/entities/Tours.entity.ts | 11 + server/src/db/kysely/Tours.table.ts | 1 + ...tion20200101045200_a_tour_can_be_ridden.ts | 18 + .../src/db/repositories/Tours.repository.ts | 12 +- server/src/middleware/globalMiddleware.ts | 5 + server/src/nest/addons/addons.service.ts | 11 + server/src/nest/admin/admin.controller.ts | 19 ++ .../src/nest/common/app-settings.registry.ts | 4 + server/src/nest/ferratas/ferratas.helpers.ts | 5 - server/src/nest/ferratas/ferratas.module.ts | 10 +- server/src/nest/ferratas/ferratas.service.ts | 6 +- server/src/nest/maps/maps.helpers.ts | 5 +- .../overpass-elements.client.ts} | 23 +- server/src/nest/overpass/overpass.module.ts | 13 + .../nest/tours/dto/tour-trail-grade.dto.ts | 5 + .../src/nest/tours/tour-gpx-export.service.ts | 16 +- .../src/nest/tours/tour-poi-search.service.ts | 1 + .../nest/tours/tour-trail-grade.controller.ts | 29 ++ .../nest/tours/tour-trail-grade.service.ts | 97 ++++++ server/src/nest/tours/tours.mcp.ts | 4 +- server/src/nest/tours/tours.module.ts | 8 +- server/src/nest/tours/tours.service.ts | 13 +- server/tests/e2e/addons.e2e.test.ts | 1 + server/tests/e2e/tours-bike.e2e.test.ts | 307 ++++++++++++++++++ server/tests/e2e/tours-planned.e2e.test.ts | 6 +- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Tours.repository.test.ts | 4 + .../middleware/globalMiddleware.csp.test.ts | 6 + server/tests/unit/nest/addons.service.test.ts | 16 + .../tests/unit/nest/admin.controller.test.ts | 4 + .../tests/unit/nest/ferratas.helpers.test.ts | 13 - .../tests/unit/nest/ferratas.service.test.ts | 4 +- ...st.ts => overpass-elements.client.test.ts} | 32 +- .../nest/tour-trail-grade.service.test.ts | 78 +++++ .../nest/tours.service.permissions.test.ts | 2 + server/tests/unit/nest/tours.service.test.ts | 4 +- server/vitest.config.ts | 3 + shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/ar/map.ts | 2 + shared/src/i18n/ar/tours.ts | 78 ++++- shared/src/i18n/az/admin.ts | 3 + shared/src/i18n/az/map.ts | 2 + shared/src/i18n/az/tours.ts | 80 ++++- shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/map.ts | 2 + shared/src/i18n/br/tours.ts | 81 ++++- shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/map.ts | 2 + shared/src/i18n/ca/tours.ts | 81 ++++- shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/map.ts | 2 + shared/src/i18n/cs/tours.ts | 78 ++++- shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/map.ts | 3 + shared/src/i18n/de/tours.ts | 82 ++++- shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/map.ts | 2 + shared/src/i18n/en/tours.ts | 80 ++++- shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/map.ts | 2 + shared/src/i18n/es/tours.ts | 80 ++++- shared/src/i18n/et/admin.ts | 3 + shared/src/i18n/et/map.ts | 2 + shared/src/i18n/et/tours.ts | 79 ++++- shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/map.ts | 3 + shared/src/i18n/fr/tours.ts | 83 ++++- shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/map.ts | 2 + shared/src/i18n/gr/tours.ts | 82 ++++- shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/map.ts | 2 + shared/src/i18n/hu/tours.ts | 81 ++++- shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/map.ts | 2 + shared/src/i18n/id/tours.ts | 80 ++++- shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/map.ts | 2 + shared/src/i18n/it/tours.ts | 80 ++++- shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ja/map.ts | 2 + shared/src/i18n/ja/tours.ts | 80 ++++- shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/ko/map.ts | 2 + shared/src/i18n/ko/tours.ts | 79 ++++- shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/map.ts | 3 + shared/src/i18n/nl/tours.ts | 80 ++++- shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/map.ts | 2 + shared/src/i18n/pl/tours.ts | 81 ++++- shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/map.ts | 2 + shared/src/i18n/ru/tours.ts | 81 ++++- shared/src/i18n/sk/admin.ts | 3 + shared/src/i18n/sk/map.ts | 2 + shared/src/i18n/sk/tours.ts | 80 ++++- shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/map.ts | 2 + shared/src/i18n/sv/tours.ts | 79 ++++- shared/src/i18n/th/admin.ts | 3 + shared/src/i18n/th/map.ts | 2 + shared/src/i18n/th/tours.ts | 78 ++++- shared/src/i18n/tours-locales.spec.ts | 4 +- shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/map.ts | 2 + shared/src/i18n/tr/tours.ts | 81 ++++- shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/map.ts | 2 + shared/src/i18n/uk/tours.ts | 80 ++++- shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/map.ts | 3 + shared/src/i18n/vi/tours.ts | 80 ++++- shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh-TW/map.ts | 2 + shared/src/i18n/zh-TW/tours.ts | 75 ++++- shared/src/i18n/zh/admin.ts | 3 + shared/src/i18n/zh/map.ts | 2 + shared/src/i18n/zh/tours.ts | 75 ++++- shared/src/index.ts | 1 + shared/src/tours/tour-gpx-import.schema.ts | 24 +- .../src/tours/tour-poi-search.schema.spec.ts | 2 +- shared/src/tours/tour-poi-search.schema.ts | 4 +- shared/src/tours/tour-trail-grade.schema.ts | 49 +++ shared/src/tours/tours.schema.test.ts | 78 ++++- shared/src/tours/tours.schema.ts | 97 ++++-- 126 files changed, 3112 insertions(+), 237 deletions(-) create mode 100644 server/src/db/migrations/Migration20200101045200_a_tour_can_be_ridden.ts rename server/src/nest/{ferratas/ferratas-overpass.client.ts => overpass/overpass-elements.client.ts} (78%) create mode 100644 server/src/nest/overpass/overpass.module.ts create mode 100644 server/src/nest/tours/dto/tour-trail-grade.dto.ts create mode 100644 server/src/nest/tours/tour-trail-grade.controller.ts create mode 100644 server/src/nest/tours/tour-trail-grade.service.ts create mode 100644 server/tests/e2e/tours-bike.e2e.test.ts rename server/tests/unit/nest/{ferratas-overpass.client.test.ts => overpass-elements.client.test.ts} (75%) create mode 100644 server/tests/unit/nest/tour-trail-grade.service.test.ts create mode 100644 shared/src/tours/tour-trail-grade.schema.ts diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 25f0b6a64..ea8c5c990 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -38,6 +38,7 @@ export class Tours { hiking_preferences?: string | null; taken_over_from_trip_title?: string | null; taken_over_from_tour_name?: string | null; + bike_profile?: TToursBikeProfile | null; tourTypeRef!: Ref; tour_obstacle_reviews_collection = new Collection(this); tour_sources: Ref | null = null; @@ -75,6 +76,15 @@ export const ToursPaddleLakeStyle = { export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle]; +export const ToursBikeProfile = { + TOURING: 'touring', + ROAD: 'road', + GRAVEL: 'gravel', + MTB: 'mtb', +} as const; + +export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile]; + export const ToursSchema = defineEntity({ class: Tours, repository: () => ToursRepository, @@ -116,6 +126,7 @@ export const ToursSchema = defineEntity({ hiking_preferences: p.text().nullable(), taken_over_from_trip_title: p.text().nullable(), taken_over_from_tour_name: p.text().nullable(), + bike_profile: p.enum(() => ToursBikeProfile).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/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index 66f7de44e..b9ced302e 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -30,4 +30,5 @@ export interface ToursTable { hiking_preferences: InsertOptional; taken_over_from_trip_title: InsertOptional; taken_over_from_tour_name: InsertOptional; + bike_profile: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101045200_a_tour_can_be_ridden.ts b/server/src/db/migrations/Migration20200101045200_a_tour_can_be_ridden.ts new file mode 100644 index 000000000..1f24d2252 --- /dev/null +++ b/server/src/db/migrations/Migration20200101045200_a_tour_can_be_ridden.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * BIK-01: a tour can be a bike tour. The `bike` key has been in `tour_types`, + * disabled, since every contract type was seeded; it is switched on here. The + * profile it is ridden on (touring, road, gravel, mtb) is a column of the + * tour, NULL on every other type, so no existing tour changes its meaning. + */ +export class Migration20200101045200_a_tour_can_be_ridden extends Migration { + override name = 'Migration20200101045200_a_tour_can_be_ridden'; + + override up(): void { + this.addSql( + "ALTER TABLE tours ADD COLUMN bike_profile TEXT CHECK (bike_profile IS NULL OR bike_profile IN ('touring', 'road', 'gravel', 'mtb'))", + ); + this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'bike'"); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 0d5ed25cb..b3b2408f3 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, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared'; +import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared'; /** * A tour as the Tours list and detail read it: the `tours` facet joined with @@ -28,6 +28,8 @@ export interface TourListRow { max_hiking_difficulty: number | null; derived_from_place_id: number | null; origin: string; + /** BIK-01: a bike tour's profile; NULL on every other type. */ + bike_profile: string | null; boat_type: string | null; water_context: string | null; paddle_speed_kmh: number | null; @@ -92,6 +94,7 @@ export interface TourRow { hiking_preferences: string | null; taken_over_from_trip_title: string | null; taken_over_from_tour_name: string | null; + bike_profile: string | null; } const _tourRowKeys: AssertRowKeys = true; @@ -107,6 +110,8 @@ export interface TourInsert { break_additional_minutes?: number | null; match_confidence: number | null; max_hiking_difficulty: number | null; + /** BIK-01: a bike tour's profile; left out, NULL. */ + bike_profile?: BikeProfile | null; /** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */ boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; @@ -123,6 +128,7 @@ export type TourUpdate = Omit< | 'place_id' | 'planned_duration_minutes' | 'break_additional_minutes' + | 'bike_profile' | 'boat_type' | 'water_context' | 'paddle_speed_kmh' @@ -131,6 +137,7 @@ export type TourUpdate = Omit< > & { planned_duration_minutes?: number | null; break_additional_minutes?: number | null; + bike_profile: BikeProfile | null; boat_type: PaddleBoatType | null; water_context: PaddleWaterContext | null; paddle_speed_kmh: number | null; @@ -232,6 +239,7 @@ export class ToursRepository extends TrekRepository { 't.max_hiking_difficulty as max_hiking_difficulty', 't.derived_from_place_id as derived_from_place_id', 't.origin as origin', + 't.bike_profile as bike_profile', 't.boat_type as boat_type', 't.water_context as water_context', 't.paddle_speed_kmh as paddle_speed_kmh', @@ -585,6 +593,7 @@ export class ToursRepository extends TrekRepository { break_additional_minutes: input.break_additional_minutes ?? null, match_confidence: input.match_confidence, max_hiking_difficulty: input.max_hiking_difficulty, + bike_profile: input.bike_profile ?? null, boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, @@ -669,6 +678,7 @@ export class ToursRepository extends TrekRepository { : {}), match_confidence: input.match_confidence, max_hiking_difficulty: input.max_hiking_difficulty, + bike_profile: input.bike_profile, boat_type: input.boat_type, water_context: input.water_context, paddle_speed_kmh: input.paddle_speed_kmh, diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index ad0541cd1..96d4c6454 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -328,6 +328,11 @@ export function applyGlobalMiddleware( // OpenHikingMap, the hiking base map of the Tours planner (MAP-01), loaded only // while a user shows it and the admin allows it; GL reads it through fetch. 'https://tile.openmaps.fr', + // CyclOSM, the cycling base map of a bike tour in the Tours planner (BIK-06), on the + // same terms; its three shards are named, so no wildcard opens the whole domain. + 'https://a.tile-cyclosm.openstreetmap.fr', + 'https://b.tile-cyclosm.openstreetmap.fr', + 'https://c.tile-cyclosm.openstreetmap.fr', // Amap's raster tiles, for an install whose users are in China, for the // same reason as the hosts above (#2180). Road (webrd01..04) and // satellite (webst01..04) are numbered shards of one domain, so the diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 18781f5ec..ef5bb037e 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -91,6 +91,16 @@ export class AddonsService { return this.writeFlag('tours_hiking_map_enabled', enabled); } + /** BIK-06: the admin's switch for CyclOSM under Tours, as stored; fail-open like OpenHikingMap's. */ + async getToursCycleMap() { + const value = await readAppSetting(this.appSettings, 'tours_cycle_map_enabled'); + return { enabled: value !== 'false' }; + } + + async updateToursCycleMap(enabled: boolean) { + return this.writeFlag('tours_cycle_map_enabled', enabled); + } + /** wanderer may be used: Tours is on and the admin allowed it under Tours. */ async isWandererAvailable(): Promise { return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled; @@ -177,6 +187,7 @@ export class AddonsService { bagTracking: (await this.getBagTracking()).enabled, toursWanderer: await this.isWandererAvailable(), toursHikingMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursHikingMap()).enabled, + toursCycleMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursCycleMap()).enabled, addons: [ // The repository row carries every scalar column (description, config, // sort_order included); the legacy statement selected only these five, diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 012380083..159d817e7 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -323,6 +323,25 @@ export class AdminController { return result; } + @Get('tours-cycle-map') + @ResponseContract(adminFeatureToggleResponseSchema) + getToursCycleMap() { + return this.addons.getToursCycleMap(); + } + + @Put('tours-cycle-map') + @ResponseContract(adminFeatureToggleResponseSchema) + async updateToursCycleMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updateToursCycleMap(body.enabled); + await this.audit.writeAudit({ + userId: user.id, + action: 'admin.tours_cycle_map', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + return result; + } + @Get('places-photos') getPlacesPhotos() { return this.addons.getPlacesPhotos(); diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 37528e6ee..20e45c322 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -112,6 +112,10 @@ export const APP_SETTINGS = { // the installs TREK is made for; an admin whose install is commercial or larger switches it off // until there is an agreement with the operator (openmaps.fr/tile-usage-policy.html). tours_hiking_map_enabled: { schema: flag, default: 'true' }, + // BIK-06: CyclOSM as a base map of a bike tour in the Tours planner, on the same terms: its + // tiles are OpenStreetMap France's, free for moderate use with a Referer; a heavy or commercial + // install switches it off (or reaches an agreement) under the operator's usage policy. + tours_cycle_map_enabled: { schema: flag, default: 'true' }, 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' }, diff --git a/server/src/nest/ferratas/ferratas.helpers.ts b/server/src/nest/ferratas/ferratas.helpers.ts index bb26ba596..36cb0149a 100644 --- a/server/src/nest/ferratas/ferratas.helpers.ts +++ b/server/src/nest/ferratas/ferratas.helpers.ts @@ -202,11 +202,6 @@ type WayElement = z.infer; type RelationElement = z.infer; /** What an Overpass mirror answers with, at the boundary. */ -export const overpassFerrataAnswerSchema = z.object({ - elements: z.array(z.unknown()).max(50000), - remark: z.string().optional(), -}); - function cleanName(value: string | undefined): string | null { const name = value?.trim() ?? ''; return name === '' ? null : name.slice(0, NAME_MAX); diff --git a/server/src/nest/ferratas/ferratas.module.ts b/server/src/nest/ferratas/ferratas.module.ts index fcf5c632c..ae6648397 100644 --- a/server/src/nest/ferratas/ferratas.module.ts +++ b/server/src/nest/ferratas/ferratas.module.ts @@ -1,6 +1,6 @@ import { FerrataTiles } from '../../db/entities/FerrataTiles.entity'; +import { OverpassModule } from '../overpass/overpass.module'; import { PermissionsModule } from '../permissions/permissions.module'; -import { FerratasOverpassClient } from './ferratas-overpass.client'; import { FerratasController } from './ferratas.controller'; import { FerratasService } from './ferratas.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; @@ -8,14 +8,14 @@ import { Module } from '@nestjs/common'; /** * Via ferratas around a trip, read from OpenStreetMap through Overpass. - * PermissionsModule brings TripAccessGuard; RuntimeEnvService (the Overpass - * endpoints and deadline) comes from the global AppConfigModule. The answers + * PermissionsModule brings TripAccessGuard; OverpassModule the client that asks + * the operator's Overpass mirrors. The answers * are kept per grid cell in `ferrata_tiles`; UnitOfWork and MikroORM come from * the global OrmModule. */ @Module({ - imports: [PermissionsModule, MikroOrmModule.forFeature([FerrataTiles])], + imports: [PermissionsModule, OverpassModule, MikroOrmModule.forFeature([FerrataTiles])], controllers: [FerratasController], - providers: [FerratasService, FerratasOverpassClient], + providers: [FerratasService], }) export class FerratasModule {} diff --git a/server/src/nest/ferratas/ferratas.service.ts b/server/src/nest/ferratas/ferratas.service.ts index fa0f83143..053383509 100644 --- a/server/src/nest/ferratas/ferratas.service.ts +++ b/server/src/nest/ferratas/ferratas.service.ts @@ -6,7 +6,7 @@ import { logWarn } from '../audit/audit-log.logger'; import { badRequest, DomainError } from '../common/domain-error'; import { withRequestContext } from '../database/request-context'; import { UnitOfWork } from '../database/unit-of-work'; -import { FerratasOverpassClient } from './ferratas-overpass.client'; +import { OverpassElementsClient } from '../overpass/overpass-elements.client'; import { buildFerrataQuery, ferrataTileBlocks, @@ -98,7 +98,7 @@ export class FerratasService { private readonly waiting: Array<() => void> = []; constructor( - private readonly overpass: FerratasOverpassClient, + private readonly overpass: OverpassElementsClient, @InjectRepository(FerrataTiles) private readonly tiles: FerrataTilesRepository, private readonly uow: UnitOfWork, private readonly orm: MikroORM, @@ -242,7 +242,7 @@ export class FerratasService { private async queryOverpass(keys: readonly string[]): Promise { await this.turn(); try { - return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys))); + return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)), 'Ferratas'); } catch { // The client already logged why each mirror failed. throw new DomainError(502, FERRATAS_UNAVAILABLE_MESSAGE, { diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index 89ef00249..21a144a07 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -367,8 +367,8 @@ export const CATEGORY_OSM_FILTERS: Record = { charging: ['amenity=charging_station'], rest_area: ['highway=rest_area', 'highway=services'], campsite: ['tourism=camp_site', 'tourism=caravan_site'], - // What a hike or a paddle tour stops for along its line (PLN-02). Asked for by the Tours - // search along a route, never offered by the explore pill. + // What a hike, a ride or a paddle tour stops for along its line (PLN-02). Asked for by the + // Tours search along a route, never offered by the explore pill. viewpoint: ['tourism=viewpoint'], peak: ['natural=peak'], shelter: ['amenity=shelter', 'tourism=wilderness_hut', 'tourism=alpine_hut'], @@ -377,6 +377,7 @@ export const CATEGORY_OSM_FILTERS: Record = { picnic: ['tourism=picnic_site', 'leisure=picnic_table'], slipway: ['leisure=slipway', 'canoe=put_in'], boat_rental: ['amenity=boat_rental'], + bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'], }; export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS); diff --git a/server/src/nest/ferratas/ferratas-overpass.client.ts b/server/src/nest/overpass/overpass-elements.client.ts similarity index 78% rename from server/src/nest/ferratas/ferratas-overpass.client.ts rename to server/src/nest/overpass/overpass-elements.client.ts index 0eb7f78d1..a88075b3a 100644 --- a/server/src/nest/ferratas/ferratas-overpass.client.ts +++ b/server/src/nest/overpass/overpass-elements.client.ts @@ -3,12 +3,14 @@ import { readCappedJson } from '../../utils/cappedFetch'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { logWarn } from '../audit/audit-log.logger'; import { buildUserAgent, parseOverpassEndpoints } from '../maps/overpass.constants'; -import { overpassFerrataAnswerSchema } from './ferratas.helpers'; import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + /** - * The outbound half of the via ferrata lookup: one Overpass query raced across - * the mirrors the operator configured. + * One Overpass query raced across the mirrors the operator configured, for the + * domains that read OpenStreetMap elements themselves: the via ferratas around + * a trip, the trail grades along a bike tour (BIK-05). * * It reads the same settings the maps domain's Overpass client reads * (OVERPASS_URL, a comma-separated list that replaces the public mirrors, and @@ -22,6 +24,12 @@ import { Injectable } from '@nestjs/common'; // megabytes means the mirror is misbehaving, not that the mountains got bigger. const MAX_ANSWER_BYTES = 8 * 1024 * 1024; +/** An Overpass answer: its elements, and a remark when Overpass gave up on the query. */ +export const overpassAnswerSchema = z.object({ + elements: z.array(z.unknown()).max(50000), + remark: z.string().optional(), +}); + /** Why every mirror failed, one reason per mirror, for the log line. */ function reasons(err: unknown): string { const all = err instanceof AggregateError ? err.errors : [err]; @@ -29,7 +37,7 @@ function reasons(err: unknown): string { } @Injectable() -export class FerratasOverpassClient { +export class OverpassElementsClient { constructor(private readonly env: RuntimeEnvService) {} /** @@ -37,8 +45,9 @@ export class FerratasOverpassClient { * document. A `remark` (Overpass' own timeout or runtime error, sent with a * 200) counts as a failure, so a fast but empty answer cannot win the race. * Throws once every mirror failed; the caller decides what the client sees. + * `caller` names the domain in the log line. */ - async query(query: string): Promise { + async query(query: string, caller: string): Promise { const { overpassUrl, overpassTimeoutMs } = this.env.env().integrations; const endpoints = parseOverpassEndpoints(overpassUrl); const body = `data=${encodeURIComponent(query)}`; @@ -52,7 +61,7 @@ export class FerratasOverpassClient { await res.body?.cancel().catch(() => {}); throw new Error(`Overpass ${res.status} @ ${url}`); } - const answer = overpassFerrataAnswerSchema.safeParse(await readCappedJson(res, MAX_ANSWER_BYTES)); + const answer = overpassAnswerSchema.safeParse(await readCappedJson(res, MAX_ANSWER_BYTES)); if (!answer.success) throw new Error(`Overpass non-OSM or oversized body @ ${url}`); if (answer.data.remark) throw new Error(`Overpass remark @ ${url}: ${answer.data.remark}`); return answer.data.elements; @@ -61,7 +70,7 @@ export class FerratasOverpassClient { try { return await Promise.any(endpoints.map(attempt)); } catch (err) { - logWarn(`[Ferratas] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`); + logWarn(`[${caller}] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`); throw new Error('Could not reach any Overpass endpoint', { cause: err }); } finally { // The losers are cancelled once one mirror answered (or all gave up). diff --git a/server/src/nest/overpass/overpass.module.ts b/server/src/nest/overpass/overpass.module.ts new file mode 100644 index 000000000..ddc0f58d5 --- /dev/null +++ b/server/src/nest/overpass/overpass.module.ts @@ -0,0 +1,13 @@ +import { OverpassElementsClient } from './overpass-elements.client'; +import { Module } from '@nestjs/common'; + +/** + * The Overpass client shared by the domains that read OpenStreetMap elements + * themselves. RuntimeEnvService (the mirrors and the deadline) comes from the + * global AppConfigModule. + */ +@Module({ + providers: [OverpassElementsClient], + exports: [OverpassElementsClient], +}) +export class OverpassModule {} diff --git a/server/src/nest/tours/dto/tour-trail-grade.dto.ts b/server/src/nest/tours/dto/tour-trail-grade.dto.ts new file mode 100644 index 000000000..485f9bb06 --- /dev/null +++ b/server/src/nest/tours/dto/tour-trail-grade.dto.ts @@ -0,0 +1,5 @@ +import { tourTrailGradeRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class TourTrailGradeDto extends createZodDto(tourTrailGradeRequestSchema) {} diff --git a/server/src/nest/tours/tour-gpx-export.service.ts b/server/src/nest/tours/tour-gpx-export.service.ts index b33ab328e..a25551447 100644 --- a/server/src/nest/tours/tour-gpx-export.service.ts +++ b/server/src/nest/tours/tour-gpx-export.service.ts @@ -9,7 +9,7 @@ import { toRowId } from '../common/row-id'; import { PlaceImportService } from '../place-import/place-import.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; -import type { PaddleBoatType } from '@trek/shared'; +import type { BikeProfile, PaddleBoatType } from '@trek/shared'; /** What GPX calls the activity in a track's ; a paddle tour says what it is paddled in. */ const PADDLE_ACTIVITY: Record = { @@ -19,8 +19,18 @@ const PADDLE_ACTIVITY: Record = { other: 'paddling', }; -function gpxActivityOf(type: string, boat: string | null): string { +/** A bike tour says what it is ridden on, in the words Garmin and Strava read. */ +const BIKE_ACTIVITY: Record = { + touring: 'cycling', + road: 'road_biking', + gravel: 'gravel_cycling', + mtb: 'mountain_biking', +}; + +function gpxActivityOf(tour: { tour_type: string; boat_type?: string | null; bike_profile?: string | null }): string { + const { tour_type: type, boat_type: boat, bike_profile: bike } = tour; if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other; + if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring; return type === 'hike' ? 'hiking' : type; } @@ -51,7 +61,7 @@ export class TourGpxExportService { name: tour.name, description: tour.description ?? null, website: tour.website ?? null, - activity: gpxActivityOf(tour.tour_type, tour.boat_type), + activity: gpxActivityOf(tour), route_geometry: place.route_geometry ?? null, stops, }); diff --git a/server/src/nest/tours/tour-poi-search.service.ts b/server/src/nest/tours/tour-poi-search.service.ts index 6f1003d8e..10f03eb52 100644 --- a/server/src/nest/tours/tour-poi-search.service.ts +++ b/server/src/nest/tours/tour-poi-search.service.ts @@ -31,6 +31,7 @@ const MAPS_CATEGORIES: Record = { sights: ['sights'], slipway: ['slipway'], boat_rental: ['boat_rental'], + bike_repair: ['bike_repair'], }; /** diff --git a/server/src/nest/tours/tour-trail-grade.controller.ts b/server/src/nest/tours/tour-trail-grade.controller.ts new file mode 100644 index 000000000..f1256c3ed --- /dev/null +++ b/server/src/nest/tours/tour-trail-grade.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 { TourTrailGradeDto } from './dto/tour-trail-grade.dto'; +import { TourTrailGradeService } from './tour-trail-grade.service'; +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { tourTrailGradeResponseSchema } from '@trek/shared'; + +/** + * BIK-05 POST /api/trips/:tripId/tours/trail-grades: the trail grades of the + * ways a mountain bike tour runs on. A read, so anyone who sees the trip may + * ask; the ways come in the body because the draft may not be saved yet. + */ +@Controller('api/trips/:tripId/tours') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class TourTrailGradeController { + constructor(private readonly trailGrades: TourTrailGradeService) {} + + @Post('trail-grades') + @HttpCode(200) + @ResponseContract(tourTrailGradeResponseSchema) + async grades(@Body() body: TourTrailGradeDto) { + return await this.trailGrades.grades(body); + } +} diff --git a/server/src/nest/tours/tour-trail-grade.service.ts b/server/src/nest/tours/tour-trail-grade.service.ts new file mode 100644 index 000000000..bf2f8fe4c --- /dev/null +++ b/server/src/nest/tours/tour-trail-grade.service.ts @@ -0,0 +1,97 @@ +import { OverpassElementsClient } from '../overpass/overpass-elements.client'; +import { Injectable } from '@nestjs/common'; +import type { TourTrailGrade, TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared'; + +import { z } from 'zod'; + +/** Ways per Overpass question: a query names them all, and a shorter one answers sooner. */ +const WAYS_PER_QUERY = 400; +/** How long a way's grades are kept: OpenStreetMap changes slowly, the mirrors are shared. */ +const KEPT_MS = 24 * 60 * 60 * 1000; +/** The most ways kept at once; the oldest go first. */ +const MAX_KEPT = 50_000; + +const wayElementSchema = z.object({ + type: z.literal('way'), + id: z.number().int().positive(), + tags: z.record(z.string(), z.string()).optional(), +}); + +/** A tag's value as a step of its scale (`S2` and ` 2 ` read as 2), or null when it is none. */ +function step(value: string | undefined, top: number, signed: boolean): string | null { + const match = /^\s*S?([0-9])([+-])?\s*$/i.exec(value ?? ''); + if (!match || Number(match[1]) > top) return null; + return signed ? `${match[1]}${match[2] ?? ''}` : (match[1] ?? null); +} + +function gradeOf(id: number, tags: Record = {}): TourTrailGrade | null { + const grade = { + id, + mtb_scale: step(tags['mtb:scale'], 6, true), + mtb_scale_uphill: step(tags['mtb:scale:uphill'], 5, true), + mtb_scale_imba: step(tags['mtb:scale:imba'], 4, false), + }; + return grade.mtb_scale || grade.mtb_scale_uphill || grade.mtb_scale_imba ? grade : null; +} + +/** + * BIK-05: the trail grades of the ways a mountain bike tour runs on, read from + * OpenStreetMap through the operator's Overpass mirrors. What was read is kept + * a day, so the same tour asked about again costs the mirrors nothing; ways a + * mirror could not answer are said, never guessed. + */ +@Injectable() +export class TourTrailGradeService { + private readonly kept = new Map(); + + constructor(private readonly overpass: OverpassElementsClient) {} + + async grades(request: TourTrailGradeRequest): Promise { + const now = Date.now(); + const ids = [...new Set(request.way_ids)]; + const unknown = ids.filter((id) => { + const known = this.kept.get(id); + return !known || now - known.at > KEPT_MS; + }); + const parts = Array.from({ length: Math.ceil(unknown.length / WAYS_PER_QUERY) }, (_, index) => + unknown.slice(index * WAYS_PER_QUERY, (index + 1) * WAYS_PER_QUERY), + ); + // One question after another, so a long tour does not flood the shared mirrors. + const unanswered = await parts.reduce>(async (done, part) => { + const failed = await done; + return (await this.ask(part, now)) ? failed : [...failed, ...part]; + }, Promise.resolve([])); + const ways = ids.flatMap((id) => { + const grade = this.kept.get(id)?.grade; + return grade ? [grade] : []; + }); + return { ways, unanswered }; + } + + /** Reads one part's grades into what is kept; false when no mirror answered. */ + private async ask(ids: number[], now: number): Promise { + let elements: unknown[]; + try { + elements = await this.overpass.query(`[out:json][timeout:25];way(id:${ids.join(',')});out tags;`, 'Tours'); + } catch { + return false; + } + const tagged = new Map | undefined>(); + for (const element of elements) { + const way = wayElementSchema.safeParse(element); + if (way.success) tagged.set(way.data.id, way.data.tags); + } + // A way the answer leaves out has no tags worth naming, or no longer exists: no grade either way. + for (const id of ids) this.keep(id, gradeOf(id, tagged.get(id)), now); + return true; + } + + private keep(id: number, grade: TourTrailGrade | null, at: number): void { + this.kept.delete(id); + this.kept.set(id, { grade, at }); + if (this.kept.size > MAX_KEPT) { + const oldest = this.kept.keys().next().value; + if (oldest !== undefined) this.kept.delete(oldest); + } + } +} diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 10fef0fd6..3a888fbb2 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -52,7 +52,7 @@ export class ToursMcp { @Tool({ name: 'list_tours', description: - 'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_steps, avoid_ferries, prefer_paths, shortest; weightings, never a grade of the path), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.', + 'List the saved tours of a trip (hikes, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.', inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), @@ -130,7 +130,7 @@ export class ToursMcp { @Tool({ name: 'search_tour_pois', description: - 'Search places along the saved line of a tour: what a hike or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.', + 'Search places along the saved line of a tour: what a hike, a bike tour or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.', inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 53176c2db..ef168e374 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -7,6 +7,7 @@ import { Tours } from '../../db/entities/Tours.entity'; import { AddonsModule } from '../addons/addons.module'; import { MapsModule } from '../maps/maps.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; +import { OverpassModule } from '../overpass/overpass.module'; import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlaceImportModule } from '../place-import/place-import.module'; @@ -23,6 +24,8 @@ import { TourPaddleCheckService } from './tour-paddle-check.service'; import { TourPaddlePlanService } from './tour-paddle-plan.service'; import { TourPoiSearchController } from './tour-poi-search.controller'; import { TourPoiSearchService } from './tour-poi-search.service'; +import { TourTrailGradeController } from './tour-trail-grade.controller'; +import { TourTrailGradeService } from './tour-trail-grade.service'; import { ToursImportController } from './tours-import.controller'; import { ToursWandererController } from './tours-wanderer.controller'; import { ToursWandererService } from './tours-wanderer.service'; @@ -49,7 +52,8 @@ import { Module } from '@nestjs/common'; McpSharedModule, // PLN-02 searches places along a tour through the map search. MapsModule, - + // BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap. + OverpassModule, AddonsModule, // The wanderer import reads trails through the caller's own connection. WandererModule, @@ -60,6 +64,7 @@ import { Module } from '@nestjs/common'; controllers: [ TourTakeOverController, TourPoiSearchController, + TourTrailGradeController, ToursController, ToursImportController, ToursWandererController, @@ -75,6 +80,7 @@ import { Module } from '@nestjs/common'; ToursWandererService, TourLibraryService, TourPoiSearchService, + TourTrailGradeService, ToursMcp, TourLibraryMcp, ], diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 433d7905b..be1584aa2 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -35,6 +35,7 @@ import { MAX_PLANNED_TOUR_DURATION_MINUTES, plannedTourDurationMinutesSchema, tourWebsiteSchema, + type BikeProfile, type PaddleBoatType, type PaddleLakeStyle, type PaddleWaterContext, @@ -108,6 +109,7 @@ export class ToursService { max_hiking_difficulty: r.max_hiking_difficulty, derived_from_place_id: r.derived_from_place_id, origin: r.origin as TourOrigin, + bike_profile: r.bike_profile as BikeProfile | null, boat_type: r.boat_type as PaddleBoatType | null, water_context: r.water_context as PaddleWaterContext | null, paddle_speed_kmh: r.paddle_speed_kmh, @@ -143,6 +145,7 @@ export class ToursService { : {}), match_confidence: 1, max_hiking_difficulty: input.max_hiking_difficulty, + bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null, boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, @@ -381,6 +384,7 @@ export class ToursService { row.wanderer_ref && row.tour_type === 'hike' ? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null) : null, + bike_profile: row.bike_profile as BikeProfile | null, boat_type: row.boat_type as PaddleBoatType | null, water_context: row.water_context as PaddleWaterContext | null, paddle_speed_kmh: row.paddle_speed_kmh, @@ -486,6 +490,7 @@ export class ToursService { break_additional_minutes: row.break_additional_minutes, match_confidence: row.match_confidence, max_hiking_difficulty: row.max_hiking_difficulty, + bike_profile: row.bike_profile as BikeProfile | null, boat_type: row.boat_type as PaddleBoatType | null, water_context: row.water_context as PaddleWaterContext | null, paddle_speed_kmh: row.paddle_speed_kmh, @@ -647,7 +652,9 @@ export class ToursService { const rows = this.readGpxTracks(fileBuffer, fileName); if (rows.length === 0) return null; await this.assertTourTypeEnabled(fields.tour_type); - const paddle = { + // What the type adds to each imported tour: a bike tour its profile, a paddle tour its boat and water. + const typeFields = { + bike_profile: fields.bike_profile ?? null, boat_type: fields.boat_type ?? null, water_context: fields.water_context ?? null, paddle_speed_kmh: null, @@ -663,7 +670,7 @@ export class ToursService { await this.toursRepo.insertTour({ place_id: place.id, tour_type: fields.tour_type, - ...paddle, + ...typeFields, distance: metrics.distanceKm, elevation_gain: metrics.elevationGainM, elevation_loss: metrics.elevationLossM, @@ -682,7 +689,7 @@ export class ToursService { description: place.description ?? null, website: place.website ?? null, tour_type: fields.tour_type, - ...paddle, + ...typeFields, distance: metrics.distanceKm, elevation_gain: metrics.elevationGainM, elevation_loss: metrics.elevationLossM, diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 83615876c..90a7d33ff 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -137,6 +137,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { // Tours is off in this database, so wanderer is not offered either. toursWanderer: false, toursHikingMap: false, + toursCycleMap: false, addons: [ { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, { id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }, diff --git a/server/tests/e2e/tours-bike.e2e.test.ts b/server/tests/e2e/tours-bike.e2e.test.ts new file mode 100644 index 000000000..b3fdf8e46 --- /dev/null +++ b/server/tests/e2e/tours-bike.e2e.test.ts @@ -0,0 +1,307 @@ +/** + * Tours e2e for bike tours (BIK-01 to BIK-05): a bike tour saved, read, copied, + * imported and exported with its profile, through the real JwtAuthGuard, + * AddonGuard and TripAccessGuard against a migrated and seeded temp SQLite db. + * The permission check is a container spy and the broadcast goes to a + * FakeRealtimeService, as in tours.e2e.test.ts. + * + * Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the + * permission spy denies 'place_edit', user 3 has no access to either trip. + */ +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { Places } from '../../src/db/entities/Places.entity'; +import { Tours } from '../../src/db/entities/Tours.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { MapsService } from '../../src/nest/maps/maps.service'; +import { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.client'; +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { ToursModule } from '../../src/nest/tours/tours.module'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { makePlace } from '../helpers/factories/places'; +import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { makeTour } from '../helpers/factories/tours'; +import { addTripMember } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } 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 { journeyHooks } = vi.hoisted(() => ({ + journeyHooks: { + reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), + onPlaceCreated: vi.fn().mockResolvedValue(undefined), + onPlaceUpdated: vi.fn().mockResolvedValue(undefined), + onPlaceDeleted: vi.fn().mockResolvedValue(undefined), + }, +})); + +const db = createSnapshotTestDb(); +const mapsPois = vi.fn(); +// The trail grades (BIK-05) read OpenStreetMap through Overpass: no network in a test. +const overpassQuery = vi.fn(); +const realtime = new FakeRealtimeService(); + +let orm: TestOrm; +let checkPermission: MockInstance; + +const OWNER = 1; +const VIEWER = 2; +const OUTSIDER = 3; + +const GEOMETRY = [ + [47, 11, 600], + [47.01, 11.01, 700], + [47.02, 11.02, 650], +]; +const bikeBody = (overrides: Record = {}) => ({ + name: 'Lakeside ride', + tour_type: 'bike', + bike_profile: 'gravel', + route_geometry: GEOMETRY, + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, + ], + duration_seconds: 1800, + ...overrides, +}); +const GPX = ` +Gravel loop +600 +640 +`; + +describe('Bike tours e2e (real guards + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + RealtimeModule, + ToursModule, + ], + }) + .overrideProvider(RealtimeService) + .useValue(realtime) + .overrideProvider(JourneyDomainService) + .useValue(journeyHooks) + .overrideProvider(MapsService) + .useValue({ pois: mapsPois }) + .overrideProvider(OverpassElementsClient) + .useValue({ query: overpassQuery }) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + const createViaApi = async (tripId = 5, body: Record = bikeBody()) => { + const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body); + expect(res.status).toBe(201); + return res.body.tour.place_id as number; + }; + + beforeAll(async () => { + orm = await createTestOrm(db); + // Pinned ids: the session cookies sign for exactly these users. + await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { + id: 3, + username: 'outsider', + email: 'outsider@example.test', + role: 'user', + password_version: 0, + }); + await insertRows(orm, Trips, [ + { id: 5, user: 1, title: 'Alps' }, + { id: 6, user: 1, title: 'Other' }, + ]); + await addTripMember(orm, 5, 2); + app = await build(); + checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + checkPermission.mockImplementation( + (action: string, _role: string, _owner: number, userId: number) => + !(action === 'place_edit' && userId === VIEWER), + ); + await setAddonEnabled(orm, 'tours', true); + realtime.broadcastMock.mockClear(); + await deleteRows(orm, DayAssignments); + await deleteRows(orm, Places); + }); + + afterAll(async () => { + await app.close(); + await orm.close(); + }); + + const send = (method: 'post' | 'put', path: string, body: Record, userId = OWNER) => + request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body); + + const gpxTypeOf = async (placeId: number) => { + const res = await request(server) + .get(`/api/trips/5/tours/${placeId}/export.gpx`) + .set('Cookie', sessionCookie(OWNER)) + .buffer(true) + .parse((res, done) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => (text += chunk)); + res.on('end', () => done(null, text)); + }); + return /([^<]+)<\/type>/.exec(String(res.body))?.[1] ?? ''; + }; + + describe('a bike tour and its profile (BIK-01 to BIK-04)', () => { + it('saves a bike tour with its profile, lists it so and broadcasts the change', async () => { + const placeId = await createViaApi(); + const row = await findRow(orm, Tours, { place: placeId }); + expect(row).toMatchObject({ bike_profile: 'gravel', max_hiking_difficulty: null, boat_type: null }); + const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER)); + expect(list.status).toBe(200); + const tour = list.body.tours.find((item: { place_id: number }) => item.place_id === placeId); + expect(tour).toMatchObject({ tour_type: 'bike', bike_profile: 'gravel', duration: 30 }); + expect(tour.elevation_gain).toBe(100); + expect(realtime.broadcastMock).toHaveBeenCalled(); + }); + + it('refuses a bike tour without a profile, with a hiking limit, or over a via ferrata', async () => { + const ferrata = { kind: 'ferrata', ref: 'way/1', name: null, geometry: [GEOMETRY[0], GEOMETRY[2]] }; + const bodies = [ + bikeBody({ bike_profile: null }), + bikeBody({ bike_profile: 'ebike' }), + bikeBody({ max_hiking_difficulty: 3 }), + bikeBody({ + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0, fixed_leg: ferrata }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, + ], + }), + ]; + const statuses = await bodies.reduce>( + async (done, body) => [...(await done), (await send('post', '', body)).status], + Promise.resolve([]), + ); + expect(statuses).toEqual([400, 400, 400, 400]); + expect(await countRows(orm, Tours)).toBe(0); + }); + + it('changes the profile with an edit, and a hike made of it keeps none', async () => { + const placeId = await createViaApi(); + const road = await send('put', `/${placeId}`, bikeBody({ bike_profile: 'road', expected_revision: 1 })); + expect(road.status).toBe(200); + expect(road.body.tour).toMatchObject({ bike_profile: 'road', revision: 2 }); + const hike = await send( + 'put', + `/${placeId}`, + bikeBody({ tour_type: 'hike', bike_profile: undefined, max_hiking_difficulty: 2, expected_revision: 2 }), + ); + expect(hike.status).toBe(200); + expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ + tour_type: 'hike', + bike_profile: null, + max_hiking_difficulty: 2, + }); + }); + + it('a member who may only read cannot save one', async () => { + expect((await send('post', '', bikeBody(), VIEWER)).status).toBe(403); + }); + + it('a copy keeps the profile', async () => { + const placeId = await createViaApi(5, bikeBody({ bike_profile: 'mtb' })); + const copy = await send('post', `/${placeId}/copy`, { name: 'Again' }); + expect(copy.status).toBe(201); + expect(copy.body.tour).toMatchObject({ tour_type: 'bike', bike_profile: 'mtb' }); + }); + + it('exports a bike tour as the activity its profile names', async () => { + const kinds: Record = { + touring: 'cycling', + road: 'road_biking', + gravel: 'gravel_cycling', + mtb: 'mountain_biking', + }; + const types = await Object.keys(kinds).reduce>( + async (done, profile) => [ + ...(await done), + await gpxTypeOf(await createViaApi(5, bikeBody({ bike_profile: profile }))), + ], + Promise.resolve([]), + ); + expect(types).toEqual(Object.values(kinds)); + }); + + it('imports a GPX file as a bike tour only with its profile', async () => { + const importGpx = (fields: Record) => { + let req = request(server).post('/api/trips/5/tours/import/gpx').set('Cookie', sessionCookie(OWNER)); + for (const [key, value] of Object.entries(fields)) req = req.field(key, value); + return req.attach('file', Buffer.from(GPX), { filename: 'loop.gpx', contentType: 'application/gpx+xml' }); + }; + expect((await importGpx({ tour_type: 'bike' })).status).toBe(400); + const res = await importGpx({ tour_type: 'bike', bike_profile: 'road' }); + expect(res.status).toBe(201); + expect(res.body.tours[0]).toMatchObject({ tour_type: 'bike', bike_profile: 'road', origin: 'gpx' }); + }); + }); + + describe('POST trail-grades (BIK-05)', () => { + const ask = (body: unknown, userId?: number, tripId = 5) => { + const req = request(server).post(`/api/trips/${tripId}/tours/trail-grades`); + return (userId ? req.set('Cookie', sessionCookie(userId)) : req).send(body as object); + }; + + it('answers the grades of the ways asked about to anyone who sees the trip', async () => { + overpassQuery.mockResolvedValueOnce([ + { type: 'way', id: 11, tags: { 'mtb:scale': '2', 'mtb:scale:uphill': '1' } }, + ]); + const res = await ask({ way_ids: [11, 12] }, VIEWER); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: '1', mtb_scale_imba: null }], + unanswered: [], + }); + }); + + it('refuses an outsider, a body it does not know, and no session', async () => { + expect((await ask({ way_ids: [1] }, OUTSIDER)).status).toBe(404); + const statuses = await [{ way_ids: [] }, { way_ids: [0] }, { way_ids: [1], extra: 1 }].reduce>( + async (done, body) => [...(await done), (await ask(body, OWNER)).status], + Promise.resolve([]), + ); + expect(statuses).toEqual([400, 400, 400]); + expect((await ask({ way_ids: [1] })).status).toBe(401); + }); + }); +}); diff --git a/server/tests/e2e/tours-planned.e2e.test.ts b/server/tests/e2e/tours-planned.e2e.test.ts index cc862b10e..6e1295270 100644 --- a/server/tests/e2e/tours-planned.e2e.test.ts +++ b/server/tests/e2e/tours-planned.e2e.test.ts @@ -435,15 +435,15 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => { it('a hike keeps the preferences switched on, reads them back, and a copy carries them', async () => { const placeId = await createViaApi( 5, - tourBody({ hiking_preferences: { avoid_steps: true, prefer_paths: true, shortest: false } }), + tourBody({ hiking_preferences: { avoid_climbs: true, avoid_steps: true, avoid_ferries: false } }), ); const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)); - expect(detail.body.tour.hiking_preferences).toEqual({ avoid_steps: true, prefer_paths: true }); + expect(detail.body.tour.hiking_preferences).toEqual({ avoid_climbs: true, avoid_steps: true }); const copy = await request(server) .post(`/api/trips/5/tours/${placeId}/copy`) .set('Cookie', sessionCookie(OWNER)) .send({ name: 'Copy' }); - expect(copy.body.tour.hiking_preferences).toEqual({ avoid_steps: true, prefer_paths: true }); + expect(copy.body.tour.hiking_preferences).toEqual({ avoid_climbs: true, avoid_steps: true }); // An edit that switches them all off keeps none; one without the field keeps none too. const off = await request(server) diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 4b1501cc9..1fa6c1a02 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -63,6 +63,7 @@ const UNNUMBERED = [ 'Migration20200101044900_a_tour_waypoint_can_be_a_place_of_the_trip', 'Migration20200101045000_a_hike_keeps_its_routing_preferences', 'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from', + 'Migration20200101045200_a_tour_can_be_ridden', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 9a15341c4..50c8f0331 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -101,6 +101,7 @@ describe('ToursRepository reads', () => { has_recorded_track: 0, revision: 1, hiking_preferences: null, + bike_profile: null, }, { place_id: older.id, @@ -131,6 +132,7 @@ describe('ToursRepository reads', () => { has_recorded_track: 0, revision: 1, hiking_preferences: null, + bike_profile: null, }, ]); expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id); @@ -217,6 +219,7 @@ describe('ToursRepository writes', () => { has_recorded_track: 0, revision: 1, hiking_preferences: null, + bike_profile: null, }); }); @@ -297,6 +300,7 @@ describe('ToursRepository writes', () => { track_marks: null, revision: 1, hiking_preferences: null, + bike_profile: null, taken_over_from_trip_title: null, taken_over_from_tour_name: null, }, diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index f5a36b798..536fb87ab 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -60,6 +60,12 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { expect(await connectSrcSources()).toContain('https://tile.openmaps.fr'); }); + it('allows the CyclOSM shards the Tours planner offers as a cycling base map (BIK-06), and nothing wider', async () => { + const sources = await connectSrcSources(); + for (const shard of ['a', 'b', 'c']) expect(sources).toContain(`https://${shard}.tile-cyclosm.openstreetmap.fr`); + expect(sources).not.toContain('https://*.openstreetmap.fr'); + }); + it('keeps the routing host, which is a different host and covers nothing here', async () => { // routing.openstreetmap.de was on the list all along and looks close enough // to hide the gap: a CSP source matches a host, not a suffix of one. diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 5d5c0bfde..df5b8d7f9 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -439,6 +439,22 @@ describe('AddonsService addon/feature flags', () => { expect((await svc.list()).toursHikingMap).toBe(false); }); + it('CyclOSM under Tours is on until an admin switches it off, and offered only with Tours (BIK-06)', async () => { + expect(await svc.getToursCycleMap()).toEqual({ enabled: true }); // absent row → ON + expect((await svc.list()).toursCycleMap).toBe(false); // Tours is off + await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 }); + expect((await svc.list()).toursCycleMap).toBe(true); + + expect(await svc.updateToursCycleMap(false)).toEqual({ enabled: false }); + expect(await storedAppSetting('tours_cycle_map_enabled')).toEqual({ + key: 'tours_cycle_map_enabled', + value: 'false', + }); + expect((await svc.list()).toursCycleMap).toBe(false); + // The two maps are switched apart. + expect((await svc.list()).toursHikingMap).toBe(true); + }); + it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => { await setAppSetting('collab_chat_enabled', 'false'); await setAppSetting('collab_polls_enabled', 'true'); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index f961af51f..b3335469c 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -48,6 +48,8 @@ const addonsStub = () => updateToursWanderer: vi.fn((enabled: boolean) => ({ enabled })), getToursHikingMap: vi.fn(() => ({ enabled: true })), updateToursHikingMap: vi.fn((enabled: boolean) => ({ enabled })), + getToursCycleMap: vi.fn(() => ({ enabled: true })), + updateToursCycleMap: vi.fn((enabled: boolean) => ({ enabled })), getPlacesPhotos: vi.fn(() => ({ enabled: false })), updatePlacesPhotos: vi.fn((enabled: boolean) => ({ enabled })), getPlacesAutocomplete: vi.fn(() => ({ enabled: false })), @@ -495,6 +497,7 @@ describe('AdminController feature toggles', () => { [() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'], [() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'], [() => c.updateToursHikingMap(user, { enabled: true }, req), 'admin.tours_hiking_map', 'updateToursHikingMap'], + [() => c.updateToursCycleMap(user, { enabled: true }, req), 'admin.tours_cycle_map', 'updateToursCycleMap'], [() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'], [ () => c.updatePlacesAutocomplete(user, { enabled: true }, req), @@ -517,6 +520,7 @@ describe('AdminController feature toggles', () => { expect(await c.getBagTracking()).toEqual({ enabled: false }); expect(await c.getToursWanderer()).toEqual({ enabled: false }); expect(await c.getToursHikingMap()).toEqual({ enabled: true }); + expect(await c.getToursCycleMap()).toEqual({ enabled: true }); expect(await c.getPlacesPhotos()).toEqual({ enabled: false }); expect(await c.getPlacesAutocomplete()).toEqual({ enabled: false }); expect(await c.getPlacesDetails()).toEqual({ enabled: false }); diff --git a/server/tests/unit/nest/ferratas.helpers.test.ts b/server/tests/unit/nest/ferratas.helpers.test.ts index 5dd508142..9de9d4119 100644 --- a/server/tests/unit/nest/ferratas.helpers.test.ts +++ b/server/tests/unit/nest/ferratas.helpers.test.ts @@ -4,7 +4,6 @@ import { ferrataTileBlocks, ferrataTileKeysFor, ferrataTilesBbox, - overpassFerrataAnswerSchema, parseFerrataGrade, snapFerrataBbox, splitFerratasByTile, @@ -211,18 +210,6 @@ describe('buildFerrataQuery', () => { }); }); -describe('overpassFerrataAnswerSchema', () => { - it('accepts an element list with or without a remark', () => { - expect(overpassFerrataAnswerSchema.safeParse({ elements: [] }).success).toBe(true); - expect(overpassFerrataAnswerSchema.safeParse({ elements: [], remark: 'runtime error' }).success).toBe(true); - }); - - it('refuses a body without elements', () => { - expect(overpassFerrataAnswerSchema.safeParse({ error: 'x' }).success).toBe(false); - expect(overpassFerrataAnswerSchema.safeParse(undefined).success).toBe(false); - }); -}); - describe('toFerratas', () => { it('maps a way to the wire row', () => { const { ferratas, truncated } = toFerratas([way(42, { name: ' Pisciadù ', via_ferrata_scale: '3+' })]); diff --git a/server/tests/unit/nest/ferratas.service.test.ts b/server/tests/unit/nest/ferratas.service.test.ts index a1b0c79b0..052a0d328 100644 --- a/server/tests/unit/nest/ferratas.service.test.ts +++ b/server/tests/unit/nest/ferratas.service.test.ts @@ -6,13 +6,13 @@ import { FerrataTiles } from '../../../src/db/entities/FerrataTiles.entity'; import { FerrataTilesRepository } from '../../../src/db/repositories/FerrataTiles.repository'; import { DomainError } from '../../../src/nest/common/domain-error'; -import { FerratasOverpassClient } from '../../../src/nest/ferratas/ferratas-overpass.client'; import { FerratasModule } from '../../../src/nest/ferratas/ferratas.module'; import { FERRATA_TILE_STALE_MS, FERRATAS_UNAVAILABLE_MESSAGE, FerratasService, } from '../../../src/nest/ferratas/ferratas.service'; +import { OverpassElementsClient } from '../../../src/nest/overpass/overpass-elements.client'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { countRows, deleteRows, findRow, findRows, insertRows } from '../../helpers/factories/rows'; import { resetTestDb } from '../../helpers/test-db'; @@ -73,7 +73,7 @@ async function boot(): Promise { t = await createTestModule({ db, imports: [FerratasModule], - overrides: [{ provide: FerratasOverpassClient, useValue: { query } }], + overrides: [{ provide: OverpassElementsClient, useValue: { query } }], }); service = t.get(FerratasService); } diff --git a/server/tests/unit/nest/ferratas-overpass.client.test.ts b/server/tests/unit/nest/overpass-elements.client.test.ts similarity index 75% rename from server/tests/unit/nest/ferratas-overpass.client.test.ts rename to server/tests/unit/nest/overpass-elements.client.test.ts index ddd3e43ec..cbc472972 100644 --- a/server/tests/unit/nest/ferratas-overpass.client.test.ts +++ b/server/tests/unit/nest/overpass-elements.client.test.ts @@ -1,20 +1,20 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; -import { FerratasOverpassClient } from '../../../src/nest/ferratas/ferratas-overpass.client'; import { parseOverpassEndpoints } from '../../../src/nest/maps/overpass.constants'; +import { OverpassElementsClient, overpassAnswerSchema } from '../../../src/nest/overpass/overpass-elements.client'; import { Test } from '@nestjs/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const integrations = { overpassUrl: undefined as string | undefined, overpassTimeoutMs: 5000 }; -let client: FerratasOverpassClient; +let client: OverpassElementsClient; beforeEach(async () => { integrations.overpassUrl = 'https://a.example/api/interpreter,https://b.example/api/interpreter'; integrations.overpassTimeoutMs = 5000; const moduleRef = await Test.createTestingModule({ - providers: [FerratasOverpassClient, { provide: RuntimeEnvService, useValue: { env: () => ({ integrations }) } }], + providers: [OverpassElementsClient, { provide: RuntimeEnvService, useValue: { env: () => ({ integrations }) } }], }).compile(); - client = moduleRef.get(FerratasOverpassClient); + client = moduleRef.get(OverpassElementsClient); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -46,12 +46,12 @@ describe('parseOverpassEndpoints', () => { }); }); -describe('FerratasOverpassClient.query', () => { +describe('OverpassElementsClient.query', () => { it('POSTs the query with a user agent and a deadline, and answers the elements', async () => { const fetcher = vi.fn(async (_url: string, _init: RequestInit) => json({ elements: [{ type: 'way', id: 1 }] })); vi.stubGlobal('fetch', fetcher); integrations.overpassUrl = 'https://a.example/api/interpreter'; - const elements = await client.query('[out:json];way(1);out;'); + const elements = await client.query('[out:json];way(1);out;', 'Ferratas'); expect(elements).toEqual([{ type: 'way', id: 1 }]); const [url, init] = fetcher.mock.calls[0] ?? []; expect(url).toBe('https://a.example/api/interpreter'); @@ -71,7 +71,7 @@ describe('FerratasOverpassClient.query', () => { return json({ elements: [{ type: 'way', id: 2 }] }); }), ); - expect(await client.query('q')).toEqual([{ type: 'way', id: 2 }]); + expect(await client.query('q', 'Ferratas')).toEqual([{ type: 'way', id: 2 }]); expect(signals.every((s) => s.aborted)).toBe(true); }); @@ -80,7 +80,7 @@ describe('FerratasOverpassClient.query', () => { 'fetch', vi.fn(async (url: string) => (url.startsWith('https://a.') ? json({}, 504) : json({ elements: 'nope' }))), ); - await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + await expect(client.query('q', 'Ferratas')).rejects.toThrow('Could not reach any Overpass endpoint'); }); it('throws when a mirror cannot be reached at all', async () => { @@ -90,7 +90,7 @@ describe('FerratasOverpassClient.query', () => { throw new TypeError('fetch failed'); }), ); - await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + await expect(client.query('q', 'Ferratas')).rejects.toThrow('Could not reach any Overpass endpoint'); }); it('refuses an answer larger than the cap', async () => { @@ -101,6 +101,18 @@ describe('FerratasOverpassClient.query', () => { new Response('{"elements":[]}', { status: 200, headers: { 'content-length': String(9 * 1024 * 1024) } }), ), ); - await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + await expect(client.query('q', 'Ferratas')).rejects.toThrow('Could not reach any Overpass endpoint'); + }); +}); + +describe('overpassAnswerSchema', () => { + it('accepts an element list with or without a remark', () => { + expect(overpassAnswerSchema.safeParse({ elements: [] }).success).toBe(true); + expect(overpassAnswerSchema.safeParse({ elements: [], remark: 'runtime error' }).success).toBe(true); + }); + + it('refuses a body without elements', () => { + expect(overpassAnswerSchema.safeParse({ error: 'x' }).success).toBe(false); + expect(overpassAnswerSchema.safeParse(undefined).success).toBe(false); }); }); diff --git a/server/tests/unit/nest/tour-trail-grade.service.test.ts b/server/tests/unit/nest/tour-trail-grade.service.test.ts new file mode 100644 index 000000000..a94f2bfea --- /dev/null +++ b/server/tests/unit/nest/tour-trail-grade.service.test.ts @@ -0,0 +1,78 @@ +import { OverpassElementsClient } from '../../../src/nest/overpass/overpass-elements.client'; +import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// TOUR-GRADE-001 to TOUR-GRADE-005 + +const query = vi.fn<(q: string, caller: string) => Promise>(); +let service: TourTrailGradeService; + +beforeEach(async () => { + query.mockReset(); + const moduleRef = await Test.createTestingModule({ + providers: [TourTrailGradeService, { provide: OverpassElementsClient, useValue: { query } }], + }).compile(); + service = moduleRef.get(TourTrailGradeService); +}); + +const way = (id: number, tags: Record) => ({ type: 'way', id, tags }); + +describe('TourTrailGradeService (BIK-05)', () => { + it('TOUR-GRADE-001: reads each scale to its steps and leaves a way without one out', async () => { + query.mockResolvedValue([ + way(1, { 'mtb:scale': '2+', 'mtb:scale:uphill': '3' }), + way(2, { 'mtb:scale': 'S1', 'mtb:scale:imba': '2' }), + way(3, { highway: 'track' }), + way(4, { 'mtb:scale': '9', 'mtb:scale:imba': 'hard' }), + ]); + const answer = await service.grades({ way_ids: [1, 2, 3, 4, 5] }); + expect(answer).toEqual({ + ways: [ + { id: 1, mtb_scale: '2+', mtb_scale_uphill: '3', mtb_scale_imba: null }, + { id: 2, mtb_scale: '1', mtb_scale_uphill: null, mtb_scale_imba: '2' }, + ], + unanswered: [], + }); + expect(query).toHaveBeenCalledWith('[out:json][timeout:25];way(id:1,2,3,4,5);out tags;', 'Tours'); + }); + + it('TOUR-GRADE-002: names the ways it could not ask about, never guesses them', async () => { + query.mockRejectedValue(new Error('all mirrors down')); + expect(await service.grades({ way_ids: [7, 8] })).toEqual({ ways: [], unanswered: [7, 8] }); + }); + + it('TOUR-GRADE-003: asks a long tour in parts, one after another, and keeps what one part answered', async () => { + const ids = Array.from({ length: 900 }, (_, index) => index + 1); + query + .mockResolvedValueOnce([way(1, { 'mtb:scale': '0' })]) + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce([way(900, { 'mtb:scale': '5' })]); + const answer = await service.grades({ way_ids: ids }); + expect(query).toHaveBeenCalledTimes(3); + expect(answer.ways.map((grade) => grade.id)).toEqual([1, 900]); + expect(answer.unanswered).toHaveLength(400); + expect(answer.unanswered[0]).toBe(401); + }); + + it('TOUR-GRADE-004: asks a way it already read only again a day later', async () => { + vi.useFakeTimers(); + try { + query.mockResolvedValue([way(1, { 'mtb:scale': '3' })]); + await service.grades({ way_ids: [1, 1] }); + await service.grades({ way_ids: [1] }); + expect(query).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + await service.grades({ way_ids: [1] }); + expect(query).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('TOUR-GRADE-005: skips what is no way in the answer', async () => { + query.mockResolvedValue([{ type: 'node', id: 1, tags: { 'mtb:scale': '1' } }, { nonsense: true }]); + expect(await service.grades({ way_ids: [1] })).toEqual({ ways: [], unanswered: [] }); + }); +}); diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index a7ab2efd3..973d8925d 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -188,6 +188,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a portage_speed_kmh: null, paddle_lake_style: null, hiking_preferences: null, + bike_profile: null, }); expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0); expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints); @@ -305,6 +306,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a portage_speed_kmh: null, paddle_lake_style: null, hiking_preferences: null, + bike_profile: null, }); expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42); expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints); diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index 5884f34fd..ac9f93a09 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -250,7 +250,7 @@ describe('ToursService planner creation', () => { it('TOURS-SVC-010: a type the catalogue holds but has not enabled is a 400 without a write', async () => { // The contract only lets `hike` through today; this pins what happens the // day it lets more keys in before the planner offers them. - const bike = { ...request, tour_type: 'bike' } as unknown as typeof request; + const bike = { ...request, tour_type: 'run' } as unknown as typeof request; await expect(service.createTour(tripId, bike)).rejects.toBeInstanceOf(BadRequestException); @@ -262,7 +262,7 @@ describe('ToursService planner creation', () => { it('TOURS-SVC-011: an edit to a disabled type is a 400 and leaves the saved tour alone', async () => { const created = await service.createTour(tripId, request); broadcast.mockReset(); - const bike = { ...request, name: 'Renamed', tour_type: 'bike' } as unknown as typeof request; + const bike = { ...request, name: 'Renamed', tour_type: 'run' } as unknown as typeof request; await expect(service.updateTour(tripId, String(created.tour.place_id), bike)).rejects.toBeInstanceOf( BadRequestException, diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 6288e3753..c60409ba0 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -135,6 +135,9 @@ export default defineConfig({ 'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 85, lines: 87 }, 'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 }, 'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 }, + // The Overpass client the ferratas and the tours share, moved out of ferratas in this + // change with its tests: pinned at the 80 floor until a full run measures it. + 'src/nest/overpass/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, '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. diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 47e6352c6..521243a52 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -720,6 +720,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'خريطة المشي (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'يعرض OpenHikingMap كخريطة أساسية في مخطط الجولات. مجاني للتثبيتات غير التجارية حتى 500,000 مربع شهريًا؛ أما التثبيت التجاري أو الأكبر فيحتاج أولًا إلى اتفاق مع openmaps.fr', + 'admin.toursCycleMap.title': 'خريطة الدراجات (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'يعرض CyclOSM كخريطة أساس لجولات الدراجات في مخطط الجولات. تقدمها OpenStreetMap France للاستخدام المعتدل؛ والتثبيت ذو الاستخدام الكثيف أو التجاري يوقفها أو يتفق مع المشغّل أولًا', 'admin.addons.noAddons': 'لا توجد إضافات متاحة', 'admin.weather.title': 'بيانات الطقس', 'admin.weather.badge': 'منذ 24 مارس 2026', diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts index 43762ff06..83d55a83c 100644 --- a/shared/src/i18n/ar/map.ts +++ b/shared/src/i18n/ar/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'قمر صناعي', 'map.baseLayer.hiking': 'المشي', 'map.baseLayer.hikingDown': 'لا يستجيب OpenHikingMap حاليًا. انتقل إلى خريطة أخرى، وتبقى جولتك كما هي.', + 'map.baseLayer.cycle': 'دراجات', + 'map.baseLayer.cycleDown': 'لا يستجيب CyclOSM الآن. انتقل إلى خريطة أخرى؛ تبقى جولتك كما هي.', 'map.baseLayer.switchToSatellite': 'التبديل إلى عرض القمر الصناعي', 'map.baseLayer.switchToDefault': 'التبديل إلى عرض الخريطة', 'map.location.denied': diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 98ee33c55..e9a64400e 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -107,6 +107,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'تفتح الملاحة نقطة بداية الدرب، ولا توفّر إرشادًا على طول مسار الجولة.', 'tours.planner.title': 'تخطيط جولة', 'tours.planner.newTour': 'تخطيط جولة جديدة', + 'tours.planner.addTour': 'إضافة جولة', + 'tours.planner.addTourHint': 'خطط لجولة جديدة أو استورد ملفات GPX أو انقل جولة من رحلة أخرى', 'tours.planner.neutralTitle': 'تخطيط جولة', 'tours.planner.neutralBody': 'ابدأ جولة جديدة وانقر على الخريطة لتحديد نقاط المسار.', 'tours.planner.newTitle': 'جولة جديدة', @@ -122,6 +124,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'توسيع مخطط الارتفاع', 'tours.planner.name': 'اسم الجولة', 'tours.planner.namePlaceholder': 'جولة مشي بلا اسم', + 'tours.planner.namePlaceholder.bike': 'جولة دراجة بلا اسم', + 'tours.planner.namePlaceholder.kayak': 'جولة تجديف بلا اسم', 'tours.planner.mapSearch': 'ابحث عن مكان أو عنوان', 'tours.planner.placePoints': 'تعيين النقاط', 'tours.planner.pointPlaced': 'تم تعيين النقطة', @@ -188,6 +192,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'بانتظار الحساب', 'tours.planner.status.routing': 'جارٍ حساب مسار المشي…', 'tours.planner.status.routingFailed': 'فشل حساب المسار. لا يزال آخر مسار حُسب بنجاح معروضًا.', + 'tours.planner.status.unreachable': 'لا يمكن الوصول إلى النقطة {point}', + 'tours.planner.unreachable.hike': + 'لا يصل أي مسار مشي إلى النقطة {point}: أقرب مسار يعرفه الموجّه على بعد {distance}. انقل النقطة إلى مسار أو ارسم المقطع إليها يدويًا.', + 'tours.planner.unreachable.bike': + 'لا يصل أي طريق للدراجات إلى النقطة {point}: أقرب طريق يعرفه الموجّه على بعد {distance}. انقل النقطة إلى طريق أو ارسم المقطع إليها يدويًا.', + 'tours.planner.unreachable.show': 'عرض النقطة {point}', 'tours.planner.status.elevation': 'جارٍ تحميل الارتفاع…', 'tours.planner.status.elevationFailed': 'بيانات الارتفاع غير متاحة. لا يزال المسار المحسوب معروضًا.', 'tours.planner.status.saving': 'جارٍ الحفظ…', @@ -349,6 +359,62 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'معالم', 'tours.poi.cat.slipway': 'نقاط إنزال القوارب', 'tours.poi.cat.boat_rental': 'تأجير القوارب', + 'tours.poi.cat.bike_repair': 'إصلاح الدراجات', + 'tours.planner.tourTypeHint.hike': 'سيرًا على الأقدام، ضمن صعوبة تحددها', + 'tours.planner.tourTypeHint.bike': 'بالدراجة، مسار مناسب للتجوال أو الطريق أو الحصى أو الجبلية', + 'tours.planner.tourTypeHint.kayak': 'على الماء، بقارب الكاياك أو الكانو أو لوح التجديف', + 'tours.bike.profile': 'الملف', + 'tours.bike.profile.touring': 'تجوال', + 'tours.bike.profile.road': 'طريق', + 'tours.bike.profile.gravel': 'حصى', + 'tours.bike.profile.mtb': 'جبلية', + 'tours.bike.profileHint.touring': 'يفضل مسارات الدراجات والطرق الهادئة، مرصوفة حيثما أمكن.', + 'tours.bike.profileHint.road': 'يلتزم بالأسفلت؛ وتُعرض المقاطع غير المرصوفة المعروفة.', + 'tours.bike.profileHint.gravel': 'يسلك طرق الحصى والطرق الزراعية، لا المسارات الفردية التقنية.', + 'tours.bike.profileHint.mtb': 'يسلك الطرق الوعرة كما هي؛ وتُعرض درجات المسارات حيث تُرسم.', + 'tours.bike.timeEstimated': 'وقت الركوب (تقديري)', + 'tours.bike.timeEstimatedShort': 'وقت الركوب', + 'tours.bike.timeEstimatedInfoLabel': 'كيف يُحسب وقت الركوب', + 'tours.bike.timeEstimatedInfo': 'وقت الموجّه للملف المختار مع سرعته والصعود. قيمة تخطيطية دون الاستراحات.', + 'tours.bike.ways.title': 'السطح والطرق', + 'tours.bike.ways.surface': 'السطح', + 'tours.bike.ways.surface.paved': 'أسفلت', + 'tours.bike.ways.surface.rough': 'رصف خشن', + 'tours.bike.ways.surface.compacted': 'مدكوك', + 'tours.bike.ways.surface.gravel': 'حصى', + 'tours.bike.ways.surface.ground': 'أرض طبيعية', + 'tours.bike.ways.surface.unknown': 'غير موجود في البيانات', + 'tours.bike.ways.way': 'نوع الطريق', + 'tours.bike.ways.way.cyclePath': 'مسار دراجات', + 'tours.bike.ways.way.cycleLane': 'حارة دراجات بجانب السير', + 'tours.bike.ways.way.quiet': 'شارع هادئ', + 'tours.bike.ways.way.road': 'طريق فيه حركة مرور', + 'tours.bike.ways.way.track': 'طريق زراعي أو حرجي', + 'tours.bike.ways.way.path': 'ممر', + 'tours.bike.ways.way.ferry': 'عبّارة', + 'tours.bike.ways.way.other': 'أخرى', + 'tours.bike.ways.loading': 'جارٍ قراءة الطرق على طول المسار…', + 'tours.bike.ways.offline': 'يتطلب السطح ودرجات المسارات اتصالًا.', + 'tours.bike.ways.failed': 'تعذرت قراءة الطرق على طول المسار. المسار نفسه لم يتأثر.', + 'tours.bike.ways.source': + 'من OpenStreetMap كما يقرأه الموجّه. حيث لا تذكر البيانات شيئًا يكون السطح مجهولًا، لا جيدًا.', + 'tours.bike.ways.showOnMap': 'عرض على الخريطة', + 'tours.bike.ways.unpavedBadge': '{share} غير مرصوف', + 'tours.bike.ways.unpavedNotice': '{length} من المسار معروف بأنه غير مرصوف.', + 'tours.bike.trail.title': 'درجة المسار (mtb:scale)', + 'tours.bike.trail.s0': 'S0، بلا صعوبة خاصة', + 'tours.bike.trail.s1': 'S1، عوائق صغيرة', + 'tours.bike.trail.s2': 'S2، جذور ودرجات وأرض رخوة', + 'tours.bike.trail.s3': 'S3، صخور وانحدار ومنعطفات ضيقة', + 'tours.bike.trail.s4': 'S4 فأعلى، أرض للخبراء', + 'tours.bike.trail.none': 'لا توجد درجة في البيانات', + 'tours.bike.trail.unasked': 'تعذر الاستعلام هذه المرة', + 'tours.bike.trail.uphillTitle': 'درجة الصعود (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'صعود {step}', + 'tours.bike.trail.note': + 'الدرجات كما وضعها مساهمو OpenStreetMap. الطريق بلا درجة مجهول وليس سهلًا. ولا تقول شيئًا عن ملاءمة المسار لك.', + 'tours.bike.trail.loading': 'جارٍ قراءة درجات المسارات…', + 'tours.bike.trail.failed': 'تعذرت قراءة درجات المسارات هذه المرة.', 'tours.copy.action': 'حفظ كجولة جديدة', 'tours.copy.hint': 'ينسخ هذه الجولة كجولة جديدة مستقلة دون أيامها. يبقى الأصل كما هو.', 'tours.copy.name': '{name} (نسخة)', @@ -361,6 +427,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'يتطلب تنزيل ملف GPX اتصالًا بالخادم.', 'tours.gpxExport.noRoute': 'لا يوجد لهذه الجولة مسار محفوظ للتصدير.', 'tours.gpxExport.failed': 'تعذّر إنشاء ملف GPX.', + 'tours.gpxExport.share': 'مشاركة GPX', + 'tours.gpxExport.shareShort': 'مشاركة', + 'tours.gpxExport.shareSaveFirst': 'احفظ الجولة أولًا لمشاركتها بصيغة GPX', + 'tours.gpxExport.shareReady': 'ملف GPX جاهز. اضغط على مشاركة مرة أخرى لفتح نافذة المشاركة.', + 'tours.gpxExport.shareFailed': 'لم تنجح المشاركة. لا يزال بإمكانك تنزيل ملف GPX.', 'tours.revision.remoteTitle': 'حُفظت هذه الجولة في مكان آخر', 'tours.revision.remoteBody': 'حفظها شخص ما، أو أنت على جهاز آخر، أثناء تعديلك. تبقى تغييراتك حتى تقرّر.', 'tours.revision.loadNewer': 'تحميل الأحدث', @@ -389,14 +460,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'ما الذي يفعله اختيار المسار', 'tours.preferences.info': 'تؤثر هذه الخيارات في الطريق الذي يختاره الموجّه. حيث لا يوجد طريق آخر، يمر المسار هناك رغم ذلك. ولا تقول شيئًا عن صعوبة الطريق؛ فحد الصعوبة أعلاه يحدد ما يمكن للمسار استخدامه.', + 'tours.preferences.avoid_climbs': 'تجنّب المنحدرات الشديدة', + 'tours.preferences.avoid_climbsHint': + 'طرق أقل انحدارًا حيث توجد، حتى لو كانت أطول. حيث لا يوجد طريق بديل، يبقى المسار شديد الانحدار.', 'tours.preferences.avoid_steps': 'تجنّب الدرج', 'tours.preferences.avoid_stepsHint': 'الدرج فقط حيث لا يوجد طريق بديل.', 'tours.preferences.avoid_ferries': 'تجنّب العبّارات', 'tours.preferences.avoid_ferriesHint': 'عبر البر حيث يوجد طريق بديل، حتى لو كان أطول.', - 'tours.preferences.prefer_paths': 'تفضيل ممرات المشاة', - 'tours.preferences.prefer_pathsHint': 'مسارات مستقلة بدل الطرق وأرصفتها حيث توجد.', - 'tours.preferences.shortest': 'الأقصر بدل الأسرع', - 'tours.preferences.shortestHint': 'يُختار الطريق بطوله لا بزمنه؛ غالبًا أقصر قليلًا، وليس دائمًا أسرع.', 'tours.planner.tourType': 'نوع الجولة', 'tours.paddle.boat': 'القارب', 'tours.paddle.boat.kayak': 'كاياك', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index 0cf443501..1ba4cbec0 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -753,6 +753,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Gəzinti xəritəsi (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'OpenHikingMap-i tur planlayıcısında əsas xəritə kimi təklif edir. Qeyri-kommersiya quraşdırmalar üçün ayda 500.000 plitəyə qədər pulsuzdur; kommersiya və ya daha böyük quraşdırma əvvəlcə openmaps.fr ilə razılaşma tələb edir', + 'admin.toursCycleMap.title': 'Velosiped xəritəsi (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Tur planlayıcısında velosiped turları üçün CyclOSM-i əsas xəritə kimi təklif edir. OpenStreetMap France onu orta istifadə üçün təqdim edir; ağır və ya kommersiya quraşdırması onu söndürür və ya əvvəlcə operatorla razılaşır', 'admin.addons.noAddons': 'Heç bir əlavə mövcud deyil', 'admin.weather.title': 'Hava məlumatları', 'admin.weather.badge': '24 mart 2026-cı ildən', diff --git a/shared/src/i18n/az/map.ts b/shared/src/i18n/az/map.ts index 5d0a73fb6..b052cd0ea 100644 --- a/shared/src/i18n/az/map.ts +++ b/shared/src/i18n/az/map.ts @@ -23,6 +23,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Peyk', 'map.baseLayer.hiking': 'Gəzinti', 'map.baseLayer.hikingDown': 'OpenHikingMap hazırda cavab vermir. Başqa xəritəyə keçin; turunuz olduğu kimi qalır.', + 'map.baseLayer.cycle': 'Velosiped', + 'map.baseLayer.cycleDown': 'CyclOSM hazırda cavab vermir. Başqa xəritəyə keç; turun olduğu kimi qalır.', 'map.baseLayer.switchToSatellite': 'Peyk görünüşünə keç', 'map.baseLayer.switchToDefault': 'Xəritə görünüşünə keç', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index c6874baa6..b59693bda 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -93,6 +93,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Naviqasiya cığırın başlanğıcını açır; marşrut boyunca istiqamət göstərmir.', 'tours.planner.title': 'Tur planlaşdır', 'tours.planner.newTour': 'Yeni tur planlaşdır', + 'tours.planner.addTour': 'Tur əlavə et', + 'tours.planner.addTourHint': 'Yeni tur planla, GPX fayllarını idxal et və ya başqa səfərdən tur götür', 'tours.planner.neutralTitle': 'Tur planlaşdır', 'tours.planner.neutralBody': 'Yeni tur başladın və marşrut nöqtələrini qoymaq üçün xəritəyə klikləyin.', 'tours.planner.newTitle': 'Yeni tur', @@ -109,6 +111,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Yüksəklik profilini aç', 'tours.planner.name': 'Turun adı', 'tours.planner.namePlaceholder': 'Adsız yürüş', + 'tours.planner.namePlaceholder.bike': 'Adsız velosiped turu', + 'tours.planner.namePlaceholder.kayak': 'Adsız avarçəkmə turu', 'tours.planner.mapSearch': 'Yer və ya ünvan axtar', 'tours.planner.placePoints': 'Nöqtə qoy', 'tours.planner.pointPlaced': 'Nöqtə qoyuldu', @@ -172,6 +176,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Hesablama gözlənilir', 'tours.planner.status.routing': 'Piyada marşrutu hesablanır…', 'tours.planner.status.routingFailed': 'Marşrut hesablanmadı. Son uğurlu marşrut hələ göstərilir.', + 'tours.planner.status.unreachable': '{point} nöqtəsinə çatmaq olmur', + 'tours.planner.unreachable.hike': + 'Heç bir piyada yolu {point} nöqtəsinə çatmır: marşrutlaşdırıcının bildiyi ən yaxın yol {distance} uzaqdadır. Nöqtəni yola köçür və ya ora hissəni əllə çək.', + 'tours.planner.unreachable.bike': + 'Heç bir velosiped yolu {point} nöqtəsinə çatmır: marşrutlaşdırıcının bildiyi ən yaxın yol {distance} uzaqdadır. Nöqtəni yola köçür və ya ora hissəni əllə çək.', + 'tours.planner.unreachable.show': '{point} nöqtəsini göstər', 'tours.planner.status.elevation': 'Yüksəklik yüklənir…', 'tours.planner.status.elevationFailed': 'Yüksəklik əlçatan deyil. Hesablanmış marşrut hələ göstərilir.', 'tours.planner.status.saving': 'Saxlanılır…', @@ -340,6 +350,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Görməli yerlər', 'tours.poi.cat.slipway': 'Suya buraxılma yerləri', 'tours.poi.cat.boat_rental': 'Qayıq icarəsi', + 'tours.poi.cat.bike_repair': 'Velosiped təmiri', + 'tours.planner.tourTypeHint.hike': 'Piyada, təyin etdiyin çətinlik daxilində', + 'tours.planner.tourTypeHint.bike': 'Velosipedlə, turinq, yol, qravel və ya MTB üçün marşrut', + 'tours.planner.tourTypeHint.kayak': 'Suda, kayak, kanoe və ya SUP ilə', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Turinq', + 'tours.bike.profile.road': 'Yol', + 'tours.bike.profile.gravel': 'Qravel', + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Velosiped yollarına və sakit küçələrə üstünlük verir, mümkün olduqda asfaltlı.', + 'tours.bike.profileHint.road': 'Asfaltda qalır; məlum asfaltsız hissələr göstərilir.', + 'tours.bike.profileHint.gravel': 'Çınqıl və təsərrüfat yollarından keçir, texniki tək cığırlardan yox.', + 'tours.bike.profileHint.mtb': 'Kobud yollardan olduğu kimi keçir; cığır çətinlikləri qeyd olunduğu yerdə göstərilir.', + 'tours.bike.timeEstimated': 'Sürüş vaxtı (təxmini)', + 'tours.bike.timeEstimatedShort': 'Sürüş vaxtı', + 'tours.bike.timeEstimatedInfoLabel': 'Sürüş vaxtı necə hesablanır', + 'tours.bike.timeEstimatedInfo': + 'Seçilmiş profil üçün marşrutlaşdırıcının vaxtı, sürəti və yoxuşlar nəzərə alınmaqla. Fasilələrsiz planlaşdırma dəyəri.', + 'tours.bike.ways.title': 'Səth və yollar', + 'tours.bike.ways.surface': 'Səth', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Kobud örtük', + 'tours.bike.ways.surface.compacted': 'Sıxlaşdırılmış', + 'tours.bike.ways.surface.gravel': 'Çınqıl', + 'tours.bike.ways.surface.ground': 'Təbii torpaq', + 'tours.bike.ways.surface.unknown': 'Məlumatda yoxdur', + 'tours.bike.ways.way': 'Yol növü', + 'tours.bike.ways.way.cyclePath': 'Velosiped yolu', + 'tours.bike.ways.way.cycleLane': 'Nəqliyyat yanında velosiped zolağı', + 'tours.bike.ways.way.quiet': 'Sakit küçə', + 'tours.bike.ways.way.road': 'Nəqliyyatlı yol', + 'tours.bike.ways.way.track': 'Təsərrüfat və ya meşə yolu', + 'tours.bike.ways.way.path': 'Cığır', + 'tours.bike.ways.way.ferry': 'Bərə', + 'tours.bike.ways.way.other': 'Digər', + 'tours.bike.ways.loading': 'Marşrut boyunca yollar oxunur…', + 'tours.bike.ways.offline': 'Səth və cığır çətinlikləri üçün bağlantı lazımdır.', + 'tours.bike.ways.failed': 'Marşrut boyunca yollar oxunmadı. Marşrutun özü dəyişmir.', + 'tours.bike.ways.source': + 'Marşrutlaşdırıcının oxuduğu kimi OpenStreetMap-dən. Məlumat heç nə demirsə, səth naməlumdur, yaxşı deyil.', + 'tours.bike.ways.showOnMap': 'Xəritədə göstər', + 'tours.bike.ways.unpavedBadge': '{share} asfaltsız', + 'tours.bike.ways.unpavedNotice': 'Marşrutun {length} hissəsinin asfaltsız olduğu məlumdur.', + 'tours.bike.trail.title': 'Cığır çətinliyi (mtb:scale)', + 'tours.bike.trail.s0': 'S0, xüsusi çətinlik yoxdur', + 'tours.bike.trail.s1': 'S1, kiçik maneələr', + 'tours.bike.trail.s2': 'S2, köklər, pillələr, boş torpaq', + 'tours.bike.trail.s3': 'S3, daş bloklar, dik, dar döngələr', + 'tours.bike.trail.s4': 'S4 və yuxarı, ekspert ərazisi', + 'tours.bike.trail.none': 'Məlumatda dərəcə yoxdur', + 'tours.bike.trail.unasked': 'Bu dəfə soruşmaq mümkün olmadı', + 'tours.bike.trail.uphillTitle': 'Yoxuş çətinliyi (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Yoxuş {step}', + 'tours.bike.trail.note': + 'Dərəcələr OpenStreetMap redaktorlarının verdiyi kimidir. Dərəcəsiz yol asan deyil, naməlumdur. Cığırın sənə uyğun olub-olmadığını demir.', + 'tours.bike.trail.loading': 'Cığır çətinlikləri oxunur…', + 'tours.bike.trail.failed': 'Cığır çətinlikləri bu dəfə oxunmadı.', 'tours.copy.action': 'Yeni tur kimi saxla', 'tours.copy.hint': 'Bu turu günləri olmadan yeni, müstəqil tur kimi kopyalayır. Orijinal olduğu kimi qalır.', 'tours.copy.name': '{name} (surət)', @@ -352,6 +419,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'GPX faylını endirmək üçün serverə bağlantı lazımdır.', 'tours.gpxExport.noRoute': 'Bu turun ixrac ediləcək saxlanmış marşrutu yoxdur.', 'tours.gpxExport.failed': 'GPX faylını yaratmaq olmadı.', + 'tours.gpxExport.share': 'GPX paylaş', + 'tours.gpxExport.shareShort': 'Paylaş', + 'tours.gpxExport.shareSaveFirst': 'GPX kimi paylaşmaq üçün əvvəlcə turu yadda saxla', + 'tours.gpxExport.shareReady': 'GPX faylı hazırdır. Paylaşma pəncərəsini açmaq üçün Paylaş-a bir daha toxun.', + 'tours.gpxExport.shareFailed': 'Paylaşma alınmadı. GPX faylını yenə də yükləmək olar.', 'tours.revision.remoteTitle': 'Bu tur başqa yerdə saxlanıb', 'tours.revision.remoteBody': 'Siz redaktə edərkən kimsə və ya başqa cihazda siz özünüz onu saxladı. Dəyişiklikləriniz qərar verənə qədər qalır.', @@ -382,15 +454,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Marşrut seçimi nə edir', 'tours.preferences.info': 'Bu seçimlər marşrutlaşdırıcının hansı yolu seçəcəyinə təsir edir. Başqa yol yoxdursa, marşrut yenə oradan keçir. Yolun çətinliyi barədə heç nə demir; marşrutun nədən istifadə edə biləcəyini yuxarıdakı çətinlik həddi müəyyən edir.', + 'tours.preferences.avoid_climbs': 'Dik yoxuşlardan qaç', + 'tours.preferences.avoid_climbsHint': + 'Mövcud olduqda daha yumşaq yollar, daha uzun olsalar belə. Dolama yol olmayan yerdə dik qalır.', 'tours.preferences.avoid_steps': 'Pilləkənlərdən qaç', 'tours.preferences.avoid_stepsHint': 'Pilləkən yalnız dolama yol olmayan yerdə.', 'tours.preferences.avoid_ferries': 'Bərələrdən qaç', 'tours.preferences.avoid_ferriesHint': 'Dolama yol varsa, daha uzun olsa da quru ilə.', - 'tours.preferences.prefer_paths': 'Piyada yollarına üstünlük ver', - 'tours.preferences.prefer_pathsHint': 'Olan yerdə yollar və səkilər əvəzinə ayrıca cığırlar.', - 'tours.preferences.shortest': 'Daha sürətli yox, daha qısa', - 'tours.preferences.shortestHint': - 'Yol vaxta görə yox, uzunluğa görə seçilir; çox vaxt bir az qısa, həmişə daha sürətli deyil.', 'tours.planner.tourType': 'Tur növü', 'tours.paddle.boat': 'Qayıq', 'tours.paddle.boat.kayak': 'Kayak', // same-as-en diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index aa40cf357..500eacee2 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -661,6 +661,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Mapa de trilhas (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Oferece o OpenHikingMap como mapa base no planejador de passeios. Gratuito para instalações não comerciais até 500.000 blocos por mês; uma instalação comercial ou maior precisa antes de um acordo com o openmaps.fr', + 'admin.toursCycleMap.title': 'Mapa de ciclismo (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Oferece o CyclOSM como mapa base para passeios de bicicleta no planejador. O OpenStreetMap France o fornece para uso moderado; uma instalação de uso intenso ou comercial o desativa ou faz antes um acordo com o operador', 'admin.addons.noAddons': 'Nenhum complemento disponível', 'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada', 'admin.weather.title': 'Dados meteorológicos', diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts index b7a431785..ea4df1ce1 100644 --- a/shared/src/i18n/br/map.ts +++ b/shared/src/i18n/br/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Trilhas', 'map.baseLayer.hikingDown': 'O OpenHikingMap não está respondendo agora. Mude para outro mapa; seu passeio continua como está.', + 'map.baseLayer.cycle': 'Bicicleta', + 'map.baseLayer.cycleDown': 'O CyclOSM não está respondendo agora. Mude para outro mapa; seu passeio continua igual.', 'map.baseLayer.switchToSatellite': 'Mudar para vista de satélite', 'map.baseLayer.switchToDefault': 'Mudar para vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 5cb5c3308..959d5b8fe 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -97,6 +97,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'A navegação abre o início da trilha; ela não orienta ao longo da rota.', 'tours.planner.title': 'Planejar um passeio', 'tours.planner.newTour': 'Planejar novo passeio', + 'tours.planner.addTour': 'Adicionar passeio', + 'tours.planner.addTourHint': 'Planeje um novo passeio, importe arquivos GPX ou traga um passeio de outra viagem', 'tours.planner.neutralTitle': 'Planejar um passeio', 'tours.planner.neutralBody': 'Comece um novo passeio e clique no mapa para marcar os pontos do percurso.', 'tours.planner.newTitle': 'Novo passeio', @@ -113,6 +115,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Expandir perfil de elevação', 'tours.planner.name': 'Nome do passeio', 'tours.planner.namePlaceholder': 'Trilha sem título', + 'tours.planner.namePlaceholder.bike': 'Passeio de bicicleta sem nome', + 'tours.planner.namePlaceholder.kayak': 'Passeio de remo sem nome', 'tours.planner.mapSearch': 'Buscar lugar ou endereço', 'tours.planner.placePoints': 'Marcar pontos', 'tours.planner.pointPlaced': 'Ponto marcado', @@ -176,6 +180,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Aguardando cálculo', 'tours.planner.status.routing': 'Calculando rota a pé…', 'tours.planner.status.routingFailed': 'O cálculo da rota falhou. A última rota válida continua sendo exibida.', + 'tours.planner.status.unreachable': 'O ponto {point} não pode ser alcançado', + 'tours.planner.unreachable.hike': + 'Nenhuma trilha de caminhada chega ao ponto {point}: a mais próxima que o roteador conhece fica a {distance}. Mova o ponto para uma trilha ou desenhe o trecho até ele à mão.', + 'tours.planner.unreachable.bike': + 'Nenhuma via para bicicletas chega ao ponto {point}: a mais próxima que o roteador conhece fica a {distance}. Mova o ponto para uma via ou desenhe o trecho até ele à mão.', + 'tours.planner.unreachable.show': 'Mostrar o ponto {point}', 'tours.planner.status.elevation': 'Carregando elevação…', 'tours.planner.status.elevationFailed': 'A elevação não está disponível. A rota calculada continua sendo exibida.', 'tours.planner.status.saving': 'Salvando…', @@ -345,6 +355,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Atrações', 'tours.poi.cat.slipway': 'Pontos de entrada na água', 'tours.poi.cat.boat_rental': 'Aluguel de barcos', + 'tours.poi.cat.bike_repair': 'Conserto de bicicleta', + 'tours.planner.tourTypeHint.hike': 'A pé, dentro de uma dificuldade que você define', + 'tours.planner.tourTypeHint.bike': 'De bicicleta, com rota para cicloturismo, estrada, gravel ou MTB', + 'tours.planner.tourTypeHint.kayak': 'Na água, de caiaque, canoa ou SUP', + 'tours.bike.profile': 'Perfil', + 'tours.bike.profile.touring': 'Cicloturismo', + 'tours.bike.profile.road': 'Estrada', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Prefere ciclovias e ruas tranquilas, pavimentadas quando possível.', + 'tours.bike.profileHint.road': 'Fica no asfalto; trechos sem pavimento conhecidos são mostrados.', + 'tours.bike.profileHint.gravel': 'Usa cascalho e estradas rurais, não trilhas técnicas.', + 'tours.bike.profileHint.mtb': 'Usa trilhas brutas como vierem; graus de trilha aparecem onde mapeados.', + 'tours.bike.timeEstimated': 'Tempo pedalando (estimado)', + 'tours.bike.timeEstimatedShort': 'Tempo pedalando', + 'tours.bike.timeEstimatedInfoLabel': 'Como o tempo pedalando é calculado', + 'tours.bike.timeEstimatedInfo': + 'O tempo do roteador para o perfil escolhido, com sua velocidade e as subidas. Um valor de planejamento, sem pausas.', + 'tours.bike.ways.title': 'Piso e vias', + 'tours.bike.ways.surface': 'Piso', + 'tours.bike.ways.surface.paved': 'Asfalto', + 'tours.bike.ways.surface.rough': 'Pavimento irregular', + 'tours.bike.ways.surface.compacted': 'Compactado', + 'tours.bike.ways.surface.gravel': 'Cascalho', + 'tours.bike.ways.surface.ground': 'Terra', + 'tours.bike.ways.surface.unknown': 'Fora dos dados', + 'tours.bike.ways.way': 'Tipo de via', + 'tours.bike.ways.way.cyclePath': 'Ciclovia', + 'tours.bike.ways.way.cycleLane': 'Ciclofaixa ao lado do trânsito', + 'tours.bike.ways.way.quiet': 'Rua tranquila', + 'tours.bike.ways.way.road': 'Estrada com trânsito', + 'tours.bike.ways.way.track': 'Estrada rural ou florestal', + 'tours.bike.ways.way.path': 'Trilha', + 'tours.bike.ways.way.ferry': 'Balsa', + 'tours.bike.ways.way.other': 'Outros', + 'tours.bike.ways.loading': 'Lendo as vias ao longo da rota…', + 'tours.bike.ways.offline': 'Pisos e graus de trilha precisam de conexão.', + 'tours.bike.ways.failed': 'Não foi possível ler as vias da rota. A rota em si não muda.', + 'tours.bike.ways.source': + 'Do OpenStreetMap, como o roteador lê. Onde os dados não dizem nada, o piso é desconhecido, não bom.', + 'tours.bike.ways.showOnMap': 'Mostrar no mapa', + 'tours.bike.ways.unpavedBadge': '{share} sem pavimento', + 'tours.bike.ways.unpavedNotice': '{length} da rota sabidamente sem pavimento.', + 'tours.bike.trail.title': 'Grau da trilha (mtb:scale)', + 'tours.bike.trail.s0': 'S0, sem dificuldade especial', + 'tours.bike.trail.s1': 'S1, pequenos obstáculos', + 'tours.bike.trail.s2': 'S2, raízes, degraus, chão solto', + 'tours.bike.trail.s3': 'S3, blocos, íngreme, curvas fechadas', + 'tours.bike.trail.s4': 'S4 ou mais, terreno para especialistas', + 'tours.bike.trail.none': 'Sem grau nos dados', + 'tours.bike.trail.unasked': 'Não foi possível consultar agora', + 'tours.bike.trail.uphillTitle': 'Grau de subida (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Subida {step}', + 'tours.bike.trail.note': + 'Graus como os mapeadores do OpenStreetMap os deram. Uma via sem grau é desconhecida, não fácil. Eles não dizem se a trilha combina com você.', + 'tours.bike.trail.loading': 'Lendo os graus das trilhas…', + 'tours.bike.trail.failed': 'Não foi possível ler os graus das trilhas agora.', 'tours.copy.action': 'Salvar como novo passeio', 'tours.copy.hint': 'Copia este passeio como um novo passeio independente, sem os seus dias. O original fica como está.', @@ -358,6 +425,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Baixar um arquivo GPX exige conexão com o servidor.', 'tours.gpxExport.noRoute': 'Este passeio não tem uma rota salva para exportar.', 'tours.gpxExport.failed': 'Não foi possível criar o arquivo GPX.', + 'tours.gpxExport.share': 'Compartilhar GPX', + 'tours.gpxExport.shareShort': 'Compartilhar', + 'tours.gpxExport.shareSaveFirst': 'Salve o passeio primeiro para compartilhá-lo como GPX', + 'tours.gpxExport.shareReady': + 'O arquivo GPX está pronto. Toque em Compartilhar mais uma vez para abrir o menu de compartilhamento.', + 'tours.gpxExport.shareFailed': 'Não foi possível compartilhar. O arquivo GPX ainda pode ser baixado.', 'tours.revision.remoteTitle': 'Este passeio foi salvo em outro lugar', 'tours.revision.remoteBody': 'Alguém, ou você em outro aparelho, salvou enquanto você editava. Suas alterações ficam até você decidir.', @@ -388,15 +461,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'O que a escolha da rota faz', 'tours.preferences.info': 'Estas escolhas pesam o caminho que o roteador escolhe. Onde não há outro caminho, a rota passa por ali mesmo assim. Não dizem nada sobre a dificuldade de um caminho; o limite de dificuldade acima define o que a rota pode usar.', + 'tours.preferences.avoid_climbs': 'Evitar subidas íngremes', + 'tours.preferences.avoid_climbsHint': + 'Caminhos mais suaves onde houver, mesmo que mais longos. Onde não há como contornar, continua íngreme.', 'tours.preferences.avoid_steps': 'Evitar escadas', 'tours.preferences.avoid_stepsHint': 'Escadas só onde não há caminho alternativo.', 'tours.preferences.avoid_ferries': 'Evitar balsas', 'tours.preferences.avoid_ferriesHint': 'Por terra onde houver caminho alternativo, mesmo mais longo.', - 'tours.preferences.prefer_paths': 'Preferir trilhas a pé', - 'tours.preferences.prefer_pathsHint': 'Caminhos próprios em vez de estradas e calçadas, onde houver.', - 'tours.preferences.shortest': 'Mais curto em vez de mais rápido', - 'tours.preferences.shortestHint': - 'O caminho escolhido pela distância, não pelo tempo; em geral um pouco mais curto, nem sempre mais rápido.', 'tours.planner.tourType': 'Tipo de passeio', 'tours.paddle.boat': 'Embarcação', 'tours.paddle.boat.kayak': 'Caiaque', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index febabd59f..82c7f22a7 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -270,6 +270,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': "Mapa d'excursionisme (OpenHikingMap)", 'admin.toursHikingMap.subtitle': 'Ofereix OpenHikingMap com a mapa base al planificador de rutes. Gratuït per a instal·lacions no comercials fins a 500.000 tessel·les al mes; una instal·lació comercial o més gran necessita abans un acord amb openmaps.fr', + 'admin.toursCycleMap.title': 'Mapa ciclista (CyclOSM)', + 'admin.toursCycleMap.subtitle': + "Ofereix CyclOSM com a mapa base per a rutes en bici al planificador. OpenStreetMap France el serveix per a un ús moderat; una instal·lació d'ús intens o comercial el desactiva o primer arriba a un acord amb l'operador", 'admin.addons.noAddons': 'No hi ha complements disponibles', 'admin.weather.title': 'Dades meteorològiques', 'admin.weather.badge': 'Des del 24 de març de 2026', diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts index 8d18f2310..0a0bab97c 100644 --- a/shared/src/i18n/ca/map.ts +++ b/shared/src/i18n/ca/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Excursionisme', 'map.baseLayer.hikingDown': 'OpenHikingMap no respon ara mateix. Canvia a un altre mapa; la teva ruta es queda tal com és.', + 'map.baseLayer.cycle': 'Bicicleta', + 'map.baseLayer.cycleDown': 'CyclOSM no respon ara mateix. Canvia a un altre mapa; la teva ruta es queda igual.', 'map.baseLayer.switchToSatellite': 'Canvia a vista de satèl·lit', 'map.baseLayer.switchToDefault': 'Canvia a vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 290d5dcad..2888f8fec 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -97,6 +97,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': "La navegació obre el punt d'inici del sender; no guia al llarg de la ruta.", 'tours.planner.title': 'Planifica una excursió', 'tours.planner.newTour': 'Planifica una nova excursió', + 'tours.planner.addTour': 'Afegeix una ruta', + 'tours.planner.addTourHint': "Planifica una ruta nova, importa fitxers GPX o porta una ruta d'un altre viatge", 'tours.planner.neutralTitle': 'Planifica una excursió', 'tours.planner.neutralBody': 'Comença una ruta nova i fes clic al mapa per marcar-ne els punts.', 'tours.planner.newTitle': 'Nova excursió', @@ -113,6 +115,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': "Desplega el perfil d'altitud", 'tours.planner.name': "Nom de l'excursió", 'tours.planner.namePlaceholder': 'Ruta de senderisme sense títol', + 'tours.planner.namePlaceholder.bike': 'Ruta en bici sense nom', + 'tours.planner.namePlaceholder.kayak': 'Sortida en caiac sense nom', 'tours.planner.mapSearch': 'Cerca un lloc o una adreça', 'tours.planner.placePoints': 'Posar punts', 'tours.planner.pointPlaced': 'Punt posat', @@ -176,6 +180,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Pendent de càlcul', 'tours.planner.status.routing': 'Calculant ruta a peu…', 'tours.planner.status.routingFailed': "El càlcul de la ruta ha fallat. Es continua mostrant l'última ruta vàlida.", + 'tours.planner.status.unreachable': 'No es pot arribar al punt {point}', + 'tours.planner.unreachable.hike': + "Cap camí de senderisme arriba al punt {point}: el més proper que coneix l'enrutador és a {distance}. Mou el punt a un camí o dibuixa el tram fins allà a mà.", + 'tours.planner.unreachable.bike': + "Cap camí per a bicicletes arriba al punt {point}: el més proper que coneix l'enrutador és a {distance}. Mou el punt a un camí o dibuixa el tram fins allà a mà.", + 'tours.planner.unreachable.show': 'Mostra el punt {point}', 'tours.planner.status.elevation': 'Carregant altitud…', 'tours.planner.status.elevationFailed': "L'altitud no està disponible. Es continua mostrant la ruta calculada.", 'tours.planner.status.saving': 'Desant…', @@ -346,6 +356,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': "Llocs d'interès", 'tours.poi.cat.slipway': "Punts d'embarcament", 'tours.poi.cat.boat_rental': 'Lloguer de barques', + 'tours.poi.cat.bike_repair': 'Reparació de bicicletes', + 'tours.planner.tourTypeHint.hike': "A peu, dins d'una dificultat que tu tries", + 'tours.planner.tourTypeHint.bike': 'En bicicleta, amb ruta per a cicloturisme, carretera, gravel o BTT', + 'tours.planner.tourTypeHint.kayak': "A l'aigua, amb caiac, canoa o SUP", + 'tours.bike.profile': 'Perfil', + 'tours.bike.profile.touring': 'Cicloturisme', + 'tours.bike.profile.road': 'Carretera', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'BTT', + 'tours.bike.profileHint.touring': 'Prefereix carrils bici i carrers tranquils, pavimentats quan es pot.', + 'tours.bike.profileHint.road': "Es manté a l'asfalt; es mostren els trams sense pavimentar coneguts.", + 'tours.bike.profileHint.gravel': 'Pren grava i camins agrícoles, no corriols tècnics.', + 'tours.bike.profileHint.mtb': 'Pren camins durs tal com vénen; es mostren els graus on estan cartografiats.', + 'tours.bike.timeEstimated': 'Temps en bici (estimat)', + 'tours.bike.timeEstimatedShort': 'Temps en bici', + 'tours.bike.timeEstimatedInfoLabel': 'Com es calcula el temps en bici', + 'tours.bike.timeEstimatedInfo': + "El temps de l'enrutador per al perfil triat, amb la seva velocitat i les pujades. Un valor de planificació, sense pauses.", + 'tours.bike.ways.title': 'Ferm i camins', + 'tours.bike.ways.surface': 'Ferm', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Paviment irregular', + 'tours.bike.ways.surface.compacted': 'Compactat', + 'tours.bike.ways.surface.gravel': 'Grava', + 'tours.bike.ways.surface.ground': 'Terra natural', + 'tours.bike.ways.surface.unknown': 'No consta a les dades', + 'tours.bike.ways.way': 'Tipus de camí', + 'tours.bike.ways.way.cyclePath': 'Carril bici', + 'tours.bike.ways.way.cycleLane': 'Carril bici al costat del trànsit', + 'tours.bike.ways.way.quiet': 'Carrer tranquil', + 'tours.bike.ways.way.road': 'Carretera amb trànsit', + 'tours.bike.ways.way.track': 'Camí agrícola o forestal', + 'tours.bike.ways.way.path': 'Sender', + 'tours.bike.ways.way.ferry': 'Transbordador', + 'tours.bike.ways.way.other': 'Altres', + 'tours.bike.ways.loading': "S'estan llegint els camins de la ruta…", + 'tours.bike.ways.offline': 'El ferm i els graus dels corriols necessiten connexió.', + 'tours.bike.ways.failed': "No s'han pogut llegir els camins de la ruta. La ruta no canvia.", + 'tours.bike.ways.source': + "D'OpenStreetMap, tal com ho llegeix l'enrutador. On les dades no diuen res, el ferm és desconegut, no bo.", + 'tours.bike.ways.showOnMap': 'Mostra al mapa', + 'tours.bike.ways.unpavedBadge': '{share} sense pavimentar', + 'tours.bike.ways.unpavedNotice': 'Se sap que {length} de la ruta no estan pavimentats.', + 'tours.bike.trail.title': 'Grau del corriol (mtb:scale)', + 'tours.bike.trail.s0': 'S0, sense dificultat especial', + 'tours.bike.trail.s1': 'S1, obstacles petits', + 'tours.bike.trail.s2': 'S2, arrels, graons, terra solta', + 'tours.bike.trail.s3': 'S3, blocs, pendent fort, revolts tancats', + 'tours.bike.trail.s4': "S4 o més, terreny d'experts", + 'tours.bike.trail.none': 'Sense grau a les dades', + 'tours.bike.trail.unasked': "No s'ha pogut consultar aquesta vegada", + 'tours.bike.trail.uphillTitle': 'Grau de pujada (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Pujada {step}', + 'tours.bike.trail.note': + "Graus tal com els han posat els mapadors d'OpenStreetMap. Un camí sense grau és desconegut, no fàcil. No diuen res de si un corriol et convé.", + 'tours.bike.trail.loading': "S'estan llegint els graus dels corriols…", + 'tours.bike.trail.failed': "Aquesta vegada no s'han pogut llegir els graus dels corriols.", 'tours.copy.action': 'Desa com a excursió nova', 'tours.copy.hint': 'Copia aquesta excursió com una excursió nova i independent, sense els seus dies. L’original es queda com està.', @@ -359,6 +426,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Per baixar un fitxer GPX cal connexió amb el servidor.', 'tours.gpxExport.noRoute': 'Aquesta excursió no té cap ruta desada per exportar.', 'tours.gpxExport.failed': 'No s’ha pogut crear el fitxer GPX.', + 'tours.gpxExport.share': 'Comparteix el GPX', + 'tours.gpxExport.shareShort': 'Comparteix', + 'tours.gpxExport.shareSaveFirst': 'Desa primer la ruta per compartir-la com a GPX', + 'tours.gpxExport.shareReady': + 'El fitxer GPX és a punt. Toca Comparteix una altra vegada per obrir el diàleg de compartir.', + 'tours.gpxExport.shareFailed': "No s'ha pogut compartir. El fitxer GPX encara es pot baixar.", 'tours.revision.remoteTitle': 'Aquesta excursió s’ha desat en un altre lloc', 'tours.revision.remoteBody': 'Algú, o tu en un altre dispositiu, l’ha desada mentre l’editaves. Els teus canvis es conserven fins que decideixis.', @@ -389,15 +462,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Què fa l’elecció de ruta', 'tours.preferences.info': 'Aquestes opcions ponderen el camí que tria el càlcul de ruta. On no hi ha cap altre camí, la ruta hi passa igualment. No diuen res de la dificultat d’un camí; el límit de dificultat de dalt fixa què pot fer servir la ruta.', + 'tours.preferences.avoid_climbs': 'Evitar pujades fortes', + 'tours.preferences.avoid_climbsHint': + "Camins més suaus on n'hi hagi, encara que siguin més llargs. On no hi ha volta possible, continua sent costerut.", 'tours.preferences.avoid_steps': 'Evita escales', 'tours.preferences.avoid_stepsHint': 'Escales només on no hi ha cap volta possible.', 'tours.preferences.avoid_ferries': 'Evita transbordadors', 'tours.preferences.avoid_ferriesHint': 'Per terra on hi hagi una volta, encara que sigui més llarga.', - 'tours.preferences.prefer_paths': 'Prefereix camins de vianants', - 'tours.preferences.prefer_pathsHint': 'Camins propis en lloc de carreteres i voreres, on n’hi hagi.', - 'tours.preferences.shortest': 'Més curt que ràpid', - 'tours.preferences.shortestHint': - 'El camí triat per la llargada, no pel temps; sovint una mica més curt, no sempre més ràpid.', 'tours.planner.tourType': 'Tipus de ruta', 'tours.paddle.boat': 'Embarcació', 'tours.paddle.boat.kayak': 'Caiac', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 7eafca240..694766527 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -664,6 +664,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Turistická mapa (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Nabízí OpenHikingMap jako podkladovou mapu v plánovači túr. Zdarma pro nekomerční instalace do 500 000 dlaždic měsíčně; komerční nebo větší instalace potřebuje nejdřív dohodu s openmaps.fr', + 'admin.toursCycleMap.title': 'Cyklomapa (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Nabízí CyclOSM jako podkladovou mapu pro cyklotúry v plánovači. OpenStreetMap France ji poskytuje pro přiměřené používání; silně využívaná nebo komerční instalace ji vypne, nebo se nejprve dohodne s provozovatelem', 'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů', diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts index 1cf8acf0c..747c8f43b 100644 --- a/shared/src/i18n/cs/map.ts +++ b/shared/src/i18n/cs/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satelit', 'map.baseLayer.hiking': 'Turistika', 'map.baseLayer.hikingDown': 'OpenHikingMap teď neodpovídá. Přepni na jinou mapu, tvoje túra zůstane beze změny.', + 'map.baseLayer.cycle': 'Cyklo', + 'map.baseLayer.cycleDown': 'CyclOSM teď neodpovídá. Přepni na jinou mapu, tvoje túra zůstává beze změny.', 'map.baseLayer.switchToSatellite': 'Přepnout na satelitní zobrazení', 'map.baseLayer.switchToDefault': 'Přepnout na mapové zobrazení', 'map.location.denied': diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 0726904ba..84004bd97 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -97,6 +97,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigace otevře výchozí bod túry; nevede po její trase.', 'tours.planner.title': 'Naplánovat výlet', 'tours.planner.newTour': 'Naplánovat nový výlet', + 'tours.planner.addTour': 'Přidat túru', + 'tours.planner.addTourHint': 'Naplánuj novou túru, importuj soubory GPX nebo převezmi túru z jiné cesty', 'tours.planner.neutralTitle': 'Naplánovat výlet', 'tours.planner.neutralBody': 'Začněte novou túru a kliknutím do mapy umístěte její body.', 'tours.planner.newTitle': 'Nový výlet', @@ -112,6 +114,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Rozbalit výškový profil', 'tours.planner.name': 'Název výletu', 'tours.planner.namePlaceholder': 'Túra bez názvu', + 'tours.planner.namePlaceholder.bike': 'Cyklotúra bez názvu', + 'tours.planner.namePlaceholder.kayak': 'Vodácký výlet bez názvu', 'tours.planner.mapSearch': 'Hledat místo nebo adresu', 'tours.planner.placePoints': 'Přidávat body', 'tours.planner.pointPlaced': 'Bod přidán', @@ -175,6 +179,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Čeká na výpočet', 'tours.planner.status.routing': 'Výpočet pěší trasy…', 'tours.planner.status.routingFailed': 'Výpočet trasy selhal. Stále se zobrazuje poslední platná trasa.', + 'tours.planner.status.unreachable': 'Bod {point} je nedosažitelný', + 'tours.planner.unreachable.hike': + 'K bodu {point} nevede žádná pěší cesta: nejbližší, kterou router zná, je {distance} daleko. Přesuň bod na cestu, nebo úsek k němu nakresli ručně.', + 'tours.planner.unreachable.bike': + 'K bodu {point} nevede žádná cesta pro kola: nejbližší, kterou router zná, je {distance} daleko. Přesuň bod na cestu, nebo úsek k němu nakresli ručně.', + 'tours.planner.unreachable.show': 'Ukázat bod {point}', 'tours.planner.status.elevation': 'Načítání výškových údajů…', 'tours.planner.status.elevationFailed': 'Výškové údaje nejsou dostupné. Vypočtená trasa se stále zobrazuje.', 'tours.planner.status.saving': 'Ukládání…', @@ -339,6 +349,62 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Pamětihodnosti', 'tours.poi.cat.slipway': 'Nástupní místa', 'tours.poi.cat.boat_rental': 'Půjčovna lodí', + 'tours.poi.cat.bike_repair': 'Oprava kol', + 'tours.planner.tourTypeHint.hike': 'Pěšky, do obtížnosti, kterou nastavíš', + 'tours.planner.tourTypeHint.bike': 'Na kole, trasa pro treking, silnici, gravel nebo MTB', + 'tours.planner.tourTypeHint.kayak': 'Na vodě, na kajaku, kánoi nebo SUP', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Treking', + 'tours.bike.profile.road': 'Silnice', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Upřednostňuje cyklostezky a klidné silnice, kde to jde zpevněné.', + 'tours.bike.profileHint.road': 'Drží se asfaltu; známé nezpevněné úseky se zobrazí.', + 'tours.bike.profileHint.gravel': 'Jezdí po štěrku a polních cestách, ne po technických singletrailech.', + 'tours.bike.profileHint.mtb': 'Bere drsné cesty, jak přijdou; obtížnost traťí se ukáže, kde je zmapovaná.', + 'tours.bike.timeEstimated': 'Doba jízdy (odhad)', + 'tours.bike.timeEstimatedShort': 'Doba jízdy', + 'tours.bike.timeEstimatedInfoLabel': 'Jak se počítá doba jízdy', + 'tours.bike.timeEstimatedInfo': + 'Čas routeru pro zvolený profil, s jeho rychlostí a stoupáním. Plánovací hodnota bez přestávek.', + 'tours.bike.ways.title': 'Povrch a cesty', + 'tours.bike.ways.surface': 'Povrch', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Hrubá dlažba', + 'tours.bike.ways.surface.compacted': 'Zhutněný', + 'tours.bike.ways.surface.gravel': 'Štěrk', + 'tours.bike.ways.surface.ground': 'Přírodní povrch', + 'tours.bike.ways.surface.unknown': 'Není v datech', + 'tours.bike.ways.way': 'Druh cesty', + 'tours.bike.ways.way.cyclePath': 'Cyklostezka', + 'tours.bike.ways.way.cycleLane': 'Cyklopruh vedle provozu', + 'tours.bike.ways.way.quiet': 'Klidná ulice', + 'tours.bike.ways.way.road': 'Silnice s provozem', + 'tours.bike.ways.way.track': 'Polní nebo lesní cesta', + 'tours.bike.ways.way.path': 'Stezka', + 'tours.bike.ways.way.ferry': 'Trajekt', + 'tours.bike.ways.way.other': 'Ostatní', + 'tours.bike.ways.loading': 'Načítají se cesty podél trasy…', + 'tours.bike.ways.offline': 'Povrch a obtížnost tratí potřebují připojení.', + 'tours.bike.ways.failed': 'Cesty podél trasy se nepodařilo načíst. Samotná trasa zůstává beze změny.', + 'tours.bike.ways.source': 'Z OpenStreetMap, jak je čte router. Kde data nic neříkají, je povrch neznámý, ne dobrý.', + 'tours.bike.ways.showOnMap': 'Ukázat na mapě', + 'tours.bike.ways.unpavedBadge': '{share} nezpevněno', + 'tours.bike.ways.unpavedNotice': '{length} trasy je podle dat nezpevněno.', + 'tours.bike.trail.title': 'Obtížnost trati (mtb:scale)', + 'tours.bike.trail.s0': 'S0, bez zvláštních obtíží', + 'tours.bike.trail.s1': 'S1, malé překážky', + 'tours.bike.trail.s2': 'S2, kořeny, schody, sypký povrch', + 'tours.bike.trail.s3': 'S3, bloky, prudké, úzké zatáčky', + 'tours.bike.trail.s4': 'S4 a více, terén pro experty', + 'tours.bike.trail.none': 'Bez stupně v datech', + 'tours.bike.trail.unasked': 'Tentokrát nešlo zjistit', + 'tours.bike.trail.uphillTitle': 'Obtížnost do kopce (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Do kopce {step}', + 'tours.bike.trail.note': + 'Stupně, jak je zadali mapovači OpenStreetMap. Cesta bez stupně je neznámá, ne snadná. Neříkají nic o tom, zda se trať hodí pro tebe.', + 'tours.bike.trail.loading': 'Načítá se obtížnost tratí…', + 'tours.bike.trail.failed': 'Obtížnost tratí se tentokrát nepodařilo načíst.', 'tours.copy.action': 'Uložit jako novou túru', 'tours.copy.hint': 'Zkopíruje tuto túru jako novou, samostatnou túru bez jejích dnů. Originál zůstane, jak je.', 'tours.copy.name': '{name} (kopie)', @@ -351,6 +417,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Ke stažení souboru GPX je potřeba připojení k serveru.', 'tours.gpxExport.noRoute': 'Tato túra nemá uloženou trasu k exportu.', 'tours.gpxExport.failed': 'Soubor GPX se nepodařilo vytvořit.', + 'tours.gpxExport.share': 'Sdílet GPX', + 'tours.gpxExport.shareShort': 'Sdílet', + 'tours.gpxExport.shareSaveFirst': 'Nejdřív túru ulož, pak ji můžeš sdílet jako GPX', + 'tours.gpxExport.shareReady': 'Soubor GPX je připravený. Klepni ještě jednou na Sdílet a otevře se nabídka sdílení.', + 'tours.gpxExport.shareFailed': 'Sdílení se nepovedlo. Soubor GPX si můžeš dál stáhnout.', 'tours.revision.remoteTitle': 'Tato túra byla uložena jinde', 'tours.revision.remoteBody': 'Někdo, nebo ty na jiném zařízení, ji uložil, zatímco ji upravuješ. Tvoje změny zůstanou, dokud se nerozhodneš.', @@ -381,14 +452,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Co volba trasy dělá', 'tours.preferences.info': 'Tato volba ovlivňuje, kterou cestu plánovač vybere. Kde jiná cesta není, vede trasa tudy i tak. O obtížnosti cesty neříká nic; co smí trasa použít, určuje limit obtížnosti výše.', + 'tours.preferences.avoid_climbs': 'Vyhnout se prudkým stoupáním', + 'tours.preferences.avoid_climbsHint': + 'Mírnější cesty, kde existují, i když jsou delší. Kde se nedá obejít, zůstává to strmé.', 'tours.preferences.avoid_steps': 'Vyhnout se schodům', 'tours.preferences.avoid_stepsHint': 'Schody jen tam, kde není cesta kolem.', 'tours.preferences.avoid_ferries': 'Vyhnout se přívozům', 'tours.preferences.avoid_ferriesHint': 'Po souši, kde vede cesta kolem, i když je delší.', - 'tours.preferences.prefer_paths': 'Upřednostnit pěšiny', - 'tours.preferences.prefer_pathsHint': 'Samostatné cesty místo silnic a jejich chodníků, kde jsou.', - 'tours.preferences.shortest': 'Kratší místo rychlejší', - 'tours.preferences.shortestHint': 'Cesta vybraná podle délky, ne podle času; často o něco kratší, ne vždy rychlejší.', 'tours.planner.tourType': 'Typ túry', 'tours.paddle.boat': 'Loď', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 849fe9730..c4f7bbff1 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -669,6 +669,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Wanderkarte (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Bietet OpenHikingMap als Grundkarte im Tourplaner an. Kostenlos für nichtkommerzielle Installationen bis 500.000 Kacheln im Monat; eine kommerzielle oder größere Installation braucht vorher eine Vereinbarung mit openmaps.fr', + 'admin.toursCycleMap.title': 'Fahrradkarte (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Bietet CyclOSM als Grundkarte für Radtouren im Tourplaner an. OpenStreetMap France stellt sie für moderate Nutzung bereit; eine stark genutzte oder kommerzielle Installation schaltet sie ab oder trifft vorher eine Vereinbarung mit dem Betreiber', 'admin.addons.noAddons': 'Keine Addons verfügbar', 'admin.weather.title': 'Wetterdaten', 'admin.weather.badge': 'Seit 24. März 2026', diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts index 67bace502..f9639b9f3 100644 --- a/shared/src/i18n/de/map.ts +++ b/shared/src/i18n/de/map.ts @@ -21,6 +21,9 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Wandern', 'map.baseLayer.hikingDown': 'OpenHikingMap antwortet gerade nicht. Wechsle auf eine andere Karte, deine Tour bleibt unverändert.', + 'map.baseLayer.cycle': 'Rad', + 'map.baseLayer.cycleDown': + 'CyclOSM antwortet gerade nicht. Wechsle auf eine andere Karte, deine Tour bleibt unverändert.', 'map.baseLayer.switchToSatellite': 'Zur Satellitenansicht wechseln', 'map.baseLayer.switchToDefault': 'Zur Kartenansicht wechseln', 'map.location.denied': diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index b9dac7044..0f7eca06e 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -97,6 +97,9 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Die Navigation führt zum Startpunkt, nicht entlang der Tour.', 'tours.planner.title': 'Tour planen', 'tours.planner.newTour': 'Neue Tour planen', + 'tours.planner.addTour': 'Tour hinzufügen', + 'tours.planner.addTourHint': + 'Neue Tour planen, GPX-Dateien importieren oder eine Tour aus einer anderen Reise übernehmen', 'tours.planner.neutralTitle': 'Tour planen', 'tours.planner.neutralBody': 'Starte eine neue Tour und klicke auf die Karte, um die Wegpunkte zu setzen.', 'tours.planner.newTitle': 'Neue Tour', @@ -113,6 +116,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Höhenprofil ausklappen', 'tours.planner.name': 'Name der Tour', 'tours.planner.namePlaceholder': 'Wanderung ohne Titel', + 'tours.planner.namePlaceholder.bike': 'Radtour ohne Namen', + 'tours.planner.namePlaceholder.kayak': 'Paddeltour ohne Namen', 'tours.planner.mapSearch': 'Ort oder Adresse suchen', 'tours.planner.placePoints': 'Punkte setzen', 'tours.planner.pointPlaced': 'Punkt gesetzt', @@ -177,6 +182,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Wanderroute wird berechnet...', 'tours.planner.status.routingFailed': 'Route konnte nicht berechnet werden. Die letzte gültige Route bleibt sichtbar.', + 'tours.planner.status.unreachable': 'Punkt {point} ist nicht erreichbar', + 'tours.planner.unreachable.hike': + 'Kein Wanderweg erreicht Punkt {point}: der nächste, den der Router kennt, liegt {distance} entfernt. Setz den Punkt auf einen Weg oder zeichne den Abschnitt dorthin von Hand.', + 'tours.planner.unreachable.bike': + 'Kein Weg für Räder erreicht Punkt {point}: der nächste, den der Router kennt, liegt {distance} entfernt. Setz den Punkt auf einen Weg oder zeichne den Abschnitt dorthin von Hand.', + 'tours.planner.unreachable.show': 'Punkt {point} zeigen', 'tours.planner.status.elevation': 'Höhendaten werden geladen...', 'tours.planner.status.elevationFailed': 'Höhendaten sind nicht verfügbar. Die berechnete Route bleibt sichtbar.', 'tours.planner.status.saving': 'Tour wird gespeichert...', @@ -348,6 +359,64 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Sehenswertes', 'tours.poi.cat.slipway': 'Einsetzstellen', 'tours.poi.cat.boat_rental': 'Bootsverleih', + 'tours.poi.cat.bike_repair': 'Radwerkstatt', + 'tours.planner.tourTypeHint.hike': 'Zu Fuß, bis zu einer Schwierigkeit, die du festlegst', + 'tours.planner.tourTypeHint.bike': 'Mit dem Rad, geroutet für Touring, Rennrad, Gravel oder MTB', + 'tours.planner.tourTypeHint.kayak': 'Auf dem Wasser, mit Kajak, Kanadier oder SUP', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Touring', // same-as-en + 'tours.bike.profile.road': 'Rennrad', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Bevorzugt Radwege und ruhige Straßen, wo es geht befestigt.', + 'tours.bike.profileHint.road': 'Bleibt auf Asphalt; bekannte unbefestigte Abschnitte werden angezeigt.', + 'tours.bike.profileHint.gravel': 'Nimmt Schotter und Wirtschaftswege, keine technischen Singletrails.', + 'tours.bike.profileHint.mtb': 'Nimmt raue Wege, wie sie kommen; Trail-Schwierigkeiten werden gezeigt, wo erfasst.', + 'tours.bike.timeEstimated': 'Fahrzeit (geschätzt)', + 'tours.bike.timeEstimatedShort': 'Fahrzeit', + 'tours.bike.timeEstimatedInfoLabel': 'Wie die Fahrzeit berechnet wird', + 'tours.bike.timeEstimatedInfo': + 'Die Zeit des Routers für das gewählte Profil, mit seinem Tempo und den Anstiegen. Ein Planungswert, ohne Pausen.', + 'tours.bike.ways.title': 'Belag und Wege', + 'tours.bike.ways.surface': 'Belag', + 'tours.bike.ways.surface.paved': 'Asphalt', // same-as-en + 'tours.bike.ways.surface.rough': 'Grobes Pflaster', + 'tours.bike.ways.surface.compacted': 'Verdichtet', + 'tours.bike.ways.surface.gravel': 'Schotter', + 'tours.bike.ways.surface.ground': 'Naturboden', + 'tours.bike.ways.surface.unknown': 'Nicht in den Daten', + 'tours.bike.ways.way': 'Wegart', + 'tours.bike.ways.way.cyclePath': 'Radweg', + 'tours.bike.ways.way.cycleLane': 'Radspur neben dem Verkehr', + 'tours.bike.ways.way.quiet': 'Ruhige Straße', + 'tours.bike.ways.way.road': 'Straße mit Verkehr', + 'tours.bike.ways.way.track': 'Wirtschaftsweg', + 'tours.bike.ways.way.path': 'Pfad', + 'tours.bike.ways.way.ferry': 'Fähre', + 'tours.bike.ways.way.other': 'Sonstige', + 'tours.bike.ways.loading': 'Wege entlang der Route werden gelesen…', + 'tours.bike.ways.offline': 'Belag und Trail-Schwierigkeit brauchen eine Verbindung.', + 'tours.bike.ways.failed': + 'Die Wege entlang der Route konnten nicht gelesen werden. Die Route selbst bleibt unverändert.', + 'tours.bike.ways.source': + 'Aus OpenStreetMap, wie der Router es liest. Wo die Daten nichts sagen, ist der Belag unbekannt, nicht gut.', + 'tours.bike.ways.showOnMap': 'Auf der Karte zeigen', + 'tours.bike.ways.unpavedBadge': '{share} unbefestigt', + 'tours.bike.ways.unpavedNotice': '{length} der Route sind bekannt unbefestigt.', + 'tours.bike.trail.title': 'Trail-Schwierigkeit (mtb:scale)', + 'tours.bike.trail.s0': 'S0, keine besonderen Schwierigkeiten', + 'tours.bike.trail.s1': 'S1, kleine Hindernisse', + 'tours.bike.trail.s2': 'S2, Wurzeln, Stufen, loser Boden', + 'tours.bike.trail.s3': 'S3, Blöcke, steil, enge Kehren', + 'tours.bike.trail.s4': 'S4 und mehr, Expertengelände', + 'tours.bike.trail.none': 'Keine Angabe in den Daten', + 'tours.bike.trail.unasked': 'Diesmal nicht abfragbar', + 'tours.bike.trail.uphillTitle': 'Bergauf-Schwierigkeit (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Bergauf {step}', + 'tours.bike.trail.note': + 'Schwierigkeiten, wie OpenStreetMap-Mapper sie vergeben haben. Ein Weg ohne Angabe ist unbekannt, nicht leicht. Sie sagen nichts darüber, ob ein Trail zu dir passt.', + 'tours.bike.trail.loading': 'Trail-Schwierigkeiten werden gelesen…', + 'tours.bike.trail.failed': 'Die Trail-Schwierigkeiten konnten diesmal nicht gelesen werden.', 'tours.copy.action': 'Als neue Tour speichern', 'tours.copy.hint': 'Kopiert diese Tour als neue, eigenständige Tour ohne ihre Tage. Das Original bleibt, wie es ist.', 'tours.copy.name': '{name} (Kopie)', @@ -360,6 +429,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Für den GPX-Download braucht es eine Verbindung zum Server.', 'tours.gpxExport.noRoute': 'Diese Tour hat keine gespeicherte Route zum Exportieren.', 'tours.gpxExport.failed': 'Die GPX-Datei konnte nicht erstellt werden.', + 'tours.gpxExport.share': 'GPX teilen', + 'tours.gpxExport.shareShort': 'Teilen', + 'tours.gpxExport.shareSaveFirst': 'Erst speichern, dann als GPX teilen', + 'tours.gpxExport.shareReady': + 'Die GPX-Datei ist bereit. Tippe noch einmal auf Teilen, um den Teilen-Dialog zu öffnen.', + 'tours.gpxExport.shareFailed': 'Teilen hat nicht geklappt. Die GPX-Datei lässt sich weiterhin herunterladen.', 'tours.revision.remoteTitle': 'Diese Tour wurde woanders gespeichert', 'tours.revision.remoteBody': 'Jemand, oder du auf einem anderen Gerät, hat sie gespeichert, während du sie bearbeitest. Deine Änderungen bleiben, bis du entscheidest.', @@ -390,14 +465,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Was die Routenwahl bewirkt', 'tours.preferences.info': 'Diese Wahl gewichtet, welchen Weg der Router nimmt. Gibt es keinen anderen Weg, führt die Route trotzdem dort entlang. Über die Schwierigkeit eines Weges sagt sie nichts; was die Route nutzen darf, legt das Schwierigkeitslimit darüber fest.', + 'tours.preferences.avoid_climbs': 'Steigungen meiden', + 'tours.preferences.avoid_climbsHint': + 'Flachere Wege, wo es sie gibt, auch wenn sie länger sind. Wo kein Weg herumführt, bleibt es steil.', 'tours.preferences.avoid_steps': 'Treppen meiden', 'tours.preferences.avoid_stepsHint': 'Treppen nur, wo es keinen Weg außen herum gibt.', 'tours.preferences.avoid_ferries': 'Fähren meiden', 'tours.preferences.avoid_ferriesHint': 'Über Land, wo es einen Weg außen herum gibt, auch wenn er länger ist.', - 'tours.preferences.prefer_paths': 'Fußwege bevorzugen', - 'tours.preferences.prefer_pathsHint': 'Eigene Wege statt Straßen und ihrer Gehsteige, wo es sie gibt.', - 'tours.preferences.shortest': 'Kürzer statt schneller', - 'tours.preferences.shortestHint': 'Der Weg nach Länge statt nach Gehzeit; oft etwas kürzer, nicht immer schneller.', 'tours.planner.tourType': 'Tourtyp', 'tours.paddle.boat': 'Boot', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 8b93d0263..67a1d0780 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -713,6 +713,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Hiking map (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Offers OpenHikingMap as a base map in the tour planner. Free for non-commercial installs up to 500,000 tiles a month; a commercial or larger install needs an agreement with openmaps.fr first', + 'admin.toursCycleMap.title': 'Cycling map (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Offers CyclOSM as a base map for bike tours in the tour planner. OpenStreetMap France serves it for moderate use; a heavy or commercial install switches it off or reaches an agreement with the operator first', 'admin.addons.noAddons': 'No addons available', 'admin.weather.title': 'Weather Data', 'admin.weather.badge': 'Since March 24, 2026', diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts index 7b46c1ebf..18ab42d7c 100644 --- a/shared/src/i18n/en/map.ts +++ b/shared/src/i18n/en/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Hiking', 'map.baseLayer.hikingDown': 'OpenHikingMap is not answering right now. Switch to another map; your tour stays as it is.', + 'map.baseLayer.cycle': 'Cycling', + 'map.baseLayer.cycleDown': 'CyclOSM is not answering right now. Switch to another map; your tour stays as it is.', 'map.baseLayer.switchToSatellite': 'Switch to satellite view', 'map.baseLayer.switchToDefault': 'Switch to map view', 'map.location.denied': diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 4644d28bb..8e9525124 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -93,6 +93,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigation opens the trailhead; it does not navigate along the route.', 'tours.planner.title': 'Plan a tour', 'tours.planner.newTour': 'Plan new tour', + 'tours.planner.addTour': 'Add tour', + 'tours.planner.addTourHint': 'Plan a new tour, import GPX files or take a tour over from another trip', 'tours.planner.neutralTitle': 'Plan a tour', 'tours.planner.neutralBody': 'Start a new tour and click the map to place its waypoints.', 'tours.planner.newTitle': 'New tour', @@ -109,6 +111,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Expand elevation profile', 'tours.planner.name': 'Tour name', 'tours.planner.namePlaceholder': 'Untitled hike', + 'tours.planner.namePlaceholder.bike': 'Untitled bike tour', + 'tours.planner.namePlaceholder.kayak': 'Untitled paddle tour', 'tours.planner.mapSearch': 'Search place or address', 'tours.planner.placePoints': 'Set points', 'tours.planner.pointPlaced': 'Point set', @@ -171,6 +175,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Waiting to calculate', 'tours.planner.status.routing': 'Calculating walking route…', 'tours.planner.status.routingFailed': 'Route calculation failed. The last good route is still shown.', + 'tours.planner.status.unreachable': 'Point {point} cannot be reached', + 'tours.planner.unreachable.hike': + 'No path for hikes reaches point {point}: the nearest the router knows is {distance} away. Move the point onto a path, or draw the leg to it by hand.', + 'tours.planner.unreachable.bike': + 'No way for bikes reaches point {point}: the nearest the router knows is {distance} away. Move the point onto a way, or draw the leg to it by hand.', + 'tours.planner.unreachable.show': 'Show point {point}', 'tours.planner.status.elevation': 'Loading elevation…', 'tours.planner.status.elevationFailed': 'Elevation is unavailable. The calculated route is still shown.', 'tours.planner.status.saving': 'Saving…', @@ -339,6 +349,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Sights', 'tours.poi.cat.slipway': 'Put-ins', 'tours.poi.cat.boat_rental': 'Boat rental', + 'tours.poi.cat.bike_repair': 'Bike repair', + 'tours.planner.tourTypeHint.hike': 'On foot, within a difficulty you set', + 'tours.planner.tourTypeHint.bike': 'By bike, routed for touring, road, gravel or MTB', + 'tours.planner.tourTypeHint.kayak': 'On the water, by kayak, canoe or SUP', + 'tours.bike.profile': 'Profile', + 'tours.bike.profile.touring': 'Touring', + 'tours.bike.profile.road': 'Road', + 'tours.bike.profile.gravel': 'Gravel', + 'tours.bike.profile.mtb': 'MTB', + 'tours.bike.profileHint.touring': 'Prefers cycle paths and quiet roads, paved where it can.', + 'tours.bike.profileHint.road': 'Keeps to asphalt; known unpaved stretches are shown.', + 'tours.bike.profileHint.gravel': 'Takes gravel and farm tracks, not technical singletrails.', + 'tours.bike.profileHint.mtb': 'Takes rough tracks as they come; trail grades are shown where mapped.', + 'tours.bike.timeEstimated': 'Riding time (estimated)', + 'tours.bike.timeEstimatedShort': 'Riding time', + 'tours.bike.timeEstimatedInfoLabel': 'How the riding time is worked out', + 'tours.bike.timeEstimatedInfo': + "The router's time for the profile chosen, its speed and the climbs weighed in. A planning value, breaks not included.", + 'tours.bike.ways.title': 'Surface and ways', + 'tours.bike.ways.surface': 'Surface', + 'tours.bike.ways.surface.paved': 'Asphalt', + 'tours.bike.ways.surface.rough': 'Rough paving', + 'tours.bike.ways.surface.compacted': 'Compacted', + 'tours.bike.ways.surface.gravel': 'Gravel', + 'tours.bike.ways.surface.ground': 'Natural ground', + 'tours.bike.ways.surface.unknown': 'Not in the data', + 'tours.bike.ways.way': 'Kind of way', + 'tours.bike.ways.way.cyclePath': 'Cycle path', + 'tours.bike.ways.way.cycleLane': 'Cycle lane beside traffic', + 'tours.bike.ways.way.quiet': 'Quiet street', + 'tours.bike.ways.way.road': 'Road with traffic', + 'tours.bike.ways.way.track': 'Farm or forest track', + 'tours.bike.ways.way.path': 'Path', + 'tours.bike.ways.way.ferry': 'Ferry', + 'tours.bike.ways.way.other': 'Other', + 'tours.bike.ways.loading': 'Reading the ways along the route…', + 'tours.bike.ways.offline': 'Surfaces and trail grades need a connection.', + 'tours.bike.ways.failed': 'The ways along the route could not be read. The route itself is unaffected.', + 'tours.bike.ways.source': + 'From OpenStreetMap, as the router reads it. Where the data says nothing, the surface is unknown, not fine.', + 'tours.bike.ways.showOnMap': 'Show on the map', + 'tours.bike.ways.unpavedBadge': '{share} unpaved', + 'tours.bike.ways.unpavedNotice': '{length} of the route is known to be unpaved.', + 'tours.bike.trail.title': 'Trail grade (mtb:scale)', + 'tours.bike.trail.s0': 'S0, no particular difficulty', + 'tours.bike.trail.s1': 'S1, small obstacles', + 'tours.bike.trail.s2': 'S2, roots, steps, loose ground', + 'tours.bike.trail.s3': 'S3, blocks, steep, tight bends', + 'tours.bike.trail.s4': 'S4 and up, expert ground', + 'tours.bike.trail.none': 'No grade in the data', + 'tours.bike.trail.unasked': 'Could not be asked this time', + 'tours.bike.trail.uphillTitle': 'Uphill grade (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Uphill {step}', + 'tours.bike.trail.note': + 'Grades as OpenStreetMap mappers gave them. A way without one is unknown, not easy. They say nothing about whether a trail suits you.', + 'tours.bike.trail.loading': 'Reading the trail grades…', + 'tours.bike.trail.failed': 'The trail grades could not be read this time.', 'tours.copy.action': 'Save as new tour', 'tours.copy.hint': 'Copies this tour as a new, independent tour without its days. The original stays as it is.', 'tours.copy.name': '{name} (copy)', @@ -351,6 +418,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Downloading a GPX file needs a connection to the server.', 'tours.gpxExport.noRoute': 'This tour has no saved route to export.', 'tours.gpxExport.failed': 'The GPX file could not be created.', + 'tours.gpxExport.share': 'Share GPX', + 'tours.gpxExport.shareShort': 'Share', + 'tours.gpxExport.shareSaveFirst': 'Save the tour first to share it as GPX', + 'tours.gpxExport.shareReady': 'The GPX file is ready. Tap share once more to open the share sheet.', + 'tours.gpxExport.shareFailed': 'Sharing did not work. The GPX file can still be downloaded.', 'tours.revision.remoteTitle': 'This tour was saved elsewhere', 'tours.revision.remoteBody': 'Someone, or you on another device, saved it while you were editing. Your changes are kept until you decide.', @@ -381,15 +453,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'What the route choice does', 'tours.preferences.info': 'These choices weigh the way the router picks. Where there is no other way, the route still goes there. They say nothing about how difficult a path is; the difficulty limit above sets what the route may use.', + 'tours.preferences.avoid_climbs': 'Avoid steep climbs', + 'tours.preferences.avoid_climbsHint': + 'Gentler ways where there are some, even when they are longer. Where there is no way round, it stays steep.', 'tours.preferences.avoid_steps': 'Avoid steps', 'tours.preferences.avoid_stepsHint': 'Steps and stairs only where there is no way round.', 'tours.preferences.avoid_ferries': 'Avoid ferries', 'tours.preferences.avoid_ferriesHint': 'Over land where there is a way round, even when it is longer.', - 'tours.preferences.prefer_paths': 'Prefer footpaths', - 'tours.preferences.prefer_pathsHint': 'Paths of their own over roads and their pavements, where there are some.', - 'tours.preferences.shortest': 'Shorter over faster', - 'tours.preferences.shortestHint': - 'The way chosen by its length, not by the time it takes; often a little shorter, not always quicker.', 'tours.planner.tourType': 'Tour type', 'tours.paddle.boat': 'Boat', 'tours.paddle.boat.kayak': 'Kayak', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 7464b24f0..c1a377952 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -652,6 +652,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Mapa de senderismo (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Ofrece OpenHikingMap como mapa base en el planificador de rutas. Gratis para instalaciones no comerciales hasta 500.000 teselas al mes; una instalación comercial o mayor necesita antes un acuerdo con openmaps.fr', + 'admin.toursCycleMap.title': 'Mapa ciclista (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Ofrece CyclOSM como mapa base para rutas en bici en el planificador. OpenStreetMap France lo sirve para un uso moderado; una instalación de uso intenso o comercial lo desactiva o llega antes a un acuerdo con el operador', 'admin.addons.noAddons': 'No hay complementos disponibles', 'admin.weather.title': 'Datos meteorológicos', 'admin.weather.badge': 'Desde el 24 de marzo de 2026', diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts index ad19a58df..dbafe4cf2 100644 --- a/shared/src/i18n/es/map.ts +++ b/shared/src/i18n/es/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satélite', 'map.baseLayer.hiking': 'Senderismo', 'map.baseLayer.hikingDown': 'OpenHikingMap no responde ahora mismo. Cambia a otro mapa; tu ruta se queda como está.', + 'map.baseLayer.cycle': 'Bici', + 'map.baseLayer.cycleDown': 'CyclOSM no responde ahora mismo. Cambia a otro mapa; tu ruta sigue igual.', 'map.baseLayer.switchToSatellite': 'Cambiar a vista de satélite', 'map.baseLayer.switchToDefault': 'Cambiar a vista de mapa', 'map.location.denied': diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 1b4f8c528..e27b7f3c6 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -98,6 +98,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'La navegación abre el punto de inicio; no guía a lo largo de la ruta.', 'tours.planner.title': 'Planificar una excursión', 'tours.planner.newTour': 'Planificar nueva excursión', + 'tours.planner.addTour': 'Añadir ruta', + 'tours.planner.addTourHint': 'Planifica una ruta nueva, importa archivos GPX o trae una ruta de otro viaje', 'tours.planner.neutralTitle': 'Planificar una excursión', 'tours.planner.neutralBody': 'Empieza una ruta nueva y haz clic en el mapa para colocar sus puntos.', 'tours.planner.newTitle': 'Nueva excursión', @@ -114,6 +116,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Expandir perfil de altitud', 'tours.planner.name': 'Nombre de la excursión', 'tours.planner.namePlaceholder': 'Ruta de senderismo sin título', + 'tours.planner.namePlaceholder.bike': 'Ruta en bici sin nombre', + 'tours.planner.namePlaceholder.kayak': 'Ruta en kayak sin nombre', 'tours.planner.mapSearch': 'Buscar lugar o dirección', 'tours.planner.placePoints': 'Colocar puntos', 'tours.planner.pointPlaced': 'Punto colocado', @@ -177,6 +181,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Pendiente de cálculo', 'tours.planner.status.routing': 'Calculando ruta a pie…', 'tours.planner.status.routingFailed': 'No se ha podido calcular la ruta. Se sigue mostrando la última ruta válida.', + 'tours.planner.status.unreachable': 'No se puede llegar al punto {point}', + 'tours.planner.unreachable.hike': + 'Ningún sendero llega al punto {point}: el más cercano que conoce el enrutador está a {distance}. Mueve el punto a un camino o dibuja el tramo hasta él a mano.', + 'tours.planner.unreachable.bike': + 'Ninguna vía para bicis llega al punto {point}: la más cercana que conoce el enrutador está a {distance}. Mueve el punto a una vía o dibuja el tramo hasta él a mano.', + 'tours.planner.unreachable.show': 'Mostrar el punto {point}', 'tours.planner.status.elevation': 'Cargando altitud…', 'tours.planner.status.elevationFailed': 'La altitud no está disponible. Se sigue mostrando la ruta calculada.', 'tours.planner.status.saving': 'Guardando…', @@ -345,6 +355,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Lugares de interés', 'tours.poi.cat.slipway': 'Embarcaderos', 'tours.poi.cat.boat_rental': 'Alquiler de barcas', + 'tours.poi.cat.bike_repair': 'Reparación de bicis', + 'tours.planner.tourTypeHint.hike': 'A pie, dentro de una dificultad que tú eliges', + 'tours.planner.tourTypeHint.bike': 'En bici, con ruta para cicloturismo, carretera, gravel o MTB', + 'tours.planner.tourTypeHint.kayak': 'En el agua, en kayak, canoa o SUP', + 'tours.bike.profile': 'Perfil', + 'tours.bike.profile.touring': 'Cicloturismo', + 'tours.bike.profile.road': 'Carretera', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Prefiere carriles bici y calles tranquilas, pavimentadas cuando se puede.', + 'tours.bike.profileHint.road': 'Se mantiene en el asfalto; se muestran los tramos sin pavimentar conocidos.', + 'tours.bike.profileHint.gravel': 'Toma grava y caminos rurales, no senderos técnicos.', + 'tours.bike.profileHint.mtb': 'Toma caminos duros tal como vienen; se muestran los grados donde están mapeados.', + 'tours.bike.timeEstimated': 'Tiempo en bici (estimado)', + 'tours.bike.timeEstimatedShort': 'Tiempo en bici', + 'tours.bike.timeEstimatedInfoLabel': 'Cómo se calcula el tiempo en bici', + 'tours.bike.timeEstimatedInfo': + 'El tiempo del enrutador para el perfil elegido, con su velocidad y las subidas. Un valor de planificación, sin pausas.', + 'tours.bike.ways.title': 'Firme y vías', + 'tours.bike.ways.surface': 'Firme', + 'tours.bike.ways.surface.paved': 'Asfalto', + 'tours.bike.ways.surface.rough': 'Pavimento irregular', + 'tours.bike.ways.surface.compacted': 'Compactado', + 'tours.bike.ways.surface.gravel': 'Grava', + 'tours.bike.ways.surface.ground': 'Tierra natural', + 'tours.bike.ways.surface.unknown': 'No consta en los datos', + 'tours.bike.ways.way': 'Tipo de vía', + 'tours.bike.ways.way.cyclePath': 'Carril bici', + 'tours.bike.ways.way.cycleLane': 'Carril bici junto al tráfico', + 'tours.bike.ways.way.quiet': 'Calle tranquila', + 'tours.bike.ways.way.road': 'Carretera con tráfico', + 'tours.bike.ways.way.track': 'Camino agrícola o forestal', + 'tours.bike.ways.way.path': 'Sendero', + 'tours.bike.ways.way.ferry': 'Ferri', + 'tours.bike.ways.way.other': 'Otros', + 'tours.bike.ways.loading': 'Leyendo las vías a lo largo de la ruta…', + 'tours.bike.ways.offline': 'El firme y los grados de los senderos necesitan conexión.', + 'tours.bike.ways.failed': 'No se pudieron leer las vías de la ruta. La ruta en sí no cambia.', + 'tours.bike.ways.source': + 'De OpenStreetMap, tal como lo lee el enrutador. Donde los datos no dicen nada, el firme es desconocido, no bueno.', + 'tours.bike.ways.showOnMap': 'Mostrar en el mapa', + 'tours.bike.ways.unpavedBadge': '{share} sin pavimentar', + 'tours.bike.ways.unpavedNotice': 'Se sabe que {length} de la ruta no están pavimentados.', + 'tours.bike.trail.title': 'Grado del sendero (mtb:scale)', + 'tours.bike.trail.s0': 'S0, sin dificultad especial', + 'tours.bike.trail.s1': 'S1, obstáculos pequeños', + 'tours.bike.trail.s2': 'S2, raíces, escalones, suelo suelto', + 'tours.bike.trail.s3': 'S3, bloques, pendiente fuerte, curvas cerradas', + 'tours.bike.trail.s4': 'S4 o más, terreno de expertos', + 'tours.bike.trail.none': 'Sin grado en los datos', + 'tours.bike.trail.unasked': 'No se pudo consultar esta vez', + 'tours.bike.trail.uphillTitle': 'Grado de subida (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Subida {step}', + 'tours.bike.trail.note': + 'Grados tal como los dieron los mapeadores de OpenStreetMap. Una vía sin grado es desconocida, no fácil. No dicen nada sobre si un sendero te conviene.', + 'tours.bike.trail.loading': 'Leyendo los grados de los senderos…', + 'tours.bike.trail.failed': 'Esta vez no se pudieron leer los grados de los senderos.', 'tours.copy.action': 'Guardar como nueva excursión', 'tours.copy.hint': 'Copia esta excursión como una nueva excursión independiente, sin sus días. El original queda como está.', @@ -358,6 +425,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Para descargar un archivo GPX hace falta conexión con el servidor.', 'tours.gpxExport.noRoute': 'Esta excursión no tiene una ruta guardada que exportar.', 'tours.gpxExport.failed': 'No se pudo crear el archivo GPX.', + 'tours.gpxExport.share': 'Compartir GPX', + 'tours.gpxExport.shareShort': 'Compartir', + 'tours.gpxExport.shareSaveFirst': 'Guarda primero la ruta para compartirla como GPX', + 'tours.gpxExport.shareReady': 'El archivo GPX está listo. Toca Compartir otra vez para abrir el menú de compartir.', + 'tours.gpxExport.shareFailed': 'No se pudo compartir. El archivo GPX se puede seguir descargando.', 'tours.revision.remoteTitle': 'Esta excursión se guardó en otro lugar', 'tours.revision.remoteBody': 'Alguien, o tú en otro dispositivo, la guardó mientras la editabas. Tus cambios se mantienen hasta que decidas.', @@ -388,15 +460,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Qué hace la elección de ruta', 'tours.preferences.info': 'Estas opciones ponderan el camino que elige el enrutador. Donde no hay otro camino, la ruta pasa igualmente por ahí. No dicen nada sobre la dificultad de un camino; el límite de dificultad de arriba fija lo que la ruta puede usar.', + 'tours.preferences.avoid_climbs': 'Evitar subidas pronunciadas', + 'tours.preferences.avoid_climbsHint': + 'Caminos más suaves donde los haya, aunque sean más largos. Donde no hay forma de rodear, sigue siendo empinado.', 'tours.preferences.avoid_steps': 'Evitar escaleras', 'tours.preferences.avoid_stepsHint': 'Escaleras solo donde no hay forma de rodearlas.', 'tours.preferences.avoid_ferries': 'Evitar ferris', 'tours.preferences.avoid_ferriesHint': 'Por tierra donde haya una forma de rodear, aunque sea más larga.', - 'tours.preferences.prefer_paths': 'Preferir senderos peatonales', - 'tours.preferences.prefer_pathsHint': 'Caminos propios en lugar de carreteras y aceras, donde los haya.', - 'tours.preferences.shortest': 'Más corto antes que más rápido', - 'tours.preferences.shortestHint': - 'El camino elegido por su longitud, no por el tiempo; a menudo algo más corto, no siempre más rápido.', 'tours.planner.tourType': 'Tipo de ruta', 'tours.paddle.boat': 'Embarcación', 'tours.paddle.boat.kayak': 'Kayak', // same-as-en diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 4d0b00554..625007337 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -733,6 +733,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Matkakaart (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Pakub OpenHikingMapi matkaplaneerija aluskaardina. Mitteäriliste paigalduste jaoks tasuta kuni 500 000 paani kuus; äriline või suurem paigaldus vajab enne kokkulepet openmaps.fr-iga', + 'admin.toursCycleMap.title': 'Rattakaart (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Pakub CyclOSM-i rattamatkade aluskaardina matkaplaneerijas. OpenStreetMap France pakub seda mõõdukaks kasutuseks; tugeva kasutusega või äriline paigaldus lülitab selle välja või lepib enne operaatoriga kokku', 'admin.addons.noAddons': 'Lisamooduleid pole saadaval', 'admin.weather.title': 'Ilmaandmed', 'admin.weather.badge': 'Alates 24. märtsist 2026', diff --git a/shared/src/i18n/et/map.ts b/shared/src/i18n/et/map.ts index 74767a3db..a209aea04 100644 --- a/shared/src/i18n/et/map.ts +++ b/shared/src/i18n/et/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satelliit', 'map.baseLayer.hiking': 'Matkamine', 'map.baseLayer.hikingDown': 'OpenHikingMap ei vasta praegu. Vaheta teisele kaardile, sinu matk jääb samaks.', + 'map.baseLayer.cycle': 'Ratas', + 'map.baseLayer.cycleDown': 'CyclOSM ei vasta praegu. Vaheta teise kaardi vastu, sinu matk jääb samaks.', 'map.baseLayer.switchToSatellite': 'Lülitu satelliidivaatele', 'map.baseLayer.switchToDefault': 'Lülitu kaardivaatele', 'map.location.denied': diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 98c816ac5..b5bdabf26 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -94,6 +94,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigeerimine avab raja alguspunkti, mitte juhiseid mööda marsruuti.', 'tours.planner.title': 'Planeeri retk', 'tours.planner.newTour': 'Planeeri uus retk', + 'tours.planner.addTour': 'Lisa matk', + 'tours.planner.addTourHint': 'Planeeri uus matk, impordi GPX-faile või võta matk üle teiselt reisilt', 'tours.planner.neutralTitle': 'Planeeri retk', 'tours.planner.neutralBody': 'Alusta uut matka ja klõpsa kaardil, et selle punktid paigutada.', 'tours.planner.newTitle': 'Uus retk', @@ -110,6 +112,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Laienda kõrgusprofiil', 'tours.planner.name': 'Retke nimi', 'tours.planner.namePlaceholder': 'Nimetu matk', + 'tours.planner.namePlaceholder.bike': 'Nimetu rattamatk', + 'tours.planner.namePlaceholder.kayak': 'Nimetu aerutusmatk', 'tours.planner.mapSearch': 'Otsi kohta või aadressi', 'tours.planner.placePoints': 'Lisa punkte', 'tours.planner.pointPlaced': 'Punkt lisatud', @@ -174,6 +178,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Jalgsimarsruudi arvutamine…', 'tours.planner.status.routingFailed': 'Marsruudi arvutamine ebaõnnestus. Viimane õnnestunud marsruut on endiselt nähtav.', + 'tours.planner.status.unreachable': 'Punkti {point} ei saa jõuda', + 'tours.planner.unreachable.hike': + 'Ükski matkarada ei jõua punktini {point}: lähim, mida marsruutija teab, on {distance} kaugusel. Liiguta punkt rajale või joonista lõik sinna käsitsi.', + 'tours.planner.unreachable.bike': + 'Ükski rattatee ei jõua punktini {point}: lähim, mida marsruutija teab, on {distance} kaugusel. Liiguta punkt teele või joonista lõik sinna käsitsi.', + 'tours.planner.unreachable.show': 'Näita punkti {point}', 'tours.planner.status.elevation': 'Kõrgusandmete laadimine…', 'tours.planner.status.elevationFailed': 'Kõrgusandmed pole saadaval. Arvutatud marsruut on endiselt nähtav.', 'tours.planner.status.saving': 'Salvestamine…', @@ -342,6 +352,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Vaatamisväärsused', 'tours.poi.cat.slipway': 'Vettelaskmiskohad', 'tours.poi.cat.boat_rental': 'Paadilaenutus', + 'tours.poi.cat.bike_repair': 'Rattaremont', + 'tours.planner.tourTypeHint.hike': 'Jalgsi, sinu määratud raskuse piires', + 'tours.planner.tourTypeHint.bike': 'Rattaga, marsruut matka-, maantee-, gravel- või maastikurattale', + 'tours.planner.tourTypeHint.kayak': 'Vee peal, süsta, kanuu või SUP-iga', + 'tours.bike.profile': 'Profiil', + 'tours.bike.profile.touring': 'Matk', + 'tours.bike.profile.road': 'Maantee', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Eelistab rattateid ja vaikseid tänavaid, võimalusel kõvakattega.', + 'tours.bike.profileHint.road': 'Hoiab asfaldile; teadaolevad kattega lõigud näidatakse.', + 'tours.bike.profileHint.gravel': 'Kasutab kruusa- ja põllumajandusteid, mitte tehnilisi rajalõike.', + 'tours.bike.profileHint.mtb': 'Võtab karmid rajad nii, nagu need tulevad; rajaraskus näidatakse, kus kaardistatud.', + 'tours.bike.timeEstimated': 'Sõiduaeg (hinnanguline)', + 'tours.bike.timeEstimatedShort': 'Sõiduaeg', + 'tours.bike.timeEstimatedInfoLabel': 'Kuidas sõiduaeg arvutatakse', + 'tours.bike.timeEstimatedInfo': + 'Marsruudi aeg valitud profiilile, selle kiiruse ja tõusudega. Planeerimisväärtus, pausideta.', + 'tours.bike.ways.title': 'Kate ja teed', + 'tours.bike.ways.surface': 'Kate', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Konarlik kate', + 'tours.bike.ways.surface.compacted': 'Tihendatud', + 'tours.bike.ways.surface.gravel': 'Kruus', + 'tours.bike.ways.surface.ground': 'Pinnas', + 'tours.bike.ways.surface.unknown': 'Andmetes puudub', + 'tours.bike.ways.way': 'Tee liik', + 'tours.bike.ways.way.cyclePath': 'Rattatee', + 'tours.bike.ways.way.cycleLane': 'Rattarada liikluse kõrval', + 'tours.bike.ways.way.quiet': 'Vaikne tänav', + 'tours.bike.ways.way.road': 'Liiklusega tee', + 'tours.bike.ways.way.track': 'Põllu- või metsatee', + 'tours.bike.ways.way.path': 'Rada', + 'tours.bike.ways.way.ferry': 'Praam', + 'tours.bike.ways.way.other': 'Muu', + 'tours.bike.ways.loading': 'Loen marsruudi teid…', + 'tours.bike.ways.offline': 'Kate ja rajaraskus vajavad ühendust.', + 'tours.bike.ways.failed': 'Marsruudi teid ei õnnestunud lugeda. Marsruut ise ei muutu.', + 'tours.bike.ways.source': + 'OpenStreetMapist, nagu marsruutija seda loeb. Kus andmed midagi ei ütle, on kate teadmata, mitte hea.', + 'tours.bike.ways.showOnMap': 'Näita kaardil', + 'tours.bike.ways.unpavedBadge': '{share} katteta', + 'tours.bike.ways.unpavedNotice': 'Marsruudist {length} on teadaolevalt katteta.', + 'tours.bike.trail.title': 'Raja raskus (mtb:scale)', + 'tours.bike.trail.s0': 'S0, erilisi raskusi pole', + 'tours.bike.trail.s1': 'S1, väikesed takistused', + 'tours.bike.trail.s2': 'S2, juured, astmed, lahtine pinnas', + 'tours.bike.trail.s3': 'S3, rahnud, järsk, kitsad pöörded', + 'tours.bike.trail.s4': 'S4 ja üle, ekspertide maastik', + 'tours.bike.trail.none': 'Andmetes raskust pole', + 'tours.bike.trail.unasked': 'Seekord ei õnnestunud küsida', + 'tours.bike.trail.uphillTitle': 'Tõusu raskus (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Tõus {step}', + 'tours.bike.trail.note': + 'Raskused nii, nagu OpenStreetMapi kaardistajad need andsid. Rada ilma raskuseta on teadmata, mitte kerge. Need ei ütle midagi selle kohta, kas rada sulle sobib.', + 'tours.bike.trail.loading': 'Loen radade raskust…', + 'tours.bike.trail.failed': 'Radade raskust ei õnnestunud seekord lugeda.', 'tours.copy.action': 'Salvesta uue matkana', 'tours.copy.hint': 'Kopeerib selle matka uue, iseseisva matkana ilma päevadeta. Originaal jääb samaks.', 'tours.copy.name': '{name} (koopia)', @@ -354,6 +421,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'GPX-faili allalaadimiseks on vaja ühendust serveriga.', 'tours.gpxExport.noRoute': 'Sellel matkal pole eksportimiseks salvestatud marsruuti.', 'tours.gpxExport.failed': 'GPX-faili ei õnnestunud luua.', + 'tours.gpxExport.share': 'Jaga GPX-i', + 'tours.gpxExport.shareShort': 'Jaga', + 'tours.gpxExport.shareSaveFirst': 'Salvesta matk enne, et seda GPX-ina jagada', + 'tours.gpxExport.shareReady': 'GPX-fail on valmis. Puuduta veel kord Jaga, et avada jagamisaken.', + 'tours.gpxExport.shareFailed': 'Jagamine ei õnnestunud. GPX-faili saab endiselt alla laadida.', 'tours.revision.remoteTitle': 'Seda matka salvestati mujal', 'tours.revision.remoteBody': 'Keegi või sina teises seadmes salvestas selle sinu muutmise ajal. Sinu muudatused jäävad alles, kuni otsustad.', @@ -383,14 +455,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Mida marsruudi valik teeb', 'tours.preferences.info': 'Need valikud kaaluvad, millise tee marsruutija valib. Kus teist teed pole, läheb marsruut ikkagi sealt. Tee raskuse kohta need midagi ei ütle; mida marsruut kasutada tohib, määrab ülal olev raskuspiir.', + 'tours.preferences.avoid_climbs': 'Väldi järske tõuse', + 'tours.preferences.avoid_climbsHint': + 'Laugemad teed, kus neid on, isegi kui need on pikemad. Kus ümbersõitu pole, jääb järsuks.', 'tours.preferences.avoid_steps': 'Väldi treppe', 'tours.preferences.avoid_stepsHint': 'Trepid ainult seal, kus ringi minna ei saa.', 'tours.preferences.avoid_ferries': 'Väldi praame', 'tours.preferences.avoid_ferriesHint': 'Mööda maad, kus saab ringi minna, isegi kui see on pikem.', - 'tours.preferences.prefer_paths': 'Eelista jalgradu', - 'tours.preferences.prefer_pathsHint': 'Omaette rajad teede ja kõnniteede asemel, kus neid on.', - 'tours.preferences.shortest': 'Lühem kiirema asemel', - 'tours.preferences.shortestHint': 'Tee valitakse pikkuse, mitte aja järgi; sageli veidi lühem, mitte alati kiirem.', 'tours.planner.tourType': 'Matka tüüp', 'tours.paddle.boat': 'Paat', 'tours.paddle.boat.kayak': 'Süst', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 3aa1b6fea..971fefd0a 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -674,6 +674,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Carte de randonnée (OpenHikingMap)', 'admin.toursHikingMap.subtitle': "Propose OpenHikingMap comme fond de carte dans le planificateur d'itinéraires. Gratuit pour les installations non commerciales jusqu'à 500 000 tuiles par mois ; une installation commerciale ou plus importante nécessite d'abord un accord avec openmaps.fr", + 'admin.toursCycleMap.title': 'Carte vélo (CyclOSM)', + 'admin.toursCycleMap.subtitle': + "Propose CyclOSM comme fond de carte pour les sorties vélo dans le planificateur. OpenStreetMap France le sert pour un usage modéré ; une installation à fort trafic ou commerciale le désactive ou conclut d'abord un accord avec l'opérateur", 'admin.addons.noAddons': 'Aucune extension disponible', 'admin.weather.title': 'Données météo', 'admin.weather.badge': 'Depuis le 24 mars 2026', diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts index ca723b9ec..b1da49c6b 100644 --- a/shared/src/i18n/fr/map.ts +++ b/shared/src/i18n/fr/map.ts @@ -21,6 +21,9 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Randonnée', 'map.baseLayer.hikingDown': 'OpenHikingMap ne répond pas pour le moment. Passe à une autre carte ; ton itinéraire reste inchangé.', + 'map.baseLayer.cycle': 'Vélo', + 'map.baseLayer.cycleDown': + 'CyclOSM ne répond pas pour le moment. Passe à une autre carte ; ta sortie reste telle quelle.', 'map.baseLayer.switchToSatellite': 'Passer à la vue satellite', 'map.baseLayer.switchToDefault': 'Passer à la vue carte', 'map.location.denied': diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 26fa55e01..f74ac60ca 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -99,6 +99,9 @@ const tours: TranslationStrings = { "La navigation ouvre le point de départ du sentier ; elle ne vous guide pas le long de l'itinéraire.", 'tours.planner.title': 'Planifier une sortie', 'tours.planner.newTour': 'Planifier une nouvelle sortie', + 'tours.planner.addTour': 'Ajouter une sortie', + 'tours.planner.addTourHint': + "Planifier une nouvelle sortie, importer des fichiers GPX ou reprendre une sortie d'un autre voyage", 'tours.planner.neutralTitle': 'Planifier une sortie', 'tours.planner.neutralBody': 'Commencez une nouvelle randonnée et cliquez sur la carte pour placer ses points.', 'tours.planner.newTitle': 'Nouvelle sortie', @@ -115,6 +118,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Déployer le profil altimétrique', 'tours.planner.name': 'Nom de la sortie', 'tours.planner.namePlaceholder': 'Randonnée sans titre', + 'tours.planner.namePlaceholder.bike': 'Sortie vélo sans titre', + 'tours.planner.namePlaceholder.kayak': 'Sortie en pagaie sans titre', 'tours.planner.mapSearch': 'Rechercher un lieu ou une adresse', 'tours.planner.placePoints': 'Placer des points', 'tours.planner.pointPlaced': 'Point placé', @@ -179,6 +184,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': "Calcul de l'itinéraire à pied…", 'tours.planner.status.routingFailed': "Le calcul de l'itinéraire a échoué. Le dernier itinéraire valide reste affiché.", + 'tours.planner.status.unreachable': 'Le point {point} est inaccessible', + 'tours.planner.unreachable.hike': + "Aucun chemin de randonnée n'atteint le point {point} : le plus proche que connaît le routeur est à {distance}. Déplace le point sur un chemin ou dessine le tronçon à la main.", + 'tours.planner.unreachable.bike': + "Aucune voie cyclable n'atteint le point {point} : la plus proche que connaît le routeur est à {distance}. Déplace le point sur une voie ou dessine le tronçon à la main.", + 'tours.planner.unreachable.show': 'Montrer le point {point}', 'tours.planner.status.elevation': "Chargement de l'altitude…", 'tours.planner.status.elevationFailed': "L'altitude est indisponible. L'itinéraire calculé reste affiché.", 'tours.planner.status.saving': 'Enregistrement…', @@ -351,6 +362,64 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'À voir', 'tours.poi.cat.slipway': "Mises à l'eau", 'tours.poi.cat.boat_rental': 'Location de bateaux', + 'tours.poi.cat.bike_repair': 'Réparation vélo', + 'tours.planner.tourTypeHint.hike': 'À pied, dans la difficulté que tu choisis', + 'tours.planner.tourTypeHint.bike': 'À vélo, itinéraire pour le voyage, la route, le gravel ou le VTT', + 'tours.planner.tourTypeHint.kayak': "Sur l'eau, en kayak, canoë ou SUP", + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Voyage', + 'tours.bike.profile.road': 'Route', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'VTT', + 'tours.bike.profileHint.touring': 'Préfère les pistes cyclables et les routes calmes, revêtues si possible.', + 'tours.bike.profileHint.road': "Reste sur l'asphalte ; les tronçons non revêtus connus sont signalés.", + 'tours.bike.profileHint.gravel': 'Prend le gravier et les chemins agricoles, pas les sentiers techniques.', + 'tours.bike.profileHint.mtb': + 'Prend les chemins rugueux comme ils viennent ; la difficulté est montrée là où elle est cartographiée.', + 'tours.bike.timeEstimated': 'Temps à vélo (estimé)', + 'tours.bike.timeEstimatedShort': 'Temps à vélo', + 'tours.bike.timeEstimatedInfoLabel': 'Comment le temps à vélo est calculé', + 'tours.bike.timeEstimatedInfo': + 'Le temps du routeur pour le profil choisi, sa vitesse et les montées comprises. Une valeur de planification, sans pauses.', + 'tours.bike.ways.title': 'Revêtement et voies', + 'tours.bike.ways.surface': 'Revêtement', + 'tours.bike.ways.surface.paved': 'Asphalte', + 'tours.bike.ways.surface.rough': 'Pavé grossier', + 'tours.bike.ways.surface.compacted': 'Stabilisé', + 'tours.bike.ways.surface.gravel': 'Gravier', + 'tours.bike.ways.surface.ground': 'Sol naturel', + 'tours.bike.ways.surface.unknown': 'Absent des données', + 'tours.bike.ways.way': 'Type de voie', + 'tours.bike.ways.way.cyclePath': 'Piste cyclable', + 'tours.bike.ways.way.cycleLane': 'Bande cyclable le long du trafic', + 'tours.bike.ways.way.quiet': 'Rue calme', + 'tours.bike.ways.way.road': 'Route avec circulation', + 'tours.bike.ways.way.track': 'Chemin agricole ou forestier', + 'tours.bike.ways.way.path': 'Sentier', + 'tours.bike.ways.way.ferry': 'Bac', + 'tours.bike.ways.way.other': 'Autre', + 'tours.bike.ways.loading': "Lecture des voies le long de l'itinéraire…", + 'tours.bike.ways.offline': 'Le revêtement et la difficulté des sentiers demandent une connexion.', + 'tours.bike.ways.failed': "Impossible de lire les voies de l'itinéraire. L'itinéraire lui-même ne change pas.", + 'tours.bike.ways.source': + 'Depuis OpenStreetMap, tel que le routeur le lit. Là où les données ne disent rien, le revêtement est inconnu, pas bon.', + 'tours.bike.ways.showOnMap': 'Montrer sur la carte', + 'tours.bike.ways.unpavedBadge': '{share} non revêtu', + 'tours.bike.ways.unpavedNotice': "{length} de l'itinéraire sont connus comme non revêtus.", + 'tours.bike.trail.title': 'Difficulté du sentier (mtb:scale)', + 'tours.bike.trail.s0': 'S0, sans difficulté particulière', + 'tours.bike.trail.s1': 'S1, petits obstacles', + 'tours.bike.trail.s2': 'S2, racines, marches, sol meuble', + 'tours.bike.trail.s3': 'S3, blocs, raide, virages serrés', + 'tours.bike.trail.s4': "S4 et plus, terrain d'experts", + 'tours.bike.trail.none': 'Pas de difficulté dans les données', + 'tours.bike.trail.unasked': 'Pas interrogeable cette fois', + 'tours.bike.trail.uphillTitle': 'Difficulté en montée (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Montée {step}', + 'tours.bike.trail.note': + "Difficultés telles que les contributeurs d'OpenStreetMap les ont notées. Une voie sans note est inconnue, pas facile. Elles ne disent rien de ce qui te convient.", + 'tours.bike.trail.loading': 'Lecture des difficultés des sentiers…', + 'tours.bike.trail.failed': "Les difficultés des sentiers n'ont pas pu être lues cette fois.", 'tours.copy.action': 'Enregistrer comme nouvelle sortie', 'tours.copy.hint': 'Copie cette sortie en une nouvelle sortie indépendante, sans ses jours. L’original reste tel quel.', @@ -364,6 +433,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Le téléchargement d’un fichier GPX nécessite une connexion au serveur.', 'tours.gpxExport.noRoute': 'Cette sortie n’a pas d’itinéraire enregistré à exporter.', 'tours.gpxExport.failed': 'Le fichier GPX n’a pas pu être créé.', + 'tours.gpxExport.share': 'Partager le GPX', + 'tours.gpxExport.shareShort': 'Partager', + 'tours.gpxExport.shareSaveFirst': "Enregistre d'abord la sortie pour la partager en GPX", + 'tours.gpxExport.shareReady': + 'Le fichier GPX est prêt. Touche Partager encore une fois pour ouvrir le menu de partage.', + 'tours.gpxExport.shareFailed': "Le partage n'a pas fonctionné. Le fichier GPX reste téléchargeable.", 'tours.revision.remoteTitle': 'Cette sortie a été enregistrée ailleurs', 'tours.revision.remoteBody': 'Quelqu’un, ou vous sur un autre appareil, l’a enregistrée pendant votre modification. Vos changements restent jusqu’à votre décision.', @@ -394,15 +469,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Ce que fait le choix de l’itinéraire', 'tours.preferences.info': 'Ces choix pondèrent le chemin que choisit le calcul d’itinéraire. Là où il n’y a pas d’autre chemin, l’itinéraire y passe quand même. Ils ne disent rien de la difficulté d’un chemin ; la limite de difficulté ci-dessus fixe ce que l’itinéraire peut emprunter.', + 'tours.preferences.avoid_climbs': 'Éviter les fortes montées', + 'tours.preferences.avoid_climbsHint': + 'Des chemins plus doux là où il y en a, même plus longs. Là où on ne peut pas contourner, ça reste raide.', 'tours.preferences.avoid_steps': 'Éviter les escaliers', 'tours.preferences.avoid_stepsHint': 'Des escaliers seulement là où on ne peut pas les contourner.', 'tours.preferences.avoid_ferries': 'Éviter les bacs', 'tours.preferences.avoid_ferriesHint': 'Par voie terrestre là où on peut contourner, même si c’est plus long.', - 'tours.preferences.prefer_paths': 'Préférer les sentiers', - 'tours.preferences.prefer_pathsHint': 'Des chemins à part plutôt que les routes et leurs trottoirs, là où il y en a.', - 'tours.preferences.shortest': 'Plus court plutôt que plus rapide', - 'tours.preferences.shortestHint': - 'Le chemin choisi selon sa longueur, pas selon le temps ; souvent un peu plus court, pas toujours plus rapide.', 'tours.planner.tourType': 'Type de sortie', 'tours.paddle.boat': 'Embarcation', 'tours.paddle.boat.kayak': 'Kayak', // same-as-en diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index c7031902c..6cd96df7d 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -737,6 +737,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Χάρτης πεζοπορίας (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Προσφέρει το OpenHikingMap ως χάρτη βάσης στον σχεδιαστή διαδρομών. Δωρεάν για μη εμπορικές εγκαταστάσεις έως 500.000 πλακίδια τον μήνα· μια εμπορική ή μεγαλύτερη εγκατάσταση χρειάζεται πρώτα συμφωνία με το openmaps.fr', + 'admin.toursCycleMap.title': 'Ποδηλατικός χάρτης (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Προσφέρει το CyclOSM ως βασικό χάρτη για ποδηλατικές διαδρομές στον σχεδιαστή. Το OpenStreetMap France το παρέχει για μέτρια χρήση· μια εγκατάσταση με μεγάλη ή εμπορική χρήση το απενεργοποιεί ή συμφωνεί πρώτα με τον πάροχο', 'admin.addons.noAddons': 'Δεν υπάρχουν διαθέσιμα πρόσθετα', 'admin.weather.title': 'Δεδομένα Καιρού', 'admin.weather.badge': 'Από 24 Μαρτίου 2026', diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts index 193506ae2..5d1d3e58d 100644 --- a/shared/src/i18n/gr/map.ts +++ b/shared/src/i18n/gr/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Πεζοπορία', 'map.baseLayer.hikingDown': 'Το OpenHikingMap δεν απαντά αυτή τη στιγμή. Άλλαξε σε άλλο χάρτη· η διαδρομή σου μένει ως έχει.', + 'map.baseLayer.cycle': 'Ποδήλατο', + 'map.baseLayer.cycleDown': 'Το CyclOSM δεν απαντά αυτή τη στιγμή. Άλλαξε σε άλλον χάρτη· η διαδρομή σου μένει ίδια.', 'map.baseLayer.switchToSatellite': 'Εναλλαγή σε δορυφορική προβολή', 'map.baseLayer.switchToDefault': 'Εναλλαγή σε προβολή χάρτη', 'map.location.denied': diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 2dbe8f097..79da6d5dd 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -98,6 +98,8 @@ const tours: TranslationStrings = { 'Η πλοήγηση ανοίγει την αφετηρία του μονοπατιού· δεν σας καθοδηγεί κατά μήκος της διαδρομής.', 'tours.planner.title': 'Σχεδιασμός εκδρομής', 'tours.planner.newTour': 'Σχεδιασμός νέας εκδρομής', + 'tours.planner.addTour': 'Προσθήκη διαδρομής', + 'tours.planner.addTourHint': 'Σχεδίασε νέα διαδρομή, εισήγαγε αρχεία GPX ή πάρε μια διαδρομή από άλλο ταξίδι', 'tours.planner.neutralTitle': 'Σχεδιασμός εκδρομής', 'tours.planner.neutralBody': 'Ξεκινήστε μια νέα διαδρομή και κάντε κλικ στον χάρτη για να ορίσετε τα σημεία της.', 'tours.planner.newTitle': 'Νέα εκδρομή', @@ -114,6 +116,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Ανάπτυξη υψομετρικού προφίλ', 'tours.planner.name': 'Όνομα εκδρομής', 'tours.planner.namePlaceholder': 'Πεζοπορία χωρίς όνομα', + 'tours.planner.namePlaceholder.bike': 'Ποδηλατάδα χωρίς όνομα', + 'tours.planner.namePlaceholder.kayak': 'Κουπί χωρίς όνομα', 'tours.planner.mapSearch': 'Αναζήτηση τόπου ή διεύθυνσης', 'tours.planner.placePoints': 'Τοποθέτηση σημείων', 'tours.planner.pointPlaced': 'Το σημείο τοποθετήθηκε', @@ -179,6 +183,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Υπολογισμός διαδρομής πεζοπορίας…', 'tours.planner.status.routingFailed': 'Ο υπολογισμός διαδρομής απέτυχε. Εμφανίζεται ακόμη η τελευταία έγκυρη διαδρομή.', + 'tours.planner.status.unreachable': 'Το σημείο {point} δεν είναι προσβάσιμο', + 'tours.planner.unreachable.hike': + 'Κανένα μονοπάτι πεζοπορίας δεν φτάνει στο σημείο {point}: το πλησιέστερο που ξέρει ο δρομολογητής απέχει {distance}. Μετακίνησε το σημείο σε μονοπάτι ή σχεδίασε το τμήμα με το χέρι.', + 'tours.planner.unreachable.bike': + 'Κανένας δρόμος για ποδήλατα δεν φτάνει στο σημείο {point}: ο πλησιέστερος που ξέρει ο δρομολογητής απέχει {distance}. Μετακίνησε το σημείο σε δρόμο ή σχεδίασε το τμήμα με το χέρι.', + 'tours.planner.unreachable.show': 'Εμφάνιση σημείου {point}', 'tours.planner.status.elevation': 'Φόρτωση υψομετρικών δεδομένων…', 'tours.planner.status.elevationFailed': 'Τα υψομετρικά δεδομένα δεν είναι διαθέσιμα. Η υπολογισμένη διαδρομή εξακολουθεί να εμφανίζεται.', @@ -352,6 +362,64 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Αξιοθέατα', 'tours.poi.cat.slipway': 'Σημεία καθέλκυσης', 'tours.poi.cat.boat_rental': 'Ενοικίαση σκαφών', + 'tours.poi.cat.bike_repair': 'Επισκευή ποδηλάτου', + 'tours.planner.tourTypeHint.hike': 'Με τα πόδια, μέσα στη δυσκολία που ορίζεις', + 'tours.planner.tourTypeHint.bike': 'Με ποδήλατο, διαδρομή για περιήγηση, δρόμο, gravel ή MTB', + 'tours.planner.tourTypeHint.kayak': 'Στο νερό, με καγιάκ, κανό ή SUP', + 'tours.bike.profile': 'Προφίλ', + 'tours.bike.profile.touring': 'Περιήγηση', + 'tours.bike.profile.road': 'Δρόμου', + 'tours.bike.profile.gravel': 'Χαλίκι', + 'tours.bike.profile.mtb': 'MTB', + 'tours.bike.profileHint.touring': 'Προτιμά ποδηλατοδρόμους και ήσυχους δρόμους, ασφαλτοστρωμένους όπου γίνεται.', + 'tours.bike.profileHint.road': 'Μένει στην άσφαλτο· εμφανίζονται τα γνωστά χωμάτινα τμήματα.', + 'tours.bike.profileHint.gravel': 'Παίρνει χαλίκι και αγροτικούς δρόμους, όχι τεχνικά μονοπάτια.', + 'tours.bike.profileHint.mtb': + 'Παίρνει τραχιά μονοπάτια όπως έρχονται· οι βαθμοί δυσκολίας εμφανίζονται όπου είναι χαρτογραφημένοι.', + 'tours.bike.timeEstimated': 'Χρόνος ποδηλασίας (εκτίμηση)', + 'tours.bike.timeEstimatedShort': 'Χρόνος ποδηλασίας', + 'tours.bike.timeEstimatedInfoLabel': 'Πώς υπολογίζεται ο χρόνος ποδηλασίας', + 'tours.bike.timeEstimatedInfo': + 'Ο χρόνος του δρομολογητή για το προφίλ που επέλεξες, με την ταχύτητα και τις ανηφόρες. Τιμή σχεδιασμού, χωρίς διαλείμματα.', + 'tours.bike.ways.title': 'Επιφάνεια και δρόμοι', + 'tours.bike.ways.surface': 'Επιφάνεια', + 'tours.bike.ways.surface.paved': 'Άσφαλτος', + 'tours.bike.ways.surface.rough': 'Τραχύ πλακόστρωτο', + 'tours.bike.ways.surface.compacted': 'Συμπιεσμένο', + 'tours.bike.ways.surface.gravel': 'Χαλίκι', + 'tours.bike.ways.surface.ground': 'Φυσικό έδαφος', + 'tours.bike.ways.surface.unknown': 'Δεν υπάρχει στα δεδομένα', + 'tours.bike.ways.way': 'Είδος δρόμου', + 'tours.bike.ways.way.cyclePath': 'Ποδηλατόδρομος', + 'tours.bike.ways.way.cycleLane': 'Λωρίδα ποδηλάτου δίπλα στην κυκλοφορία', + 'tours.bike.ways.way.quiet': 'Ήσυχος δρόμος', + 'tours.bike.ways.way.road': 'Δρόμος με κίνηση', + 'tours.bike.ways.way.track': 'Αγροτικός ή δασικός δρόμος', + 'tours.bike.ways.way.path': 'Μονοπάτι', + 'tours.bike.ways.way.ferry': 'Πορθμείο', + 'tours.bike.ways.way.other': 'Άλλο', + 'tours.bike.ways.loading': 'Διαβάζονται οι δρόμοι κατά μήκος της διαδρομής…', + 'tours.bike.ways.offline': 'Η επιφάνεια και οι βαθμοί δυσκολίας χρειάζονται σύνδεση.', + 'tours.bike.ways.failed': 'Δεν διαβάστηκαν οι δρόμοι της διαδρομής. Η ίδια η διαδρομή δεν αλλάζει.', + 'tours.bike.ways.source': + 'Από το OpenStreetMap, όπως το διαβάζει ο δρομολογητής. Όπου τα δεδομένα δεν λένε τίποτα, η επιφάνεια είναι άγνωστη, όχι καλή.', + 'tours.bike.ways.showOnMap': 'Εμφάνιση στον χάρτη', + 'tours.bike.ways.unpavedBadge': '{share} χωρίς άσφαλτο', + 'tours.bike.ways.unpavedNotice': '{length} της διαδρομής είναι γνωστό ότι δεν έχουν άσφαλτο.', + 'tours.bike.trail.title': 'Βαθμός δυσκολίας (mtb:scale)', + 'tours.bike.trail.s0': 'S0, χωρίς ιδιαίτερη δυσκολία', + 'tours.bike.trail.s1': 'S1, μικρά εμπόδια', + 'tours.bike.trail.s2': 'S2, ρίζες, σκαλοπάτια, χαλαρό έδαφος', + 'tours.bike.trail.s3': 'S3, βράχια, απότομο, κλειστές στροφές', + 'tours.bike.trail.s4': 'S4 και πάνω, έδαφος για έμπειρους', + 'tours.bike.trail.none': 'Χωρίς βαθμό στα δεδομένα', + 'tours.bike.trail.unasked': 'Δεν ήταν δυνατό να ζητηθεί αυτή τη φορά', + 'tours.bike.trail.uphillTitle': 'Δυσκολία ανάβασης (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Ανάβαση {step}', + 'tours.bike.trail.note': + 'Βαθμοί όπως τους έδωσαν οι χαρτογράφοι του OpenStreetMap. Δρόμος χωρίς βαθμό είναι άγνωστος, όχι εύκολος. Δεν λένε αν ένα μονοπάτι σου ταιριάζει.', + 'tours.bike.trail.loading': 'Διαβάζονται οι βαθμοί δυσκολίας…', + 'tours.bike.trail.failed': 'Οι βαθμοί δυσκολίας δεν διαβάστηκαν αυτή τη φορά.', 'tours.copy.action': 'Αποθήκευση ως νέα εκδρομή', 'tours.copy.hint': 'Αντιγράφει αυτή την εκδρομή ως νέα, ανεξάρτητη εκδρομή χωρίς τις μέρες της. Το πρωτότυπο μένει ως έχει.', @@ -365,6 +433,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Η λήψη αρχείου GPX χρειάζεται σύνδεση με τον διακομιστή.', 'tours.gpxExport.noRoute': 'Αυτή η εκδρομή δεν έχει αποθηκευμένη διαδρομή για εξαγωγή.', 'tours.gpxExport.failed': 'Το αρχείο GPX δεν δημιουργήθηκε.', + 'tours.gpxExport.share': 'Κοινοποίηση GPX', + 'tours.gpxExport.shareShort': 'Κοινοποίηση', + 'tours.gpxExport.shareSaveFirst': 'Αποθήκευσε πρώτα τη διαδρομή για να την κοινοποιήσεις ως GPX', + 'tours.gpxExport.shareReady': + 'Το αρχείο GPX είναι έτοιμο. Πάτησε ξανά Κοινοποίηση για να ανοίξει το μενού κοινοποίησης.', + 'tours.gpxExport.shareFailed': 'Η κοινοποίηση δεν πέτυχε. Το αρχείο GPX μπορεί ακόμη να ληφθεί.', 'tours.revision.remoteTitle': 'Αυτή η εκδρομή αποθηκεύτηκε αλλού', 'tours.revision.remoteBody': 'Κάποιος, ή εσείς σε άλλη συσκευή, την αποθήκευσε ενώ την επεξεργαζόσασταν. Οι αλλαγές σας μένουν μέχρι να αποφασίσετε.', @@ -395,15 +469,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Τι κάνει η επιλογή διαδρομής', 'tours.preferences.info': 'Αυτές οι επιλογές σταθμίζουν τον δρόμο που επιλέγει ο δρομολογητής. Όπου δεν υπάρχει άλλος δρόμος, η διαδρομή περνά από εκεί ούτως ή άλλως. Δεν λένε τίποτα για τη δυσκολία ενός μονοπατιού· το όριο δυσκολίας παραπάνω ορίζει τι μπορεί να χρησιμοποιήσει η διαδρομή.', + 'tours.preferences.avoid_climbs': 'Αποφυγή απότομων ανηφορών', + 'tours.preferences.avoid_climbsHint': + 'Ηπιότεροι δρόμοι όπου υπάρχουν, ακόμη κι αν είναι μακρύτεροι. Όπου δεν υπάρχει παράκαμψη, παραμένει απότομο.', 'tours.preferences.avoid_steps': 'Αποφυγή σκαλοπατιών', 'tours.preferences.avoid_stepsHint': 'Σκαλοπάτια μόνο όπου δεν υπάρχει παράκαμψη.', 'tours.preferences.avoid_ferries': 'Αποφυγή πορθμείων', 'tours.preferences.avoid_ferriesHint': 'Από ξηράς όπου υπάρχει παράκαμψη, ακόμη κι αν είναι μεγαλύτερη.', - 'tours.preferences.prefer_paths': 'Προτίμηση πεζόδρομων', - 'tours.preferences.prefer_pathsHint': 'Ξεχωριστά μονοπάτια αντί για δρόμους και πεζοδρόμια, όπου υπάρχουν.', - 'tours.preferences.shortest': 'Συντομότερη αντί για ταχύτερη', - 'tours.preferences.shortestHint': - 'Ο δρόμος επιλέγεται με βάση το μήκος, όχι τον χρόνο· συχνά λίγο μικρότερος, όχι πάντα ταχύτερος.', 'tours.planner.tourType': 'Τύπος διαδρομής', 'tours.paddle.boat': 'Σκάφος', 'tours.paddle.boat.kayak': 'Καγιάκ', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 1b8c1c592..b415b1045 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -675,6 +675,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Túratérkép (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Az OpenHikingMapet alaptérképként kínálja a túratervezőben. Nem kereskedelmi telepítéseknek havi 500 000 csempéig ingyenes; kereskedelmi vagy nagyobb telepítéshez előbb megállapodás kell az openmaps.fr-rel', + 'admin.toursCycleMap.title': 'Kerékpáros térkép (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'A CyclOSM-et kínálja alaptérképként a kerékpártúrákhoz a túratervezőben. Az OpenStreetMap France mérsékelt használatra biztosítja; egy erősen használt vagy kereskedelmi telepítés kikapcsolja, vagy előbb megállapodik az üzemeltetővel', 'admin.addons.noAddons': 'Nincsenek elérhető bővítmények', 'admin.weather.title': 'Időjárás adatok', 'admin.weather.badge': '2026. március 24. óta', diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts index 075a58e92..975e4477c 100644 --- a/shared/src/i18n/hu/map.ts +++ b/shared/src/i18n/hu/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Műhold', 'map.baseLayer.hiking': 'Túrázás', 'map.baseLayer.hikingDown': 'Az OpenHikingMap most nem válaszol. Válts másik térképre, a túrád változatlan marad.', + 'map.baseLayer.cycle': 'Kerékpár', + 'map.baseLayer.cycleDown': 'A CyclOSM most nem válaszol. Válts másik térképre, a túrád változatlan marad.', 'map.baseLayer.switchToSatellite': 'Váltás műholdas nézetre', 'map.baseLayer.switchToDefault': 'Váltás térkép nézetre', 'map.location.denied': diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index ad9bbb148..7fd059b98 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -95,6 +95,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'A navigáció a túra kiindulópontját nyitja meg; nem vezet végig az útvonalon.', 'tours.planner.title': 'Túra tervezése', 'tours.planner.newTour': 'Új túra tervezése', + 'tours.planner.addTour': 'Túra hozzáadása', + 'tours.planner.addTourHint': 'Új túra tervezése, GPX-fájlok importálása vagy túra átvétele egy másik utazásból', 'tours.planner.neutralTitle': 'Túra tervezése', 'tours.planner.neutralBody': 'Indíts új túrát, és kattints a térképre a pontjai elhelyezéséhez.', 'tours.planner.newTitle': 'Új túra', @@ -110,6 +112,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Magassági profil kibontása', 'tours.planner.name': 'Túra neve', 'tours.planner.namePlaceholder': 'Névtelen gyalogtúra', + 'tours.planner.namePlaceholder.bike': 'Névtelen kerékpártúra', + 'tours.planner.namePlaceholder.kayak': 'Névtelen evezőstúra', 'tours.planner.mapSearch': 'Hely vagy cím keresése', 'tours.planner.placePoints': 'Pontok elhelyezése', 'tours.planner.pointPlaced': 'Pont elhelyezve', @@ -174,6 +178,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Gyalogos útvonal kiszámítása…', 'tours.planner.status.routingFailed': 'Az útvonalszámítás sikertelen. Továbbra is az utolsó sikeresen kiszámított útvonal látható.', + 'tours.planner.status.unreachable': 'A(z) {point}. pont nem érhető el', + 'tours.planner.unreachable.hike': + 'Egyetlen gyalogút sem éri el a(z) {point}. pontot: a legközelebbi, amit az útvonaltervező ismer, {distance} távolságra van. Tedd a pontot egy útra, vagy rajzold meg kézzel a szakaszt.', + 'tours.planner.unreachable.bike': + 'Egyetlen kerékpárral járható út sem éri el a(z) {point}. pontot: a legközelebbi, amit az útvonaltervező ismer, {distance} távolságra van. Tedd a pontot egy útra, vagy rajzold meg kézzel a szakaszt.', + 'tours.planner.unreachable.show': 'A(z) {point}. pont mutatása', 'tours.planner.status.elevation': 'Magassági adatok betöltése…', 'tours.planner.status.elevationFailed': 'A magassági adatok nem érhetők el. A kiszámított útvonal továbbra is látható.', @@ -346,6 +356,64 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Látnivalók', 'tours.poi.cat.slipway': 'Vízre szállási helyek', 'tours.poi.cat.boat_rental': 'Csónakkölcsönző', + 'tours.poi.cat.bike_repair': 'Kerékpárszerviz', + 'tours.planner.tourTypeHint.hike': 'Gyalog, az általad megadott nehézségig', + 'tours.planner.tourTypeHint.bike': 'Kerékpárral, túra, országúti, gravel vagy MTB útvonallal', + 'tours.planner.tourTypeHint.kayak': 'Vízen, kajakkal, kenuval vagy SUP-pal', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Túra', + 'tours.bike.profile.road': 'Országúti', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Kerékpárutakat és csendes utakat részesít előnyben, lehetőleg burkoltat.', + 'tours.bike.profileHint.road': 'Aszfalton marad; az ismert burkolatlan szakaszokat mutatja.', + 'tours.bike.profileHint.gravel': 'Kavicsos és gazdasági utakat használ, technikás singletraileket nem.', + 'tours.bike.profileHint.mtb': + 'Úgy veszi a durva utakat, ahogy jönnek; a nehézséget ott mutatja, ahol fel van térképezve.', + 'tours.bike.timeEstimated': 'Kerékpáros idő (becsült)', + 'tours.bike.timeEstimatedShort': 'Kerékpáros idő', + 'tours.bike.timeEstimatedInfoLabel': 'Hogyan számoljuk a kerékpáros időt', + 'tours.bike.timeEstimatedInfo': + 'Az útvonaltervező ideje a választott profilra, a sebességgel és az emelkedőkkel. Tervezési érték, szünetek nélkül.', + 'tours.bike.ways.title': 'Burkolat és utak', + 'tours.bike.ways.surface': 'Burkolat', + 'tours.bike.ways.surface.paved': 'Aszfalt', + 'tours.bike.ways.surface.rough': 'Durva burkolat', + 'tours.bike.ways.surface.compacted': 'Tömörített', + 'tours.bike.ways.surface.gravel': 'Kavics', + 'tours.bike.ways.surface.ground': 'Földút', + 'tours.bike.ways.surface.unknown': 'Nincs az adatokban', + 'tours.bike.ways.way': 'Úttípus', + 'tours.bike.ways.way.cyclePath': 'Kerékpárút', + 'tours.bike.ways.way.cycleLane': 'Kerékpársáv a forgalom mellett', + 'tours.bike.ways.way.quiet': 'Csendes utca', + 'tours.bike.ways.way.road': 'Forgalmas út', + 'tours.bike.ways.way.track': 'Mező- vagy erdei út', + 'tours.bike.ways.way.path': 'Ösvény', + 'tours.bike.ways.way.ferry': 'Komp', + 'tours.bike.ways.way.other': 'Egyéb', + 'tours.bike.ways.loading': 'Az útvonal mentén lévő utak beolvasása…', + 'tours.bike.ways.offline': 'A burkolathoz és a nehézséghez kapcsolat kell.', + 'tours.bike.ways.failed': 'Az útvonal menti utakat nem sikerült beolvasni. Maga az útvonal nem változik.', + 'tours.bike.ways.source': + 'Az OpenStreetMapből, ahogy az útvonaltervező olvassa. Ahol az adat hallgat, a burkolat ismeretlen, nem jó.', + 'tours.bike.ways.showOnMap': 'Mutasd a térképen', + 'tours.bike.ways.unpavedBadge': '{share} burkolatlan', + 'tours.bike.ways.unpavedNotice': 'Az útvonalból {length} ismerten burkolatlan.', + 'tours.bike.trail.title': 'Nehézségi fok (mtb:scale)', + 'tours.bike.trail.s0': 'S0, különösebb nehézség nélkül', + 'tours.bike.trail.s1': 'S1, kisebb akadályok', + 'tours.bike.trail.s2': 'S2, gyökerek, lépcsők, laza talaj', + 'tours.bike.trail.s3': 'S3, sziklák, meredek, szűk fordulók', + 'tours.bike.trail.s4': 'S4 és fölötte, haladó terep', + 'tours.bike.trail.none': 'Nincs fok az adatokban', + 'tours.bike.trail.unasked': 'Ezúttal nem lehetett lekérdezni', + 'tours.bike.trail.uphillTitle': 'Emelkedő nehézsége (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Emelkedő {step}', + 'tours.bike.trail.note': + 'A fokok az OpenStreetMap-térképezők szerint. A fok nélküli út ismeretlen, nem könnyű. Semmit sem mondanak arról, hogy egy ösvény neked való-e.', + 'tours.bike.trail.loading': 'Nehézségi fokok beolvasása…', + 'tours.bike.trail.failed': 'A nehézségi fokokat ezúttal nem sikerült beolvasni.', 'tours.copy.action': 'Mentés új túraként', 'tours.copy.hint': 'Ezt a túrát új, önálló túraként másolja a napjai nélkül. Az eredeti változatlan marad.', 'tours.copy.name': '{name} (másolat)', @@ -358,6 +426,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'A GPX-fájl letöltéséhez kapcsolat kell a szerverrel.', 'tours.gpxExport.noRoute': 'Ennek a túrának nincs exportálható mentett útvonala.', 'tours.gpxExport.failed': 'A GPX-fájlt nem sikerült létrehozni.', + 'tours.gpxExport.share': 'GPX megosztása', + 'tours.gpxExport.shareShort': 'Megosztás', + 'tours.gpxExport.shareSaveFirst': 'Előbb mentsd a túrát, hogy GPX-ként megoszthasd', + 'tours.gpxExport.shareReady': 'A GPX-fájl kész. Koppints még egyszer a Megosztásra a megosztási ablak megnyitásához.', + 'tours.gpxExport.shareFailed': 'A megosztás nem sikerült. A GPX-fájl továbbra is letölthető.', 'tours.revision.remoteTitle': 'Ezt a túrát máshol mentették', 'tours.revision.remoteBody': 'Valaki, vagy te egy másik eszközön, elmentette, miközben szerkesztetted. A módosításaid megmaradnak, amíg nem döntesz.', @@ -388,15 +461,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Mit tesz az útvonalválasztás', 'tours.preferences.info': 'Ezek a beállítások azt súlyozzák, melyik utat választja az útvonaltervező. Ahol nincs más út, az útvonal mégis arra vezet. A nehézségről nem mondanak semmit; hogy mit használhat az útvonal, a fenti nehézségi korlát szabja meg.', + 'tours.preferences.avoid_climbs': 'Meredek emelkedők kerülése', + 'tours.preferences.avoid_climbsHint': + 'Enyhébb utak, ahol vannak, még ha hosszabbak is. Ahol nincs kerülőút, meredek marad.', 'tours.preferences.avoid_steps': 'Lépcsők kerülése', 'tours.preferences.avoid_stepsHint': 'Lépcső csak ott, ahol nincs kerülőút.', 'tours.preferences.avoid_ferries': 'Kompok kerülése', 'tours.preferences.avoid_ferriesHint': 'Szárazföldön, ahol van kerülőút, akkor is, ha hosszabb.', - 'tours.preferences.prefer_paths': 'Gyalogutak előnyben', - 'tours.preferences.prefer_pathsHint': 'Önálló utak az utak és járdáik helyett, ahol vannak.', - 'tours.preferences.shortest': 'Rövidebb a gyorsabb helyett', - 'tours.preferences.shortestHint': - 'Az utat a hossza szerint választja, nem az idő szerint; gyakran kicsit rövidebb, nem mindig gyorsabb.', 'tours.planner.tourType': 'Túratípus', 'tours.paddle.boat': 'Hajó', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 4d7b887db..1c355d6af 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -713,6 +713,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Peta pendakian (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Menawarkan OpenHikingMap sebagai peta dasar di perencana tur. Gratis untuk instalasi nonkomersial hingga 500.000 ubin per bulan; instalasi komersial atau yang lebih besar perlu perjanjian dengan openmaps.fr terlebih dahulu', + 'admin.toursCycleMap.title': 'Peta sepeda (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Menawarkan CyclOSM sebagai peta dasar untuk tur sepeda di perencana tur. OpenStreetMap France menyediakannya untuk penggunaan wajar; instalasi dengan penggunaan berat atau komersial mematikannya atau terlebih dahulu membuat kesepakatan dengan operator', 'admin.addons.noAddons': 'Tidak ada addon yang tersedia', 'admin.weather.title': 'Data Cuaca', 'admin.weather.badge': 'Sejak 24 Maret 2026', diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts index de5627376..fe62dd135 100644 --- a/shared/src/i18n/id/map.ts +++ b/shared/src/i18n/id/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satelit', 'map.baseLayer.hiking': 'Pendakian', 'map.baseLayer.hikingDown': 'OpenHikingMap sedang tidak merespons. Beralih ke peta lain; turmu tetap seperti semula.', + 'map.baseLayer.cycle': 'Sepeda', + 'map.baseLayer.cycleDown': 'CyclOSM sedang tidak merespons. Beralih ke peta lain; turmu tetap seperti semula.', 'map.baseLayer.switchToSatellite': 'Beralih ke tampilan satelit', 'map.baseLayer.switchToDefault': 'Beralih ke tampilan peta', 'map.location.denied': diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 47ae99964..e19a573fe 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -92,6 +92,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigasi membuka titik awal jalur, bukan memberikan panduan sepanjang rute.', 'tours.planner.title': 'Rencanakan tur', 'tours.planner.newTour': 'Rencanakan tur baru', + 'tours.planner.addTour': 'Tambah tur', + 'tours.planner.addTourHint': 'Rencanakan tur baru, impor file GPX, atau ambil tur dari perjalanan lain', 'tours.planner.neutralTitle': 'Rencanakan tur', 'tours.planner.neutralBody': 'Mulai tur baru dan klik peta untuk menempatkan titik-titiknya.', 'tours.planner.newTitle': 'Tur baru', @@ -108,6 +110,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Perluas profil elevasi', 'tours.planner.name': 'Nama tur', 'tours.planner.namePlaceholder': 'Pendakian tanpa judul', + 'tours.planner.namePlaceholder.bike': 'Tur sepeda tanpa nama', + 'tours.planner.namePlaceholder.kayak': 'Tur dayung tanpa nama', 'tours.planner.mapSearch': 'Cari tempat atau alamat', 'tours.planner.placePoints': 'Tempatkan titik', 'tours.planner.pointPlaced': 'Titik ditempatkan', @@ -170,6 +174,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Menghitung rute berjalan kaki...', 'tours.planner.status.routingFailed': 'Perhitungan rute gagal. Rute terakhir yang berhasil dihitung tetap ditampilkan.', + 'tours.planner.status.unreachable': 'Titik {point} tidak dapat dicapai', + 'tours.planner.unreachable.hike': + 'Tidak ada jalur pendakian yang mencapai titik {point}: yang terdekat yang diketahui perute berjarak {distance}. Pindahkan titik ke jalur, atau gambar ruas ke sana secara manual.', + 'tours.planner.unreachable.bike': + 'Tidak ada jalan untuk sepeda yang mencapai titik {point}: yang terdekat yang diketahui perute berjarak {distance}. Pindahkan titik ke jalan, atau gambar ruas ke sana secara manual.', + 'tours.planner.unreachable.show': 'Tampilkan titik {point}', 'tours.planner.status.elevation': 'Memuat elevasi...', 'tours.planner.status.elevationFailed': 'Elevasi tidak tersedia. Rute yang telah dihitung tetap ditampilkan.', 'tours.planner.status.saving': 'Menyimpan...', @@ -339,6 +349,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Tempat menarik', 'tours.poi.cat.slipway': 'Titik masuk air', 'tours.poi.cat.boat_rental': 'Penyewaan perahu', + 'tours.poi.cat.bike_repair': 'Bengkel sepeda', + 'tours.planner.tourTypeHint.hike': 'Jalan kaki, dalam tingkat kesulitan yang kamu tentukan', + 'tours.planner.tourTypeHint.bike': 'Dengan sepeda, rute untuk touring, road, gravel, atau MTB', + 'tours.planner.tourTypeHint.kayak': 'Di air, dengan kayak, kano, atau SUP', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Touring', // same-as-en + 'tours.bike.profile.road': 'Road', // same-as-en + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Mengutamakan jalur sepeda dan jalan sepi, beraspal bila bisa.', + 'tours.bike.profileHint.road': 'Tetap di aspal; ruas tak beraspal yang diketahui ditampilkan.', + 'tours.bike.profileHint.gravel': 'Melewati kerikil dan jalan tani, bukan singletrail teknis.', + 'tours.bike.profileHint.mtb': 'Melewati jalur kasar apa adanya; tingkat jalur ditampilkan bila dipetakan.', + 'tours.bike.timeEstimated': 'Waktu bersepeda (perkiraan)', + 'tours.bike.timeEstimatedShort': 'Waktu bersepeda', + 'tours.bike.timeEstimatedInfoLabel': 'Cara waktu bersepeda dihitung', + 'tours.bike.timeEstimatedInfo': + 'Waktu dari perute untuk profil yang dipilih, termasuk kecepatan dan tanjakan. Nilai perencanaan, tanpa istirahat.', + 'tours.bike.ways.title': 'Permukaan dan jalan', + 'tours.bike.ways.surface': 'Permukaan', + 'tours.bike.ways.surface.paved': 'Aspal', + 'tours.bike.ways.surface.rough': 'Perkerasan kasar', + 'tours.bike.ways.surface.compacted': 'Dipadatkan', + 'tours.bike.ways.surface.gravel': 'Kerikil', + 'tours.bike.ways.surface.ground': 'Tanah alami', + 'tours.bike.ways.surface.unknown': 'Tidak ada di data', + 'tours.bike.ways.way': 'Jenis jalan', + 'tours.bike.ways.way.cyclePath': 'Jalur sepeda', + 'tours.bike.ways.way.cycleLane': 'Lajur sepeda di samping lalu lintas', + 'tours.bike.ways.way.quiet': 'Jalan sepi', + 'tours.bike.ways.way.road': 'Jalan ramai', + 'tours.bike.ways.way.track': 'Jalan tani atau hutan', + 'tours.bike.ways.way.path': 'Jalan setapak', + 'tours.bike.ways.way.ferry': 'Feri', + 'tours.bike.ways.way.other': 'Lainnya', + 'tours.bike.ways.loading': 'Membaca jalan di sepanjang rute…', + 'tours.bike.ways.offline': 'Permukaan dan tingkat jalur memerlukan koneksi.', + 'tours.bike.ways.failed': 'Jalan di sepanjang rute tidak dapat dibaca. Rutenya sendiri tidak berubah.', + 'tours.bike.ways.source': + 'Dari OpenStreetMap, sebagaimana dibaca perute. Bila data tidak menyebut apa pun, permukaannya tidak diketahui, bukan baik.', + 'tours.bike.ways.showOnMap': 'Tampilkan di peta', + 'tours.bike.ways.unpavedBadge': '{share} tak beraspal', + 'tours.bike.ways.unpavedNotice': '{length} rute diketahui tidak beraspal.', + 'tours.bike.trail.title': 'Tingkat jalur (mtb:scale)', + 'tours.bike.trail.s0': 'S0, tanpa kesulitan khusus', + 'tours.bike.trail.s1': 'S1, rintangan kecil', + 'tours.bike.trail.s2': 'S2, akar, undakan, tanah gembur', + 'tours.bike.trail.s3': 'S3, bebatuan, curam, tikungan tajam', + 'tours.bike.trail.s4': 'S4 ke atas, medan ahli', + 'tours.bike.trail.none': 'Tidak ada tingkat di data', + 'tours.bike.trail.unasked': 'Tidak dapat ditanyakan kali ini', + 'tours.bike.trail.uphillTitle': 'Tingkat tanjakan (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Tanjakan {step}', + 'tours.bike.trail.note': + 'Tingkat sebagaimana diberikan pemeta OpenStreetMap. Jalan tanpa tingkat tidak diketahui, bukan mudah. Ini tidak menyatakan apakah jalur cocok untukmu.', + 'tours.bike.trail.loading': 'Membaca tingkat jalur…', + 'tours.bike.trail.failed': 'Tingkat jalur tidak dapat dibaca kali ini.', 'tours.copy.action': 'Simpan sebagai tur baru', 'tours.copy.hint': 'Menyalin tur ini sebagai tur baru yang mandiri tanpa hari-harinya. Aslinya tetap seperti semula.', 'tours.copy.name': '{name} (salinan)', @@ -351,6 +418,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Mengunduh file GPX memerlukan koneksi ke server.', 'tours.gpxExport.noRoute': 'Tur ini tidak punya rute tersimpan untuk diekspor.', 'tours.gpxExport.failed': 'File GPX tidak dapat dibuat.', + 'tours.gpxExport.share': 'Bagikan GPX', + 'tours.gpxExport.shareShort': 'Bagikan', + 'tours.gpxExport.shareSaveFirst': 'Simpan tur dulu untuk membagikannya sebagai GPX', + 'tours.gpxExport.shareReady': 'File GPX sudah siap. Ketuk Bagikan sekali lagi untuk membuka lembar berbagi.', + 'tours.gpxExport.shareFailed': 'Berbagi tidak berhasil. File GPX masih bisa diunduh.', 'tours.revision.remoteTitle': 'Tur ini disimpan di tempat lain', 'tours.revision.remoteBody': 'Seseorang, atau kamu di perangkat lain, menyimpannya saat kamu mengedit. Perubahanmu tetap ada sampai kamu memutuskan.', @@ -381,15 +453,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Apa yang dilakukan pilihan rute', 'tours.preferences.info': 'Pilihan ini memberi bobot pada jalan yang dipilih perute. Jika tidak ada jalan lain, rute tetap lewat sana. Pilihan ini tidak mengatakan apa pun soal tingkat kesulitan jalur; batas kesulitan di atas menentukan apa yang boleh dipakai rute.', + 'tours.preferences.avoid_climbs': 'Hindari tanjakan curam', + 'tours.preferences.avoid_climbsHint': + 'Jalan yang lebih landai jika ada, meski lebih panjang. Jika tidak ada jalan memutar, tetap curam.', 'tours.preferences.avoid_steps': 'Hindari tangga', 'tours.preferences.avoid_stepsHint': 'Tangga hanya jika tidak ada jalan memutar.', 'tours.preferences.avoid_ferries': 'Hindari feri', 'tours.preferences.avoid_ferriesHint': 'Lewat darat jika ada jalan memutar, meski lebih panjang.', - 'tours.preferences.prefer_paths': 'Utamakan jalur pejalan kaki', - 'tours.preferences.prefer_pathsHint': 'Jalur tersendiri alih-alih jalan raya dan trotoarnya, bila ada.', - 'tours.preferences.shortest': 'Lebih pendek ketimbang lebih cepat', - 'tours.preferences.shortestHint': - 'Jalan dipilih berdasarkan panjangnya, bukan waktunya; sering sedikit lebih pendek, tidak selalu lebih cepat.', 'tours.planner.tourType': 'Jenis tur', 'tours.paddle.boat': 'Perahu', 'tours.paddle.boat.kayak': 'Kayak', // same-as-en diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 9ae087b40..423aae60d 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -669,6 +669,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Mappa escursionistica (OpenHikingMap)', 'admin.toursHikingMap.subtitle': "Offre OpenHikingMap come mappa di base nel pianificatore di percorsi. Gratuito per installazioni non commerciali fino a 500.000 tile al mese; un'installazione commerciale o più grande richiede prima un accordo con openmaps.fr", + 'admin.toursCycleMap.title': 'Mappa ciclabile (CyclOSM)', + 'admin.toursCycleMap.subtitle': + "Offre CyclOSM come mappa di base per i giri in bici nel pianificatore. OpenStreetMap France la fornisce per un uso moderato; un'installazione molto usata o commerciale la disattiva o prende prima un accordo con il gestore", 'admin.addons.noAddons': 'Nessun modulo disponibile', 'admin.weather.title': 'Dati meteo', 'admin.weather.badge': 'Dal 24 marzo 2026', diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts index 34fbcd59c..7ed78b307 100644 --- a/shared/src/i18n/it/map.ts +++ b/shared/src/i18n/it/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Escursionismo', 'map.baseLayer.hikingDown': "OpenHikingMap al momento non risponde. Passa a un'altra mappa; il tuo percorso resta com'è.", + 'map.baseLayer.cycle': 'Bici', + 'map.baseLayer.cycleDown': "CyclOSM al momento non risponde. Passa a un'altra mappa; il tuo giro resta com'è.", 'map.baseLayer.switchToSatellite': 'Passa alla vista satellitare', 'map.baseLayer.switchToDefault': 'Passa alla vista mappa', 'map.location.denied': diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 479ffeb56..2721b4753 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -96,6 +96,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'La navigazione apre il punto di partenza del sentiero; non guida lungo il percorso.', 'tours.planner.title': "Pianifica un'escursione", 'tours.planner.newTour': 'Pianifica nuova escursione', + 'tours.planner.addTour': 'Aggiungi giro', + 'tours.planner.addTourHint': 'Pianifica un nuovo giro, importa file GPX o riprendi un giro da un altro viaggio', 'tours.planner.neutralTitle': "Pianifica un'escursione", 'tours.planner.neutralBody': 'Inizia un nuovo percorso e fai clic sulla mappa per posizionarne i punti.', 'tours.planner.newTitle': 'Nuova escursione', @@ -112,6 +114,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Espandi profilo altimetrico', 'tours.planner.name': "Nome dell'escursione", 'tours.planner.namePlaceholder': 'Escursione a piedi senza titolo', + 'tours.planner.namePlaceholder.bike': 'Giro in bici senza nome', + 'tours.planner.namePlaceholder.kayak': 'Uscita in canoa senza nome', 'tours.planner.mapSearch': 'Cerca luogo o indirizzo', 'tours.planner.placePoints': 'Posiziona punti', 'tours.planner.pointPlaced': 'Punto posizionato', @@ -175,6 +179,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'In attesa del calcolo', 'tours.planner.status.routing': 'Calcolo del percorso a piedi…', 'tours.planner.status.routingFailed': "Calcolo del percorso non riuscito. L'ultimo percorso valido resta visibile.", + 'tours.planner.status.unreachable': 'Il punto {point} non è raggiungibile', + 'tours.planner.unreachable.hike': + 'Nessun sentiero raggiunge il punto {point}: il più vicino che il router conosce è a {distance}. Sposta il punto su un sentiero o disegna a mano il tratto fin lì.', + 'tours.planner.unreachable.bike': + 'Nessuna strada percorribile in bici raggiunge il punto {point}: la più vicina che il router conosce è a {distance}. Sposta il punto su una strada o disegna a mano il tratto fin lì.', + 'tours.planner.unreachable.show': 'Mostra il punto {point}', 'tours.planner.status.elevation': 'Caricamento delle quote…', 'tours.planner.status.elevationFailed': 'Le quote non sono disponibili. Il percorso calcolato resta visibile.', 'tours.planner.status.saving': 'Salvataggio…', @@ -347,6 +357,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Da vedere', 'tours.poi.cat.slipway': 'Punti di imbarco', 'tours.poi.cat.boat_rental': 'Noleggio barche', + 'tours.poi.cat.bike_repair': 'Riparazione bici', + 'tours.planner.tourTypeHint.hike': 'A piedi, entro una difficoltà che scegli tu', + 'tours.planner.tourTypeHint.bike': 'In bici, percorso per cicloturismo, strada, gravel o MTB', + 'tours.planner.tourTypeHint.kayak': 'In acqua, in kayak, canoa o SUP', + 'tours.bike.profile': 'Profilo', + 'tours.bike.profile.touring': 'Turismo', + 'tours.bike.profile.road': 'Strada', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Preferisce piste ciclabili e strade tranquille, asfaltate dove possibile.', + 'tours.bike.profileHint.road': "Resta sull'asfalto; i tratti sterrati noti vengono mostrati.", + 'tours.bike.profileHint.gravel': 'Prende ghiaia e strade di campagna, non singletrack tecnici.', + 'tours.bike.profileHint.mtb': 'Prende i sentieri sconnessi come vengono; le difficoltà sono mostrate dove mappate.', + 'tours.bike.timeEstimated': 'Tempo in bici (stimato)', + 'tours.bike.timeEstimatedShort': 'Tempo in bici', + 'tours.bike.timeEstimatedInfoLabel': 'Come si calcola il tempo in bici', + 'tours.bike.timeEstimatedInfo': + 'Il tempo del router per il profilo scelto, con la sua velocità e le salite. Un valore di pianificazione, pause escluse.', + 'tours.bike.ways.title': 'Fondo e percorsi', + 'tours.bike.ways.surface': 'Fondo', + 'tours.bike.ways.surface.paved': 'Asfalto', + 'tours.bike.ways.surface.rough': 'Pavé irregolare', + 'tours.bike.ways.surface.compacted': 'Battuto', + 'tours.bike.ways.surface.gravel': 'Ghiaia', + 'tours.bike.ways.surface.ground': 'Terreno naturale', + 'tours.bike.ways.surface.unknown': 'Non nei dati', + 'tours.bike.ways.way': 'Tipo di percorso', + 'tours.bike.ways.way.cyclePath': 'Pista ciclabile', + 'tours.bike.ways.way.cycleLane': 'Corsia ciclabile accanto al traffico', + 'tours.bike.ways.way.quiet': 'Strada tranquilla', + 'tours.bike.ways.way.road': 'Strada trafficata', + 'tours.bike.ways.way.track': 'Strada agricola o forestale', + 'tours.bike.ways.way.path': 'Sentiero', + 'tours.bike.ways.way.ferry': 'Traghetto', + 'tours.bike.ways.way.other': 'Altro', + 'tours.bike.ways.loading': "Lettura dei percorsi lungo l'itinerario…", + 'tours.bike.ways.offline': 'Fondo e difficoltà dei sentieri richiedono una connessione.', + 'tours.bike.ways.failed': "Impossibile leggere i percorsi lungo l'itinerario. L'itinerario resta invariato.", + 'tours.bike.ways.source': + 'Da OpenStreetMap, come lo legge il router. Dove i dati non dicono nulla, il fondo è sconosciuto, non buono.', + 'tours.bike.ways.showOnMap': 'Mostra sulla mappa', + 'tours.bike.ways.unpavedBadge': '{share} sterrato', + 'tours.bike.ways.unpavedNotice': '{length} del percorso risultano sterrati.', + 'tours.bike.trail.title': 'Difficoltà del sentiero (mtb:scale)', + 'tours.bike.trail.s0': 'S0, nessuna difficoltà particolare', + 'tours.bike.trail.s1': 'S1, piccoli ostacoli', + 'tours.bike.trail.s2': 'S2, radici, gradini, fondo smosso', + 'tours.bike.trail.s3': 'S3, massi, ripido, tornanti stretti', + 'tours.bike.trail.s4': 'S4 e oltre, terreno da esperti', + 'tours.bike.trail.none': 'Nessun grado nei dati', + 'tours.bike.trail.unasked': 'Non interrogabile questa volta', + 'tours.bike.trail.uphillTitle': 'Difficoltà in salita (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Salita {step}', + 'tours.bike.trail.note': + "Gradi come li hanno assegnati i mappatori di OpenStreetMap. Un percorso senza grado è sconosciuto, non facile. Non dicono nulla sull'adattezza del sentiero a te.", + 'tours.bike.trail.loading': 'Lettura delle difficoltà dei sentieri…', + 'tours.bike.trail.failed': 'Questa volta non è stato possibile leggere le difficoltà dei sentieri.', 'tours.copy.action': 'Salva come nuovo tour', 'tours.copy.hint': 'Copia questo tour come nuovo tour indipendente, senza i suoi giorni. L’originale resta com’è.', 'tours.copy.name': '{name} (copia)', @@ -359,6 +426,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Per scaricare un file GPX serve una connessione al server.', 'tours.gpxExport.noRoute': 'Questo tour non ha un percorso salvato da esportare.', 'tours.gpxExport.failed': 'Non è stato possibile creare il file GPX.', + 'tours.gpxExport.share': 'Condividi GPX', + 'tours.gpxExport.shareShort': 'Condividi', + 'tours.gpxExport.shareSaveFirst': 'Salva prima il giro per condividerlo come GPX', + 'tours.gpxExport.shareReady': 'Il file GPX è pronto. Tocca di nuovo Condividi per aprire il menu di condivisione.', + 'tours.gpxExport.shareFailed': 'La condivisione non è riuscita. Il file GPX si può comunque scaricare.', 'tours.revision.remoteTitle': 'Questo tour è stato salvato altrove', 'tours.revision.remoteBody': 'Qualcuno, o tu su un altro dispositivo, l’ha salvato mentre lo modificavi. Le tue modifiche restano finché non decidi.', @@ -389,15 +461,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Cosa fa la scelta del percorso', 'tours.preferences.info': 'Queste scelte pesano la strada che sceglie il calcolo del percorso. Dove non c’è un’altra via, il percorso passa comunque di lì. Non dicono nulla sulla difficoltà di un sentiero; il limite di difficoltà sopra stabilisce cosa può usare il percorso.', + 'tours.preferences.avoid_climbs': 'Evita salite ripide', + 'tours.preferences.avoid_climbsHint': + "Percorsi più dolci dove ci sono, anche se più lunghi. Dove non c'è modo di aggirare, resta ripido.", 'tours.preferences.avoid_steps': 'Evita le scale', 'tours.preferences.avoid_stepsHint': 'Scale solo dove non c’è modo di aggirarle.', 'tours.preferences.avoid_ferries': 'Evita i traghetti', 'tours.preferences.avoid_ferriesHint': 'Via terra dove c’è un modo di aggirare, anche se più lungo.', - 'tours.preferences.prefer_paths': 'Preferisci i sentieri', - 'tours.preferences.prefer_pathsHint': 'Sentieri propri invece di strade e marciapiedi, dove ci sono.', - 'tours.preferences.shortest': 'Più breve invece che più veloce', - 'tours.preferences.shortestHint': - 'La strada scelta per lunghezza, non per tempo; spesso un po’ più breve, non sempre più veloce.', 'tours.planner.tourType': 'Tipo di tour', 'tours.paddle.boat': 'Imbarcazione', 'tours.paddle.boat.kayak': 'Kayak', // same-as-en diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 221260a06..1a3829528 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -676,6 +676,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'ハイキング地図 (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'ツアープランナーのベースマップとして OpenHikingMap を提供します。非商用の環境では月 500,000 タイルまで無料です。商用またはそれ以上の規模の環境では、先に openmaps.fr との合意が必要です', + 'admin.toursCycleMap.title': 'サイクリング地図(CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'ツアープランナーで自転車ツアーのベース地図として CyclOSM を提供します。OpenStreetMap France が適度な利用向けに配信しています。利用の多い、または商用のインストールでは無効にするか、事前に運営者と合意してください', 'admin.addons.noAddons': '利用可能なアドオンはありません', 'admin.weather.title': '天気データ', 'admin.weather.badge': '2026年3月24日以降', diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts index b55b41b61..f5b6dad37 100644 --- a/shared/src/i18n/ja/map.ts +++ b/shared/src/i18n/ja/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'ハイキング', 'map.baseLayer.hikingDown': 'OpenHikingMap が現在応答していません。別の地図に切り替えてください。ツアーはそのまま残ります。', + 'map.baseLayer.cycle': '自転車', + 'map.baseLayer.cycleDown': 'CyclOSM が現在応答していません。別の地図に切り替えてください。ツアーはそのままです。', 'map.baseLayer.switchToSatellite': '衛星表示に切り替える', 'map.baseLayer.switchToDefault': '地図表示に切り替える', 'map.location.denied': diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 8aa8eccc3..8cd417447 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -91,6 +91,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'ナビには登山口が表示されます。ルートに沿った案内は行いません。', 'tours.planner.title': 'ツアーを計画', 'tours.planner.newTour': '新しいツアーを計画', + 'tours.planner.addTour': 'ツアーを追加', + 'tours.planner.addTourHint': '新しいツアーを計画、GPX ファイルを読み込み、または別の旅行からツアーを引き継ぎます', 'tours.planner.neutralTitle': 'ツアーを計画', 'tours.planner.neutralBody': '新しいツアーを始めて、地図をクリックして経由地点を置きます。', 'tours.planner.newTitle': '新しいツアー', @@ -106,6 +108,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': '標高グラフを展開', 'tours.planner.name': 'ツアー名', 'tours.planner.namePlaceholder': '名前のないハイキング', + 'tours.planner.namePlaceholder.bike': '名前のないサイクリング', + 'tours.planner.namePlaceholder.kayak': '名前のないパドルツアー', 'tours.planner.mapSearch': '場所や住所を検索', 'tours.planner.placePoints': 'ポイントを配置', 'tours.planner.pointPlaced': 'ポイントを配置しました', @@ -167,6 +171,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': '計算待ち', 'tours.planner.status.routing': '徒歩ルートを計算中…', 'tours.planner.status.routingFailed': 'ルートの計算に失敗しました。直前に計算できたルートを引き続き表示しています。', + 'tours.planner.status.unreachable': 'ポイント {point} に到達できません', + 'tours.planner.unreachable.hike': + 'ポイント {point} に届く登山道がありません。ルーターが知る最も近い道は {distance} 先です。ポイントを道の上に移すか、その区間を手で描いてください。', + 'tours.planner.unreachable.bike': + 'ポイント {point} に届く自転車の通れる道がありません。ルーターが知る最も近い道は {distance} 先です。ポイントを道の上に移すか、その区間を手で描いてください。', + 'tours.planner.unreachable.show': 'ポイント {point} を表示', 'tours.planner.status.elevation': '標高を読み込み中…', 'tours.planner.status.elevationFailed': '標高を取得できません。計算済みのルートを引き続き表示しています。', 'tours.planner.status.saving': '保存中…', @@ -333,6 +343,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': '見どころ', 'tours.poi.cat.slipway': '入水地点', 'tours.poi.cat.boat_rental': 'ボートレンタル', + 'tours.poi.cat.bike_repair': '自転車修理', + 'tours.planner.tourTypeHint.hike': '徒歩、設定した難易度まで', + 'tours.planner.tourTypeHint.bike': '自転車で、ツーリング、ロード、グラベル、MTB向けのルート', + 'tours.planner.tourTypeHint.kayak': '水上で、カヤック、カヌー、SUPで', + 'tours.bike.profile': 'プロファイル', + 'tours.bike.profile.touring': 'ツーリング', + 'tours.bike.profile.road': 'ロード', + 'tours.bike.profile.gravel': 'グラベル', + 'tours.bike.profile.mtb': 'MTB', + 'tours.bike.profileHint.touring': '自転車道と静かな道を優先し、できる限り舗装路を選びます。', + 'tours.bike.profileHint.road': 'アスファルトを選び、既知の未舗装区間を表示します。', + 'tours.bike.profileHint.gravel': '砂利道や農道を通り、テクニカルなシングルトラックは避けます。', + 'tours.bike.profileHint.mtb': '荒れた道もそのまま通り、記録のある箇所はトレイル難度を表示します。', + 'tours.bike.timeEstimated': '走行時間(推定)', + 'tours.bike.timeEstimatedShort': '走行時間', + 'tours.bike.timeEstimatedInfoLabel': '走行時間の算出方法', + 'tours.bike.timeEstimatedInfo': + '選んだプロファイルの速度と登りを考慮したルーターの時間です。休憩を含まない計画値です。', + 'tours.bike.ways.title': '路面と道', + 'tours.bike.ways.surface': '路面', + 'tours.bike.ways.surface.paved': 'アスファルト', + 'tours.bike.ways.surface.rough': '荒れた舗装', + 'tours.bike.ways.surface.compacted': '締め固め', + 'tours.bike.ways.surface.gravel': '砂利', + 'tours.bike.ways.surface.ground': '未舗装の土', + 'tours.bike.ways.surface.unknown': 'データなし', + 'tours.bike.ways.way': '道の種類', + 'tours.bike.ways.way.cyclePath': '自転車道', + 'tours.bike.ways.way.cycleLane': '車道沿いの自転車レーン', + 'tours.bike.ways.way.quiet': '静かな道', + 'tours.bike.ways.way.road': '交通のある道路', + 'tours.bike.ways.way.track': '農道・林道', + 'tours.bike.ways.way.path': '小道', + 'tours.bike.ways.way.ferry': 'フェリー', + 'tours.bike.ways.way.other': 'その他', + 'tours.bike.ways.loading': 'ルート沿いの道を読み込み中…', + 'tours.bike.ways.offline': '路面とトレイル難度には接続が必要です。', + 'tours.bike.ways.failed': 'ルート沿いの道を読み込めませんでした。ルート自体は変わりません。', + 'tours.bike.ways.source': + 'ルーターが読み取った OpenStreetMap のデータです。記載がない箇所の路面は不明であり、良好という意味ではありません。', + 'tours.bike.ways.showOnMap': '地図に表示', + 'tours.bike.ways.unpavedBadge': '未舗装 {share}', + 'tours.bike.ways.unpavedNotice': 'ルートのうち {length} が未舗装と分かっています。', + 'tours.bike.trail.title': 'トレイル難度(mtb:scale)', + 'tours.bike.trail.s0': 'S0、特に難しさなし', + 'tours.bike.trail.s1': 'S1、小さな障害物', + 'tours.bike.trail.s2': 'S2、木の根、段差、緩い地面', + 'tours.bike.trail.s3': 'S3、岩、急斜面、急カーブ', + 'tours.bike.trail.s4': 'S4以上、上級者向け', + 'tours.bike.trail.none': 'データに難度なし', + 'tours.bike.trail.unasked': '今回は取得できませんでした', + 'tours.bike.trail.uphillTitle': '登りの難度(mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': '登り {step}', + 'tours.bike.trail.note': + 'OpenStreetMap のマッパーが付けた難度です。難度のない道は簡単ではなく不明です。そのトレイルがあなたに合うかどうかは示しません。', + 'tours.bike.trail.loading': 'トレイル難度を読み込み中…', + 'tours.bike.trail.failed': '今回はトレイル難度を読み込めませんでした。', 'tours.copy.action': '新しいツアーとして保存', 'tours.copy.hint': 'このツアーを日程なしの独立した新しいツアーとしてコピーします。元のツアーはそのままです。', 'tours.copy.name': '{name}(コピー)', @@ -345,6 +412,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'GPXファイルのダウンロードにはサーバーへの接続が必要です。', 'tours.gpxExport.noRoute': 'このツアーにはエクスポートできる保存済みルートがありません。', 'tours.gpxExport.failed': 'GPXファイルを作成できませんでした。', + 'tours.gpxExport.share': 'GPX を共有', + 'tours.gpxExport.shareShort': '共有', + 'tours.gpxExport.shareSaveFirst': 'GPX として共有するには先にツアーを保存してください', + 'tours.gpxExport.shareReady': 'GPX ファイルの準備ができました。もう一度「共有」をタップすると共有シートが開きます。', + 'tours.gpxExport.shareFailed': '共有できませんでした。GPX ファイルは引き続きダウンロードできます。', 'tours.revision.remoteTitle': 'このツアーは別の場所で保存されました', 'tours.revision.remoteBody': '編集中に、誰か(または別の端末のあなた)が保存しました。変更は決めるまで残ります。', 'tours.revision.loadNewer': '新しい版を読み込む', @@ -373,15 +445,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'ルートの選び方の働き', 'tours.preferences.info': 'これらの選択はルート計算が選ぶ道の重み付けです。ほかに道がない場合は、それでもその道を通ります。道の難しさについては何も示しません。ルートが使える道は上の難易度上限で決まります。', + 'tours.preferences.avoid_climbs': '急な登りを避ける', + 'tours.preferences.avoid_climbsHint': + 'あればより緩やかな道を選びます。長くなる場合もあります。迂回路がない所は急なままです。', 'tours.preferences.avoid_steps': '階段を避ける', 'tours.preferences.avoid_stepsHint': '回り道がない場所だけ階段を通ります。', 'tours.preferences.avoid_ferries': 'フェリーを避ける', 'tours.preferences.avoid_ferriesHint': '回り道があれば、長くても陸路を通ります。', - 'tours.preferences.prefer_paths': '歩道を優先', - 'tours.preferences.prefer_pathsHint': 'あれば、車道や歩道より独立した道を通ります。', - 'tours.preferences.shortest': '速さより短さ', - 'tours.preferences.shortestHint': - '所要時間ではなく距離で道を選びます。少し短くなることが多いですが、速いとは限りません。', 'tours.planner.tourType': 'ツアーの種類', 'tours.paddle.boat': 'ボート', 'tours.paddle.boat.kayak': 'カヤック', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 1cbceb40e..a7d5dd224 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -674,6 +674,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': '하이킹 지도 (OpenHikingMap)', 'admin.toursHikingMap.subtitle': '투어 플래너에서 OpenHikingMap을 기본 지도로 제공합니다. 비상업적 설치는 월 500,000 타일까지 무료이며, 상업적이거나 더 큰 설치는 먼저 openmaps.fr과의 협의가 필요합니다', + 'admin.toursCycleMap.title': '자전거 지도 (CyclOSM)', + 'admin.toursCycleMap.subtitle': + '투어 플래너에서 자전거 투어의 기본 지도로 CyclOSM을 제공합니다. OpenStreetMap France가 적당한 사용을 위해 제공하며, 사용량이 많거나 상업적인 설치는 이를 끄거나 먼저 운영자와 합의합니다', 'admin.addons.noAddons': '사용 가능한 애드온이 없습니다', 'admin.weather.title': '날씨 데이터', 'admin.weather.badge': '2026년 3월 24일부터', diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts index 455bd44d2..b72ed5bbd 100644 --- a/shared/src/i18n/ko/map.ts +++ b/shared/src/i18n/ko/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': '하이킹', 'map.baseLayer.hikingDown': 'OpenHikingMap이 지금 응답하지 않습니다. 다른 지도로 전환하세요. 투어는 그대로 유지됩니다.', + 'map.baseLayer.cycle': '자전거', + 'map.baseLayer.cycleDown': 'CyclOSM이 지금 응답하지 않습니다. 다른 지도로 바꾸세요. 투어는 그대로입니다.', 'map.baseLayer.switchToSatellite': '위성 보기로 전환', 'map.baseLayer.switchToDefault': '지도 보기로 전환', 'map.location.denied': diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index d2ab23b6e..852e1dd88 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -88,6 +88,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': '길찾기는 탐방로 입구를 목적지로 엽니다. 투어 경로를 따라 안내하지는 않습니다.', 'tours.planner.title': '투어 계획', 'tours.planner.newTour': '새 투어 계획', + 'tours.planner.addTour': '투어 추가', + 'tours.planner.addTourHint': '새 투어를 계획하거나 GPX 파일을 가져오거나 다른 여행의 투어를 가져옵니다', 'tours.planner.neutralTitle': '투어 계획', 'tours.planner.neutralBody': '새 투어를 시작하고 지도를 클릭해 경유 지점을 놓으세요.', 'tours.planner.newTitle': '새 투어', @@ -104,6 +106,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': '고도 그래프 펼치기', 'tours.planner.name': '투어 이름', 'tours.planner.namePlaceholder': '이름 없는 하이킹', + 'tours.planner.namePlaceholder.bike': '이름 없는 자전거 투어', + 'tours.planner.namePlaceholder.kayak': '이름 없는 패들 투어', 'tours.planner.mapSearch': '장소 또는 주소 검색', 'tours.planner.placePoints': '지점 찍기', 'tours.planner.pointPlaced': '지점을 찍었습니다', @@ -165,6 +169,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': '계산 대기 중', 'tours.planner.status.routing': '도보 경로 계산 중…', 'tours.planner.status.routingFailed': '경로 계산에 실패했습니다. 마지막으로 계산에 성공한 경로를 계속 표시합니다.', + 'tours.planner.status.unreachable': '지점 {point}에 도달할 수 없습니다', + 'tours.planner.unreachable.hike': + '지점 {point}에 닿는 등산로가 없습니다. 라우터가 아는 가장 가까운 길은 {distance} 떨어져 있습니다. 지점을 길 위로 옮기거나 그 구간을 직접 그리세요.', + 'tours.planner.unreachable.bike': + '지점 {point}에 닿는 자전거 길이 없습니다. 라우터가 아는 가장 가까운 길은 {distance} 떨어져 있습니다. 지점을 길 위로 옮기거나 그 구간을 직접 그리세요.', + 'tours.planner.unreachable.show': '지점 {point} 보기', 'tours.planner.status.elevation': '고도 불러오는 중…', 'tours.planner.status.elevationFailed': '고도를 불러올 수 없습니다. 계산된 경로를 계속 표시합니다.', 'tours.planner.status.saving': '저장 중…', @@ -330,6 +340,62 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': '볼거리', 'tours.poi.cat.slipway': '입수 지점', 'tours.poi.cat.boat_rental': '보트 대여', + 'tours.poi.cat.bike_repair': '자전거 수리', + 'tours.planner.tourTypeHint.hike': '도보로, 설정한 난이도 안에서', + 'tours.planner.tourTypeHint.bike': '자전거로, 투어링, 로드, 그래블, MTB에 맞춘 경로', + 'tours.planner.tourTypeHint.kayak': '물 위에서, 카약, 카누 또는 SUP로', + 'tours.bike.profile': '프로필', + 'tours.bike.profile.touring': '투어링', + 'tours.bike.profile.road': '로드', + 'tours.bike.profile.gravel': '그래블', + 'tours.bike.profile.mtb': 'MTB', + 'tours.bike.profileHint.touring': '자전거도로와 한적한 도로를 우선하며, 가능하면 포장도로를 택합니다.', + 'tours.bike.profileHint.road': '아스팔트를 따르며, 알려진 비포장 구간을 표시합니다.', + 'tours.bike.profileHint.gravel': '자갈길과 농로를 따르고, 기술적인 싱글트랙은 피합니다.', + 'tours.bike.profileHint.mtb': '거친 길도 그대로 지나며, 기록된 곳에는 트레일 난이도를 표시합니다.', + 'tours.bike.timeEstimated': '주행 시간(추정)', + 'tours.bike.timeEstimatedShort': '주행 시간', + 'tours.bike.timeEstimatedInfoLabel': '주행 시간 계산 방식', + 'tours.bike.timeEstimatedInfo': '선택한 프로필의 속도와 오르막을 반영한 라우터 시간입니다. 휴식을 뺀 계획 값입니다.', + 'tours.bike.ways.title': '노면과 길', + 'tours.bike.ways.surface': '노면', + 'tours.bike.ways.surface.paved': '아스팔트', + 'tours.bike.ways.surface.rough': '거친 포장', + 'tours.bike.ways.surface.compacted': '다짐길', + 'tours.bike.ways.surface.gravel': '자갈', + 'tours.bike.ways.surface.ground': '자연 노면', + 'tours.bike.ways.surface.unknown': '데이터 없음', + 'tours.bike.ways.way': '길의 종류', + 'tours.bike.ways.way.cyclePath': '자전거도로', + 'tours.bike.ways.way.cycleLane': '차도 옆 자전거 차선', + 'tours.bike.ways.way.quiet': '한적한 도로', + 'tours.bike.ways.way.road': '차량 통행 도로', + 'tours.bike.ways.way.track': '농로·임도', + 'tours.bike.ways.way.path': '오솔길', + 'tours.bike.ways.way.ferry': '페리', + 'tours.bike.ways.way.other': '기타', + 'tours.bike.ways.loading': '경로의 길 정보를 읽는 중…', + 'tours.bike.ways.offline': '노면과 트레일 난이도에는 연결이 필요합니다.', + 'tours.bike.ways.failed': '경로의 길 정보를 읽지 못했습니다. 경로 자체는 그대로입니다.', + 'tours.bike.ways.source': + '라우터가 읽은 OpenStreetMap 데이터입니다. 정보가 없는 곳의 노면은 좋은 것이 아니라 알 수 없는 것입니다.', + 'tours.bike.ways.showOnMap': '지도에 표시', + 'tours.bike.ways.unpavedBadge': '비포장 {share}', + 'tours.bike.ways.unpavedNotice': '경로 중 {length}가 비포장으로 알려져 있습니다.', + 'tours.bike.trail.title': '트레일 난이도 (mtb:scale)', + 'tours.bike.trail.s0': 'S0, 특별한 어려움 없음', + 'tours.bike.trail.s1': 'S1, 작은 장애물', + 'tours.bike.trail.s2': 'S2, 뿌리, 계단, 무른 노면', + 'tours.bike.trail.s3': 'S3, 바위, 급경사, 급커브', + 'tours.bike.trail.s4': 'S4 이상, 전문가 지형', + 'tours.bike.trail.none': '데이터에 난이도 없음', + 'tours.bike.trail.unasked': '이번에는 조회할 수 없었음', + 'tours.bike.trail.uphillTitle': '오르막 난이도 (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': '오르막 {step}', + 'tours.bike.trail.note': + 'OpenStreetMap 매퍼가 매긴 난이도입니다. 난이도가 없는 길은 쉬운 것이 아니라 알 수 없는 것입니다. 그 트레일이 당신에게 맞는지는 알려 주지 않습니다.', + 'tours.bike.trail.loading': '트레일 난이도를 읽는 중…', + 'tours.bike.trail.failed': '이번에는 트레일 난이도를 읽지 못했습니다.', 'tours.copy.action': '새 투어로 저장', 'tours.copy.hint': '이 투어를 날짜 없이 독립된 새 투어로 복사합니다. 원본은 그대로입니다.', 'tours.copy.name': '{name} (사본)', @@ -342,6 +408,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'GPX 파일을 다운로드하려면 서버 연결이 필요합니다.', 'tours.gpxExport.noRoute': '이 투어에는 내보낼 저장된 경로가 없습니다.', 'tours.gpxExport.failed': 'GPX 파일을 만들지 못했습니다.', + 'tours.gpxExport.share': 'GPX 공유', + 'tours.gpxExport.shareShort': '공유', + 'tours.gpxExport.shareSaveFirst': 'GPX로 공유하려면 먼저 투어를 저장하세요', + 'tours.gpxExport.shareReady': 'GPX 파일이 준비되었습니다. 공유 창을 열려면 공유를 한 번 더 누르세요.', + 'tours.gpxExport.shareFailed': '공유하지 못했습니다. GPX 파일은 계속 다운로드할 수 있습니다.', 'tours.revision.remoteTitle': '이 투어가 다른 곳에서 저장되었습니다', 'tours.revision.remoteBody': '편집하는 동안 누군가 또는 다른 기기의 내가 저장했습니다. 결정할 때까지 변경 내용은 그대로 남습니다.', @@ -371,15 +442,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': '경로 선택이 하는 일', 'tours.preferences.info': '이 선택은 경로 계산이 어떤 길을 고를지에 가중치를 줍니다. 다른 길이 없으면 경로는 그래도 그 길로 갑니다. 길의 난이도에 대해서는 아무것도 말하지 않으며, 경로가 쓸 수 있는 길은 위의 난이도 제한이 정합니다.', + 'tours.preferences.avoid_climbs': '가파른 오르막 피하기', + 'tours.preferences.avoid_climbsHint': + '더 완만한 길이 있으면 그 길로, 더 길어지더라도 택합니다. 돌아갈 길이 없으면 가파른 채로 남습니다.', 'tours.preferences.avoid_steps': '계단 피하기', 'tours.preferences.avoid_stepsHint': '돌아갈 길이 없을 때만 계단을 지납니다.', 'tours.preferences.avoid_ferries': '페리 피하기', 'tours.preferences.avoid_ferriesHint': '돌아가는 길이 있으면 더 길어도 육로로 갑니다.', - 'tours.preferences.prefer_paths': '보행로 우선', - 'tours.preferences.prefer_pathsHint': '있다면 도로와 인도 대신 별도의 길로 갑니다.', - 'tours.preferences.shortest': '빠르기보다 짧게', - 'tours.preferences.shortestHint': - '걸리는 시간이 아니라 길이로 길을 고릅니다. 대개 조금 짧지만 항상 빠르지는 않습니다.', 'tours.planner.tourType': '투어 유형', 'tours.paddle.boat': '보트', 'tours.paddle.boat.kayak': '카약', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index ffd2c33ea..048538009 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -668,6 +668,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Wandelkaart (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Biedt OpenHikingMap aan als basiskaart in de tochtplanner. Gratis voor niet-commerciële installaties tot 500.000 tegels per maand; een commerciële of grotere installatie heeft eerst een afspraak met openmaps.fr nodig', + 'admin.toursCycleMap.title': 'Fietskaart (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Biedt CyclOSM aan als basiskaart voor fietstochten in de tourplanner. OpenStreetMap France levert hem voor gematigd gebruik; een intensief gebruikte of commerciële installatie zet hem uit of maakt eerst afspraken met de beheerder', 'admin.addons.noAddons': 'Geen add-ons beschikbaar', 'admin.weather.title': 'Weergegevens', 'admin.weather.badge': 'Sinds 24 maart 2026', diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts index ecb583e10..4c7b7d3fe 100644 --- a/shared/src/i18n/nl/map.ts +++ b/shared/src/i18n/nl/map.ts @@ -21,6 +21,9 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Wandelen', 'map.baseLayer.hikingDown': 'OpenHikingMap reageert op dit moment niet. Schakel over naar een andere kaart; je tocht blijft zoals hij is.', + 'map.baseLayer.cycle': 'Fiets', + 'map.baseLayer.cycleDown': + 'CyclOSM reageert nu niet. Schakel over naar een andere kaart; je tocht blijft zoals hij is.', 'map.baseLayer.switchToSatellite': 'Overschakelen naar satellietweergave', 'map.baseLayer.switchToDefault': 'Overschakelen naar kaartweergave', 'map.location.denied': diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index b412b5e5d..0bf5e96df 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -97,6 +97,9 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'De navigatie opent het startpunt van het pad; ze begeleidt je niet langs de route.', 'tours.planner.title': 'Een tocht plannen', 'tours.planner.newTour': 'Nieuwe tocht plannen', + 'tours.planner.addTour': 'Tocht toevoegen', + 'tours.planner.addTourHint': + 'Een nieuwe tocht plannen, GPX-bestanden importeren of een tocht uit een andere reis overnemen', 'tours.planner.neutralTitle': 'Een tocht plannen', 'tours.planner.neutralBody': 'Begin een nieuwe tocht en klik op de kaart om de punten te plaatsen.', 'tours.planner.newTitle': 'Nieuwe tocht', @@ -113,6 +116,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Hoogteprofiel uitklappen', 'tours.planner.name': 'Naam van de tocht', 'tours.planner.namePlaceholder': 'Naamloze wandeling', + 'tours.planner.namePlaceholder.bike': 'Fietstocht zonder naam', + 'tours.planner.namePlaceholder.kayak': 'Peddeltocht zonder naam', 'tours.planner.mapSearch': 'Zoek plaats of adres', 'tours.planner.placePoints': 'Punten plaatsen', 'tours.planner.pointPlaced': 'Punt geplaatst', @@ -175,6 +180,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Wachten op berekening', 'tours.planner.status.routing': 'Wandelroute berekenen…', 'tours.planner.status.routingFailed': 'Routeberekening mislukt. De laatste geldige route blijft zichtbaar.', + 'tours.planner.status.unreachable': 'Punt {point} is niet bereikbaar', + 'tours.planner.unreachable.hike': + 'Geen wandelpad bereikt punt {point}: het dichtstbijzijnde dat de router kent ligt {distance} verderop. Zet het punt op een pad of teken het stuk ernaartoe met de hand.', + 'tours.planner.unreachable.bike': + 'Geen fietsbare weg bereikt punt {point}: de dichtstbijzijnde die de router kent ligt {distance} verderop. Zet het punt op een weg of teken het stuk ernaartoe met de hand.', + 'tours.planner.unreachable.show': 'Punt {point} tonen', 'tours.planner.status.elevation': 'Hoogtegegevens laden…', 'tours.planner.status.elevationFailed': 'Hoogtegegevens zijn niet beschikbaar. De berekende route blijft zichtbaar.', 'tours.planner.status.saving': 'Opslaan…', @@ -347,6 +358,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Bezienswaardigheden', 'tours.poi.cat.slipway': 'Instapplaatsen', 'tours.poi.cat.boat_rental': 'Bootverhuur', + 'tours.poi.cat.bike_repair': 'Fietsreparatie', + 'tours.planner.tourTypeHint.hike': 'Te voet, binnen een moeilijkheid die je kiest', + 'tours.planner.tourTypeHint.bike': 'Met de fiets, gerouteerd voor toer, race, gravel of MTB', + 'tours.planner.tourTypeHint.kayak': 'Op het water, met kajak, kano of SUP', + 'tours.bike.profile': 'Profiel', + 'tours.bike.profile.touring': 'Toer', + 'tours.bike.profile.road': 'Racefiets', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Kiest bij voorkeur fietspaden en rustige wegen, verhard waar het kan.', + 'tours.bike.profileHint.road': 'Blijft op asfalt; bekende onverharde stukken worden getoond.', + 'tours.bike.profileHint.gravel': 'Neemt grind en landwegen, geen technische singletracks.', + 'tours.bike.profileHint.mtb': 'Neemt ruwe paden zoals ze komen; trailgraden worden getoond waar ze in kaart staan.', + 'tours.bike.timeEstimated': 'Fietstijd (geschat)', + 'tours.bike.timeEstimatedShort': 'Fietstijd', + 'tours.bike.timeEstimatedInfoLabel': 'Hoe de fietstijd wordt berekend', + 'tours.bike.timeEstimatedInfo': + 'De tijd van de router voor het gekozen profiel, met de snelheid en de klimmen meegerekend. Een planningswaarde, zonder pauzes.', + 'tours.bike.ways.title': 'Ondergrond en wegen', + 'tours.bike.ways.surface': 'Ondergrond', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Ruwe bestrating', + 'tours.bike.ways.surface.compacted': 'Halfverhard', + 'tours.bike.ways.surface.gravel': 'Grind', + 'tours.bike.ways.surface.ground': 'Natuurlijke ondergrond', + 'tours.bike.ways.surface.unknown': 'Niet in de gegevens', + 'tours.bike.ways.way': 'Soort weg', + 'tours.bike.ways.way.cyclePath': 'Fietspad', + 'tours.bike.ways.way.cycleLane': 'Fietsstrook naast het verkeer', + 'tours.bike.ways.way.quiet': 'Rustige straat', + 'tours.bike.ways.way.road': 'Weg met verkeer', + 'tours.bike.ways.way.track': 'Land- of bosweg', + 'tours.bike.ways.way.path': 'Pad', + 'tours.bike.ways.way.ferry': 'Veerpont', + 'tours.bike.ways.way.other': 'Overig', + 'tours.bike.ways.loading': 'Wegen langs de route worden gelezen…', + 'tours.bike.ways.offline': 'Ondergrond en trailgraden hebben een verbinding nodig.', + 'tours.bike.ways.failed': 'De wegen langs de route konden niet worden gelezen. De route zelf blijft hetzelfde.', + 'tours.bike.ways.source': + 'Uit OpenStreetMap, zoals de router het leest. Waar de gegevens niets zeggen, is de ondergrond onbekend, niet goed.', + 'tours.bike.ways.showOnMap': 'Op de kaart tonen', + 'tours.bike.ways.unpavedBadge': '{share} onverhard', + 'tours.bike.ways.unpavedNotice': '{length} van de route is bekend onverhard.', + 'tours.bike.trail.title': 'Trailgraad (mtb:scale)', + 'tours.bike.trail.s0': 'S0, geen bijzondere moeilijkheden', + 'tours.bike.trail.s1': 'S1, kleine obstakels', + 'tours.bike.trail.s2': 'S2, wortels, treden, losse grond', + 'tours.bike.trail.s3': 'S3, rotsblokken, steil, krappe bochten', + 'tours.bike.trail.s4': 'S4 en hoger, terrein voor experts', + 'tours.bike.trail.none': 'Geen graad in de gegevens', + 'tours.bike.trail.unasked': 'Kon deze keer niet worden opgevraagd', + 'tours.bike.trail.uphillTitle': 'Klimgraad (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Klim {step}', + 'tours.bike.trail.note': + 'Graden zoals OpenStreetMap-mappers ze gaven. Een weg zonder graad is onbekend, niet makkelijk. Ze zeggen niets over of een trail bij je past.', + 'tours.bike.trail.loading': 'Trailgraden worden gelezen…', + 'tours.bike.trail.failed': 'De trailgraden konden deze keer niet worden gelezen.', 'tours.copy.action': 'Opslaan als nieuwe tocht', 'tours.copy.hint': 'Kopieert deze tocht als nieuwe, zelfstandige tocht zonder zijn dagen. Het origineel blijft zoals het is.', @@ -360,6 +428,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Voor het downloaden van een GPX-bestand is een verbinding met de server nodig.', 'tours.gpxExport.noRoute': 'Deze tocht heeft geen opgeslagen route om te exporteren.', 'tours.gpxExport.failed': 'Het GPX-bestand kon niet worden gemaakt.', + 'tours.gpxExport.share': 'GPX delen', + 'tours.gpxExport.shareShort': 'Delen', + 'tours.gpxExport.shareSaveFirst': 'Sla de tocht eerst op om hem als GPX te delen', + 'tours.gpxExport.shareReady': 'Het GPX-bestand staat klaar. Tik nog een keer op Delen om het deelmenu te openen.', + 'tours.gpxExport.shareFailed': 'Delen is niet gelukt. Het GPX-bestand kan nog steeds worden gedownload.', 'tours.revision.remoteTitle': 'Deze tocht is elders opgeslagen', 'tours.revision.remoteBody': 'Iemand, of jij op een ander apparaat, heeft hem opgeslagen terwijl je bewerkte. Je wijzigingen blijven tot je beslist.', @@ -390,14 +463,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Wat de routekeuze doet', 'tours.preferences.info': 'Deze keuzes wegen welke weg de routeplanner kiest. Waar geen andere weg is, gaat de route er toch langs. Ze zeggen niets over hoe moeilijk een pad is; de moeilijkheidsgrens hierboven bepaalt wat de route mag gebruiken.', + 'tours.preferences.avoid_climbs': 'Steile hellingen vermijden', + 'tours.preferences.avoid_climbsHint': + 'Vlakkere wegen waar die er zijn, ook als ze langer zijn. Waar geen omweg is, blijft het steil.', 'tours.preferences.avoid_steps': 'Trappen vermijden', 'tours.preferences.avoid_stepsHint': 'Trappen alleen waar er geen weg omheen is.', 'tours.preferences.avoid_ferries': 'Veerponten vermijden', 'tours.preferences.avoid_ferriesHint': 'Over land waar er een weg omheen is, ook als die langer is.', - 'tours.preferences.prefer_paths': 'Voetpaden voorkeur geven', - 'tours.preferences.prefer_pathsHint': 'Eigen paden in plaats van wegen en stoepen, waar ze er zijn.', - 'tours.preferences.shortest': 'Korter in plaats van sneller', - 'tours.preferences.shortestHint': 'De weg gekozen op lengte, niet op tijd; vaak iets korter, niet altijd sneller.', 'tours.planner.tourType': 'Soort tocht', 'tours.paddle.boat': 'Boot', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index dd3220942..b3dafa699 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -688,6 +688,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Mapa turystyczna (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Udostępnia OpenHikingMap jako mapę bazową w planerze tras. Bezpłatnie dla niekomercyjnych instalacji do 500 000 kafelków miesięcznie; instalacja komercyjna lub większa wymaga najpierw porozumienia z openmaps.fr', + 'admin.toursCycleMap.title': 'Mapa rowerowa (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Udostępnia CyclOSM jako mapę bazową dla wycieczek rowerowych w planerze. OpenStreetMap France udostępnia ją do umiarkowanego użytku; instalacja intensywnie używana lub komercyjna wyłącza ją albo najpierw zawiera porozumienie z operatorem', 'admin.addons.noAddons': 'Brak dostępnych dodatków', 'admin.weather.title': 'Dane pogodowe', 'admin.weather.badge': 'Od 24 marca 2026', diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts index 0310c97a7..d084f55a8 100644 --- a/shared/src/i18n/pl/map.ts +++ b/shared/src/i18n/pl/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Turystyka', 'map.baseLayer.hikingDown': 'OpenHikingMap w tej chwili nie odpowiada. Przełącz na inną mapę; twoja trasa pozostaje bez zmian.', + 'map.baseLayer.cycle': 'Rower', + 'map.baseLayer.cycleDown': 'CyclOSM teraz nie odpowiada. Przełącz na inną mapę; twoja trasa pozostaje bez zmian.', 'map.baseLayer.switchToSatellite': 'Przełącz na widok satelitarny', 'map.baseLayer.switchToDefault': 'Przełącz na widok mapy', 'map.location.denied': diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 8e1f3be9c..86372fe1e 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -104,6 +104,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Nawigacja otwiera punkt początkowy szlaku; nie prowadzi wzdłuż trasy.', 'tours.planner.title': 'Zaplanuj wycieczkę', 'tours.planner.newTour': 'Zaplanuj nową wycieczkę', + 'tours.planner.addTour': 'Dodaj trasę', + 'tours.planner.addTourHint': 'Zaplanuj nową trasę, zaimportuj pliki GPX lub przejmij trasę z innej podróży', 'tours.planner.neutralTitle': 'Zaplanuj wycieczkę', 'tours.planner.neutralBody': 'Rozpocznij nową trasę i kliknij mapę, aby wyznaczyć jej punkty.', 'tours.planner.newTitle': 'Nowa wycieczka', @@ -120,6 +122,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Rozwiń profil wysokościowy', 'tours.planner.name': 'Nazwa wycieczki', 'tours.planner.namePlaceholder': 'Wędrówka bez nazwy', + 'tours.planner.namePlaceholder.bike': 'Wycieczka rowerowa bez nazwy', + 'tours.planner.namePlaceholder.kayak': 'Spływ bez nazwy', 'tours.planner.mapSearch': 'Szukaj miejsca lub adresu', 'tours.planner.placePoints': 'Ustawiaj punkty', 'tours.planner.pointPlaced': 'Punkt ustawiony', @@ -184,6 +188,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Oczekiwanie na obliczenie', 'tours.planner.status.routing': 'Obliczanie trasy pieszej…', 'tours.planner.status.routingFailed': 'Nie udało się obliczyć trasy. Nadal widoczna jest ostatnia poprawna trasa.', + 'tours.planner.status.unreachable': 'Punkt {point} jest nieosiągalny', + 'tours.planner.unreachable.hike': + 'Żadna ścieżka piesza nie dochodzi do punktu {point}: najbliższa, którą zna router, jest {distance} dalej. Przesuń punkt na ścieżkę albo narysuj odcinek do niego ręcznie.', + 'tours.planner.unreachable.bike': + 'Żadna droga dla rowerów nie dochodzi do punktu {point}: najbliższa, którą zna router, jest {distance} dalej. Przesuń punkt na drogę albo narysuj odcinek do niego ręcznie.', + 'tours.planner.unreachable.show': 'Pokaż punkt {point}', 'tours.planner.status.elevation': 'Wczytywanie wysokości…', 'tours.planner.status.elevationFailed': 'Dane wysokościowe są niedostępne. Obliczona trasa nadal jest widoczna.', 'tours.planner.status.saving': 'Zapisywanie…', @@ -353,6 +363,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Atrakcje', 'tours.poi.cat.slipway': 'Miejsca wodowania', 'tours.poi.cat.boat_rental': 'Wypożyczalnia łodzi', + 'tours.poi.cat.bike_repair': 'Naprawa rowerów', + 'tours.planner.tourTypeHint.hike': 'Pieszo, w granicach wybranej trudności', + 'tours.planner.tourTypeHint.bike': 'Na rowerze, trasa dla turystyki, szosy, gravela lub MTB', + 'tours.planner.tourTypeHint.kayak': 'Na wodzie, kajakiem, kanadyjką lub SUP-em', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Turystyka', + 'tours.bike.profile.road': 'Szosa', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Preferuje drogi rowerowe i spokojne ulice, utwardzone, gdzie się da.', + 'tours.bike.profileHint.road': 'Trzyma się asfaltu; znane nieutwardzone odcinki są pokazane.', + 'tours.bike.profileHint.gravel': 'Wybiera szuter i drogi polne, nie techniczne single.', + 'tours.bike.profileHint.mtb': 'Jedzie trudnymi ścieżkami, jakie są; stopnie trudności widać tam, gdzie są zmapowane.', + 'tours.bike.timeEstimated': 'Czas jazdy (szacowany)', + 'tours.bike.timeEstimatedShort': 'Czas jazdy', + 'tours.bike.timeEstimatedInfoLabel': 'Jak liczony jest czas jazdy', + 'tours.bike.timeEstimatedInfo': + 'Czas routera dla wybranego profilu, z jego prędkością i podjazdami. Wartość planistyczna, bez przerw.', + 'tours.bike.ways.title': 'Nawierzchnia i drogi', + 'tours.bike.ways.surface': 'Nawierzchnia', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Nierówna nawierzchnia', + 'tours.bike.ways.surface.compacted': 'Utwardzona', + 'tours.bike.ways.surface.gravel': 'Szuter', + 'tours.bike.ways.surface.ground': 'Grunt naturalny', + 'tours.bike.ways.surface.unknown': 'Brak w danych', + 'tours.bike.ways.way': 'Rodzaj drogi', + 'tours.bike.ways.way.cyclePath': 'Droga rowerowa', + 'tours.bike.ways.way.cycleLane': 'Pas rowerowy przy ruchu', + 'tours.bike.ways.way.quiet': 'Spokojna ulica', + 'tours.bike.ways.way.road': 'Droga z ruchem', + 'tours.bike.ways.way.track': 'Droga polna lub leśna', + 'tours.bike.ways.way.path': 'Ścieżka', + 'tours.bike.ways.way.ferry': 'Prom', + 'tours.bike.ways.way.other': 'Inne', + 'tours.bike.ways.loading': 'Odczytywanie dróg wzdłuż trasy…', + 'tours.bike.ways.offline': 'Nawierzchnia i stopnie trudności wymagają połączenia.', + 'tours.bike.ways.failed': 'Nie udało się odczytać dróg wzdłuż trasy. Sama trasa się nie zmienia.', + 'tours.bike.ways.source': + 'Z OpenStreetMap, tak jak czyta to router. Tam, gdzie dane milczą, nawierzchnia jest nieznana, a nie dobra.', + 'tours.bike.ways.showOnMap': 'Pokaż na mapie', + 'tours.bike.ways.unpavedBadge': '{share} nieutwardzone', + 'tours.bike.ways.unpavedNotice': '{length} trasy to znane odcinki bez utwardzenia.', + 'tours.bike.trail.title': 'Stopień trudności (mtb:scale)', + 'tours.bike.trail.s0': 'S0, bez szczególnych trudności', + 'tours.bike.trail.s1': 'S1, małe przeszkody', + 'tours.bike.trail.s2': 'S2, korzenie, stopnie, luźne podłoże', + 'tours.bike.trail.s3': 'S3, bloki skalne, stromo, ciasne zakręty', + 'tours.bike.trail.s4': 'S4 i więcej, teren dla ekspertów', + 'tours.bike.trail.none': 'Brak stopnia w danych', + 'tours.bike.trail.unasked': 'Tym razem nie udało się sprawdzić', + 'tours.bike.trail.uphillTitle': 'Trudność podjazdu (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Podjazd {step}', + 'tours.bike.trail.note': + 'Stopnie nadane przez maperów OpenStreetMap. Droga bez stopnia jest nieznana, a nie łatwa. Nie mówią, czy szlak jest dla ciebie odpowiedni.', + 'tours.bike.trail.loading': 'Odczytywanie stopni trudności…', + 'tours.bike.trail.failed': 'Tym razem nie udało się odczytać stopni trudności.', 'tours.copy.action': 'Zapisz jako nową trasę', 'tours.copy.hint': 'Kopiuje tę trasę jako nową, niezależną trasę bez jej dni. Oryginał zostaje bez zmian.', 'tours.copy.name': '{name} (kopia)', @@ -365,6 +432,12 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Pobranie pliku GPX wymaga połączenia z serwerem.', 'tours.gpxExport.noRoute': 'Ta trasa nie ma zapisanego przebiegu do eksportu.', 'tours.gpxExport.failed': 'Nie udało się utworzyć pliku GPX.', + 'tours.gpxExport.share': 'Udostępnij GPX', + 'tours.gpxExport.shareShort': 'Udostępnij', + 'tours.gpxExport.shareSaveFirst': 'Najpierw zapisz trasę, aby udostępnić ją jako GPX', + 'tours.gpxExport.shareReady': + 'Plik GPX jest gotowy. Stuknij jeszcze raz Udostępnij, aby otworzyć okno udostępniania.', + 'tours.gpxExport.shareFailed': 'Udostępnianie nie powiodło się. Plik GPX nadal można pobrać.', 'tours.revision.remoteTitle': 'Ta trasa została zapisana gdzie indziej', 'tours.revision.remoteBody': 'Ktoś, albo ty na innym urządzeniu, zapisał ją podczas twojej edycji. Twoje zmiany zostają, dopóki nie zdecydujesz.', @@ -395,15 +468,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Co robi wybór trasy', 'tours.preferences.info': 'Te ustawienia ważą, którą drogę wybiera wyznaczanie trasy. Gdzie nie ma innej drogi, trasa i tak tamtędy prowadzi. Nie mówią nic o trudności ścieżki; to, czego trasa może użyć, ustala limit trudności powyżej.', + 'tours.preferences.avoid_climbs': 'Unikaj stromych podejść', + 'tours.preferences.avoid_climbsHint': + 'Łagodniejsze drogi tam, gdzie są, nawet jeśli dłuższe. Gdzie nie ma objazdu, zostaje stromo.', 'tours.preferences.avoid_steps': 'Unikaj schodów', 'tours.preferences.avoid_stepsHint': 'Schody tylko tam, gdzie nie da się ich obejść.', 'tours.preferences.avoid_ferries': 'Unikaj promów', 'tours.preferences.avoid_ferriesHint': 'Lądem, gdzie da się obejść, nawet jeśli dłużej.', - 'tours.preferences.prefer_paths': 'Preferuj ścieżki piesze', - 'tours.preferences.prefer_pathsHint': 'Osobne ścieżki zamiast dróg i chodników, gdzie są.', - 'tours.preferences.shortest': 'Krócej zamiast szybciej', - 'tours.preferences.shortestHint': - 'Droga wybrana według długości, nie czasu; często trochę krótsza, nie zawsze szybsza.', 'tours.planner.tourType': 'Rodzaj wycieczki', 'tours.paddle.boat': 'Łódź', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 698f178aa..c43e277b2 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -693,6 +693,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Туристическая карта (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Предлагает OpenHikingMap как базовую карту в планировщике маршрутов. Бесплатно для некоммерческих установок до 500 000 тайлов в месяц; коммерческой или более крупной установке сначала нужно соглашение с openmaps.fr', + 'admin.toursCycleMap.title': 'Велокарта (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Предлагает CyclOSM как базовую карту для велопоездок в планировщике. OpenStreetMap France предоставляет её для умеренного использования; нагруженная или коммерческая установка отключает её или сначала договаривается с оператором', 'admin.addons.noAddons': 'Нет доступных дополнений', 'admin.weather.title': 'Данные о погоде', 'admin.weather.badge': 'С 24 марта 2026', diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts index 82d7b14c6..930de36ce 100644 --- a/shared/src/i18n/ru/map.ts +++ b/shared/src/i18n/ru/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Походы', 'map.baseLayer.hikingDown': 'OpenHikingMap сейчас не отвечает. Переключитесь на другую карту, ваш маршрут останется без изменений.', + 'map.baseLayer.cycle': 'Вело', + 'map.baseLayer.cycleDown': 'CyclOSM сейчас не отвечает. Переключись на другую карту, маршрут останется прежним.', 'map.baseLayer.switchToSatellite': 'Переключить на спутниковый вид', 'map.baseLayer.switchToDefault': 'Переключить на вид карты', 'map.location.denied': diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 7d25a4d01..540099304 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -104,6 +104,9 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Навигация открывает начальную точку тропы, но не ведёт по маршруту.', 'tours.planner.title': 'Спланировать тур', 'tours.planner.newTour': 'Спланировать новый тур', + 'tours.planner.addTour': 'Добавить маршрут', + 'tours.planner.addTourHint': + 'Спланировать новый маршрут, импортировать файлы GPX или перенести маршрут из другой поездки', 'tours.planner.neutralTitle': 'Спланировать тур', 'tours.planner.neutralBody': 'Начните новый маршрут и нажимайте на карту, чтобы расставить точки.', 'tours.planner.newTitle': 'Новый тур', @@ -120,6 +123,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Развернуть профиль высот', 'tours.planner.name': 'Название тура', 'tours.planner.namePlaceholder': 'Поход без названия', + 'tours.planner.namePlaceholder.bike': 'Велопоездка без названия', + 'tours.planner.namePlaceholder.kayak': 'Сплав без названия', 'tours.planner.mapSearch': 'Поиск места или адреса', 'tours.planner.placePoints': 'Ставить точки', 'tours.planner.pointPlaced': 'Точка поставлена', @@ -186,6 +191,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Расчёт пешего маршрута…', 'tours.planner.status.routingFailed': 'Не удалось рассчитать маршрут. По-прежнему отображается последний успешно рассчитанный маршрут.', + 'tours.planner.status.unreachable': 'Точка {point} недостижима', + 'tours.planner.unreachable.hike': + 'Ни одна пешеходная тропа не доходит до точки {point}: ближайшая известная маршрутизатору в {distance}. Перенеси точку на тропу или нарисуй участок к ней вручную.', + 'tours.planner.unreachable.bike': + 'Ни одна дорога для велосипедов не доходит до точки {point}: ближайшая известная маршрутизатору в {distance}. Перенеси точку на дорогу или нарисуй участок к ней вручную.', + 'tours.planner.unreachable.show': 'Показать точку {point}', 'tours.planner.status.elevation': 'Загрузка высот…', 'tours.planner.status.elevationFailed': 'Данные о высоте недоступны. Рассчитанный маршрут по-прежнему отображается.', 'tours.planner.status.saving': 'Сохранение…', @@ -357,6 +368,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Достопримечательности', 'tours.poi.cat.slipway': 'Места спуска на воду', 'tours.poi.cat.boat_rental': 'Прокат лодок', + 'tours.poi.cat.bike_repair': 'Ремонт велосипедов', + 'tours.planner.tourTypeHint.hike': 'Пешком, в пределах выбранной сложности', + 'tours.planner.tourTypeHint.bike': 'На велосипеде, маршрут для туризма, шоссе, гравела или МТБ', + 'tours.planner.tourTypeHint.kayak': 'По воде, на каяке, каноэ или SUP', + 'tours.bike.profile': 'Профиль', + 'tours.bike.profile.touring': 'Туризм', + 'tours.bike.profile.road': 'Шоссе', + 'tours.bike.profile.gravel': 'Гравел', + 'tours.bike.profile.mtb': 'МТБ', + 'tours.bike.profileHint.touring': 'Предпочитает велодорожки и тихие улицы, по возможности с покрытием.', + 'tours.bike.profileHint.road': 'Держится асфальта; известные участки без покрытия показаны.', + 'tours.bike.profileHint.gravel': 'Едет по гравию и полевым дорогам, не по техничным синглтрекам.', + 'tours.bike.profileHint.mtb': 'Едет по грубым тропам как есть; сложность троп показана там, где она размечена.', + 'tours.bike.timeEstimated': 'Время в пути (оценка)', + 'tours.bike.timeEstimatedShort': 'Время в пути', + 'tours.bike.timeEstimatedInfoLabel': 'Как считается время в пути', + 'tours.bike.timeEstimatedInfo': + 'Время маршрутизатора для выбранного профиля с его скоростью и подъёмами. Плановое значение без перерывов.', + 'tours.bike.ways.title': 'Покрытие и дороги', + 'tours.bike.ways.surface': 'Покрытие', + 'tours.bike.ways.surface.paved': 'Асфальт', + 'tours.bike.ways.surface.rough': 'Грубое покрытие', + 'tours.bike.ways.surface.compacted': 'Уплотнённое', + 'tours.bike.ways.surface.gravel': 'Гравий', + 'tours.bike.ways.surface.ground': 'Грунт', + 'tours.bike.ways.surface.unknown': 'Нет в данных', + 'tours.bike.ways.way': 'Тип дороги', + 'tours.bike.ways.way.cyclePath': 'Велодорожка', + 'tours.bike.ways.way.cycleLane': 'Велополоса рядом с движением', + 'tours.bike.ways.way.quiet': 'Тихая улица', + 'tours.bike.ways.way.road': 'Дорога с движением', + 'tours.bike.ways.way.track': 'Полевая или лесная дорога', + 'tours.bike.ways.way.path': 'Тропа', + 'tours.bike.ways.way.ferry': 'Паром', + 'tours.bike.ways.way.other': 'Другое', + 'tours.bike.ways.loading': 'Читаем дороги вдоль маршрута…', + 'tours.bike.ways.offline': 'Для покрытия и сложности троп нужно подключение.', + 'tours.bike.ways.failed': 'Не удалось прочитать дороги вдоль маршрута. Сам маршрут не меняется.', + 'tours.bike.ways.source': + 'Из OpenStreetMap, как их читает маршрутизатор. Где данных нет, покрытие неизвестно, а не хорошее.', + 'tours.bike.ways.showOnMap': 'Показать на карте', + 'tours.bike.ways.unpavedBadge': '{share} без покрытия', + 'tours.bike.ways.unpavedNotice': 'Известно, что {length} маршрута без покрытия.', + 'tours.bike.trail.title': 'Сложность тропы (mtb:scale)', + 'tours.bike.trail.s0': 'S0, без особых трудностей', + 'tours.bike.trail.s1': 'S1, небольшие препятствия', + 'tours.bike.trail.s2': 'S2, корни, ступени, сыпучий грунт', + 'tours.bike.trail.s3': 'S3, камни, круто, узкие повороты', + 'tours.bike.trail.s4': 'S4 и выше, рельеф для экспертов', + 'tours.bike.trail.none': 'Нет сложности в данных', + 'tours.bike.trail.unasked': 'На этот раз не удалось запросить', + 'tours.bike.trail.uphillTitle': 'Сложность подъёма (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Подъём {step}', + 'tours.bike.trail.note': + 'Сложность, как её указали участники OpenStreetMap. Дорога без оценки неизвестна, а не проста. Это ничего не говорит о том, подходит ли тебе трейл.', + 'tours.bike.trail.loading': 'Читаем сложность троп…', + 'tours.bike.trail.failed': 'На этот раз не удалось прочитать сложность троп.', 'tours.copy.action': 'Сохранить как новый поход', 'tours.copy.hint': 'Копирует этот поход как новый, самостоятельный поход без его дней. Оригинал остаётся как есть.', 'tours.copy.name': '{name} (копия)', @@ -369,6 +437,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Для скачивания файла GPX нужно соединение с сервером.', 'tours.gpxExport.noRoute': 'У этого похода нет сохранённого маршрута для экспорта.', 'tours.gpxExport.failed': 'Не удалось создать файл GPX.', + 'tours.gpxExport.share': 'Поделиться GPX', + 'tours.gpxExport.shareShort': 'Поделиться', + 'tours.gpxExport.shareSaveFirst': 'Сначала сохрани маршрут, чтобы поделиться им в GPX', + 'tours.gpxExport.shareReady': 'Файл GPX готов. Нажми «Поделиться» ещё раз, чтобы открыть меню отправки.', + 'tours.gpxExport.shareFailed': 'Не удалось поделиться. Файл GPX по-прежнему можно скачать.', 'tours.revision.remoteTitle': 'Этот поход сохранили в другом месте', 'tours.revision.remoteBody': 'Кто-то, или вы на другом устройстве, сохранил его во время правки. Ваши изменения останутся, пока вы не решите.', @@ -398,15 +471,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Что делает выбор маршрута', 'tours.preferences.info': 'Эти настройки влияют на то, какой путь выбирает маршрутизатор. Где другого пути нет, маршрут всё равно идёт там. О сложности тропы они ничего не говорят; что может использовать маршрут, задаёт предел сложности выше.', + 'tours.preferences.avoid_climbs': 'Избегать крутых подъёмов', + 'tours.preferences.avoid_climbsHint': + 'Более пологие пути, где они есть, даже если длиннее. Где обхода нет, остаётся круто.', 'tours.preferences.avoid_steps': 'Избегать лестниц', 'tours.preferences.avoid_stepsHint': 'Лестницы только там, где их не обойти.', 'tours.preferences.avoid_ferries': 'Избегать паромов', 'tours.preferences.avoid_ferriesHint': 'По суше, где есть обход, даже если он длиннее.', - 'tours.preferences.prefer_paths': 'Предпочитать пешеходные тропы', - 'tours.preferences.prefer_pathsHint': 'Отдельные тропы вместо дорог и тротуаров, где они есть.', - 'tours.preferences.shortest': 'Короче, а не быстрее', - 'tours.preferences.shortestHint': - 'Путь выбирается по длине, а не по времени; часто чуть короче, но не всегда быстрее.', 'tours.planner.tourType': 'Тип тура', 'tours.paddle.boat': 'Лодка', 'tours.paddle.boat.kayak': 'Каяк', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index b6ddce271..217a00c2c 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -740,6 +740,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Turistická mapa (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Ponúka OpenHikingMap ako podkladovú mapu v plánovači túr. Zadarmo pre nekomerčné inštalácie do 500 000 dlaždíc mesačne; komerčná alebo väčšia inštalácia potrebuje najprv dohodu s openmaps.fr', + 'admin.toursCycleMap.title': 'Cyklomapa (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Ponúka CyclOSM ako podkladovú mapu pre cyklotúry v plánovači. OpenStreetMap France ju poskytuje na primerané používanie; silne využívaná alebo komerčná inštalácia ju vypne, alebo sa najprv dohodne s prevádzkovateľom', 'admin.addons.noAddons': 'Žiadne doplnky nie sú dostupné', 'admin.weather.title': 'Dáta o počasí', 'admin.weather.badge': 'Od 24. marca 2026', diff --git a/shared/src/i18n/sk/map.ts b/shared/src/i18n/sk/map.ts index 8bdcff424..29285355d 100644 --- a/shared/src/i18n/sk/map.ts +++ b/shared/src/i18n/sk/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satelit', 'map.baseLayer.hiking': 'Turistika', 'map.baseLayer.hikingDown': 'OpenHikingMap teraz neodpovedá. Prepni na inú mapu, tvoja túra zostane bez zmeny.', + 'map.baseLayer.cycle': 'Cyklo', + 'map.baseLayer.cycleDown': 'CyclOSM teraz neodpovedá. Prepni na inú mapu, tvoja túra zostáva bez zmeny.', 'map.baseLayer.switchToSatellite': 'Prepnúť na satelitné zobrazenie', 'map.baseLayer.switchToDefault': 'Prepnúť na mapové zobrazenie', 'map.location.denied': diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index c56d344f1..b32efbc0a 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -98,6 +98,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigácia otvorí začiatok túry; nevedie po trase.', 'tours.planner.title': 'Naplánovať túru', 'tours.planner.newTour': 'Naplánovať novú túru', + 'tours.planner.addTour': 'Pridať túru', + 'tours.planner.addTourHint': 'Naplánuj novú túru, importuj súbory GPX alebo prevezmi túru z inej cesty', 'tours.planner.neutralTitle': 'Naplánovať túru', 'tours.planner.neutralBody': 'Začnite novú túru a kliknutím do mapy umiestnite jej body.', 'tours.planner.newTitle': 'Nová túra', @@ -113,6 +115,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Rozbaliť výškový profil', 'tours.planner.name': 'Názov túry', 'tours.planner.namePlaceholder': 'Pešia túra bez názvu', + 'tours.planner.namePlaceholder.bike': 'Cyklotúra bez názvu', + 'tours.planner.namePlaceholder.kayak': 'Vodácky výlet bez názvu', 'tours.planner.mapSearch': 'Hľadať miesto alebo adresu', 'tours.planner.placePoints': 'Pridávať body', 'tours.planner.pointPlaced': 'Bod pridaný', @@ -176,6 +180,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Čaká na výpočet', 'tours.planner.status.routing': 'Vypočítava sa pešia trasa…', 'tours.planner.status.routingFailed': 'Výpočet trasy zlyhal. Naďalej sa zobrazuje posledná úspešne vypočítaná trasa.', + 'tours.planner.status.unreachable': 'Bod {point} je nedosiahnuteľný', + 'tours.planner.unreachable.hike': + 'K bodu {point} nevedie žiadna turistická cesta: najbližšia, ktorú router pozná, je {distance} ďaleko. Presuň bod na cestu alebo úsek k nemu nakresli ručne.', + 'tours.planner.unreachable.bike': + 'K bodu {point} nevedie žiadna cesta pre bicykle: najbližšia, ktorú router pozná, je {distance} ďaleko. Presuň bod na cestu alebo úsek k nemu nakresli ručne.', + 'tours.planner.unreachable.show': 'Ukázať bod {point}', 'tours.planner.status.elevation': 'Načítavajú sa výškové údaje…', 'tours.planner.status.elevationFailed': 'Výškové údaje nie sú dostupné. Vypočítaná trasa sa naďalej zobrazuje.', 'tours.planner.status.saving': 'Ukladá sa…', @@ -340,6 +350,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Pamätihodnosti', 'tours.poi.cat.slipway': 'Nástupné miesta', 'tours.poi.cat.boat_rental': 'Požičovňa lodí', + 'tours.poi.cat.bike_repair': 'Oprava bicyklov', + 'tours.planner.tourTypeHint.hike': 'Pešo, do obtiažnosti, ktorú nastavíš', + 'tours.planner.tourTypeHint.bike': 'Na bicykli, trasa pre trekking, cestu, gravel alebo MTB', + 'tours.planner.tourTypeHint.kayak': 'Na vode, na kajaku, kanoe alebo SUP', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Trekking', + 'tours.bike.profile.road': 'Cestný', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Uprednostňuje cyklotrasy a tiché cesty, kde sa dá, spevnené.', + 'tours.bike.profileHint.road': 'Drží sa asfaltu; známe nespevnené úseky sa zobrazia.', + 'tours.bike.profileHint.gravel': 'Ide po štrku a poľných cestách, nie po technických singletrailoch.', + 'tours.bike.profileHint.mtb': 'Ide po drsných cestách, ako prídu; obtiažnosť trailov sa ukáže, kde je zmapovaná.', + 'tours.bike.timeEstimated': 'Čas jazdy (odhad)', + 'tours.bike.timeEstimatedShort': 'Čas jazdy', + 'tours.bike.timeEstimatedInfoLabel': 'Ako sa počíta čas jazdy', + 'tours.bike.timeEstimatedInfo': + 'Čas routera pre zvolený profil, s jeho rýchlosťou a stúpaniami. Plánovacia hodnota bez prestávok.', + 'tours.bike.ways.title': 'Povrch a cesty', + 'tours.bike.ways.surface': 'Povrch', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Hrubá dlažba', + 'tours.bike.ways.surface.compacted': 'Zhutnený', + 'tours.bike.ways.surface.gravel': 'Štrk', + 'tours.bike.ways.surface.ground': 'Prírodný povrch', + 'tours.bike.ways.surface.unknown': 'Nie je v dátach', + 'tours.bike.ways.way': 'Druh cesty', + 'tours.bike.ways.way.cyclePath': 'Cyklotrasa', + 'tours.bike.ways.way.cycleLane': 'Cyklopruh vedľa premávky', + 'tours.bike.ways.way.quiet': 'Tichá ulica', + 'tours.bike.ways.way.road': 'Cesta s premávkou', + 'tours.bike.ways.way.track': 'Poľná alebo lesná cesta', + 'tours.bike.ways.way.path': 'Chodník', + 'tours.bike.ways.way.ferry': 'Kompa', + 'tours.bike.ways.way.other': 'Ostatné', + 'tours.bike.ways.loading': 'Načítavajú sa cesty pozdĺž trasy…', + 'tours.bike.ways.offline': 'Povrch a obtiažnosť trailov potrebujú pripojenie.', + 'tours.bike.ways.failed': 'Cesty pozdĺž trasy sa nepodarilo načítať. Samotná trasa zostáva bez zmeny.', + 'tours.bike.ways.source': + 'Z OpenStreetMap, ako ich číta router. Kde dáta nič nehovoria, je povrch neznámy, nie dobrý.', + 'tours.bike.ways.showOnMap': 'Ukázať na mape', + 'tours.bike.ways.unpavedBadge': '{share} nespevnené', + 'tours.bike.ways.unpavedNotice': '{length} trasy je podľa dát nespevnených.', + 'tours.bike.trail.title': 'Obtiažnosť trailu (mtb:scale)', + 'tours.bike.trail.s0': 'S0, bez zvláštnych ťažkostí', + 'tours.bike.trail.s1': 'S1, malé prekážky', + 'tours.bike.trail.s2': 'S2, korene, schody, sypký povrch', + 'tours.bike.trail.s3': 'S3, bloky, strmé, úzke zákruty', + 'tours.bike.trail.s4': 'S4 a viac, terén pre expertov', + 'tours.bike.trail.none': 'Bez stupňa v dátach', + 'tours.bike.trail.unasked': 'Tentoraz sa nedalo zistiť', + 'tours.bike.trail.uphillTitle': 'Obtiažnosť do kopca (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Do kopca {step}', + 'tours.bike.trail.note': + 'Stupne, ako ich zadali mapovači OpenStreetMap. Cesta bez stupňa je neznáma, nie ľahká. Nehovoria nič o tom, či sa trail hodí pre teba.', + 'tours.bike.trail.loading': 'Načítava sa obtiažnosť trailov…', + 'tours.bike.trail.failed': 'Obtiažnosť trailov sa tentoraz nepodarilo načítať.', 'tours.copy.action': 'Uložiť ako novú túru', 'tours.copy.hint': 'Skopíruje túto túru ako novú, samostatnú túru bez jej dní. Originál zostane, ako je.', 'tours.copy.name': '{name} (kópia)', @@ -352,6 +419,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Na stiahnutie súboru GPX je potrebné pripojenie k serveru.', 'tours.gpxExport.noRoute': 'Táto túra nemá uloženú trasu na export.', 'tours.gpxExport.failed': 'Súbor GPX sa nepodarilo vytvoriť.', + 'tours.gpxExport.share': 'Zdieľať GPX', + 'tours.gpxExport.shareShort': 'Zdieľať', + 'tours.gpxExport.shareSaveFirst': 'Najprv túru ulož, potom ju môžeš zdieľať ako GPX', + 'tours.gpxExport.shareReady': 'Súbor GPX je pripravený. Ťukni ešte raz na Zdieľať a otvorí sa ponuka zdieľania.', + 'tours.gpxExport.shareFailed': 'Zdieľanie sa nepodarilo. Súbor GPX si môžeš ďalej stiahnuť.', 'tours.revision.remoteTitle': 'Táto túra bola uložená inde', 'tours.revision.remoteBody': 'Niekto, alebo ty na inom zariadení, ju uložil, kým ju upravuješ. Tvoje zmeny zostanú, kým sa nerozhodneš.', @@ -382,15 +454,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Čo robí voľba trasy', 'tours.preferences.info': 'Táto voľba ovplyvňuje, ktorú cestu plánovač vyberie. Kde iná cesta nie je, vedie trasa tadiaľ aj tak. O náročnosti cesty nehovorí nič; čo smie trasa použiť, určuje limit náročnosti vyššie.', + 'tours.preferences.avoid_climbs': 'Vyhnúť sa prudkým stúpaniam', + 'tours.preferences.avoid_climbsHint': + 'Miernejšie cesty, kde existujú, aj keď sú dlhšie. Kde sa nedá obísť, zostáva to strmé.', 'tours.preferences.avoid_steps': 'Vyhnúť sa schodom', 'tours.preferences.avoid_stepsHint': 'Schody len tam, kde nie je cesta okolo.', 'tours.preferences.avoid_ferries': 'Vyhnúť sa prievozom', 'tours.preferences.avoid_ferriesHint': 'Po súši, kde vedie cesta okolo, aj keď je dlhšia.', - 'tours.preferences.prefer_paths': 'Uprednostniť chodníky', - 'tours.preferences.prefer_pathsHint': 'Samostatné cesty namiesto ciest a chodníkov, kde sú.', - 'tours.preferences.shortest': 'Kratšie namiesto rýchlejšie', - 'tours.preferences.shortestHint': - 'Cesta vybraná podľa dĺžky, nie podľa času; často o niečo kratšia, nie vždy rýchlejšia.', 'tours.planner.tourType': 'Typ túry', 'tours.paddle.boat': 'Loď', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 9c10feb49..66816b3cd 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -735,6 +735,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Vandringskarta (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Erbjuder OpenHikingMap som baskarta i turplaneraren. Gratis för icke-kommersiella installationer upp till 500 000 rutor i månaden; en kommersiell eller större installation behöver först ett avtal med openmaps.fr', + 'admin.toursCycleMap.title': 'Cykelkarta (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Erbjuder CyclOSM som baskarta för cykelturer i turplaneraren. OpenStreetMap France tillhandahåller den för måttlig användning; en tungt använd eller kommersiell installation stänger av den eller kommer först överens med operatören', 'admin.addons.noAddons': 'Inga tillägg tillgängliga', 'admin.weather.title': 'Väderdata', 'admin.weather.badge': 'Sedan den 24 mars 2026', diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts index 0d4760683..34560d948 100644 --- a/shared/src/i18n/sv/map.ts +++ b/shared/src/i18n/sv/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Satellit', 'map.baseLayer.hiking': 'Vandring', 'map.baseLayer.hikingDown': 'OpenHikingMap svarar inte just nu. Byt till en annan karta; din tur förblir som den är.', + 'map.baseLayer.cycle': 'Cykel', + 'map.baseLayer.cycleDown': 'CyclOSM svarar inte just nu. Byt till en annan karta; din tur förblir som den är.', 'map.baseLayer.switchToSatellite': 'Byt till satellitvy', 'map.baseLayer.switchToDefault': 'Byt till kartvy', 'map.location.denied': diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index dd134b375..5d1bd583a 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -94,6 +94,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigeringen öppnar ledens startpunkt; den vägleder inte längs rutten.', 'tours.planner.title': 'Planera en tur', 'tours.planner.newTour': 'Planera ny tur', + 'tours.planner.addTour': 'Lägg till tur', + 'tours.planner.addTourHint': 'Planera en ny tur, importera GPX-filer eller ta över en tur från en annan resa', 'tours.planner.neutralTitle': 'Planera en tur', 'tours.planner.neutralBody': 'Starta en ny tur och klicka på kartan för att placera dess punkter.', 'tours.planner.newTitle': 'Ny tur', @@ -109,6 +111,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Fäll ut höjdprofil', 'tours.planner.name': 'Turens namn', 'tours.planner.namePlaceholder': 'Vandring utan namn', + 'tours.planner.namePlaceholder.bike': 'Cykeltur utan namn', + 'tours.planner.namePlaceholder.kayak': 'Paddeltur utan namn', 'tours.planner.mapSearch': 'Sök plats eller adress', 'tours.planner.placePoints': 'Sätt punkter', 'tours.planner.pointPlaced': 'Punkt satt', @@ -171,6 +175,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Väntar på beräkning', 'tours.planner.status.routing': 'Beräknar vandringsrutt…', 'tours.planner.status.routingFailed': 'Ruttberäkningen misslyckades. Den senaste giltiga rutten visas fortfarande.', + 'tours.planner.status.unreachable': 'Punkt {point} kan inte nås', + 'tours.planner.unreachable.hike': + 'Ingen vandringsled når punkt {point}: den närmaste routern känner till ligger {distance} bort. Flytta punkten till en stig eller rita sträckan dit för hand.', + 'tours.planner.unreachable.bike': + 'Ingen väg för cyklar når punkt {point}: den närmaste routern känner till ligger {distance} bort. Flytta punkten till en väg eller rita sträckan dit för hand.', + 'tours.planner.unreachable.show': 'Visa punkt {point}', 'tours.planner.status.elevation': 'Läser in höjddata…', 'tours.planner.status.elevationFailed': 'Höjddata är inte tillgängliga. Den beräknade rutten visas fortfarande.', 'tours.planner.status.saving': 'Sparar…', @@ -338,6 +348,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Sevärdheter', 'tours.poi.cat.slipway': 'Isättningsplatser', 'tours.poi.cat.boat_rental': 'Båtuthyrning', + 'tours.poi.cat.bike_repair': 'Cykelreparation', + 'tours.planner.tourTypeHint.hike': 'Till fots, inom en svårighet du väljer', + 'tours.planner.tourTypeHint.bike': 'Med cykel, rutt för tur, landsväg, grus eller MTB', + 'tours.planner.tourTypeHint.kayak': 'På vattnet, med kajak, kanot eller SUP', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Tur', + 'tours.bike.profile.road': 'Landsväg', + 'tours.bike.profile.gravel': 'Grus', + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Föredrar cykelvägar och lugna gator, belagda där det går.', + 'tours.bike.profileHint.road': 'Håller sig till asfalt; kända grusavsnitt visas.', + 'tours.bike.profileHint.gravel': 'Tar grus och skogsvägar, inte tekniska stigar.', + 'tours.bike.profileHint.mtb': 'Tar grova stigar som de kommer; svårighetsgrader visas där de är karterade.', + 'tours.bike.timeEstimated': 'Cykeltid (uppskattad)', + 'tours.bike.timeEstimatedShort': 'Cykeltid', + 'tours.bike.timeEstimatedInfoLabel': 'Hur cykeltiden räknas fram', + 'tours.bike.timeEstimatedInfo': + 'Routerns tid för valt profil, med dess fart och stigningar. Ett planeringsvärde, utan pauser.', + 'tours.bike.ways.title': 'Underlag och vägar', + 'tours.bike.ways.surface': 'Underlag', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Ojämn beläggning', + 'tours.bike.ways.surface.compacted': 'Packat grus', + 'tours.bike.ways.surface.gravel': 'Grus', + 'tours.bike.ways.surface.ground': 'Naturmark', + 'tours.bike.ways.surface.unknown': 'Saknas i datan', + 'tours.bike.ways.way': 'Vägtyp', + 'tours.bike.ways.way.cyclePath': 'Cykelväg', + 'tours.bike.ways.way.cycleLane': 'Cykelfält intill trafiken', + 'tours.bike.ways.way.quiet': 'Lugn gata', + 'tours.bike.ways.way.road': 'Väg med trafik', + 'tours.bike.ways.way.track': 'Skogs- eller jordbruksväg', + 'tours.bike.ways.way.path': 'Stig', + 'tours.bike.ways.way.ferry': 'Färja', + 'tours.bike.ways.way.other': 'Övrigt', + 'tours.bike.ways.loading': 'Läser vägarna längs rutten…', + 'tours.bike.ways.offline': 'Underlag och svårighetsgrader kräver anslutning.', + 'tours.bike.ways.failed': 'Vägarna längs rutten kunde inte läsas. Själva rutten påverkas inte.', + 'tours.bike.ways.source': + 'Från OpenStreetMap, som routern läser det. Där datan inte säger något är underlaget okänt, inte bra.', + 'tours.bike.ways.showOnMap': 'Visa på kartan', + 'tours.bike.ways.unpavedBadge': '{share} grus', + 'tours.bike.ways.unpavedNotice': '{length} av rutten är känt grus.', + 'tours.bike.trail.title': 'Svårighetsgrad (mtb:scale)', + 'tours.bike.trail.s0': 'S0, inga särskilda svårigheter', + 'tours.bike.trail.s1': 'S1, små hinder', + 'tours.bike.trail.s2': 'S2, rötter, trappsteg, löst underlag', + 'tours.bike.trail.s3': 'S3, block, brant, snäva kurvor', + 'tours.bike.trail.s4': 'S4 och uppåt, terräng för experter', + 'tours.bike.trail.none': 'Ingen grad i datan', + 'tours.bike.trail.unasked': 'Kunde inte hämtas den här gången', + 'tours.bike.trail.uphillTitle': 'Uppförsgrad (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Uppför {step}', + 'tours.bike.trail.note': + 'Grader som OpenStreetMaps kartläggare satt dem. En väg utan grad är okänd, inte lätt. De säger inget om huruvida en stig passar dig.', + 'tours.bike.trail.loading': 'Läser svårighetsgraderna…', + 'tours.bike.trail.failed': 'Svårighetsgraderna kunde inte läsas den här gången.', 'tours.copy.action': 'Spara som ny tur', 'tours.copy.hint': 'Kopierar turen som en ny, fristående tur utan dess dagar. Originalet förblir som det är.', 'tours.copy.name': '{name} (kopia)', @@ -350,6 +417,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'För att ladda ner en GPX-fil behövs en anslutning till servern.', 'tours.gpxExport.noRoute': 'Den här turen har ingen sparad rutt att exportera.', 'tours.gpxExport.failed': 'GPX-filen kunde inte skapas.', + 'tours.gpxExport.share': 'Dela GPX', + 'tours.gpxExport.shareShort': 'Dela', + 'tours.gpxExport.shareSaveFirst': 'Spara turen först för att dela den som GPX', + 'tours.gpxExport.shareReady': 'GPX-filen är klar. Tryck på Dela en gång till för att öppna delningsmenyn.', + 'tours.gpxExport.shareFailed': 'Delningen fungerade inte. GPX-filen kan fortfarande laddas ner.', 'tours.revision.remoteTitle': 'Den här turen sparades någon annanstans', 'tours.revision.remoteBody': 'Någon, eller du på en annan enhet, sparade den medan du redigerade. Dina ändringar finns kvar tills du bestämmer dig.', @@ -380,14 +452,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Vad ruttvalet gör', 'tours.preferences.info': 'De här valen väger vilken väg ruttplaneraren väljer. Där det inte finns någon annan väg går rutten ändå där. De säger inget om hur svår en stig är; svårighetsgränsen ovanför bestämmer vad rutten får använda.', + 'tours.preferences.avoid_climbs': 'Undvik branta stigningar', + 'tours.preferences.avoid_climbsHint': + 'Flackare vägar där sådana finns, även när de är längre. Där det inte finns någon väg runt förblir det brant.', 'tours.preferences.avoid_steps': 'Undvik trappor', 'tours.preferences.avoid_stepsHint': 'Trappor bara där det inte finns någon väg runt.', 'tours.preferences.avoid_ferries': 'Undvik färjor', 'tours.preferences.avoid_ferriesHint': 'Över land där det finns en väg runt, även om den är längre.', - 'tours.preferences.prefer_paths': 'Föredra gångvägar', - 'tours.preferences.prefer_pathsHint': 'Egna gångvägar i stället för vägar och trottoarer, där de finns.', - 'tours.preferences.shortest': 'Kortare i stället för snabbare', - 'tours.preferences.shortestHint': 'Vägen väljs efter längd, inte efter tid; ofta lite kortare, inte alltid snabbare.', 'tours.planner.tourType': 'Turtyp', 'tours.paddle.boat': 'Båt', 'tours.paddle.boat.kayak': 'Kajak', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index c529a462b..d8f449af6 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -636,6 +636,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'แผนที่เดินป่า (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'เสนอ OpenHikingMap เป็นแผนที่ฐานในตัววางแผนทัวร์ ใช้ฟรีสำหรับการติดตั้งที่ไม่ใช่เชิงพาณิชย์ได้ถึง 500,000 ไทล์ต่อเดือน การติดตั้งเชิงพาณิชย์หรือขนาดใหญ่กว่าต้องทำข้อตกลงกับ openmaps.fr ก่อน', + 'admin.toursCycleMap.title': 'แผนที่จักรยาน (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'ให้ CyclOSM เป็นแผนที่พื้นฐานสำหรับทริปจักรยานในตัววางแผนทัวร์ OpenStreetMap France ให้บริการสำหรับการใช้งานปานกลาง การติดตั้งที่ใช้งานหนักหรือเชิงพาณิชย์ควรปิดไว้หรือทำข้อตกลงกับผู้ให้บริการก่อน', 'admin.addons.noAddons': 'ไม่มีส่วนเสริมให้ใช้งาน', 'admin.weather.title': 'ข้อมูลสภาพอากาศ', 'admin.weather.badge': 'ตั้งแต่วันที่ 24 มีนาคม 2569', diff --git a/shared/src/i18n/th/map.ts b/shared/src/i18n/th/map.ts index ded5d2567..a95083053 100644 --- a/shared/src/i18n/th/map.ts +++ b/shared/src/i18n/th/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'ดาวเทียม', 'map.baseLayer.hiking': 'เดินป่า', 'map.baseLayer.hikingDown': 'OpenHikingMap ไม่ตอบสนองในขณะนี้ เปลี่ยนไปใช้แผนที่อื่น ทัวร์ของคุณยังคงเหมือนเดิม', + 'map.baseLayer.cycle': 'จักรยาน', + 'map.baseLayer.cycleDown': 'CyclOSM ไม่ตอบสนองในขณะนี้ เปลี่ยนไปใช้แผนที่อื่น ทริปของคุณยังเหมือนเดิม', 'map.baseLayer.switchToSatellite': 'สลับไปที่มุมมองดาวเทียม', 'map.baseLayer.switchToDefault': 'สลับไปที่มุมมองแผนที่', 'map.location.denied': diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 85b2dd3d1..d82423f32 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -87,6 +87,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'การนำทางจะเปิดจุดเริ่มต้นทางเดิน ไม่ได้นำทางตามเส้นทางทัวร์', 'tours.planner.title': 'วางแผนทัวร์', 'tours.planner.newTour': 'วางแผนทัวร์ใหม่', + 'tours.planner.addTour': 'เพิ่มทริป', + 'tours.planner.addTourHint': 'วางแผนทริปใหม่ นำเข้าไฟล์ GPX หรือรับทริปมาจากการเดินทางอื่น', 'tours.planner.neutralTitle': 'วางแผนทัวร์', 'tours.planner.neutralBody': 'เริ่มทัวร์ใหม่ แล้วคลิกแผนที่เพื่อวางจุดของเส้นทาง', 'tours.planner.newTitle': 'ทัวร์ใหม่', @@ -103,6 +105,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'ขยายกราฟระดับความสูง', 'tours.planner.name': 'ชื่อทัวร์', 'tours.planner.namePlaceholder': 'เส้นทางเดินป่าที่ยังไม่มีชื่อ', + 'tours.planner.namePlaceholder.bike': 'ทริปจักรยานไม่มีชื่อ', + 'tours.planner.namePlaceholder.kayak': 'ทริปพายเรือไม่มีชื่อ', 'tours.planner.mapSearch': 'ค้นหาสถานที่หรือที่อยู่', 'tours.planner.placePoints': 'วางจุด', 'tours.planner.pointPlaced': 'วางจุดแล้ว', @@ -164,6 +168,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'รอคำนวณ', 'tours.planner.status.routing': 'กำลังคำนวณเส้นทางเดิน...', 'tours.planner.status.routingFailed': 'คำนวณเส้นทางไม่สำเร็จ ยังคงแสดงเส้นทางล่าสุดที่คำนวณสำเร็จ', + 'tours.planner.status.unreachable': 'ไปถึงจุด {point} ไม่ได้', + 'tours.planner.unreachable.hike': + 'ไม่มีทางเดินป่าถึงจุด {point} ทางที่ใกล้ที่สุดที่ระบบรู้จักอยู่ห่าง {distance} ย้ายจุดไปไว้บนทาง หรือวาดช่วงไปยังจุดนั้นเอง', + 'tours.planner.unreachable.bike': + 'ไม่มีทางจักรยานถึงจุด {point} ทางที่ใกล้ที่สุดที่ระบบรู้จักอยู่ห่าง {distance} ย้ายจุดไปไว้บนทาง หรือวาดช่วงไปยังจุดนั้นเอง', + 'tours.planner.unreachable.show': 'แสดงจุด {point}', 'tours.planner.status.elevation': 'กำลังโหลดระดับความสูง...', 'tours.planner.status.elevationFailed': 'ไม่มีข้อมูลระดับความสูง ยังคงแสดงเส้นทางที่คำนวณแล้ว', 'tours.planner.status.saving': 'กำลังบันทึก...', @@ -324,6 +334,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'สถานที่น่าสนใจ', 'tours.poi.cat.slipway': 'จุดลงเรือ', 'tours.poi.cat.boat_rental': 'เช่าเรือ', + 'tours.poi.cat.bike_repair': 'ซ่อมจักรยาน', + 'tours.planner.tourTypeHint.hike': 'เดินเท้า ภายในระดับความยากที่คุณกำหนด', + 'tours.planner.tourTypeHint.bike': 'ด้วยจักรยาน เส้นทางสำหรับทัวริ่ง เสือหมอบ กราเวล หรือเสือภูเขา', + 'tours.planner.tourTypeHint.kayak': 'บนน้ำ ด้วยคายัค แคนู หรือ SUP', + 'tours.bike.profile': 'โปรไฟล์', + 'tours.bike.profile.touring': 'ทัวริ่ง', + 'tours.bike.profile.road': 'เสือหมอบ', + 'tours.bike.profile.gravel': 'กราเวล', + 'tours.bike.profile.mtb': 'เสือภูเขา', + 'tours.bike.profileHint.touring': 'เน้นทางจักรยานและถนนเงียบ ลาดยางเมื่อทำได้', + 'tours.bike.profileHint.road': 'อยู่บนถนนลาดยาง และแสดงช่วงทางไม่ลาดยางที่ทราบ', + 'tours.bike.profileHint.gravel': 'ใช้ทางกรวดและทางเกษตร ไม่ใช้ซิงเกิลแทร็กแบบเทคนิค', + 'tours.bike.profileHint.mtb': 'ใช้ทางขรุขระตามที่มี และแสดงระดับความยากของเส้นทางเมื่อมีข้อมูล', + 'tours.bike.timeEstimated': 'เวลาปั่น (ประมาณ)', + 'tours.bike.timeEstimatedShort': 'เวลาปั่น', + 'tours.bike.timeEstimatedInfoLabel': 'วิธีคำนวณเวลาปั่น', + 'tours.bike.timeEstimatedInfo': + 'เวลาจากระบบหาเส้นทางสำหรับโปรไฟล์ที่เลือก รวมความเร็วและทางขึ้น เป็นค่าสำหรับวางแผนโดยไม่รวมช่วงพัก', + 'tours.bike.ways.title': 'พื้นผิวและเส้นทาง', + 'tours.bike.ways.surface': 'พื้นผิว', + 'tours.bike.ways.surface.paved': 'ลาดยาง', + 'tours.bike.ways.surface.rough': 'ทางปูหยาบ', + 'tours.bike.ways.surface.compacted': 'บดอัด', + 'tours.bike.ways.surface.gravel': 'กรวด', + 'tours.bike.ways.surface.ground': 'ดินธรรมชาติ', + 'tours.bike.ways.surface.unknown': 'ไม่มีในข้อมูล', + 'tours.bike.ways.way': 'ประเภททาง', + 'tours.bike.ways.way.cyclePath': 'ทางจักรยาน', + 'tours.bike.ways.way.cycleLane': 'เลนจักรยานข้างการจราจร', + 'tours.bike.ways.way.quiet': 'ถนนเงียบ', + 'tours.bike.ways.way.road': 'ถนนที่มีรถ', + 'tours.bike.ways.way.track': 'ทางเกษตรหรือทางป่า', + 'tours.bike.ways.way.path': 'ทางเดิน', + 'tours.bike.ways.way.ferry': 'เรือข้ามฟาก', + 'tours.bike.ways.way.other': 'อื่นๆ', + 'tours.bike.ways.loading': 'กำลังอ่านเส้นทางตลอดแนว…', + 'tours.bike.ways.offline': 'พื้นผิวและระดับความยากต้องใช้การเชื่อมต่อ', + 'tours.bike.ways.failed': 'อ่านเส้นทางตลอดแนวไม่ได้ ตัวเส้นทางไม่เปลี่ยนแปลง', + 'tours.bike.ways.source': + 'จาก OpenStreetMap ตามที่ระบบหาเส้นทางอ่าน ที่ใดข้อมูลไม่ระบุ พื้นผิวถือว่าไม่ทราบ ไม่ใช่ว่าดี', + 'tours.bike.ways.showOnMap': 'แสดงบนแผนที่', + 'tours.bike.ways.unpavedBadge': 'ไม่ลาดยาง {share}', + 'tours.bike.ways.unpavedNotice': 'ทราบว่าเส้นทาง {length} เป็นทางไม่ลาดยาง', + 'tours.bike.trail.title': 'ระดับความยากเส้นทาง (mtb:scale)', + 'tours.bike.trail.s0': 'S0 ไม่มีความยากเป็นพิเศษ', + 'tours.bike.trail.s1': 'S1 อุปสรรคเล็กน้อย', + 'tours.bike.trail.s2': 'S2 รากไม้ ขั้นบันได พื้นร่วน', + 'tours.bike.trail.s3': 'S3 ก้อนหิน ชัน โค้งแคบ', + 'tours.bike.trail.s4': 'S4 ขึ้นไป พื้นที่สำหรับผู้เชี่ยวชาญ', + 'tours.bike.trail.none': 'ไม่มีระดับในข้อมูล', + 'tours.bike.trail.unasked': 'ครั้งนี้สอบถามไม่ได้', + 'tours.bike.trail.uphillTitle': 'ระดับความยากทางขึ้น (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'ทางขึ้น {step}', + 'tours.bike.trail.note': + 'ระดับตามที่ผู้ทำแผนที่ OpenStreetMap กำหนด ทางที่ไม่มีระดับคือไม่ทราบ ไม่ใช่ง่าย และไม่ได้บอกว่าเส้นทางเหมาะกับคุณหรือไม่', + 'tours.bike.trail.loading': 'กำลังอ่านระดับความยาก…', + 'tours.bike.trail.failed': 'ครั้งนี้อ่านระดับความยากไม่ได้', 'tours.copy.action': 'บันทึกเป็นทัวร์ใหม่', 'tours.copy.hint': 'คัดลอกทัวร์นี้เป็นทัวร์ใหม่ที่แยกเป็นอิสระโดยไม่รวมวัน ต้นฉบับจะคงเดิม', 'tours.copy.name': '{name} (สำเนา)', @@ -336,6 +403,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'การดาวน์โหลดไฟล์ GPX ต้องเชื่อมต่อกับเซิร์ฟเวอร์', 'tours.gpxExport.noRoute': 'ทัวร์นี้ไม่มีเส้นทางที่บันทึกไว้ให้ส่งออก', 'tours.gpxExport.failed': 'สร้างไฟล์ GPX ไม่สำเร็จ', + 'tours.gpxExport.share': 'แชร์ GPX', + 'tours.gpxExport.shareShort': 'แชร์', + 'tours.gpxExport.shareSaveFirst': 'บันทึกทริปก่อนเพื่อแชร์เป็น GPX', + 'tours.gpxExport.shareReady': 'ไฟล์ GPX พร้อมแล้ว แตะแชร์อีกครั้งเพื่อเปิดหน้าต่างแชร์', + 'tours.gpxExport.shareFailed': 'แชร์ไม่สำเร็จ ยังดาวน์โหลดไฟล์ GPX ได้ตามปกติ', 'tours.revision.remoteTitle': 'ทัวร์นี้ถูกบันทึกจากที่อื่น', 'tours.revision.remoteBody': 'มีคนหรือคุณจากอุปกรณ์อื่นบันทึกไว้ระหว่างที่คุณแก้ไข การเปลี่ยนแปลงของคุณยังอยู่จนกว่าคุณจะตัดสินใจ', @@ -365,14 +437,12 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'การเลือกเส้นทางทำอะไร', 'tours.preferences.info': 'ตัวเลือกเหล่านี้ให้น้ำหนักกับทางที่ระบบเลือก หากไม่มีทางอื่น เส้นทางก็ยังผ่านตรงนั้น ตัวเลือกไม่ได้บอกความยากของทาง ขีดจำกัดความยากด้านบนกำหนดว่าเส้นทางใช้ทางใดได้', + 'tours.preferences.avoid_climbs': 'เลี่ยงทางชัน', + 'tours.preferences.avoid_climbsHint': 'เลือกทางที่ลาดชันน้อยกว่าเมื่อมี แม้จะไกลกว่า หากไม่มีทางอ้อม ก็ยังคงชันอยู่', 'tours.preferences.avoid_steps': 'หลีกเลี่ยงบันได', 'tours.preferences.avoid_stepsHint': 'ผ่านบันไดเฉพาะเมื่อไม่มีทางอ้อม', 'tours.preferences.avoid_ferries': 'หลีกเลี่ยงเรือข้ามฟาก', 'tours.preferences.avoid_ferriesHint': 'ไปทางบกเมื่อมีทางอ้อม แม้จะไกลกว่า', - 'tours.preferences.prefer_paths': 'เน้นทางเดินเท้า', - 'tours.preferences.prefer_pathsHint': 'ใช้ทางของตัวเองแทนถนนและทางเท้าข้างถนนเมื่อมี', - 'tours.preferences.shortest': 'สั้นกว่าแทนเร็วกว่า', - 'tours.preferences.shortestHint': 'เลือกทางตามระยะทาง ไม่ใช่เวลา มักสั้นกว่าเล็กน้อย แต่ไม่ได้เร็วกว่าเสมอ', 'tours.planner.tourType': 'ประเภททริป', 'tours.paddle.boat': 'เรือ', 'tours.paddle.boat.kayak': 'เรือคายัก', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 36c2adbb1..fa529fff9 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 = 638; +const GENERAL_KEY_COUNT = 703; 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 638 general source keys', () => { + it('covers all 27 supported locales with 698 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 e835570b3..5798c19c4 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -724,6 +724,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Yürüyüş haritası (OpenHikingMap)', 'admin.toursHikingMap.subtitle': "OpenHikingMap'i tur planlayıcısında temel harita olarak sunar. Ticari olmayan kurulumlar için ayda 500.000 karoya kadar ücretsizdir; ticari veya daha büyük bir kurulum önce openmaps.fr ile anlaşma gerektirir", + 'admin.toursCycleMap.title': 'Bisiklet haritası (CyclOSM)', + 'admin.toursCycleMap.subtitle': + "Tur planlayıcısında bisiklet turları için CyclOSM'i temel harita olarak sunar. OpenStreetMap France onu ölçülü kullanım için sağlar; yoğun veya ticari bir kurulum onu kapatır ya da önce işletmeciyle anlaşır", 'admin.addons.noAddons': 'Kullanılabilir eklenti yok', 'admin.weather.title': 'Hava Durumu Verileri', 'admin.weather.badge': "24 Mart 2026'dan beri", diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts index 840cc09fe..2c7fc0657 100644 --- a/shared/src/i18n/tr/map.ts +++ b/shared/src/i18n/tr/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': 'Uydu', 'map.baseLayer.hiking': 'Yürüyüş', 'map.baseLayer.hikingDown': 'OpenHikingMap şu anda yanıt vermiyor. Başka bir haritaya geç; turun olduğu gibi kalır.', + 'map.baseLayer.cycle': 'Bisiklet', + 'map.baseLayer.cycleDown': 'CyclOSM şu anda yanıt vermiyor. Başka bir haritaya geç; turun olduğu gibi kalır.', 'map.baseLayer.switchToSatellite': 'Uydu görünümüne geç', 'map.baseLayer.switchToDefault': 'Harita görünümüne geç', 'map.location.denied': diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 69573ab09..952869658 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -93,6 +93,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Navigasyon yürüyüşün başlangıç noktasını açar; rota boyunca yol tarifi vermez.', 'tours.planner.title': 'Tur planla', 'tours.planner.newTour': 'Yeni tur planla', + 'tours.planner.addTour': 'Tur ekle', + 'tours.planner.addTourHint': 'Yeni bir tur planla, GPX dosyaları içe aktar veya başka bir seyahatten tur al', 'tours.planner.neutralTitle': 'Tur planla', 'tours.planner.neutralBody': 'Yeni bir tur başlatın ve noktalarını yerleştirmek için haritaya tıklayın.', 'tours.planner.newTitle': 'Yeni tur', @@ -109,6 +111,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Yükseklik profilini genişlet', 'tours.planner.name': 'Tur adı', 'tours.planner.namePlaceholder': 'Adsız doğa yürüyüşü', + 'tours.planner.namePlaceholder.bike': 'Adsız bisiklet turu', + 'tours.planner.namePlaceholder.kayak': 'Adsız kürek turu', 'tours.planner.mapSearch': 'Yer veya adres ara', 'tours.planner.placePoints': 'Nokta koy', 'tours.planner.pointPlaced': 'Nokta kondu', @@ -171,6 +175,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': 'Hesaplama bekleniyor', 'tours.planner.status.routing': 'Yürüyüş rotası hesaplanıyor…', 'tours.planner.status.routingFailed': 'Rota hesaplanamadı. Son başarılı rota hâlâ gösteriliyor.', + 'tours.planner.status.unreachable': '{point}. noktaya ulaşılamıyor', + 'tours.planner.unreachable.hike': + 'Hiçbir yürüyüş yolu {point}. noktaya ulaşmıyor: yönlendiricinin bildiği en yakın yol {distance} uzakta. Noktayı bir yola taşı veya o bölümü elle çiz.', + 'tours.planner.unreachable.bike': + 'Hiçbir bisiklet yolu {point}. noktaya ulaşmıyor: yönlendiricinin bildiği en yakın yol {distance} uzakta. Noktayı bir yola taşı veya o bölümü elle çiz.', + 'tours.planner.unreachable.show': '{point}. noktayı göster', 'tours.planner.status.elevation': 'Yükseklik verileri yükleniyor…', 'tours.planner.status.elevationFailed': 'Yükseklik verileri kullanılamıyor. Hesaplanan rota hâlâ gösteriliyor.', 'tours.planner.status.saving': 'Kaydediliyor…', @@ -340,6 +350,64 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Görülecek yerler', 'tours.poi.cat.slipway': 'Suya iniş noktaları', 'tours.poi.cat.boat_rental': 'Tekne kiralama', + 'tours.poi.cat.bike_repair': 'Bisiklet tamiri', + 'tours.planner.tourTypeHint.hike': 'Yürüyerek, belirlediğin zorluk içinde', + 'tours.planner.tourTypeHint.bike': 'Bisikletle, tur, yol, gravel veya MTB için rota', + 'tours.planner.tourTypeHint.kayak': 'Suda, kano, kanoe veya SUP ile', + 'tours.bike.profile': 'Profil', + 'tours.bike.profile.touring': 'Tur', + 'tours.bike.profile.road': 'Yol', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Bisiklet yollarını ve sakin sokakları, mümkünse asfaltı tercih eder.', + 'tours.bike.profileHint.road': 'Asfaltta kalır; bilinen asfaltsız kesimler gösterilir.', + 'tours.bike.profileHint.gravel': 'Çakıl ve tarla yollarını kullanır, teknik tek patikaları değil.', + 'tours.bike.profileHint.mtb': + 'Engebeli yolları olduğu gibi kullanır; parkur zorlukları haritalandığı yerde gösterilir.', + 'tours.bike.timeEstimated': 'Sürüş süresi (tahmini)', + 'tours.bike.timeEstimatedShort': 'Sürüş süresi', + 'tours.bike.timeEstimatedInfoLabel': 'Sürüş süresi nasıl hesaplanır', + 'tours.bike.timeEstimatedInfo': + 'Seçilen profil için yönlendiricinin süresi, hızı ve tırmanışlar dahil. Molalar hariç bir planlama değeri.', + 'tours.bike.ways.title': 'Zemin ve yollar', + 'tours.bike.ways.surface': 'Zemin', + 'tours.bike.ways.surface.paved': 'Asfalt', + 'tours.bike.ways.surface.rough': 'Bozuk kaplama', + 'tours.bike.ways.surface.compacted': 'Sıkıştırılmış', + 'tours.bike.ways.surface.gravel': 'Çakıl', + 'tours.bike.ways.surface.ground': 'Doğal zemin', + 'tours.bike.ways.surface.unknown': 'Veride yok', + 'tours.bike.ways.way': 'Yol türü', + 'tours.bike.ways.way.cyclePath': 'Bisiklet yolu', + 'tours.bike.ways.way.cycleLane': 'Trafik yanında bisiklet şeridi', + 'tours.bike.ways.way.quiet': 'Sakin sokak', + 'tours.bike.ways.way.road': 'Trafikli yol', + 'tours.bike.ways.way.track': 'Tarla veya orman yolu', + 'tours.bike.ways.way.path': 'Patika', + 'tours.bike.ways.way.ferry': 'Feribot', + 'tours.bike.ways.way.other': 'Diğer', + 'tours.bike.ways.loading': 'Rota boyunca yollar okunuyor…', + 'tours.bike.ways.offline': 'Zemin ve parkur zorlukları için bağlantı gerekir.', + 'tours.bike.ways.failed': 'Rota boyunca yollar okunamadı. Rotanın kendisi değişmez.', + 'tours.bike.ways.source': + "Yönlendiricinin okuduğu biçimde OpenStreetMap'ten. Verinin bir şey söylemediği yerde zemin bilinmez, iyi değildir.", + 'tours.bike.ways.showOnMap': 'Haritada göster', + 'tours.bike.ways.unpavedBadge': '{share} asfaltsız', + 'tours.bike.ways.unpavedNotice': 'Rotanın {length} kısmının asfaltsız olduğu biliniyor.', + 'tours.bike.trail.title': 'Parkur zorluğu (mtb:scale)', + 'tours.bike.trail.s0': 'S0, özel bir zorluk yok', + 'tours.bike.trail.s1': 'S1, küçük engeller', + 'tours.bike.trail.s2': 'S2, kökler, basamaklar, gevşek zemin', + 'tours.bike.trail.s3': 'S3, kayalar, dik, keskin virajlar', + 'tours.bike.trail.s4': 'S4 ve üstü, uzman arazisi', + 'tours.bike.trail.none': 'Veride zorluk yok', + 'tours.bike.trail.unasked': 'Bu kez sorgulanamadı', + 'tours.bike.trail.uphillTitle': 'Tırmanış zorluğu (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Tırmanış {step}', + 'tours.bike.trail.note': + 'Zorluklar OpenStreetMap haritacılarının verdiği gibidir. Zorluğu olmayan bir yol kolay değil, bilinmezdir. Parkurun sana uygun olup olmadığını söylemez.', + 'tours.bike.trail.loading': 'Parkur zorlukları okunuyor…', + 'tours.bike.trail.failed': 'Parkur zorlukları bu kez okunamadı.', 'tours.copy.action': 'Yeni tur olarak kaydet', 'tours.copy.hint': 'Bu turu günleri olmadan yeni ve bağımsız bir tur olarak kopyalar. Asıl tur olduğu gibi kalır.', 'tours.copy.name': '{name} (kopya)', @@ -352,6 +420,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'GPX dosyası indirmek için sunucuya bağlantı gerekir.', 'tours.gpxExport.noRoute': 'Bu turun dışa aktarılacak kayıtlı bir rotası yok.', 'tours.gpxExport.failed': 'GPX dosyası oluşturulamadı.', + 'tours.gpxExport.share': 'GPX paylaş', + 'tours.gpxExport.shareShort': 'Paylaş', + 'tours.gpxExport.shareSaveFirst': 'GPX olarak paylaşmak için önce turu kaydet', + 'tours.gpxExport.shareReady': "GPX dosyası hazır. Paylaşım penceresini açmak için Paylaş'a bir kez daha dokun.", + 'tours.gpxExport.shareFailed': 'Paylaşım olmadı. GPX dosyası yine de indirilebilir.', 'tours.revision.remoteTitle': 'Bu tur başka yerde kaydedildi', 'tours.revision.remoteBody': 'Sen düzenlerken biri, ya da başka bir cihazda sen, kaydetti. Değişikliklerin karar verene kadar kalır.', @@ -382,15 +455,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Rota seçimi ne yapar', 'tours.preferences.info': 'Bu seçimler rota planlayıcının hangi yolu seçeceğini ağırlıklandırır. Başka yol yoksa rota yine oradan geçer. Bir patikanın zorluğu hakkında bir şey söylemez; rotanın neyi kullanabileceğini yukarıdaki zorluk sınırı belirler.', + 'tours.preferences.avoid_climbs': 'Dik yokuşlardan kaçın', + 'tours.preferences.avoid_climbsHint': + 'Varsa daha yumuşak yollar, daha uzun olsalar bile. Dolanacak yol yoksa dik kalır.', 'tours.preferences.avoid_steps': 'Merdivenlerden kaçın', 'tours.preferences.avoid_stepsHint': 'Merdiven yalnızca dolaşacak yol yoksa.', 'tours.preferences.avoid_ferries': 'Feribotlardan kaçın', 'tours.preferences.avoid_ferriesHint': 'Dolaşacak yol varsa daha uzun olsa da karadan.', - 'tours.preferences.prefer_paths': 'Yaya yollarını tercih et', - 'tours.preferences.prefer_pathsHint': 'Varsa yollar ve kaldırımlar yerine ayrı patikalar.', - 'tours.preferences.shortest': 'Hızlı yerine kısa', - 'tours.preferences.shortestHint': - 'Yol süreye göre değil uzunluğa göre seçilir; çoğu zaman biraz daha kısa, her zaman daha hızlı değil.', 'tours.planner.tourType': 'Tur türü', 'tours.paddle.boat': 'Tekne', 'tours.paddle.boat.kayak': 'Kano (kayak)', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 74ebb1143..098c26148 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -690,6 +690,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Туристична карта (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Пропонує OpenHikingMap як базову карту в планувальнику маршрутів. Безкоштовно для некомерційних установок до 500 000 тайлів на місяць; комерційній або більшій установці спершу потрібна домовленість з openmaps.fr', + 'admin.toursCycleMap.title': 'Велокарта (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Пропонує CyclOSM як базову карту для велопоїздок у планувальнику. OpenStreetMap France надає її для помірного використання; навантажена або комерційна інсталяція вимикає її або спершу домовляється з оператором', 'admin.addons.noAddons': 'Немає доступних доповнень', 'admin.weather.title': 'Дані про погоду', 'admin.weather.badge': 'З 24 березня 2026', diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts index a86040c23..7172b4c42 100644 --- a/shared/src/i18n/uk/map.ts +++ b/shared/src/i18n/uk/map.ts @@ -21,6 +21,8 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Походи', 'map.baseLayer.hikingDown': 'OpenHikingMap зараз не відповідає. Перемкніться на іншу карту, ваш маршрут залишиться без змін.', + 'map.baseLayer.cycle': 'Вело', + 'map.baseLayer.cycleDown': 'CyclOSM зараз не відповідає. Перемкнися на іншу карту, маршрут залишиться без змін.', 'map.baseLayer.switchToSatellite': 'Перемкнути на супутниковий вигляд', 'map.baseLayer.switchToDefault': 'Перемкнути на вигляд карти', 'map.location.denied': diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 0a4630b52..9ea63da45 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -103,6 +103,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Навігація відкриває початкову точку стежки, але не веде вздовж маршруту.', 'tours.planner.title': 'Спланувати тур', 'tours.planner.newTour': 'Спланувати новий тур', + 'tours.planner.addTour': 'Додати маршрут', + 'tours.planner.addTourHint': 'Спланувати новий маршрут, імпортувати файли GPX або перенести маршрут з іншої подорожі', 'tours.planner.neutralTitle': 'Спланувати тур', 'tours.planner.neutralBody': 'Почніть новий маршрут і натискайте на карту, щоб розставити точки.', 'tours.planner.newTitle': 'Новий тур', @@ -118,6 +120,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Розгорнути профіль висот', 'tours.planner.name': 'Назва туру', 'tours.planner.namePlaceholder': 'Похід без назви', + 'tours.planner.namePlaceholder.bike': 'Велопоїздка без назви', + 'tours.planner.namePlaceholder.kayak': 'Сплав без назви', 'tours.planner.mapSearch': 'Пошук місця або адреси', 'tours.planner.placePoints': 'Ставити точки', 'tours.planner.pointPlaced': 'Точку поставлено', @@ -183,6 +187,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Розрахунок пішого маршруту…', 'tours.planner.status.routingFailed': 'Не вдалося розрахувати маршрут. Далі відображається останній успішно розрахований маршрут.', + 'tours.planner.status.unreachable': 'Точка {point} недосяжна', + 'tours.planner.unreachable.hike': + 'Жодна пішохідна стежка не доходить до точки {point}: найближча відома маршрутизатору за {distance}. Перенеси точку на стежку або намалюй ділянку до неї вручну.', + 'tours.planner.unreachable.bike': + 'Жодна дорога для велосипедів не доходить до точки {point}: найближча відома маршрутизатору за {distance}. Перенеси точку на дорогу або намалюй ділянку до неї вручну.', + 'tours.planner.unreachable.show': 'Показати точку {point}', 'tours.planner.status.elevation': 'Завантаження висот…', 'tours.planner.status.elevationFailed': 'Дані про висоту недоступні. Розрахований маршрут далі відображається.', 'tours.planner.status.saving': 'Збереження…', @@ -353,6 +363,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': "Пам'ятки", 'tours.poi.cat.slipway': 'Місця спуску на воду', 'tours.poi.cat.boat_rental': 'Прокат човнів', + 'tours.poi.cat.bike_repair': 'Ремонт велосипедів', + 'tours.planner.tourTypeHint.hike': 'Пішки, у межах обраної складності', + 'tours.planner.tourTypeHint.bike': 'На велосипеді, маршрут для туризму, шосе, гревелу або МТБ', + 'tours.planner.tourTypeHint.kayak': 'По воді, на каяку, каное або SUP', + 'tours.bike.profile': 'Профіль', + 'tours.bike.profile.touring': 'Туризм', + 'tours.bike.profile.road': 'Шосе', + 'tours.bike.profile.gravel': 'Гревел', + 'tours.bike.profile.mtb': 'МТБ', + 'tours.bike.profileHint.touring': 'Надає перевагу велодоріжкам і тихим вулицям, де можна, з покриттям.', + 'tours.bike.profileHint.road': 'Тримається асфальту; відомі ділянки без покриття показано.', + 'tours.bike.profileHint.gravel': 'Їде гравієм і польовими дорогами, не технічними синглтреками.', + 'tours.bike.profileHint.mtb': 'Їде грубими стежками як є; складність трейлів показано там, де її нанесено.', + 'tours.bike.timeEstimated': 'Час їзди (оцінка)', + 'tours.bike.timeEstimatedShort': 'Час їзди', + 'tours.bike.timeEstimatedInfoLabel': 'Як рахується час їзди', + 'tours.bike.timeEstimatedInfo': + 'Час маршрутизатора для обраного профілю з його швидкістю та підйомами. Планове значення без перерв.', + 'tours.bike.ways.title': 'Покриття і дороги', + 'tours.bike.ways.surface': 'Покриття', + 'tours.bike.ways.surface.paved': 'Асфальт', + 'tours.bike.ways.surface.rough': 'Грубе покриття', + 'tours.bike.ways.surface.compacted': 'Ущільнене', + 'tours.bike.ways.surface.gravel': 'Гравій', + 'tours.bike.ways.surface.ground': 'Ґрунт', + 'tours.bike.ways.surface.unknown': 'Немає в даних', + 'tours.bike.ways.way': 'Тип дороги', + 'tours.bike.ways.way.cyclePath': 'Велодоріжка', + 'tours.bike.ways.way.cycleLane': 'Велосмуга поруч із рухом', + 'tours.bike.ways.way.quiet': 'Тиха вулиця', + 'tours.bike.ways.way.road': 'Дорога з рухом', + 'tours.bike.ways.way.track': 'Польова або лісова дорога', + 'tours.bike.ways.way.path': 'Стежка', + 'tours.bike.ways.way.ferry': 'Пором', + 'tours.bike.ways.way.other': 'Інше', + 'tours.bike.ways.loading': 'Читаємо дороги вздовж маршруту…', + 'tours.bike.ways.offline': "Для покриття і складності трейлів потрібне з'єднання.", + 'tours.bike.ways.failed': 'Не вдалося прочитати дороги вздовж маршруту. Сам маршрут не змінюється.', + 'tours.bike.ways.source': + 'З OpenStreetMap, як їх читає маршрутизатор. Де даних немає, покриття невідоме, а не добре.', + 'tours.bike.ways.showOnMap': 'Показати на карті', + 'tours.bike.ways.unpavedBadge': '{share} без покриття', + 'tours.bike.ways.unpavedNotice': 'Відомо, що {length} маршруту без покриття.', + 'tours.bike.trail.title': 'Складність трейлу (mtb:scale)', + 'tours.bike.trail.s0': 'S0, без особливих труднощів', + 'tours.bike.trail.s1': 'S1, невеликі перешкоди', + 'tours.bike.trail.s2': 'S2, коріння, сходинки, сипкий ґрунт', + 'tours.bike.trail.s3': 'S3, брили, круто, вузькі повороти', + 'tours.bike.trail.s4': 'S4 і вище, рельєф для експертів', + 'tours.bike.trail.none': 'Немає складності в даних', + 'tours.bike.trail.unasked': 'Цього разу не вдалося запитати', + 'tours.bike.trail.uphillTitle': 'Складність підйому (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Підйом {step}', + 'tours.bike.trail.note': + 'Складність, як її вказали учасники OpenStreetMap. Дорога без оцінки невідома, а не легка. Це нічого не каже про те, чи підходить тобі трейл.', + 'tours.bike.trail.loading': 'Читаємо складність трейлів…', + 'tours.bike.trail.failed': 'Цього разу не вдалося прочитати складність трейлів.', 'tours.copy.action': 'Зберегти як новий похід', 'tours.copy.hint': 'Копіює цей похід як новий, самостійний похід без його днів. Оригінал лишається як є.', 'tours.copy.name': '{name} (копія)', @@ -365,6 +432,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Для завантаження файлу GPX потрібне з’єднання із сервером.', 'tours.gpxExport.noRoute': 'У цього походу немає збереженого маршруту для експорту.', 'tours.gpxExport.failed': 'Не вдалося створити файл GPX.', + 'tours.gpxExport.share': 'Поділитися GPX', + 'tours.gpxExport.shareShort': 'Поділитися', + 'tours.gpxExport.shareSaveFirst': 'Спершу збережи маршрут, щоб поділитися ним у GPX', + 'tours.gpxExport.shareReady': 'Файл GPX готовий. Натисни «Поділитися» ще раз, щоб відкрити меню надсилання.', + 'tours.gpxExport.shareFailed': 'Не вдалося поділитися. Файл GPX і далі можна завантажити.', 'tours.revision.remoteTitle': 'Цей похід збережено деінде', 'tours.revision.remoteBody': 'Хтось, або ви на іншому пристрої, зберіг його під час редагування. Ваші зміни лишаються, доки ви не вирішите.', @@ -395,15 +467,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Що робить вибір маршруту', 'tours.preferences.info': 'Ці налаштування впливають на те, який шлях обирає маршрутизатор. Де іншого шляху немає, маршрут однаково йде там. Про складність стежки вони нічого не кажуть; що може використовувати маршрут, визначає межа складності вище.', + 'tours.preferences.avoid_climbs': 'Уникати крутих підйомів', + 'tours.preferences.avoid_climbsHint': + 'Пологіші шляхи, де вони є, навіть якщо довші. Де обходу немає, лишається круто.', 'tours.preferences.avoid_steps': 'Уникати сходів', 'tours.preferences.avoid_stepsHint': 'Сходи лише там, де їх не обійти.', 'tours.preferences.avoid_ferries': 'Уникати поромів', 'tours.preferences.avoid_ferriesHint': 'Суходолом, де є обхід, навіть якщо він довший.', - 'tours.preferences.prefer_paths': 'Віддавати перевагу пішохідним стежкам', - 'tours.preferences.prefer_pathsHint': 'Окремі стежки замість доріг і тротуарів, де вони є.', - 'tours.preferences.shortest': 'Коротше, а не швидше', - 'tours.preferences.shortestHint': - 'Шлях обирається за довжиною, а не за часом; часто трохи коротший, не завжди швидший.', 'tours.planner.tourType': 'Тип туру', 'tours.paddle.boat': 'Човен', 'tours.paddle.boat.kayak': 'Каяк', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 3175d1f44..5a3c3383b 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -726,6 +726,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': 'Bản đồ đi bộ đường dài (OpenHikingMap)', 'admin.toursHikingMap.subtitle': 'Cung cấp OpenHikingMap làm bản đồ nền trong trình lập kế hoạch chuyến đi. Miễn phí cho các bản cài đặt phi thương mại đến 500.000 ô bản đồ mỗi tháng; bản cài đặt thương mại hoặc lớn hơn cần thỏa thuận với openmaps.fr trước', + 'admin.toursCycleMap.title': 'Bản đồ xe đạp (CyclOSM)', + 'admin.toursCycleMap.subtitle': + 'Cung cấp CyclOSM làm bản đồ nền cho chuyến đạp xe trong trình lập kế hoạch. OpenStreetMap France phục vụ nó cho mức dùng vừa phải; bản cài đặt dùng nhiều hoặc thương mại cần tắt nó hoặc thỏa thuận trước với nhà vận hành', 'admin.addons.noAddons': 'Không có tiện ích bổ sung nào', 'admin.weather.title': 'Dữ liệu thời tiết', 'admin.weather.badge': 'Kể từ ngày 24 tháng 3 năm 2026', diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts index 3cde37a16..420c7443c 100644 --- a/shared/src/i18n/vi/map.ts +++ b/shared/src/i18n/vi/map.ts @@ -21,6 +21,9 @@ const map: TranslationStrings = { 'map.baseLayer.hiking': 'Đi bộ đường dài', 'map.baseLayer.hikingDown': 'OpenHikingMap hiện không phản hồi. Hãy chuyển sang bản đồ khác; chuyến đi của bạn vẫn giữ nguyên.', + 'map.baseLayer.cycle': 'Xe đạp', + 'map.baseLayer.cycleDown': + 'CyclOSM hiện không phản hồi. Hãy chuyển sang bản đồ khác; chuyến đi của bạn vẫn giữ nguyên.', 'map.baseLayer.switchToSatellite': 'Chuyển sang chế độ xem vệ tinh', 'map.baseLayer.switchToDefault': 'Chuyển sang chế độ xem bản đồ', 'map.location.denied': diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index d80e1c355..e47b74d95 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -94,6 +94,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': 'Chỉ đường sẽ mở điểm bắt đầu đường mòn, không hướng dẫn đi dọc theo tuyến đường.', 'tours.planner.title': 'Lên kế hoạch tham quan', 'tours.planner.newTour': 'Lên kế hoạch tham quan mới', + 'tours.planner.addTour': 'Thêm chuyến đi', + 'tours.planner.addTourHint': 'Lên kế hoạch chuyến mới, nhập tệp GPX hoặc lấy một chuyến từ hành trình khác', 'tours.planner.neutralTitle': 'Lên kế hoạch tham quan', 'tours.planner.neutralBody': 'Bắt đầu chuyến đi mới và nhấp vào bản đồ để đặt các điểm.', 'tours.planner.newTitle': 'Chuyến tham quan mới', @@ -110,6 +112,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': 'Mở rộng biểu đồ độ cao', 'tours.planner.name': 'Tên chuyến tham quan', 'tours.planner.namePlaceholder': 'Chuyến đi bộ đường dài chưa có tên', + 'tours.planner.namePlaceholder.bike': 'Chuyến đạp xe chưa đặt tên', + 'tours.planner.namePlaceholder.kayak': 'Chuyến chèo thuyền chưa đặt tên', 'tours.planner.mapSearch': 'Tìm địa điểm hoặc địa chỉ', 'tours.planner.placePoints': 'Đặt điểm', 'tours.planner.pointPlaced': 'Đã đặt điểm', @@ -172,6 +176,12 @@ const tours: TranslationStrings = { 'tours.planner.status.routing': 'Đang tính tuyến đi bộ...', 'tours.planner.status.routingFailed': 'Tính tuyến đường thất bại. Tuyến được tính thành công gần nhất vẫn được hiển thị.', + 'tours.planner.status.unreachable': 'Không thể đến điểm {point}', + 'tours.planner.unreachable.hike': + 'Không có đường đi bộ nào tới điểm {point}: đường gần nhất bộ định tuyến biết cách {distance}. Hãy dời điểm lên một con đường hoặc tự vẽ đoạn tới đó.', + 'tours.planner.unreachable.bike': + 'Không có đường cho xe đạp nào tới điểm {point}: đường gần nhất bộ định tuyến biết cách {distance}. Hãy dời điểm lên một con đường hoặc tự vẽ đoạn tới đó.', + 'tours.planner.unreachable.show': 'Hiện điểm {point}', 'tours.planner.status.elevation': 'Đang tải độ cao...', 'tours.planner.status.elevationFailed': 'Không có dữ liệu độ cao. Tuyến đường đã tính vẫn được hiển thị.', 'tours.planner.status.saving': 'Đang lưu...', @@ -339,6 +349,63 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': 'Điểm tham quan', 'tours.poi.cat.slipway': 'Điểm hạ thủy', 'tours.poi.cat.boat_rental': 'Cho thuê thuyền', + 'tours.poi.cat.bike_repair': 'Sửa xe đạp', + 'tours.planner.tourTypeHint.hike': 'Đi bộ, trong mức độ khó bạn chọn', + 'tours.planner.tourTypeHint.bike': 'Bằng xe đạp, lộ trình cho touring, đường trường, gravel hoặc MTB', + 'tours.planner.tourTypeHint.kayak': 'Trên nước, bằng kayak, xuồng canoe hoặc SUP', + 'tours.bike.profile': 'Hồ sơ', + 'tours.bike.profile.touring': 'Du lịch', + 'tours.bike.profile.road': 'Đường trường', + 'tours.bike.profile.gravel': 'Gravel', // same-as-en + 'tours.bike.profile.mtb': 'MTB', // same-as-en + 'tours.bike.profileHint.touring': 'Ưu tiên làn xe đạp và đường vắng, có trải nhựa khi có thể.', + 'tours.bike.profileHint.road': 'Đi trên nhựa; các đoạn chưa trải nhựa đã biết được hiển thị.', + 'tours.bike.profileHint.gravel': 'Đi đường sỏi và đường nông nghiệp, không đi đường mòn kỹ thuật.', + 'tours.bike.profileHint.mtb': 'Đi đường gồ ghề như vốn có; cấp độ đường mòn hiển thị ở nơi được lập bản đồ.', + 'tours.bike.timeEstimated': 'Thời gian đạp xe (ước tính)', + 'tours.bike.timeEstimatedShort': 'Thời gian đạp xe', + 'tours.bike.timeEstimatedInfoLabel': 'Cách tính thời gian đạp xe', + 'tours.bike.timeEstimatedInfo': + 'Thời gian của bộ định tuyến cho hồ sơ đã chọn, tính cả tốc độ và dốc lên. Giá trị lập kế hoạch, không gồm nghỉ.', + 'tours.bike.ways.title': 'Mặt đường và loại đường', + 'tours.bike.ways.surface': 'Mặt đường', + 'tours.bike.ways.surface.paved': 'Nhựa đường', + 'tours.bike.ways.surface.rough': 'Mặt lát gồ ghề', + 'tours.bike.ways.surface.compacted': 'Nén chặt', + 'tours.bike.ways.surface.gravel': 'Sỏi', + 'tours.bike.ways.surface.ground': 'Đất tự nhiên', + 'tours.bike.ways.surface.unknown': 'Không có trong dữ liệu', + 'tours.bike.ways.way': 'Loại đường', + 'tours.bike.ways.way.cyclePath': 'Đường dành cho xe đạp', + 'tours.bike.ways.way.cycleLane': 'Làn xe đạp cạnh dòng xe', + 'tours.bike.ways.way.quiet': 'Đường vắng', + 'tours.bike.ways.way.road': 'Đường có xe cộ', + 'tours.bike.ways.way.track': 'Đường nông nghiệp hoặc đường rừng', + 'tours.bike.ways.way.path': 'Lối mòn', + 'tours.bike.ways.way.ferry': 'Phà', + 'tours.bike.ways.way.other': 'Khác', + 'tours.bike.ways.loading': 'Đang đọc các đoạn đường dọc lộ trình…', + 'tours.bike.ways.offline': 'Mặt đường và cấp độ đường mòn cần có kết nối.', + 'tours.bike.ways.failed': 'Không đọc được các đoạn đường dọc lộ trình. Bản thân lộ trình không đổi.', + 'tours.bike.ways.source': + 'Từ OpenStreetMap, theo cách bộ định tuyến đọc. Nơi dữ liệu không nói gì, mặt đường là chưa rõ, không phải là tốt.', + 'tours.bike.ways.showOnMap': 'Hiện trên bản đồ', + 'tours.bike.ways.unpavedBadge': '{share} chưa trải nhựa', + 'tours.bike.ways.unpavedNotice': 'Đã biết {length} của lộ trình chưa trải nhựa.', + 'tours.bike.trail.title': 'Cấp độ đường mòn (mtb:scale)', + 'tours.bike.trail.s0': 'S0, không có khó khăn đặc biệt', + 'tours.bike.trail.s1': 'S1, chướng ngại nhỏ', + 'tours.bike.trail.s2': 'S2, rễ cây, bậc, nền đất lỏng', + 'tours.bike.trail.s3': 'S3, đá tảng, dốc, cua gắt', + 'tours.bike.trail.s4': 'S4 trở lên, địa hình cho chuyên gia', + 'tours.bike.trail.none': 'Không có cấp độ trong dữ liệu', + 'tours.bike.trail.unasked': 'Lần này không truy vấn được', + 'tours.bike.trail.uphillTitle': 'Cấp độ lên dốc (mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': 'Lên dốc {step}', + 'tours.bike.trail.note': + 'Cấp độ theo những người lập bản đồ OpenStreetMap ghi. Đường không có cấp độ là chưa rõ, không phải dễ. Chúng không cho biết đường mòn có hợp với bạn không.', + 'tours.bike.trail.loading': 'Đang đọc cấp độ đường mòn…', + 'tours.bike.trail.failed': 'Lần này không đọc được cấp độ đường mòn.', 'tours.copy.action': 'Lưu thành chuyến đi bộ mới', 'tours.copy.hint': 'Sao chép chuyến này thành một chuyến đi bộ mới, độc lập, không kèm các ngày. Bản gốc giữ nguyên.', 'tours.copy.name': '{name} (bản sao)', @@ -351,6 +418,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': 'Cần kết nối tới máy chủ để tải tệp GPX.', 'tours.gpxExport.noRoute': 'Chuyến đi bộ này không có tuyến đường đã lưu để xuất.', 'tours.gpxExport.failed': 'Không thể tạo tệp GPX.', + 'tours.gpxExport.share': 'Chia sẻ GPX', + 'tours.gpxExport.shareShort': 'Chia sẻ', + 'tours.gpxExport.shareSaveFirst': 'Hãy lưu chuyến đi trước để chia sẻ dưới dạng GPX', + 'tours.gpxExport.shareReady': 'Tệp GPX đã sẵn sàng. Chạm Chia sẻ thêm một lần để mở bảng chia sẻ.', + 'tours.gpxExport.shareFailed': 'Không chia sẻ được. Bạn vẫn có thể tải tệp GPX xuống.', 'tours.revision.remoteTitle': 'Chuyến đi bộ này đã được lưu ở nơi khác', 'tours.revision.remoteBody': 'Ai đó, hoặc chính bạn trên thiết bị khác, đã lưu trong lúc bạn chỉnh sửa. Thay đổi của bạn vẫn giữ cho tới khi bạn quyết định.', @@ -381,15 +453,13 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': 'Chọn tuyến đường làm gì', 'tours.preferences.info': 'Các lựa chọn này điều chỉnh con đường bộ định tuyến chọn. Nơi không có đường khác, tuyến vẫn đi qua đó. Chúng không nói gì về độ khó của đường; giới hạn độ khó phía trên quyết định tuyến được dùng những gì.', + 'tours.preferences.avoid_climbs': 'Tránh dốc gắt', + 'tours.preferences.avoid_climbsHint': + 'Đi đường thoải hơn nếu có, kể cả khi dài hơn. Nơi không có đường vòng thì vẫn dốc.', 'tours.preferences.avoid_steps': 'Tránh bậc thang', 'tours.preferences.avoid_stepsHint': 'Chỉ đi bậc thang khi không có đường vòng.', 'tours.preferences.avoid_ferries': 'Tránh phà', 'tours.preferences.avoid_ferriesHint': 'Đi đường bộ nơi có đường vòng, kể cả khi dài hơn.', - 'tours.preferences.prefer_paths': 'Ưu tiên lối đi bộ', - 'tours.preferences.prefer_pathsHint': 'Lối riêng thay vì đường xe và vỉa hè, nơi có.', - 'tours.preferences.shortest': 'Ngắn hơn thay vì nhanh hơn', - 'tours.preferences.shortestHint': - 'Chọn đường theo độ dài chứ không theo thời gian; thường ngắn hơn một chút, không phải lúc nào cũng nhanh hơn.', 'tours.planner.tourType': 'Loại chuyến', 'tours.paddle.boat': 'Thuyền', 'tours.paddle.boat.kayak': 'Thuyền kayak', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 70c90782e..60f1144ae 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -648,6 +648,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': '健行地圖 (OpenHikingMap)', 'admin.toursHikingMap.subtitle': '在路線規劃器中提供 OpenHikingMap 作為底圖。非商業安裝每月 500,000 個圖磚以內免費;商業或更大規模的安裝需要先與 openmaps.fr 達成協議', + 'admin.toursCycleMap.title': '騎乘地圖(CyclOSM)', + 'admin.toursCycleMap.subtitle': + '在行程規劃器中為騎乘提供 CyclOSM 作為底圖。OpenStreetMap France 為適度使用提供該地圖;使用量大或商業的安裝應關閉它,或先與營運方達成協議', 'admin.addons.noAddons': '暫無可用擴充套件', 'admin.weather.title': '天氣資料', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts index f47874878..6e2621f67 100644 --- a/shared/src/i18n/zh-TW/map.ts +++ b/shared/src/i18n/zh-TW/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': '衛星', 'map.baseLayer.hiking': '健行', 'map.baseLayer.hikingDown': 'OpenHikingMap 目前沒有回應。請切換到其他地圖,你的路線保持不變。', + 'map.baseLayer.cycle': '騎乘', + 'map.baseLayer.cycleDown': 'CyclOSM 目前沒有回應。請切換到其他地圖,你的行程保持不變。', 'map.baseLayer.switchToSatellite': '切換到衛星檢視', 'map.baseLayer.switchToDefault': '切換到地圖檢視', 'map.location.denied': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 0afd9ef9a..ec3503fcc 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -84,6 +84,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': '導航會開啟登山口或步道入口,不會沿著遊程路線提供導航。', 'tours.planner.title': '規劃遊程', 'tours.planner.newTour': '規劃新遊程', + 'tours.planner.addTour': '新增行程', + 'tours.planner.addTourHint': '規劃新行程、匯入 GPX 檔案,或從其他旅行中接管行程', 'tours.planner.neutralTitle': '規劃遊程', 'tours.planner.neutralBody': '開始新路線,點選地圖放置路線點。', 'tours.planner.newTitle': '新遊程', @@ -99,6 +101,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': '展開海拔剖面圖', 'tours.planner.name': '遊程名稱', 'tours.planner.namePlaceholder': '未命名的健行', + 'tours.planner.namePlaceholder.bike': '未命名的騎乘', + 'tours.planner.namePlaceholder.kayak': '未命名的划船行程', 'tours.planner.mapSearch': '搜尋地點或地址', 'tours.planner.placePoints': '放置點', 'tours.planner.pointPlaced': '已放置點', @@ -158,6 +162,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': '等待計算', 'tours.planner.status.routing': '正在計算步行路線…', 'tours.planner.status.routingFailed': '路線計算失敗。仍顯示上次成功計算的路線。', + 'tours.planner.status.unreachable': '無法到達第 {point} 個點', + 'tours.planner.unreachable.hike': + '沒有健行路徑能到達第 {point} 個點:路線規劃器已知的最近道路在 {distance} 外。請把點移到道路上,或手動繪製到該點的路段。', + 'tours.planner.unreachable.bike': + '沒有可騎乘的道路能到達第 {point} 個點:路線規劃器已知的最近道路在 {distance} 外。請把點移到道路上,或手動繪製到該點的路段。', + 'tours.planner.unreachable.show': '顯示第 {point} 個點', 'tours.planner.status.elevation': '正在載入海拔…', 'tours.planner.status.elevationFailed': '無法取得海拔資料。仍顯示已計算的路線。', 'tours.planner.status.saving': '正在儲存…', @@ -314,6 +324,60 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': '景點', 'tours.poi.cat.slipway': '下水點', 'tours.poi.cat.boat_rental': '租船', + 'tours.poi.cat.bike_repair': '自行車維修', + 'tours.planner.tourTypeHint.hike': '步行,在你設定的難度內', + 'tours.planner.tourTypeHint.bike': '騎乘,為旅行車、公路車、礫石車或登山車規劃路線', + 'tours.planner.tourTypeHint.kayak': '水上,獨木舟、加拿大艇或立槳', + 'tours.bike.profile': '類型', + 'tours.bike.profile.touring': '旅行', + 'tours.bike.profile.road': '公路', + 'tours.bike.profile.gravel': '礫石', + 'tours.bike.profile.mtb': '登山', + 'tours.bike.profileHint.touring': '優先選擇自行車道和安靜道路,盡量走鋪面路。', + 'tours.bike.profileHint.road': '保持在柏油路上,並顯示已知的未鋪面路段。', + 'tours.bike.profileHint.gravel': '走礫石路和農用道路,不走技術性單線小徑。', + 'tours.bike.profileHint.mtb': '照原樣走崎嶇小徑,在有標註的地方顯示路徑難度。', + 'tours.bike.timeEstimated': '騎乘時間(估算)', + 'tours.bike.timeEstimatedShort': '騎乘時間', + 'tours.bike.timeEstimatedInfoLabel': '騎乘時間如何計算', + 'tours.bike.timeEstimatedInfo': '路線規劃器依所選類型的速度和爬升計算的時間。規劃值,不含休息。', + 'tours.bike.ways.title': '路面與道路', + 'tours.bike.ways.surface': '路面', + 'tours.bike.ways.surface.paved': '柏油', + 'tours.bike.ways.surface.rough': '粗糙鋪面', + 'tours.bike.ways.surface.compacted': '壓實路面', + 'tours.bike.ways.surface.gravel': '礫石', + 'tours.bike.ways.surface.ground': '自然地面', + 'tours.bike.ways.surface.unknown': '資料中沒有', + 'tours.bike.ways.way': '道路類型', + 'tours.bike.ways.way.cyclePath': '自行車道', + 'tours.bike.ways.way.cycleLane': '車道旁的自行車道', + 'tours.bike.ways.way.quiet': '安靜街道', + 'tours.bike.ways.way.road': '有車流的道路', + 'tours.bike.ways.way.track': '農用或林間道路', + 'tours.bike.ways.way.path': '小徑', + 'tours.bike.ways.way.ferry': '渡輪', + 'tours.bike.ways.way.other': '其他', + 'tours.bike.ways.loading': '正在讀取沿線道路…', + 'tours.bike.ways.offline': '路面和路徑難度需要連線。', + 'tours.bike.ways.failed': '無法讀取沿線道路,路線本身不受影響。', + 'tours.bike.ways.source': '來自 OpenStreetMap,依路線規劃器的讀取。資料未說明之處,路面為未知,而非良好。', + 'tours.bike.ways.showOnMap': '在地圖上顯示', + 'tours.bike.ways.unpavedBadge': '未鋪面 {share}', + 'tours.bike.ways.unpavedNotice': '已知路線中有 {length} 為未鋪面路段。', + 'tours.bike.trail.title': '路徑難度(mtb:scale)', + 'tours.bike.trail.s0': 'S0,無特別難度', + 'tours.bike.trail.s1': 'S1,小障礙', + 'tours.bike.trail.s2': 'S2,樹根、台階、鬆散路面', + 'tours.bike.trail.s3': 'S3,岩塊、陡坡、急彎', + 'tours.bike.trail.s4': 'S4 以上,專家級地形', + 'tours.bike.trail.none': '資料中無難度', + 'tours.bike.trail.unasked': '本次無法查詢', + 'tours.bike.trail.uphillTitle': '上坡難度(mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': '上坡 {step}', + 'tours.bike.trail.note': '難度為 OpenStreetMap 繪圖者所標註。無難度的道路為未知,而非容易。它不說明路徑是否適合你。', + 'tours.bike.trail.loading': '正在讀取路徑難度…', + 'tours.bike.trail.failed': '本次無法讀取路徑難度。', 'tours.copy.action': '另存為新遊程', 'tours.copy.hint': '將此遊程複製為不含日程的獨立新遊程。原遊程保持不變。', 'tours.copy.name': '{name}(副本)', @@ -325,6 +389,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': '下載 GPX 檔案需要連線到伺服器。', 'tours.gpxExport.noRoute': '這個遊程沒有可匯出的已儲存路線。', 'tours.gpxExport.failed': '無法建立 GPX 檔案。', + 'tours.gpxExport.share': '分享 GPX', + 'tours.gpxExport.shareShort': '分享', + 'tours.gpxExport.shareSaveFirst': '先儲存行程,再以 GPX 分享', + 'tours.gpxExport.shareReady': 'GPX 檔案已準備好。再點一次分享即可開啟分享面板。', + 'tours.gpxExport.shareFailed': '分享未成功。仍可下載 GPX 檔案。', 'tours.revision.remoteTitle': '這個遊程已在別處儲存', 'tours.revision.remoteBody': '在你編輯時,有人(或你在另一台裝置上)儲存了它。你的變更會保留,直到你做出決定。', 'tours.revision.loadNewer': '載入較新版本', @@ -353,14 +422,12 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': '路線偏好的作用', 'tours.preferences.info': '這些選項會影響路線規劃選擇哪條路。沒有其他路時,路線仍會經過那裡。它們不表示道路的難度;路線可以使用哪些路,由上方的難度上限決定。', + 'tours.preferences.avoid_climbs': '避開陡坡', + 'tours.preferences.avoid_climbsHint': '有較平緩的路時優先走,即使更長。沒有繞行路線的地方仍然陡峭。', 'tours.preferences.avoid_steps': '避開階梯', 'tours.preferences.avoid_stepsHint': '只在無法繞行時經過階梯。', 'tours.preferences.avoid_ferries': '避開渡輪', 'tours.preferences.avoid_ferriesHint': '有繞行陸路時走陸路,即使更遠。', - 'tours.preferences.prefer_paths': '優先步道', - 'tours.preferences.prefer_pathsHint': '有獨立小路時,優先於道路與人行道。', - 'tours.preferences.shortest': '更短而非更快', - 'tours.preferences.shortestHint': '依長度而非用時選擇道路;通常略短,但不一定更快。', 'tours.planner.tourType': '路線類型', 'tours.paddle.boat': '船隻', 'tours.paddle.boat.kayak': '獨木舟(Kayak)', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index dc1d6d363..694d45a76 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -598,6 +598,9 @@ const admin: TranslationStrings = { 'admin.toursHikingMap.title': '徒步地图 (OpenHikingMap)', 'admin.toursHikingMap.subtitle': '在路线规划器中提供 OpenHikingMap 作为底图。非商业安装每月 500,000 个瓦片以内免费;商业或更大规模的安装需要先与 openmaps.fr 达成协议', + 'admin.toursCycleMap.title': '骑行地图(CyclOSM)', + 'admin.toursCycleMap.subtitle': + '在行程规划器中为骑行提供 CyclOSM 作为底图。OpenStreetMap France 为适度使用提供该地图;使用量大或商业的安装应关闭它,或先与运营方达成协议', 'admin.addons.noAddons': '暂无可用扩展', 'admin.weather.title': '天气数据', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts index e4a1bfb69..8235abb9f 100644 --- a/shared/src/i18n/zh/map.ts +++ b/shared/src/i18n/zh/map.ts @@ -20,6 +20,8 @@ const map: TranslationStrings = { 'map.baseLayer.satellite': '卫星', 'map.baseLayer.hiking': '徒步', 'map.baseLayer.hikingDown': 'OpenHikingMap 当前无响应。请切换到其他地图,你的路线保持不变。', + 'map.baseLayer.cycle': '骑行', + 'map.baseLayer.cycleDown': 'CyclOSM 目前没有响应。请切换到其他地图,你的行程保持不变。', 'map.baseLayer.switchToSatellite': '切换到卫星视图', 'map.baseLayer.switchToDefault': '切换到地图视图', 'map.location.denied': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index c9b9951af..192a0f8ae 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -85,6 +85,8 @@ const tours: TranslationStrings = { 'tours.detail.navigationHint': '导航打开的是步道起点,不会沿游览路线提供导航。', 'tours.planner.title': '规划游览路线', 'tours.planner.newTour': '规划新游览路线', + 'tours.planner.addTour': '添加行程', + 'tours.planner.addTourHint': '规划新行程、导入 GPX 文件,或从其他旅行中接管行程', 'tours.planner.neutralTitle': '规划游览路线', 'tours.planner.neutralBody': '开始新路线,点击地图放置路线点。', 'tours.planner.newTitle': '新游览路线', @@ -100,6 +102,8 @@ const tours: TranslationStrings = { 'tours.planner.expandElevation': '展开海拔剖面图', 'tours.planner.name': '游览名称', 'tours.planner.namePlaceholder': '未命名的徒步活动', + 'tours.planner.namePlaceholder.bike': '未命名的骑行', + 'tours.planner.namePlaceholder.kayak': '未命名的划船行程', 'tours.planner.mapSearch': '搜索地点或地址', 'tours.planner.placePoints': '放置点', 'tours.planner.pointPlaced': '已放置点', @@ -159,6 +163,12 @@ const tours: TranslationStrings = { 'tours.planner.status.dirty': '等待计算', 'tours.planner.status.routing': '正在计算步行路线…', 'tours.planner.status.routingFailed': '路线计算失败。仍显示上次成功计算的路线。', + 'tours.planner.status.unreachable': '无法到达第 {point} 个点', + 'tours.planner.unreachable.hike': + '没有徒步路径能到达第 {point} 个点:路线规划器已知的最近道路在 {distance} 外。请把点移到道路上,或手动绘制到该点的路段。', + 'tours.planner.unreachable.bike': + '没有可骑行的道路能到达第 {point} 个点:路线规划器已知的最近道路在 {distance} 外。请把点移到道路上,或手动绘制到该点的路段。', + 'tours.planner.unreachable.show': '显示第 {point} 个点', 'tours.planner.status.elevation': '正在加载海拔…', 'tours.planner.status.elevationFailed': '无法获取海拔数据。仍显示已计算的路线。', 'tours.planner.status.saving': '正在保存…', @@ -315,6 +325,60 @@ const tours: TranslationStrings = { 'tours.poi.cat.sights': '景点', 'tours.poi.cat.slipway': '下水点', 'tours.poi.cat.boat_rental': '租船', + 'tours.poi.cat.bike_repair': '自行车维修', + 'tours.planner.tourTypeHint.hike': '步行,在你设定的难度内', + 'tours.planner.tourTypeHint.bike': '骑行,为旅行车、公路车、砾石车或山地车规划路线', + 'tours.planner.tourTypeHint.kayak': '水上,皮划艇、独木舟或桨板', + 'tours.bike.profile': '类型', + 'tours.bike.profile.touring': '旅行', + 'tours.bike.profile.road': '公路', + 'tours.bike.profile.gravel': '砾石', + 'tours.bike.profile.mtb': '山地', + 'tours.bike.profileHint.touring': '优先选择自行车道和安静道路,尽量走铺装路面。', + 'tours.bike.profileHint.road': '保持在柏油路上,并显示已知的未铺装路段。', + 'tours.bike.profileHint.gravel': '走砾石路和农用道路,不走技术性单线小径。', + 'tours.bike.profileHint.mtb': '按原样走崎岖小路,在有标注的地方显示路径难度。', + 'tours.bike.timeEstimated': '骑行时间(估算)', + 'tours.bike.timeEstimatedShort': '骑行时间', + 'tours.bike.timeEstimatedInfoLabel': '骑行时间如何计算', + 'tours.bike.timeEstimatedInfo': '路线规划器按所选类型的速度和爬升计算的时间。规划值,不含休息。', + 'tours.bike.ways.title': '路面与道路', + 'tours.bike.ways.surface': '路面', + 'tours.bike.ways.surface.paved': '柏油', + 'tours.bike.ways.surface.rough': '粗糙铺面', + 'tours.bike.ways.surface.compacted': '压实路面', + 'tours.bike.ways.surface.gravel': '砾石', + 'tours.bike.ways.surface.ground': '自然地面', + 'tours.bike.ways.surface.unknown': '数据中没有', + 'tours.bike.ways.way': '道路类型', + 'tours.bike.ways.way.cyclePath': '自行车道', + 'tours.bike.ways.way.cycleLane': '机动车旁的自行车道', + 'tours.bike.ways.way.quiet': '安静街道', + 'tours.bike.ways.way.road': '有车流的道路', + 'tours.bike.ways.way.track': '农用或林间道路', + 'tours.bike.ways.way.path': '小径', + 'tours.bike.ways.way.ferry': '渡轮', + 'tours.bike.ways.way.other': '其他', + 'tours.bike.ways.loading': '正在读取沿线道路…', + 'tours.bike.ways.offline': '路面和路径难度需要联网。', + 'tours.bike.ways.failed': '无法读取沿线道路,路线本身不受影响。', + 'tours.bike.ways.source': '来自 OpenStreetMap,按路线规划器的读取。数据未说明之处,路面为未知,而非良好。', + 'tours.bike.ways.showOnMap': '在地图上显示', + 'tours.bike.ways.unpavedBadge': '未铺装 {share}', + 'tours.bike.ways.unpavedNotice': '已知路线中有 {length} 为未铺装路面。', + 'tours.bike.trail.title': '路径难度(mtb:scale)', + 'tours.bike.trail.s0': 'S0,无特别难度', + 'tours.bike.trail.s1': 'S1,小障碍', + 'tours.bike.trail.s2': 'S2,树根、台阶、松散路面', + 'tours.bike.trail.s3': 'S3,岩块、陡坡、急弯', + 'tours.bike.trail.s4': 'S4 及以上,专家级地形', + 'tours.bike.trail.none': '数据中无难度', + 'tours.bike.trail.unasked': '本次无法查询', + 'tours.bike.trail.uphillTitle': '上坡难度(mtb:scale:uphill)', + 'tours.bike.trail.uphillStep': '上坡 {step}', + 'tours.bike.trail.note': '难度为 OpenStreetMap 绘图者所标注。无难度的道路为未知,而非容易。它不说明路径是否适合你。', + 'tours.bike.trail.loading': '正在读取路径难度…', + 'tours.bike.trail.failed': '本次无法读取路径难度。', 'tours.copy.action': '另存为新路线', 'tours.copy.hint': '将此路线复制为不含日程的独立新路线。原路线保持不变。', 'tours.copy.name': '{name}(副本)', @@ -326,6 +390,11 @@ const tours: TranslationStrings = { 'tours.gpxExport.offline': '下载 GPX 文件需要连接服务器。', 'tours.gpxExport.noRoute': '这条路线没有可导出的已保存线路。', 'tours.gpxExport.failed': '无法创建 GPX 文件。', + 'tours.gpxExport.share': '分享 GPX', + 'tours.gpxExport.shareShort': '分享', + 'tours.gpxExport.shareSaveFirst': '先保存行程,再以 GPX 分享', + 'tours.gpxExport.shareReady': 'GPX 文件已准备好。再点一次分享即可打开分享面板。', + 'tours.gpxExport.shareFailed': '分享未成功。仍可下载 GPX 文件。', 'tours.revision.remoteTitle': '这条路线已在别处保存', 'tours.revision.remoteBody': '在你编辑时,有人(或你在另一台设备上)保存了它。你的更改会保留,直到你做出决定。', 'tours.revision.loadNewer': '加载较新版本', @@ -354,14 +423,12 @@ const tours: TranslationStrings = { 'tours.preferences.infoLabel': '路线偏好的作用', 'tours.preferences.info': '这些选项会影响路线规划选择哪条路。没有其他路时,路线仍会经过那里。它们不表示道路的难度;路线可以使用哪些路,由上方的难度上限决定。', + 'tours.preferences.avoid_climbs': '避开陡坡', + 'tours.preferences.avoid_climbsHint': '有较平缓的路时优先走,即使更长。没有绕行路线的地方仍然陡峭。', 'tours.preferences.avoid_steps': '避开台阶', 'tours.preferences.avoid_stepsHint': '只在无法绕行时经过台阶。', 'tours.preferences.avoid_ferries': '避开渡轮', 'tours.preferences.avoid_ferriesHint': '有绕行陆路时走陆路,即使更远。', - 'tours.preferences.prefer_paths': '优先步道', - 'tours.preferences.prefer_pathsHint': '有独立小路时,优先于道路和人行道。', - 'tours.preferences.shortest': '更短而非更快', - 'tours.preferences.shortestHint': '按长度而非用时选择道路;通常略短,但不一定更快。', 'tours.planner.tourType': '路线类型', 'tours.paddle.boat': '船只', 'tours.paddle.boat.kayak': '皮划艇', diff --git a/shared/src/index.ts b/shared/src/index.ts index df8567e8f..ca1e58737 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -58,6 +58,7 @@ export * from './tours/tours.schema'; export * from './tours/tour-provenance.schema'; export * from './tours/tour-library.schema'; export * from './tours/tour-poi-search.schema'; +export * from './tours/tour-trail-grade.schema'; export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; export * from './tours/paddle-plan.schema'; diff --git a/shared/src/tours/tour-gpx-import.schema.ts b/shared/src/tours/tour-gpx-import.schema.ts index a352c5106..0ccbc3f93 100644 --- a/shared/src/tours/tour-gpx-import.schema.ts +++ b/shared/src/tours/tour-gpx-import.schema.ts @@ -1,4 +1,9 @@ -import { paddleBoatTypeSchema, paddleWaterContextSchema, plannableTourTypeSchema } from './tours.schema'; +import { + bikeProfileSchema, + paddleBoatTypeSchema, + paddleWaterContextSchema, + plannableTourTypeSchema, +} from './tours.schema'; import { z } from 'zod'; @@ -33,21 +38,24 @@ export type TourGpxPreviewResponse = z.infer { - const paddle = value.tour_type === 'kayak'; - for (const field of ['boat_type', 'water_context'] as const) { - if (paddle && value[field] === undefined) - ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `a paddle import needs ${field}` }); - if (!paddle && value[field] !== undefined) - ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `${field} belongs to a paddle import` }); + const owners = { bike_profile: 'bike', boat_type: 'kayak', water_context: 'kayak' } as const; + for (const [field, owner] of Object.entries(owners) as Array<[keyof typeof owners, string]>) { + const name = owner === 'bike' ? 'a bike import' : 'a paddle import'; + if (value.tour_type === owner && value[field] === undefined) + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `${name} needs ${field}` }); + if (value.tour_type !== owner && value[field] !== undefined) + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `${field} belongs to ${name}` }); } }); export type TourGpxImportFields = z.infer; diff --git a/shared/src/tours/tour-poi-search.schema.spec.ts b/shared/src/tours/tour-poi-search.schema.spec.ts index c72da2beb..e486e86b9 100644 --- a/shared/src/tours/tour-poi-search.schema.spec.ts +++ b/shared/src/tours/tour-poi-search.schema.spec.ts @@ -37,7 +37,7 @@ describe('tour POI search contracts (PLN-02)', () => { ], }).success, ).toBe(false); - expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'bike' }).success).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'run' }).success).toBe(false); expect(tourPoiSearchRequestSchema.safeParse({ ...ok, add: true }).success).toBe(false); }); diff --git a/shared/src/tours/tour-poi-search.schema.ts b/shared/src/tours/tour-poi-search.schema.ts index fda814c7b..a56aa4470 100644 --- a/shared/src/tours/tour-poi-search.schema.ts +++ b/shared/src/tours/tour-poi-search.schema.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; * the line as drawn, never a detour someone walked or paddled. */ -/** What can be looked for along a tour on foot or on the water. */ +/** What can be looked for along a tour on foot, by bike or on the water. */ export const TOUR_POI_CATEGORIES = [ 'viewpoint', 'peak', @@ -21,6 +21,7 @@ export const TOUR_POI_CATEGORIES = [ 'sights', 'slipway', 'boat_rental', + 'bike_repair', ] as const; export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES); export type TourPoiCategory = z.infer; @@ -28,6 +29,7 @@ export type TourPoiCategory = z.infer; /** The categories each planned tour type offers, the most asked first. */ export const TOUR_POI_CATEGORIES_BY_TYPE: Record = { hike: ['viewpoint', 'shelter', 'drinking_water', 'food', 'peak', 'toilets', 'picnic', 'campsite', 'sights'], + bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint', 'toilets', 'shelter', 'picnic', 'campsite', 'sights'], kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'], }; diff --git a/shared/src/tours/tour-trail-grade.schema.ts b/shared/src/tours/tour-trail-grade.schema.ts new file mode 100644 index 000000000..32451de63 --- /dev/null +++ b/shared/src/tours/tour-trail-grade.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +/** + * BIK-05: the trail grades OpenStreetMap carries for the ways a mountain bike + * tour runs on, asked for by the ways' ids. A read: nothing about the tour + * changes. A way without a grade is unknown, never easy; a way that could not + * be asked about is named, so the planner can say so. + */ + +/** The most ways one question names; a longer tour asks for its first ones. */ +export const TOUR_TRAIL_GRADE_MAX_WAYS = 2000; + +export const tourTrailGradeRequestSchema = z + .object({ + way_ids: z.array(z.number().int().positive()).min(1).max(TOUR_TRAIL_GRADE_MAX_WAYS), + }) + .strict(); +export type TourTrailGradeRequest = z.infer; + +/** + * One way's grades as OpenStreetMap tags them, read to the scale's own steps: + * `mtb:scale` 0 to 6 (a + or - kept), `mtb:scale:uphill` 0 to 5, + * `mtb:scale:imba` 0 to 4. Null where the way has no such tag or one that is + * no step of the scale. + */ +export const tourTrailGradeSchema = z.object({ + id: z.number().int().positive(), + mtb_scale: z + .string() + .regex(/^[0-6][+-]?$/) + .nullable(), + mtb_scale_uphill: z + .string() + .regex(/^[0-5][+-]?$/) + .nullable(), + mtb_scale_imba: z + .string() + .regex(/^[0-4]$/) + .nullable(), +}); +export type TourTrailGrade = z.infer; + +export const tourTrailGradeResponseSchema = z.object({ + /** The ways asked about that carry at least one grade. */ + ways: z.array(tourTrailGradeSchema), + /** The ways the data could not be asked about this time: their grade is unknown, not absent. */ + unanswered: z.array(z.number().int().positive()), +}); +export type TourTrailGradeResponse = z.infer; diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts index 4f0ccc6b1..70882713c 100644 --- a/shared/src/tours/tours.schema.test.ts +++ b/shared/src/tours/tours.schema.test.ts @@ -1,4 +1,7 @@ +import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema'; import { + BIKE_PROFILES, + HIKING_PREFERENCE_KEYS, hikingPreferencesOf, MAX_PADDLE_SPEED_KMH, MAX_PLANNED_TOUR_DURATION_MINUTES, @@ -275,7 +278,7 @@ describe('Tours paddle type', () => { it('refuses boat fields on a hike, and a type nobody can plan yet', () => { expect(() => tourCreateRequestSchema.parse({ ...baseRequest, boat_type: 'sup' })).toThrow(/paddle tour/); expect(() => tourCreateRequestSchema.parse({ ...baseRequest, paddle_speed_kmh: 4 })).toThrow(/paddle tour/); - expect(() => tourCreateRequestSchema.parse({ ...baseRequest, tour_type: 'bike' })).toThrow(); + expect(() => tourCreateRequestSchema.parse({ ...baseRequest, tour_type: 'run' })).toThrow(); }); it('suggests 4 km/h for kayak, canoe and SUP, and nothing for any other boat', () => { @@ -318,17 +321,23 @@ describe('hiking preferences (MF-08)', () => { }; it('keeps only the preferences switched on, and none as null', () => { - expect(hikingPreferencesOf({ avoid_steps: true, shortest: false })).toEqual({ avoid_steps: true }); + expect(hikingPreferencesOf({ avoid_steps: true, avoid_climbs: false })).toEqual({ avoid_steps: true }); expect(hikingPreferencesOf({ avoid_steps: false })).toBeNull(); expect(hikingPreferencesOf(null)).toBeNull(); const parsed = tourCreateRequestSchema.parse({ ...hike, - hiking_preferences: { prefer_paths: true, avoid_ferries: false }, + hiking_preferences: { avoid_climbs: true, avoid_ferries: false }, }); - expect(hikingPreferencesOf(parsed.hiking_preferences)).toEqual({ prefer_paths: true }); + expect(hikingPreferencesOf(parsed.hiking_preferences)).toEqual({ avoid_climbs: true }); expect(tourCreateRequestSchema.parse(hike).hiking_preferences).toBeUndefined(); }); + it('RTE-01: offers exactly the three preferences, and refuses a key it does not know', () => { + expect(HIKING_PREFERENCE_KEYS).toEqual(['avoid_climbs', 'avoid_steps', 'avoid_ferries']); + const unknown = tourCreateRequestSchema.safeParse({ ...hike, hiking_preferences: { shortest: true } }); + expect(unknown.success).toBe(false); + }); + it('refuses a preference it does not know, and any on a paddle tour', () => { expect(tourCreateRequestSchema.safeParse({ ...hike, hiking_preferences: { avoid_hills: true } }).success).toBe( false, @@ -342,3 +351,64 @@ describe('hiking preferences (MF-08)', () => { ); }); }); + +describe('bike tours (BIK-01 to BIK-04)', () => { + const bike = { ...baseRequest, tour_type: 'bike' as const, bike_profile: 'gravel' as const }; + const ferrata = { + kind: 'ferrata' as const, + ref: 'way/1', + name: null, + geometry: [ + [48, 11], + [48.01, 11.02], + ], + }; + + it('saves a bike tour with its one profile and nothing of a hike or a paddle tour', () => { + expect(BIKE_PROFILES).toEqual(['touring', 'road', 'gravel', 'mtb']); + expect(tourCreateRequestSchema.parse(bike)).toMatchObject({ + tour_type: 'bike', + bike_profile: 'gravel', + max_hiking_difficulty: null, + }); + }); + + it('refuses a bike tour without a profile, with a hike or a paddler field, or over a via ferrata', () => { + expect(() => tourCreateRequestSchema.parse({ ...bike, bike_profile: null })).toThrow(/bike profile/); + expect(() => tourCreateRequestSchema.parse({ ...bike, bike_profile: 'ebike' })).toThrow(); + expect(() => tourCreateRequestSchema.parse({ ...bike, max_hiking_difficulty: 3 })).toThrow(/hiking difficulty/); + expect(() => tourCreateRequestSchema.parse({ ...bike, hiking_preferences: { avoid_steps: true } })).toThrow( + /hiking preferences/, + ); + expect(() => tourCreateRequestSchema.parse({ ...bike, boat_type: 'sup' })).toThrow(/paddle tour/); + const overFerrata = { + ...bike, + waypoints: [{ ...bike.waypoints[0], fixed_leg: ferrata }, bike.waypoints[1]], + }; + expect(() => tourCreateRequestSchema.parse(overFerrata)).toThrow(/via ferrata/); + }); + + it('keeps a profile to a bike tour', () => { + expect(() => tourCreateRequestSchema.parse({ ...baseRequest, bike_profile: 'road' })).toThrow(/bike tour/); + const paddle = { + ...baseRequest, + tour_type: 'kayak', + boat_type: 'kayak', + water_context: 'lake', + bike_profile: 'mtb', + }; + expect(() => tourCreateRequestSchema.parse(paddle)).toThrow(/bike tour/); + }); + + it('imports a GPX file as a bike tour only with its profile, and a profile only for one', () => { + expect(tourGpxImportFieldsSchema.parse({ tour_type: 'bike', bike_profile: 'road' })).toMatchObject({ + tour_type: 'bike', + bike_profile: 'road', + }); + expect(tourGpxImportFieldsSchema.safeParse({ tour_type: 'bike' }).success).toBe(false); + expect(tourGpxImportFieldsSchema.safeParse({ bike_profile: 'road' }).success).toBe(false); + expect( + tourGpxImportFieldsSchema.safeParse({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }).success, + ).toBe(true); + }); +}); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 37465d3cf..46a49b09e 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -27,11 +27,23 @@ export type TourTypeKey = z.infer; /** * The types a tour can be planned, saved and imported as. `kayak` is the key * the paddle tour has had in `tour_types` from the start; the app calls it a - * paddle tour, and the boat is a field of its own. + * paddle tour, and the boat is a field of its own. A bike tour (BIK-01) names + * its profile the same way. */ -export const plannableTourTypeSchema = z.enum(['hike', 'kayak']); +export const plannableTourTypeSchema = z.enum(['hike', 'bike', 'kayak']); export type PlannableTourType = z.infer; +/** + * BIK-01 to BIK-04: what a bike tour is ridden on, the one profile it is routed + * for. Touring/city keeps to paved ways, cycle paths and quiet roads; road + * keeps to asphalt; gravel takes gravel and farm tracks; mtb takes rough tracks + * as they come. Each asks the router for something else (see the client's + * bike routing for what was measured); none says a way is rideable or safe. + */ +export const BIKE_PROFILES = ['touring', 'road', 'gravel', 'mtb'] as const; +export const bikeProfileSchema = z.enum(BIKE_PROFILES); +export type BikeProfile = z.infer; + /** PAD-01: the one boat a paddle tour is planned for. */ export const paddleBoatTypeSchema = z.enum(['kayak', 'canoe', 'sup', 'other']); export type PaddleBoatType = z.infer; @@ -60,24 +72,22 @@ export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6); export type TourMaxHikingDifficulty = z.infer; /** - * MF-08: what a hiker asks the router for besides the difficulty limit. Only - * preferences whose effect on the router was measured are offered; each is a - * weighting that gives way where there is no other path, never a ban, and - * none says anything about how hard the path is. A hike without any keeps - * null, which is what every tour saved before had. + * MF-08, RTE-01: what a hiker asks the router for besides the difficulty + * limit: steep climbs, steps and ferries, each avoided where there is a way + * round. Each is a weighting whose effect on the router was measured, never a + * ban, and none says anything about how hard the path is. A hike without any + * keeps null, which is what every tour saved before had. */ -export const HIKING_PREFERENCE_KEYS = ['avoid_steps', 'avoid_ferries', 'prefer_paths', 'shortest'] as const; +export const HIKING_PREFERENCE_KEYS = ['avoid_climbs', 'avoid_steps', 'avoid_ferries'] as const; export type HikingPreferenceKey = (typeof HIKING_PREFERENCE_KEYS)[number]; export const hikingPreferencesSchema = z .object({ + /** Gentler ways where there are some, even when they are longer. */ + avoid_climbs: z.boolean().optional(), /** Steps and stairs only where there is no way round. */ avoid_steps: z.boolean().optional(), /** A ferry only where there is no way round over land. */ avoid_ferries: z.boolean().optional(), - /** Footpaths of their own over roads and their pavements, where there are some. */ - prefer_paths: z.boolean().optional(), - /** The way chosen by its length, not by the time it takes. */ - shortest: z.boolean().optional(), }) .strict(); export type HikingPreferences = z.infer; @@ -151,6 +161,8 @@ export const tourSchema = z.object({ hiking_preferences: hikingPreferencesSchema.nullable().optional(), /** The import (a GPX file, a wanderer snapshot) this tour was planned from, while it is still in the trip. */ derived_from_place_id: z.number().int().positive().nullable().optional(), + /** BIK-01: a bike tour's profile; null for every other type. Absent in a list cached before it existed. */ + bike_profile: bikeProfileSchema.nullable().optional(), /** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */ boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), @@ -327,6 +339,8 @@ export const tourCreateRequestSchema = z max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable().optional(), /** MF-08, a hike's only: what it asks the router for besides its limit. */ hiking_preferences: hikingPreferencesSchema.nullable().optional(), + /** BIK-01, a bike tour's only, and its one requirement. */ + bike_profile: bikeProfileSchema.nullable().optional(), boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), @@ -399,49 +413,66 @@ export type TourCreateRequest = z.infer; /** What a client sends: the fields the server fills in may be left out. */ export type TourCreateInput = z.input; +type Issue = { path: Array; message: string }; + +const PADDLE_FIELDS = [ + 'boat_type', + 'water_context', + 'paddle_speed_kmh', + 'portage_speed_kmh', + 'paddle_lake_style', +] as const; + /** * What belongs to which type: a hike carries a routing limit and no boat; a - * paddle tour carries exactly one boat and its water, and no hiking limit. + * bike tour exactly one profile and nothing of a hike's or a paddler's; a + * paddle tour exactly one boat and its water, and no hiking limit. */ function tourTypeIssues(value: { tour_type: PlannableTourType; max_hiking_difficulty?: number | null; hiking_preferences?: HikingPreferences | null; + bike_profile?: BikeProfile | null; boat_type?: PaddleBoatType | null; 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 }> = []; - if (value.tour_type === 'kayak') { +}): Issue[] { + const issues: Issue[] = []; + const type = value.tour_type; + const named = type === 'kayak' ? 'a paddle tour' : type === 'bike' ? 'a bike tour' : 'a hike'; + if (type === 'kayak') { if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' }); if (value.water_context == null) issues.push({ path: ['water_context'], message: 'a paddle tour needs a water context' }); + } else { + for (const field of PADDLE_FIELDS) { + if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` }); + } + } + if (type === 'hike') { + if (value.max_hiking_difficulty === null) + issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' }); + } else { if (value.max_hiking_difficulty != null) - issues.push({ path: ['max_hiking_difficulty'], message: 'a paddle tour has no hiking difficulty' }); + issues.push({ path: ['max_hiking_difficulty'], message: `${named} has no hiking difficulty` }); if (hikingPreferencesOf(value.hiking_preferences) !== null) - issues.push({ path: ['hiking_preferences'], message: 'a paddle tour has no hiking preferences' }); - return issues; + issues.push({ path: ['hiking_preferences'], message: `${named} has no hiking preferences` }); } - 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', - '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. + if (type === 'bike' && value.bike_profile == null) + issues.push({ path: ['bike_profile'], message: 'a bike tour needs a bike profile' }); + if (type !== 'bike' && value.bike_profile != null) + issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' }); + // A portage and a lock are a paddler's; a via ferrata is climbed, never ridden. (value.waypoints ?? []).forEach((point, index) => { - if (point.fixed_leg?.kind === 'portage') + if (type !== 'kayak' && point.fixed_leg?.kind === 'portage') issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' }); - if (point.lock != null) + if (type !== 'kayak' && point.lock != null) issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' }); + if (type === 'bike' && point.fixed_leg?.kind === 'ferrata') + issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a bike tour has no via ferrata' }); }); return issues; }