feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)

Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
This commit is contained in:
Maurice
2026-10-20 13:06:17 +02:00
parent a8be1c5b45
commit 6a0c54cb2f
51 changed files with 1699 additions and 56 deletions
@@ -323,6 +323,42 @@ describe('TourDetailDialog', () => {
expect(screen.getByTestId('tour-detail-track-of')).toHaveTextContent('Actual track of Lake loop')
})
it('ACT-02: an actual track stands beside the plan the head shows, and only the map detail offers to compare it there', async () => {
const activity = {
id: 4,
assignment_id: null,
day_id: null,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx' as const, file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 3,
elevation_gain_m: null,
elevation_loss_m: null,
started_at: null,
ended_at: null,
elapsed_minutes: null,
point_count: 2,
},
geometry: [
[48, 11],
[48, 11.03],
] as [number, number][],
}
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour, waypoints: [], activities: [activity] })
const { unmount } = render(<TourDetailDialog {...defaultProps} desktopNonModal />)
const distance = await screen.findByTestId('tour-activity-compare-distance')
// The plan as the head measures it from the line, not the list's stale 999 km.
expect(distance).toHaveTextContent('2.2 km')
expect(distance).toHaveTextContent('3 km')
expect(screen.getByRole('button', { name: /Compare on map/ })).toBeInTheDocument()
unmount()
render(<TourDetailDialog {...defaultProps} />)
await screen.findByTestId('tour-activity-compare-distance')
expect(screen.queryByRole('button', { name: /Compare on map/ })).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
@@ -50,6 +50,7 @@ import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/tourActivityCompare';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
@@ -543,6 +544,21 @@ export default function TourDetailDialog({
const times = tourPlannedTimes(tour);
const timeFields = tourTimeFields(times, t, tour);
// The plan as the line measures it now: the head says it, and an actual track is compared with it (ACT-02).
const planDistanceKm = analysis?.distanceKm ?? tour.distance;
const planGain = analysis ? analysis.gain : tour.elevation_gain;
const planLoss = analysis ? analysis.loss : tour.elevation_loss;
const plan = useMemo<PlanValues>(
() => ({
distanceKm: planDistanceKm,
gain: planGain,
loss: planLoss,
minutes: times.plannedTotalMinutes,
minutesAtLeast: times.atLeast,
line: analysis?.routeCoordinates ?? null,
}),
[analysis, planDistanceKm, planGain, planLoss, times.atLeast, times.plannedTotalMinutes]
);
const header = (
<>
@@ -573,11 +589,7 @@ export default function TourDetailDialog({
trackColor={trackColor}
timeFields={timeFields}
times={times}
metrics={{
distanceKm: analysis?.distanceKm ?? tour.distance,
gain: analysis ? analysis.gain : tour.elevation_gain,
loss: analysis ? analysis.loss : tour.elevation_loss,
}}
metrics={{ distanceKm: plan.distanceKm, gain: plan.gain, loss: plan.loss }}
/>
</div>
</>
@@ -734,6 +746,9 @@ export default function TourDetailDialog({
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor={trackColor}
mapCompare={desktopNonModal}
onToursChanged={onToursChanged}
/>
)}
@@ -0,0 +1,27 @@
// FE-ACT-COMPARE-LEGEND-001
// ACT-02 over the plan map, rendered without the translation provider so
// every text reads as its key: the legend names both lines, and closing it
// ends the comparison.
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import ActivityCompareLegend from './ActivityCompareLegend';
beforeEach(() => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
});
describe('ActivityCompareLegend (ACT-02)', () => {
it('FE-ACT-COMPARE-LEGEND-001: names the plan and the actual track, and ends the comparison when closed', () => {
const legend = { planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' };
render(<ActivityCompareLegend legend={legend} />);
const card = screen.getByTestId('activity-compare-legend');
expect(card).toHaveTextContent('tours.activity.compare.plan');
expect(card).toHaveTextContent('Ridge');
expect(card).toHaveTextContent('tours.activity.badge');
expect(card).toHaveTextContent('Watch export');
fireEvent.click(screen.getByRole('button', { name: 'tours.activity.compare.hide' }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
});
@@ -0,0 +1,74 @@
import { GitCompareArrows, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { Eyebrow } from '../../Planner/bookings/bookingParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import type { ActivityCompareLegend as Legend } from './useActivityCompareMap';
/** One line of the legend: how it is drawn, what it is, and whose it is. */
function LegendRow({ swatch, label, name }: { swatch: ReactNode; label: string; name: string }) {
return (
<li className="flex min-w-0 items-center gap-2">
<span className="grid h-6 w-8 flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint">
{swatch}
</span>
<span className="flex min-w-0 flex-col">
<Eyebrow>{label}</Eyebrow>
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>
{name}
</span>
</span>
</li>
);
}
/**
* ACT-02 over the plan map: what its two lines are while a tour is compared
* with its actual track, in the frosted look of the map's cards. Closing it
* ends the comparison; it changes nothing else.
*/
export default function ActivityCompareLegend({ legend }: { legend: Legend }) {
const { t } = useTranslation();
const close = t('tours.activity.compare.hide');
return (
<aside
data-testid="activity-compare-legend"
aria-label={t('tours.activity.compare.title')}
className="pointer-events-auto w-[240px] rounded-2xl p-3"
style={MAP_CARD_GLASS}
>
<div className="mb-2 flex items-center gap-1.5">
<GitCompareArrows size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.activity.compare.title')}
</span>
<Tooltip label={close}>
<button
type="button"
onClick={() => useTourActivityCompareStore.getState().clear()}
aria-label={close}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<X size={13} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
<LegendRow
swatch={<LineSwatch color={legend.planColor} />}
label={t('tours.activity.compare.plan')}
name={legend.planName}
/>
<LegendRow
swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />}
label={t('tours.activity.badge')}
name={legend.trackName}
/>
</ul>
</aside>
);
}
@@ -0,0 +1,142 @@
// FE-ACT-COMPARE-VIEW-001 to FE-ACT-COMPARE-VIEW-006
// ACT-02 in an actual track's card, rendered without the translation
// provider so every text reads as its key: plan and track side by side under
// their own names, the difference only where both are known, a missing value
// named as missing, the deviation from the planned line, and the map switch.
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
import type { PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 2,
gain: 120,
loss: null,
minutes: 180,
minutesAtLeast: false,
line: [
[47, 11],
[47.01, 11],
[47.02, 11],
],
};
function activity(overrides: Partial<TourActivity['metrics']> = {}): TourActivity {
return {
id: 4,
assignment_id: 11,
day_id: 1,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 2.5,
elevation_gain_m: 120,
elevation_loss_m: 90,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T09:15:00Z',
elapsed_minutes: 195,
point_count: 3,
...overrides,
},
geometry: [
[47, 11, 600],
[47.012, 11.01, 650],
[47.02, 11, 700],
],
};
}
const row = (key: string) => screen.getByTestId(`tour-activity-compare-${key}`);
function show(props: Partial<Parameters<typeof TourActivityCompare>[0]> = {}) {
return render(
<TourActivityCompare activity={activity()} plan={plan} planColor="plan-paint" unit="metric" {...props} />
);
}
describe('TourActivityCompare (ACT-02)', () => {
it('FE-ACT-COMPARE-VIEW-001: names plan, actual and difference, and sets each measure of both side by side', () => {
show();
const table = screen.getByTestId('tour-activity-compare');
expect(within(table).getByText('tours.activity.compare.plan')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.badge')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.compare.difference')).toBeInTheDocument();
expect(row('distance')).toHaveTextContent('2 km');
expect(row('distance')).toHaveTextContent('2.5 km');
expect(row('distance')).toHaveTextContent('+500 m');
expect(row('ascent')).toHaveTextContent('±0 m');
expect(row('time')).toHaveTextContent('3 h');
expect(row('time')).toHaveTextContent('3 h 15 min');
expect(row('time')).toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-002: a value a side does not carry is named missing, and nothing is set between them', () => {
show({ activity: activity({ elapsed_minutes: null }), plan: { ...plan, gain: null } });
expect(row('ascent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('descent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('time')).toHaveTextContent('tours.activity.noTime');
expect(within(row('time')).queryByText(/min$/)).toBeNull();
show({ activity: activity({ elevation_loss_m: null }), plan: { ...plan, loss: 80 } });
expect(screen.getAllByTestId('tour-activity-compare-descent')[1]).toHaveTextContent(
'tours.activity.compare.notInTrack'
);
});
it('FE-ACT-COMPARE-VIEW-003: a planned total that is only a floor reads "at least" and is not subtracted from', () => {
show({ plan: { ...plan, minutesAtLeast: true } });
expect(row('time')).toHaveTextContent('≥ 3 h');
expect(row('time')).not.toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-004: measures how far the track left the planned line, or says there is no line', () => {
const { unmount } = show();
expect(screen.getByText('tours.activity.compare.farthest')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlan')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlanValue')).toBeInTheDocument();
unmount();
show({ plan: { ...plan, line: null } });
expect(screen.queryByText('tours.activity.compare.farthest')).toBeNull();
expect(screen.getByText('tours.activity.compare.noLine')).toBeInTheDocument();
});
it('FE-ACT-COMPARE-VIEW-005: the map switch draws and ends the comparison, and says which it is', () => {
const onToggle = vi.fn();
const { rerender } = show({ map: { shown: false, onToggle } });
const button = screen.getByRole('button', { name: /tours.activity.compare.show/ });
expect(button).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(
<TourActivityCompare
activity={activity()}
plan={plan}
planColor="plan-paint"
unit="metric"
map={{ shown: true, onToggle }}
/>
);
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ACT-COMPARE-VIEW-006: no map switch without a plan map beside the detail, or without a line to draw', () => {
const { unmount } = show();
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
unmount();
show({ activity: { ...activity(), geometry: [] }, map: { shown: false, onToggle: vi.fn() } });
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
});
describe('formatDifference (ACT-02)', () => {
it('signs a difference, calls one that rounds to nothing even, and keeps the unit shown', () => {
expect(formatDifference('distance', -1.25, 'metric')).toBe('-1.3 km');
expect(formatDifference('descent', 0.2, 'metric')).toBe('±0 m');
expect(formatDifference('ascent', 30, 'imperial')).toBe('+98 ft');
expect(formatDifference('time', -45, 'metric')).toBe('-45 min');
});
});
@@ -0,0 +1,231 @@
import type { TourActivity } from '@trek/shared';
import { Equal, Map as MapIcon, TrendingDown, TrendingUp } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BOX, Eyebrow } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourFactFields, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import {
compareRows,
OFF_PLAN_M,
planDeviation,
type CompareKey,
type CompareRow,
type PlanValues,
} from './tourActivityCompare';
type Translate = (key: string, params?: Record<string, unknown>) => string;
const ROW_LABELS: Record<CompareKey, string> = {
distance: 'tours.detail.distance',
ascent: 'tours.detail.ascent',
descent: 'tours.detail.descent',
time: 'tours.activity.compare.time',
};
function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
if (key === 'distance') return formatDistance(value, unit);
if (key === 'time') return formatPlannedTourDuration(value);
return formatElevation(value, unit);
}
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
const size = formatValue(key, Math.abs(difference), unit);
if (size === formatValue(key, 0, unit)) return `±${size}`;
return `${difference > 0 ? '+' : '-'}${size}`;
}
/** One value of one side: the number, or a word saying that side does not carry it. */
function ValueCell({
text,
missing,
hint,
strong = false,
}: {
text: string;
missing: boolean;
hint?: string;
strong?: boolean;
}) {
const look = missing
? 'italic text-content-faint'
: strong
? 'font-semibold text-content'
: 'font-medium text-content-secondary';
return (
<td className="px-2 py-1.5 text-end tabular-nums">
<Tooltip label={hint ?? ''} disabled={!hint}>
<span className={`inline-flex ${look}`}>{text}</span>
</Tooltip>
</td>
);
}
/** The plan's value; a total that leaves a stay out reads "at least", as the head says it. */
function planText(row: CompareRow, plan: PlanValues, unit: DistanceUnit, t: Translate): string {
if (row.plan === null) return t('tours.activity.compare.notInPlan');
const text = formatValue(row.key, row.plan, unit);
return row.key === 'time' && plan.minutesAtLeast ? `≥ ${text}` : text;
}
/** The track's value; a time is said to be measured, or the time stamps to be missing. */
function actualCell(row: CompareRow, unit: DistanceUnit, t: Translate): { text: string; hint?: string } {
if (row.key === 'time') {
return row.actual === null
? { text: t('tours.activity.noTime'), hint: t('tours.activity.noTimeHint') }
: { text: formatValue('time', row.actual, unit), hint: t('tours.activity.timeHint') };
}
return {
text: row.actual === null ? t('tours.activity.compare.notInTrack') : formatValue(row.key, row.actual, unit),
};
}
/** The difference as a small badge whose arrow says which way; empty where the two cannot be compared. */
function DifferenceCell({ row, unit }: { row: CompareRow; unit: DistanceUnit }) {
if (row.difference === null) return <td className="px-2 py-1.5" />;
const text = formatDifference(row.key, row.difference, unit);
const Icon = text.startsWith('±') ? Equal : row.difference > 0 ? TrendingUp : TrendingDown;
return (
<td className="px-2 py-1.5 text-end">
<SoftPill
className="tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
{text}
</SoftPill>
</td>
);
}
/** A column head: its name with the line it is drawn as on the map. */
function ColumnHead({ label, swatch }: { label: string; swatch?: ReactNode }) {
return (
<th scope="col" className="px-2 py-1.5 text-end font-normal">
<span className="inline-flex items-center gap-1.5">
{swatch}
<Eyebrow>{label}</Eyebrow>
</span>
</th>
);
}
/** The four measures of plan and track, one row each, with what lies between them. */
function CompareTable({
rows,
plan,
planColor,
unit,
}: {
rows: readonly CompareRow[];
plan: PlanValues;
planColor: string;
unit: DistanceUnit;
}) {
const { t } = useTranslation();
return (
<div className={`${BOX} overflow-hidden`}>
<table data-testid="tour-activity-compare" className="w-full border-collapse" style={fs(12, 'body')}>
<thead className="bg-surface-secondary">
<tr>
<td />
<ColumnHead label={t('tours.activity.compare.plan')} swatch={<LineSwatch color={planColor} />} />
<ColumnHead label={t('tours.activity.badge')} swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />} />
<ColumnHead label={t('tours.activity.compare.difference')} />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const actual = actualCell(row, unit, t);
return (
<tr key={row.key} data-testid={`tour-activity-compare-${row.key}`} className="border-t border-edge-faint">
<th scope="row" className="px-2.5 py-1.5 text-start font-medium text-content-muted">
{t(ROW_LABELS[row.key])}
</th>
<ValueCell text={planText(row, plan, unit, t)} missing={row.plan === null} />
<ValueCell text={actual.text} hint={actual.hint} missing={row.actual === null} strong />
<DifferenceCell row={row} unit={unit} />
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** How far the track left the planned line, or that there is no line to measure against. */
function deviationFields(
activity: TourActivity,
plan: PlanValues,
unit: DistanceUnit,
t: Translate
): TourMetricField[] {
const deviation = planDeviation(activity.geometry, plan.line);
const offLabel = t('tours.activity.compare.offPlan');
if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }];
const offInfo = {
label: offLabel,
description: t('tours.activity.compare.offPlanHint', { distance: formatDistance(OFF_PLAN_M / 1000, unit) }),
};
return [
{ label: t('tours.activity.compare.farthest'), value: formatDistance(deviation.farthestM / 1000, unit) },
{
label: offLabel,
value: t('tours.activity.compare.offPlanValue', {
distance: formatDistance(deviation.offPlanKm, unit),
percent: Math.round(deviation.offPlanShare * 100),
}),
info: offInfo,
},
];
}
interface TourActivityCompareProps {
activity: TourActivity;
plan: PlanValues;
/** The plan's track colour, as the map draws it. */
planColor: string;
unit: DistanceUnit;
/** The switch for the plan map; left out where no plan map is beside the detail (the phone). */
map?: { shown: boolean; onToggle: () => void };
}
/**
* ACT-02 in an actual track's card: its values beside the plan's, each side
* named and drawn as it is on the map, the difference only where both are
* known, and how far the track strayed from the planned line. It reads both
* and changes neither; the map switch only draws.
*/
export default function TourActivityCompare({ activity, plan, planColor, unit, map }: TourActivityCompareProps) {
const { t } = useTranslation();
const rows = useMemo(() => compareRows(plan, activity.metrics), [activity.metrics, plan]);
const deviation = useMemo(() => deviationFields(activity, plan, unit, t), [activity, plan, unit, t]);
const mapLabel = t(map?.shown ? 'tours.activity.compare.hide' : 'tours.activity.compare.show');
return (
<section aria-label={t('tours.activity.compare.title')} className="flex flex-col gap-2">
<div className="flex min-h-[28px] items-center gap-2">
<Eyebrow>{t('tours.activity.compare.title')}</Eyebrow>
{map && activity.geometry.length >= 2 && (
<span className="ms-auto inline-flex">
<Tooltip label={t('tours.activity.compare.showHint')} disabled={map.shown}>
<span className="inline-flex">
<CardAction icon={<MapIcon size={12} strokeWidth={2.2} />} onClick={map.onToggle} pressed={map.shown}>
{mapLabel}
</CardAction>
</span>
</Tooltip>
</span>
)}
</div>
<CompareTable rows={rows} plan={plan} planColor={planColor} unit={unit} />
<TourFactFields fields={deviation} columns={deviation.length === 2 ? 2 : undefined} />
</section>
);
}
@@ -1,13 +1,16 @@
// FE-ACT-SECTION-001 to FE-ACT-SECTION-006
// FE-ACT-SECTION-001 to FE-ACT-SECTION-007
// ACT-01 in the tour's detail, rendered without the translation provider so
// every text reads as its key: the actual tracks with their own values marked
// as such, a time only where the track measured one, and the actions only for
// someone who may change the tour.
import { fireEvent, render, screen } from '@testing-library/react';
// as such beside the plan's (ACT-02), a time only where the track measured
// one, the actions only for someone who may change the tour, and the map
// comparison only where a plan map is beside the detail.
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Day } from '../../../types';
import TourActivitySection from './TourActivitySection';
import type { PlanValues } from './tourActivityCompare';
import type { TourActivities } from './useTourActivities';
const hook = vi.hoisted(() => ({ state: null as unknown }));
@@ -60,30 +63,43 @@ function state(overrides: Partial<TourActivities> = {}): TourActivities {
} as TourActivities;
}
const show = (canEdit = true, item: TourListItem = tour) =>
render(<TourActivitySection tripId={1} tour={item} days={days} assignments={assignments} canEdit={canEdit} />);
const plan: PlanValues = { distanceKm: 0.1, gain: 80, loss: 0, minutes: 180, minutesAtLeast: false, line: null };
const show = (canEdit = true, item: TourListItem = tour, mapCompare = false) =>
render(
<TourActivitySection
tripId={1}
tour={item}
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor="plan-paint"
mapCompare={mapCompare}
/>
);
beforeEach(() => {
hook.state = state();
useTourActivityCompareStore.getState().clear();
});
describe('TourActivitySection (ACT-01)', () => {
it('FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values and its profile', () => {
it("FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values beside the plan's, and its profile", () => {
show();
expect(screen.getByText('tours.activity.label')).toBeInTheDocument();
expect(screen.getByText('Watch export')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('dayplan.dayN');
expect(screen.getByText('morning.gpx')).toBeInTheDocument();
expect(screen.getByText('tours.activity.badge')).toBeInTheDocument();
expect(screen.getByText('tours.activity.time')).toBeInTheDocument();
expect(screen.getByText('3 h 15 min')).toBeInTheDocument();
expect(screen.getByText('tours.detail.ascent')).toBeInTheDocument();
expect(screen.getAllByText('tours.activity.badge').length).toBeGreaterThan(0);
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('3 h 15 min');
expect(screen.getByTestId('tour-activity-compare-ascent')).toHaveTextContent('+20 m');
});
it('FE-ACT-SECTION-002: a track without times measures no time, and a track without a day says so', () => {
hook.state = state({ activities: [activity({ elapsed_minutes: null, started_at: null, ended_at: null }, null)] });
show();
expect(screen.getByText('tours.activity.noTime')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('tours.activity.noTime');
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('tours.activity.noDay');
expect(screen.queryByText('tours.activity.recordedOn')).toBeNull();
});
@@ -146,4 +162,20 @@ describe('TourActivitySection (ACT-01)', () => {
show(false);
expect(screen.getByText('tours.activity.unavailable')).toBeInTheDocument();
});
it('FE-ACT-SECTION-007: beside a plan map a track is compared there and back, and on the phone it is not offered', () => {
const { unmount } = show(false, tour, true);
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.hide/ }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
show(false);
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
});
@@ -11,24 +11,21 @@ import { DialogSection, fs } from '../../shared/DialogShell';
import ElevationProfile from '../../shared/ElevationProfile';
import { Tooltip } from '../../shared/Tooltip';
import { dayMenuLabel } from '../tourDays';
import { CardAction, TourMetricFields, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { CardAction, TourNotice } from '../tourParts';
import TourActivityCompare from './TourActivityCompare';
import TourActivityDialog from './TourActivityDialog';
import { ACTUAL_TRACK_COLOR } from './activityCompareParts';
import type { PlanValues } from './tourActivityCompare';
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison';
import { useTourActivities, type TourActivities } from './useTourActivities';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The line and the profile of what was recorded take the colour of done, apart from the plan's track colour. */
const RECORDED_COLOR = 'var(--success)';
/** The recorded time as a field: measured from the track's times, or said to be missing, never estimated. */
function timeField(activity: TourActivity, t: Translate): TourMetricField {
const minutes = activity.metrics.elapsed_minutes;
const label = t('tours.activity.time');
return minutes === null
? { label, value: t('tours.activity.noTime'), info: { label, description: t('tours.activity.noTimeHint') } }
: { label, value: formatPlannedTourDuration(minutes), info: { label, description: t('tours.activity.timeHint') } };
/** What a card compares its track with: the plan's values and colour, and the map switch where there is a plan map. */
interface PlanSide {
plan: PlanValues;
planColor: string;
/** Null on the phone, whose detail has no plan map beside it. */
comparison: ActivityComparisonState | null;
}
/** The tile of a recording: footprints, with a small dot at its foot that says it was recorded. */
@@ -76,19 +73,26 @@ function ActivityBadges({ activity, execution }: { activity: TourActivity; execu
);
}
/** One recording: who it is, its values marked as recorded, its profile, and what the user can do with it. */
/**
* One recording: who it is, its values beside the plan's (ACT-02), its
* profile, and what the user can do with it.
*/
function ActivityCard({
activity,
execution,
state,
side,
}: {
activity: TourActivity;
execution: TourExecution;
state: TourActivities;
side: PlanSide;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const analysis = useMemo(() => analysisOfLine(activity.geometry), [activity.geometry]);
const trackPlaceId = activity.track.place_id;
const { comparison } = side;
return (
<article data-testid="tour-activity" className={`${BOX} overflow-hidden`}>
<header className="flex items-start gap-2.5 px-3 py-2.5">
@@ -108,11 +112,21 @@ function ActivityCard({
</Tooltip>
</header>
<div className="flex flex-col gap-2 border-t border-edge-faint p-2.5">
<TourMetricFields analysis={analysis} unit={unit} extra={[timeField(activity, t)]} />
<TourActivityCompare
activity={activity}
plan={side.plan}
planColor={side.planColor}
unit={unit}
map={
comparison
? { shown: comparison.comparedTrack === trackPlaceId, onToggle: () => comparison.toggle(trackPlaceId) }
: undefined
}
/>
{analysis && analysis.distanceIndexedProfileSamples.length >= 2 && (
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={RECORDED_COLOR}
color={ACTUAL_TRACK_COLOR}
gradientId={`tour-activity-${activity.id}`}
/>
)}
@@ -157,7 +171,15 @@ function NoActivity() {
}
/** What the section holds: the tracks, the empty place for one, or word that the tour's tracks could not be read. */
function ActivityList({ state, executions }: { state: TourActivities; executions: readonly TourExecution[] }) {
function ActivityList({
state,
executions,
side,
}: {
state: TourActivities;
executions: readonly TourExecution[];
side: PlanSide;
}) {
const { t } = useTranslation();
const activities = state.activities ?? [];
if (state.unavailable) {
@@ -176,6 +198,7 @@ function ActivityList({ state, executions }: { state: TourActivities; executions
activity={activity}
execution={executionOf(executions, activity.assignment_id)}
state={state}
side={side}
/>
))}
</div>
@@ -188,6 +211,12 @@ interface TourActivitySectionProps {
days: Day[];
assignments: AssignmentsMap;
canEdit: boolean;
/** ACT-02: what the plan says, for each track's values to stand beside. */
plan: PlanValues;
/** The plan's track colour, as the map and the table draw it. */
planColor: string;
/** Offers to compare a track on the plan map; only where one is beside the detail. */
mapCompare?: boolean;
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
@@ -195,8 +224,10 @@ interface TourActivitySectionProps {
* ACT-01 in the tour's detail: the tracks the tour was actually done with,
* each with the values its own recording measures and its profile, apart from
* the planned values above; and for someone who may change the tour, the way
* to assign one, change it or take it off. A tour that is itself the recording
* of another shows none: it says so in its head instead.
* to assign one, change it or take it off. Each track's values stand beside
* the plan's, and one track at a time can be compared with the plan on the map
* (ACT-02). A tour that is itself the recording of another shows none: it says
* so in its head instead.
*/
export default function TourActivitySection({
tripId,
@@ -204,12 +235,17 @@ export default function TourActivitySection({
days,
assignments,
canEdit,
plan,
planColor,
mapCompare = false,
onToursChanged,
}: TourActivitySectionProps) {
const { t } = useTranslation();
const executions = useMemo(() => executionsOf(tour.place_id, days, assignments), [assignments, days, tour.place_id]);
const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged });
const { activities, unavailable } = state;
const comparison = useActivityComparison(tour.place_id, activities);
const side: PlanSide = { plan, planColor, comparison: mapCompare ? comparison : null };
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) {
return null;
}
@@ -233,7 +269,7 @@ export default function TourActivitySection({
)
}
>
<ActivityList state={state} executions={executions} />
<ActivityList state={state} executions={executions} side={side} />
{state.assigning && (
<TourActivityDialog
tripId={tripId}
@@ -0,0 +1,28 @@
/**
* ACT-02: how plan and actual track look wherever they are named side by
* side, so the detail's table and the map's legend show the same two lines:
* the plan solid in its track colour, the actual track dotted in the colour
* of done. Dotted as well as coloured, so the two stay apart for someone who
* cannot tell the hues apart, and on a screenshot in black and white.
*/
/** The colour of what was recorded, as the actual track's card and profile already use it. */
export const ACTUAL_TRACK_COLOR = 'var(--success)';
/** A short stretch of line: solid for the plan, dotted for the actual track. */
export function LineSwatch({ color, dotted = false }: { color: string; dotted?: boolean }) {
return (
<svg width="22" height="8" viewBox="0 0 22 8" aria-hidden="true" className="flex-none overflow-visible">
<line
x1="2"
y1="4"
x2="20"
y2="4"
strokeWidth={dotted ? 3.2 : 3.5}
strokeLinecap="round"
strokeDasharray={dotted ? '0.1 5.4' : undefined}
style={{ stroke: color }}
/>
</svg>
);
}
@@ -0,0 +1,108 @@
// FE-ACT-COMPARE-MODEL-001 to FE-ACT-COMPARE-MODEL-006
// ACT-02, the pure half of comparing a plan with its actual track: the values
// side by side, a difference only where both are known and comparable, and
// how far the recorded line strayed from the planned one.
import type { TourActivityMetrics } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 8,
gain: 600,
loss: 550,
minutes: 240,
minutesAtLeast: false,
line: null,
};
const metrics: TourActivityMetrics = {
distance_km: 8.6,
elevation_gain_m: 640,
elevation_loss_m: null,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T10:30:00Z',
elapsed_minutes: 270,
point_count: 400,
};
// A planned line running north along 11° E for about 2.2 km.
const planned = [
[47, 11],
[47.01, 11],
[47.02, 11],
];
describe('compareRows (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-001: names both values of every measure and what lies between them', () => {
const rows = compareRows(plan, metrics);
expect(rows.map((row) => row.key)).toEqual(['distance', 'ascent', 'descent', 'time']);
expect(rows[0]).toEqual({ key: 'distance', plan: 8, actual: 8.6, difference: expect.closeTo(0.6, 6) });
expect(rows[1]).toEqual({ key: 'ascent', plan: 600, actual: 640, difference: 40 });
expect(rows[3]).toEqual({ key: 'time', plan: 240, actual: 270, difference: 30 });
});
it('FE-ACT-COMPARE-MODEL-002: a value one side does not carry stays missing, and so does the difference', () => {
const rows = compareRows({ ...plan, gain: null, minutes: null }, metrics);
expect(rows[1]).toEqual({ key: 'ascent', plan: null, actual: 640, difference: null });
expect(rows[2]).toEqual({ key: 'descent', plan: 550, actual: null, difference: null });
expect(rows[3]).toEqual({ key: 'time', plan: null, actual: 270, difference: null });
});
it('FE-ACT-COMPARE-MODEL-003: a planned total that is only a floor is shown but never subtracted from', () => {
const time = compareRows({ ...plan, minutesAtLeast: true }, metrics)[3];
expect(time).toEqual({ key: 'time', plan: 240, actual: 270, difference: null });
});
});
describe('planDeviation (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-004: a track along the plan stays on it', () => {
const deviation = planDeviation(
[
[47, 11.0001],
[47.01, 11.0001],
[47.02, 11.0001],
],
planned
);
expect(deviation?.farthestM).toBeLessThan(OFF_PLAN_M);
expect(deviation?.offPlanKm).toBe(0);
expect(deviation?.offPlanShare).toBe(0);
});
it('FE-ACT-COMPARE-MODEL-005: a detour away from the plan is measured by its farthest point and its length', () => {
// Out along the plan, then about 760 m east and back to the plan's end.
const deviation = planDeviation(
[
[47, 11],
[47.01, 11],
[47.012, 11.01],
[47.018, 11.01],
[47.02, 11],
],
planned
);
expect(deviation?.farthestM).toBeGreaterThan(740);
expect(deviation?.farthestM).toBeLessThan(780);
// Only the leg between the two eastern points has both ends off the plan: about 670 m.
expect(deviation?.offPlanKm).toBeGreaterThan(0.6);
expect(deviation?.offPlanKm).toBeLessThan(0.7);
// Of about 3.4 km recorded in all.
expect(deviation?.offPlanShare).toBeGreaterThan(0.15);
expect(deviation?.offPlanShare).toBeLessThan(0.25);
});
it('FE-ACT-COMPARE-MODEL-006: nothing without two points on either line, and a long track measured in samples to its end', () => {
expect(planDeviation([[47, 11]], planned)).toBeNull();
expect(planDeviation(planned, null)).toBeNull();
expect(planDeviation(planned, [[47, 11]])).toBeNull();
// Twice as many points as are measured along the plan, then on past its end; the last point is kept.
const count = MEASURED_POINTS * 2;
const long = Array.from({ length: count }, (_, index) => [47 + (0.02 * index) / count, 11]);
long.push([47.03, 11], [47.04, 11]);
const deviation = planDeviation(long, planned);
expect(deviation?.farthestM).toBeGreaterThan(2200);
expect(deviation?.offPlanKm).toBeGreaterThan(1.1);
expect(deviation?.offPlanKm).toBeLessThan(1.12);
});
});
@@ -0,0 +1,104 @@
import { distancesToLine, haversineMetres, type TourActivityMetrics } from '@trek/shared';
/**
* ACT-02, the pure half of comparing a tour's plan with a track it was
* actually done with: the values both carry side by side, the difference only
* where both are known and mean the same thing, and how far the recorded line
* strayed from the planned one. Nothing is estimated: a value one side does
* not carry stays missing and is said to be.
*/
type Line = readonly (readonly number[])[];
/** What the plan says, as the tour's detail shows it in its head. */
export interface PlanValues {
distanceKm: number | null;
gain: number | null;
loss: number | null;
/** The planned total, breaks included, as a recording's time includes its pauses; null without one. */
minutes: number | null;
/** The total leaves out a stay nobody entered yet (CW-02): a floor, not a value to subtract from. */
minutesAtLeast: boolean;
/** The planned line, `[lat, lng]` points; null while the tour has none. */
line: Line | null;
}
export type CompareKey = 'distance' | 'ascent' | 'descent' | 'time';
/** One measure of both: kilometres, metres or minutes, as the key says. */
export interface CompareRow {
key: CompareKey;
plan: number | null;
actual: number | null;
/** Actual less planned; null unless both are known and comparable. */
difference: number | null;
}
function row(key: CompareKey, plan: number | null, actual: number | null, comparable = true): CompareRow {
return { key, plan, actual, difference: comparable && plan !== null && actual !== null ? actual - plan : null };
}
/** The four measures both sides can carry, in the order the detail names them. */
export function compareRows(plan: PlanValues, metrics: TourActivityMetrics): CompareRow[] {
return [
row('distance', plan.distanceKm, metrics.distance_km),
row('ascent', plan.gain, metrics.elevation_gain_m),
row('descent', plan.loss, metrics.elevation_loss_m),
row('time', plan.minutes, metrics.elapsed_minutes, !plan.minutesAtLeast),
];
}
/**
* How far from the planned line a recorded point may lie and still count as
* on it: a phone's position wanders by tens of metres, and a planned line runs
* down the middle of a road the track walked beside.
*/
export const OFF_PLAN_M = 50;
/**
* At most this many recorded points are measured. A track logged every second
* holds tens of thousands, each measured against every leg of the plan; every
* n-th point keeps the measuring quick and the answer within a few metres.
*/
export const MEASURED_POINTS = 1500;
interface PlanDeviation {
/** The largest distance of a recorded point from the planned line, in metres. */
farthestM: number;
/** The recorded kilometres farther than OFF_PLAN_M from the plan. */
offPlanKm: number;
/** That share of the recorded distance, 0 to 1. */
offPlanShare: number;
}
/** Every n-th point, the last always among them, so the measured line still ends where the track did. */
function sampled(points: Line): Line {
if (points.length <= MEASURED_POINTS) return points;
const step = Math.ceil(points.length / MEASURED_POINTS);
const kept = points.filter((_, index) => index % step === 0);
return kept[kept.length - 1] === points[points.length - 1] ? kept : [...kept, points[points.length - 1]];
}
/**
* Where the recorded line left the planned one: its farthest point and the
* stretches away from it. A stretch counts as away when both its ends lie
* farther than OFF_PLAN_M. Null without two points on either line.
*/
export function planDeviation(recorded: Line, planned: Line | null): PlanDeviation | null {
if (!planned || planned.length < 2 || recorded.length < 2) return null;
const points = sampled(recorded);
const distances = distancesToLine(points, planned);
if (!distances) return null;
let total = 0;
let off = 0;
let farthestM = distances[0] ?? 0;
for (let index = 1; index < points.length; index += 1) {
const from = points[index - 1];
const to = points[index];
const step = haversineMetres(Number(from[0]), Number(from[1]), Number(to[0]), Number(to[1]));
total += step;
if ((distances[index - 1] ?? 0) > OFF_PLAN_M && (distances[index] ?? 0) > OFF_PLAN_M) off += step;
farthestM = Math.max(farthestM, distances[index] ?? 0);
}
return { farthestM, offPlanKm: off / 1000, offPlanShare: total > 0 ? off / total : 0 };
}
@@ -0,0 +1,117 @@
// FE-ACT-COMPARE-MAP-001 to FE-ACT-COMPARE-MAP-005
// ACT-02 on the plan map: while a tour is compared with its actual track, the
// track leaves the map as a tour of its own and comes back as one dotted line
// of the route, the plan stays on the map, and the legend names both; off,
// the map gets exactly what it would have had.
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../../tests/helpers/factories';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { compareMapLines, useActivityCompareMap } from './useActivityCompareMap';
const plan = buildPlace({ id: 7, name: 'Ridge', route_color: 'plan-paint' });
const track = buildPlace({
id: 20,
name: 'Watch export',
route_geometry: JSON.stringify([
[47, 11, 600],
[47.01, 11, 640],
]),
});
const cafe = buildPlace({ id: 3, name: 'Cafe' });
const places = [plan, track, cafe];
const dayRoute: [number, number][][] = [
[
[46, 10],
[46.1, 10],
],
[
[46.1, 10],
[46.2, 10],
],
];
const paint = { line: 'actual-paint', casing: 'casing-paint' };
const input = {
enabled: true,
places,
mapPlaces: [plan, track, cafe],
route: dayRoute,
routeWalking: [true],
selectedPlaceId: 7,
};
beforeEach(() => {
useTourActivityCompareStore.getState().clear();
});
describe('compareMapLines (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-001: lays the track into the route, dotted and in its own colour, and names both lines', () => {
const drawn = compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 20 }, paint);
expect(drawn.places.map((place) => place.id)).toEqual([7, 3]);
expect(drawn.route).toEqual([
...dayRoute,
[
[47, 11],
[47.01, 11],
],
]);
// The route's own flags run short of its lines; they are read by index.
expect(drawn.routeWalking).toEqual([true, false, true]);
expect(drawn.routeColors).toEqual([undefined, undefined, paint]);
expect(drawn.legend).toEqual({ planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' });
});
it('FE-ACT-COMPARE-MAP-002: a plan hidden from the map comes back, and a day without a route has the track alone', () => {
const drawn = compareMapLines(
{ ...input, mapPlaces: [cafe], route: null, routeWalking: null },
{ tourPlaceId: 7, trackPlaceId: 20 },
paint
);
expect(drawn.places.map((place) => place.id)).toEqual([3, 7]);
expect(drawn.route).toHaveLength(1);
expect(drawn.routeWalking).toEqual([true]);
expect(drawn.routeColors).toEqual([paint]);
});
it('FE-ACT-COMPARE-MAP-003: off, beside another tour, elsewhere on the map or without a line, the map gets what it had', () => {
const cases = [
compareMapLines(input, null, paint),
compareMapLines({ ...input, selectedPlaceId: 3 }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines({ ...input, enabled: false }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 3 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 99 }, paint),
];
for (const drawn of cases) {
expect(drawn.places).toBe(input.mapPlaces);
expect(drawn.route).toBe(dayRoute);
expect(drawn.routeWalking).toBe(input.routeWalking);
expect(drawn.routeColors).toBeUndefined();
expect(drawn.legend).toBeNull();
}
});
});
describe('useActivityCompareMap (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-004: follows the comparison the detail switches on and off', () => {
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.legend).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
expect(result.current.legend?.trackName).toBe('Watch export');
expect(result.current.route).toHaveLength(3);
act(() => useTourActivityCompareStore.getState().clear());
expect(result.current.route).toBe(dayRoute);
});
it('FE-ACT-COMPARE-MAP-005: draws the track in the colour the theme gives done', () => {
document.documentElement.style.setProperty('--success', 'success-paint');
try {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.routeColors?.[2]?.line).toBe('success-paint');
} finally {
document.documentElement.style.removeProperty('--success');
}
});
});
@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Place } from '../../../types';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { useMapPaint } from '../../Map/mapPaint';
import { resolveTrackColor } from '../../Map/trackColors';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/** The actual track's colour resolved for map paint; the map cannot read a CSS variable. */
const ACTUAL_PAINT = { line: '--success', casing: '--surface-card' } as const;
/** What the legend over the map names: the two lines, each as it is drawn. */
export interface ActivityCompareLegend {
planName: string;
planColor: string;
trackName: string;
}
interface CompareMapInput<P extends Place> {
/** False where the map shows something else (the road trip, the trip overview): nothing is compared there. */
enabled: boolean;
/** Every place of the trip, to find the plan and the recording in even when the map leaves them out. */
places: Place[];
/** The places the map draws. */
mapPlaces: P[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** The place whose detail is open: the comparison shows only beside its plan. */
selectedPlaceId: number | null;
}
/** The map's input as it is drawn: unchanged, or with the comparison laid in. */
interface CompareMapLines<P extends Place> {
places: (P | Place)[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** One entry per line of `route`; left out, the route keeps its own colour throughout. */
routeColors: LineColor[] | undefined;
legend: ActivityCompareLegend | null;
}
/**
* The plan map's places and lines while the comparison is on, and the
* legend's names. Pure, for the hook below and its tests.
*
* The plan is the tour's own track, picked on the map and drawn in its track
* colour; it is put back on the map should the person have hidden it there.
* The recording leaves the map as a tour of its own and comes back as one more
* line of the route, dotted in the colour of done, so it is drawn once and
* never in the colour of a plan. Off, everything stays exactly as it came.
*/
export function compareMapLines<P extends Place>(
input: CompareMapInput<P>,
comparison: { tourPlaceId: number; trackPlaceId: number } | null,
paint: { line: string; casing: string }
): CompareMapLines<P> {
const unchanged: CompareMapLines<P> = {
places: input.mapPlaces,
route: input.route,
routeWalking: input.routeWalking,
routeColors: undefined,
legend: null,
};
if (!input.enabled || !comparison || comparison.tourPlaceId !== input.selectedPlaceId) return unchanged;
const plan = input.places.find((place) => place.id === comparison.tourPlaceId);
const track = input.places.find((place) => place.id === comparison.trackPlaceId);
const line = parseRenderableRouteGeometry(track?.route_geometry);
if (!plan || !track || !line || line.length < 2) return unchanged;
const route = input.route ?? [];
const drawn = input.mapPlaces.filter((place) => place.id !== track.id);
return {
places: drawn.some((place) => place.id === plan.id) ? drawn : [...drawn, plan],
route: [...route, line.map(([lat, lng]): [number, number] => [lat, lng])],
// Aligned by index: the route's own flags may run short of its lines.
routeWalking: [...route.map((_, index) => input.routeWalking?.[index] === true), true],
routeColors: [...route.map((): LineColor => undefined), paint],
legend: { planName: plan.name, planColor: resolveTrackColor(plan), trackName: track.name },
};
}
/** ACT-02 on the plan map: `compareMapLines` with the comparison of the store and the theme's colours. */
export function useActivityCompareMap<P extends Place>(input: CompareMapInput<P>) {
const comparison = useTourActivityCompareStore((state) => state.comparison);
const paint = useMapPaint(ACTUAL_PAINT);
const { enabled, places, mapPlaces, route, routeWalking, selectedPlaceId } = input;
return useMemo(
() => compareMapLines({ enabled, places, mapPlaces, route, routeWalking, selectedPlaceId }, comparison, paint),
[comparison, enabled, mapPlaces, paint, places, route, routeWalking, selectedPlaceId]
);
}
@@ -0,0 +1,61 @@
// FE-ACT-COMPARE-HOOK-001 to FE-ACT-COMPARE-HOOK-004
// ACT-02 from the tour's detail: one actual track at a time is compared on
// the map, and the comparison ends with the detail or with the track's link.
import { act, renderHook } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { useActivityComparison } from './useActivityComparison';
const linked = (trackPlaceId: number) => ({ id: trackPlaceId, track: { place_id: trackPlaceId } }) as TourActivity;
const activities = [linked(20), linked(21)];
beforeEach(() => {
useTourActivityCompareStore.getState().clear();
});
describe('useActivityComparison (ACT-02)', () => {
it('FE-ACT-COMPARE-HOOK-001: switches one track on, another instead, and off again', () => {
const { result } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
act(() => result.current.toggle(20));
expect(result.current.comparedTrack).toBe(20);
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBe(21);
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBeNull();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it("FE-ACT-COMPARE-HOOK-002: another tour's comparison is not this one's, and is left alone when this detail closes", () => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 9, trackPlaceId: 20 });
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
unmount();
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 9, trackPlaceId: 20 });
});
it('FE-ACT-COMPARE-HOOK-003: closing the detail ends its comparison', () => {
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
act(() => result.current.toggle(20));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it('FE-ACT-COMPARE-HOOK-004: a track no longer linked stops being compared; one still loading is kept', () => {
const { result, rerender } = renderHook(({ list }) => useActivityComparison(7, list), {
initialProps: { list: activities as TourActivity[] | null },
});
act(() => result.current.toggle(20));
rerender({ list: null });
expect(result.current.comparedTrack).toBe(20);
rerender({ list: [linked(21)] });
expect(result.current.comparedTrack).toBeNull();
});
});
@@ -0,0 +1,43 @@
import type { TourActivity } from '@trek/shared';
import { useCallback, useEffect } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
/**
* ACT-02 from the tour's detail: which of its actual tracks the plan map
* compares with the plan, and the switch for it. One at a time: comparing
* another ends the first. The comparison ends with the detail, and when the
* track it shows is no longer linked to this tour, so the map never compares
* a plan with a track that is not its own.
*/
export function useActivityComparison(tourPlaceId: number, activities: readonly TourActivity[] | null) {
const comparedTrack = useTourActivityCompareStore((state) =>
state.comparison?.tourPlaceId === tourPlaceId ? state.comparison.trackPlaceId : null
);
useEffect(() => {
if (comparedTrack === null || activities === null) return;
if (!activities.some((activity) => activity.track.place_id === comparedTrack)) {
useTourActivityCompareStore.getState().clear();
}
}, [activities, comparedTrack]);
useEffect(
() => () => {
const { comparison, clear } = useTourActivityCompareStore.getState();
if (comparison?.tourPlaceId === tourPlaceId) clear();
},
[tourPlaceId]
);
const toggle = useCallback(
(trackPlaceId: number) => {
const { show } = useTourActivityCompareStore.getState();
show(comparedTrack === trackPlaceId ? null : { tourPlaceId, trackPlaceId });
},
[comparedTrack, tourPlaceId]
);
return { comparedTrack, toggle };
}
export type ActivityComparisonState = ReturnType<typeof useActivityComparison>;
+8 -1
View File
@@ -434,19 +434,26 @@ export function CardAction({
icon,
onClick,
disabled = false,
pressed,
children,
}: {
icon: ReactNode
onClick: () => void
disabled?: boolean
/** Makes it a switch: on, it takes the accent and says so to a screen reader. */
pressed?: boolean
children: ReactNode
}) {
const look = pressed
? 'bg-accent text-accent-text ring-transparent'
: 'bg-surface-card text-content-secondary ring-edge-faint enabled:hover:text-content'
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content disabled:cursor-default disabled:opacity-40"
aria-pressed={pressed}
className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded-full px-2.5 font-semibold shadow-sm ring-1 ring-inset transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40 ${look}`}
style={fs(11.5, 'body')}
>
<span className="flex-none">{icon}</span>
+1 -1
View File
@@ -275,7 +275,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}>
<PlanMapStage
tripId={tripId} trip={trip} can={can} days={days} reservations={reservations} isMobile={isMobile}
tripId={tripId} trip={trip} can={can} days={days} places={places} reservations={reservations} isMobile={isMobile}
selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedPlace={selectedPlace} showDayDetail={showDayDetail} handleSelectDay={handleSelectDay}
mapPlaces={mapPlaces} dayPlaces={dayPlaces} dayOrderMap={dayOrderMap} mapTileUrl={mapTileUrl} fitKey={fitKey} mapLocked={mapLocked} toggleMapLocked={toggleMapLocked} mapFocusPoints={mapFocusPoints}
route={route} routeWalking={routeWalking} routeSegments={routeSegments} routeVias={routeVias} transitRoutesShown={transitRoutesShown}
@@ -1,4 +1,4 @@
// FE-PAGE-PLANNER-MAPSTAGE-001 to FE-PAGE-PLANNER-MAPSTAGE-010
// FE-PAGE-PLANNER-MAPSTAGE-001 to FE-PAGE-PLANNER-MAPSTAGE-011
//
// The plan tab's map and its floating controls, rendered on their own. The map
// and the pills are stubs that record their props, so each mode the stage
@@ -6,6 +6,7 @@
import React from 'react'
import { render, screen } from '../../../tests/helpers/render'
import { buildTrip, buildPlace, buildReservation } from '../../../tests/helpers/factories'
import { useTourActivityCompareStore } from '../../store/tourActivityCompareStore'
import { PlanMapStage, type PlanMapStageProps } from './PlanMapStage'
type Recorded = Record<string, unknown> & Record<`on${string}`, (...args: unknown[]) => unknown>
@@ -68,6 +69,7 @@ function renderStage(overrides: Partial<Record<keyof PlanMapStageProps, unknown>
beforeEach(() => {
for (const key of Object.keys(captured)) delete captured[key]
useTourActivityCompareStore.getState().clear()
})
describe('PlanMapStage', () => {
@@ -210,4 +212,20 @@ describe('PlanMapStage', () => {
loadingKeys: poi.loadingKeys, errorKeys: poi.errorKeys, moved: false, onSearchArea: poi.searchArea,
})
})
it('FE-PAGE-PLANNER-MAPSTAGE-011: a tour compared with its actual track draws the track dotted and explains both lines (ACT-02)', () => {
const plan = buildPlace({ id: 7, name: 'Ridge' })
const track = buildPlace({ id: 20, name: 'Watch export', route_geometry: JSON.stringify([[47, 11], [47.01, 11]]) })
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 })
const compared = { places: [plan, track], mapPlaces: [plan, track], selectedPlaceId: 7, route: null, routeWalking: [] }
const { rerender } = renderStage(compared)
expect(recorded('map')).toMatchObject({ places: [plan], route: [[[47, 11], [47.01, 11]]], routeWalking: [true] })
expect(screen.getByTestId('activity-compare-legend')).toHaveTextContent('Watch export')
// The road trip and the trip overview draw other lines; nothing is compared there.
rerender(<PlanMapStage {...stageProps({ ...compared, overviewActive: true })} />)
expect(recorded('map')).toMatchObject({ route: ['overview'] })
expect(screen.queryByTestId('activity-compare-legend')).toBeNull()
})
})
+14 -6
View File
@@ -5,6 +5,8 @@ import { MapCompassPill, type CompassMap } from '../../components/Map/MapCompass
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../components/Map/PoiCategoryPill'
import ActivityCompareLegend from '../../components/Tours/activity/ActivityCompareLegend'
import { useActivityCompareMap } from '../../components/Tours/activity/useActivityCompareMap'
import type { Poi } from '../../components/Map/poiCategories'
import type { Settings } from '../../types'
import { RoadtripAlternativesBar } from './plannerLazy'
@@ -16,7 +18,7 @@ type Planner = ReturnType<typeof useTripPlanner>
type PageState = ReturnType<typeof useTripPlannerPage>
// Picked per concern so the key lists stay short and typed by the hook that owns them.
type StageTrip = Pick<Planner, 'tripId' | 'trip' | 'can' | 'days' | 'reservations' | 'isMobile' | 'selectedDayId' | 'selectedPlaceId' | 'selectedPlace' | 'showDayDetail' | 'handleSelectDay'>
type StageTrip = Pick<Planner, 'tripId' | 'trip' | 'can' | 'days' | 'places' | 'reservations' | 'isMobile' | 'selectedDayId' | 'selectedPlaceId' | 'selectedPlace' | 'showDayDetail' | 'handleSelectDay'>
type StageMap = Pick<Planner,
| 'mapPlaces' | 'dayPlaces' | 'dayOrderMap' | 'mapTileUrl' | 'fitKey' | 'mapLocked' | 'toggleMapLocked' | 'mapFocusPoints'
| 'route' | 'routeWalking' | 'routeSegments' | 'routeVias' | 'transitRoutesShown'
@@ -57,7 +59,7 @@ export interface PlanMapStageProps
* exactly what they were when this lived in the page.
*/
export function PlanMapStage({
tripId, trip, can, days, reservations, isMobile,
tripId, trip, can, days, places, reservations, isMobile,
selectedDayId, selectedPlaceId, selectedPlace, showDayDetail, handleSelectDay,
mapPlaces, dayPlaces, dayOrderMap, mapTileUrl, fitKey, mapLocked, toggleMapLocked, mapFocusPoints,
route, routeWalking, routeSegments, routeVias, transitRoutesShown,
@@ -73,17 +75,22 @@ export function PlanMapStage({
poi, tourMap, leftPanelPx, rightPanelPx, mapInsetLeft, mapInsetRight,
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit,
}: Readonly<PlanMapStageProps>): ReactElement {
// ACT-02: a tour compared with its actual track lays the track into the day's lines, dotted.
const compared = useActivityCompareMap({
enabled: !roadtripActive && !overviewActive && !isMobile,
places, mapPlaces, route, routeWalking, selectedPlaceId,
})
return (
<>
<MapView
tripId={tripId}
dawarichTrack={dawarichTrail.track}
dawarichHiddenDates={dawarichHiddenDates}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
places={roadtripActive ? roadtripMapPlaces : compared.places}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeWalking={roadtripActive || overviewActive ? undefined : routeWalking}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : compared.route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : compared.routeColors}
routeWalking={roadtripActive || overviewActive ? undefined : compared.routeWalking}
followSelection={!mapLocked || isMobile}
onToggleFollow={isMobile ? undefined : toggleMapLocked}
routeVias={roadtripActive ? roadtripMapVias : routeVias}
@@ -172,6 +179,7 @@ export function PlanMapStage({
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26, // rtl-lint-disable: measured from the right panel's inset
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{compared.legend && <ActivityCompareLegend legend={compared.legend} />}
{!roadtripActive && overviewActive && (
<TripRouteOverviewPanel
overview={tripOverview}
@@ -0,0 +1,26 @@
import { create } from 'zustand';
/**
* ACT-02: the actual track one tour is compared with on the plan map. Only
* ids: the planned line and the recorded one are both places of the trip, so
* the map reads them where they are kept and a changed recording or plan
* shows changed. Comparing is looking; it writes nothing.
*/
interface ActivityComparison {
/** The planned tour, whose detail is open. */
tourPlaceId: number;
/** The recording linked to it as its actual track. */
trackPlaceId: number;
}
interface TourActivityCompareState {
comparison: ActivityComparison | null;
show: (comparison: ActivityComparison | null) => void;
clear: () => void;
}
export const useTourActivityCompareStore = create<TourActivityCompareState>((set) => ({
comparison: null,
show: (comparison) => set({ comparison }),
clear: () => set({ comparison: null }),
}));
+2
View File
@@ -59,6 +59,8 @@ export const UNRESET_STORES: Record<string, string> = {
useServerVersionStore: NOT_YET,
useStudioStore: NOT_YET,
useSystemNoticeStore: NOT_YET,
useTourActivityCompareStore:
'the actual track compared on the map; the tests that read it clear it in their own beforeEach',
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
useTourPoiStore: 'the places found along a tour; the tests that read it clear it in their own beforeEach',
+14
View File
@@ -1246,5 +1246,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'لا يمكن ربط هذا المسار بهذه الجولة',
'tours.activity.refused.activity_tour_is_track': 'هذه الجولة هي نفسها المسار الفعلي لجولة أخرى',
'tours.activity.refused.activity_assignment_not_of_tour': 'لم تعد الجولة في ذلك اليوم',
'tours.activity.compare.title': 'المخطط والفعلي',
'tours.activity.compare.plan': 'المخطط',
'tours.activity.compare.difference': 'الفرق',
'tours.activity.compare.time': 'الوقت مع الاستراحات',
'tours.activity.compare.notInPlan': 'غير موجود في المخطط',
'tours.activity.compare.notInTrack': 'غير موجود في المسار',
'tours.activity.compare.farthest': 'أبعد نقطة عن المخطط',
'tours.activity.compare.offPlan': 'خارج المخطط',
'tours.activity.compare.offPlanValue': '{distance} ({percent}٪)',
'tours.activity.compare.offPlanHint': 'المقاطع المسجلة التي تبعد أكثر من {distance} عن الخط المخطط',
'tours.activity.compare.noLine': 'لا يوجد خط للمقارنة',
'tours.activity.compare.show': 'قارن على الخريطة',
'tours.activity.compare.showHint': 'يرسم المسار الفعلي منقطًا بجانب المخطط. لا يتغير شيء.',
'tours.activity.compare.hide': 'إنهاء المقارنة',
};
export default tours;
+14
View File
@@ -1214,5 +1214,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Bu iz bu tura bağlana bilməz',
'tours.activity.refused.activity_tour_is_track': 'Bu tur özü başqa turun faktiki izidir',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur artıq o gündə deyil',
'tours.activity.compare.title': 'Plan və faktiki',
'tours.activity.compare.plan': 'Planlaşdırılan',
'tours.activity.compare.difference': 'Fərq',
'tours.activity.compare.time': 'Fasilələrlə vaxt',
'tours.activity.compare.notInPlan': 'Planda yoxdur',
'tours.activity.compare.notInTrack': 'İzdə yoxdur',
'tours.activity.compare.farthest': 'Plandan ən uzaq məsafə',
'tours.activity.compare.offPlan': 'Plandan kənar',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Planlaşdırılmış xətdən {distance} artıq uzaqda olan qeydə alınmış hissələr',
'tours.activity.compare.noLine': 'Müqayisə üçün xətt yoxdur',
'tours.activity.compare.show': 'Xəritədə müqayisə et',
'tours.activity.compare.showHint': 'Faktiki izi planın yanında nöqtəli çəkir. Heç nə dəyişmir.',
'tours.activity.compare.hide': 'Müqayisəni bitir',
};
export default tours;
+14
View File
@@ -1226,5 +1226,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Esta trilha não pode ser vinculada a este passeio',
'tours.activity.refused.activity_tour_is_track': 'Este passeio é a trilha real de outro passeio',
'tours.activity.refused.activity_assignment_not_of_tour': 'O passeio não está mais nesse dia',
'tours.activity.compare.title': 'Plano e real',
'tours.activity.compare.plan': 'Plano',
'tours.activity.compare.difference': 'Diferença',
'tours.activity.compare.time': 'Tempo com pausas',
'tours.activity.compare.notInPlan': 'Não está no plano',
'tours.activity.compare.notInTrack': 'Não está na trilha',
'tours.activity.compare.farthest': 'Maior distância do plano',
'tours.activity.compare.offPlan': 'Fora do plano',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Trechos gravados a mais de {distance} da linha planejada',
'tours.activity.compare.noLine': 'Nenhuma linha para comparar',
'tours.activity.compare.show': 'Comparar no mapa',
'tours.activity.compare.showHint': 'Desenha a trilha real pontilhada ao lado do plano. Nada muda.',
'tours.activity.compare.hide': 'Encerrar comparação',
};
export default tours;
+14
View File
@@ -1233,5 +1233,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Aquest traç no es pot vincular a aquesta ruta',
'tours.activity.refused.activity_tour_is_track': 'Aquesta ruta és alhora el traç real d’una altra ruta',
'tours.activity.refused.activity_assignment_not_of_tour': 'La ruta ja no és en aquell dia',
'tours.activity.compare.title': 'Pla i real',
'tours.activity.compare.plan': 'Pla',
'tours.activity.compare.difference': 'Diferència',
'tours.activity.compare.time': 'Temps amb pauses',
'tours.activity.compare.notInPlan': 'No és al pla',
'tours.activity.compare.notInTrack': 'No és al traç',
'tours.activity.compare.farthest': 'Distància màxima al pla',
'tours.activity.compare.offPlan': 'Fora del pla',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Trams enregistrats a més de {distance} de la línia planificada',
'tours.activity.compare.noLine': 'No hi ha cap línia per comparar',
'tours.activity.compare.show': 'Compara al mapa',
'tours.activity.compare.showHint': 'Dibuixa el traç real puntejat al costat del pla. No canvia res.',
'tours.activity.compare.hide': 'Acaba la comparació',
};
export default tours;
+14
View File
@@ -1212,5 +1212,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Tuto trasu nelze propojit s touto túrou',
'tours.activity.refused.activity_tour_is_track': 'Tato túra je sama skutečnou trasou jiné túry',
'tours.activity.refused.activity_assignment_not_of_tour': 'Túra už v tento den není',
'tours.activity.compare.title': 'Plán a skutečnost',
'tours.activity.compare.plan': 'Plán',
'tours.activity.compare.difference': 'Rozdíl',
'tours.activity.compare.time': 'Čas s přestávkami',
'tours.activity.compare.notInPlan': 'Není v plánu',
'tours.activity.compare.notInTrack': 'Není v trase',
'tours.activity.compare.farthest': 'Největší odchylka od plánu',
'tours.activity.compare.offPlan': 'Mimo plán',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Zaznamenané úseky vzdálené od plánované linie více než {distance}',
'tours.activity.compare.noLine': 'Žádná linie k porovnání',
'tours.activity.compare.show': 'Porovnat na mapě',
'tours.activity.compare.showHint': 'Vykreslí skutečnou trasu tečkovaně vedle plánu. Nic se nemění.',
'tours.activity.compare.hide': 'Ukončit porovnání',
};
export default tours;
+15
View File
@@ -1239,6 +1239,21 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Dieser Track kann nicht mit dieser Tour verknüpft werden',
'tours.activity.refused.activity_tour_is_track': 'Diese Tour ist selbst der Ist-Track einer anderen Tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'Die Tour ist nicht mehr an diesem Tag',
'tours.activity.compare.title': 'Planung und Ist',
'tours.activity.compare.plan': 'Planung',
'tours.activity.compare.difference': 'Unterschied',
'tours.activity.compare.time': 'Zeit mit Pausen',
'tours.activity.compare.notInPlan': 'Nicht in der Planung',
'tours.activity.compare.notInTrack': 'Nicht im Track',
'tours.activity.compare.farthest': 'Größter Abstand zur Planung',
'tours.activity.compare.offPlan': 'Abseits der Planung',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint':
'Aufgezeichnete Abschnitte, die mehr als {distance} von der geplanten Linie entfernt liegen',
'tours.activity.compare.noLine': 'Keine Linie zum Vergleichen',
'tours.activity.compare.show': 'Auf der Karte vergleichen',
'tours.activity.compare.showHint': 'Zeichnet den Ist-Track gepunktet neben die Planung. Es ändert sich nichts.',
'tours.activity.compare.hide': 'Vergleich beenden',
};
export default tours;
+14
View File
@@ -1201,5 +1201,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'This track cannot be linked to this tour',
'tours.activity.refused.activity_tour_is_track': 'This tour is itself the actual track of another tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'The tour is no longer on that day',
'tours.activity.compare.title': 'Plan and actual',
'tours.activity.compare.plan': 'Plan',
'tours.activity.compare.difference': 'Difference',
'tours.activity.compare.time': 'Time with breaks',
'tours.activity.compare.notInPlan': 'Not in the plan',
'tours.activity.compare.notInTrack': 'Not in the track',
'tours.activity.compare.farthest': 'Farthest from the plan',
'tours.activity.compare.offPlan': 'Off the plan',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Recorded stretches more than {distance} away from the planned line',
'tours.activity.compare.noLine': 'No line to compare',
'tours.activity.compare.show': 'Compare on map',
'tours.activity.compare.showHint': 'Draws the actual track dotted beside the plan. Nothing changes.',
'tours.activity.compare.hide': 'End comparison',
};
export default tours;
+14
View File
@@ -1226,5 +1226,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Este track no se puede vincular a esta ruta',
'tours.activity.refused.activity_tour_is_track': 'Esta ruta es a su vez el track real de otra ruta',
'tours.activity.refused.activity_assignment_not_of_tour': 'La ruta ya no está en ese día',
'tours.activity.compare.title': 'Previsto y real',
'tours.activity.compare.plan': 'Previsto',
'tours.activity.compare.difference': 'Diferencia',
'tours.activity.compare.time': 'Tiempo con pausas',
'tours.activity.compare.notInPlan': 'No está en el plan',
'tours.activity.compare.notInTrack': 'No está en el track',
'tours.activity.compare.farthest': 'Máxima distancia al plan',
'tours.activity.compare.offPlan': 'Fuera del plan',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Tramos grabados a más de {distance} de la línea planificada',
'tours.activity.compare.noLine': 'No hay línea para comparar',
'tours.activity.compare.show': 'Comparar en el mapa',
'tours.activity.compare.showHint': 'Dibuja el track real punteado junto al plan. No cambia nada.',
'tours.activity.compare.hide': 'Terminar la comparación',
};
export default tours;
+14
View File
@@ -1210,5 +1210,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Seda rada ei saa selle matkaga siduda',
'tours.activity.refused.activity_tour_is_track': 'See matk on ise teise matka tegelik rada',
'tours.activity.refused.activity_assignment_not_of_tour': 'Matk pole enam sellel päeval',
'tours.activity.compare.title': 'Plaan ja tegelik',
'tours.activity.compare.plan': 'Plaan',
'tours.activity.compare.difference': 'Erinevus',
'tours.activity.compare.time': 'Aeg koos pausidega',
'tours.activity.compare.notInPlan': 'Plaanis puudub',
'tours.activity.compare.notInTrack': 'Rajal puudub',
'tours.activity.compare.farthest': 'Suurim kaugus plaanist',
'tours.activity.compare.offPlan': 'Plaanist kõrval',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Salvestatud lõigud, mis on planeeritud joonest kaugemal kui {distance}',
'tours.activity.compare.noLine': 'Võrdlemiseks pole joont',
'tours.activity.compare.show': 'Võrdle kaardil',
'tours.activity.compare.showHint': 'Joonistab tegeliku raja plaani kõrvale punktiirina. Midagi ei muutu.',
'tours.activity.compare.hide': 'Lõpeta võrdlus',
};
export default tours;
+14
View File
@@ -1250,5 +1250,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Cette trace ne peut pas être liée à cette sortie',
'tours.activity.refused.activity_tour_is_track': 'Cette sortie est elle-même la trace réelle d’une autre sortie',
'tours.activity.refused.activity_assignment_not_of_tour': 'La sortie n’est plus prévue ce jour-là',
'tours.activity.compare.title': 'Prévu et réel',
'tours.activity.compare.plan': 'Prévu',
'tours.activity.compare.difference': 'Écart',
'tours.activity.compare.time': 'Temps avec pauses',
'tours.activity.compare.notInPlan': 'Absent du plan',
'tours.activity.compare.notInTrack': 'Absent de la trace',
'tours.activity.compare.farthest': 'Écart maximal au plan',
'tours.activity.compare.offPlan': 'Hors du plan',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Portions enregistrées à plus de {distance} de la ligne prévue',
'tours.activity.compare.noLine': 'Aucune ligne à comparer',
'tours.activity.compare.show': 'Comparer sur la carte',
'tours.activity.compare.showHint': 'Dessine la trace réelle en pointillés à côté du plan. Rien ne change.',
'tours.activity.compare.hide': 'Terminer la comparaison',
};
export default tours;
+16
View File
@@ -1251,5 +1251,21 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Αυτή η διαδρομή δεν μπορεί να συνδεθεί με αυτή την περιήγηση',
'tours.activity.refused.activity_tour_is_track': 'Αυτή η εκδρομή είναι η ίδια πραγματική διαδρομή άλλης εκδρομής',
'tours.activity.refused.activity_assignment_not_of_tour': 'Η εκδρομή δεν είναι πια σε εκείνη την ημέρα',
'tours.activity.compare.title': 'Σχέδιο και πραγματικό',
'tours.activity.compare.plan': 'Σχέδιο',
'tours.activity.compare.difference': 'Διαφορά',
'tours.activity.compare.time': 'Χρόνος με διαλείμματα',
'tours.activity.compare.notInPlan': 'Δεν υπάρχει στο σχέδιο',
'tours.activity.compare.notInTrack': 'Δεν υπάρχει στη διαδρομή',
'tours.activity.compare.farthest': 'Μέγιστη απόσταση από το σχέδιο',
'tours.activity.compare.offPlan': 'Εκτός σχεδίου',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint':
'Καταγεγραμμένα τμήματα σε απόσταση άνω των {distance} από τη σχεδιασμένη γραμμή',
'tours.activity.compare.noLine': 'Δεν υπάρχει γραμμή για σύγκριση',
'tours.activity.compare.show': 'Σύγκριση στον χάρτη',
'tours.activity.compare.showHint':
'Σχεδιάζει την πραγματική διαδρομή με τελείες δίπλα στο σχέδιο. Τίποτα δεν αλλάζει.',
'tours.activity.compare.hide': 'Τέλος σύγκρισης',
};
export default tours;
+15
View File
@@ -1230,5 +1230,20 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Ez a nyomvonal nem kapcsolható ehhez a túrához',
'tours.activity.refused.activity_tour_is_track': 'Ez a túra maga is egy másik túra tényleges nyomvonala',
'tours.activity.refused.activity_assignment_not_of_tour': 'A túra már nem szerepel azon a napon',
'tours.activity.compare.title': 'Terv és tényleges',
'tours.activity.compare.plan': 'Terv',
'tours.activity.compare.difference': 'Eltérés',
'tours.activity.compare.time': 'Idő szünetekkel',
'tours.activity.compare.notInPlan': 'Nincs a tervben',
'tours.activity.compare.notInTrack': 'Nincs a nyomvonalban',
'tours.activity.compare.farthest': 'Legnagyobb távolság a tervtől',
'tours.activity.compare.offPlan': 'Terven kívül',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint':
'Rögzített szakaszok, amelyek {distance} távolságnál messzebb vannak a tervezett vonaltól',
'tours.activity.compare.noLine': 'Nincs összehasonlítható vonal',
'tours.activity.compare.show': 'Összehasonlítás a térképen',
'tours.activity.compare.showHint': 'A tényleges nyomvonalat pontozva rajzolja a terv mellé. Semmi sem változik.',
'tours.activity.compare.hide': 'Összehasonlítás befejezése',
};
export default tours;
+14
View File
@@ -1212,5 +1212,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Jejak ini tidak dapat ditautkan ke tur ini',
'tours.activity.refused.activity_tour_is_track': 'Tur ini sendiri adalah jejak aktual tur lain',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur ini sudah tidak ada di hari itu',
'tours.activity.compare.title': 'Rencana dan aktual',
'tours.activity.compare.plan': 'Rencana',
'tours.activity.compare.difference': 'Selisih',
'tours.activity.compare.time': 'Waktu dengan istirahat',
'tours.activity.compare.notInPlan': 'Tidak ada di rencana',
'tours.activity.compare.notInTrack': 'Tidak ada di jejak',
'tours.activity.compare.farthest': 'Jarak terjauh dari rencana',
'tours.activity.compare.offPlan': 'Di luar rencana',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Bagian terekam yang berjarak lebih dari {distance} dari garis rencana',
'tours.activity.compare.noLine': 'Tidak ada garis untuk dibandingkan',
'tours.activity.compare.show': 'Bandingkan di peta',
'tours.activity.compare.showHint': 'Menggambar jejak aktual bertitik di samping rencana. Tidak ada yang berubah.',
'tours.activity.compare.hide': 'Akhiri perbandingan',
};
export default tours;
+14
View File
@@ -1240,5 +1240,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Questa traccia non può essere collegata a questo tour',
'tours.activity.refused.activity_tour_is_track': 'Questo tour è a sua volta la traccia effettiva di un altro tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'Il tour non è più in quel giorno',
'tours.activity.compare.title': 'Piano ed effettivo',
'tours.activity.compare.plan': 'Piano',
'tours.activity.compare.difference': 'Differenza',
'tours.activity.compare.time': 'Tempo con pause',
'tours.activity.compare.notInPlan': 'Non nel piano',
'tours.activity.compare.notInTrack': 'Non nella traccia',
'tours.activity.compare.farthest': 'Distanza massima dal piano',
'tours.activity.compare.offPlan': 'Fuori dal piano',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Tratti registrati a più di {distance} dalla linea pianificata',
'tours.activity.compare.noLine': 'Nessuna linea da confrontare',
'tours.activity.compare.show': 'Confronta sulla mappa',
'tours.activity.compare.showHint': 'Disegna la traccia effettiva a puntini accanto al piano. Non cambia nulla.',
'tours.activity.compare.hide': 'Termina il confronto',
};
export default tours;
+14
View File
@@ -1176,5 +1176,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'このトラックはこのツアーにリンクできません',
'tours.activity.refused.activity_tour_is_track': 'このツアー自体が別のツアーの実際のトラックです',
'tours.activity.refused.activity_assignment_not_of_tour': 'このツアーはもうその日にありません',
'tours.activity.compare.title': '予定と実績',
'tours.activity.compare.plan': '予定',
'tours.activity.compare.difference': '差',
'tours.activity.compare.time': '休憩込みの時間',
'tours.activity.compare.notInPlan': '予定にありません',
'tours.activity.compare.notInTrack': 'トラックにありません',
'tours.activity.compare.farthest': '予定からの最大距離',
'tours.activity.compare.offPlan': '予定外',
'tours.activity.compare.offPlanValue': '{distance}({percent}%)',
'tours.activity.compare.offPlanHint': '予定ルートから {distance} 以上離れた記録区間',
'tours.activity.compare.noLine': '比較するルートがありません',
'tours.activity.compare.show': '地図で比較',
'tours.activity.compare.showHint': '実際のトラックを予定の横に点線で描きます。何も変更されません。',
'tours.activity.compare.hide': '比較を終了',
};
export default tours;
+14
View File
@@ -1174,5 +1174,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': '이 트랙은 이 투어에 연결할 수 없습니다',
'tours.activity.refused.activity_tour_is_track': '이 투어는 다른 투어의 실제 트랙입니다',
'tours.activity.refused.activity_assignment_not_of_tour': '이 투어는 더 이상 그날에 없습니다',
'tours.activity.compare.title': '계획과 실제',
'tours.activity.compare.plan': '계획',
'tours.activity.compare.difference': '차이',
'tours.activity.compare.time': '휴식 포함 시간',
'tours.activity.compare.notInPlan': '계획에 없음',
'tours.activity.compare.notInTrack': '트랙에 없음',
'tours.activity.compare.farthest': '계획에서 가장 먼 거리',
'tours.activity.compare.offPlan': '계획 이탈',
'tours.activity.compare.offPlanValue': '{distance} ({percent}%)',
'tours.activity.compare.offPlanHint': '계획된 선에서 {distance} 이상 떨어진 기록 구간',
'tours.activity.compare.noLine': '비교할 선이 없음',
'tours.activity.compare.show': '지도에서 비교',
'tours.activity.compare.showHint': '실제 트랙을 계획 옆에 점선으로 그립니다. 아무것도 바뀌지 않습니다.',
'tours.activity.compare.hide': '비교 종료',
};
export default tours;
+14
View File
@@ -1233,5 +1233,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Deze track kan niet aan deze tocht worden gekoppeld',
'tours.activity.refused.activity_tour_is_track': 'Deze tocht is zelf de werkelijke track van een andere tocht',
'tours.activity.refused.activity_assignment_not_of_tour': 'De tocht staat niet meer op die dag',
'tours.activity.compare.title': 'Planning en werkelijk',
'tours.activity.compare.plan': 'Planning',
'tours.activity.compare.difference': 'Verschil',
'tours.activity.compare.time': 'Tijd met pauzes',
'tours.activity.compare.notInPlan': 'Niet in de planning',
'tours.activity.compare.notInTrack': 'Niet in de track',
'tours.activity.compare.farthest': 'Grootste afstand tot de planning',
'tours.activity.compare.offPlan': 'Buiten de planning',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Opgenomen stukken die meer dan {distance} van de geplande lijn liggen',
'tours.activity.compare.noLine': 'Geen lijn om te vergelijken',
'tours.activity.compare.show': 'Vergelijken op de kaart',
'tours.activity.compare.showHint': 'Tekent de werkelijke track gestippeld naast de planning. Er verandert niets.',
'tours.activity.compare.hide': 'Vergelijking beëindigen',
};
export default tours;
+14
View File
@@ -1254,5 +1254,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Tej trasy nie można powiązać z tą wycieczką',
'tours.activity.refused.activity_tour_is_track': 'Ta wycieczka jest sama rzeczywistą trasą innej wycieczki',
'tours.activity.refused.activity_assignment_not_of_tour': 'Wycieczki nie ma już w tym dniu',
'tours.activity.compare.title': 'Plan i rzeczywistość',
'tours.activity.compare.plan': 'Planowane',
'tours.activity.compare.difference': 'Różnica',
'tours.activity.compare.time': 'Czas z przerwami',
'tours.activity.compare.notInPlan': 'Brak w planie',
'tours.activity.compare.notInTrack': 'Brak w trasie',
'tours.activity.compare.farthest': 'Największa odległość od planu',
'tours.activity.compare.offPlan': 'Poza planem',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Zarejestrowane odcinki oddalone o więcej niż {distance} od planowanej linii',
'tours.activity.compare.noLine': 'Brak linii do porównania',
'tours.activity.compare.show': 'Porównaj na mapie',
'tours.activity.compare.showHint': 'Rysuje rzeczywistą trasę kropkami obok planu. Nic się nie zmienia.',
'tours.activity.compare.hide': 'Zakończ porównanie',
};
export default tours;
+14
View File
@@ -1261,5 +1261,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Этот трек нельзя связать с этим маршрутом',
'tours.activity.refused.activity_tour_is_track': 'Этот маршрут сам является фактическим треком другого маршрута',
'tours.activity.refused.activity_assignment_not_of_tour': 'Маршрута больше нет в этот день',
'tours.activity.compare.title': 'План и факт',
'tours.activity.compare.plan': 'План',
'tours.activity.compare.difference': 'Разница',
'tours.activity.compare.time': 'Время с перерывами',
'tours.activity.compare.notInPlan': 'Нет в плане',
'tours.activity.compare.notInTrack': 'Нет в треке',
'tours.activity.compare.farthest': 'Наибольшее удаление от плана',
'tours.activity.compare.offPlan': 'Вне плана',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Записанные участки дальше {distance} от запланированной линии',
'tours.activity.compare.noLine': 'Нет линии для сравнения',
'tours.activity.compare.show': 'Сравнить на карте',
'tours.activity.compare.showHint': 'Рисует фактический трек пунктиром рядом с планом. Ничего не меняется.',
'tours.activity.compare.hide': 'Завершить сравнение',
};
export default tours;
+14
View File
@@ -1216,5 +1216,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Túto trasu nemožno prepojiť s touto túrou',
'tours.activity.refused.activity_tour_is_track': 'Táto túra je sama skutočnou trasou inej túry',
'tours.activity.refused.activity_assignment_not_of_tour': 'Túra už v tento deň nie je',
'tours.activity.compare.title': 'Plán a skutočnosť',
'tours.activity.compare.plan': 'Plán',
'tours.activity.compare.difference': 'Rozdiel',
'tours.activity.compare.time': 'Čas s prestávkami',
'tours.activity.compare.notInPlan': 'Nie je v pláne',
'tours.activity.compare.notInTrack': 'Nie je v trase',
'tours.activity.compare.farthest': 'Najväčšia odchýlka od plánu',
'tours.activity.compare.offPlan': 'Mimo plánu',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Zaznamenané úseky vzdialené od plánovanej línie viac ako {distance}',
'tours.activity.compare.noLine': 'Žiadna línia na porovnanie',
'tours.activity.compare.show': 'Porovnať na mape',
'tours.activity.compare.showHint': 'Vykreslí skutočnú trasu bodkovane vedľa plánu. Nič sa nemení.',
'tours.activity.compare.hide': 'Ukončiť porovnanie',
};
export default tours;
+14
View File
@@ -1205,5 +1205,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Det här spåret kan inte länkas till den här turen',
'tours.activity.refused.activity_tour_is_track': 'Den här turen är själv det faktiska spåret för en annan tur',
'tours.activity.refused.activity_assignment_not_of_tour': 'Turen ligger inte längre på den dagen',
'tours.activity.compare.title': 'Planerat och faktiskt',
'tours.activity.compare.plan': 'Planerat',
'tours.activity.compare.difference': 'Skillnad',
'tours.activity.compare.time': 'Tid med pauser',
'tours.activity.compare.notInPlan': 'Saknas i planen',
'tours.activity.compare.notInTrack': 'Saknas i spåret',
'tours.activity.compare.farthest': 'Största avstånd från planen',
'tours.activity.compare.offPlan': 'Utanför planen',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Inspelade sträckor mer än {distance} från den planerade linjen',
'tours.activity.compare.noLine': 'Ingen linje att jämföra',
'tours.activity.compare.show': 'Jämför på kartan',
'tours.activity.compare.showHint': 'Ritar det faktiska spåret prickat bredvid planen. Inget ändras.',
'tours.activity.compare.hide': 'Avsluta jämförelsen',
};
export default tours;
+14
View File
@@ -1157,5 +1157,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'ไม่สามารถเชื่อมเส้นทางนี้กับทัวร์นี้ได้',
'tours.activity.refused.activity_tour_is_track': 'ทัวร์นี้เป็นเส้นทางจริงของทัวร์อื่น',
'tours.activity.refused.activity_assignment_not_of_tour': 'ทัวร์ไม่ได้อยู่ในวันนั้นแล้ว',
'tours.activity.compare.title': 'แผนและเส้นทางจริง',
'tours.activity.compare.plan': 'แผน',
'tours.activity.compare.difference': 'ส่วนต่าง',
'tours.activity.compare.time': 'เวลารวมพัก',
'tours.activity.compare.notInPlan': 'ไม่มีในแผน',
'tours.activity.compare.notInTrack': 'ไม่มีในเส้นทาง',
'tours.activity.compare.farthest': 'ระยะห่างจากแผนมากที่สุด',
'tours.activity.compare.offPlan': 'นอกแผน',
'tours.activity.compare.offPlanValue': '{distance} ({percent}%)',
'tours.activity.compare.offPlanHint': 'ช่วงที่บันทึกไว้ซึ่งห่างจากเส้นที่วางแผนไว้เกิน {distance}',
'tours.activity.compare.noLine': 'ไม่มีเส้นให้เปรียบเทียบ',
'tours.activity.compare.show': 'เปรียบเทียบบนแผนที่',
'tours.activity.compare.showHint': 'วาดเส้นทางจริงเป็นจุดข้างแผน ไม่มีอะไรเปลี่ยน',
'tours.activity.compare.hide': 'จบการเปรียบเทียบ',
};
export default tours;
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 1099;
const GENERAL_KEY_COUNT = 1113;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+14
View File
@@ -1212,5 +1212,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Bu iz bu tura bağlanamaz',
'tours.activity.refused.activity_tour_is_track': 'Bu tur zaten başka bir turun gerçek izi',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur artık o günde değil',
'tours.activity.compare.title': 'Plan ve gerçek',
'tours.activity.compare.plan': 'Planlanan',
'tours.activity.compare.difference': 'Fark',
'tours.activity.compare.time': 'Molalar dahil süre',
'tours.activity.compare.notInPlan': 'Planda yok',
'tours.activity.compare.notInTrack': 'İzde yok',
'tours.activity.compare.farthest': 'Plandan en uzak mesafe',
'tours.activity.compare.offPlan': 'Plan dışı',
'tours.activity.compare.offPlanValue': '{distance} (%{percent})',
'tours.activity.compare.offPlanHint': 'Planlanan çizgiden {distance} fazla uzakta kalan kayıtlı bölümler',
'tours.activity.compare.noLine': 'Karşılaştırılacak çizgi yok',
'tours.activity.compare.show': 'Haritada karşılaştır',
'tours.activity.compare.showHint': 'Gerçek izi planın yanına noktalı çizer. Hiçbir şey değişmez.',
'tours.activity.compare.hide': 'Karşılaştırmayı bitir',
};
export default tours;
+14
View File
@@ -1247,5 +1247,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Цей трек не можна пов’язати з цим маршрутом',
'tours.activity.refused.activity_tour_is_track': 'Цей маршрут сам є фактичним треком іншого маршруту',
'tours.activity.refused.activity_assignment_not_of_tour': 'Маршруту вже немає цього дня',
'tours.activity.compare.title': 'План і факт',
'tours.activity.compare.plan': 'План',
'tours.activity.compare.difference': 'Різниця',
'tours.activity.compare.time': 'Час із перервами',
'tours.activity.compare.notInPlan': 'Немає в плані',
'tours.activity.compare.notInTrack': 'Немає в треку',
'tours.activity.compare.farthest': 'Найбільша відстань від плану',
'tours.activity.compare.offPlan': 'Поза планом',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Записані ділянки далі ніж {distance} від запланованої лінії',
'tours.activity.compare.noLine': 'Немає лінії для порівняння',
'tours.activity.compare.show': 'Порівняти на карті',
'tours.activity.compare.showHint': 'Малює фактичний трек пунктиром поруч із планом. Нічого не змінюється.',
'tours.activity.compare.hide': 'Завершити порівняння',
};
export default tours;
+14
View File
@@ -1201,5 +1201,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': 'Không thể liên kết lộ trình này với chuyến đi này',
'tours.activity.refused.activity_tour_is_track': 'Tour này chính là lộ trình thực tế của tour khác',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tour không còn ở ngày đó',
'tours.activity.compare.title': 'Kế hoạch và thực tế',
'tours.activity.compare.plan': 'Kế hoạch',
'tours.activity.compare.difference': 'Chênh lệch',
'tours.activity.compare.time': 'Thời gian kể cả nghỉ',
'tours.activity.compare.notInPlan': 'Không có trong kế hoạch',
'tours.activity.compare.notInTrack': 'Không có trong lộ trình',
'tours.activity.compare.farthest': 'Xa kế hoạch nhất',
'tours.activity.compare.offPlan': 'Ngoài kế hoạch',
'tours.activity.compare.offPlanValue': '{distance} ({percent} %)',
'tours.activity.compare.offPlanHint': 'Các đoạn đã ghi cách đường dự kiến hơn {distance}',
'tours.activity.compare.noLine': 'Không có đường để so sánh',
'tours.activity.compare.show': 'So sánh trên bản đồ',
'tours.activity.compare.showHint': 'Vẽ lộ trình thực tế bằng nét chấm cạnh kế hoạch. Không có gì thay đổi.',
'tours.activity.compare.hide': 'Kết thúc so sánh',
};
export default tours;
+14
View File
@@ -1117,5 +1117,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': '此軌跡無法連結到此路線',
'tours.activity.refused.activity_tour_is_track': '此路線本身是另一條路線的實際軌跡',
'tours.activity.refused.activity_assignment_not_of_tour': '此路線已不在那一天',
'tours.activity.compare.title': '計畫與實際',
'tours.activity.compare.plan': '計畫',
'tours.activity.compare.difference': '差異',
'tours.activity.compare.time': '含休息時間',
'tours.activity.compare.notInPlan': '計畫中沒有',
'tours.activity.compare.notInTrack': '軌跡中沒有',
'tours.activity.compare.farthest': '距計畫最遠',
'tours.activity.compare.offPlan': '偏離計畫',
'tours.activity.compare.offPlanValue': '{distance}({percent}%)',
'tours.activity.compare.offPlanHint': '距計畫路線超過 {distance} 的已記錄路段',
'tours.activity.compare.noLine': '沒有可比較的路線',
'tours.activity.compare.show': '在地圖上比較',
'tours.activity.compare.showHint': '將實際軌跡以點線畫在計畫旁。不會變更任何內容。',
'tours.activity.compare.hide': '結束比較',
};
export default tours;
+14
View File
@@ -1118,5 +1118,19 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_is_tour': '该轨迹无法关联到此路线',
'tours.activity.refused.activity_tour_is_track': '该路线本身是另一条路线的实际轨迹',
'tours.activity.refused.activity_assignment_not_of_tour': '该路线已不在那一天',
'tours.activity.compare.title': '计划与实际',
'tours.activity.compare.plan': '计划',
'tours.activity.compare.difference': '差值',
'tours.activity.compare.time': '含休息时间',
'tours.activity.compare.notInPlan': '计划中没有',
'tours.activity.compare.notInTrack': '轨迹中没有',
'tours.activity.compare.farthest': '距计划最远',
'tours.activity.compare.offPlan': '偏离计划',
'tours.activity.compare.offPlanValue': '{distance}({percent}%)',
'tours.activity.compare.offPlanHint': '距计划线路超过 {distance} 的已记录路段',
'tours.activity.compare.noLine': '没有可比较的线路',
'tours.activity.compare.show': '在地图上比较',
'tours.activity.compare.showHint': '将实际轨迹以点线画在计划旁。不会更改任何内容。',
'tours.activity.compare.hide': '结束比较',
};
export default tours;
+31 -1
View File
@@ -1,4 +1,4 @@
import { nearnessToLine } from './route-corridor';
import { distancesToLine, nearnessToLine } from './route-corridor';
import { describe, expect, it } from 'vitest';
@@ -41,3 +41,33 @@ describe('nearnessToLine', () => {
).toBe(0);
});
});
describe('distancesToLine', () => {
const line = [
[47, 11],
[47.01, 11],
[47.02, 11],
];
it('measures every point against the line, in their order, as nearnessToLine does one', () => {
const points = [
[47.015, 11.001],
[47.005, 11],
[46.99, 11, 900],
];
const distances = distancesToLine(points, line);
expect(distances).toHaveLength(3);
points.forEach((point, index) => {
expect(distances?.[index]).toBeCloseTo(
nearnessToLine({ lat: Number(point[0]), lng: Number(point[1]) }, line)?.distanceM ?? -1,
6,
);
});
expect(distances?.[1]).toBe(0);
});
it('answers nothing for a line too short, and nothing to measure for no points', () => {
expect(distancesToLine([[47, 11]], [[47, 11]])).toBeNull();
expect(distancesToLine([], line)).toEqual([]);
});
});
+31 -7
View File
@@ -27,14 +27,15 @@ function flatten(line: readonly Coordinate[]) {
return (lat: number, lng: number): [number, number] => [lng * kx, lat * ky];
}
/** How near the point is to a line of `[lat, lng]` points; null for a line of fewer than two. */
export function nearnessToLine(point: { lat: number; lng: number }, line: readonly Coordinate[]): RouteNearness | null {
if (line.length < 2) return null;
/** The line laid flat once, so many points can be measured against it without laying it again. */
function flatLine(line: readonly Coordinate[]) {
const flat = flatten(line);
const [px, py] = flat(point.lat, point.lng);
let best: RouteNearness | null = null;
return { flat, points: line.map((p) => flat(Number(p[0]), Number(p[1]))) };
}
function nearestOnFlat(points: readonly [number, number][], px: number, py: number): RouteNearness {
let best: RouteNearness = { distanceM: Number.POSITIVE_INFINITY, alongM: 0 };
let walked = 0;
const points = line.map((p) => flat(Number(p[0]), Number(p[1])));
for (let i = 1; i < points.length; i += 1) {
const [ax, ay] = points[i - 1] as [number, number];
const [bx, by] = points[i] as [number, number];
@@ -43,8 +44,31 @@ export function nearnessToLine(point: { lat: number; lng: number }, line: readon
const length2 = dx * dx + dy * dy;
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
const distanceM = Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
if (!best || distanceM < best.distanceM) best = { distanceM, alongM: walked + t * Math.sqrt(length2) };
if (distanceM < best.distanceM) best = { distanceM, alongM: walked + t * Math.sqrt(length2) };
walked += Math.sqrt(length2);
}
return best;
}
/** How near the point is to a line of `[lat, lng]` points; null for a line of fewer than two. */
export function nearnessToLine(point: { lat: number; lng: number }, line: readonly Coordinate[]): RouteNearness | null {
if (line.length < 2) return null;
const { flat, points } = flatLine(line);
const [px, py] = flat(point.lat, point.lng);
return nearestOnFlat(points, px, py);
}
/**
* How far each of `points` lies from the line, in metres and in their order;
* null for a line of fewer than two. The line is laid flat once for all of
* them, so a recorded track of thousands of points is measured against a plan
* of thousands (ACT-02) without laying the plan again for every point.
*/
export function distancesToLine(points: readonly Coordinate[], line: readonly Coordinate[]): number[] | null {
if (line.length < 2) return null;
const { flat, points: flatPoints } = flatLine(line);
return points.map((p) => {
const [px, py] = flat(Number(p[0]), Number(p[1]));
return nearestOnFlat(flatPoints, px, py).distanceM;
});
}