mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(tours): places along the tour picked on the map, stops that keep their kind, and calmer station rows
Along the tour (PLN-02, PLN-03): the list shows one line per place (its icon, name and distance from the route) and scrolls past ten. A place picked in the list or on the map opens its card over the map, where the other cards of the planner sit, with what the source says and the way to take it in as a stop. What taking it in did is one line in the list. The intro, the theme line and the out-of-date note are gone: the themes say what they look for on hover, and the search button says when to search again. A stop taken in from there keeps the kind of place it was found as (migration 045400, poi_category, read back only where the contract knows the kind) and its number carries that kind's icon at the corner, as a via ferrata's end does. City walks (CW-02, CW-03, CW-10): a stay nobody entered says nothing. While no stay is entered at all the walk is planned as its walking time, with no "unknown" and no "at least". A must-see rings its number, an optional stop has a dashed ring, a protected one a shield at the corner; only the stay stays under the name, below the coordinates.
This commit is contained in:
@@ -167,13 +167,14 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
const times = tourPlannedTimes(tour);
|
||||
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
|
||||
const total = plannedTotalText(times, t);
|
||||
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
|
||||
const timeParts = [
|
||||
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
: null,
|
||||
// CW-02: a city walk's stays, which its total adds to the walking time.
|
||||
times.walk ? `${t('tours.walk.stays')}: ${walkStaysText(times.walk, t)}` : null,
|
||||
staysText ? `${t('tours.walk.stays')}: ${staysText}` : null,
|
||||
total
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { StationDialog } from './StationDialog';
|
||||
import { StationPills } from './stationParts';
|
||||
import { stationMarkNote, stationRingClass } from './stationMarks';
|
||||
import { StationPills, StationShield } from './stationParts';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
const cathedral: TourPlannerWaypoint = {
|
||||
@@ -126,32 +127,53 @@ describe('StationDialog (CW-02, CW-03, CW-10)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('StationPills (CW-02, CW-03, CW-10)', () => {
|
||||
const pills = (point: Partial<TourPlannerWaypoint>) => {
|
||||
describe('StationPills (CW-02)', () => {
|
||||
const pills = (point: Partial<TourPlannerWaypoint>) =>
|
||||
render(<StationPills point={{ id: 'p', lat: 0, lng: 0, role: 'via', ...point }} />);
|
||||
return screen.getByTestId('station-pills');
|
||||
};
|
||||
|
||||
it('says a stay nobody entered is unknown, and nothing of a normal, unprotected station', () => {
|
||||
const row = pills({});
|
||||
expect(row).toHaveTextContent('Stay unknown');
|
||||
expect(row).not.toHaveTextContent('Must see');
|
||||
expect(row).not.toHaveTextContent('Optional');
|
||||
expect(screen.queryByLabelText('Protected')).not.toBeInTheDocument();
|
||||
it('says nothing of a stay nobody entered', () => {
|
||||
pills({ importance: 'must', protected: true });
|
||||
expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a point passed through', () => {
|
||||
expect(pills({ stayMinutes: 0 })).toHaveTextContent('Passing through');
|
||||
it('names a point passed through, and a stay entered as its time alone', () => {
|
||||
pills({ stayMinutes: 0 });
|
||||
expect(screen.getByTestId('station-pills')).toHaveTextContent('Passing through');
|
||||
});
|
||||
|
||||
it('shows the stay, a must-see and a protection', () => {
|
||||
const row = pills({ stayMinutes: 45, importance: 'must', protected: true });
|
||||
it('shows the stay and nothing of the importance or the protection, which are on the number', () => {
|
||||
pills({ stayMinutes: 45, importance: 'must', protected: true });
|
||||
const row = screen.getByTestId('station-pills');
|
||||
expect(row).toHaveTextContent('45');
|
||||
expect(row).toHaveTextContent('Must see');
|
||||
expect(screen.getByLabelText('Protected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an optional station as optional', () => {
|
||||
expect(pills({ stayMinutes: 90, importance: 'optional' })).toHaveTextContent('Optional');
|
||||
expect(row).not.toHaveTextContent('Must see');
|
||||
expect(row).not.toHaveTextContent('Protected');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a station's marks on its number (CW-03, CW-10)", () => {
|
||||
const t = (key: string) =>
|
||||
({
|
||||
'tours.walk.importance.must': 'Must see',
|
||||
'tours.walk.importance.optional': 'Optional',
|
||||
'tours.walk.station.protected': 'Protected',
|
||||
})[key] ?? key;
|
||||
|
||||
it('rings a must-see in full, an optional stop dashed, and a normal one not at all', () => {
|
||||
expect(stationRingClass('must')).toContain('outline-warning');
|
||||
expect(stationRingClass('optional')).toContain('outline-dashed');
|
||||
expect(stationRingClass(null)).toBe('');
|
||||
expect(stationRingClass(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('names the importance and the protection for the label, and nothing for a plain station', () => {
|
||||
expect(stationMarkNote({ importance: 'must', protected: true }, t)).toBe('Must see, Protected');
|
||||
expect(stationMarkNote({ importance: 'optional' }, t)).toBe('Optional');
|
||||
expect(stationMarkNote({ protected: true }, t)).toBe('Protected');
|
||||
expect(stationMarkNote({}, t)).toBeNull();
|
||||
});
|
||||
|
||||
it('draws a protection as a shield at the corner of the number', () => {
|
||||
const { container } = render(<StationShield />);
|
||||
expect(container.querySelector('[data-station-protected="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,8 +192,13 @@ describe('TourWaypointList', () => {
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(within(at(rows, 2)).getByTestId('tour-planner-waypoint-name')).toHaveTextContent('Cathedral');
|
||||
expect(within(at(rows, 2)).getByTestId('station-pills')).toHaveTextContent('Must see');
|
||||
expect(within(at(rows, 0)).getByTestId('station-pills')).toHaveTextContent('Stay unknown');
|
||||
// The stay goes under the name; a must-see rings the number instead of a pill beside it.
|
||||
expect(within(at(rows, 2)).getByTestId('station-pills')).toHaveTextContent('30 min');
|
||||
expect(within(at(rows, 2)).getByTestId('station-pills')).not.toHaveTextContent('Must see');
|
||||
const mustSee = within(at(rows, 2)).getByLabelText('Waypoint 2, Must see');
|
||||
expect(mustSee.className).toContain('outline-warning');
|
||||
// A stop without a stay entered says nothing of it, and a shaping point is no stop.
|
||||
expect(within(at(rows, 0)).queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
expect(within(at(rows, 1)).queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
|
||||
@@ -220,12 +225,23 @@ describe('TourWaypointList', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-009: a stop taken in along the tour carries its kind of place at the corner of its number (PLN-03)', () => {
|
||||
const controller = planner({ waypoints: [START, { ...STOP, poiCategory: 'restaurant' as const }, END] });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
const number = within(at(rows, 1)).getByLabelText('Waypoint 2, Places to eat');
|
||||
expect(number.querySelector('[data-waypoint-poi="restaurant"]')).toBeInTheDocument();
|
||||
// A point set by hand has no kind to show.
|
||||
expect(within(at(rows, 0)).queryByLabelText(/Places to eat/)).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll('[data-waypoint-poi]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-008: the way back of a loop walk is no stop: no pills and no stop to edit', async () => {
|
||||
const back = { ...START, id: 'back', role: 'end' as const };
|
||||
const controller = planner({
|
||||
tourType: 'walk',
|
||||
isLoop: true,
|
||||
waypoints: [START, STOP, back],
|
||||
waypoints: [{ ...START, stayMinutes: 10 }, STOP, back],
|
||||
selectedWaypointId: 'back',
|
||||
});
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paddleLayoutOfPoints, type PaddleRole } from '@trek/shared';
|
||||
import { paddleLayoutOfPoints, type PaddleRole, type TourPoiCategory } from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../../../store/tourFerrataCrossingStore';
|
||||
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
|
||||
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
@@ -42,7 +43,8 @@ import { usePlannerWalkSchedule } from '../walk/useWalkSchedule';
|
||||
import { WalkDayLine } from '../walk/WalkDayLine';
|
||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||
import { StationDialog } from './StationDialog';
|
||||
import { StationPills } from './stationParts';
|
||||
import { stationMarkNote, stationRingClass } from './stationMarks';
|
||||
import { StationPills, StationShield } from './stationParts';
|
||||
import { TripPlacePicker } from './TripPlacePicker';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
|
||||
@@ -147,15 +149,57 @@ function WaypointEmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
/** PLN-03: the kind of place a point was found as, its icon at the number's corner in its colour on the map. */
|
||||
function PoiCornerIcon({ category }: { category: TourPoiCategory }) {
|
||||
const look = TOUR_POI_LOOKS[category];
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-waypoint-poi={category}
|
||||
className="absolute -bottom-1 -start-1 grid h-4 w-4 place-items-center rounded-full ring-2 ring-[color:var(--bg-card)]"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 22%, var(--bg-card))`, color: look.color }}
|
||||
>
|
||||
<look.Icon size={9} strokeWidth={2.6} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A city walk station's own marks on its number (CW-03, CW-10); none on any other point. */
|
||||
type StationMarks = Pick<WaypointPoint, 'importance' | 'protected'> | null;
|
||||
|
||||
/**
|
||||
* What a waypoint's number says besides the number: the via ferrata it stands
|
||||
* at an end of, or else the kind of place it was found as along the tour (a
|
||||
* ferrata's end says more about the point, so it wins), and a station's
|
||||
* importance and protection.
|
||||
*/
|
||||
function numberNote(
|
||||
ferrata: TourWaypointFerrata | null,
|
||||
poiCategory: TourPoiCategory | null,
|
||||
station: StationMarks,
|
||||
t: Translate
|
||||
): { note: string | null; poi: TourPoiCategory | null } {
|
||||
const marks = station ? stationMarkNote(station, t) : null;
|
||||
const join = (first: string | null) => [first, marks].filter(Boolean).join(', ') || null;
|
||||
if (ferrata) return { note: join(t('tours.ferrata.waypointAtEnd', { name: ferrata.name })), poi: null };
|
||||
if (poiCategory) return { note: join(t(TOUR_POI_LOOKS[poiCategory].labelKey)), poi: poiCategory };
|
||||
return { note: join(null), poi: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A waypoint's number, as the map pins show it. At the foot or the top of a via
|
||||
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
|
||||
* by the tooltip.
|
||||
* by the tooltip. A point taken in from the places along the tour (PLN-03)
|
||||
* carries that kind's icon at the same corner, in its colour on the map. A
|
||||
* city walk's station shows how much it matters as a ring around the number
|
||||
* and its protection as a shield at the top corner.
|
||||
*/
|
||||
function WaypointNumber({
|
||||
stop,
|
||||
selected,
|
||||
ferrata,
|
||||
poiCategory = null,
|
||||
station = null,
|
||||
loopEnd = false,
|
||||
shaping = false,
|
||||
}: {
|
||||
@@ -163,6 +207,10 @@ function WaypointNumber({
|
||||
stop: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
/** PLN-03: the kind of place the point was found as along the tour. */
|
||||
poiCategory?: TourPoiCategory | null;
|
||||
/** CW-03, CW-10: a station's importance and protection, drawn on the number; only a city walk's points carry them. */
|
||||
station?: StationMarks;
|
||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||
loopEnd?: boolean;
|
||||
/** P2A-03: a shaping point, a small dot as on the map. */
|
||||
@@ -194,19 +242,22 @@ function WaypointNumber({
|
||||
);
|
||||
}
|
||||
const label = t('tours.planner.waypointLabel', { n: stop });
|
||||
const { note, poi } = numberNote(ferrata, poiCategory, station, t);
|
||||
const number = (
|
||||
<span
|
||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||
aria-label={[label, note].filter(Boolean).join(', ')}
|
||||
data-waypoint-number={stop}
|
||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''} ${stationRingClass(station?.importance)}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{stop}
|
||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||
{poi && <PoiCornerIcon category={poi} />}
|
||||
{station?.protected && <StationShield />}
|
||||
</span>
|
||||
);
|
||||
if (!ferrata) return number;
|
||||
if (!ferrata) return note ? <Tooltip label={note}>{number}</Tooltip> : number;
|
||||
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
|
||||
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
|
||||
}
|
||||
@@ -231,7 +282,7 @@ function WaypointRowLabel({
|
||||
paddleRole?: PaddleRole | null;
|
||||
/** PR1-10: the point stands for this place of the trip, named after it. */
|
||||
linked?: boolean;
|
||||
/** CW-01: a city walk's station, which shows its stay, importance and protection. */
|
||||
/** CW-01: a city walk's station, which shows its stay (its importance and protection are on its number). */
|
||||
station?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -260,7 +311,6 @@ function WaypointRowLabel({
|
||||
)}
|
||||
{point.auto && <AutoPointPill />}
|
||||
</span>
|
||||
{station && !loopEnd && <StationPills point={point} />}
|
||||
{linked && !loopEnd ? (
|
||||
// A stop standing for a place of the trip says so where the coordinates would be (PR1-10).
|
||||
<Tooltip label={t('tours.placePicker.linkedHint')}>
|
||||
@@ -285,6 +335,8 @@ function WaypointRowLabel({
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* Under the coordinates: a station's stay, the one mark of it that is not on its number. */}
|
||||
{station && !loopEnd && <StationPills point={point} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -727,6 +779,8 @@ export default function TourWaypointList({
|
||||
stop={stop}
|
||||
selected={selected}
|
||||
ferrata={ferrataEnds[point.id] ?? null}
|
||||
poiCategory={point.poiCategory ?? null}
|
||||
station={point}
|
||||
loopEnd={loopEnd}
|
||||
shaping={!!point.shaping}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { useTranslation } from '../../../i18n';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/**
|
||||
* CW-03: how much a station matters, as a ring around its number: a full ring
|
||||
* in the warning colour for a must-see, a dashed muted one for an optional
|
||||
* stop, none for a normal one.
|
||||
*/
|
||||
export function stationRingClass(importance: TourPlannerWaypoint['importance']): string {
|
||||
if (importance === 'must') return 'outline outline-2 outline-offset-1 outline-warning';
|
||||
if (importance === 'optional') return 'outline outline-2 outline-offset-1 outline-dashed outline-content-faint';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** CW-03, CW-10: what the ring and the shield on a station's number say, for its label and tooltip. */
|
||||
export function stationMarkNote(
|
||||
point: Pick<TourPlannerWaypoint, 'importance' | 'protected'>,
|
||||
t: Translate
|
||||
): string | null {
|
||||
const parts = [
|
||||
point.importance ? t(`tours.walk.importance.${point.importance}`) : null,
|
||||
point.protected ? t('tours.walk.station.protected') : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return parts.length ? parts.join(', ') : null;
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { walkStationsOf } from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-02: a city walk's time, walking and staying kept apart. A stay nobody
|
||||
* entered is unknown, never nothing: the total then counts only what is
|
||||
* known and says it is at least that much. A point passed through is a stay
|
||||
* of 0, entered on purpose.
|
||||
* CW-02: a city walk's time, walking and staying kept apart. While no stay is
|
||||
* entered at all, the walk is planned as its walking time alone and nothing
|
||||
* calls the stays unknown. Once some are entered, one left empty is unknown,
|
||||
* never nothing: the total then counts only what is known and says it is at
|
||||
* least that much. A point passed through is a stay of 0, entered on purpose.
|
||||
*/
|
||||
export interface WalkTimes {
|
||||
walkingMinutes: number | null;
|
||||
@@ -23,16 +24,21 @@ export interface WalkTimes {
|
||||
|
||||
/** The times of a walk from its parts: the walking time, the stays entered, how many are unknown and of how many. */
|
||||
export function walkTimesOf(parts: Omit<WalkTimes, 'totalMinutes' | 'partial'>): WalkTimes {
|
||||
const { walkingMinutes, stayMinutes, unknownStays } = parts;
|
||||
const { walkingMinutes, stayMinutes, unknownStays, stations } = parts;
|
||||
// Without a walking time the stays entered are still part of the whole: at least that much, never nothing.
|
||||
const totalMinutes = walkingMinutes !== null ? walkingMinutes + stayMinutes : stayMinutes > 0 ? stayMinutes : null;
|
||||
return {
|
||||
...parts,
|
||||
totalMinutes,
|
||||
partial: totalMinutes !== null && (unknownStays > 0 || walkingMinutes === null),
|
||||
partial: totalMinutes !== null && (someStaysUnknown({ unknownStays, stations }) || walkingMinutes === null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Some stays entered and some not: none entered at all means none planned, not unknown. */
|
||||
export function someStaysUnknown({ unknownStays, stations }: Pick<WalkTimes, 'unknownStays' | 'stations'>): boolean {
|
||||
return unknownStays > 0 && unknownStays < stations;
|
||||
}
|
||||
|
||||
export function walkTimes(points: readonly TourPlannerWaypoint[], walkingMinutes: number | null): WalkTimes {
|
||||
// A shaping point is no station, and the way back of a loop is its start again: its stay counts once.
|
||||
const stations = walkStationsOf(points);
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
import { Clock, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-02, CW-03, CW-10: what a city walk's station row says besides its name:
|
||||
* the stay (or that it is not known), an importance other than normal, and a
|
||||
* protection, each a quiet pill.
|
||||
* CW-02: a city walk station's stay, where one is entered, as a quiet pill
|
||||
* under its name. A stay nobody entered says nothing. How much the station
|
||||
* matters and whether it is protected are drawn on its number instead
|
||||
* (`stationRingClass`, `StationShield`).
|
||||
*/
|
||||
export function StationPills({ point }: { point: TourPlannerWaypoint }) {
|
||||
export function StationPills({ point }: { point: Pick<TourPlannerWaypoint, 'stayMinutes'> }) {
|
||||
const { t } = useTranslation();
|
||||
if (point.stayMinutes == null) return null;
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1" data-testid="station-pills">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{point.stayMinutes == null
|
||||
? t('tours.walk.station.stayUnknownShort')
|
||||
: point.stayMinutes === 0
|
||||
? t('tours.walk.station.passThrough')
|
||||
: formatPlannedTourDuration(point.stayMinutes)}
|
||||
{point.stayMinutes === 0 ? t('tours.walk.station.passThrough') : formatPlannedTourDuration(point.stayMinutes)}
|
||||
</SoftPill>
|
||||
{point.importance && (
|
||||
<SoftPill tone={point.importance === 'must' ? 'warning' : undefined}>
|
||||
{t(`tours.walk.importance.${point.importance}`)}
|
||||
</SoftPill>
|
||||
)}
|
||||
{point.protected && (
|
||||
<Tooltip label={t('tours.walk.station.protectedHint')}>
|
||||
<span className="inline-flex" aria-label={t('tours.walk.station.protected')}>
|
||||
<SoftPill icon={<ShieldCheck size={10} strokeWidth={2.2} aria-hidden="true" />} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-10: a protected station's shield, at the top corner of its number. */
|
||||
export function StationShield() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-station-protected="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm"
|
||||
>
|
||||
<ShieldCheck size={9} strokeWidth={2.4} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PlannableTourType,
|
||||
StationImportance,
|
||||
TourFixedLeg,
|
||||
TourPoiCategory,
|
||||
TourWaypoint,
|
||||
TourWaypointRole,
|
||||
} from '@trek/shared';
|
||||
@@ -40,6 +41,8 @@ export interface TourPlannerWaypoint {
|
||||
protected?: boolean;
|
||||
/** CW-04: the station's opening hours as OpenStreetMap writes them. */
|
||||
openingHours?: string | null;
|
||||
/** PLN-03: the kind of place the point was taken in from along the tour, whose icon its row carries. */
|
||||
poiCategory?: TourPoiCategory | null;
|
||||
}
|
||||
|
||||
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
|
||||
@@ -59,6 +62,7 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
|
||||
...(point.importance ? { importance: point.importance } : {}),
|
||||
...(point.protected ? { protected: true } : {}),
|
||||
...(point.opening_hours ? { openingHours: point.opening_hours } : {}),
|
||||
...(point.poi_category ? { poiCategory: point.poi_category } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@ export function savedWaypointsOf(points: readonly TourPlannerWaypoint[], tourTyp
|
||||
...(walk && point.importance ? { importance: point.importance } : {}),
|
||||
...(walk && point.protected ? { protected: true } : {}),
|
||||
...(point.openingHours ? { opening_hours: point.openingHours } : {}),
|
||||
...(point.poiCategory ? { poi_category: point.poiCategory } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -144,8 +149,8 @@ export function routeExtrasKey(points: TourPlannerWaypoint[]): string {
|
||||
|
||||
/**
|
||||
* CW-02: everything a route is planned from: every point with all it holds but
|
||||
* a station's own fields (its name, stay, importance, protection and opening
|
||||
* hours) and the key a save gave it. A change to those alone leaves the route
|
||||
* a station's own fields (its name, stay, importance, protection, opening
|
||||
* hours and the kind of place it was found as) and the key a save gave it. A change to those alone leaves the route
|
||||
* as it is; any other change plans it again.
|
||||
*/
|
||||
export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
|
||||
@@ -157,6 +162,7 @@ export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
|
||||
importance: _importance,
|
||||
protected: _protected,
|
||||
openingHours: _hours,
|
||||
poiCategory: _kind,
|
||||
key: _key,
|
||||
...routed
|
||||
}) => routed
|
||||
@@ -173,6 +179,7 @@ function stationFieldsKey(points: TourPlannerWaypoint[]): string {
|
||||
point.importance ?? null,
|
||||
!!point.protected,
|
||||
point.openingHours ?? null,
|
||||
point.poiCategory ?? null,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,12 +30,13 @@ interface StationParts {
|
||||
importance?: string | null;
|
||||
protected?: boolean;
|
||||
openingHours?: string | null;
|
||||
poiCategory?: string | null;
|
||||
}
|
||||
|
||||
/** A point split into what makes it a station and the rest of it; a part it does not have is left out. */
|
||||
function stationSplit<T extends RoutePoint>(point: T & StationParts): { station: StationParts; rest: T } {
|
||||
const { key, name, stayMinutes, importance, protected: kept, openingHours, ...rest } = point;
|
||||
const parts: StationParts = { key, name, stayMinutes, importance, protected: kept, openingHours };
|
||||
const { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory, ...rest } = point;
|
||||
const parts: StationParts = { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory };
|
||||
const station = Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined));
|
||||
return { station, rest: rest as T };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { WALK_THEMES } from '@trek/shared';
|
||||
import { Clock, ExternalLink, MoveHorizontal, Plus, Route, X } from 'lucide-react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
|
||||
import { WhiteBadge } from '../../Planner/planParts';
|
||||
import { PillSelect } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { usePoiInsertion } from './usePoiInsertion';
|
||||
import { useSelectedPoi } from './useTourPoiSearch';
|
||||
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
const km = (metres: number) => metres / 1000;
|
||||
|
||||
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
|
||||
function AfterPicker({
|
||||
planner,
|
||||
poi,
|
||||
insertion,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
poi: TourPoi;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const names = useWaypointPlaceNames(planner.waypoints);
|
||||
const afterId = insertion.afterIdFor(poi);
|
||||
const options = planner.waypoints.map((point, index) => ({
|
||||
value: point.id,
|
||||
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
|
||||
}));
|
||||
const block = afterId ? insertion.blockOf(afterId) : 'none';
|
||||
const locked = !planner.canEdit || planner.isSaving;
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-2 border-t border-edge-faint pt-3" data-testid="tour-poi-insert">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex-none text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.poi.addAfter')}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.poi.addAfter')}
|
||||
value={afterId ?? ''}
|
||||
options={options}
|
||||
onChange={(value) => insertion.chooseAfter(poi, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{block && (
|
||||
<p className="m-0 text-warning" style={fs(11, 'body')}>
|
||||
{t(`tours.poi.blocked.${block}`)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!block || locked}
|
||||
onClick={() => insertion.insert(poi)}
|
||||
data-testid="tour-poi-add"
|
||||
title={t('tours.poi.notAPlace')}
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('tours.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the source says about the place: its address, its opening hours as written, and its website. */
|
||||
function PoiFacts({ poi }: { poi: TourPoi }) {
|
||||
const { t } = useTranslation();
|
||||
if (!poi.address && !poi.opening_hours && !poi.website) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{poi.address && (
|
||||
<p className="m-0 break-words text-content-secondary" style={fs(11.5, 'body')}>
|
||||
{poi.address}
|
||||
</p>
|
||||
)}
|
||||
{poi.opening_hours && (
|
||||
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
|
||||
<span className="min-w-0 break-words" title={t('tours.poi.hoursHint')}>
|
||||
{poi.opening_hours}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{poi.website && (
|
||||
<a
|
||||
href={poi.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
{t('tours.poi.website')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02/PLN-03: a place found along the tour, picked on the map or in the
|
||||
* list, in the look of the other cards over the planner map: what it is, how
|
||||
* far from the route and how far along it, why a city walk's theme suggested
|
||||
* it, where the data comes from, and the way to take it in as a stop.
|
||||
*/
|
||||
function TourPoiCard({
|
||||
poi,
|
||||
theme,
|
||||
planner,
|
||||
insertion,
|
||||
unit,
|
||||
onClose,
|
||||
width,
|
||||
}: {
|
||||
poi: TourPoi;
|
||||
theme: WalkTheme | null;
|
||||
planner: TourPlannerController;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
unit: Unit;
|
||||
onClose: () => void;
|
||||
width?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const look = TOUR_POI_LOOKS[poi.category];
|
||||
const kind = t(look.labelKey);
|
||||
const suggested = theme && (WALK_THEMES[theme] as readonly string[]).includes(poi.category) ? theme : null;
|
||||
return (
|
||||
<article
|
||||
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-[min(20rem,calc(100vw_-_2rem))]'}`}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="tour-poi-card-map"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
|
||||
>
|
||||
<look.Icon size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={poi.name || kind}>
|
||||
{poi.name || kind}
|
||||
</h3>
|
||||
{poi.name && <p className="truncate text-caption text-content-muted">{kind}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
|
||||
</WhiteBadge>
|
||||
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
|
||||
</WhiteBadge>
|
||||
</div>
|
||||
<PoiFacts poi={poi} />
|
||||
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
|
||||
<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>}
|
||||
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen place's card over the planner map, where a via ferrata's or a
|
||||
* paddle object's card sits and closing as those do: on a press outside it
|
||||
* and on Escape, the press handed on so the map leaves its click alone. The
|
||||
* choice lives in the places store, so a pin on the map and a row in the list
|
||||
* open the same card.
|
||||
*/
|
||||
export default function TourPoiOverlay({
|
||||
planner,
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
width?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const poi = useSelectedPoi();
|
||||
const theme = useTourPoiStore((state) => state.theme);
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const insertion = usePoiInsertion(planner);
|
||||
const close = useCallback(() => select(null), [select]);
|
||||
useMapCardDismiss(ref, !!poi, close, onOutsidePress);
|
||||
|
||||
if (!poi) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="tour-poi-overlay"
|
||||
>
|
||||
<TourPoiCard
|
||||
poi={poi}
|
||||
theme={theme}
|
||||
planner={planner}
|
||||
insertion={insertion}
|
||||
unit={unit}
|
||||
onClose={close}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { TOUR_POI_CORRIDORS_M, WALK_THEMES } from '@trek/shared';
|
||||
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Landmark,
|
||||
Loader2,
|
||||
MoveHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Search,
|
||||
Shuffle,
|
||||
Trees,
|
||||
WifiOff,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
@@ -26,11 +23,10 @@ import { formatDistance } from '../../../utils/units';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { PopoverItem } from '../../Packing/PackingPopover';
|
||||
import { WhiteBadge } from '../../Planner/planParts';
|
||||
import { PillSelect, PopoverListPanel, Segmented } from '../../shared/dialogParts';
|
||||
import { PopoverListPanel, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
|
||||
@@ -139,6 +135,7 @@ function ThemeGrid({
|
||||
type="button"
|
||||
onClick={() => onChoose(theme)}
|
||||
aria-pressed={on}
|
||||
title={t(`tours.walk.themeHint.${theme}`)}
|
||||
data-testid={`tour-poi-theme-${theme}`}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-2 text-start font-medium leading-tight transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint' : 'bg-surface-tertiary text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5, 'body')}
|
||||
@@ -153,13 +150,14 @@ function ThemeGrid({
|
||||
}
|
||||
|
||||
/**
|
||||
* What the search answered beyond its places: out of date, offline, failed,
|
||||
* kinds that could not be searched, partly searched, cut, or empty. Nothing
|
||||
* What the search answered beyond its places: offline, failed, kinds that
|
||||
* 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.
|
||||
*/
|
||||
function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, answer, outdated } = search;
|
||||
const { status, answer } = search;
|
||||
if (status === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.poi.offline')}</TourNotice>;
|
||||
if (status === 'error')
|
||||
@@ -169,7 +167,6 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
</TourNotice>
|
||||
);
|
||||
if (!answer) return null;
|
||||
// Out of date is no fault, the route was just changed (often by adding a place from here): said plainly.
|
||||
const unavailable = answer.unavailable.map((category) => t(TOUR_POI_LOOKS[category].labelKey)).join(', ');
|
||||
const notes = [
|
||||
unavailable && t('tours.poi.unavailable', { kinds: unavailable }),
|
||||
@@ -179,7 +176,6 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
].filter((note): note is string => !!note);
|
||||
return (
|
||||
<>
|
||||
{outdated && <TourNotice icon={<RefreshCw size={13} strokeWidth={2} />}>{t('tours.poi.outdated')}</TourNotice>}
|
||||
{notes.map((note) => (
|
||||
<TourNotice key={note} tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{note}
|
||||
@@ -194,79 +190,12 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
);
|
||||
}
|
||||
|
||||
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
|
||||
function AfterPicker({
|
||||
planner,
|
||||
poi,
|
||||
insertion,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
poi: TourPoi;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const names = useWaypointPlaceNames(planner.waypoints);
|
||||
const afterId = insertion.afterIdFor(poi);
|
||||
const options = planner.waypoints.map((point, index) => ({
|
||||
value: point.id,
|
||||
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
|
||||
}));
|
||||
const block = afterId ? insertion.blockOf(afterId) : 'none';
|
||||
const locked = !planner.canEdit || planner.isSaving;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-surface-secondary p-2.5" data-testid="tour-poi-insert">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.poi.addAfter')}
|
||||
</span>
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.poi.addAfter')}
|
||||
value={afterId ?? ''}
|
||||
options={options}
|
||||
onChange={(value) => insertion.chooseAfter(poi, value)}
|
||||
/>
|
||||
</div>
|
||||
{block && (
|
||||
<p className="m-0 text-warning" style={fs(11, 'body')}>
|
||||
{t(`tours.poi.blocked.${block}`)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!block || locked}
|
||||
onClick={() => insertion.insert(poi)}
|
||||
data-testid="tour-poi-add"
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('tours.poi.add')}
|
||||
</button>
|
||||
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.notAPlace')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One place found: its kind, name and where it lies; opened, what the source says and the way to take it in. */
|
||||
function PoiRow({
|
||||
poi,
|
||||
selected,
|
||||
planner,
|
||||
insertion,
|
||||
unit,
|
||||
theme = null,
|
||||
}: {
|
||||
poi: TourPoi;
|
||||
selected: boolean;
|
||||
planner: TourPlannerController;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
unit: Unit;
|
||||
/** CW-05: the theme the place was suggested for, said as its reason. */
|
||||
theme?: WalkTheme | null;
|
||||
}) {
|
||||
/**
|
||||
* One place found, in one line: its kind's icon in its map colour, its name,
|
||||
* and how far it lies from the route. A click opens its card over the map,
|
||||
* where the rest is said and the place is taken in as a stop.
|
||||
*/
|
||||
function PoiRow({ poi, selected, unit }: { poi: TourPoi; selected: boolean; unit: Unit }) {
|
||||
const { t } = useTranslation();
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const look = TOUR_POI_LOOKS[poi.category];
|
||||
@@ -274,77 +203,33 @@ function PoiRow({
|
||||
useEffect(() => {
|
||||
if (selected) rowRef.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [selected]);
|
||||
const distance = formatDistance(km(poi.distance_m), unit);
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={`rounded-[10px] ${selected ? 'bg-surface-selected' : ''}`}
|
||||
data-testid={`tour-poi-${poi.id}`}
|
||||
>
|
||||
<li ref={rowRef} data-testid={`tour-poi-${poi.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(selected ? null : poi.id)}
|
||||
aria-expanded={selected}
|
||||
className="flex w-full min-w-0 items-start gap-2.5 rounded-[10px] px-2 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
onClick={() => select(poi.id)}
|
||||
aria-pressed={selected}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-[8px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover ${selected ? 'bg-surface-selected' : ''}`}
|
||||
>
|
||||
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full"
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
|
||||
>
|
||||
<look.Icon size={14} strokeWidth={2.2} />
|
||||
<look.Icon size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{poi.name || t(look.labelKey)}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
|
||||
</WhiteBadge>
|
||||
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
|
||||
{poi.name || t(look.labelKey)}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none tabular-nums text-content-faint"
|
||||
style={fs(11, 'body')}
|
||||
title={t('tours.poi.fromRoute', { distance })}
|
||||
>
|
||||
{distance}
|
||||
</span>
|
||||
</button>
|
||||
{selected && (
|
||||
<div className="flex flex-col gap-2 px-2 pb-2.5">
|
||||
<p className="m-0 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
|
||||
<span>{t(look.labelKey)}</span>
|
||||
{theme && <span>{t('tours.walk.themeReason', { theme: t(`tours.walk.theme.${theme}`) })}</span>}
|
||||
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
|
||||
</p>
|
||||
{poi.address && (
|
||||
<p className="m-0 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
{poi.address}
|
||||
</p>
|
||||
)}
|
||||
{poi.opening_hours && (
|
||||
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
|
||||
<span>
|
||||
{poi.opening_hours}
|
||||
<span className="block text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.hoursHint')}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{poi.website && (
|
||||
<a
|
||||
href={poi.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
{t('tours.poi.website')}
|
||||
</a>
|
||||
)}
|
||||
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -364,24 +249,30 @@ function OutcomeNotice({ outcome, onDismiss, unit }: { outcome: InsertionOutcome
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex flex-col gap-1.5">
|
||||
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
|
||||
{outcome.kind === 'measured' && (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<WhiteBadge>{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}</WhiteBadge>
|
||||
{outcome.extraMinutes != null && (
|
||||
<span className="flex items-start gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
|
||||
{outcome.kind === 'measured' && (
|
||||
<>
|
||||
<WhiteBadge>
|
||||
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
|
||||
{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-wrap items-center gap-2 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.undoHint')}
|
||||
<button type="button" onClick={onDismiss} className="font-semibold underline underline-offset-2">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
{outcome.extraMinutes != null && (
|
||||
<WhiteBadge>
|
||||
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded p-0.5 text-content-faint hover:text-content"
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
</div>
|
||||
@@ -415,15 +306,9 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" data-testid="tour-poi-card">
|
||||
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.poi.intro')}
|
||||
</p>
|
||||
{search.themes.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-poi-themes">
|
||||
<div data-testid="tour-poi-themes">
|
||||
<ThemeGrid themes={search.themes} active={search.theme} onChoose={search.chooseTheme} />
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{search.theme ? t(`tours.walk.themeHint.${search.theme}`) : t('tours.walk.themeHint.own')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<CategoryPicker search={search} />
|
||||
@@ -446,8 +331,10 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
type="button"
|
||||
onClick={() => void search.search()}
|
||||
disabled={!search.canSearch}
|
||||
title={search.outdated ? t('tours.poi.outdated') : undefined}
|
||||
data-testid="tour-poi-search"
|
||||
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] bg-surface-tertiary font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||
data-outdated={search.outdated || undefined}
|
||||
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] font-semibold transition-colors disabled:cursor-default disabled:opacity-50 ${search.outdated ? 'bg-accent text-accent-text hover:opacity-90' : 'bg-surface-tertiary text-content hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{search.status === 'loading' ? (
|
||||
@@ -464,21 +351,12 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
{insertion.outcome && <OutcomeNotice outcome={insertion.outcome} onDismiss={insertion.dismiss} unit={unit} />}
|
||||
<SearchNotices search={search} unit={unit} />
|
||||
{pois.length > 0 && (
|
||||
<ul className={`m-0 flex list-none flex-col gap-0.5 p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}>
|
||||
<ul
|
||||
className={`m-0 flex max-h-[320px] list-none flex-col gap-px overflow-y-auto overscroll-contain p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}
|
||||
data-testid="tour-poi-list"
|
||||
>
|
||||
{pois.map((poi) => (
|
||||
<PoiRow
|
||||
key={poi.id}
|
||||
poi={poi}
|
||||
selected={selected?.id === poi.id}
|
||||
planner={planner}
|
||||
insertion={insertion}
|
||||
unit={unit}
|
||||
theme={
|
||||
search.theme && (WALK_THEMES[search.theme] as readonly string[]).includes(poi.category)
|
||||
? search.theme
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<PoiRow key={poi.id} poi={poi} selected={selected?.id === poi.id} unit={unit} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -8,12 +8,13 @@ import { tourPoiSearchRepo } from '../../../repo/tourPoiSearchRepo';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import TourPoiOverlay from './TourPoiCard';
|
||||
import { TourPoiSearchCard } from './TourPoiSearchCard';
|
||||
import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
|
||||
import { useTourPoiMarkers } from './useTourPoiMarkers';
|
||||
import { useTourPoiSearch } from './useTourPoiSearch';
|
||||
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-015
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-016
|
||||
|
||||
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||
|
||||
@@ -194,22 +195,63 @@ describe('usePoiInsertion (PLN-03)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The list in the side panel and the chosen place's card over the map, as the planner shows them. */
|
||||
function renderAlong(planner: TourPlannerController) {
|
||||
return render(
|
||||
<>
|
||||
<TourPoiSearchCard planner={planner} />
|
||||
<TourPoiOverlay planner={planner} insetEnd={0} bottom={0} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TourPoiSearchCard', () => {
|
||||
it('FE-TOUR-POI-008: lists what was found with where it lies; opening a place offers to add it after a point', async () => {
|
||||
it('FE-TOUR-POI-008: lists what was found in one line each; a place opens its card over the map, which adds it after a point', async () => {
|
||||
const planner = {
|
||||
...searchPlanner(),
|
||||
...insertionPlanner(),
|
||||
} as unknown as TourPlannerController;
|
||||
render(<TourPoiSearchCard planner={planner} />);
|
||||
renderAlong(planner);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const row = await screen.findByTestId('tour-poi-node:1');
|
||||
expect(row).toHaveTextContent('Spring');
|
||||
expect(row).toHaveTextContent('80 m from the route');
|
||||
expect(row).toHaveTextContent('80 m');
|
||||
expect(within(row).queryByTestId('tour-poi-add')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: /Spring/ }));
|
||||
expect(within(row).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
|
||||
expect(within(row).getByText('As the source has them; check before you go.')).toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByTestId('tour-poi-add'));
|
||||
const card = screen.getByTestId('tour-poi-overlay');
|
||||
expect(within(row).getByRole('button', { name: /Spring/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(card).toHaveTextContent('80 m from the route');
|
||||
expect(within(card).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Mo-Su 08:00-18:00')).toHaveAttribute(
|
||||
'title',
|
||||
'As the source has them; check before you go.'
|
||||
);
|
||||
fireEvent.click(within(card).getByTestId('tour-poi-add'));
|
||||
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
|
||||
// The card closes once the place is in, and the list says what it did.
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-poi-outcome')).toHaveTextContent('Spring');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-016: the card over the map closes on its button and on Escape, and a pin on the map opens it too', async () => {
|
||||
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
|
||||
renderAlong(planner);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
await screen.findByTestId('tour-poi-node:1');
|
||||
|
||||
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
|
||||
// The one place found, picked as a click on its pin does.
|
||||
for (const poi of markers.result.current.pois) act(() => markers.result.current.onPoiClick(poi));
|
||||
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Spring');
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-overlay')).getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
|
||||
expect(screen.getByTestId('tour-poi-overlay')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-009: names the kinds that could not be searched, and does not claim nothing was found', async () => {
|
||||
@@ -272,11 +314,11 @@ describe('a city walk along its places (CW-05)', () => {
|
||||
// The walk starts with its first three categories, which no theme is.
|
||||
for (const theme of ['culture', 'parks', 'food', 'mixed'])
|
||||
expect(themeButton(theme)).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(themes).toHaveTextContent('Your own pick of categories below; a theme sets them for you.');
|
||||
// What a theme looks for is said on the button, not in a line of its own.
|
||||
expect(themeButton('food')).toHaveAttribute('title', 'Coffee shops, places to eat and markets along the route.');
|
||||
|
||||
fireEvent.click(themeButton('food'));
|
||||
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(themes).toHaveTextContent('Coffee shops, places to eat and markets along the route.');
|
||||
expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Coffee shops, Places to eat +1');
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
await waitFor(() =>
|
||||
@@ -290,7 +332,6 @@ describe('a city walk along its places (CW-05)', () => {
|
||||
fireEvent.click(screen.getByTestId('tour-poi-categories'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Markets' }));
|
||||
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(themes).toHaveTextContent('Your own pick of categories below; a theme sets them for you.');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-012: the category field names two categories, counts the rest, and says when none is chosen', () => {
|
||||
@@ -312,12 +353,14 @@ describe('a city walk along its places (CW-05)', () => {
|
||||
|
||||
it('FE-TOUR-POI-013: a place found for a theme says which theme suggested it', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [CAFE, SPRING] });
|
||||
render(<TourPoiSearchCard planner={walkPlanner()} />);
|
||||
renderAlong(walkPlanner());
|
||||
fireEvent.click(themeButton('food'));
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const cafe = await screen.findByTestId('tour-poi-node:3');
|
||||
fireEvent.click(within(cafe).getByRole('button', { name: /Cafe Luitpold/ }));
|
||||
expect(within(cafe).getByText('Suggested for Food and local life')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('tour-poi-overlay')).getByText('Suggested for Food and local life')
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
|
||||
expect(screen.queryByText('Suggested for Food and local life')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -345,7 +388,13 @@ describe('a city walk along its places (CW-05)', () => {
|
||||
(point) => !POINTS.includes(point)
|
||||
) as TourPlannerWaypoint
|
||||
);
|
||||
expect(added[0]).toMatchObject({ name: 'Spring', openingHours: 'Mo-Su 08:00-18:00' });
|
||||
expect(added[0]).toMatchObject({
|
||||
name: 'Spring',
|
||||
openingHours: 'Mo-Su 08:00-18:00',
|
||||
poiCategory: 'drinking_water',
|
||||
});
|
||||
// PLN-03: the kind the place was found as goes with it, so its row carries the kind's icon.
|
||||
expect(added[1]).toMatchObject({ poiCategory: 'cafe' });
|
||||
expect(added[1]).not.toHaveProperty('name');
|
||||
expect(added[1]).not.toHaveProperty('openingHours');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
@@ -16,15 +16,6 @@ interface Measures {
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
/** A place taken in: which point it became, and the route's measures just before. */
|
||||
interface Inserted {
|
||||
pointId: string;
|
||||
name: string;
|
||||
before: Measures | null;
|
||||
/** How many points the tour had once the place was in; another edit after it ends the comparison. */
|
||||
pointCount: number;
|
||||
}
|
||||
|
||||
export type InsertionOutcome =
|
||||
| { kind: 'measuring'; name: string }
|
||||
| { kind: 'measured'; name: string; extraM: number; extraMinutes: number | null }
|
||||
@@ -62,7 +53,9 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
const { t } = useTranslation();
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const [afterChoice, setAfterChoice] = useState<{ poiId: string; afterId: string } | null>(null);
|
||||
const [inserted, setInserted] = useState<Inserted | null>(null);
|
||||
// Kept in the store: the place is taken in from its card over the map, and the list says what it did.
|
||||
const inserted = useTourPoiStore((state) => state.inserted);
|
||||
const setInserted = useTourPoiStore((state) => state.setInserted);
|
||||
|
||||
const afterIdFor = useCallback(
|
||||
(poi: TourPoi) =>
|
||||
@@ -85,6 +78,8 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
role: 'via',
|
||||
...(poi.name ? { name: poi.name } : {}),
|
||||
...(poi.opening_hours ? { openingHours: poi.opening_hours } : {}),
|
||||
// PLN-03: its row carries the kind's icon, as the map drew the place.
|
||||
poiCategory: poi.category,
|
||||
};
|
||||
const next = withPointAfter(planner.waypoints, afterId, point);
|
||||
if (!next) return false;
|
||||
@@ -97,10 +92,10 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
select(null);
|
||||
return true;
|
||||
},
|
||||
[afterIdFor, planner, select, t]
|
||||
[afterIdFor, planner, select, setInserted, t]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => setInserted(null), []);
|
||||
const dismiss = useCallback(() => setInserted(null), [setInserted]);
|
||||
|
||||
return {
|
||||
afterIdFor,
|
||||
@@ -117,7 +112,7 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
* now: nothing once the point is gone again (undone or removed) or the tour
|
||||
* was edited further, so the figure never describes another route.
|
||||
*/
|
||||
function outcomeOf(inserted: Inserted | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
|
||||
function outcomeOf(inserted: InsertedTourPoi | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
|
||||
if (!inserted) return null;
|
||||
const stillThere = planner.waypoints.some((point) => point.id === inserted.pointId);
|
||||
if (!stillThere || planner.waypoints.length !== inserted.pointCount) return null;
|
||||
|
||||
@@ -112,7 +112,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
controller.signal
|
||||
);
|
||||
setAnswer({ key, status: 'ready', data });
|
||||
publish(data.pois);
|
||||
publish(data.pois, theme);
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
setAnswer({ key, status: err instanceof OnlineOnlyError ? 'offline' : 'error' });
|
||||
@@ -123,7 +123,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
setLoadingKey(null);
|
||||
}
|
||||
}
|
||||
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, type]);
|
||||
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, theme, type]);
|
||||
|
||||
// Leaving the planner, or a tour closed, takes the places off the map.
|
||||
useEffect(
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { Assignment, AssignmentsMap, Day } from '../../../types';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { StationPills } from '../planner/stationParts';
|
||||
import { stationMarkNote, stationRingClass } from '../planner/stationMarks';
|
||||
import { StationPills, StationShield } from '../planner/stationParts';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { CardAction } from '../tourParts';
|
||||
@@ -102,9 +103,11 @@ function StationRow({
|
||||
data-testid="walk-station"
|
||||
className={`flex items-start gap-2.5 rounded-[10px] px-1.5 py-1.5 ${status ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{/* How much the station matters rings its number; a protection is a shield at its corner (CW-03, CW-10). */}
|
||||
<span
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-tertiary font-geist tabular-nums text-content-secondary"
|
||||
className={`relative grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-tertiary font-geist tabular-nums text-content-secondary ${stationRingClass(station.importance)}`}
|
||||
style={fs(11)}
|
||||
title={stationMarkNote(station, t) ?? undefined}
|
||||
>
|
||||
{status === 'visited' ? (
|
||||
<Check size={12} strokeWidth={2.4} />
|
||||
@@ -113,6 +116,7 @@ function StationRow({
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
{station.protected && <StationShield />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span
|
||||
|
||||
@@ -35,11 +35,10 @@ describe('walkTimeFields', () => {
|
||||
['Stays', 'at least 1 h 30 min'],
|
||||
['Planned', 'at least 2 h 15 min'],
|
||||
]);
|
||||
// No stay entered at all is not known, never nothing.
|
||||
// No stay entered at all plans the walking time alone, with no stays field to call them unknown.
|
||||
expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 3, stations: 3 })).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Stays', 'Not known yet'],
|
||||
['Planned', 'at least 45 min'],
|
||||
['Planned', '45 min'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -59,7 +58,6 @@ describe('walkTimeFields', () => {
|
||||
]);
|
||||
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual([
|
||||
['Walking', 'Not calculated'],
|
||||
['Stays', 'Not known yet'],
|
||||
['Planned', 'Not calculated'],
|
||||
]);
|
||||
});
|
||||
@@ -71,7 +69,7 @@ describe('walkTimeFields', () => {
|
||||
['Planned (Manual override)', '4 h'],
|
||||
['Computed', 'at least 2 h 15 min'],
|
||||
]);
|
||||
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 1, stations: 1 }, 60)[3]).toEqual([
|
||||
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 1, stations: 1 }, 60)[2]).toEqual([
|
||||
'Computed',
|
||||
'Not calculated',
|
||||
]);
|
||||
@@ -104,7 +102,9 @@ describe('walkTimeFields', () => {
|
||||
});
|
||||
|
||||
describe('walkStaysText', () => {
|
||||
it('says a walk without stations has no stays to wait for', () => {
|
||||
expect(walkStaysText({ stayMinutes: 0, unknownStays: 0, stations: 0 }, t)).toBe('0 min');
|
||||
it('says nothing of the stays of a walk without stations, or with none entered', () => {
|
||||
expect(walkStaysText({ stayMinutes: 0, unknownStays: 0, stations: 0 }, t)).toBeNull();
|
||||
expect(walkStaysText({ stayMinutes: 0, unknownStays: 2, stations: 2 }, t)).toBeNull();
|
||||
expect(walkStaysText({ stayMinutes: 30, unknownStays: 1, stations: 2 }, t)).toBe('at least 30 min');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { walkTimesOf, type WalkTimes } from '../planner/stationModel';
|
||||
import { someStaysUnknown, walkTimesOf, type WalkTimes } from '../planner/stationModel';
|
||||
import type { TourMetricField } from '../tourParts';
|
||||
import { formatPlannedTourDuration, plannedTotalText } from '../tourPresentation';
|
||||
|
||||
@@ -8,14 +8,16 @@ type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
type WalkStays = Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'>;
|
||||
|
||||
/**
|
||||
* A city walk's stays as said: their sum, at least the sum while a stay is not
|
||||
* entered yet, and not known while none is. A stop passed through counts as
|
||||
* the 0 entered for it, so stays all entered as 0 read "0 min".
|
||||
* A city walk's stays as said: their sum, and at least the sum while some
|
||||
* stay is not entered yet. Nothing while no stay is entered at all: the walk
|
||||
* is then planned as its walking time, and saying "unknown" at every turn
|
||||
* would only be noise. A stop passed through counts as the 0 entered for it,
|
||||
* so stays all entered as 0 read "0 min".
|
||||
*/
|
||||
export function walkStaysText(stays: WalkStays, t: Translate): string {
|
||||
if (stays.stations > 0 && stays.unknownStays === stays.stations) return t('tours.walk.station.stayUnknown');
|
||||
export function walkStaysText(stays: WalkStays, t: Translate): string | null {
|
||||
if (stays.unknownStays >= stays.stations) return null;
|
||||
const time = formatPlannedTourDuration(stays.stayMinutes);
|
||||
return stays.unknownStays > 0 ? t('tours.walk.totalAtLeast', { time }) : time;
|
||||
return someStaysUnknown(stays) ? t('tours.walk.totalAtLeast', { time }) : time;
|
||||
}
|
||||
|
||||
export interface WalkTimeFieldsInput {
|
||||
@@ -47,9 +49,10 @@ export function walkTimeFields({
|
||||
const times = walkTimesOf({ walkingMinutes, stayMinutes, unknownStays, stations });
|
||||
const computed =
|
||||
plannedTotalText({ plannedTotalMinutes: times.totalMinutes, atLeast: times.partial }, t) ?? notCalculated;
|
||||
const staysText = walkStaysText(stays, t);
|
||||
const parts: TourMetricField[] = [
|
||||
{ ...walking, value: walkingMinutes == null ? notCalculated : formatPlannedTourDuration(walkingMinutes) },
|
||||
{ label: t('tours.walk.stays'), value: walkStaysText(stays, t) },
|
||||
...(staysText ? [{ label: t('tours.walk.stays'), value: staysText }] : []),
|
||||
];
|
||||
const planned = t('tours.planner.plannedShort');
|
||||
if (manualMinutes == null) return [...parts, { label: planned, value: computed }];
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDe
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
|
||||
import { useShapingClick } from '../../components/Tours/planner/useShapingClick';
|
||||
import TourPoiOverlay from '../../components/Tours/poi/TourPoiCard';
|
||||
import { useTourPoiMarkers } from '../../components/Tours/poi/useTourPoiMarkers';
|
||||
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
|
||||
import { isRtlLanguage, useTranslation } from '../../i18n';
|
||||
@@ -261,6 +262,13 @@ export function TourPlannerMapStage({
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{/* PLN-02: the place picked on the map or in the list along the tour, taken in as a stop from here. */}
|
||||
<TourPoiOverlay
|
||||
planner={tourPlanner}
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={cardInsetEnd}
|
||||
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
|
||||
/>
|
||||
{/* "Point set", with the way back, above the bottom row and between the panels. */}
|
||||
{placedHint.shown && (
|
||||
<div
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** A place taken in as a stop: which point it became, and the route's measures just before. */
|
||||
export interface InsertedTourPoi {
|
||||
pointId: string;
|
||||
name: string;
|
||||
before: { distanceM: number; minutes: number | null } | null;
|
||||
/** How many points the tour had once the place was in; another edit after it ends the comparison. */
|
||||
pointCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02: the places found along the tour in the planner, and the one chosen.
|
||||
* The card searches and lists them; the map stage draws them and picks one
|
||||
* on a click. Nothing here is kept beyond the view.
|
||||
* The card searches and lists them; the map stage draws them and opens the
|
||||
* chosen one's card over the map, where it is taken in as a stop. What the
|
||||
* last one taken in did is kept here, so the list says it once that card has
|
||||
* closed. Nothing here is kept beyond the view.
|
||||
*/
|
||||
export const useTourPoiStore = create<{
|
||||
pois: readonly TourPoi[];
|
||||
/** CW-05: the city walk theme the places were searched for, said as each one's reason. */
|
||||
theme: WalkTheme | null;
|
||||
selectedId: string | null;
|
||||
publish: (pois: readonly TourPoi[]) => void;
|
||||
inserted: InsertedTourPoi | null;
|
||||
publish: (pois: readonly TourPoi[], theme?: WalkTheme | null) => void;
|
||||
select: (id: string | null) => void;
|
||||
setInserted: (inserted: InsertedTourPoi | null) => void;
|
||||
clear: () => void;
|
||||
}>((set) => ({
|
||||
pois: [],
|
||||
theme: null,
|
||||
selectedId: null,
|
||||
publish: (pois) => set({ pois, selectedId: null }),
|
||||
inserted: null,
|
||||
publish: (pois, theme = null) => set({ pois, theme, selectedId: null }),
|
||||
select: (selectedId) => set({ selectedId }),
|
||||
clear: () => set({ pois: [], selectedId: null }),
|
||||
setInserted: (inserted) => set({ inserted }),
|
||||
clear: () => set({ pois: [], theme: null, selectedId: null, inserted: null }),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ export class TourWaypoints {
|
||||
importance?: TTourWaypointsImportance | null;
|
||||
protected: number & Opt = 0;
|
||||
opening_hours?: string | null;
|
||||
poi_category?: string | null;
|
||||
}
|
||||
|
||||
export const TourWaypointsImportance = {
|
||||
@@ -70,5 +71,6 @@ export const TourWaypointsSchema = defineEntity({
|
||||
importance: p.enum(() => TourWaypointsImportance).nullable(),
|
||||
protected: p.integer().default(0),
|
||||
opening_hours: p.text().nullable(),
|
||||
poi_category: p.text().nullable(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,4 +19,5 @@ export interface TourWaypointsTable {
|
||||
importance: InsertOptional<string | null>;
|
||||
protected: InsertOptional<number>;
|
||||
opening_hours: InsertOptional<string | null>;
|
||||
poi_category: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* PLN-03: a point taken in from the places found along a tour keeps the kind
|
||||
* of place it was found as (a restaurant, a museum), so the planner can mark
|
||||
* its row with that kind's icon as the map drew the place. Plain text, read
|
||||
* back only where the contract knows the kind, so a kind added later needs no
|
||||
* migration. NULL on every point saved before and on a point set by hand.
|
||||
*/
|
||||
export class Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as extends Migration {
|
||||
override name = 'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as';
|
||||
|
||||
override up(): void {
|
||||
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN poi_category TEXT');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { TourWaypoints, TTourWaypointsImportance } from '../entities/TourWa
|
||||
import { TOUR_WAYPOINT_FIXED_LEG, TOUR_WAYPOINT_LOCK } from '../json-columns';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { PaddleLock, TourFixedLeg } from '@trek/shared';
|
||||
import { TOUR_POI_CATEGORIES, type PaddleLock, type TourFixedLeg, type TourPoiCategory } from '@trek/shared';
|
||||
|
||||
/** One saved routing control point of a tour, in route order. */
|
||||
export interface TourWaypointRow {
|
||||
@@ -31,6 +31,8 @@ export interface TourWaypointRow {
|
||||
protected?: boolean;
|
||||
/** CW-04: the station's opening hours as OpenStreetMap writes them; absent where there are none. */
|
||||
opening_hours?: string | null;
|
||||
/** PLN-03: the kind of place the point was taken in from along the tour; absent on a point set by hand. */
|
||||
poi_category?: TourPoiCategory | null;
|
||||
}
|
||||
|
||||
/** The row as stored, the fixed leg still JSON text. */
|
||||
@@ -49,6 +51,7 @@ interface StoredWaypoint {
|
||||
importance: TTourWaypointsImportance | null;
|
||||
protected: number;
|
||||
opening_hours: string | null;
|
||||
poi_category: string | null;
|
||||
}
|
||||
|
||||
/** The columns a waypoint is read with, as the stored row names them. */
|
||||
@@ -67,13 +70,20 @@ const WAYPOINT_COLUMNS = [
|
||||
'importance',
|
||||
'protected',
|
||||
'opening_hours',
|
||||
'poi_category',
|
||||
] as const;
|
||||
|
||||
/** A stored kind the contract knows; one it does not (dropped since) reads as none. */
|
||||
function poiCategoryOf(stored: string | null): TourPoiCategory | null {
|
||||
return TOUR_POI_CATEGORIES.find((category) => category === stored) ?? null;
|
||||
}
|
||||
|
||||
/** A stored row as callers read it: every optional part left out entirely where there is none. */
|
||||
function readWaypoint(row: StoredWaypoint): TourWaypointRow {
|
||||
const { fixed_leg, lock, shaping, linked_place_id, station_key, name, stay_minutes, importance, opening_hours } = row;
|
||||
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
|
||||
const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint');
|
||||
const poi_category = poiCategoryOf(row.poi_category);
|
||||
return {
|
||||
lat: row.lat,
|
||||
lng: row.lng,
|
||||
@@ -89,6 +99,7 @@ function readWaypoint(row: StoredWaypoint): TourWaypointRow {
|
||||
...(importance ? { importance } : {}),
|
||||
...(row.protected ? { protected: true } : {}),
|
||||
...(opening_hours ? { opening_hours } : {}),
|
||||
...(poi_category ? { poi_category } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +195,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
importance: w.importance ?? null,
|
||||
protected: w.protected ? 1 : 0,
|
||||
opening_hours: w.opening_hours ?? null,
|
||||
poi_category: w.poi_category ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from',
|
||||
'Migration20200101045200_a_tour_can_be_ridden',
|
||||
'Migration20200101045300_a_tour_can_be_a_city_walk',
|
||||
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -196,6 +196,30 @@ describe('TourWaypointsRepository', () => {
|
||||
expect(points[1]).not.toHaveProperty('name');
|
||||
expect(await repo.listForTrip(trip.id)).toEqual(points.map((point) => ({ place_id: place.id, ...point })));
|
||||
});
|
||||
it('TWREPO-011: a point taken in along the tour keeps the kind of place it was found as; a kind no longer known reads as none (PLN-03)', async () => {
|
||||
const { place } = tourInTrip();
|
||||
const restaurant: TourWaypointRow = {
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'via',
|
||||
sequence: 1,
|
||||
name: 'Café Luitpold',
|
||||
poi_category: 'restaurant',
|
||||
};
|
||||
await repo.insertForPlace(place.id, [
|
||||
{ lat: 48, lng: 11.4, role: 'start', sequence: 0 },
|
||||
restaurant,
|
||||
{ lat: 48.2, lng: 11.6, role: 'end', sequence: 2 },
|
||||
]);
|
||||
const points = await repo.listForPlace(place.id);
|
||||
expect(points[1]).toEqual(restaurant);
|
||||
expect(points[0]).not.toHaveProperty('poi_category');
|
||||
|
||||
await t.em.nativeUpdate(TourWaypoints, { place: place.id, sequence: 1 }, { poi_category: 'spaceport' });
|
||||
t.clear();
|
||||
expect((await repo.listForPlace(place.id))[1]).not.toHaveProperty('poi_category');
|
||||
});
|
||||
|
||||
it('TWREPO-010: listForPlaces reads the waypoints of the tours asked for, by place and sequence, and none for none (CW-02)', async () => {
|
||||
const { trip, place } = tourInTrip();
|
||||
const second = createPlace(testDb, trip.id);
|
||||
|
||||
@@ -363,7 +363,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'في هذا المسار محطات أكثر مما تتسع له جولة المشي.',
|
||||
'tours.provenance.takenOverFrom': 'منقولة من',
|
||||
'tours.poi.title': 'على طول الجولة',
|
||||
'tours.poi.intro': 'أماكن للتوقف قريبة من المسار. لا يتغير شيء حتى تضيف واحدًا.',
|
||||
'tours.poi.categories': 'ما الذي تبحث عنه',
|
||||
'tours.poi.within': 'ضمن هذه المسافة من المسار',
|
||||
'tours.poi.search': 'ابحث على طول الجولة',
|
||||
@@ -392,7 +391,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': 'أصبح «{name}» توقفًا الآن؛ يُعاد تخطيط المسار.',
|
||||
'tours.poi.outcome.measured': 'أصبح «{name}» توقفًا الآن. تتغير الجولة بمقدار:',
|
||||
'tours.poi.outcome.unmeasured': 'أصبح «{name}» توقفًا الآن. تعذّر حساب مقدار ما يضيفه.',
|
||||
'tours.poi.undoHint': 'التراجع في المحرر يلغيه.',
|
||||
'tours.poi.cat.viewpoint': 'نقاط مشاهدة',
|
||||
'tours.poi.cat.peak': 'قمم',
|
||||
'tours.poi.cat.shelter': 'أكواخ وملاجئ',
|
||||
@@ -526,7 +524,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'الفارغ يعني غير معروف بعد؛ 0 لنقطة تمر بها فقط.',
|
||||
'tours.walk.station.stayInvalid': 'أدخل دقائق كاملة، حتى يوم واحد.',
|
||||
'tours.walk.station.stayUnknown': 'غير معروف بعد',
|
||||
'tours.walk.station.stayUnknownShort': 'مدة غير معروفة',
|
||||
'tours.walk.station.passThrough': 'مرور فقط',
|
||||
'tours.walk.station.importance': 'الأهمية',
|
||||
'tours.walk.station.protected': 'محمية',
|
||||
@@ -568,7 +565,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'حدائق ومتنزهات ونقاط مشاهدة على طول المسار.',
|
||||
'tours.walk.themeHint.food': 'مقاهٍ ومطاعم وأسواق على طول المسار.',
|
||||
'tours.walk.themeHint.mixed': 'قليل من كل شيء: معالم ومتاحف وحدائق وإطلالات ومقاهٍ وأسواق.',
|
||||
'tours.walk.themeHint.own': 'اختيارك الخاص من الفئات أدناه؛ يضبطها الموضوع لك.',
|
||||
'tours.walk.themeReason': 'مقترح لموضوع {theme}',
|
||||
'tours.walk.arrival': 'الوصول نحو {time}',
|
||||
'tours.walk.arrivalAtLeast': 'الوصول في {time} على أقرب تقدير',
|
||||
|
||||
@@ -350,7 +350,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Bu marşrutda bir gəzintinin götürə biləcəyindən çox dayanacaq var.',
|
||||
'tours.provenance.takenOverFrom': 'Götürüldüyü yer',
|
||||
'tours.poi.title': 'Tur boyunca',
|
||||
'tours.poi.intro': 'Marşruta yaxın dayanacaq yerləri. Birini əlavə edənə qədər heç nə dəyişmir.',
|
||||
'tours.poi.categories': 'Nə axtarılsın',
|
||||
'tours.poi.within': 'Marşrutdan bu məsafədə',
|
||||
'tours.poi.search': 'Tur boyunca axtar',
|
||||
@@ -381,7 +380,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” artıq dayanacaqdır; marşrut yenidən planlaşdırılır.',
|
||||
'tours.poi.outcome.measured': '“{name}” artıq dayanacaqdır. Tur bu qədər dəyişir:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” artıq dayanacaqdır. Nə qədər əlavə etdiyi hesablana bilmədi.',
|
||||
'tours.poi.undoHint': 'Redaktordakı geri al bunu ləğv edir.',
|
||||
'tours.poi.cat.viewpoint': 'Mənzərə nöqtələri',
|
||||
'tours.poi.cat.peak': 'Zirvələr',
|
||||
'tours.poi.cat.shelter': 'Daxmalar və sığınacaqlar',
|
||||
@@ -518,7 +516,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Boş hələ məlum deyil deməkdir; yalnız keçdiyin nöqtə üçün 0.',
|
||||
'tours.walk.station.stayInvalid': 'Tam dəqiqə daxil et, ən çox bir gün.',
|
||||
'tours.walk.station.stayUnknown': 'Hələ məlum deyil',
|
||||
'tours.walk.station.stayUnknownShort': 'Müddət bilinmir',
|
||||
'tours.walk.station.passThrough': 'Keçid',
|
||||
'tours.walk.station.importance': 'Əhəmiyyət',
|
||||
'tours.walk.station.protected': 'Qorunur',
|
||||
@@ -566,7 +563,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.food': 'Marşrut boyunca kafelər, yemək yerləri və bazarlar.',
|
||||
'tours.walk.themeHint.mixed':
|
||||
'Hər şeydən bir az: görməli yerlər, muzeylər, parklar, mənzərələr, kafelər və bazarlar.',
|
||||
'tours.walk.themeHint.own': 'Aşağıda öz kateqoriya seçimin; mövzu onları sənin üçün qurur.',
|
||||
'tours.walk.themeReason': '{theme} üçün təklif olunur',
|
||||
'tours.walk.arrival': 'Təxmini çatma {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Ən tez {time} çatma',
|
||||
|
||||
@@ -356,7 +356,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Essa rota tem mais paradas do que um passeio comporta.',
|
||||
'tours.provenance.takenOverFrom': 'Trazido de',
|
||||
'tours.poi.title': 'Ao longo do passeio',
|
||||
'tours.poi.intro': 'Lugares para uma parada, perto da rota. Nada muda até você adicionar um.',
|
||||
'tours.poi.categories': 'O que procurar',
|
||||
'tours.poi.within': 'Até esta distância da rota',
|
||||
'tours.poi.search': 'Buscar ao longo do passeio',
|
||||
@@ -387,7 +386,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” agora é uma parada; a rota está sendo planejada de novo.',
|
||||
'tours.poi.outcome.measured': '“{name}” agora é uma parada. O passeio muda em:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” agora é uma parada. Não foi possível calcular quanto ela acrescenta.',
|
||||
'tours.poi.undoHint': 'Desfazer no editor reverte isso.',
|
||||
'tours.poi.cat.viewpoint': 'Mirantes',
|
||||
'tours.poi.cat.peak': 'Cumes',
|
||||
'tours.poi.cat.shelter': 'Cabanas e abrigos',
|
||||
@@ -526,7 +524,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Vazio significa ainda desconhecido; 0 para um ponto pelo qual você só passa.',
|
||||
'tours.walk.station.stayInvalid': 'Informe minutos inteiros, até um dia.',
|
||||
'tours.walk.station.stayUnknown': 'Ainda desconhecido',
|
||||
'tours.walk.station.stayUnknownShort': 'Permanência em aberto',
|
||||
'tours.walk.station.passThrough': 'Só de passagem',
|
||||
'tours.walk.station.importance': 'Importância',
|
||||
'tours.walk.station.protected': 'Protegida',
|
||||
@@ -572,7 +569,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parques, jardins e mirantes ao longo da rota.',
|
||||
'tours.walk.themeHint.food': 'Cafeterias, lugares para comer e feiras ao longo da rota.',
|
||||
'tours.walk.themeHint.mixed': 'Um pouco de cada: pontos turísticos, museus, parques, vistas, cafeterias e feiras.',
|
||||
'tours.walk.themeHint.own': 'Sua própria seleção de categorias abaixo; um tema as define para você.',
|
||||
'tours.walk.themeReason': 'Sugerido para {theme}',
|
||||
'tours.walk.arrival': 'Chegada por volta de {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Chegada a partir de {time}',
|
||||
|
||||
@@ -357,7 +357,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Aquesta ruta té més parades de les que admet un passeig.',
|
||||
'tours.provenance.takenOverFrom': 'Agafada de',
|
||||
'tours.poi.title': 'Al llarg de la ruta',
|
||||
'tours.poi.intro': "Llocs per fer una parada, a prop de la ruta. No canvia res fins que n'afegeixis un.",
|
||||
'tours.poi.categories': 'Què cercar',
|
||||
'tours.poi.within': "Dins d'aquesta distància de la ruta",
|
||||
'tours.poi.search': 'Cerca al llarg de la ruta',
|
||||
@@ -388,7 +387,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': "«{name}» ara és una parada; s'està tornant a planificar la ruta.",
|
||||
'tours.poi.outcome.measured': '«{name}» ara és una parada. La ruta canvia en:',
|
||||
'tours.poi.outcome.unmeasured': "«{name}» ara és una parada. No s'ha pogut calcular quant hi afegeix.",
|
||||
'tours.poi.undoHint': "Desfer a l'editor ho anul·la.",
|
||||
'tours.poi.cat.viewpoint': 'Miradors',
|
||||
'tours.poi.cat.peak': 'Cims',
|
||||
'tours.poi.cat.shelter': 'Refugis i aixoplucs',
|
||||
@@ -527,7 +525,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Buit vol dir encara desconegut; 0 per a un punt pel qual només passes.',
|
||||
'tours.walk.station.stayInvalid': 'Introdueix minuts sencers, fins a un dia.',
|
||||
'tours.walk.station.stayUnknown': 'Encara desconegut',
|
||||
'tours.walk.station.stayUnknownShort': 'Estada oberta',
|
||||
'tours.walk.station.passThrough': 'Només de pas',
|
||||
'tours.walk.station.importance': 'Importància',
|
||||
'tours.walk.station.protected': 'Protegida',
|
||||
@@ -573,7 +570,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parcs, jardins i miradors al llarg de la ruta.',
|
||||
'tours.walk.themeHint.food': 'Cafeteries, llocs per menjar i mercats al llarg de la ruta.',
|
||||
'tours.walk.themeHint.mixed': "Una mica de cada: llocs d'interès, museus, parcs, vistes, cafeteries i mercats.",
|
||||
'tours.walk.themeHint.own': 'La teva pròpia tria de categories a sota; un tema te les defineix.',
|
||||
'tours.walk.themeReason': 'Suggerit per a {theme}',
|
||||
'tours.walk.arrival': 'Arribada cap a les {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Arribada com a aviat a les {time}',
|
||||
|
||||
@@ -350,7 +350,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Tato trasa má víc zastávek, než procházka pojme.',
|
||||
'tours.provenance.takenOverFrom': 'Převzato z',
|
||||
'tours.poi.title': 'Podél túry',
|
||||
'tours.poi.intro': 'Místa na zastávku blízko trasy. Nic se nezmění, dokud nějaké nepřidáš.',
|
||||
'tours.poi.categories': 'Co hledat',
|
||||
'tours.poi.within': 'Do této vzdálenosti od trasy',
|
||||
'tours.poi.search': 'Hledat podél túry',
|
||||
@@ -380,7 +379,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}“ je teď zastávka; trasa se plánuje znovu.',
|
||||
'tours.poi.outcome.measured': '„{name}“ je teď zastávka. Túra se mění o:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}“ je teď zastávka. Kolik přidává, se nepodařilo spočítat.',
|
||||
'tours.poi.undoHint': 'Zpět v editoru to vrátí.',
|
||||
'tours.poi.cat.viewpoint': 'Vyhlídky',
|
||||
'tours.poi.cat.peak': 'Vrcholy',
|
||||
'tours.poi.cat.shelter': 'Chaty a přístřešky',
|
||||
@@ -516,7 +514,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Prázdné znamená zatím neznámé; 0 pro místo, kterým jen projdeš.',
|
||||
'tours.walk.station.stayInvalid': 'Zadej celé minuty, nejvýše jeden den.',
|
||||
'tours.walk.station.stayUnknown': 'Zatím neznámé',
|
||||
'tours.walk.station.stayUnknownShort': 'Pobyt neznámý',
|
||||
'tours.walk.station.passThrough': 'Jen průchod',
|
||||
'tours.walk.station.importance': 'Důležitost',
|
||||
'tours.walk.station.protected': 'Chráněná',
|
||||
@@ -562,7 +559,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parky, zahrady a vyhlídky podél trasy.',
|
||||
'tours.walk.themeHint.food': 'Kavárny, místa k jídlu a trhy podél trasy.',
|
||||
'tours.walk.themeHint.mixed': 'Od všeho trochu: památky, muzea, parky, výhledy, kavárny a trhy.',
|
||||
'tours.walk.themeHint.own': 'Tvůj vlastní výběr kategorií níže; téma je nastaví za tebe.',
|
||||
'tours.walk.themeReason': 'Navrženo pro {theme}',
|
||||
'tours.walk.arrival': 'Příchod kolem {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Příchod nejdříve {time}',
|
||||
|
||||
@@ -359,7 +359,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Diese Route hat mehr Stationen, als ein Walk aufnehmen kann.',
|
||||
'tours.provenance.takenOverFrom': 'Übernommen aus',
|
||||
'tours.poi.title': 'Entlang der Tour',
|
||||
'tours.poi.intro': 'Orte für einen Stopp, nah an der Route. Es ändert sich nichts, bis du einen hinzufügst.',
|
||||
'tours.poi.categories': 'Wonach suchen',
|
||||
'tours.poi.within': 'In diesem Abstand zur Route',
|
||||
'tours.poi.search': 'Entlang der Tour suchen',
|
||||
@@ -390,7 +389,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}“ ist jetzt ein Stopp; die Route wird neu geplant.',
|
||||
'tours.poi.outcome.measured': '„{name}“ ist jetzt ein Stopp. Die Tour ändert sich um:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}“ ist jetzt ein Stopp. Wie viel er hinzufügt, ließ sich nicht berechnen.',
|
||||
'tours.poi.undoHint': 'Rückgängig im Editor nimmt es zurück.',
|
||||
'tours.poi.cat.viewpoint': 'Aussichtspunkte',
|
||||
'tours.poi.cat.peak': 'Gipfel',
|
||||
'tours.poi.cat.shelter': 'Hütten und Unterstände',
|
||||
@@ -529,7 +527,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Leer heißt noch unbekannt; 0 für einen Punkt, den du nur passierst.',
|
||||
'tours.walk.station.stayInvalid': 'Ganze Minuten eingeben, höchstens einen Tag.',
|
||||
'tours.walk.station.stayUnknown': 'Noch unbekannt',
|
||||
'tours.walk.station.stayUnknownShort': 'Aufenthalt offen',
|
||||
'tours.walk.station.passThrough': 'Durchgang',
|
||||
'tours.walk.station.importance': 'Wichtigkeit',
|
||||
'tours.walk.station.protected': 'Geschützt',
|
||||
@@ -577,7 +574,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parks, Gärten und Aussichtspunkte entlang der Route.',
|
||||
'tours.walk.themeHint.food': 'Cafés, Gaststätten und Märkte entlang der Route.',
|
||||
'tours.walk.themeHint.mixed': 'Von allem etwas: Sehenswürdigkeiten, Museen, Parks, Aussicht, Cafés und Märkte.',
|
||||
'tours.walk.themeHint.own': 'Deine eigene Auswahl darunter; ein Thema stellt sie für dich zusammen.',
|
||||
'tours.walk.themeReason': 'Vorgeschlagen für {theme}',
|
||||
'tours.walk.arrival': 'Ankunft ca. {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Ankunft frühestens {time}',
|
||||
|
||||
@@ -349,7 +349,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'That route has more stops than a walk can take.',
|
||||
'tours.provenance.takenOverFrom': 'Taken over from',
|
||||
'tours.poi.title': 'Along the tour',
|
||||
'tours.poi.intro': 'Places for a stop, close to the route. Nothing changes until you add one.',
|
||||
'tours.poi.categories': 'What to look for',
|
||||
'tours.poi.within': 'Within this distance of the route',
|
||||
'tours.poi.search': 'Search along the tour',
|
||||
@@ -380,7 +379,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” is a stop now; the route is being planned again.',
|
||||
'tours.poi.outcome.measured': '“{name}” is a stop now. The tour changes by:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” is a stop now. How much it adds could not be worked out.',
|
||||
'tours.poi.undoHint': 'Undo in the editor takes it back.',
|
||||
'tours.poi.cat.viewpoint': 'Viewpoints',
|
||||
'tours.poi.cat.peak': 'Summits',
|
||||
'tours.poi.cat.shelter': 'Huts and shelters',
|
||||
@@ -517,7 +515,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Empty means not known yet; 0 for a point you only pass.',
|
||||
'tours.walk.station.stayInvalid': 'Enter whole minutes, up to a day.',
|
||||
'tours.walk.station.stayUnknown': 'Not known yet',
|
||||
'tours.walk.station.stayUnknownShort': 'Stay unknown',
|
||||
'tours.walk.station.passThrough': 'Passing through',
|
||||
'tours.walk.station.importance': 'Importance',
|
||||
'tours.walk.station.protected': 'Protected',
|
||||
@@ -563,7 +560,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parks, gardens and viewpoints along the route.',
|
||||
'tours.walk.themeHint.food': 'Coffee shops, places to eat and markets along the route.',
|
||||
'tours.walk.themeHint.mixed': 'Some of each: sights, museums, parks, views, coffee shops and markets.',
|
||||
'tours.walk.themeHint.own': 'Your own pick of categories below; a theme sets them for you.',
|
||||
'tours.walk.themeReason': 'Suggested for {theme}',
|
||||
'tours.walk.arrival': 'Arrival about {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Arrival {time} at the earliest',
|
||||
|
||||
@@ -356,7 +356,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Esa ruta tiene más paradas de las que admite un paseo.',
|
||||
'tours.provenance.takenOverFrom': 'Traída de',
|
||||
'tours.poi.title': 'A lo largo de la ruta',
|
||||
'tours.poi.intro': 'Lugares para una parada, cerca de la ruta. Nada cambia hasta que añadas uno.',
|
||||
'tours.poi.categories': 'Qué buscar',
|
||||
'tours.poi.within': 'A esta distancia de la ruta',
|
||||
'tours.poi.search': 'Buscar a lo largo de la ruta',
|
||||
@@ -387,7 +386,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '«{name}» ya es una parada; la ruta se está planificando de nuevo.',
|
||||
'tours.poi.outcome.measured': '«{name}» ya es una parada. La ruta cambia en:',
|
||||
'tours.poi.outcome.unmeasured': '«{name}» ya es una parada. No se pudo calcular cuánto añade.',
|
||||
'tours.poi.undoHint': 'Deshacer en el editor lo revierte.',
|
||||
'tours.poi.cat.viewpoint': 'Miradores',
|
||||
'tours.poi.cat.peak': 'Cumbres',
|
||||
'tours.poi.cat.shelter': 'Refugios y cobertizos',
|
||||
@@ -525,7 +523,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Vacío significa aún desconocido; 0 para un punto por el que solo pasas.',
|
||||
'tours.walk.station.stayInvalid': 'Introduce minutos enteros, hasta un día.',
|
||||
'tours.walk.station.stayUnknown': 'Aún desconocido',
|
||||
'tours.walk.station.stayUnknownShort': 'Estancia abierta',
|
||||
'tours.walk.station.passThrough': 'Solo de paso',
|
||||
'tours.walk.station.importance': 'Importancia',
|
||||
'tours.walk.station.protected': 'Protegida',
|
||||
@@ -571,7 +568,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parques, jardines y miradores a lo largo de la ruta.',
|
||||
'tours.walk.themeHint.food': 'Cafeterías, sitios para comer y mercados a lo largo de la ruta.',
|
||||
'tours.walk.themeHint.mixed': 'Un poco de cada: lugares de interés, museos, parques, vistas, cafeterías y mercados.',
|
||||
'tours.walk.themeHint.own': 'Tu propia selección de categorías abajo; un tema las elige por ti.',
|
||||
'tours.walk.themeReason': 'Sugerido para {theme}',
|
||||
'tours.walk.arrival': 'Llegada hacia las {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Llegada no antes de las {time}',
|
||||
|
||||
@@ -351,7 +351,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Sellel marsruudil on rohkem peatusi, kui jalutuskäiku mahub.',
|
||||
'tours.provenance.takenOverFrom': 'Üle võetud',
|
||||
'tours.poi.title': 'Matka ääres',
|
||||
'tours.poi.intro': 'Peatuskohad marsruudi lähedal. Midagi ei muutu enne, kui mõne lisad.',
|
||||
'tours.poi.categories': 'Mida otsida',
|
||||
'tours.poi.within': 'Selle kaugusega marsruudist',
|
||||
'tours.poi.search': 'Otsi matka äärest',
|
||||
@@ -382,7 +381,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}“ on nüüd peatus; marsruuti planeeritakse uuesti.',
|
||||
'tours.poi.outcome.measured': '„{name}“ on nüüd peatus. Matk muutub nii palju:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}“ on nüüd peatus. Kui palju see lisab, ei õnnestunud arvutada.',
|
||||
'tours.poi.undoHint': 'Võta toimeta redaktoris tühistab selle.',
|
||||
'tours.poi.cat.viewpoint': 'Vaatekohad',
|
||||
'tours.poi.cat.peak': 'Tipud',
|
||||
'tours.poi.cat.shelter': 'Onnid ja varjualused',
|
||||
@@ -518,7 +516,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Tühi tähendab veel teadmata; 0 punkti jaoks, millest ainult möödud.',
|
||||
'tours.walk.station.stayInvalid': 'Sisesta täisminutid, kuni üks päev.',
|
||||
'tours.walk.station.stayUnknown': 'Veel teadmata',
|
||||
'tours.walk.station.stayUnknownShort': 'Peatus teadmata',
|
||||
'tours.walk.station.passThrough': 'Läbisõit',
|
||||
'tours.walk.station.importance': 'Tähtsus',
|
||||
'tours.walk.station.protected': 'Kaitstud',
|
||||
@@ -564,7 +561,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Pargid, aiad ja vaatekohad marsruudi ääres.',
|
||||
'tours.walk.themeHint.food': 'Kohvikud, söögikohad ja turud marsruudi ääres.',
|
||||
'tours.walk.themeHint.mixed': 'Igaühest natuke: vaatamisväärsused, muuseumid, pargid, vaated, kohvikud ja turud.',
|
||||
'tours.walk.themeHint.own': 'Sinu enda kategooriate valik allpool; teema seab need sinu eest.',
|
||||
'tours.walk.themeReason': 'Soovitatud teemale {theme}',
|
||||
'tours.walk.arrival': 'Saabumine umbes {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Saabumine kõige varem {time}',
|
||||
|
||||
@@ -362,7 +362,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': "Cet itinéraire compte plus d'étapes qu'une balade ne peut en prendre.",
|
||||
'tours.provenance.takenOverFrom': 'Repris de',
|
||||
'tours.poi.title': "Le long de l'itinéraire",
|
||||
'tours.poi.intro': "Des lieux où s'arrêter, près de l'itinéraire. Rien ne change tant que tu n'en ajoutes pas un.",
|
||||
'tours.poi.categories': 'Quoi chercher',
|
||||
'tours.poi.within': "À cette distance de l'itinéraire",
|
||||
'tours.poi.search': "Chercher le long de l'itinéraire",
|
||||
@@ -393,7 +392,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': "« {name} » est maintenant un arrêt ; l'itinéraire est recalculé.",
|
||||
'tours.poi.outcome.measured': "« {name} » est maintenant un arrêt. L'itinéraire change de :",
|
||||
'tours.poi.outcome.unmeasured': "« {name} » est maintenant un arrêt. Ce qu'il ajoute n'a pas pu être calculé.",
|
||||
'tours.poi.undoHint': "Annuler dans l'éditeur le retire.",
|
||||
'tours.poi.cat.viewpoint': 'Points de vue',
|
||||
'tours.poi.cat.peak': 'Sommets',
|
||||
'tours.poi.cat.shelter': 'Refuges et abris',
|
||||
@@ -533,7 +531,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Vide signifie encore inconnu ; 0 pour un point où tu ne fais que passer.',
|
||||
'tours.walk.station.stayInvalid': "Saisis des minutes entières, jusqu'à une journée.",
|
||||
'tours.walk.station.stayUnknown': 'Encore inconnu',
|
||||
'tours.walk.station.stayUnknownShort': 'Arrêt inconnu',
|
||||
'tours.walk.station.passThrough': 'Simple passage',
|
||||
'tours.walk.station.importance': "Importance de l'étape",
|
||||
'tours.walk.station.protected': 'Protégée',
|
||||
@@ -580,7 +577,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parcs, jardins et points de vue le long du parcours.',
|
||||
'tours.walk.themeHint.food': 'Cafés, restaurants et marchés le long du parcours.',
|
||||
'tours.walk.themeHint.mixed': 'Un peu de chaque : sites, musées, parcs, points de vue, cafés et marchés.',
|
||||
'tours.walk.themeHint.own': 'Ta propre sélection de catégories ci-dessous ; un thème les choisit pour toi.',
|
||||
'tours.walk.themeReason': 'Suggéré pour {theme}',
|
||||
'tours.walk.arrival': 'Arrivée vers {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Arrivée au plus tôt à {time}',
|
||||
|
||||
@@ -364,7 +364,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Αυτή η διαδρομή έχει περισσότερες στάσεις απ’ όσες χωράει μια βόλτα.',
|
||||
'tours.provenance.takenOverFrom': 'Μεταφέρθηκε από',
|
||||
'tours.poi.title': 'Κατά μήκος της διαδρομής',
|
||||
'tours.poi.intro': 'Μέρη για στάση κοντά στη διαδρομή. Τίποτα δεν αλλάζει μέχρι να προσθέσεις κάποιο.',
|
||||
'tours.poi.categories': 'Τι να αναζητηθεί',
|
||||
'tours.poi.within': 'Σε αυτή την απόσταση από τη διαδρομή',
|
||||
'tours.poi.search': 'Αναζήτηση κατά μήκος της διαδρομής',
|
||||
@@ -395,7 +394,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': 'Το «{name}» είναι τώρα στάση· η διαδρομή σχεδιάζεται ξανά.',
|
||||
'tours.poi.outcome.measured': 'Το «{name}» είναι τώρα στάση. Η διαδρομή αλλάζει κατά:',
|
||||
'tours.poi.outcome.unmeasured': 'Το «{name}» είναι τώρα στάση. Δεν ήταν δυνατό να υπολογιστεί πόσο προσθέτει.',
|
||||
'tours.poi.undoHint': 'Η αναίρεση στον επεξεργαστή το επαναφέρει.',
|
||||
'tours.poi.cat.viewpoint': 'Σημεία θέας',
|
||||
'tours.poi.cat.peak': 'Κορυφές',
|
||||
'tours.poi.cat.shelter': 'Καταφύγια και στέγαστρα',
|
||||
@@ -535,7 +533,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Κενό σημαίνει άγνωστο ακόμη· 0 για σημείο από το οποίο απλώς περνάς.',
|
||||
'tours.walk.station.stayInvalid': 'Δώσε ακέραια λεπτά, έως μία ημέρα.',
|
||||
'tours.walk.station.stayUnknown': 'Άγνωστο ακόμη',
|
||||
'tours.walk.station.stayUnknownShort': 'Παραμονή άγνωστη',
|
||||
'tours.walk.station.passThrough': 'Απλή διέλευση',
|
||||
'tours.walk.station.importance': 'Σημασία',
|
||||
'tours.walk.station.protected': 'Προστατευμένη',
|
||||
@@ -582,7 +579,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Πάρκα, κήποι και σημεία θέας κατά μήκος της διαδρομής.',
|
||||
'tours.walk.themeHint.food': 'Καφετέριες, μέρη για φαγητό και αγορές κατά μήκος της διαδρομής.',
|
||||
'tours.walk.themeHint.mixed': "Λίγο απ' όλα: αξιοθέατα, μουσεία, πάρκα, θέα, καφετέριες και αγορές.",
|
||||
'tours.walk.themeHint.own': 'Η δική σου επιλογή κατηγοριών παρακάτω· ένα θέμα τις ορίζει για σένα.',
|
||||
'tours.walk.themeReason': 'Προτείνεται για {theme}',
|
||||
'tours.walk.arrival': 'Άφιξη περίπου {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Άφιξη το νωρίτερο {time}',
|
||||
|
||||
@@ -357,7 +357,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Ennek az útvonalnak több megállója van, mint amennyit egy séta elbír.',
|
||||
'tours.provenance.takenOverFrom': 'Átvéve innen',
|
||||
'tours.poi.title': 'A túra mentén',
|
||||
'tours.poi.intro': 'Megállóhelyek az útvonal közelében. Semmi sem változik, amíg hozzá nem adsz egyet.',
|
||||
'tours.poi.categories': 'Mit keress',
|
||||
'tours.poi.within': 'Ezen a távolságon belül az útvonaltól',
|
||||
'tours.poi.search': 'Keresés a túra mentén',
|
||||
@@ -387,7 +386,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}” most már megálló; az útvonal újratervezése folyik.',
|
||||
'tours.poi.outcome.measured': '„{name}” most már megálló. A túra ennyivel változik:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}” most már megálló. Nem sikerült kiszámolni, mennyit tesz hozzá.',
|
||||
'tours.poi.undoHint': 'A szerkesztőben a visszavonás visszaállítja.',
|
||||
'tours.poi.cat.viewpoint': 'Kilátópontok',
|
||||
'tours.poi.cat.peak': 'Csúcsok',
|
||||
'tours.poi.cat.shelter': 'Kunyhók és menedékek',
|
||||
@@ -525,7 +523,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Üres: még nem ismert; 0 egy pontnál, amelyen csak áthaladsz.',
|
||||
'tours.walk.station.stayInvalid': 'Egész perceket adj meg, legfeljebb egy napot.',
|
||||
'tours.walk.station.stayUnknown': 'Még nem ismert',
|
||||
'tours.walk.station.stayUnknownShort': 'Tartózkodás nyitott',
|
||||
'tours.walk.station.passThrough': 'Csak áthaladás',
|
||||
'tours.walk.station.importance': 'Fontosság',
|
||||
'tours.walk.station.protected': 'Védett',
|
||||
@@ -572,7 +569,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parkok, kertek és kilátópontok az útvonal mentén.',
|
||||
'tours.walk.themeHint.food': 'Kávézók, étkezőhelyek és piacok az útvonal mentén.',
|
||||
'tours.walk.themeHint.mixed': 'Mindenből egy kicsit: látnivalók, múzeumok, parkok, kilátás, kávézók és piacok.',
|
||||
'tours.walk.themeHint.own': 'Saját kategóriaválasztásod lent; egy téma beállítja helyetted.',
|
||||
'tours.walk.themeReason': 'Javasolva: {theme}',
|
||||
'tours.walk.arrival': 'Érkezés kb. {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Érkezés legkorábban {time}',
|
||||
|
||||
@@ -351,7 +351,6 @@ const tours: TranslationStrings = {
|
||||
'Rute ini punya lebih banyak titik singgah daripada yang muat dalam satu rute jalan kaki.',
|
||||
'tours.provenance.takenOverFrom': 'Diambil dari',
|
||||
'tours.poi.title': 'Di sepanjang tur',
|
||||
'tours.poi.intro': 'Tempat untuk berhenti, dekat rute. Tidak ada yang berubah sampai kamu menambahkannya.',
|
||||
'tours.poi.categories': 'Apa yang dicari',
|
||||
'tours.poi.within': 'Dalam jarak ini dari rute',
|
||||
'tours.poi.search': 'Cari di sepanjang tur',
|
||||
@@ -382,7 +381,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” kini jadi pemberhentian; rute sedang direncanakan ulang.',
|
||||
'tours.poi.outcome.measured': '“{name}” kini jadi pemberhentian. Tur berubah sebesar:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” kini jadi pemberhentian. Seberapa banyak tambahannya tidak dapat dihitung.',
|
||||
'tours.poi.undoHint': 'Urungkan di editor akan membatalkannya.',
|
||||
'tours.poi.cat.viewpoint': 'Titik pandang',
|
||||
'tours.poi.cat.peak': 'Puncak',
|
||||
'tours.poi.cat.shelter': 'Pondok dan tempat berteduh',
|
||||
@@ -519,7 +517,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Kosong berarti belum diketahui; 0 untuk titik yang hanya kamu lewati.',
|
||||
'tours.walk.station.stayInvalid': 'Masukkan menit bulat, paling lama satu hari.',
|
||||
'tours.walk.station.stayUnknown': 'Belum diketahui',
|
||||
'tours.walk.station.stayUnknownShort': 'Lama singgah belum ada',
|
||||
'tours.walk.station.passThrough': 'Hanya lewat',
|
||||
'tours.walk.station.importance': 'Tingkat penting',
|
||||
'tours.walk.station.protected': 'Dilindungi',
|
||||
@@ -567,7 +564,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.food': 'Kedai kopi, tempat makan, dan pasar di sepanjang rute.',
|
||||
'tours.walk.themeHint.mixed':
|
||||
'Sedikit dari semuanya: tempat wisata, museum, taman, pemandangan, kedai kopi, dan pasar.',
|
||||
'tours.walk.themeHint.own': 'Pilihan kategorimu sendiri di bawah; tema mengaturnya untukmu.',
|
||||
'tours.walk.themeReason': 'Disarankan untuk {theme}',
|
||||
'tours.walk.arrival': 'Tiba sekitar {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Tiba paling cepat {time}',
|
||||
|
||||
@@ -359,7 +359,6 @@ const tours: TranslationStrings = {
|
||||
'Questo percorso ha più tappe di quante una passeggiata ne possa contenere.',
|
||||
'tours.provenance.takenOverFrom': 'Ripreso da',
|
||||
'tours.poi.title': 'Lungo il percorso',
|
||||
'tours.poi.intro': 'Luoghi per una sosta, vicino al percorso. Nulla cambia finché non ne aggiungi uno.',
|
||||
'tours.poi.categories': 'Cosa cercare',
|
||||
'tours.poi.within': 'Entro questa distanza dal percorso',
|
||||
'tours.poi.search': 'Cerca lungo il percorso',
|
||||
@@ -390,7 +389,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” ora è una sosta; il percorso viene ripianificato.',
|
||||
'tours.poi.outcome.measured': '“{name}” ora è una sosta. Il percorso cambia di:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” ora è una sosta. Non è stato possibile calcolare quanto aggiunge.',
|
||||
'tours.poi.undoHint': "Annulla nell'editor lo ripristina.",
|
||||
'tours.poi.cat.viewpoint': 'Punti panoramici',
|
||||
'tours.poi.cat.peak': 'Vette',
|
||||
'tours.poi.cat.shelter': 'Rifugi e ripari',
|
||||
@@ -527,7 +525,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Vuoto significa ancora sconosciuto; 0 per un punto da cui passi soltanto.',
|
||||
'tours.walk.station.stayInvalid': 'Inserisci minuti interi, fino a un giorno.',
|
||||
'tours.walk.station.stayUnknown': 'Ancora sconosciuto',
|
||||
'tours.walk.station.stayUnknownShort': 'Sosta da definire',
|
||||
'tours.walk.station.passThrough': 'Solo di passaggio',
|
||||
'tours.walk.station.importance': 'Importanza',
|
||||
'tours.walk.station.protected': 'Protetta',
|
||||
@@ -573,7 +570,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parchi, giardini e punti panoramici lungo il percorso.',
|
||||
'tours.walk.themeHint.food': 'Caffetterie, posti dove mangiare e mercati lungo il percorso.',
|
||||
'tours.walk.themeHint.mixed': "Un po' di tutto: attrazioni, musei, parchi, panorami, caffetterie e mercati.",
|
||||
'tours.walk.themeHint.own': 'La tua scelta di categorie qui sotto; un tema le imposta per te.',
|
||||
'tours.walk.themeReason': 'Suggerito per {theme}',
|
||||
'tours.walk.arrival': 'Arrivo verso le {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Arrivo non prima delle {time}',
|
||||
|
||||
@@ -341,7 +341,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'このルートの立ち寄り先は、街歩きに入る数を超えています。',
|
||||
'tours.provenance.takenOverFrom': '取り込み元',
|
||||
'tours.poi.title': 'ツアー沿い',
|
||||
'tours.poi.intro': 'ルートの近くにある立ち寄り先です。追加するまで何も変わりません。',
|
||||
'tours.poi.categories': '探すもの',
|
||||
'tours.poi.within': 'ルートからこの距離以内',
|
||||
'tours.poi.search': 'ツアー沿いを検索',
|
||||
@@ -372,7 +371,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '「{name}」を立ち寄り地点にしました。ルートを再計算しています。',
|
||||
'tours.poi.outcome.measured': '「{name}」を立ち寄り地点にしました。ツアーの変化:',
|
||||
'tours.poi.outcome.unmeasured': '「{name}」を立ち寄り地点にしました。どれだけ増えるかは計算できませんでした。',
|
||||
'tours.poi.undoHint': 'エディターの「元に戻す」で取り消せます。',
|
||||
'tours.poi.cat.viewpoint': '展望地',
|
||||
'tours.poi.cat.peak': '山頂',
|
||||
'tours.poi.cat.shelter': '山小屋・避難小屋',
|
||||
@@ -507,7 +505,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': '空欄はまだ不明、通過するだけの地点は 0。',
|
||||
'tours.walk.station.stayInvalid': '整数の分で入力してください(最大1日)。',
|
||||
'tours.walk.station.stayUnknown': 'まだ不明',
|
||||
'tours.walk.station.stayUnknownShort': '滞在時間未定',
|
||||
'tours.walk.station.passThrough': '通過のみ',
|
||||
'tours.walk.station.importance': '重要度',
|
||||
'tours.walk.station.protected': '保護',
|
||||
@@ -554,7 +551,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'ルート沿いの公園、庭園、展望スポット。',
|
||||
'tours.walk.themeHint.food': 'ルート沿いのカフェ、食事処、市場。',
|
||||
'tours.walk.themeHint.mixed': '観光名所、美術館・博物館、公園、眺望、カフェ、市場を少しずつ。',
|
||||
'tours.walk.themeHint.own': '下のカテゴリーは自分で選択中。テーマを選ぶと自動で設定されます。',
|
||||
'tours.walk.themeReason': '{theme} のおすすめ',
|
||||
'tours.walk.arrival': '到着 {time} ごろ',
|
||||
'tours.walk.arrivalAtLeast': '到着は早くても {time}',
|
||||
|
||||
@@ -340,7 +340,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': '이 경로에는 산책 하나에 담을 수 있는 것보다 많은 정류지가 있습니다.',
|
||||
'tours.provenance.takenOverFrom': '가져온 곳',
|
||||
'tours.poi.title': '투어 경로 주변',
|
||||
'tours.poi.intro': '경로 근처에 머물 만한 곳입니다. 추가하기 전에는 아무것도 바뀌지 않습니다.',
|
||||
'tours.poi.categories': '찾을 항목',
|
||||
'tours.poi.within': '경로에서 이 거리 이내',
|
||||
'tours.poi.search': '투어 경로 주변 검색',
|
||||
@@ -370,7 +369,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}”이(가) 정차 지점이 되었습니다. 경로를 다시 계산하는 중입니다.',
|
||||
'tours.poi.outcome.measured': '“{name}”이(가) 정차 지점이 되었습니다. 투어 변화:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}”이(가) 정차 지점이 되었습니다. 얼마나 늘어나는지 계산하지 못했습니다.',
|
||||
'tours.poi.undoHint': '편집기에서 실행 취소하면 되돌릴 수 있습니다.',
|
||||
'tours.poi.cat.viewpoint': '전망대',
|
||||
'tours.poi.cat.peak': '정상',
|
||||
'tours.poi.cat.shelter': '산장과 대피소',
|
||||
@@ -505,7 +503,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': '비워 두면 아직 모름, 지나가기만 하는 곳은 0.',
|
||||
'tours.walk.station.stayInvalid': '정수 분으로 입력하세요(최대 하루).',
|
||||
'tours.walk.station.stayUnknown': '아직 모름',
|
||||
'tours.walk.station.stayUnknownShort': '머무는 시간 미정',
|
||||
'tours.walk.station.passThrough': '지나가기만',
|
||||
'tours.walk.station.importance': '중요도',
|
||||
'tours.walk.station.protected': '보호됨',
|
||||
@@ -550,7 +547,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': '경로를 따라 있는 공원, 정원, 전망 명소.',
|
||||
'tours.walk.themeHint.food': '경로를 따라 있는 카페, 식당, 시장.',
|
||||
'tours.walk.themeHint.mixed': '명소, 박물관, 공원, 전망, 카페, 시장을 골고루.',
|
||||
'tours.walk.themeHint.own': '아래 카테고리는 직접 고른 것입니다. 테마를 고르면 자동으로 설정됩니다.',
|
||||
'tours.walk.themeReason': '{theme} 추천',
|
||||
'tours.walk.arrival': '도착 약 {time}',
|
||||
'tours.walk.arrivalAtLeast': '도착 빨라야 {time}',
|
||||
|
||||
@@ -358,7 +358,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Deze route heeft meer haltes dan een wandeling kan bevatten.',
|
||||
'tours.provenance.takenOverFrom': 'Overgenomen uit',
|
||||
'tours.poi.title': 'Langs de tocht',
|
||||
'tours.poi.intro': 'Plekken voor een stop, dicht bij de route. Er verandert niets tot je er een toevoegt.',
|
||||
'tours.poi.categories': 'Waarnaar zoeken',
|
||||
'tours.poi.within': 'Binnen deze afstand van de route',
|
||||
'tours.poi.search': 'Zoeken langs de tocht',
|
||||
@@ -389,7 +388,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '‘{name}’ is nu een stop; de route wordt opnieuw gepland.',
|
||||
'tours.poi.outcome.measured': '‘{name}’ is nu een stop. De tocht verandert met:',
|
||||
'tours.poi.outcome.unmeasured': '‘{name}’ is nu een stop. Hoeveel het toevoegt, kon niet worden berekend.',
|
||||
'tours.poi.undoHint': 'Ongedaan maken in de editor draait het terug.',
|
||||
'tours.poi.cat.viewpoint': 'Uitzichtpunten',
|
||||
'tours.poi.cat.peak': 'Toppen',
|
||||
'tours.poi.cat.shelter': 'Hutten en schuilplaatsen',
|
||||
@@ -527,7 +525,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Leeg betekent nog onbekend; 0 voor een punt waar je alleen langs loopt.',
|
||||
'tours.walk.station.stayInvalid': 'Vul hele minuten in, tot een dag.',
|
||||
'tours.walk.station.stayUnknown': 'Nog onbekend',
|
||||
'tours.walk.station.stayUnknownShort': 'Verblijf open',
|
||||
'tours.walk.station.passThrough': 'Alleen doorgaan',
|
||||
'tours.walk.station.importance': 'Belang',
|
||||
'tours.walk.station.protected': 'Beschermd',
|
||||
@@ -574,7 +571,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parken, tuinen en uitkijkpunten langs de route.',
|
||||
'tours.walk.themeHint.food': 'Koffiezaken, eetgelegenheden en markten langs de route.',
|
||||
'tours.walk.themeHint.mixed': 'Van alles wat: bezienswaardigheden, musea, parken, uitzicht, koffiezaken en markten.',
|
||||
'tours.walk.themeHint.own': 'Je eigen keuze van categorieën hieronder; een thema stelt ze voor je in.',
|
||||
'tours.walk.themeReason': 'Voorgesteld voor {theme}',
|
||||
'tours.walk.arrival': 'Aankomst rond {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Aankomst op zijn vroegst {time}',
|
||||
|
||||
@@ -366,7 +366,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Ta trasa ma więcej przystanków, niż zmieści spacer.',
|
||||
'tours.provenance.takenOverFrom': 'Przejęto z',
|
||||
'tours.poi.title': 'Wzdłuż trasy',
|
||||
'tours.poi.intro': 'Miejsca na postój blisko trasy. Nic się nie zmienia, dopóki nie dodasz któregoś.',
|
||||
'tours.poi.categories': 'Czego szukać',
|
||||
'tours.poi.within': 'W tej odległości od trasy',
|
||||
'tours.poi.search': 'Szukaj wzdłuż trasy',
|
||||
@@ -396,7 +395,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}” jest teraz postojem; trasa jest wyznaczana od nowa.',
|
||||
'tours.poi.outcome.measured': '„{name}” jest teraz postojem. Trasa zmienia się o:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}” jest teraz postojem. Nie udało się obliczyć, ile dodaje.',
|
||||
'tours.poi.undoHint': 'Cofnij w edytorze to wycofa.',
|
||||
'tours.poi.cat.viewpoint': 'Punkty widokowe',
|
||||
'tours.poi.cat.peak': 'Szczyty',
|
||||
'tours.poi.cat.shelter': 'Schroniska i wiaty',
|
||||
@@ -534,7 +532,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Puste oznacza jeszcze nieznane; 0 dla punktu, przez który tylko przechodzisz.',
|
||||
'tours.walk.station.stayInvalid': 'Wpisz pełne minuty, maksymalnie jeden dzień.',
|
||||
'tours.walk.station.stayUnknown': 'Jeszcze nieznane',
|
||||
'tours.walk.station.stayUnknownShort': 'Pobyt nieznany',
|
||||
'tours.walk.station.passThrough': 'Tylko przejście',
|
||||
'tours.walk.station.importance': 'Ważność',
|
||||
'tours.walk.station.protected': 'Chroniony',
|
||||
@@ -581,7 +578,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parki, ogrody i punkty widokowe wzdłuż trasy.',
|
||||
'tours.walk.themeHint.food': 'Kawiarnie, miejsca do jedzenia i targi wzdłuż trasy.',
|
||||
'tours.walk.themeHint.mixed': 'Po trochu wszystkiego: atrakcje, muzea, parki, widoki, kawiarnie i targi.',
|
||||
'tours.walk.themeHint.own': 'Twój własny wybór kategorii poniżej; motyw ustawi je za ciebie.',
|
||||
'tours.walk.themeReason': 'Proponowane dla: {theme}',
|
||||
'tours.walk.arrival': 'Przyjście około {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Przyjście najwcześniej {time}',
|
||||
|
||||
@@ -369,7 +369,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'В этом маршруте больше остановок, чем вмещает прогулка.',
|
||||
'tours.provenance.takenOverFrom': 'Взят из',
|
||||
'tours.poi.title': 'Вдоль маршрута',
|
||||
'tours.poi.intro': 'Места для остановки рядом с маршрутом. Ничего не меняется, пока вы не добавите одно из них.',
|
||||
'tours.poi.categories': 'Что искать',
|
||||
'tours.poi.within': 'В пределах этого расстояния от маршрута',
|
||||
'tours.poi.search': 'Искать вдоль маршрута',
|
||||
@@ -400,7 +399,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '«{name}» теперь остановка; маршрут строится заново.',
|
||||
'tours.poi.outcome.measured': '«{name}» теперь остановка. Маршрут меняется на:',
|
||||
'tours.poi.outcome.unmeasured': '«{name}» теперь остановка. Не удалось рассчитать, сколько она добавляет.',
|
||||
'tours.poi.undoHint': 'Отмена в редакторе вернёт всё назад.',
|
||||
'tours.poi.cat.viewpoint': 'Смотровые площадки',
|
||||
'tours.poi.cat.peak': 'Вершины',
|
||||
'tours.poi.cat.shelter': 'Хижины и укрытия',
|
||||
@@ -536,7 +534,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Пусто значит пока неизвестно; 0 для точки, которую ты только проходишь.',
|
||||
'tours.walk.station.stayInvalid': 'Введи целые минуты, не больше суток.',
|
||||
'tours.walk.station.stayUnknown': 'Пока неизвестно',
|
||||
'tours.walk.station.stayUnknownShort': 'Остановка неизвестна',
|
||||
'tours.walk.station.passThrough': 'Только мимо',
|
||||
'tours.walk.station.importance': 'Важность',
|
||||
'tours.walk.station.protected': 'Защищена',
|
||||
@@ -583,7 +580,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Парки, сады и смотровые площадки вдоль маршрута.',
|
||||
'tours.walk.themeHint.food': 'Кафе, места, где поесть, и рынки вдоль маршрута.',
|
||||
'tours.walk.themeHint.mixed': 'Всего понемногу: достопримечательности, музеи, парки, виды, кафе и рынки.',
|
||||
'tours.walk.themeHint.own': 'Твой собственный выбор категорий ниже; тема задаст их за тебя.',
|
||||
'tours.walk.themeReason': 'Предложено для темы {theme}',
|
||||
'tours.walk.arrival': 'Прибытие около {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Прибытие не раньше {time}',
|
||||
|
||||
@@ -351,7 +351,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Táto trasa má viac zastávok, než prechádzka unesie.',
|
||||
'tours.provenance.takenOverFrom': 'Prevzaté z',
|
||||
'tours.poi.title': 'Pozdĺž túry',
|
||||
'tours.poi.intro': 'Miesta na zastávku blízko trasy. Nič sa nezmení, kým nejaké nepridáš.',
|
||||
'tours.poi.categories': 'Čo hľadať',
|
||||
'tours.poi.within': 'Do tejto vzdialenosti od trasy',
|
||||
'tours.poi.search': 'Hľadať pozdĺž túry',
|
||||
@@ -381,7 +380,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '„{name}“ je teraz zastávka; trasa sa plánuje znova.',
|
||||
'tours.poi.outcome.measured': '„{name}“ je teraz zastávka. Túra sa mení o:',
|
||||
'tours.poi.outcome.unmeasured': '„{name}“ je teraz zastávka. Koľko pridáva, sa nepodarilo vypočítať.',
|
||||
'tours.poi.undoHint': 'Späť v editore to vráti.',
|
||||
'tours.poi.cat.viewpoint': 'Vyhliadky',
|
||||
'tours.poi.cat.peak': 'Vrcholy',
|
||||
'tours.poi.cat.shelter': 'Chaty a prístrešky',
|
||||
@@ -518,7 +516,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Prázdne znamená zatiaľ neznáme; 0 pre miesto, ktorým len prejdeš.',
|
||||
'tours.walk.station.stayInvalid': 'Zadaj celé minúty, najviac jeden deň.',
|
||||
'tours.walk.station.stayUnknown': 'Zatiaľ neznáme',
|
||||
'tours.walk.station.stayUnknownShort': 'Pobyt neznámy',
|
||||
'tours.walk.station.passThrough': 'Len prechod',
|
||||
'tours.walk.station.importance': 'Dôležitosť',
|
||||
'tours.walk.station.protected': 'Chránená',
|
||||
@@ -564,7 +561,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parky, záhrady a vyhliadky pozdĺž trasy.',
|
||||
'tours.walk.themeHint.food': 'Kaviarne, miesta na jedlo a trhy pozdĺž trasy.',
|
||||
'tours.walk.themeHint.mixed': 'Od všetkého trochu: pamiatky, múzeá, parky, výhľady, kaviarne a trhy.',
|
||||
'tours.walk.themeHint.own': 'Tvoj vlastný výber kategórií nižšie; téma ich nastaví za teba.',
|
||||
'tours.walk.themeReason': 'Navrhnuté pre {theme}',
|
||||
'tours.walk.arrival': 'Príchod okolo {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Príchod najskôr {time}',
|
||||
|
||||
@@ -348,7 +348,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Den rutten har fler stopp än en promenad rymmer.',
|
||||
'tours.provenance.takenOverFrom': 'Hämtad från',
|
||||
'tours.poi.title': 'Längs turen',
|
||||
'tours.poi.intro': 'Platser för ett stopp nära rutten. Inget ändras förrän du lägger till en.',
|
||||
'tours.poi.categories': 'Vad att leta efter',
|
||||
'tours.poi.within': 'Inom det här avståndet från rutten',
|
||||
'tours.poi.search': 'Sök längs turen',
|
||||
@@ -379,7 +378,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '”{name}” är nu ett stopp; rutten planeras om.',
|
||||
'tours.poi.outcome.measured': '”{name}” är nu ett stopp. Turen ändras med:',
|
||||
'tours.poi.outcome.unmeasured': '”{name}” är nu ett stopp. Hur mycket det lägger till gick inte att räkna ut.',
|
||||
'tours.poi.undoHint': 'Ångra i redigeraren tar tillbaka det.',
|
||||
'tours.poi.cat.viewpoint': 'Utsiktspunkter',
|
||||
'tours.poi.cat.peak': 'Toppar',
|
||||
'tours.poi.cat.shelter': 'Stugor och vindskydd',
|
||||
@@ -516,7 +514,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Tomt betyder ännu okänt; 0 för en punkt du bara passerar.',
|
||||
'tours.walk.station.stayInvalid': 'Ange hela minuter, högst ett dygn.',
|
||||
'tours.walk.station.stayUnknown': 'Ännu okänt',
|
||||
'tours.walk.station.stayUnknownShort': 'Vistelse okänd',
|
||||
'tours.walk.station.passThrough': 'Bara förbi',
|
||||
'tours.walk.station.importance': 'Vikt',
|
||||
'tours.walk.station.protected': 'Skyddad',
|
||||
@@ -563,7 +560,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Parker, trädgårdar och utsiktspunkter längs rutten.',
|
||||
'tours.walk.themeHint.food': 'Kaféer, matställen och torghandel längs rutten.',
|
||||
'tours.walk.themeHint.mixed': 'Lite av allt: sevärdheter, museer, parker, utsikter, kaféer och torghandel.',
|
||||
'tours.walk.themeHint.own': 'Ditt eget urval av kategorier nedan; ett tema väljer dem åt dig.',
|
||||
'tours.walk.themeReason': 'Föreslaget för {theme}',
|
||||
'tours.walk.arrival': 'Ankomst runt {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Ankomst tidigast {time}',
|
||||
|
||||
@@ -332,7 +332,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'เส้นทางนี้มีจุดแวะมากกว่าที่ทริปเดินเท้าหนึ่งทริปรับได้',
|
||||
'tours.provenance.takenOverFrom': 'นำมาจาก',
|
||||
'tours.poi.title': 'ตามเส้นทางทัวร์',
|
||||
'tours.poi.intro': 'สถานที่แวะพักใกล้เส้นทาง จะไม่มีอะไรเปลี่ยนจนกว่าคุณจะเพิ่ม',
|
||||
'tours.poi.categories': 'สิ่งที่ต้องการค้นหา',
|
||||
'tours.poi.within': 'ภายในระยะนี้จากเส้นทาง',
|
||||
'tours.poi.search': 'ค้นหาตามเส้นทางทัวร์',
|
||||
@@ -361,7 +360,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” เป็นจุดแวะแล้ว กำลังวางแผนเส้นทางใหม่',
|
||||
'tours.poi.outcome.measured': '“{name}” เป็นจุดแวะแล้ว ทัวร์เปลี่ยนไป:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” เป็นจุดแวะแล้ว คำนวณไม่ได้ว่าเพิ่มขึ้นเท่าไร',
|
||||
'tours.poi.undoHint': 'เลิกทำในตัวแก้ไขเพื่อย้อนกลับได้',
|
||||
'tours.poi.cat.viewpoint': 'จุดชมวิว',
|
||||
'tours.poi.cat.peak': 'ยอดเขา',
|
||||
'tours.poi.cat.shelter': 'กระท่อมและที่พักหลบ',
|
||||
@@ -496,7 +494,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'เว้นว่างหมายถึงยังไม่ทราบ ใส่ 0 สำหรับจุดที่แค่เดินผ่าน',
|
||||
'tours.walk.station.stayInvalid': 'กรอกเป็นนาทีเต็ม ไม่เกินหนึ่งวัน',
|
||||
'tours.walk.station.stayUnknown': 'ยังไม่ทราบ',
|
||||
'tours.walk.station.stayUnknownShort': 'ยังไม่ระบุเวลาแวะ',
|
||||
'tours.walk.station.passThrough': 'แค่เดินผ่าน',
|
||||
'tours.walk.station.importance': 'ความสำคัญ',
|
||||
'tours.walk.station.protected': 'ป้องกันไว้',
|
||||
@@ -538,7 +535,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'สวนสาธารณะ สวน และจุดชมวิวตามเส้นทาง',
|
||||
'tours.walk.themeHint.food': 'ร้านกาแฟ ร้านอาหาร และตลาดตามเส้นทาง',
|
||||
'tours.walk.themeHint.mixed': 'มีทุกอย่าง: สถานที่น่าสนใจ พิพิธภัณฑ์ สวน จุดชมวิว ร้านกาแฟ และตลาด',
|
||||
'tours.walk.themeHint.own': 'หมวดหมู่ด้านล่างเลือกเอง ธีมจะตั้งค่าให้อัตโนมัติ',
|
||||
'tours.walk.themeReason': 'แนะนำสำหรับ {theme}',
|
||||
'tours.walk.arrival': 'ถึงประมาณ {time}',
|
||||
'tours.walk.arrivalAtLeast': 'ถึงเร็วที่สุด {time}',
|
||||
|
||||
@@ -350,7 +350,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Bu rotada bir yürüyüşün alabileceğinden fazla durak var.',
|
||||
'tours.provenance.takenOverFrom': 'Alındığı yer',
|
||||
'tours.poi.title': 'Tur boyunca',
|
||||
'tours.poi.intro': 'Rotaya yakın mola yerleri. Birini ekleyene kadar hiçbir şey değişmez.',
|
||||
'tours.poi.categories': 'Ne aranacak',
|
||||
'tours.poi.within': 'Rotaya bu mesafe içinde',
|
||||
'tours.poi.search': 'Tur boyunca ara',
|
||||
@@ -381,7 +380,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” artık bir mola; rota yeniden planlanıyor.',
|
||||
'tours.poi.outcome.measured': '“{name}” artık bir mola. Tur şu kadar değişiyor:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” artık bir mola. Ne kadar eklediği hesaplanamadı.',
|
||||
'tours.poi.undoHint': 'Düzenleyicide geri al bunu geri alır.',
|
||||
'tours.poi.cat.viewpoint': 'Seyir noktaları',
|
||||
'tours.poi.cat.peak': 'Zirveler',
|
||||
'tours.poi.cat.shelter': 'Kulübeler ve sığınaklar',
|
||||
@@ -519,7 +517,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Boş, henüz bilinmiyor demektir; yalnızca geçtiğin bir nokta için 0.',
|
||||
'tours.walk.station.stayInvalid': 'Tam dakika gir, en fazla bir gün.',
|
||||
'tours.walk.station.stayUnknown': 'Henüz bilinmiyor',
|
||||
'tours.walk.station.stayUnknownShort': 'Kalış belirsiz',
|
||||
'tours.walk.station.passThrough': 'Sadece geçiş',
|
||||
'tours.walk.station.importance': 'Önem',
|
||||
'tours.walk.station.protected': 'Korunuyor',
|
||||
@@ -567,7 +564,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.food': 'Rota boyunca kafeler, yemek yerleri ve pazarlar.',
|
||||
'tours.walk.themeHint.mixed':
|
||||
'Her şeyden biraz: görülecek yerler, müzeler, parklar, manzaralar, kafeler ve pazarlar.',
|
||||
'tours.walk.themeHint.own': 'Aşağıdaki kategorileri kendin seçtin; bir tema onları senin için ayarlar.',
|
||||
'tours.walk.themeReason': '{theme} için önerildi',
|
||||
'tours.walk.arrival': 'Varış yaklaşık {time}',
|
||||
'tours.walk.arrivalAtLeast': 'En erken varış {time}',
|
||||
|
||||
@@ -364,7 +364,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'У цьому маршруті більше зупинок, ніж уміщує прогулянка.',
|
||||
'tours.provenance.takenOverFrom': 'Взято з',
|
||||
'tours.poi.title': 'Уздовж маршруту',
|
||||
'tours.poi.intro': 'Місця для зупинки поруч із маршрутом. Нічого не змінюється, доки ви не додасте одне з них.',
|
||||
'tours.poi.categories': 'Що шукати',
|
||||
'tours.poi.within': 'У межах цієї відстані від маршруту',
|
||||
'tours.poi.search': 'Шукати вздовж маршруту',
|
||||
@@ -395,7 +394,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '«{name}» тепер зупинка; маршрут будується заново.',
|
||||
'tours.poi.outcome.measured': '«{name}» тепер зупинка. Маршрут змінюється на:',
|
||||
'tours.poi.outcome.unmeasured': '«{name}» тепер зупинка. Не вдалося розрахувати, скільки вона додає.',
|
||||
'tours.poi.undoHint': 'Скасування в редакторі поверне все назад.',
|
||||
'tours.poi.cat.viewpoint': 'Оглядові майданчики',
|
||||
'tours.poi.cat.peak': 'Вершини',
|
||||
'tours.poi.cat.shelter': 'Хатини й укриття',
|
||||
@@ -532,7 +530,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Порожньо означає поки невідомо; 0 для точки, яку ти лише проходиш.',
|
||||
'tours.walk.station.stayInvalid': 'Введи цілі хвилини, не більше доби.',
|
||||
'tours.walk.station.stayUnknown': 'Поки невідомо',
|
||||
'tours.walk.station.stayUnknownShort': 'Перебування невідоме',
|
||||
'tours.walk.station.passThrough': 'Лише мимо',
|
||||
'tours.walk.station.importance': 'Важливість',
|
||||
'tours.walk.station.protected': 'Захищена',
|
||||
@@ -579,7 +576,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Парки, сади та оглядові майданчики вздовж маршруту.',
|
||||
'tours.walk.themeHint.food': "Кав'ярні, місця, де поїсти, та ринки вздовж маршруту.",
|
||||
'tours.walk.themeHint.mixed': "Усього потроху: пам'ятки, музеї, парки, краєвиди, кав'ярні та ринки.",
|
||||
'tours.walk.themeHint.own': 'Твій власний вибір категорій нижче; тема задасть їх за тебе.',
|
||||
'tours.walk.themeReason': 'Запропоновано для теми {theme}',
|
||||
'tours.walk.arrival': 'Прибуття близько {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Прибуття не раніше {time}',
|
||||
|
||||
@@ -349,7 +349,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': 'Tuyến này có nhiều điểm dừng hơn mức một lộ trình đi bộ chứa được.',
|
||||
'tours.provenance.takenOverFrom': 'Lấy từ',
|
||||
'tours.poi.title': 'Dọc chuyến đi',
|
||||
'tours.poi.intro': 'Những nơi để dừng chân gần lộ trình. Không có gì thay đổi cho đến khi bạn thêm một nơi.',
|
||||
'tours.poi.categories': 'Tìm gì',
|
||||
'tours.poi.within': 'Trong khoảng cách này tới lộ trình',
|
||||
'tours.poi.search': 'Tìm dọc chuyến đi',
|
||||
@@ -379,7 +378,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}” giờ là điểm dừng; lộ trình đang được lập lại.',
|
||||
'tours.poi.outcome.measured': '“{name}” giờ là điểm dừng. Chuyến đi thay đổi:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}” giờ là điểm dừng. Không tính được nó thêm bao nhiêu.',
|
||||
'tours.poi.undoHint': 'Hoàn tác trong trình chỉnh sửa sẽ bỏ thao tác này.',
|
||||
'tours.poi.cat.viewpoint': 'Điểm ngắm cảnh',
|
||||
'tours.poi.cat.peak': 'Đỉnh núi',
|
||||
'tours.poi.cat.shelter': 'Lán và chỗ trú',
|
||||
@@ -516,7 +514,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': 'Để trống nghĩa là chưa biết; nhập 0 cho điểm chỉ đi ngang qua.',
|
||||
'tours.walk.station.stayInvalid': 'Nhập số phút nguyên, tối đa một ngày.',
|
||||
'tours.walk.station.stayUnknown': 'Chưa biết',
|
||||
'tours.walk.station.stayUnknownShort': 'Chưa rõ thời gian dừng',
|
||||
'tours.walk.station.passThrough': 'Chỉ đi qua',
|
||||
'tours.walk.station.importance': 'Mức độ quan trọng',
|
||||
'tours.walk.station.protected': 'Được bảo vệ',
|
||||
@@ -562,7 +559,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': 'Công viên, khu vườn và điểm ngắm cảnh dọc tuyến đường.',
|
||||
'tours.walk.themeHint.food': 'Quán cà phê, chỗ ăn uống và chợ dọc tuyến đường.',
|
||||
'tours.walk.themeHint.mixed': 'Mỗi thứ một chút: điểm tham quan, bảo tàng, công viên, cảnh đẹp, quán cà phê và chợ.',
|
||||
'tours.walk.themeHint.own': 'Bạn đang tự chọn danh mục bên dưới; chủ đề sẽ chọn giúp bạn.',
|
||||
'tours.walk.themeReason': 'Gợi ý cho {theme}',
|
||||
'tours.walk.arrival': 'Đến khoảng {time}',
|
||||
'tours.walk.arrivalAtLeast': 'Đến sớm nhất lúc {time}',
|
||||
|
||||
@@ -317,7 +317,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': '這條路線的站點比一次漫步能容納的更多。',
|
||||
'tours.provenance.takenOverFrom': '引入自',
|
||||
'tours.poi.title': '沿途',
|
||||
'tours.poi.intro': '靠近路線的停留地點。在你新增之前,不會有任何變動。',
|
||||
'tours.poi.categories': '要找什麼',
|
||||
'tours.poi.within': '距路線的範圍',
|
||||
'tours.poi.search': '沿途搜尋',
|
||||
@@ -346,7 +345,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '「{name}」已成為停留點,正在重新規劃路線。',
|
||||
'tours.poi.outcome.measured': '「{name}」已成為停留點。路線變化:',
|
||||
'tours.poi.outcome.unmeasured': '「{name}」已成為停留點。無法計算它增加了多少。',
|
||||
'tours.poi.undoHint': '在編輯器中復原即可恢復。',
|
||||
'tours.poi.cat.viewpoint': '觀景點',
|
||||
'tours.poi.cat.peak': '山頂',
|
||||
'tours.poi.cat.shelter': '山屋與避難處',
|
||||
@@ -476,7 +474,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': '留空表示尚不清楚;只經過的地點填 0。',
|
||||
'tours.walk.station.stayInvalid': '請輸入整數分鐘,最多一天。',
|
||||
'tours.walk.station.stayUnknown': '尚不清楚',
|
||||
'tours.walk.station.stayUnknownShort': '停留時間未定',
|
||||
'tours.walk.station.passThrough': '僅經過',
|
||||
'tours.walk.station.importance': '重要程度',
|
||||
'tours.walk.station.protected': '受保護',
|
||||
@@ -518,7 +515,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': '沿途的公園、花園和觀景點。',
|
||||
'tours.walk.themeHint.food': '沿途的咖啡館、餐館和市集。',
|
||||
'tours.walk.themeHint.mixed': '各類都有一些:景點、博物館、公園、景觀、咖啡館和市集。',
|
||||
'tours.walk.themeHint.own': '下方類別為你自選;選擇主題會自動設定。',
|
||||
'tours.walk.themeReason': '推薦用於{theme}',
|
||||
'tours.walk.arrival': '約 {time} 抵達',
|
||||
'tours.walk.arrivalAtLeast': '最早 {time} 抵達',
|
||||
|
||||
@@ -318,7 +318,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walkImport.refusal.too_many_stops': '这条路线的站点比一次漫步能容纳的更多。',
|
||||
'tours.provenance.takenOverFrom': '引入自',
|
||||
'tours.poi.title': '沿途',
|
||||
'tours.poi.intro': '靠近路线的停留地点。在你添加之前,不会有任何改动。',
|
||||
'tours.poi.categories': '要找什么',
|
||||
'tours.poi.within': '距路线的范围',
|
||||
'tours.poi.search': '沿途搜索',
|
||||
@@ -347,7 +346,6 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.outcome.measuring': '“{name}”已成为停留点,正在重新规划路线。',
|
||||
'tours.poi.outcome.measured': '“{name}”已成为停留点。路线变化:',
|
||||
'tours.poi.outcome.unmeasured': '“{name}”已成为停留点。无法计算它增加了多少。',
|
||||
'tours.poi.undoHint': '在编辑器中撤销即可恢复。',
|
||||
'tours.poi.cat.viewpoint': '观景点',
|
||||
'tours.poi.cat.peak': '山顶',
|
||||
'tours.poi.cat.shelter': '小屋与避难所',
|
||||
@@ -477,7 +475,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.station.stayHint': '留空表示尚不清楚;只经过的地点填 0。',
|
||||
'tours.walk.station.stayInvalid': '请输入整数分钟,最多一天。',
|
||||
'tours.walk.station.stayUnknown': '尚不清楚',
|
||||
'tours.walk.station.stayUnknownShort': '停留时间未定',
|
||||
'tours.walk.station.passThrough': '仅经过',
|
||||
'tours.walk.station.importance': '重要程度',
|
||||
'tours.walk.station.protected': '受保护',
|
||||
@@ -519,7 +516,6 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.themeHint.parks': '沿途的公园、花园和观景点。',
|
||||
'tours.walk.themeHint.food': '沿途的咖啡馆、餐馆和集市。',
|
||||
'tours.walk.themeHint.mixed': '各类都有一些:景点、博物馆、公园、景观、咖啡馆和集市。',
|
||||
'tours.walk.themeHint.own': '下方类别为你自选;选择主题会自动设置。',
|
||||
'tours.walk.themeReason': '推荐用于{theme}',
|
||||
'tours.walk.arrival': '约 {time} 到达',
|
||||
'tours.walk.arrivalAtLeast': '最早 {time} 到达',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** What can be looked for along a tour on foot, by bike or on the water. */
|
||||
export const TOUR_POI_CATEGORIES = [
|
||||
'viewpoint',
|
||||
'peak',
|
||||
'shelter',
|
||||
'drinking_water',
|
||||
'food',
|
||||
'toilets',
|
||||
'picnic',
|
||||
'campsite',
|
||||
'sights',
|
||||
'slipway',
|
||||
'boat_rental',
|
||||
'bike_repair',
|
||||
'museum',
|
||||
'park',
|
||||
'cafe',
|
||||
'restaurant',
|
||||
'market',
|
||||
] as const;
|
||||
export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES);
|
||||
export type TourPoiCategory = z.infer<typeof tourPoiCategorySchema>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
|
||||
import { plannableTourTypeSchema, type PlannableTourType } from './tours.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -8,28 +9,9 @@ import { z } from 'zod';
|
||||
* the line as drawn, never a detour someone walked or paddled.
|
||||
*/
|
||||
|
||||
/** What can be looked for along a tour on foot, by bike or on the water. */
|
||||
export const TOUR_POI_CATEGORIES = [
|
||||
'viewpoint',
|
||||
'peak',
|
||||
'shelter',
|
||||
'drinking_water',
|
||||
'food',
|
||||
'toilets',
|
||||
'picnic',
|
||||
'campsite',
|
||||
'sights',
|
||||
'slipway',
|
||||
'boat_rental',
|
||||
'bike_repair',
|
||||
'museum',
|
||||
'park',
|
||||
'cafe',
|
||||
'restaurant',
|
||||
'market',
|
||||
] as const;
|
||||
export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES);
|
||||
export type TourPoiCategory = z.infer<typeof tourPoiCategorySchema>;
|
||||
// The categories live in a module of their own, so the tour contract can name
|
||||
// one on a waypoint without importing this one, which imports it.
|
||||
export { TOUR_POI_CATEGORIES, tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
|
||||
|
||||
/** The categories each planned tour type offers, the most asked first. */
|
||||
export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly TourPoiCategory[]> = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
portageSpeedKmhSchema,
|
||||
trackMarksSchema,
|
||||
} from './paddle-plan.schema';
|
||||
import { tourPoiCategorySchema } from './tour-poi-category';
|
||||
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -360,6 +361,12 @@ export const tourWaypointSchema = z.object({
|
||||
protected: z.boolean().optional(),
|
||||
/** CW-04: the station's opening hours as OpenStreetMap writes them, where it was found there. */
|
||||
opening_hours: z.string().trim().max(TOUR_STATION_HOURS_MAX).nullable().optional(),
|
||||
/**
|
||||
* PLN-03: the kind of place the point was taken in from, found along the
|
||||
* tour (a restaurant, a museum), so its row carries that kind's icon as the
|
||||
* map drew the place. Null on a point set by hand.
|
||||
*/
|
||||
poi_category: tourPoiCategorySchema.nullable().optional(),
|
||||
});
|
||||
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user