feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state

This commit is contained in:
Maurice
2026-10-21 11:58:39 +02:00
parent 924e38472e
commit 1a722e63fb
50 changed files with 1071 additions and 57 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
@@ -68,6 +68,8 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
// BIK-07: the road trip's charging colour, so a charger reads as one on every map.
ebike_charging: { key: 'ebike_charging', labelKey: 'tours.poi.cat.ebike_charging', Icon: BatteryCharging, color: SERVICE_COLORS.charging },
}
// A Map rather than an object, so a category named after an Object.prototype member
@@ -12,6 +12,7 @@ import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourPoiChargingFacts } from './TourPoiChargingFacts';
import { TourPoiSlotPicker } from './TourPoiSlotPicker';
import { usePoiInsertion } from './usePoiInsertion';
import { useSelectedPoi } from './useTourPoiSearch';
@@ -135,6 +136,7 @@ function TourPoiCard({
</WhiteBadge>
</div>
<PoiFacts poi={poi} />
{poi.charging && <TourPoiChargingFacts charging={poi.charging} />}
<TourPoiSlotPicker planner={planner} poi={poi} insertion={insertion} unit={unit} />
<p className="mt-2 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
{suggested && <span>{t('tours.walk.themeReason', { theme: t(`tours.walk.theme.${suggested}`) })}</span>}
@@ -0,0 +1,111 @@
import type { TourPoiCharging } from '@trek/shared';
import { Bike, Plug } from 'lucide-react';
import type { ReactElement } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
/** Socket families as people call them; proper nouns, so not translated. */
const SOCKET_NAMES: Record<string, string> = {
schuko: 'Schuko',
typee: 'Type E',
bs1363: 'BS 1363',
sev1011_t13: 'T13',
sev1011_t23: 'T23',
cee_blue: 'CEE',
type2: 'Type 2',
};
/** The household plugs: the bike's own charger goes in, none hangs there. */
const HOUSEHOLD_SOCKETS: ReadonlySet<string> = new Set([
'schuko',
'typee',
'bs1363',
'sev1011_t13',
'sev1011_t23',
'cee_blue',
]);
type Translate = ReturnType<typeof useTranslation>['t'];
/** OpenStreetMap's access values, read as one of three answers; any other is left unsaid. */
function accessLabel(access: string | null, t: Translate) {
if (access === 'yes' || access === 'permissive' || access === 'public') return t('tours.poi.charging.access.public');
if (access === 'customers') return t('tours.poi.charging.access.customers');
if (access === 'private' || access === 'no') return t('tours.poi.charging.access.private');
return null;
}
function Fact({ hint, children }: { hint: string; children: ReactElement }) {
return (
<Tooltip label={hint} placement="top">
<span className="inline-flex">{children}</span>
</Tooltip>
);
}
/**
* BIK-07: what an e-bike charging point states, a badge per fact and only the
* facts given: made for bikes or letting them charge, its sockets, fee, access
* and how many bikes fit. A plug left unsaid is said to be unsaid, dimmed, so it
* never reads as one that fits.
*/
export function TourPoiChargingFacts({ charging }: { charging: TourPoiCharging }) {
const { t } = useTranslation();
const hint = t('tours.poi.charging.sourceHint');
const access = accessLabel(charging.access, t);
return (
<div className="mt-2" data-testid="tour-poi-charging">
<p className="m-0 mb-1 text-caption text-content-faint">{t('tours.poi.charging.label')}</p>
<div className="flex flex-wrap gap-1.5">
<Fact hint={hint}>
{charging.bicycle === 'designated' ? (
<SoftPill tone="success" icon={<Bike size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('tours.poi.charging.designated')}
</SoftPill>
) : (
<WhiteBadge icon={<Bike size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.charging.allowed')}
</WhiteBadge>
)}
</Fact>
{charging.sockets.map((socket) => (
<Fact key={socket.type} hint={HOUSEHOLD_SOCKETS.has(socket.type) ? t('tours.poi.charging.ownCharger') : hint}>
<WhiteBadge icon={<Plug size={10} strokeWidth={2.2} className="text-content-faint" />}>
{SOCKET_NAMES[socket.type] ?? socket.type}
{socket.count && socket.count > 1 ? ` ×${socket.count}` : ''}
{socket.kw ? ` ${socket.kw} kW` : ''}
</WhiteBadge>
</Fact>
))}
{charging.sockets.length === 0 && (
<Fact hint={t('tours.poi.charging.socketsUnknownHint')}>
<span className="inline-flex opacity-60" data-testid="tour-poi-charging-no-socket">
<WhiteBadge icon={<Plug size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.charging.socketsUnknown')}
</WhiteBadge>
</span>
</Fact>
)}
{charging.fee !== null && (
<Fact hint={hint}>
<WhiteBadge>{charging.fee ? t('tours.poi.charging.fee') : t('tours.poi.charging.free')}</WhiteBadge>
</Fact>
)}
{access && (
<Fact hint={hint}>
<WhiteBadge>{access}</WhiteBadge>
</Fact>
)}
{charging.capacity !== null && (
<Fact hint={hint}>
<WhiteBadge>
{t('tours.poi.charging.capacity')}
<span className="font-medium text-content">{charging.capacity}</span>
</WhiteBadge>
</Fact>
)}
</div>
</div>
);
}
@@ -47,6 +47,26 @@ function RetryButton({ search }: { search: TourPoiSearch }) {
);
}
/** The search sent again with the next wider corridor, while there is one. */
function WidenButton({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
const wider = search.widerCorridorM;
if (wider === null) return null;
return (
<button
type="button"
onClick={search.widen}
disabled={!search.canSearch}
data-testid="tour-poi-widen"
className="inline-flex items-center gap-1 rounded-full border border-edge bg-surface-card px-2 py-0.5 font-medium text-content hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
style={fs(11.5, 'body')}
>
<Expand size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.poi.widen', { distance: formatDistance(km(wider), unit) })}
</button>
);
}
/** A warning of the search, with the way to send it again where that may help. */
function WarningNotice({ text, search }: { text: string; search?: TourPoiSearch }) {
return (
@@ -68,8 +88,8 @@ function WarningNotice({ text, search }: { text: string; search?: TourPoiSearch
* could not be searched, partly searched, cut, or empty. That the answer is
* out of date the search button says, as it is what to press then. Nothing
* found is said only when every kind was searched along the whole line, with
* the next wider corridor one press away; a failure or a gap carries its own
* retry.
* the next wider corridor one press away, as it is when no e-bike charger
* was found among other places; a failure or a gap carries its own retry.
*/
export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
@@ -85,9 +105,14 @@ export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: U
{ text: answer.capped && t('tours.poi.capped'), retry: false },
{ text: answer.truncated && t('tours.poi.truncated'), retry: false },
].filter((note): note is { text: string; retry: boolean } => !!note.text);
const wider = search.widerCorridorM;
// A gap wants a retry, not a wider corridor, and changed choices were not searched yet.
const empty = answer.pois.length === 0 && answer.unavailable.length === 0 && !answer.partial && !search.outdated;
const searchedAll = answer.unavailable.length === 0 && !answer.partial && !search.outdated;
const empty = answer.pois.length === 0 && searchedAll;
// BIK-07: few chargers are tagged for bikes, so none found is said, not left to look like a fault.
const noChargers =
searchedAll &&
search.categories.includes('ebike_charging') &&
!answer.pois.some((poi) => poi.category === 'ebike_charging');
return (
<>
{notes.map((note) => (
@@ -98,19 +123,16 @@ export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: U
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
</p>
{wider !== null && (
<button
type="button"
onClick={search.widen}
disabled={!search.canSearch}
data-testid="tour-poi-widen"
className="inline-flex items-center gap-1 rounded-full border border-edge bg-surface-card px-2 py-0.5 font-medium text-content hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
style={fs(11.5, 'body')}
>
<Expand size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.poi.widen', { distance: formatDistance(km(wider), unit) })}
</button>
)}
<WidenButton search={search} unit={unit} />
</div>
)}
{noChargers && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5" data-testid="tour-poi-charging-few">
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.charging.fewMapped')}
</p>
{/* Nothing at all found offers the wider corridor above already. */}
{!empty && <WidenButton search={search} unit={unit} />}
</div>
)}
</>
@@ -14,7 +14,7 @@ import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
// FE-TOUR-POI-001 to FE-TOUR-POI-027
// FE-TOUR-POI-001 to FE-TOUR-POI-030
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -668,3 +668,105 @@ describe('a search that could not ask, and its retry (PLN-05)', () => {
expect(screen.queryByTestId('tour-poi-widen')).not.toBeInTheDocument();
});
});
describe('e-bike charging along a bike tour (BIK-07)', () => {
const CHARGER: TourPoi = {
...SPRING,
id: 'node:5',
name: '',
category: 'ebike_charging',
kind: 'amenity=charging_station&bicycle=designated',
website: null,
opening_hours: null,
charging: {
bicycle: 'designated',
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: null,
fee: false,
access: null,
},
};
const bikePlanner = (over: Partial<PoiInsertionPlanner> = {}) =>
({ ...searchPlanner({ tourType: 'bike' }), ...insertionPlanner(over) }) as unknown as TourPlannerController;
it('FE-TOUR-POI-028: offered to a bike tour beside its first three, its card shows what the point states and adds it as a stop', async () => {
const { result } = renderHook(() => useTourPoiSearch(searchPlanner({ tourType: 'bike' })));
expect(result.current.offered).toContain('ebike_charging');
expect(result.current.categories).toEqual(['drinking_water', 'bike_repair', 'food']);
act(() => result.current.toggleCategory('ebike_charging'));
await act(() => result.current.search());
expect(search.mock.calls[0]?.[1]).toMatchObject({ categories: expect.arrayContaining(['ebike_charging']) });
useTourPoiStore.getState().clear();
search.mockResolvedValue({ ...ANSWER, pois: [CHARGER] });
const planner = bikePlanner();
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const row = await screen.findByTestId('tour-poi-node:5');
// Mapped without a name, it is named by its kind.
fireEvent.click(within(row).getByRole('button', { name: /E-bike charging/ }));
const facts = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-charging');
expect(facts).toHaveTextContent('Made for e-bikes');
expect(facts).toHaveTextContent('Schuko ×2');
expect(facts).toHaveTextContent('Free');
// Nothing said about access or room is shown as nothing, not as a guess.
expect(facts).not.toHaveTextContent('Open to all');
expect(facts).not.toHaveTextContent('Charging spots');
expect(within(facts).queryByTestId('tour-poi-charging-no-socket')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-poi-add'));
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
const updater = vi.mocked(planner.editWaypoints).mock.calls[0]?.[0] as (
points: TourPlannerWaypoint[]
) => TourPlannerWaypoint[];
const added = normalizePlannerWaypoints(updater(POINTS)).find((point) => point.lat === CHARGER.lat);
expect(added).toMatchObject({ lng: CHARGER.lng, name: 'E-bike charging' });
});
it('FE-TOUR-POI-029: a point that only lets bikes charge says so, and an unstated plug is dimmed, never named', async () => {
const plain: TourPoi = {
...CHARGER,
charging: { bicycle: 'yes', sockets: [], capacity: 3, fee: null, access: 'customers' },
};
search.mockResolvedValue({ ...ANSWER, pois: [plain] });
renderAlong(bikePlanner());
fireEvent.click(screen.getByTestId('tour-poi-search'));
fireEvent.click(within(await screen.findByTestId('tour-poi-node:5')).getByRole('button'));
const facts = screen.getByTestId('tour-poi-charging');
expect(facts).toHaveTextContent('E-bikes may charge');
expect(facts).not.toHaveTextContent('Made for e-bikes');
expect(within(facts).getByTestId('tour-poi-charging-no-socket')).toHaveClass('opacity-60');
expect(facts).toHaveTextContent('Plug not stated');
expect(facts).toHaveTextContent('Customers only');
expect(facts).toHaveTextContent('Charging spots3');
expect(facts).not.toHaveTextContent('Free');
expect(facts).not.toHaveTextContent('Schuko');
});
it('FE-TOUR-POI-030: chargers asked for and none found says few are mapped, with the wider corridor one press away', async () => {
render(<TourPoiSearchCard planner={searchPlanner({ tourType: 'bike' })} />);
fireEvent.click(screen.getByTestId('tour-poi-categories'));
const list = screen.getByRole('group', { name: 'What to look for' });
fireEvent.click(within(list).getByRole('button', { name: 'E-bike charging' }));
fireEvent.click(screen.getByTestId('tour-poi-search'));
// Other places found, no charger: the note and the wider corridor, not nothing found.
const note = await screen.findByTestId('tour-poi-charging-few');
expect(note).toHaveTextContent('Few charging points are mapped for e-bikes yet.');
expect(within(note).getByTestId('tour-poi-widen')).toHaveTextContent('Widen to');
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
// Nothing at all: nothing found with its own wider corridor, the note beside it without a second one.
search.mockResolvedValue({ ...ANSWER, pois: [] });
fireEvent.click(screen.getByTestId('tour-poi-widen'));
expect(await screen.findByText(/Nothing found within/)).toBeInTheDocument();
expect(screen.getByTestId('tour-poi-charging-few')).toBeInTheDocument();
expect(screen.getAllByTestId('tour-poi-widen')).toHaveLength(1);
// Chargers that could not be searched are named as such, never as few mapped.
search.mockResolvedValue({ ...ANSWER, pois: [], unavailable: ['ebike_charging'] });
fireEvent.click(screen.getByTestId('tour-poi-widen'));
expect(await screen.findByText(/Could not be searched right now: E-bike charging/)).toBeInTheDocument();
expect(screen.queryByTestId('tour-poi-charging-few')).not.toBeInTheDocument();
});
});
+2 -6
View File
@@ -3,6 +3,7 @@
* the stretches Overpass is asked in, and the query that asks for every kind
* near one stretch at once. Pure: PoisAlongResolver makes the calls.
*/
import { overpassSelector } from './osm-filter.helpers';
import { lineMetres, pointAtMeters, simplifyLine, sliceAtMeters, type LatLng } from '@trek/shared/roadtrip';
/**
@@ -147,12 +148,7 @@ const coordinate = (value: number) => value.toFixed(5);
export function overpassAlongQuery(filters: readonly string[], path: AroundPath, cap: number): string {
const { south, west, north, east } = path.bbox;
const box = `(${[south, west, north, east].map(coordinate).join(',')})`;
const selectors = filters
.map((filter) => {
const [key, value] = filter.split('=');
return ` nwr["${key}"="${value}"]${box};`;
})
.join('\n');
const selectors = filters.map((filter) => ` ${overpassSelector(filter, box)};`).join('\n');
const line = path.points.map((point) => `${coordinate(point.lat)},${coordinate(point.lng)}`).join(',');
return `[out:json][timeout:${OVERPASS_ALONG_QUERY_TIMEOUT_S}];\n(\n${selectors}\n)->.cand;\nnwr.cand(around:${path.radiusM},${line});\nout center tags ${cap + 25};`;
}
+55 -10
View File
@@ -254,8 +254,8 @@ export interface OverpassPoi {
/** Brand name and its Wikidata id, when OSM carries them — the logo is looked up from the id. */
brand: string | null;
brand_wikidata: string | null;
/** What a charging station offers, when it is one and OSM says. */
charging: ChargingInfo | null;
/** What a charging station offers, when it is one and OSM says; for an e-bike one, who may charge too. */
charging: ChargingInfo | BikeChargingInfo | null;
/**
* Which index the row came from. Overture is not OpenStreetMap: it carries
* OSM among other sources under other licences, so a row from the TREK index
@@ -315,9 +315,9 @@ function leadingNumber(raw: string | undefined): number | null {
return Number.isFinite(n) && n > 0 ? n : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
function readSockets(tags: Record<string, string>, families: readonly string[]): ChargingInfo['sockets'] {
const sockets: ChargingInfo['sockets'] = [];
for (const family of SOCKET_FAMILIES) {
for (const family of families) {
const raw = tags[`socket:${family}`];
if (raw === undefined) continue;
const count = Number.parseInt(raw, 10);
@@ -327,18 +327,60 @@ export function readChargingInfo(tags: Record<string, string>): ChargingInfo | n
kw: leadingNumber(tags[`socket:${family}:output`]),
});
}
return sockets;
}
function readCapacity(tags: Record<string, string>): number | null {
const capacity = Number.parseInt(tags.capacity ?? '', 10);
const fee = tags.fee === 'yes' ? true : tags.fee === 'no' ? false : null;
const info: ChargingInfo = {
sockets,
capacity: Number.isInteger(capacity) && capacity > 0 ? capacity : null,
fee,
};
return Number.isInteger(capacity) && capacity > 0 ? capacity : null;
}
function readFee(tags: Record<string, string>): boolean | null {
return tags.fee === 'yes' ? true : tags.fee === 'no' ? false : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
const sockets = readSockets(tags, SOCKET_FAMILIES);
const fee = readFee(tags);
const info: ChargingInfo = { sockets, capacity: readCapacity(tags), fee };
// Nothing said is null rather than an empty shell, so the client can tell "no data"
// from "no sockets" without inspecting three fields.
return sockets.length || info.capacity !== null || fee !== null ? info : null;
}
/** What an e-bike charging point states beyond a car charger's facts: that bicycles may charge, and who may use it. */
interface BikeChargingInfo extends ChargingInfo {
/** `designated` is made for bikes, `yes` lets them charge; the selector asks for one of the two. */
bicycle: 'yes' | 'designated';
/** OSM's own access value, `customers` or `private` style; null when not stated. */
access: string | null;
}
/**
* The sockets an e-bike charger is mapped with: household plugs for the bike's own
* charger (Schuko, the French type E, the British, the Swiss T13/T23, the blue CEE
* camping plug) and Type 2. OSM documents no key for a maker's bike connector that
* mappers use consistently, so none is read.
*/
const BIKE_SOCKET_FAMILIES = ['schuko', 'typee', 'bs1363', 'sev1011_t13', 'sev1011_t23', 'cee_blue', 'type2'] as const;
/**
* What an e-bike charging point states, or null when it is not tagged for bicycles:
* the search asks for `bicycle=yes|designated`, and a point read here without either
* is never passed off as one. Sockets, capacity and fee are read as on a car charger.
*/
export function readBikeChargingInfo(tags: Record<string, string>): BikeChargingInfo | null {
const bicycle = tags.bicycle;
if (bicycle !== 'yes' && bicycle !== 'designated') return null;
return {
sockets: readSockets(tags, BIKE_SOCKET_FAMILIES),
capacity: readCapacity(tags),
fee: readFee(tags),
bicycle,
access: tags.access?.trim() || null,
};
}
// Each pill category → the OSM tag selectors it searches. Keys here are the
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
// client-side).
@@ -378,6 +420,9 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
slipway: ['leisure=slipway', 'canoe=put_in'],
boat_rental: ['amenity=boat_rental'],
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
// BIK-07: charging points that say they are for bicycles. A selector with `&` asks
// for every condition at once (osm-filter.helpers), so a car charger never comes back.
ebike_charging: ['amenity=charging_station&bicycle=designated', 'amenity=charging_station&bicycle=yes'],
// A city walk's market (CW-05): the square itself, not the shops of a mall as `shopping` has them.
market: ['amenity=marketplace'],
// A city walk's museums and galleries (CW-05), without the theatres and public art of `museum`.
+1 -1
View File
@@ -130,7 +130,7 @@ export class MapsMcp {
@Tool({
name: 'search_pois',
description:
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. ebike_charging finds only charging points tagged for bicycles, and reports beside the sockets whether they are made for bikes or let them charge (bicycle) and who may use them (access). This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
inputSchema: {
category: z
.union([z.enum(POI_CATEGORY_KEYS), z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES)])
@@ -0,0 +1,27 @@
/**
* The OSM selectors of CATEGORY_OSM_FILTERS: `key=value`, or several of them
* joined by `&` when a place has to carry every one (BIK-07: a charging station
* that also says it is for bicycles). Pure, so the explore pill, the search
* along a line and the reading of their answers build and match one grammar.
*/
/** The `key=value` conditions of one selector, in the order written. */
function conditionsOf(filter: string): Array<[string, string]> {
return filter.split('&').map((condition) => {
const [key, value] = condition.split('=');
return [key, value];
});
}
/** The Overpass statement for one selector inside a box, `nwr["k"="v"](box)`; no semicolon. */
export function overpassSelector(filter: string, box: string): string {
const conditions = conditionsOf(filter)
.map(([key, value]) => `["${key}"="${value}"]`)
.join('');
return `nwr${conditions}${box}`;
}
/** Whether a place's tags meet every condition of one selector. */
export function tagsMatchFilter(tags: Record<string, string>, filter: string): boolean {
return conditionsOf(filter).every(([key, value]) => tags[key] === value);
}
+22 -15
View File
@@ -34,10 +34,12 @@ import {
haversineMetres,
namesOverlap,
readChargingInfo,
readBikeChargingInfo,
buildOsmDetails,
OSM_PLACE_ID,
type OverpassPoi,
} from '../maps.helpers';
import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers';
import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers';
import { readWikiIdentity } from './wiki-identity';
import { Injectable } from '@nestjs/common';
@@ -259,8 +261,22 @@ const KEPT_UNNAMED_ALONG: ReadonlySet<string> = new Set([
'peak',
'slipway',
'bike_repair',
// A charging point is often mapped with neither a name nor an operator.
'ebike_charging',
]);
/**
* What a charging station states, read only where it means something. Every POI
* carries `capacity` and `fee` for its own reasons (a restaurant's capacity is
* seats), so reading them as charging data anywhere else would be wrong on most
* of the map.
*/
function chargingOf(category: string, tags: Record<string, string>): OverpassPoi['charging'] {
if (category === 'charging') return readChargingInfo(tags);
if (category === 'ebike_charging') return readBikeChargingInfo(tags);
return null;
}
/**
* Overpass elements as POI rows, each labelled with the category its selector
* belongs to. An unnamed element is dropped unless its category is one of
@@ -276,11 +292,7 @@ function readPois(
const pois: OverpassPoi[] = [];
for (const el of elements) {
const tags = el.tags || {};
const matched =
filters.find((f) => {
const [k, v] = f.split('=');
return tags[k] === v;
}) || filters[0];
const matched = filters.find((f) => tagsMatchFilter(tags, f)) || filters[0];
const category = categoryOfFilter.get(matched) ?? categories[0];
// `operator` comes last but matters for the road categories: petrol stations,
// charging points and service areas are routinely mapped with an operator and no
@@ -294,6 +306,9 @@ function readPois(
// statement in the hours field.
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
const charging = chargingOf(category, tags);
// A charger that does not say bicycles may charge is never passed off as an e-bike one.
if (category === 'ebike_charging' && !charging) continue;
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
@@ -317,10 +332,7 @@ function readPois(
// Only the plain Q-id form is passed on; anything else would be a lookup we
// would have to guess at.
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
// Only where it means something. Every POI carries `capacity` and `fee` for its
// own reasons (a restaurant's capacity is seats), so reading them as charging
// data anywhere else would be wrong on most of the map.
charging: category === 'charging' ? readChargingInfo(tags) : null,
charging,
source: 'openstreetmap',
});
}
@@ -762,12 +774,7 @@ export class OsmClient {
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${south},${west},${north},${east})`;
const selectors = filters
.map((f) => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
})
.join('\n');
const selectors = filters.map((f) => ` ${overpassSelector(f, box)};`).join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
@@ -39,6 +39,8 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
cafe: ['cafe'],
restaurant: ['restaurant'],
market: ['market'],
// BIK-07: only points tagged for bicycles; the explore pill's `charging` is for cars.
ebike_charging: ['ebike_charging'],
};
/** The most places of one kind the map search answers from each source. */
@@ -187,6 +189,14 @@ function answerRows(places: readonly TourPoi[], ranks: ReadonlyMap<string, numbe
return [...highlights, ...rest];
}
/** What an e-bike charging point states, as the answer carries it; nothing for any other place. */
function bikeChargingOf(poi: MapsPoi): Pick<TourPoi, 'charging'> {
const charging = poi.charging;
if (!charging || !('bicycle' in charging)) return {};
const { bicycle, sockets, capacity, fee, access } = charging;
return { charging: { bicycle, sockets, capacity, fee, access } };
}
function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined {
return asked.find((category) => MAPS_CATEGORIES[category].includes(mapsCategory));
}
@@ -221,6 +231,7 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
opening_hours: poi.opening_hours,
distance_m: Math.round(near.distanceM),
along_m: Math.round(near.alongM),
...bikeChargingOf(poi),
};
kept.set(poi.osm_id, place);
if (name) byName.set(name, [...namesakes, place]);
+1 -1
View File
@@ -239,7 +239,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
'Search places along the saved line of a tour: what a hike, a run, a ride, a bike tour, a paddle tour, a ski and winter tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, for a ski and winter tour cafe and restaurant as well, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), 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); up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none; retry_after_s, when present, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
'Search places along the saved line of a tour: what a hike, a run, a ride, a bike tour, a paddle tour, a ski and winter tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, ebike_charging for the charging points of a bike tour, for a ski and winter tour cafe and restaurant as well, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), 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); up to 60, the nearest to the line when more match, listed from the start. An ebike_charging place is one OpenStreetMap tags for bicycles (bicycle=designated or yes; a charger for cars alone never comes back) and carries charging: bicycle, sockets (type, count, kw), capacity, fee and access as OpenStreetMap states them, null or empty where it says nothing; whether it works or is free today is not known. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none; retry_after_s, when present, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. 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'),
@@ -769,6 +769,56 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
});
});
it('finds e-bike charging along a bike tour with what the point states, and only there (BIK-07)', async () => {
const charging = {
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: null,
fee: false,
bicycle: 'designated',
access: 'customers',
};
const charger = {
...spring,
osm_id: 'node:8',
name: '',
category: 'ebike_charging',
poi_type: 'amenity=charging_station&bicycle=designated',
charging,
};
mapsPoisAlong.mockResolvedValue({
pois: [charger, spring],
failed: [],
partial: false,
truncated: false,
capped: false,
});
const res = await request(server)
.post('/api/trips/5/tours/poi-search')
.set('Cookie', sessionCookie(VIEWER))
.send({ ...body, tour_type: 'bike', categories: ['ebike_charging', 'drinking_water'], corridor_m: 1000 });
expect(res.status).toBe(200);
expect(mapsPoisAlong.mock.calls.at(-1)?.[0]).toEqual(['ebike_charging', 'drinking_water']);
expect(res.body.pois).toEqual([
{
id: 'node:8',
name: '',
lat: 47.01,
lng: 11.001,
category: 'ebike_charging',
kind: 'amenity=charging_station&bicycle=designated',
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: expect.any(Number),
along_m: expect.any(Number),
charging,
},
expect.not.objectContaining({ charging: expect.anything() }),
]);
});
it('404 for someone outside the trip, 400 for a body it does not know, 401 without a session', async () => {
const outsider = await request(server)
.post('/api/trips/5/tours/poi-search')
@@ -123,6 +123,13 @@ describe('aroundPath and overpassAlongQuery', () => {
expect(query).toContain(`nwr.cand(around:550,47.00000,11.00000,${at(0, 1000).lat.toFixed(5)},11.00000);`);
expect(query.endsWith('out center tags 85;')).toBe(true);
});
it('MAPS-ALONG-006b: a compound selector asks the box for places that carry every one of its tags (BIK-07)', () => {
const path = aroundPath([at(0, 0), at(0, 1000)], 500);
const query = overpassAlongQuery(['amenity=charging_station&bicycle=designated'], path, 30);
expect(query).toContain(' nwr["amenity"="charging_station"]["bicycle"="designated"](');
expect(query).not.toContain('nwr["amenity"="charging_station"](');
});
});
describe('overpassStretches', () => {
+61 -1
View File
@@ -1,4 +1,5 @@
import { readChargingInfo } from '../../../src/nest/maps/maps.helpers';
import { readBikeChargingInfo, readChargingInfo } from '../../../src/nest/maps/maps.helpers';
import { overpassSelector, tagsMatchFilter } from '../../../src/nest/maps/osm-filter.helpers';
import { describe, it, expect } from 'vitest';
@@ -63,3 +64,62 @@ describe('readChargingInfo', () => {
expect(readChargingInfo({ capacity: '-2' })).toBeNull();
});
});
describe('readBikeChargingInfo (BIK-07)', () => {
it('MAPS-CHARGE-007: reads what an e-bike point states: bicycles, household sockets, access, fee, capacity', () => {
expect(
readBikeChargingInfo({
amenity: 'charging_station',
bicycle: 'designated',
'socket:schuko': '4',
'socket:typee': 'yes',
'socket:schuko:output': '3.7 kW',
capacity: '4',
fee: 'no',
access: 'customers',
}),
).toEqual({
sockets: [
{ type: 'schuko', count: 4, kw: 3.7 },
{ type: 'typee', count: null, kw: null },
],
capacity: 4,
fee: false,
bicycle: 'designated',
access: 'customers',
});
});
it('MAPS-CHARGE-008: a point that says nothing beyond bicycle=yes is still one, with nothing claimed', () => {
expect(readBikeChargingInfo({ amenity: 'charging_station', bicycle: 'yes' })).toEqual({
sockets: [],
capacity: null,
fee: null,
bicycle: 'yes',
access: null,
});
});
it('MAPS-CHARGE-009: a charger not tagged for bicycles is no e-bike point, and car plugs are not read as bike sockets', () => {
expect(readBikeChargingInfo({ amenity: 'charging_station', 'socket:type2': '2' })).toBeNull();
expect(readBikeChargingInfo({ amenity: 'charging_station', bicycle: 'no' })).toBeNull();
expect(readBikeChargingInfo({ bicycle: 'yes', 'socket:ccs': '2', 'socket:chademo': '1' })?.sockets).toEqual([]);
});
});
describe('OSM selectors (BIK-07)', () => {
it('MAPS-CHARGE-010: a plain selector is built as it always was, a compound one asks for every condition', () => {
expect(overpassSelector('amenity=drinking_water', '(1,2,3,4)')).toBe('nwr["amenity"="drinking_water"](1,2,3,4)');
expect(overpassSelector('amenity=charging_station&bicycle=yes', '(1,2,3,4)')).toBe(
'nwr["amenity"="charging_station"]["bicycle"="yes"](1,2,3,4)',
);
});
it('MAPS-CHARGE-011: tags match a compound selector only when they meet every condition', () => {
const filter = 'amenity=charging_station&bicycle=designated';
expect(tagsMatchFilter({ amenity: 'charging_station', bicycle: 'designated' }, filter)).toBe(true);
expect(tagsMatchFilter({ amenity: 'charging_station' }, filter)).toBe(false);
expect(tagsMatchFilter({ amenity: 'charging_station', bicycle: 'yes' }, filter)).toBe(false);
expect(tagsMatchFilter({ amenity: 'cafe' }, 'amenity=cafe')).toBe(true);
});
});
+43 -1
View File
@@ -1,5 +1,5 @@
/**
* MAPS-ALONG-020..029, 032..034: the Overpass query near a line (PLN-02). One
* MAPS-ALONG-020..029, 032..034, 039: the Overpass query near a line (PLN-02). One
* query for every kind the index does not know, the candidates of the line's
* box kept within reach of the line itself, and unnamed taps and huts kept,
* because they are what a hike looks for, while an unnamed garden or cafe is
@@ -415,6 +415,48 @@ describe('OsmClient.searchOverpassPoisAlong', () => {
]);
});
it('MAPS-ALONG-039: e-bike charging asks for bicycle tags, keeps an unnamed point with its facts, never a car charger', async () => {
const fetchMock = vi.fn().mockResolvedValue(
answer([
{
type: 'node',
id: 1,
lat: 47.475,
lon: 11.1,
tags: { amenity: 'charging_station', bicycle: 'designated', 'socket:schuko': '2', fee: 'no' },
},
{ type: 'node', id: 2, lat: 47.476, lon: 11.1, tags: { amenity: 'charging_station', name: 'Ionity' } },
{
type: 'node',
id: 3,
lat: 47.477,
lon: 11.1,
tags: { amenity: 'charging_station', bicycle: 'yes', operator: 'Stadtwerke', access: 'customers' },
},
]),
);
vi.stubGlobal('fetch', fetchMock);
const out = await osm.searchOverpassPoisAlong('ebike_charging', line(7), 1000);
const query = queryOf(fetchMock.mock.calls[0]);
expect(query).toContain('nwr["amenity"="charging_station"]["bicycle"="designated"](');
expect(query).toContain('nwr["amenity"="charging_station"]["bicycle"="yes"](');
expect(query).not.toContain('nwr["amenity"="charging_station"](');
expect(out.pois.map((poi) => [poi.osm_id, poi.name, poi.category, poi.poi_type])).toEqual([
['node:1', '', 'ebike_charging', 'amenity=charging_station&bicycle=designated'],
['node:3', 'Stadtwerke', 'ebike_charging', 'amenity=charging_station&bicycle=yes'],
]);
expect(out.pois[0]?.charging).toEqual({
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: null,
fee: false,
bicycle: 'designated',
access: null,
});
expect(out.pois[1]?.charging).toMatchObject({ bicycle: 'yes', access: 'customers', sockets: [] });
});
it('MAPS-ALONG-024: more places than the budget are cut to it and said to be', async () => {
const many = Array.from({ length: 40 }, (_, i) => ({
type: 'node',
@@ -215,6 +215,45 @@ describe('TourPoiSearchService', () => {
expect(plain).not.toHaveProperty('retry_after_s');
});
it('TOUR-POI-018: e-bike charging asks the map search for its own kind and carries what the point states', async () => {
const charging = {
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: 2,
fee: false,
bicycle: 'designated',
access: null,
};
poisAlong.mockResolvedValue(
answer([
poi('node:1', 47.41, 11.1003, 'ebike_charging', {
name: '',
poi_type: 'amenity=charging_station&bicycle=designated',
charging,
}),
// A car charger's facts carry no bicycle tag and are never passed on as an e-bike's.
poi('node:2', 47.412, 11.1003, 'ebike_charging', {
charging: { sockets: [{ type: 'type2', count: 2, kw: 22 }], capacity: null, fee: null },
}),
poi('node:3', 47.414, 11.1003, 'drinking_water', { name: '' }),
]),
);
const result = await service.search({
tour_type: 'bike',
categories: ['ebike_charging', 'drinking_water'],
corridor_m: 1000,
line: LINE,
});
expect(poisAlong.mock.calls[0]?.[0]).toEqual(['ebike_charging', 'drinking_water']);
expect(result.pois.map((found) => [found.id, found.category, found.kind])).toEqual([
['node:1', 'ebike_charging', 'amenity=charging_station&bicycle=designated'],
['node:2', 'ebike_charging', 'tourism=ebike_charging'],
['node:3', 'drinking_water', 'tourism=drinking_water'],
]);
expect(result.pois[0]?.charging).toEqual(charging);
expect(result.pois[1]).not.toHaveProperty('charging');
expect(result.pois[2]).not.toHaveProperty('charging');
});
it('TOUR-POI-008: an unnamed place is kept and never merged with another unnamed one nearby', async () => {
poisAlong.mockResolvedValue(
answer([
+3
View File
@@ -407,6 +407,9 @@ describe('ToursMcp', () => {
expect(description(ToursMcp, 'searchTourPois')).toMatch(/best known places.*highlight: true/);
expect(description(ToursMcp, 'searchTourPois')).toContain('a ride, a bike tour');
expect(description(ToursMcp, 'searchTourPois')).toContain('a ski and winter tour or a city walk');
// BIK-07: e-bike charging only where OpenStreetMap tags it for bicycles, with what it states.
expect(description(ToursMcp, 'searchTourPois')).toMatch(/ebike_charging.*bicycle=designated or yes/);
expect(description(ToursMcp, 'searchTourPois')).toContain('charging: bicycle, sockets');
expect(description(TourLibraryMcp, 'takeOverTour')).toContain('protection');
for (const method of ['deletePlace', 'bulkDeletePlaces']) {
expect(description(PlacesMcp, method)).toContain(
+15
View File
@@ -413,6 +413,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'نقاط إنزال القوارب',
'tours.poi.cat.boat_rental': 'تأجير القوارب',
'tours.poi.cat.bike_repair': 'إصلاح الدراجات',
'tours.poi.cat.ebike_charging': 'شحن الدراجات الكهربائية',
'tours.planner.tourTypeHint.hike': 'سيرًا على الأقدام، ضمن صعوبة تحددها',
'tours.planner.tourTypeHint.bike': 'بالدراجة، مسار مناسب للتجوال أو الطريق أو الحصى أو الجبلية',
'tours.planner.tourTypeHint.kayak': 'على الماء، بقارب الكاياك أو الكانو أو لوح التجديف',
@@ -812,6 +813,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'أبرز المعالم',
'tours.poi.highlightHint': 'من أشهر الأماكن هنا',
'tours.poi.charging.label': 'الشحن',
'tours.poi.charging.designated': 'مخصّص للدراجات الكهربائية',
'tours.poi.charging.allowed': 'يُسمح بشحن الدراجات الكهربائية',
'tours.poi.charging.free': 'مجاني',
'tours.poi.charging.fee': 'برسوم',
'tours.poi.charging.access.public': 'مفتوح للجميع',
'tours.poi.charging.access.customers': 'للزبائن فقط',
'tours.poi.charging.access.private': 'خاص',
'tours.poi.charging.capacity': 'أماكن الشحن',
'tours.poi.charging.socketsUnknown': 'المقبس غير مذكور',
'tours.poi.charging.socketsUnknownHint': 'لا يذكر OpenStreetMap نوع المقبس، لذا لا يُعرف إن كان شاحنك مناسبًا.',
'tours.poi.charging.ownCharger': 'مقبس منزلي كما يذكره OpenStreetMap: أحضر شاحنك الخاص.',
'tours.poi.charging.sourceHint': 'كما يذكره OpenStreetMap. لا يُعرف إن كان يعمل أو مجانيًا اليوم.',
'tours.poi.charging.fewMapped': 'نقاط شحن الدراجات الكهربائية المرسومة على الخريطة قليلة حتى الآن. تظهر فقط النقاط التي يحدّدها OpenStreetMap للدراجات، وليس أبدًا شاحنًا للسيارات وحدها.',
'tours.planner.tourType': 'نوع الجولة',
'tours.paddle.boat': 'القارب',
'tours.paddle.boat.kayak': 'كاياك',
+15
View File
@@ -402,6 +402,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Elektrik velosiped şarjı',
'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ə',
@@ -817,6 +818,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Diqqətəlayiq',
'tours.poi.highlightHint': 'Buradakı ən tanınmış yerlərdən biri',
'tours.poi.charging.label': 'Şarj',
'tours.poi.charging.designated': 'Elektrik velosipedlər üçün',
'tours.poi.charging.allowed': 'Elektrik velosipedlər şarj edə bilər',
'tours.poi.charging.free': 'Pulsuz',
'tours.poi.charging.fee': 'Ödənişli',
'tours.poi.charging.access.public': 'Hamı üçün açıq',
'tours.poi.charging.access.customers': 'Yalnız müştərilər',
'tours.poi.charging.access.private': 'Şəxsi',
'tours.poi.charging.capacity': 'Şarj yerləri',
'tours.poi.charging.socketsUnknown': 'Rozetka göstərilməyib',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap hansı rozetka olduğunu demir, ona görə şarj cihazının uyğun gəlib-gəlmədiyi bilinmir.',
'tours.poi.charging.ownCharger': 'OpenStreetMap-ə görə adi rozetka: öz şarj cihazını götür.',
'tours.poi.charging.sourceHint': 'OpenStreetMap-ə görə. Bu gün işləyib-işləmədiyi və ya pulsuz olub-olmadığı bilinmir.',
'tours.poi.charging.fewMapped': 'Elektrik velosipedlər üçün hələ az şarj nöqtəsi xəritələnib. Yalnız OpenStreetMap-in velosipedlər üçün işarələdikləri göstərilir, heç vaxt yalnız avtomobillər üçün olan şarj nöqtəsi yox.',
'tours.planner.tourType': 'Tur növü',
'tours.paddle.boat': 'Qayıq',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+15
View File
@@ -409,6 +409,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Recarga de e-bike',
'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',
@@ -824,6 +825,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Destaque',
'tours.poi.highlightHint': 'Um dos lugares mais conhecidos daqui',
'tours.poi.charging.label': 'Recarga',
'tours.poi.charging.designated': 'Feito para e-bikes',
'tours.poi.charging.allowed': 'E-bikes podem carregar',
'tours.poi.charging.free': 'Grátis',
'tours.poi.charging.fee': 'Pago',
'tours.poi.charging.access.public': 'Aberto a todos',
'tours.poi.charging.access.customers': 'Só clientes',
'tours.poi.charging.access.private': 'Privado',
'tours.poi.charging.capacity': 'Vagas de recarga',
'tours.poi.charging.socketsUnknown': 'Tomada não informada',
'tours.poi.charging.socketsUnknownHint': 'O OpenStreetMap não diz qual tomada tem, então não se sabe se o seu carregador serve.',
'tours.poi.charging.ownCharger': 'Uma tomada comum, segundo o OpenStreetMap: leve seu próprio carregador.',
'tours.poi.charging.sourceHint': 'Segundo o OpenStreetMap. Não se sabe se hoje funciona ou é grátis.',
'tours.poi.charging.fewMapped': 'Ainda há poucos pontos de recarga mapeados para e-bikes. Só aparecem os que o OpenStreetMap marca para bicicletas, nunca um carregador só para carros.',
'tours.planner.tourType': 'Tipo de passeio',
'tours.paddle.boat': 'Embarcação',
'tours.paddle.boat.kayak': 'Caiaque',
+15
View File
@@ -410,6 +410,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': "Punts d'embarcament",
'tours.poi.cat.boat_rental': 'Lloguer de barques',
'tours.poi.cat.bike_repair': 'Reparació de bicicletes',
'tours.poi.cat.ebike_charging': 'Càrrega de bicis elèctriques',
'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",
@@ -828,6 +829,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Imprescindible',
'tours.poi.highlightHint': "Un dels llocs més coneguts d'aquí",
'tours.poi.charging.label': 'Càrrega',
'tours.poi.charging.designated': 'Pensat per a bicis elèctriques',
'tours.poi.charging.allowed': 'Les bicis elèctriques hi poden carregar',
'tours.poi.charging.free': 'Gratuït',
'tours.poi.charging.fee': 'De pagament',
'tours.poi.charging.access.public': 'Obert a tothom',
'tours.poi.charging.access.customers': 'Només clients',
'tours.poi.charging.access.private': 'Privat',
'tours.poi.charging.capacity': 'Places de càrrega',
'tours.poi.charging.socketsUnknown': 'Endoll no indicat',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap no diu quin endoll té, així que no se sap si el teu carregador hi encaixa.',
'tours.poi.charging.ownCharger': 'Un endoll domèstic, segons OpenStreetMap: porta el teu carregador.',
'tours.poi.charging.sourceHint': 'Segons OpenStreetMap. No se sap si avui funciona o és gratuït.',
'tours.poi.charging.fewMapped': 'Encara hi ha pocs punts de càrrega cartografiats per a bicis elèctriques. Només es mostren els que OpenStreetMap marca per a bicicletes, mai un carregador només per a cotxes.',
'tours.planner.tourType': 'Tipus de ruta',
'tours.paddle.boat': 'Embarcació',
'tours.paddle.boat.kayak': 'Caiac',
+15
View File
@@ -401,6 +401,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Nabíjení elektrokol',
'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',
@@ -809,6 +810,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Top místo',
'tours.poi.highlightHint': 'Jedno z nejznámějších míst v okolí',
'tours.poi.charging.label': 'Nabíjení',
'tours.poi.charging.designated': 'Určeno pro elektrokola',
'tours.poi.charging.allowed': 'Elektrokola smí nabíjet',
'tours.poi.charging.free': 'Zdarma',
'tours.poi.charging.fee': 'Placené',
'tours.poi.charging.access.public': 'Otevřeno všem',
'tours.poi.charging.access.customers': 'Jen pro zákazníky',
'tours.poi.charging.access.private': 'Soukromé',
'tours.poi.charging.capacity': 'Nabíjecí místa',
'tours.poi.charging.socketsUnknown': 'Zásuvka neuvedena',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap neuvádí, jakou má zásuvku, takže není jisté, zda se hodí tvoje nabíječka.',
'tours.poi.charging.ownCharger': 'Běžná zásuvka podle OpenStreetMap: vezmi si vlastní nabíječku.',
'tours.poi.charging.sourceHint': 'Podle OpenStreetMap. Zda dnes funguje nebo je zdarma, není známo.',
'tours.poi.charging.fewMapped': 'Nabíjecích míst pro elektrokola je zatím zmapováno málo. Zobrazují se jen ta, která OpenStreetMap označuje pro kola, nikdy nabíječka jen pro auta.',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -412,6 +412,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Einsetzstellen',
'tours.poi.cat.boat_rental': 'Bootsverleih',
'tours.poi.cat.bike_repair': 'Radwerkstatt',
'tours.poi.cat.ebike_charging': 'E-Bike-Ladestationen',
'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',
@@ -832,6 +833,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Highlight', // same-as-en
'tours.poi.highlightHint': 'Einer der bekanntesten Orte hier',
'tours.poi.charging.label': 'Laden',
'tours.poi.charging.designated': 'Für E-Bikes ausgewiesen',
'tours.poi.charging.allowed': 'E-Bikes dürfen laden',
'tours.poi.charging.free': 'Kostenlos',
'tours.poi.charging.fee': 'Kostenpflichtig',
'tours.poi.charging.access.public': 'Für alle offen',
'tours.poi.charging.access.customers': 'Nur für Kunden',
'tours.poi.charging.access.private': 'Privat',
'tours.poi.charging.capacity': 'Ladeplätze',
'tours.poi.charging.socketsUnknown': 'Stecker nicht angegeben',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap nennt keinen Stecker, ob dein Ladegerät passt, ist also offen.',
'tours.poi.charging.ownCharger': 'Eine Haushaltssteckdose, so wie OpenStreetMap sie angibt: Ladegerät selbst mitbringen.',
'tours.poi.charging.sourceHint': 'So, wie OpenStreetMap es angibt. Ob sie heute funktioniert oder kostenlos ist, ist nicht bekannt.',
'tours.poi.charging.fewMapped': 'Für E-Bikes sind bisher wenige Ladestationen erfasst. Gezeigt werden nur die, die OpenStreetMap für Fahrräder kennzeichnet, nie eine reine Auto-Ladesäule.',
'tours.planner.tourType': 'Tourtyp',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -401,6 +401,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Put-ins',
'tours.poi.cat.boat_rental': 'Boat rental',
'tours.poi.cat.bike_repair': 'Bike repair',
'tours.poi.cat.ebike_charging': 'E-bike charging',
'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',
@@ -808,6 +809,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Highlight',
'tours.poi.highlightHint': 'One of the best-known places here',
'tours.poi.charging.label': 'Charging',
'tours.poi.charging.designated': 'Made for e-bikes',
'tours.poi.charging.allowed': 'E-bikes may charge',
'tours.poi.charging.free': 'Free',
'tours.poi.charging.fee': 'Costs a fee',
'tours.poi.charging.access.public': 'Open to all',
'tours.poi.charging.access.customers': 'Customers only',
'tours.poi.charging.access.private': 'Private',
'tours.poi.charging.capacity': 'Charging spots',
'tours.poi.charging.socketsUnknown': 'Plug not stated',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap does not say which plug it has, so whether your charger fits is not known.',
'tours.poi.charging.ownCharger': 'A household socket, as OpenStreetMap states it: bring your own charger.',
'tours.poi.charging.sourceHint': 'As OpenStreetMap states it. Whether it works or is free today is not known.',
'tours.poi.charging.fewMapped': 'Few charging points are mapped for e-bikes yet. Only those OpenStreetMap marks for bicycles are shown, never a charger for cars alone.',
'tours.planner.tourType': 'Tour type',
'tours.paddle.boat': 'Boat',
'tours.paddle.boat.kayak': 'Kayak',
+15
View File
@@ -409,6 +409,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Embarcaderos',
'tours.poi.cat.boat_rental': 'Alquiler de barcas',
'tours.poi.cat.bike_repair': 'Reparación de bicis',
'tours.poi.cat.ebike_charging': 'Carga de e-bikes',
'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',
@@ -826,6 +827,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Imprescindible',
'tours.poi.highlightHint': 'Uno de los lugares más conocidos de aquí',
'tours.poi.charging.label': 'Carga',
'tours.poi.charging.designated': 'Pensado para e-bikes',
'tours.poi.charging.allowed': 'Se pueden cargar e-bikes',
'tours.poi.charging.free': 'Gratis',
'tours.poi.charging.fee': 'De pago',
'tours.poi.charging.access.public': 'Abierto a todos',
'tours.poi.charging.access.customers': 'Solo clientes',
'tours.poi.charging.access.private': 'Privado',
'tours.poi.charging.capacity': 'Plazas de carga',
'tours.poi.charging.socketsUnknown': 'Enchufe no indicado',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap no dice qué enchufe tiene, así que no se sabe si tu cargador sirve.',
'tours.poi.charging.ownCharger': 'Un enchufe doméstico, según OpenStreetMap: lleva tu propio cargador.',
'tours.poi.charging.sourceHint': 'Según OpenStreetMap. No se sabe si hoy funciona o es gratis.',
'tours.poi.charging.fewMapped': 'Aún hay pocos puntos de carga cartografiados para e-bikes. Solo se muestran los que OpenStreetMap marca para bicicletas, nunca un cargador solo para coches.',
'tours.planner.tourType': 'Tipo de ruta',
'tours.paddle.boat': 'Embarcación',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+15
View File
@@ -403,6 +403,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Vettelaskmiskohad',
'tours.poi.cat.boat_rental': 'Paadilaenutus',
'tours.poi.cat.bike_repair': 'Rattaremont',
'tours.poi.cat.ebike_charging': 'Elektrirataste laadimine',
'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',
@@ -812,6 +813,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Tippkoht',
'tours.poi.highlightHint': 'Üks siinseid tuntuimaid paiku',
'tours.poi.charging.label': 'Laadimine',
'tours.poi.charging.designated': 'Mõeldud elektriratastele',
'tours.poi.charging.allowed': 'Elektrirattaid tohib laadida',
'tours.poi.charging.free': 'Tasuta',
'tours.poi.charging.fee': 'Tasuline',
'tours.poi.charging.access.public': 'Avatud kõigile',
'tours.poi.charging.access.customers': 'Ainult klientidele',
'tours.poi.charging.access.private': 'Privaatne',
'tours.poi.charging.capacity': 'Laadimiskohad',
'tours.poi.charging.socketsUnknown': 'Pistik märkimata',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap ei ütle, mis pistik seal on, seega pole teada, kas sinu laadija sobib.',
'tours.poi.charging.ownCharger': 'Tavaline pistikupesa OpenStreetMapi järgi: võta oma laadija kaasa.',
'tours.poi.charging.sourceHint': 'OpenStreetMapi järgi. Kas see täna töötab või on tasuta, pole teada.',
'tours.poi.charging.fewMapped': 'Elektriratastele on kaardistatud veel vähe laadimiskohti. Näidatakse ainult neid, mille OpenStreetMap ratastele märgib, mitte kunagi ainult autodele mõeldud laadijat.',
'tours.planner.tourType': 'Matka tüüp',
'tours.paddle.boat': 'Paat',
'tours.paddle.boat.kayak': 'Süst',
+15
View File
@@ -416,6 +416,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Recharge vélo électrique',
'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",
@@ -838,6 +839,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Incontournable',
'tours.poi.highlightHint': 'L’un des lieux les plus connus ici',
'tours.poi.charging.label': 'Recharge',
'tours.poi.charging.designated': 'Prévu pour les VAE',
'tours.poi.charging.allowed': 'Recharge des VAE autorisée',
'tours.poi.charging.free': 'Gratuit',
'tours.poi.charging.fee': 'Payant',
'tours.poi.charging.access.public': 'Ouvert à tous',
'tours.poi.charging.access.customers': 'Réservé aux clients',
'tours.poi.charging.access.private': 'Privé',
'tours.poi.charging.capacity': 'Places de recharge',
'tours.poi.charging.socketsUnknown': 'Prise non indiquée',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap n’indique pas la prise, on ne sait donc pas si ton chargeur convient.',
'tours.poi.charging.ownCharger': 'Une prise domestique, selon OpenStreetMap : apporte ton propre chargeur.',
'tours.poi.charging.sourceHint': 'Selon OpenStreetMap. On ne sait pas si elle fonctionne ou est gratuite aujourd’hui.',
'tours.poi.charging.fewMapped': 'Peu de bornes sont encore cartographiées pour les vélos électriques. Seules celles qu’OpenStreetMap signale pour les vélos sont affichées, jamais une borne réservée aux voitures.',
'tours.planner.tourType': 'Type de sortie',
'tours.paddle.boat': 'Embarcation',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+15
View File
@@ -418,6 +418,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Σημεία καθέλκυσης',
'tours.poi.cat.boat_rental': 'Ενοικίαση σκαφών',
'tours.poi.cat.bike_repair': 'Επισκευή ποδηλάτου',
'tours.poi.cat.ebike_charging': 'Φόρτιση ηλεκτρικών ποδηλάτων',
'tours.planner.tourTypeHint.hike': 'Με τα πόδια, μέσα στη δυσκολία που ορίζεις',
'tours.planner.tourTypeHint.bike': 'Με ποδήλατο, διαδρομή για περιήγηση, δρόμο, gravel ή MTB',
'tours.planner.tourTypeHint.kayak': 'Στο νερό, με καγιάκ, κανό ή SUP',
@@ -843,6 +844,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Κορυφαίο',
'tours.poi.highlightHint': 'Ένα από τα πιο γνωστά μέρη εδώ',
'tours.poi.charging.label': 'Φόρτιση',
'tours.poi.charging.designated': 'Για ηλεκτρικά ποδήλατα',
'tours.poi.charging.allowed': 'Επιτρέπεται η φόρτιση ηλεκτρικών ποδηλάτων',
'tours.poi.charging.free': 'Δωρεάν',
'tours.poi.charging.fee': 'Με χρέωση',
'tours.poi.charging.access.public': 'Ανοιχτό σε όλους',
'tours.poi.charging.access.customers': 'Μόνο για πελάτες',
'tours.poi.charging.access.private': 'Ιδιωτικό',
'tours.poi.charging.capacity': 'Θέσεις φόρτισης',
'tours.poi.charging.socketsUnknown': 'Δεν αναφέρεται πρίζα',
'tours.poi.charging.socketsUnknownHint': 'Το OpenStreetMap δεν λέει ποια πρίζα έχει, οπότε δεν είναι γνωστό αν ταιριάζει ο φορτιστής σου.',
'tours.poi.charging.ownCharger': 'Οικιακή πρίζα, κατά το OpenStreetMap: φέρε τον δικό σου φορτιστή.',
'tours.poi.charging.sourceHint': 'Κατά το OpenStreetMap. Αν λειτουργεί ή είναι δωρεάν σήμερα δεν είναι γνωστό.',
'tours.poi.charging.fewMapped': 'Λίγα σημεία φόρτισης για ηλεκτρικά ποδήλατα έχουν χαρτογραφηθεί ακόμη. Εμφανίζονται μόνο όσα το OpenStreetMap σημειώνει για ποδήλατα, ποτέ φορτιστής μόνο για αυτοκίνητα.',
'tours.planner.tourType': 'Τύπος διαδρομής',
'tours.paddle.boat': 'Σκάφος',
'tours.paddle.boat.kayak': 'Καγιάκ',
+15
View File
@@ -408,6 +408,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'E-bike töltés',
'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',
@@ -826,6 +827,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Kiemelt',
'tours.poi.highlightHint': 'Az itteni legismertebb helyek egyike',
'tours.poi.charging.label': 'Töltés',
'tours.poi.charging.designated': 'E-bike-okhoz kialakítva',
'tours.poi.charging.allowed': 'E-bike-ok tölthetnek',
'tours.poi.charging.free': 'Ingyenes',
'tours.poi.charging.fee': 'Fizetős',
'tours.poi.charging.access.public': 'Mindenkinek nyitva',
'tours.poi.charging.access.customers': 'Csak vendégeknek',
'tours.poi.charging.access.private': 'Magán',
'tours.poi.charging.capacity': 'Töltőhelyek',
'tours.poi.charging.socketsUnknown': 'Csatlakozó nincs megadva',
'tours.poi.charging.socketsUnknownHint': 'Az OpenStreetMap nem írja, milyen csatlakozója van, így nem tudni, jó-e a töltőd.',
'tours.poi.charging.ownCharger': 'Hagyományos konnektor az OpenStreetMap szerint: hozd a saját töltődet.',
'tours.poi.charging.sourceHint': 'Az OpenStreetMap szerint. Hogy ma működik-e vagy ingyenes-e, nem ismert.',
'tours.poi.charging.fewMapped': 'E-bike-okhoz még kevés töltőpont van feltérképezve. Csak azok látszanak, amelyeket az OpenStreetMap kerékpárokhoz jelöl, sosem csak autóknak szánt töltő.',
'tours.planner.tourType': 'Túratípus',
'tours.paddle.boat': 'Hajó',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -404,6 +404,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Titik masuk air',
'tours.poi.cat.boat_rental': 'Penyewaan perahu',
'tours.poi.cat.bike_repair': 'Bengkel sepeda',
'tours.poi.cat.ebike_charging': 'Pengisian e-bike',
'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',
@@ -821,6 +822,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Sorotan',
'tours.poi.highlightHint': 'Salah satu tempat paling terkenal di sini',
'tours.poi.charging.label': 'Pengisian',
'tours.poi.charging.designated': 'Khusus untuk e-bike',
'tours.poi.charging.allowed': 'E-bike boleh mengisi daya',
'tours.poi.charging.free': 'Gratis',
'tours.poi.charging.fee': 'Berbayar',
'tours.poi.charging.access.public': 'Terbuka untuk umum',
'tours.poi.charging.access.customers': 'Hanya pelanggan',
'tours.poi.charging.access.private': 'Pribadi',
'tours.poi.charging.capacity': 'Tempat pengisian',
'tours.poi.charging.socketsUnknown': 'Colokan tidak disebutkan',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap tidak menyebutkan colokannya, jadi belum diketahui apakah charger kamu cocok.',
'tours.poi.charging.ownCharger': 'Stopkontak rumah tangga menurut OpenStreetMap: bawa charger sendiri.',
'tours.poi.charging.sourceHint': 'Menurut OpenStreetMap. Apakah berfungsi atau gratis hari ini tidak diketahui.',
'tours.poi.charging.fewMapped': 'Titik pengisian untuk e-bike yang terpetakan masih sedikit. Hanya yang ditandai OpenStreetMap untuk sepeda yang ditampilkan, tidak pernah pengisi daya khusus mobil.',
'tours.planner.tourType': 'Jenis tur',
'tours.paddle.boat': 'Perahu',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+15
View File
@@ -413,6 +413,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Punti di imbarco',
'tours.poi.cat.boat_rental': 'Noleggio barche',
'tours.poi.cat.bike_repair': 'Riparazione bici',
'tours.poi.cat.ebike_charging': 'Ricarica e-bike',
'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',
@@ -830,6 +831,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Da non perdere',
'tours.poi.highlightHint': 'Uno dei luoghi più famosi di qui',
'tours.poi.charging.label': 'Ricarica',
'tours.poi.charging.designated': 'Pensato per le e-bike',
'tours.poi.charging.allowed': 'Le e-bike possono ricaricare',
'tours.poi.charging.free': 'Gratuito',
'tours.poi.charging.fee': 'A pagamento',
'tours.poi.charging.access.public': 'Aperto a tutti',
'tours.poi.charging.access.customers': 'Solo clienti',
'tours.poi.charging.access.private': 'Privato',
'tours.poi.charging.capacity': 'Posti di ricarica',
'tours.poi.charging.socketsUnknown': 'Presa non indicata',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap non dice quale presa ha, quindi non si sa se il tuo caricatore va bene.',
'tours.poi.charging.ownCharger': 'Una presa domestica, secondo OpenStreetMap: porta il tuo caricatore.',
'tours.poi.charging.sourceHint': 'Secondo OpenStreetMap. Non si sa se oggi funziona o è gratuita.',
'tours.poi.charging.fewMapped': 'Per le e-bike sono ancora mappati pochi punti di ricarica. Sono mostrati solo quelli che OpenStreetMap segna per le bici, mai una colonnina solo per auto.',
'tours.planner.tourType': 'Tipo di tour',
'tours.paddle.boat': 'Imbarcazione',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+15
View File
@@ -394,6 +394,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': '入水地点',
'tours.poi.cat.boat_rental': 'ボートレンタル',
'tours.poi.cat.bike_repair': '自転車修理',
'tours.poi.cat.ebike_charging': 'e-bike充電',
'tours.planner.tourTypeHint.hike': '徒歩、設定した難易度まで',
'tours.planner.tourTypeHint.bike': '自転車で、ツーリング、ロード、グラベル、MTB向けのルート',
'tours.planner.tourTypeHint.kayak': '水上で、カヤック、カヌー、SUPで',
@@ -800,6 +801,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '注目スポット',
'tours.poi.highlightHint': 'このあたりで特に有名な場所のひとつ',
'tours.poi.charging.label': '充電',
'tours.poi.charging.designated': 'e-bike専用',
'tours.poi.charging.allowed': 'e-bikeの充電可',
'tours.poi.charging.free': '無料',
'tours.poi.charging.fee': '有料',
'tours.poi.charging.access.public': '誰でも利用可',
'tours.poi.charging.access.customers': '利用客のみ',
'tours.poi.charging.access.private': '私設',
'tours.poi.charging.capacity': '充電台数',
'tours.poi.charging.socketsUnknown': 'コンセント不明',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMapにコンセントの種類が記載されていないため、お手持ちの充電器が使えるかは分かりません。',
'tours.poi.charging.ownCharger': 'OpenStreetMapの記載では家庭用コンセントです。充電器はご自身でお持ちください。',
'tours.poi.charging.sourceHint': 'OpenStreetMapの記載どおりです。今日使えるか、無料かは分かりません。',
'tours.poi.charging.fewMapped': 'e-bike用の充電スポットはまだ少ししか登録されていません。OpenStreetMapが自転車向けと記載しているものだけを表示し、自動車専用の充電器は表示しません。',
'tours.planner.tourType': 'ツアーの種類',
'tours.paddle.boat': 'ボート',
'tours.paddle.boat.kayak': 'カヤック',
+15
View File
@@ -391,6 +391,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': '입수 지점',
'tours.poi.cat.boat_rental': '보트 대여',
'tours.poi.cat.bike_repair': '자전거 수리',
'tours.poi.cat.ebike_charging': '전기자전거 충전',
'tours.planner.tourTypeHint.hike': '도보로, 설정한 난이도 안에서',
'tours.planner.tourTypeHint.bike': '자전거로, 투어링, 로드, 그래블, MTB에 맞춘 경로',
'tours.planner.tourTypeHint.kayak': '물 위에서, 카약, 카누 또는 SUP로',
@@ -795,6 +796,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '하이라이트',
'tours.poi.highlightHint': '이곳에서 가장 잘 알려진 장소 중 하나',
'tours.poi.charging.label': '충전',
'tours.poi.charging.designated': '전기자전거 전용',
'tours.poi.charging.allowed': '전기자전거 충전 가능',
'tours.poi.charging.free': '무료',
'tours.poi.charging.fee': '유료',
'tours.poi.charging.access.public': '누구나 이용 가능',
'tours.poi.charging.access.customers': '고객 전용',
'tours.poi.charging.access.private': '사설',
'tours.poi.charging.capacity': '충전 자리',
'tours.poi.charging.socketsUnknown': '콘센트 정보 없음',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap에 콘센트 종류가 나와 있지 않아 내 충전기가 맞는지 알 수 없습니다.',
'tours.poi.charging.ownCharger': 'OpenStreetMap에 따르면 가정용 콘센트입니다. 충전기는 직접 챙기세요.',
'tours.poi.charging.sourceHint': 'OpenStreetMap에 나온 그대로입니다. 오늘 작동하는지, 무료인지는 알 수 없습니다.',
'tours.poi.charging.fewMapped': '전기자전거 충전소는 아직 지도에 많지 않습니다. OpenStreetMap이 자전거용으로 표시한 곳만 보여 주며, 자동차 전용 충전기는 보여 주지 않습니다.',
'tours.planner.tourType': '투어 유형',
'tours.paddle.boat': '보트',
'tours.paddle.boat.kayak': '카약',
+15
View File
@@ -412,6 +412,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Instapplaatsen',
'tours.poi.cat.boat_rental': 'Bootverhuur',
'tours.poi.cat.bike_repair': 'Fietsreparatie',
'tours.poi.cat.ebike_charging': 'E-bike laden',
'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',
@@ -827,6 +828,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Topper',
'tours.poi.highlightHint': 'Een van de bekendste plekken hier',
'tours.poi.charging.label': 'Laden',
'tours.poi.charging.designated': 'Bedoeld voor e-bikes',
'tours.poi.charging.allowed': 'E-bikes mogen laden',
'tours.poi.charging.free': 'Gratis',
'tours.poi.charging.fee': 'Betaald',
'tours.poi.charging.access.public': 'Open voor iedereen',
'tours.poi.charging.access.customers': 'Alleen klanten',
'tours.poi.charging.access.private': 'Privé',
'tours.poi.charging.capacity': 'Laadplekken',
'tours.poi.charging.socketsUnknown': 'Stekker niet vermeld',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap zegt niet welke stekker er is, dus of je lader past is onbekend.',
'tours.poi.charging.ownCharger': 'Een gewoon stopcontact, volgens OpenStreetMap: neem je eigen lader mee.',
'tours.poi.charging.sourceHint': 'Volgens OpenStreetMap. Of het vandaag werkt of gratis is, is onbekend.',
'tours.poi.charging.fewMapped': 'Er zijn nog weinig laadpunten voor e-bikes in kaart gebracht. Alleen die OpenStreetMap voor fietsen markeert worden getoond, nooit een laadpaal alleen voor auto’s.',
'tours.planner.tourType': 'Soort tocht',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -418,6 +418,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Miejsca wodowania',
'tours.poi.cat.boat_rental': 'Wypożyczalnia łodzi',
'tours.poi.cat.bike_repair': 'Naprawa rowerów',
'tours.poi.cat.ebike_charging': 'Ładowanie e-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',
@@ -833,6 +834,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Perełka',
'tours.poi.highlightHint': 'Jedno z najbardziej znanych miejsc w okolicy',
'tours.poi.charging.label': 'Ładowanie',
'tours.poi.charging.designated': 'Przeznaczone dla e-rowerów',
'tours.poi.charging.allowed': 'E-rowery mogą się ładować',
'tours.poi.charging.free': 'Bezpłatne',
'tours.poi.charging.fee': 'Płatne',
'tours.poi.charging.access.public': 'Otwarte dla wszystkich',
'tours.poi.charging.access.customers': 'Tylko dla klientów',
'tours.poi.charging.access.private': 'Prywatne',
'tours.poi.charging.capacity': 'Miejsca ładowania',
'tours.poi.charging.socketsUnknown': 'Gniazdo nie podane',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap nie podaje, jakie ma gniazdo, więc nie wiadomo, czy pasuje twoja ładowarka.',
'tours.poi.charging.ownCharger': 'Zwykłe gniazdko według OpenStreetMap: weź własną ładowarkę.',
'tours.poi.charging.sourceHint': 'Według OpenStreetMap. Nie wiadomo, czy dziś działa lub jest bezpłatne.',
'tours.poi.charging.fewMapped': 'Punktów ładowania dla e-rowerów jest na mapie jeszcze niewiele. Pokazywane są tylko te, które OpenStreetMap oznacza dla rowerów, nigdy ładowarka tylko dla aut.',
'tours.planner.tourType': 'Rodzaj wycieczki',
'tours.paddle.boat': 'Łódź',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -421,6 +421,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Места спуска на воду',
'tours.poi.cat.boat_rental': 'Прокат лодок',
'tours.poi.cat.bike_repair': 'Ремонт велосипедов',
'tours.poi.cat.ebike_charging': 'Зарядка электровелосипедов',
'tours.planner.tourTypeHint.hike': 'Пешком, в пределах выбранной сложности',
'tours.planner.tourTypeHint.bike': 'На велосипеде, маршрут для туризма, шоссе, гравела или МТБ',
'tours.planner.tourTypeHint.kayak': 'По воде, на каяке, каноэ или SUP',
@@ -836,6 +837,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Главное',
'tours.poi.highlightHint': 'Одно из самых известных мест здесь',
'tours.poi.charging.label': 'Зарядка',
'tours.poi.charging.designated': 'Для электровелосипедов',
'tours.poi.charging.allowed': 'Электровелосипеды можно заряжать',
'tours.poi.charging.free': 'Бесплатно',
'tours.poi.charging.fee': 'Платно',
'tours.poi.charging.access.public': 'Открыто для всех',
'tours.poi.charging.access.customers': 'Только для клиентов',
'tours.poi.charging.access.private': 'Частное',
'tours.poi.charging.capacity': 'Места для зарядки',
'tours.poi.charging.socketsUnknown': 'Разъём не указан',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap не указывает разъём, поэтому неизвестно, подойдёт ли ваше зарядное устройство.',
'tours.poi.charging.ownCharger': 'Обычная розетка, по данным OpenStreetMap: возьмите своё зарядное устройство.',
'tours.poi.charging.sourceHint': 'По данным OpenStreetMap. Работает ли она и бесплатна ли сегодня, неизвестно.',
'tours.poi.charging.fewMapped': 'Зарядок для электровелосипедов на карте пока мало. Показаны только те, что OpenStreetMap отмечает для велосипедов, и никогда зарядка только для автомобилей.',
'tours.planner.tourType': 'Тип тура',
'tours.paddle.boat': 'Лодка',
'tours.paddle.boat.kayak': 'Каяк',
+15
View File
@@ -402,6 +402,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Nabíjanie elektrobicyklov',
'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',
@@ -811,6 +812,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Top miesto',
'tours.poi.highlightHint': 'Jedno z najznámejších miest v okolí',
'tours.poi.charging.label': 'Nabíjanie',
'tours.poi.charging.designated': 'Určené pre elektrobicykle',
'tours.poi.charging.allowed': 'Elektrobicykle môžu nabíjať',
'tours.poi.charging.free': 'Zadarmo',
'tours.poi.charging.fee': 'Spoplatnené',
'tours.poi.charging.access.public': 'Otvorené pre všetkých',
'tours.poi.charging.access.customers': 'Len pre zákazníkov',
'tours.poi.charging.access.private': 'Súkromné',
'tours.poi.charging.capacity': 'Nabíjacie miesta',
'tours.poi.charging.socketsUnknown': 'Zásuvka neuvedená',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap neuvádza, akú má zásuvku, takže nie je isté, či sa hodí tvoja nabíjačka.',
'tours.poi.charging.ownCharger': 'Bežná zásuvka podľa OpenStreetMap: vezmi si vlastnú nabíjačku.',
'tours.poi.charging.sourceHint': 'Podľa OpenStreetMap. Či dnes funguje alebo je zadarmo, nie je známe.',
'tours.poi.charging.fewMapped': 'Nabíjacích miest pre elektrobicykle je zatiaľ zmapovaných málo. Zobrazujú sa len tie, ktoré OpenStreetMap označuje pre bicykle, nikdy nabíjačka len pre autá.',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -400,6 +400,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Isättningsplatser',
'tours.poi.cat.boat_rental': 'Båtuthyrning',
'tours.poi.cat.bike_repair': 'Cykelreparation',
'tours.poi.cat.ebike_charging': 'Elcykelladdning',
'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',
@@ -808,6 +809,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Höjdpunkt',
'tours.poi.highlightHint': 'En av de mest kända platserna här',
'tours.poi.charging.label': 'Laddning',
'tours.poi.charging.designated': 'Avsedd för elcyklar',
'tours.poi.charging.allowed': 'Elcyklar får ladda',
'tours.poi.charging.free': 'Gratis',
'tours.poi.charging.fee': 'Avgift',
'tours.poi.charging.access.public': 'Öppen för alla',
'tours.poi.charging.access.customers': 'Endast kunder',
'tours.poi.charging.access.private': 'Privat',
'tours.poi.charging.capacity': 'Laddplatser',
'tours.poi.charging.socketsUnknown': 'Kontakt inte angiven',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap säger inte vilken kontakt den har, så om din laddare passar är okänt.',
'tours.poi.charging.ownCharger': 'Ett vanligt eluttag enligt OpenStreetMap: ta med din egen laddare.',
'tours.poi.charging.sourceHint': 'Enligt OpenStreetMap. Om den fungerar eller är gratis i dag är okänt.',
'tours.poi.charging.fewMapped': 'Än så länge är få laddpunkter för elcyklar kartlagda. Bara de som OpenStreetMap märker för cyklar visas, aldrig en laddare bara för bilar.',
'tours.planner.tourType': 'Turtyp',
'tours.paddle.boat': 'Båt',
'tours.paddle.boat.kayak': 'Kajak',
+15
View File
@@ -382,6 +382,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'จุดลงเรือ',
'tours.poi.cat.boat_rental': 'เช่าเรือ',
'tours.poi.cat.bike_repair': 'ซ่อมจักรยาน',
'tours.poi.cat.ebike_charging': 'จุดชาร์จจักรยานไฟฟ้า',
'tours.planner.tourTypeHint.hike': 'เดินเท้า ภายในระดับความยากที่คุณกำหนด',
'tours.planner.tourTypeHint.bike': 'ด้วยจักรยาน เส้นทางสำหรับทัวริ่ง เสือหมอบ กราเวล หรือเสือภูเขา',
'tours.planner.tourTypeHint.kayak': 'บนน้ำ ด้วยคายัค แคนู หรือ SUP',
@@ -780,6 +781,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'ไฮไลต์',
'tours.poi.highlightHint': 'หนึ่งในสถานที่ที่มีชื่อเสียงที่สุดในบริเวณนี้',
'tours.poi.charging.label': 'การชาร์จ',
'tours.poi.charging.designated': 'สำหรับจักรยานไฟฟ้าโดยเฉพาะ',
'tours.poi.charging.allowed': 'จักรยานไฟฟ้าชาร์จได้',
'tours.poi.charging.free': 'ฟรี',
'tours.poi.charging.fee': 'มีค่าบริการ',
'tours.poi.charging.access.public': 'เปิดให้ทุกคน',
'tours.poi.charging.access.customers': 'เฉพาะลูกค้า',
'tours.poi.charging.access.private': 'ส่วนตัว',
'tours.poi.charging.capacity': 'ช่องชาร์จ',
'tours.poi.charging.socketsUnknown': 'ไม่ระบุหัวปลั๊ก',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap ไม่ได้ระบุว่ามีหัวปลั๊กแบบใด จึงไม่ทราบว่าที่ชาร์จของคุณใช้ได้หรือไม่',
'tours.poi.charging.ownCharger': 'เต้ารับไฟบ้านตามที่ OpenStreetMap ระบุ นำที่ชาร์จของคุณไปเอง',
'tours.poi.charging.sourceHint': 'ตามที่ OpenStreetMap ระบุ ไม่ทราบว่าวันนี้ใช้งานได้หรือฟรีหรือไม่',
'tours.poi.charging.fewMapped': 'จุดชาร์จจักรยานไฟฟ้าบนแผนที่ยังมีไม่มาก แสดงเฉพาะจุดที่ OpenStreetMap ระบุว่าใช้กับจักรยานได้ ไม่มีจุดชาร์จสำหรับรถยนต์อย่างเดียว',
'tours.planner.tourType': 'ประเภททริป',
'tours.paddle.boat': 'เรือ',
'tours.paddle.boat.kayak': 'เรือคายัก',
+1 -1
View File
@@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
{
file: 'tours',
generalKeyCount: 1287,
generalKeyCount: 1302,
modules: toursModules,
},
{
+15
View File
@@ -402,6 +402,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Suya iniş noktaları',
'tours.poi.cat.boat_rental': 'Tekne kiralama',
'tours.poi.cat.bike_repair': 'Bisiklet tamiri',
'tours.poi.cat.ebike_charging': 'E-bisiklet şarjı',
'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',
@@ -816,6 +817,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Öne çıkan',
'tours.poi.highlightHint': 'Buradaki en bilinen yerlerden biri',
'tours.poi.charging.label': 'Şarj',
'tours.poi.charging.designated': 'E-bisikletler için',
'tours.poi.charging.allowed': 'E-bisikletler şarj edilebilir',
'tours.poi.charging.free': 'Ücretsiz',
'tours.poi.charging.fee': 'Ücretli',
'tours.poi.charging.access.public': 'Herkese açık',
'tours.poi.charging.access.customers': 'Yalnızca müşteriler',
'tours.poi.charging.access.private': 'Özel',
'tours.poi.charging.capacity': 'Şarj yerleri',
'tours.poi.charging.socketsUnknown': 'Priz belirtilmemiş',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap hangi prizi olduğunu söylemiyor, bu yüzden şarj cihazının uyup uymadığı bilinmiyor.',
'tours.poi.charging.ownCharger': 'OpenStreetMap’e göre ev tipi priz: kendi şarj cihazını getir.',
'tours.poi.charging.sourceHint': 'OpenStreetMap’e göre. Bugün çalışıp çalışmadığı ya da ücretsiz olup olmadığı bilinmiyor.',
'tours.poi.charging.fewMapped': 'E-bisikletler için henüz az şarj noktası haritalanmış. Yalnızca OpenStreetMap’in bisikletler için işaretledikleri gösterilir, asla yalnızca otomobillere ait bir şarj noktası değil.',
'tours.planner.tourType': 'Tur türü',
'tours.paddle.boat': 'Tekne',
'tours.paddle.boat.kayak': 'Kano (kayak)',
+15
View File
@@ -416,6 +416,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': 'Місця спуску на воду',
'tours.poi.cat.boat_rental': 'Прокат човнів',
'tours.poi.cat.bike_repair': 'Ремонт велосипедів',
'tours.poi.cat.ebike_charging': 'Заряджання електровелосипедів',
'tours.planner.tourTypeHint.hike': 'Пішки, у межах обраної складності',
'tours.planner.tourTypeHint.bike': 'На велосипеді, маршрут для туризму, шосе, гревелу або МТБ',
'tours.planner.tourTypeHint.kayak': 'По воді, на каяку, каное або SUP',
@@ -827,6 +828,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Головне',
'tours.poi.highlightHint': 'Одне з найвідоміших місць тут',
'tours.poi.charging.label': 'Заряджання',
'tours.poi.charging.designated': 'Для електровелосипедів',
'tours.poi.charging.allowed': 'Електровелосипеди можна заряджати',
'tours.poi.charging.free': 'Безкоштовно',
'tours.poi.charging.fee': 'Платно',
'tours.poi.charging.access.public': 'Відкрито для всіх',
'tours.poi.charging.access.customers': 'Лише для клієнтів',
'tours.poi.charging.access.private': 'Приватне',
'tours.poi.charging.capacity': 'Місця для заряджання',
'tours.poi.charging.socketsUnknown': 'Роз’єм не вказано',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap не вказує роз’єм, тож невідомо, чи підійде ваш зарядний пристрій.',
'tours.poi.charging.ownCharger': 'Звичайна розетка, за даними OpenStreetMap: візьміть свій зарядний пристрій.',
'tours.poi.charging.sourceHint': 'За даними OpenStreetMap. Чи працює вона і чи безкоштовна сьогодні, невідомо.',
'tours.poi.charging.fewMapped': 'Зарядок для електровелосипедів на мапі поки мало. Показано лише ті, що OpenStreetMap позначає для велосипедів, і ніколи зарядку лише для автомобілів.',
'tours.planner.tourType': 'Тип туру',
'tours.paddle.boat': 'Човен',
'tours.paddle.boat.kayak': 'Каяк',
+15
View File
@@ -400,6 +400,7 @@ const tours: TranslationStrings = {
'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.poi.cat.ebike_charging': 'Sạc xe đạp điện',
'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',
@@ -810,6 +811,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Nổi bật',
'tours.poi.highlightHint': 'Một trong những nơi nổi tiếng nhất ở đây',
'tours.poi.charging.label': 'Sạc',
'tours.poi.charging.designated': 'Dành cho xe đạp điện',
'tours.poi.charging.allowed': 'Xe đạp điện được sạc',
'tours.poi.charging.free': 'Miễn phí',
'tours.poi.charging.fee': 'Có thu phí',
'tours.poi.charging.access.public': 'Mở cho mọi người',
'tours.poi.charging.access.customers': 'Chỉ dành cho khách hàng',
'tours.poi.charging.access.private': 'Riêng tư',
'tours.poi.charging.capacity': 'Chỗ sạc',
'tours.poi.charging.socketsUnknown': 'Không ghi loại ổ cắm',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap không ghi loại ổ cắm, nên chưa biết bộ sạc của bạn có vừa không.',
'tours.poi.charging.ownCharger': 'Ổ cắm gia dụng theo OpenStreetMap: hãy mang bộ sạc của bạn.',
'tours.poi.charging.sourceHint': 'Theo OpenStreetMap. Hôm nay còn hoạt động hay miễn phí không thì chưa rõ.',
'tours.poi.charging.fewMapped': 'Hiện mới có ít điểm sạc cho xe đạp điện được lập bản đồ. Chỉ hiện những điểm OpenStreetMap đánh dấu cho xe đạp, không bao giờ là trạm sạc chỉ dành cho ô tô.',
'tours.planner.tourType': 'Loại chuyến',
'tours.paddle.boat': 'Thuyền',
'tours.paddle.boat.kayak': 'Thuyền kayak',
+15
View File
@@ -367,6 +367,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': '下水點',
'tours.poi.cat.boat_rental': '租船',
'tours.poi.cat.bike_repair': '自行車維修',
'tours.poi.cat.ebike_charging': '電動自行車充電',
'tours.planner.tourTypeHint.hike': '步行,在你設定的難度內',
'tours.planner.tourTypeHint.bike': '騎乘,為旅行車、公路車、礫石車或登山車規劃路線',
'tours.planner.tourTypeHint.kayak': '水上,獨木舟、加拿大艇或立槳',
@@ -756,6 +757,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '亮點',
'tours.poi.highlightHint': '這裡最知名的地方之一',
'tours.poi.charging.label': '充電',
'tours.poi.charging.designated': '專供電動自行車',
'tours.poi.charging.allowed': '電動自行車可充電',
'tours.poi.charging.free': '免費',
'tours.poi.charging.fee': '收費',
'tours.poi.charging.access.public': '對所有人開放',
'tours.poi.charging.access.customers': '僅限顧客',
'tours.poi.charging.access.private': '私人',
'tours.poi.charging.capacity': '充電位',
'tours.poi.charging.socketsUnknown': '未註明插座',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap 沒有註明插座類型,因此不確定你的充電器是否適用。',
'tours.poi.charging.ownCharger': '據 OpenStreetMap 所列為家用插座:請自備充電器。',
'tours.poi.charging.sourceHint': '以 OpenStreetMap 所列為準。今天能否使用、是否免費均不確定。',
'tours.poi.charging.fewMapped': '目前標註的電動自行車充電點還很少。只顯示 OpenStreetMap 標為自行車可用的充電點,絕不顯示僅供汽車的充電樁。',
'tours.planner.tourType': '路線類型',
'tours.paddle.boat': '船隻',
'tours.paddle.boat.kayak': '獨木舟(Kayak)',
+15
View File
@@ -368,6 +368,7 @@ const tours: TranslationStrings = {
'tours.poi.cat.slipway': '下水点',
'tours.poi.cat.boat_rental': '租船',
'tours.poi.cat.bike_repair': '自行车维修',
'tours.poi.cat.ebike_charging': '电动自行车充电',
'tours.planner.tourTypeHint.hike': '步行,在你设定的难度内',
'tours.planner.tourTypeHint.bike': '骑行,为旅行车、公路车、砾石车或山地车规划路线',
'tours.planner.tourTypeHint.kayak': '水上,皮划艇、独木舟或桨板',
@@ -757,6 +758,20 @@ const tours: TranslationStrings = {
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '亮点',
'tours.poi.highlightHint': '这里最知名的地方之一',
'tours.poi.charging.label': '充电',
'tours.poi.charging.designated': '专供电动自行车',
'tours.poi.charging.allowed': '电动自行车可充电',
'tours.poi.charging.free': '免费',
'tours.poi.charging.fee': '收费',
'tours.poi.charging.access.public': '对所有人开放',
'tours.poi.charging.access.customers': '仅限顾客',
'tours.poi.charging.access.private': '私人',
'tours.poi.charging.capacity': '充电位',
'tours.poi.charging.socketsUnknown': '未注明插座',
'tours.poi.charging.socketsUnknownHint': 'OpenStreetMap 没有注明插座类型,因此不确定你的充电器是否适用。',
'tours.poi.charging.ownCharger': '据 OpenStreetMap 所列为家用插座:请自带充电器。',
'tours.poi.charging.sourceHint': '以 OpenStreetMap 所列为准。今天能否使用、是否免费均不确定。',
'tours.poi.charging.fewMapped': '目前标注的电动自行车充电点还很少。只显示 OpenStreetMap 标为自行车可用的充电点,绝不显示仅供汽车的充电桩。',
'tours.planner.tourType': '路线类型',
'tours.paddle.boat': '船只',
'tours.paddle.boat.kayak': '皮划艇',
+1
View File
@@ -19,6 +19,7 @@ export const TOUR_POI_CATEGORIES = [
'cafe',
'restaurant',
'market',
'ebike_charging',
] as const;
export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES);
export type TourPoiCategory = z.infer<typeof tourPoiCategorySchema>;
@@ -107,6 +107,52 @@ describe('tour POI search contracts (PLN-02)', () => {
});
});
describe('e-bike charging along a bike tour (BIK-07)', () => {
const charger = {
id: 'node:7',
name: '',
lat: 47.4,
lng: 11.1,
category: 'ebike_charging',
kind: 'amenity=charging_station&bicycle=designated',
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: 80,
along_m: 5200,
};
const answer = { truncated: false, partial: false, capped: false, unavailable: [] };
it('is offered to a bike tour after its first three, and to no other tour', () => {
expect(TOUR_POI_CATEGORIES_BY_TYPE.bike.slice(0, 3)).toEqual(['drinking_water', 'bike_repair', 'food']);
expect(TOUR_POI_CATEGORIES_BY_TYPE.bike).toContain('ebike_charging');
for (const [type, categories] of Object.entries(TOUR_POI_CATEGORIES_BY_TYPE))
if (type !== 'bike') expect(categories).not.toContain('ebike_charging');
const search = { tour_type: 'bike', categories: ['ebike_charging'], corridor_m: 1000, line };
expect(tourPoiSearchRequestSchema.safeParse(search).success).toBe(true);
});
it('carries what the point states, and leaves the field off a place that is no charger', () => {
const charging = {
bicycle: 'designated',
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: null,
fee: false,
access: null,
};
const parsed = tourPoiSearchResponseSchema.parse({ ...answer, pois: [{ ...charger, charging }, charger] });
expect(parsed.pois[0]?.charging).toEqual(charging);
expect(parsed.pois[1]).not.toHaveProperty('charging');
});
it('refuses a charger that does not say it is for bicycles', () => {
const charging = { bicycle: 'no', sockets: [], capacity: null, fee: null, access: null };
expect(tourPoiSearchResponseSchema.safeParse({ ...answer, pois: [{ ...charger, charging }] }).success).toBe(false);
});
});
describe('city walk themes (CW-05)', () => {
it('offers four themes, each a search a walk may ask for', () => {
expect(WALK_THEME_KEYS).toEqual(['culture', 'parks', 'food', 'mixed']);
+37 -1
View File
@@ -23,7 +23,19 @@ export {
/** The categories each planned tour type offers, the most asked first. */
export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly TourPoiCategory[]> = {
hike: ['viewpoint', 'shelter', 'drinking_water', 'food', 'peak', 'toilets', 'picnic', 'campsite', 'sights'],
bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint', 'toilets', 'shelter', 'picnic', 'campsite', 'sights'],
// BIK-07: e-bike charging after the first three, so a new bike tour still starts on those.
bike: [
'drinking_water',
'bike_repair',
'food',
'ebike_charging',
'viewpoint',
'toilets',
'shelter',
'picnic',
'campsite',
'sights',
],
kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'],
walk: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'restaurant', 'market', 'toilets', 'drinking_water'],
run: ['drinking_water', 'toilets', 'park', 'viewpoint', 'cafe', 'food', 'shelter', 'sights'],
@@ -71,6 +83,28 @@ export const tourPoiSearchRequestSchema = z
.strict();
export type TourPoiSearchRequest = z.infer<typeof tourPoiSearchRequestSchema>;
/**
* BIK-07: what a charging point for e-bikes says about itself, each field only
* as OpenStreetMap states it. The search keeps only points tagged for bicycles,
* so `bicycle` is always one of the two; a socket, fee or access left unsaid is
* null or missing, never a yes.
*/
const tourPoiChargingSchema = z.object({
/** `designated` is made for bikes, `yes` lets them charge. */
bicycle: z.enum(['yes', 'designated']),
/** One entry per socket family stated, with how many and how fast where said. */
sockets: z.array(
z.object({ type: z.string(), count: z.number().int().positive().nullable(), kw: z.number().positive().nullable() }),
),
/** How many bikes can charge at once. */
capacity: z.number().int().positive().nullable(),
/** true costs money, false is free, null is not said. */
fee: z.boolean().nullable(),
/** OpenStreetMap's own access value, `customers` or `private` style. */
access: z.string().nullable(),
});
export type TourPoiCharging = z.infer<typeof tourPoiChargingSchema>;
/** One place found: what the source says about it, and where it lies against the line. */
export const tourPoiSchema = z.object({
/** The source's own id, `node:123` style for OpenStreetMap. */
@@ -94,6 +128,8 @@ export const tourPoiSchema = z.object({
along_m: z.number().nonnegative(),
/** One of the best known places along the line (tour-highlights); absent means not one. */
highlight: z.boolean().optional(),
/** BIK-07: what an e-bike charging point states; absent on every other kind. */
charging: tourPoiChargingSchema.optional(),
});
export type TourPoi = z.infer<typeof tourPoiSchema>;
+1 -1
View File
@@ -242,7 +242,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
| `search_tour_pois` | Places along the saved line of a hike, run, ride, bike tour, paddle tour, ski and winter tour or city walk within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, for a ski and winter tour coffee shops and places to eat as well, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. `retry_after_s`, when present beside `unavailable`, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
| `search_tour_pois` | Places along the saved line of a hike, run, ride, bike tour, paddle tour, ski and winter tour or city walk within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, for a bike tour e-bike charging points, for a ski and winter tour coffee shops and places to eat as well, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. An e-bike charging point is one OpenStreetMap tags for bicycles (`bicycle=designated` or `yes`, never a charger for cars alone) and carries `charging`: bicycle, sockets, capacity, fee and access as OpenStreetMap states them, empty where it says nothing; whether it works or is free today is not known. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. `retry_after_s`, when present beside `unavailable`, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
| `assign_tour_track` | Links an actual track to a saved tour, once the user chose it: another tour of the same trip that came in through the GPX import or from wanderer and was not planned over, for one of the tour's day assignments or for none. A day that already has one gets this one instead; a track belongs to one tour only. The planned line, measures and times of the tour do not change. Needs `place_edit` | `trips:write` |
| `unassign_tour_track` | Unlinks an actual track from a tour again; the track stays in the trip as the import it is and the tour does not change. Needs `place_edit` | `trips:write` |