mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(tours): route paddle tours on the water, shaping points and auto-loaded regions
Paddle tours are planned on the water by TREK's own router: a water network per paddle region, built from the same Geofabrik extract as the obstacles and searched in a worker thread of the server (no sidecar). Rivers, canals and lakes (direct or along the shore), portages round weirs, locks by policy, whitewater by boat, gaps marked. Found portages and locks become points of their own; an unplannable leg says why and offers a way out. - Shaping points: a click on the line sets a point that steers the route without being a stop (hikes and paddle tours); recompute a single leg. - Lake style direct/shore, suggested from the wind forecast. - Regions load by themselves where a paddler plans (admin setting: automatic, on request, off; size cap and a daily count), with requests for the admins, a notification, and the load followed on the leg. - Portage from the last point, portages follow footways via the walking router (or stay straight), "put in here" on slipways and put-ins. - MCP plan_paddle_route with the REST route's rules.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapView.tsx": 1402,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
||||
export const paddleRegionLoadApi = {
|
||||
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
||||
apiClient
|
||||
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
||||
};
|
||||
@@ -2,17 +2,19 @@ import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
paddleRegionSettingsSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
type PaddleRegionSettings,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
decline: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
saveSettings: (settings: PaddleRegionSettings) =>
|
||||
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||
export const paddleRouteApi = {
|
||||
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
||||
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
||||
* mode, and in the automatic one the largest region it loads without asking.
|
||||
*/
|
||||
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<PaddleRegionAutoMode>
|
||||
value={settings.mode}
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
||||
</div>
|
||||
{settings.mode === 'auto' && (
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||
<ChoiceChips<string>
|
||||
value={String(settings.max_mb)}
|
||||
label={t('admin.paddleRegions.auto.maxSize')}
|
||||
onChange={(value) => void save({ max_mb: Number(value) })}
|
||||
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const busy = admin.busyId === request.id;
|
||||
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-request"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{request.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{request.declined ? (
|
||||
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
||||
{t('admin.paddleRegions.requests.declined')}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
||||
)}
|
||||
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
||||
{request.requested_by && (
|
||||
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
||||
)}
|
||||
<StatusPill>{date}</StatusPill>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{!request.declined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.decline(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.approve(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
||||
export function RequestList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.requests.length === 0) return null;
|
||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||
const open = admin.requests.filter((request) => !request.declined).length;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
||||
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
||||
{t('admin.paddleRegions.requests.title')}
|
||||
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
||||
</span>
|
||||
<SettingRows>
|
||||
{ordered.map((request) => (
|
||||
<RequestRow key={request.id} request={request} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
||||
export function AutoOriginPill() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
||||
<span className="inline-flex" data-testid="paddle-region-auto">
|
||||
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
requests: [],
|
||||
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
approve: vi.fn(async () => {}),
|
||||
decline: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
||||
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
||||
const network = {
|
||||
water_km: 1364.5,
|
||||
lakes: 266,
|
||||
portages: 14,
|
||||
inferred_portages: 232,
|
||||
gaps: 63,
|
||||
locks: 17,
|
||||
built_at: '2026-10-17T15:30:20Z',
|
||||
bytes: 1496816,
|
||||
};
|
||||
show({ regions: [region({ network })] });
|
||||
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
||||
expect(pill).toHaveTextContent('1,365 km of water');
|
||||
fireEvent.mouseEnter(pill);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
||||
show({
|
||||
regions: [
|
||||
region({ id: 'old', name: 'Old read', network: null }),
|
||||
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
||||
],
|
||||
});
|
||||
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
||||
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
||||
const save = vi.fn(async () => {});
|
||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
||||
const auto = screen.getByTestId('paddle-auto-load');
|
||||
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
||||
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
||||
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
||||
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
||||
const approve = vi.fn(async () => {});
|
||||
const decline = vi.fn(async () => {});
|
||||
const base = {
|
||||
bytes: 734_003_200,
|
||||
requested_by: 'elmo',
|
||||
requested_at: '2026-10-18T09:00:00Z',
|
||||
};
|
||||
show({
|
||||
approve,
|
||||
decline,
|
||||
requests: [
|
||||
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
||||
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
||||
],
|
||||
});
|
||||
const rows = screen.getAllByTestId('paddle-region-request');
|
||||
expect(rows[0]).toHaveTextContent('Brandenburg');
|
||||
expect(rows[0]).toHaveTextContent('Daily limit');
|
||||
expect(rows[0]).toHaveTextContent('by elmo');
|
||||
expect(rows[1]).toHaveTextContent('Declined');
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
||||
expect(decline).toHaveBeenCalledWith('brandenburg');
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
||||
expect(approve).toHaveBeenCalledWith('norway');
|
||||
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
||||
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
||||
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
||||
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
||||
expect(isReading({ status: 'queued' })).toBe(true);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
||||
* this one card under the addons, while the Tours addon is on.
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
@@ -67,6 +70,8 @@ function Panel() {
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<AutoLoadSettings admin={admin} />
|
||||
<RequestList admin={admin} />
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: the water network the router follows in a region, as one figure
|
||||
* with the rest in its tooltip. A region read before routing existed says it
|
||||
* needs reading again; a network is never claimed where none was built.
|
||||
*/
|
||||
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const { network } = region;
|
||||
if (!network) {
|
||||
if (!region.imported_at || region.status === 'importing') return null;
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
||||
const details = [
|
||||
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
||||
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
||||
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
||||
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
||||
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
||||
].join(', ');
|
||||
return (
|
||||
<Tooltip label={details}>
|
||||
<span className="inline-flex" data-testid="paddle-region-network">
|
||||
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.origin === 'auto' && <AutoOriginPill />}
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
<RegionNetwork region={region} />
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
|
||||
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
describe('usePaddleAutoLoad', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
||||
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
||||
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
||||
expect(repo.settings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ mode: 'ask' });
|
||||
});
|
||||
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
||||
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
||||
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ max_mb: 2000 });
|
||||
});
|
||||
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PaddleRegionSettings } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/**
|
||||
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
||||
* paddler plans in, read once the card shows and saved on every change. A
|
||||
* change shows at once and goes back, with a toast, when the server refuses
|
||||
* it. While one is saved the controls rest, so two saves never cross.
|
||||
*/
|
||||
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.settings().then(
|
||||
(loaded) => {
|
||||
if (!cancelled) setSettings(loaded);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, toast]);
|
||||
|
||||
const save = useCallback(
|
||||
async (change: Partial<PaddleRegionSettings>) => {
|
||||
if (!settings || pending.current) return;
|
||||
const before = settings;
|
||||
const next = { ...settings, ...change };
|
||||
pending.current = true;
|
||||
setSaving(true);
|
||||
setSettings(next);
|
||||
try {
|
||||
setSettings(await paddleRegionRepo.saveSettings(next));
|
||||
} catch (err) {
|
||||
setSettings(before);
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[settings, t, toast]
|
||||
);
|
||||
|
||||
return { settings, saving, save };
|
||||
}
|
||||
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
|
||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
|
||||
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||
paddleRegionRepo: repo,
|
||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
|
||||
id,
|
||||
name: id,
|
||||
status,
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||
|
||||
/** How often the list is asked again while a region waits or is read. */
|
||||
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The regions as the server holds them, asked again every few seconds while
|
||||
* any of them is read, and on `reload` after a change. Every answer is set
|
||||
* The regions as the server holds them, with those paddlers asked for, asked
|
||||
* again every few seconds while any of them is read, and on `reload` after a
|
||||
* change. Every answer is set
|
||||
* from the request's own callback, so a slow one never overwrites a newer.
|
||||
*/
|
||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setRegions(list.regions);
|
||||
setRequests(list.requests);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, loading: !loaded && !offline, error, reload };
|
||||
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
||||
* in Geofabrik's list to add one, reading a region again and removing it
|
||||
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||
* a read follows without reloading the page.
|
||||
* a read follows without reloading the page. PAD-11C: how TREK loads regions
|
||||
* for paddlers by itself, and the regions they asked for, to load or decline.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
/** A paddler's request let through: the region is added like any other. */
|
||||
const approve = useCallback(
|
||||
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
|
||||
[run, t]
|
||||
);
|
||||
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
requests: list.requests,
|
||||
autoLoad,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
approve,
|
||||
decline,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { RouteHitBand } from './RouteHitBand'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||
<Polyline
|
||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||
])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
|
||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
function plannerWaypointIcon(
|
||||
pinCss: PinCss,
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
draggable: boolean
|
||||
): L.DivIcon {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const { label, shaping } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping]
|
||||
);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Polyline } from 'react-leaflet';
|
||||
|
||||
/**
|
||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||
* than the drawn line, that takes the click which reshapes the route, as the
|
||||
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
||||
*/
|
||||
export function RouteHitBand({
|
||||
positions,
|
||||
onRouteClick,
|
||||
}: {
|
||||
positions: [number, number][];
|
||||
onRouteClick: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: 'transparent',
|
||||
weight: 20,
|
||||
opacity: 0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
className: 'cursor-copy',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||
e.originalEvent.stopPropagation();
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,15 @@ export interface MapViewProps {
|
||||
onHighlightAlternative?: (index: number | null) => void;
|
||||
/** Generic numbered control points for list-first route editors. */
|
||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
plannerWaypoints?: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
|
||||
describe('plannerPinShape', () => {
|
||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
||||
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||
export interface PlannerPinPoint {
|
||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}
|
||||
|
||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||
export function plannerPinShape(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): { size: number; text: string } {
|
||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PaddleLakeStyle } from '@trek/shared';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
|
||||
|
||||
/**
|
||||
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
|
||||
* shore is for wind, when a capsize near the bank is a short swim. The wind
|
||||
* card suggests it on a windy day; here it is chosen.
|
||||
*/
|
||||
export function PaddleLakeStyleField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PaddleLakeStyle;
|
||||
onChange: (style: PaddleLakeStyle) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid="tour-planner-lake-style" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
|
||||
<Segmented<PaddleLakeStyle>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t('tours.paddle.lakeStyle')}
|
||||
fill
|
||||
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
|
||||
/>
|
||||
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tours.paddle.lakeStyle.${value}Hint`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { PaddleLegFailure } from '../planner/paddleRouting';
|
||||
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||
|
||||
/**
|
||||
* PAD-11: what the waypoint list says about a leg the router planned or
|
||||
* could not plan, and about a point it set itself.
|
||||
*/
|
||||
|
||||
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
|
||||
export function AutoPointPill() {
|
||||
const { t } = useTranslation();
|
||||
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
|
||||
return (
|
||||
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
|
||||
<span
|
||||
data-testid="tour-planner-auto-point"
|
||||
aria-label={t('tours.paddle.auto')}
|
||||
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
|
||||
|
||||
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
|
||||
function notesOf(
|
||||
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
): Note[] {
|
||||
return leg.segments.flatMap((segment, index): Note[] => {
|
||||
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
|
||||
if (segment.kind === 'rapids' && segment.grade != null) {
|
||||
return [
|
||||
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
|
||||
];
|
||||
}
|
||||
if (segment.kind === 'pass')
|
||||
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
|
||||
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
|
||||
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
if (!leg || leg.status !== 'ok') return null;
|
||||
const notes = notesOf(leg, t);
|
||||
if (notes.length === 0) return null;
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
|
||||
{notes.map((note) => (
|
||||
<Tooltip
|
||||
key={note.key}
|
||||
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
|
||||
style={fs(11)}
|
||||
>
|
||||
{note.tone === 'gap' ? (
|
||||
<AlertTriangle
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-gap)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Waves
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-rapids)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{note.label}</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
|
||||
style={fs(10)}
|
||||
>
|
||||
{formatDistance(note.length / 1000, unit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan: why, in plain words, and what to do about
|
||||
* it. The map draws it straight and red meanwhile, and the tour waits to be
|
||||
* saved until it is planned or drawn by hand on purpose. Where TREK could not
|
||||
* fetch the water data of the leg's region (PAD-11C), `message` says why and
|
||||
* `onLoadRetry` reads the region again.
|
||||
*/
|
||||
export function PaddleLegFailureRow({
|
||||
code,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
message,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
message?: string | null;
|
||||
onLoadRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
|
||||
const action =
|
||||
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
|
||||
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
{retry && (
|
||||
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
|
||||
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.retry')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleObstaclePlaceButton, placeOf } from './PaddleObstaclePlaceButton';
|
||||
|
||||
describe('placeOf', () => {
|
||||
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
|
||||
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
|
||||
expect(
|
||||
placeOf({
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[12.9, 53.2],
|
||||
[12.91, 53.21],
|
||||
[12.92, 53.22],
|
||||
],
|
||||
})
|
||||
).toEqual({ lat: 53.21, lng: 12.91 });
|
||||
const middle = placeOf({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[12, 53],
|
||||
[12.02, 53],
|
||||
[12.02, 53.02],
|
||||
[12, 53.02],
|
||||
[12, 53],
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(middle?.lat).toBeCloseTo(53.01, 9);
|
||||
expect(middle?.lng).toBeCloseTo(12.01, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleObstaclePlaceButton', () => {
|
||||
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
|
||||
const onPlace = vi.fn();
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="slipway"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={onPlace}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
|
||||
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="egress"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="weir"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
|
||||
import { MapPinPlus } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
|
||||
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
|
||||
put_in: 'put_in',
|
||||
slipway: 'put_in',
|
||||
egress: 'egress',
|
||||
};
|
||||
|
||||
/**
|
||||
* The spot a waypoint goes to: the place itself, the middle of a way (a
|
||||
* slipway drawn as a ramp), or the middle of an area's outline.
|
||||
*/
|
||||
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
|
||||
const line =
|
||||
geometry.type === 'Point'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'LineString'
|
||||
? geometry.coordinates
|
||||
: geometry.coordinates[0];
|
||||
if (!line?.length) return null;
|
||||
if (geometry.type === 'Polygon') {
|
||||
const ring = line.slice(0, -1);
|
||||
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
|
||||
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
|
||||
return { lat, lng };
|
||||
}
|
||||
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
|
||||
return lat === undefined || lng === undefined ? null : { lat, lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
|
||||
* waypoint from its card on the map, so the point sits where the paddler
|
||||
* really gets in or out. Set after a portage asked for at the last point, it
|
||||
* is where the portage ends.
|
||||
*/
|
||||
export function PaddleObstaclePlaceButton({
|
||||
kind,
|
||||
geometry,
|
||||
onPlace,
|
||||
}: {
|
||||
kind: PaddleObstacleKind;
|
||||
geometry: PaddleGeometry;
|
||||
onPlace: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const role = PLACEABLE[kind];
|
||||
const spot = role ? placeOf(geometry) : null;
|
||||
if (!role || !spot) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="paddle-obstacle-place"
|
||||
onClick={() => onPlace(spot.lat, spot.lng)}
|
||||
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from './PaddleRegionLoadRow';
|
||||
|
||||
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
|
||||
|
||||
describe('PaddleRegionLoadRow', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
|
||||
const onDrawStraight = vi.fn();
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
|
||||
disabled={false}
|
||||
onDrawStraight={onDrawStraight}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
|
||||
expect(row).toHaveTextContent('Downloading 45%');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
|
||||
expect(onDrawStraight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'requested', region: REF, reason: 'too_large' }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('has been requested');
|
||||
expect(row).toHaveTextContent('larger than TREK loads by itself');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
|
||||
const { unmount } = render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
|
||||
unmount();
|
||||
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('region load states', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
|
||||
const pending: PaddleRegionLoadState[] = [
|
||||
{ state: 'ready', region: REF },
|
||||
{ state: 'requested', region: REF, reason: 'ask' },
|
||||
];
|
||||
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
|
||||
expect(isRegionLoadPending(undefined)).toBe(false);
|
||||
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
|
||||
'tours.paddle.load.declined{"region":"Oberpfalz"}'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
|
||||
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
|
||||
'tours.paddle.load.noExtract'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
|
||||
'tours.paddle.load.catalogUnavailable'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
|
||||
expect(regionLoadFailure(undefined, t)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** The states in which the data is on its way: the leg waits rather than fails. */
|
||||
export function isRegionLoadPending(
|
||||
state: PaddleRegionLoadState | undefined
|
||||
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
|
||||
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a leg stays unplanned once the data cannot come: declined, failed, no
|
||||
* water near the place, nothing to load. Null where the leg's own reason says
|
||||
* it best (loading is off, or nothing was asked yet).
|
||||
*/
|
||||
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
|
||||
if (!state) return null;
|
||||
const region = state.region?.name ?? '';
|
||||
switch (state.state) {
|
||||
case 'declined':
|
||||
return t('tours.paddle.load.declined', { region });
|
||||
case 'failed':
|
||||
return t('tours.paddle.load.failed', { region });
|
||||
case 'no_water':
|
||||
return t('tours.paddle.load.noWater', { region });
|
||||
case 'unavailable':
|
||||
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
|
||||
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
|
||||
const region = state.region.name;
|
||||
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
|
||||
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
|
||||
return t('tours.paddle.load.loading', { region });
|
||||
}
|
||||
|
||||
/** The phase of a load in words, with its share where it is known. */
|
||||
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
|
||||
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
|
||||
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
|
||||
const share = state.progress.fraction;
|
||||
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
|
||||
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
|
||||
* the data is on its way, and the leg is planned on its own once it is there.
|
||||
* Drawing it straight stays possible for whoever does not want to wait.
|
||||
*/
|
||||
export function PaddleRegionLoadRow({
|
||||
state,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
}: {
|
||||
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
|
||||
const busy = state.state !== 'requested';
|
||||
return (
|
||||
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{busy ? (
|
||||
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0">{headline(state, t)}</span>
|
||||
</span>
|
||||
{state.state === 'loading' && (
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate">{phaseText(state, t, language)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
|
||||
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.load.hint')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{state.state === 'requested' && state.reason !== 'ask' && (
|
||||
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
|
||||
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
|
||||
</span>
|
||||
)}
|
||||
{state.state !== 'ready' && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onDrawStraight}
|
||||
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
|
||||
|
||||
/** What the card writes for a time it cannot give: an en dash. */
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
|
||||
|
||||
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
|
||||
routed: false,
|
||||
noWay: false,
|
||||
searching: false,
|
||||
onFollow: vi.fn(),
|
||||
onStraighten: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
|
||||
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }));
|
||||
}
|
||||
|
||||
describe('PendingPortageRow', () => {
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
|
||||
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
|
||||
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortageLegRow way', () => {
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
|
||||
const straight = way();
|
||||
openPortage({ way: straight });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
|
||||
expect(straight.onFollow).toHaveBeenCalledTimes(1);
|
||||
const routed = way({ routed: true });
|
||||
openPortage({ way: routed });
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
|
||||
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
|
||||
openPortage({ way: way({ noWay: true }) });
|
||||
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
|
||||
openPortage({ way: way({ searching: true }) });
|
||||
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { ChevronDown, Loader2, X } from 'lucide-react';
|
||||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
@@ -199,6 +200,99 @@ function StopCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage asked for at the last point, before the point it leads to is set:
|
||||
* dashed in the portage brown, it says where to click next, and can be taken
|
||||
* back. Once the next point is set it becomes a portage like any other.
|
||||
*/
|
||||
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div
|
||||
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
|
||||
style={{ borderColor: PORTAGE_CSS_COLOR }}
|
||||
>
|
||||
<span
|
||||
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
|
||||
style={{ background: PORTAGE_CSS_COLOR }}
|
||||
>
|
||||
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
|
||||
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{t('tours.paddle.portagePending')}
|
||||
</span>
|
||||
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
|
||||
{t('tours.paddle.portagePendingHint')}
|
||||
</span>
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t('tours.paddle.portagePendingOff')}
|
||||
title={t('tours.paddle.portagePendingOff')}
|
||||
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PortageWay {
|
||||
/** The portage follows the ways on land (its line has a shape of its own). */
|
||||
routed: boolean;
|
||||
/** The router was asked and found no way without a big detour. */
|
||||
noWay: boolean;
|
||||
/** The router is being asked right now. */
|
||||
searching: boolean;
|
||||
onFollow: () => void;
|
||||
onStraighten: () => void;
|
||||
}
|
||||
|
||||
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
|
||||
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const value = way.routed ? 'follow' : 'straight';
|
||||
const hint = way.searching
|
||||
? t('tours.paddle.portageWay.searching')
|
||||
: !way.routed && way.noWay
|
||||
? t('tours.paddle.portageWay.noWay')
|
||||
: null;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
|
||||
<span className="block truncate text-content" style={fs(12, 'body')}>
|
||||
{t('tours.paddle.portageWay')}
|
||||
</span>
|
||||
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<'follow' | 'straight'>
|
||||
value={value}
|
||||
fill
|
||||
label={t('tours.paddle.portageWay')}
|
||||
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
|
||||
options={[
|
||||
{
|
||||
value: 'follow',
|
||||
label: t('tours.paddle.portageWay.follow'),
|
||||
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
|
||||
},
|
||||
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
|
||||
]}
|
||||
/>
|
||||
</fieldset>
|
||||
{hint && (
|
||||
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage under the point it starts at, in the earth brown the map draws it
|
||||
* in: its length on land and the time for getting out, unloading and back in
|
||||
@@ -211,6 +305,7 @@ export function PortageLegRow({
|
||||
onExtraChange,
|
||||
position,
|
||||
onRemove,
|
||||
way,
|
||||
}: {
|
||||
lengthM: number;
|
||||
extraMinutes: number | null;
|
||||
@@ -220,6 +315,8 @@ export function PortageLegRow({
|
||||
position?: string;
|
||||
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
|
||||
onRemove?: () => void;
|
||||
/** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */
|
||||
way?: PortageWay;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
@@ -242,6 +339,7 @@ export function PortageLegRow({
|
||||
{t('tours.paddle.portageInfo')}
|
||||
</p>
|
||||
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
|
||||
{way && <PortageWayField way={way} disabled={disabled} />}
|
||||
<StopField
|
||||
label={t('tours.paddle.portageExtra')}
|
||||
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -340,6 +340,30 @@ describe('PaddleWindSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleWindCard on the lakes (PAD-11)', () => {
|
||||
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
|
||||
const onChoose = vi.fn();
|
||||
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
|
||||
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
|
||||
const advice = screen.getByTestId('paddle-wind-shore-advice');
|
||||
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
|
||||
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
|
||||
expect(onChoose).toHaveBeenCalledWith('shore');
|
||||
|
||||
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
|
||||
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
|
||||
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FigureBadge', () => {
|
||||
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
|
||||
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { windForShore } from './lakeStyleAdvice';
|
||||
import { useTourWind, type TourWind } from './useTourWind';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -209,15 +210,58 @@ function WindHints({ t }: { t: Translate }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
|
||||
export interface LakeStyleChoice {
|
||||
value: PaddleLakeStyle;
|
||||
onChoose: (style: PaddleLakeStyle) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
|
||||
* with the wind that prompts it; once taken, it says the lakes are planned so.
|
||||
*/
|
||||
function ShoreAdvice({
|
||||
wind,
|
||||
lakes,
|
||||
t,
|
||||
}: {
|
||||
wind: { meanKmh: number; gustsKmh: number };
|
||||
lakes: LakeStyleChoice;
|
||||
t: Translate;
|
||||
}) {
|
||||
if (lakes.value === 'shore') {
|
||||
return (
|
||||
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.wind.shoreTaken')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
|
||||
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lakes.onChoose('shore')}
|
||||
className="font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('tours.paddle.wind.shoreAdopt')}
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
|
||||
* mean, the gusts and the direction are figures of their own, with the source
|
||||
* and when it was asked; no value is turned into a verdict, and missing data
|
||||
* is never shown as calm.
|
||||
*/
|
||||
export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
|
||||
const shoreWind = ready && lakes ? windForShore(ready) : null;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Wind size={14} strokeWidth={2} />}
|
||||
@@ -249,6 +293,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
) : (
|
||||
<Unavailable wind={wind} t={t} locale={locale} />
|
||||
)}
|
||||
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
|
||||
<WindHints t={t} />
|
||||
</div>
|
||||
</RailCard>
|
||||
@@ -259,10 +304,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
export function PaddleWindSection({
|
||||
placeId,
|
||||
start,
|
||||
lakes,
|
||||
}: {
|
||||
placeId: number | null;
|
||||
start: { lat: number; lng: number } | null;
|
||||
/** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */
|
||||
lakes?: LakeStyleChoice;
|
||||
}) {
|
||||
const wind = useTourWind(placeId, start);
|
||||
return <PaddleWindCard wind={wind} />;
|
||||
return <PaddleWindCard wind={wind} lakes={lakes} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
|
||||
import type { WindForecastResponse, WindHour } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { crossesLake, windForShore } from './lakeStyleAdvice';
|
||||
|
||||
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
|
||||
return {
|
||||
status: 'forecast',
|
||||
date: '2026-06-01',
|
||||
lat: 53.4,
|
||||
lng: 12.7,
|
||||
timezone: 'Europe/Berlin',
|
||||
source: 'Open-Meteo',
|
||||
fetched_at: '2026-05-30T08:00:00Z',
|
||||
horizon_days: 16,
|
||||
day: null,
|
||||
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
|
||||
} as WindForecastResponse;
|
||||
}
|
||||
|
||||
describe('lakeStyleAdvice', () => {
|
||||
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
|
||||
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
|
||||
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
|
||||
meanKmh: 21,
|
||||
gustsKmh: 26,
|
||||
});
|
||||
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
|
||||
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
|
||||
// An hour without a value is no calm hour, but it is no wind either.
|
||||
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
|
||||
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
|
||||
const leg = (kind: 'lake' | 'water') => ({
|
||||
status: 'ok' as const,
|
||||
geometry: [
|
||||
[53.4, 12.7],
|
||||
[53.5, 12.7],
|
||||
] as [number, number][],
|
||||
segments: [{ ...lake, kind }],
|
||||
distance_m: 1000,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
});
|
||||
expect(crossesLake(null)).toBe(false);
|
||||
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
|
||||
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
|
||||
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WindForecastResponse } from '@trek/shared';
|
||||
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||
|
||||
/** The hours a day tour is out on the water, as the wind strip shows them. */
|
||||
const FIRST_HOUR = 6;
|
||||
const LAST_HOUR = 21;
|
||||
|
||||
/**
|
||||
* Wind from which open water gets rough for a small boat: a moderate breeze
|
||||
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
|
||||
* work and a capsize far from the bank into a long swim.
|
||||
*/
|
||||
const MEAN_KMH = 20;
|
||||
const GUSTS_KMH = 30;
|
||||
|
||||
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
|
||||
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
|
||||
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
|
||||
* enough to keep near the shores on the lakes, else null. Lakes are crossed
|
||||
* straight over as a rule and along the shore only in wind; this says when.
|
||||
*/
|
||||
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
|
||||
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
|
||||
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
|
||||
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
|
||||
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
|
||||
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = {
|
||||
access: '--map-paddle-access',
|
||||
landing: '--map-paddle-landing',
|
||||
portage: '--map-portage',
|
||||
rapids: '--map-paddle-rapids',
|
||||
gap: '--map-paddle-gap',
|
||||
disc: '--map-paddle-disc',
|
||||
shadow: '--map-paddle-shadow',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const valhallaRun = vi.fn();
|
||||
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
|
||||
|
||||
const { routePortageLine } = await import('./portageRoute');
|
||||
|
||||
const FROM = { lat: 53.2232, lng: 12.9668 };
|
||||
const TO = { lat: 53.2229, lng: 12.9701 };
|
||||
|
||||
function answer(coordinates: [number, number][]) {
|
||||
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
|
||||
}
|
||||
|
||||
describe('routePortageLine', () => {
|
||||
beforeEach(() => valhallaRun.mockReset());
|
||||
|
||||
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
|
||||
valhallaRun.mockResolvedValue(
|
||||
answer([
|
||||
[53.2233, 12.9672],
|
||||
[53.2226, 12.9686],
|
||||
[53.223, 12.9698],
|
||||
])
|
||||
);
|
||||
const line = await routePortageLine(FROM, TO);
|
||||
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
|
||||
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
|
||||
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
|
||||
expect(line).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
|
||||
valhallaRun.mockResolvedValue(
|
||||
answer([
|
||||
[53.2233, 12.9672],
|
||||
[53.24, 12.968],
|
||||
[53.223, 12.9698],
|
||||
])
|
||||
);
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
|
||||
valhallaRun.mockResolvedValue(null);
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
|
||||
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { valhallaRun } from '../../Map/valhallaRoute';
|
||||
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||
|
||||
type Point = { lat: number; lng: number };
|
||||
|
||||
/**
|
||||
* How much longer than the straight line a carry along the ways may be before
|
||||
* it counts as a detour: a way round a fenced lock is fine, a walk round the
|
||||
* whole lake is not, and then the straight line says more.
|
||||
*/
|
||||
const DETOUR = { factor: 2.5, extraM: 300 } as const;
|
||||
|
||||
/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */
|
||||
const CARRY_DIFFICULTY = 1;
|
||||
|
||||
/**
|
||||
* PAD-03: the way a portage takes on land, from where the boat leaves the
|
||||
* water to where it goes in again, on the ways a walker would take (the
|
||||
* instance's Valhalla, the router the hikes use). The line starts and ends at
|
||||
* the two points themselves, so the steps from the bank onto the path are
|
||||
* part of it. Null when no router answers, or when the way is a detour.
|
||||
*/
|
||||
export async function routePortageLine(
|
||||
from: Point,
|
||||
to: Point,
|
||||
signal?: AbortSignal
|
||||
): Promise<[number, number][] | null> {
|
||||
// The router answers null, never throws, when it cannot be reached; the carry then stays straight.
|
||||
const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY });
|
||||
if (!run || run.total.coordinates.length < 2) return null;
|
||||
const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]];
|
||||
const straightM = haversineKm(from, to) * 1000;
|
||||
if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null;
|
||||
return line;
|
||||
}
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
selectPaddleObstacle,
|
||||
usePaddleObstacleStore,
|
||||
} from '../../../store/paddleObstacleStore';
|
||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { usePaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
|
||||
|
||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const load = vi.fn();
|
||||
vi.mock('../../../repo/paddleRegionLoadRepo', () => ({
|
||||
paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) },
|
||||
}));
|
||||
|
||||
const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad');
|
||||
|
||||
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||
const AT = { lat: 49.0123456, lng: 12.0987654 };
|
||||
const failures = [
|
||||
{ index: 0, code: 'no_region' as const, at: AT },
|
||||
{ index: 1, code: 'no_region' as const, at: AT },
|
||||
{ index: 2, code: 'no_water_path' as const, at: null },
|
||||
];
|
||||
const loading = (fraction: number): PaddleRegionLoadState => ({
|
||||
state: 'loading',
|
||||
region: REF,
|
||||
progress: { phase: 'download', fraction },
|
||||
ahead: 0,
|
||||
});
|
||||
|
||||
describe('usePaddleRegionLoad', () => {
|
||||
beforeEach(() => load.mockReset());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => {
|
||||
load.mockResolvedValue([loading(0.4)]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4)));
|
||||
expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal));
|
||||
expect(result.current.byLeg.get(1)).toEqual(loading(0.4));
|
||||
expect(result.current.byLeg.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => {
|
||||
vi.useFakeTimers();
|
||||
load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]);
|
||||
const onReady = vi.fn();
|
||||
const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(onReady).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
});
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => {
|
||||
renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn()));
|
||||
renderHook(() => usePaddleRegionLoad(null, vi.fn()));
|
||||
await Promise.resolve();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => {
|
||||
load.mockResolvedValue([{ state: 'failed', region: REF }]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => {
|
||||
vi.useFakeTimers();
|
||||
load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.byLeg.get(0)?.state).toBe('declined');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared';
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo';
|
||||
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||
|
||||
/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */
|
||||
const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const;
|
||||
|
||||
type Failure = PaddleTourRoute['failures'][number];
|
||||
|
||||
/** A place as the key it is asked and remembered by; five decimals are about a metre. */
|
||||
function keyOf(point: PaddleRoutePoint): string {
|
||||
return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
|
||||
}
|
||||
|
||||
function placeOf(key: string): PaddleRoutePoint {
|
||||
const [lat, lng] = key.split(',').map(Number);
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
/** The places the planner asks about: where its legs ran out of water data, each once. */
|
||||
function placeKeysOf(failures: readonly Failure[] | null | undefined): string {
|
||||
const keys = new Set<string>();
|
||||
for (const failure of failures ?? []) {
|
||||
if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at));
|
||||
}
|
||||
return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|');
|
||||
}
|
||||
|
||||
function nextPoll(states: readonly PaddleRegionLoadState[]): number | null {
|
||||
if (states.some((state) => state.state === 'loading')) return POLL_MS.loading;
|
||||
if (states.some((state) => state.state === 'requested')) return POLL_MS.requested;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11C: where a paddle leg failed for want of water data, the planner
|
||||
* asks the server for it right away; the server loads the region around the
|
||||
* place or asks an admin for it, as the instance is set up. While it loads
|
||||
* the leg shows how far it is, and once it is there the route is planned
|
||||
* again on its own. `onReady` plans again; it is called once per place.
|
||||
*/
|
||||
export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) {
|
||||
const placesKey = placeKeysOf(failures);
|
||||
const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]);
|
||||
const [states, setStates] = useState<ReadonlyMap<string, PaddleRegionLoadState>>(new Map());
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const readied = useRef(new Set<string>());
|
||||
const retrying = useRef(false);
|
||||
|
||||
/** The answers taken in; the route is planned again where a place's data has just arrived. */
|
||||
const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => {
|
||||
setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : []))));
|
||||
const fresh = places.filter((place, index) => {
|
||||
const key = keyOf(place);
|
||||
if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false;
|
||||
readied.current.add(key);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length > 0) onReady();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (places.length === 0) return undefined;
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const ask = async () => {
|
||||
const retry = retrying.current;
|
||||
retrying.current = false;
|
||||
let answers: PaddleRegionLoadState[] | null;
|
||||
try {
|
||||
answers = await paddleRegionLoadRepo.load(places, retry, abort.signal);
|
||||
} catch {
|
||||
// The failure row keeps saying why the leg is unplanned; a later look may get through.
|
||||
if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error);
|
||||
return;
|
||||
}
|
||||
if (abort.signal.aborted || !answers) return;
|
||||
take(answers);
|
||||
const wait = nextPoll(answers);
|
||||
if (wait !== null) timer = setTimeout(() => void ask(), wait);
|
||||
};
|
||||
void ask();
|
||||
return () => {
|
||||
abort.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [places, retryToken]);
|
||||
|
||||
/** Reads a region again whose last read failed. */
|
||||
const retry = useCallback(() => {
|
||||
retrying.current = true;
|
||||
setRetryToken((token) => token + 1);
|
||||
}, []);
|
||||
|
||||
/** The state of each failed leg's place, by the leg's index. */
|
||||
const byLeg = useMemo(() => {
|
||||
const result = new Map<number, PaddleRegionLoadState>();
|
||||
for (const failure of failures ?? []) {
|
||||
if (failure.code !== 'no_region' || !failure.at) continue;
|
||||
const state = states.get(keyOf(failure.at));
|
||||
if (state) result.set(failure.index, state);
|
||||
}
|
||||
return result;
|
||||
}, [failures, states]);
|
||||
|
||||
return { byLeg, retry };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||
|
||||
const routePortageLine = vi.fn();
|
||||
vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) }));
|
||||
|
||||
const { usePortageRouting } = await import('./usePortageRouting');
|
||||
|
||||
const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' };
|
||||
const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' };
|
||||
const WAY: [number, number][] = [
|
||||
[A.lat, A.lng],
|
||||
[53.2226, 12.9686],
|
||||
[B.lat, B.lng],
|
||||
];
|
||||
|
||||
function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) {
|
||||
const planner = {
|
||||
waypoints,
|
||||
isPaddle: true,
|
||||
hasUnsavedChanges,
|
||||
amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
planner.waypoints = change(planner.waypoints);
|
||||
}),
|
||||
editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
planner.waypoints = change(planner.waypoints);
|
||||
}),
|
||||
};
|
||||
return planner;
|
||||
}
|
||||
|
||||
const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B];
|
||||
|
||||
describe('usePortageRouting', () => {
|
||||
beforeEach(() => routePortageLine.mockReset());
|
||||
|
||||
it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => {
|
||||
routePortageLine.mockResolvedValue(WAY);
|
||||
const planner = plannerWith(carried(), true);
|
||||
renderHook(() => usePortageRouting(planner, true));
|
||||
await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled());
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => {
|
||||
const saved = plannerWith(carried(), false);
|
||||
renderHook(() => usePortageRouting(saved, true));
|
||||
const locked = plannerWith(carried(), true);
|
||||
renderHook(() => usePortageRouting(locked, false));
|
||||
await Promise.resolve();
|
||||
expect(routePortageLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => {
|
||||
routePortageLine.mockResolvedValue(null);
|
||||
const planner = plannerWith(carried(), true);
|
||||
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||
await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.noWay(['a'])).toBe(true));
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => {
|
||||
routePortageLine.mockResolvedValue(WAY);
|
||||
const planner = plannerWith(carried(), false);
|
||||
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||
let found = false;
|
||||
await act(async () => {
|
||||
found = await result.current.follow(['a']);
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => {
|
||||
const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true);
|
||||
const { result, rerender } = renderHook(() => usePortageRouting(planner, true));
|
||||
act(() => result.current.straighten(['a']));
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
]);
|
||||
rerender();
|
||||
await Promise.resolve();
|
||||
expect(routePortageLine).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import type { PortageWay } from './PaddleWaypointParts';
|
||||
import { routePortageLine } from './portageRoute';
|
||||
|
||||
type Planner = Pick<
|
||||
TourPlannerController,
|
||||
'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints'
|
||||
>;
|
||||
type Point = TourPlannerController['waypoints'][number];
|
||||
type Line = [number, number][];
|
||||
|
||||
/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */
|
||||
function keyOf(from: Point, to: Point): string {
|
||||
return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean {
|
||||
return leg?.kind === 'portage' && leg.geometry.length === 2;
|
||||
}
|
||||
|
||||
/** The portages still drawn straight, with the point after each. */
|
||||
function straightPortages(points: readonly Point[]) {
|
||||
return points.flatMap((point, index) => {
|
||||
const next = points[index + 1];
|
||||
// A portage the water router found is its own: it already follows the mapped way.
|
||||
return next && !point.auto && isStraightPortage(point.fixedLeg)
|
||||
? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-03: a portage follows the ways on land instead of cutting straight
|
||||
* across them. A portage the paddler sets, or whose ends they move, is asked
|
||||
* of the router the hikes use and takes its line, unless that line is a
|
||||
* detour; then it stays straight. A tour opened as saved keeps its straight
|
||||
* portages until the paddler asks for the way (`follow`), and one they set
|
||||
* straight on purpose (`straighten`) stays so.
|
||||
*/
|
||||
export function usePortageRouting(planner: Planner, editable: boolean) {
|
||||
const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner;
|
||||
/** Carries kept straight: as the tour was saved, or as the paddler set them. */
|
||||
const kept = useRef(new Set<string>());
|
||||
/** What the router answered per carry; null when it found no way worth taking. */
|
||||
const lines = useRef(new Map<string, Line | null>());
|
||||
|
||||
const layIn = useCallback(() => {
|
||||
amendWaypoints((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((point, index) => {
|
||||
const after = current[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point;
|
||||
const key = keyOf(point, after);
|
||||
const line = kept.current.has(key) ? null : lines.current.get(key);
|
||||
if (!line) return point;
|
||||
changed = true;
|
||||
return { ...point, fixedLeg: { ...leg, geometry: line } };
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [amendWaypoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaddle || !editable) return undefined;
|
||||
const legs = straightPortages(waypoints);
|
||||
if (!hasUnsavedChanges) {
|
||||
for (const leg of legs) kept.current.add(leg.key);
|
||||
return undefined;
|
||||
}
|
||||
const wanted = legs.filter((leg) => !kept.current.has(leg.key));
|
||||
if (wanted.some((leg) => lines.current.get(leg.key))) layIn();
|
||||
const missing = wanted.filter((leg) => !lines.current.has(leg.key));
|
||||
if (missing.length === 0) return undefined;
|
||||
const abort = new AbortController();
|
||||
// One after the other: the public router allows about one request a second.
|
||||
void missing
|
||||
.reduce(async (previous, leg) => {
|
||||
await previous;
|
||||
if (abort.signal.aborted) return;
|
||||
const line = await routePortageLine(leg.from, leg.to, abort.signal);
|
||||
if (!abort.signal.aborted) lines.current.set(leg.key, line);
|
||||
}, Promise.resolve())
|
||||
.then(() => {
|
||||
if (!abort.signal.aborted) layIn();
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]);
|
||||
|
||||
/** The carries starting at these points, each with the point after it. */
|
||||
const legsOf = useCallback(
|
||||
(ids: readonly string[]) =>
|
||||
waypoints.flatMap((point, index) => {
|
||||
const next = waypoints[index + 1];
|
||||
return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : [];
|
||||
}),
|
||||
[waypoints]
|
||||
);
|
||||
|
||||
/**
|
||||
* A portage laid on the ways, after it was straight (as saved, or set so),
|
||||
* leg by leg: true once one follows them, false when there is no way worth
|
||||
* taking and it stays straight.
|
||||
*/
|
||||
const follow = useCallback(
|
||||
async (ids: readonly string[]) => {
|
||||
const legs = legsOf(ids);
|
||||
const found = await legs.reduce(async (previous, { point, next, key }) => {
|
||||
const any = await previous;
|
||||
kept.current.delete(key);
|
||||
const line = await routePortageLine(point, next);
|
||||
lines.current.set(key, line);
|
||||
return any || !!line;
|
||||
}, Promise.resolve(false));
|
||||
if (found) layIn();
|
||||
return found;
|
||||
},
|
||||
[layIn, legsOf]
|
||||
);
|
||||
|
||||
/** A portage drawn straight, and kept so. */
|
||||
const straighten = useCallback(
|
||||
(ids: readonly string[]) => {
|
||||
for (const { key } of legsOf(ids)) kept.current.add(key);
|
||||
editPaddlePoints((current) =>
|
||||
current.map((point, index) => {
|
||||
const next = current[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point;
|
||||
return {
|
||||
...point,
|
||||
fixedLeg: {
|
||||
...leg,
|
||||
geometry: [
|
||||
[point.lat, point.lng],
|
||||
[next.lat, next.lng],
|
||||
],
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
[editPaddlePoints, legsOf]
|
||||
);
|
||||
|
||||
/** Whether the router found no way worth taking for every leg of a portage. */
|
||||
const noWay = useCallback(
|
||||
(ids: readonly string[]) => {
|
||||
const legs = legsOf(ids);
|
||||
return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null);
|
||||
},
|
||||
[legsOf]
|
||||
);
|
||||
|
||||
return { follow, straighten, noWay };
|
||||
}
|
||||
|
||||
type PortageRouting = ReturnType<typeof usePortageRouting>;
|
||||
|
||||
/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */
|
||||
function portageRunIds(points: readonly Point[], index: number): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) {
|
||||
ids.push(points[at].id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-03: the way of the portage starting at a waypoint, for its card:
|
||||
* whether it follows the ways on land, why it stayed straight, and the switch
|
||||
* between the two. Undefined where no portage starts.
|
||||
*/
|
||||
export function usePortageWay(
|
||||
points: readonly Point[],
|
||||
routing: PortageRouting,
|
||||
index: number
|
||||
): PortageWay | undefined {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const ids = portageRunIds(points, index);
|
||||
if (ids.length === 0) return undefined;
|
||||
return {
|
||||
routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2),
|
||||
noWay: routing.noWay(ids),
|
||||
searching,
|
||||
onFollow: () => {
|
||||
setSearching(true);
|
||||
void routing.follow(ids).finally(() => setSearching(false));
|
||||
},
|
||||
onStraighten: () => routing.straighten(ids),
|
||||
};
|
||||
}
|
||||
@@ -926,6 +926,24 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
|
||||
expect(safetyRow()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PCR-006: legs the water router could not plan are counted in the head, without the retry the rows already offer', () => {
|
||||
const failed = paddleEditor({
|
||||
status: 'routing-failed',
|
||||
error: 'paddle-route',
|
||||
paddleRoute: { coordinates: [], parts: [], distanceMeters: 0, legs: [], legLengths: [], failures: [
|
||||
{ index: 0, code: 'not_on_water', at: null },
|
||||
{ index: 1, code: 'busy', at: null },
|
||||
] },
|
||||
})
|
||||
const { rerender } = render(<TourPlannerRail planner={failed} />)
|
||||
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('2 legs not planned')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'routing-failed', error: 'routing' })} />)
|
||||
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('Route calculation failed. The last good route is still shown.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
|
||||
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice'
|
||||
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
|
||||
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
|
||||
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
|
||||
@@ -121,6 +122,18 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
|
||||
function retryable(planner: TourPlannerController): boolean {
|
||||
if (planner.error === 'paddle-route') return false
|
||||
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
}
|
||||
|
||||
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
|
||||
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
|
||||
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
|
||||
return t(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor is doing right now, as icon badges beside the head's title:
|
||||
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
||||
@@ -132,10 +145,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
const restored = planner.draftRestored && planner.hasUnsavedChanges
|
||||
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
|
||||
const failed = planner.error === 'save'
|
||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
const canRetry = retryable(planner)
|
||||
if (!restored && !statusKey && !failed && !onFerrata) return null
|
||||
const warning = planner.error != null || canRetry
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null
|
||||
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
||||
return (
|
||||
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
||||
@@ -326,7 +339,11 @@ function PaddleEditorCards({ planner, locked, checklist }: {
|
||||
<>
|
||||
{checklist && <PaddleCheckCard checklist={checklist} />}
|
||||
<PaddleTimeCard planner={planner} locked={locked} />
|
||||
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
|
||||
<PaddleWindSection
|
||||
placeId={planner.editingPlaceId}
|
||||
start={planner.waypoints[0] ?? null}
|
||||
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,3 +75,33 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourTypeFields lake style (PAD-11)', () => {
|
||||
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
|
||||
const river = planner({}, { waterContext: 'river' });
|
||||
const { rerender } = render(<TourTypeFields planner={river} locked={false} />);
|
||||
expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument();
|
||||
|
||||
const lake = planner({}, { waterContext: 'lake' });
|
||||
rerender(<TourTypeFields planner={lake} locked={false} />);
|
||||
expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Near the shore' }));
|
||||
expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore');
|
||||
|
||||
const lakeLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[53.4, 12.7],
|
||||
[53.5, 12.7],
|
||||
],
|
||||
segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||
distance_m: 11000,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' });
|
||||
rerender(<TourTypeFields planner={crossing} locked={false} />);
|
||||
expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice';
|
||||
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -126,6 +128,9 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
||||
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && (
|
||||
<PaddleLakeStyleField value={paddle.lakeStyle} onChange={paddle.setLakeStyle} />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
|
||||
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { PaddleTourRoute } from './paddleRouting';
|
||||
import TourWaypointList from './TourWaypointList';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||
|
||||
const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const };
|
||||
const SHAPE = { id: 's', lat: 49.08, lng: 12.11, role: 'via' as const, shaping: true };
|
||||
const STOP = { id: 'b', lat: 49.05, lng: 12.12, role: 'via' as const };
|
||||
const END = { id: 'c', lat: 49.0, lng: 12.1, role: 'end' as const };
|
||||
|
||||
const segment = (kind: 'water' | 'rapids' | 'pass' | 'gap', from: number, to: number, grade: number | null = null) => ({
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
name: null,
|
||||
source: null,
|
||||
osm_ref: null,
|
||||
grade,
|
||||
});
|
||||
|
||||
const WHITEWATER: PaddleRouteLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[49.05, 12.12],
|
||||
[49.04, 12.12],
|
||||
[49.03, 12.115],
|
||||
[49.0, 12.1],
|
||||
],
|
||||
segments: [segment('water', 0, 1), segment('rapids', 1, 2, 2), segment('gap', 2, 3)],
|
||||
distance_m: 5800,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
return {
|
||||
...hikePlannerDefaults(),
|
||||
waypoints: [START, SHAPE, STOP, END],
|
||||
selectedWaypointId: null,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
reorderWaypoint: vi.fn(),
|
||||
moveWaypoint: vi.fn(),
|
||||
removeWaypoint: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TourPlannerController;
|
||||
}
|
||||
|
||||
function paddleRoute(legs: PaddleTourRoute['legs'], failures: PaddleTourRoute['failures'] = []): PaddleTourRoute {
|
||||
return { coordinates: [], parts: [], distanceMeters: 0, legs, legLengths: legs.map(() => 0), failures };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
describe('TourWaypointList', () => {
|
||||
it('FE-TOUR-WAYPOINT-LIST-001: numbers the stops only; a shaping point is a dot and does not count', () => {
|
||||
render(<TourWaypointList planner={planner()} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(within(rows[1]!).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true');
|
||||
expect(
|
||||
[...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number'))
|
||||
).toEqual(['1', '2', '3']);
|
||||
expect(screen.getByText('Click the route on the map to add a shaping point there.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => {
|
||||
const controller = planner({ selectedWaypointId: 'b' });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const row = screen.getAllByTestId('tour-planner-waypoint-row')[2]!;
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' }));
|
||||
expect(controller.recomputeLeg).toHaveBeenCalledWith('b');
|
||||
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make it a shaping point' }));
|
||||
expect(controller.setWaypointShaping).toHaveBeenCalledWith('b', true);
|
||||
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Draw the next leg by hand' }));
|
||||
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-003: a shaping point becomes a stop again; the first point offers no shaping', async () => {
|
||||
const controller = planner({ selectedWaypointId: 's' });
|
||||
const { rerender } = render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
await userEvent.click(
|
||||
within(screen.getAllByTestId('tour-planner-waypoint-row')[1]!).getByRole('button', { name: 'More actions' })
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' }));
|
||||
expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false);
|
||||
|
||||
rerender(<TourWaypointList planner={planner({ selectedWaypointId: 'a' })} canEdit isSaving={false} />);
|
||||
await userEvent.click(
|
||||
within(screen.getAllByTestId('tour-planner-waypoint-row')[0]!).getByRole('button', { name: 'More actions' })
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-004: a paddle leg shows its whitewater and its data gap with their lengths', () => {
|
||||
const route = paddleRoute([null, null, WHITEWATER]);
|
||||
render(
|
||||
<TourWaypointList
|
||||
planner={planner({ ...{ isPaddle: true, tourType: 'kayak' }, paddleRoute: route, routeCurrent: true })}
|
||||
canEdit
|
||||
isSaving={false}
|
||||
/>
|
||||
);
|
||||
const notes = screen.getByTestId('tour-planner-leg-notes');
|
||||
expect(within(notes).getByText('Whitewater 2')).toBeInTheDocument();
|
||||
expect(within(notes).getByText('Gap in the map data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-005: a leg the router could not plan says why and offers the way out', async () => {
|
||||
const route = paddleRoute(
|
||||
[{ status: 'failed', code: 'busy', at: null }, null, null],
|
||||
[{ index: 0, code: 'busy', at: null }]
|
||||
);
|
||||
const controller = planner({ isPaddle: true, tourType: 'kayak', paddleRoute: route, routeCurrent: true });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const failure = screen.getByTestId('tour-planner-leg-failure');
|
||||
expect(within(failure).getByRole('alert')).toHaveTextContent('The route planner is busy. Try again in a moment.');
|
||||
await userEvent.click(within(failure).getByRole('button', { name: 'Try again' }));
|
||||
expect(controller.recomputeLeg).toHaveBeenCalledWith('a');
|
||||
await userEvent.click(within(failure).getByRole('button', { name: 'Draw straight' }));
|
||||
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-006: a point off the water offers no retry; legs drawn by hand can go back to the water in one step', async () => {
|
||||
const drawn = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49.1, 12.1],
|
||||
[49.08, 12.11],
|
||||
] as [number, number][],
|
||||
};
|
||||
const route = paddleRoute(
|
||||
[null, { status: 'failed', code: 'not_on_water', at: null }, null],
|
||||
[{ index: 1, code: 'not_on_water', at: null }]
|
||||
);
|
||||
const auto = { from: { id: 'a', lat: 49.1, lng: 12.1 }, to: { id: 'c', lat: 49.0, lng: 12.1 } };
|
||||
const controller = planner({
|
||||
isPaddle: true,
|
||||
tourType: 'kayak',
|
||||
paddleRoute: route,
|
||||
routeCurrent: true,
|
||||
waypoints: [{ ...START, fixedLeg: drawn }, SHAPE, { ...STOP, auto }, END],
|
||||
});
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
expect(
|
||||
within(screen.getByTestId('tour-planner-leg-failure')).queryByRole('button', { name: 'Try again' })
|
||||
).toBeNull();
|
||||
expect(screen.getByTestId('tour-planner-auto-point')).toHaveAttribute('aria-label', 'Found');
|
||||
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
|
||||
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared';
|
||||
import {
|
||||
paddleLayoutOfPoints,
|
||||
type PaddleLayout,
|
||||
type PaddleRegionLoadState,
|
||||
type PaddleRole,
|
||||
type TourFixedLeg,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
GripVertical,
|
||||
Info,
|
||||
MapPin,
|
||||
MapPinPlus,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Spline,
|
||||
Trash2,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -28,10 +36,15 @@ import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
||||
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
||||
import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from '../paddle/PaddleRegionLoadRow';
|
||||
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
|
||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
|
||||
import { RailCard } from '../tourParts';
|
||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||
import type { PaddleLegFailure } from './paddleRouting';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||
@@ -207,18 +220,35 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
||||
* by the tooltip.
|
||||
*/
|
||||
function WaypointNumber({
|
||||
index,
|
||||
stop,
|
||||
selected,
|
||||
ferrata,
|
||||
loopEnd = false,
|
||||
shaping = false,
|
||||
}: {
|
||||
index: number;
|
||||
/** The stop's number; shaping points do not count, as on the map. */
|
||||
stop: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||
loopEnd?: boolean;
|
||||
/** P2A-03: a shaping point, a small dot as on the map. */
|
||||
shaping?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (shaping) {
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.planner.shapingPoint')}
|
||||
data-waypoint-shaping="true"
|
||||
className="grid h-7 w-7 flex-none place-items-center"
|
||||
>
|
||||
<span
|
||||
className={`h-3 w-3 rounded-full ring-2 ring-surface-card ${selected ? 'bg-accent' : 'bg-content-faint'}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (loopEnd) {
|
||||
return (
|
||||
<span
|
||||
@@ -230,16 +260,16 @@ function WaypointNumber({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
||||
const label = t('tours.planner.waypointLabel', { n: stop });
|
||||
const number = (
|
||||
<span
|
||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||
data-waypoint-number={index + 1}
|
||||
data-waypoint-number={stop}
|
||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{index + 1}
|
||||
{stop}
|
||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||
</span>
|
||||
);
|
||||
@@ -278,9 +308,18 @@ function WaypointRowLabel({
|
||||
>
|
||||
{name ?? coordinates}
|
||||
</span>
|
||||
{paddleRole !== undefined
|
||||
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
|
||||
{point.shaping ? (
|
||||
<Tooltip label={t('tours.planner.shapingHint')}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill>{t('tours.planner.shapingPoint')}</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : paddleRole !== undefined ? (
|
||||
paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||
) : (
|
||||
point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
|
||||
)}
|
||||
{point.auto && <AutoPointPill />}
|
||||
</span>
|
||||
{name && !loopEnd && (
|
||||
<span
|
||||
@@ -302,6 +341,82 @@ function WaypointRowLabel({
|
||||
* and remove beside it. Two controls keep the place name readable in a narrow
|
||||
* rail however many tools a tour type adds.
|
||||
*/
|
||||
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
type WaypointPoint = TourPlannerController['waypoints'][number];
|
||||
|
||||
/**
|
||||
* The tools for the leg after a point: drawn by hand or routed (on a paddle
|
||||
* tour: on the water), planned again (P2A-05), and the point a stop or a
|
||||
* shaping point (P2A-03; never the first or the last).
|
||||
*/
|
||||
function routeMenuItems(
|
||||
planner: TourPlannerController,
|
||||
point: WaypointPoint,
|
||||
index: number,
|
||||
lastIndex: number,
|
||||
t: Translate
|
||||
): MenuItems {
|
||||
const hasLeg = index < lastIndex;
|
||||
const free = point.fixedLeg?.kind === 'free';
|
||||
const routed = hasLeg && !point.fixedLeg;
|
||||
const items: MenuItems = [];
|
||||
if (free || routed) {
|
||||
const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg');
|
||||
items.push(
|
||||
{ divider: true },
|
||||
{
|
||||
label: free ? routeAgain : t('tours.planner.freeLeg'),
|
||||
icon: free && planner.isPaddle ? Waves : Spline,
|
||||
onClick: () => planner.toggleWaypointFreeLeg(point.id),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (routed)
|
||||
items.push({
|
||||
label: t('tours.planner.recomputeLeg'),
|
||||
icon: RefreshCw,
|
||||
onClick: () => planner.recomputeLeg(point.id),
|
||||
});
|
||||
if (index > 0 && index < lastIndex) {
|
||||
items.push({
|
||||
label: t(point.shaping ? 'tours.planner.makeStop' : 'tours.planner.makeShaping'),
|
||||
icon: Navigation,
|
||||
onClick: () => planner.setWaypointShaping(point.id, !point.shaping),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** A paddle tour's tools at a point: carry the boat from here, and a lock here. */
|
||||
function paddleMenuItems(
|
||||
paddle: ReturnType<typeof usePaddleWaypointActions>,
|
||||
point: WaypointPoint,
|
||||
hasLeg: boolean,
|
||||
t: Translate
|
||||
): MenuItems {
|
||||
const portage = point.fixedLeg?.kind === 'portage';
|
||||
const items: MenuItems = [{ divider: true }];
|
||||
// At the last point too: the portage starts here and reaches the point set next.
|
||||
items.push({
|
||||
label: t(
|
||||
!hasLeg && point.carryNext
|
||||
? 'tours.paddle.portagePendingOff'
|
||||
: portage
|
||||
? 'tours.paddle.portageOff'
|
||||
: 'tours.paddle.portageOn'
|
||||
),
|
||||
icon: PortageIcon,
|
||||
onClick: () => paddle.togglePortage(point.id),
|
||||
});
|
||||
items.push({
|
||||
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
|
||||
icon: LockIcon,
|
||||
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function WaypointRowActions({
|
||||
planner,
|
||||
point,
|
||||
@@ -311,7 +426,7 @@ function WaypointRowActions({
|
||||
isSaving,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
point: WaypointPoint;
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
selected: boolean;
|
||||
@@ -320,10 +435,7 @@ function WaypointRowActions({
|
||||
const { t } = useTranslation();
|
||||
const menu = useContextMenu();
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const free = point.fixedLeg?.kind === 'free';
|
||||
const portage = point.fixedLeg?.kind === 'portage';
|
||||
const hasLeg = index < lastIndex;
|
||||
const items = [
|
||||
const items: MenuItems = [
|
||||
{
|
||||
label: t('tours.planner.moveUp'),
|
||||
icon: ArrowUp,
|
||||
@@ -336,35 +448,8 @@ function WaypointRowActions({
|
||||
disabled: index === lastIndex,
|
||||
onClick: () => planner.moveWaypoint(point.id, 1),
|
||||
},
|
||||
...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata'
|
||||
? [
|
||||
{ divider: true },
|
||||
{
|
||||
label: t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg'),
|
||||
icon: Spline,
|
||||
onClick: () => planner.toggleWaypointFreeLeg(point.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(planner.isPaddle
|
||||
? [
|
||||
{ divider: true },
|
||||
...(hasLeg
|
||||
? [
|
||||
{
|
||||
label: t(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'),
|
||||
icon: PortageIcon,
|
||||
onClick: () => paddle.togglePortage(point.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
|
||||
icon: LockIcon,
|
||||
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...routeMenuItems(planner, point, index, lastIndex, t),
|
||||
...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []),
|
||||
];
|
||||
return (
|
||||
<span
|
||||
@@ -402,24 +487,29 @@ function PaddleRowExtras({
|
||||
index,
|
||||
layout,
|
||||
editable,
|
||||
routing,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
index: number;
|
||||
layout: PaddleLayout;
|
||||
editable: boolean;
|
||||
routing: ReturnType<typeof usePortageRouting>;
|
||||
}) {
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const way = usePortageWay(planner.waypoints, routing, index);
|
||||
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
|
||||
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
|
||||
return (
|
||||
<>
|
||||
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
|
||||
{portage && (
|
||||
<PortageLegRow
|
||||
lengthM={portage.toM - portage.fromM}
|
||||
extraMinutes={portage.extraMinutes}
|
||||
disabled={!editable}
|
||||
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
||||
way={point.auto ? undefined : way}
|
||||
/>
|
||||
)}
|
||||
{point.lock && (
|
||||
@@ -463,6 +553,154 @@ function AddPointRow({ disabled }: { disabled: boolean }) {
|
||||
* reorderable by drag or by the arrow controls on each row. The coordinates
|
||||
* stand in for the name while it loads, offline, or when the lookup has none.
|
||||
*/
|
||||
/** P2A-03: how a shaping point is set, said once under the list while there is a route to click. */
|
||||
function ShapeOnLineHint({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!canEdit || planner.waypoints.length < 2) return null;
|
||||
return (
|
||||
<p className="m-0 mt-1.5 px-1.5 text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.planner.shapeOnLine')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stops are numbered on their own, as on the map: a shaping point (P2A-03) takes no number. */
|
||||
function stopNumbersOf(points: ReadonlyArray<WaypointPoint>): Map<string, number> {
|
||||
const numbers = new Map<string, number>();
|
||||
for (const point of points) if (!point.shaping) numbers.set(point.id, numbers.size + 1);
|
||||
return numbers;
|
||||
}
|
||||
|
||||
/** The route tools in the card head: reverse, plan the drawn legs on the water (PAD-11), back to the start. */
|
||||
function WaypointListTools({ planner, isSaving }: { planner: TourPlannerController; isSaving: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const hasDrawnLegs = planner.isPaddle && planner.waypoints.some((point) => point.fixedLeg?.kind === 'free');
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<RouteToolButton
|
||||
testId="tour-planner-reverse"
|
||||
label={t('tours.planner.reverse')}
|
||||
disabled={isSaving || planner.waypoints.length < 2}
|
||||
onAct={planner.reverse}
|
||||
>
|
||||
<ArrowDownUp size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
{hasDrawnLegs && (
|
||||
<RouteToolButton
|
||||
testId="tour-planner-route-on-water"
|
||||
label={t('tours.paddle.routeAllOnWater')}
|
||||
disabled={isSaving}
|
||||
onAct={planner.routeDrawnLegsOnWater}
|
||||
>
|
||||
<Waves size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
)}
|
||||
<RouteToolButton
|
||||
testId="tour-planner-return-to-start"
|
||||
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
|
||||
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
|
||||
onAct={planner.returnToStart}
|
||||
>
|
||||
<Repeat size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan. Where it lacked the water data of the
|
||||
* region (PAD-11C), the row follows the data instead: loading or asked for,
|
||||
* the leg waits calmly; declined, failed or not to be had, it says why.
|
||||
*/
|
||||
function LegFailure({
|
||||
code,
|
||||
load,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
load: PaddleRegionLoadState | undefined;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
onLoadRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isRegionLoadPending(load)) {
|
||||
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
|
||||
}
|
||||
return (
|
||||
<PaddleLegFailureRow
|
||||
code={code}
|
||||
disabled={disabled}
|
||||
onDrawStraight={onDrawStraight}
|
||||
onRetry={onRetry}
|
||||
message={regionLoadFailure(load, t)}
|
||||
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows under a waypoint for the leg that starts there: the via ferrata it
|
||||
* crosses, a line drawn by hand, and on a paddle tour what the router found on
|
||||
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
|
||||
*/
|
||||
function WaypointLegRows({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
crossings = [],
|
||||
routedLegs,
|
||||
paddleLayout,
|
||||
editable,
|
||||
portageRouting,
|
||||
regionLoad,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: WaypointPoint;
|
||||
index: number;
|
||||
crossings?: TourFerrataCrossing[];
|
||||
routedLegs: TourPlannerController['paddleRoute'] | null;
|
||||
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
|
||||
editable: boolean;
|
||||
portageRouting: ReturnType<typeof usePortageRouting>;
|
||||
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
|
||||
}) {
|
||||
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
|
||||
return (
|
||||
<>
|
||||
{crossings.map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
|
||||
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
|
||||
{failure && (
|
||||
<LegFailure
|
||||
code={failure.code}
|
||||
load={regionLoad.byLeg.get(index)}
|
||||
disabled={!editable}
|
||||
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
|
||||
onRetry={() => planner.recomputeLeg(point.id)}
|
||||
onLoadRetry={regionLoad.retry}
|
||||
/>
|
||||
)}
|
||||
{paddleLayout && (
|
||||
<PaddleRowExtras
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
layout={paddleLayout}
|
||||
editable={editable}
|
||||
routing={portageRouting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourWaypointList({
|
||||
planner,
|
||||
canEdit,
|
||||
@@ -489,34 +727,22 @@ export default function TourWaypointList({
|
||||
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
|
||||
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
|
||||
const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null;
|
||||
// PAD-03: portages the paddler sets follow the ways on land.
|
||||
const portageRouting = usePortageRouting(planner, canEdit && !isSaving);
|
||||
// The router's answer per leg, while it still belongs to these points (PAD-11).
|
||||
const routedLegs = planner.isPaddle && planner.routeCurrent ? planner.paddleRoute : null;
|
||||
// PAD-11C: where a leg lacks the water data of its region, TREK fetches it and plans the leg again.
|
||||
const regionLoad = usePaddleRegionLoad(routedLegs?.failures, planner.retry);
|
||||
const stopNumbers = stopNumbersOf(planner.waypoints);
|
||||
const endDrag = () => {
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
// One card like a day in the plan: the head counts the points, the rows sit below.
|
||||
const tools = canEdit && planner.waypoints.length > 0 && (
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<RouteToolButton
|
||||
testId="tour-planner-reverse"
|
||||
label={t('tours.planner.reverse')}
|
||||
disabled={isSaving || planner.waypoints.length < 2}
|
||||
onAct={planner.reverse}
|
||||
>
|
||||
<ArrowDownUp size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
<RouteToolButton
|
||||
testId="tour-planner-return-to-start"
|
||||
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
|
||||
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
|
||||
onAct={planner.returnToStart}
|
||||
>
|
||||
<Repeat size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
</span>
|
||||
);
|
||||
// The way back of a loop is no stop of its own, so the count leaves it out.
|
||||
const stops = planner.waypoints.length - (planner.isLoop ? 1 : 0);
|
||||
const tools = canEdit && planner.waypoints.length > 0 && <WaypointListTools planner={planner} isSaving={isSaving} />;
|
||||
// The way back of a loop is no stop of its own, and a shaping point no stop at all, so the count leaves them out.
|
||||
const stops = stopNumbers.size - (planner.isLoop ? 1 : 0);
|
||||
const card = (children: ReactNode) => (
|
||||
<RailCard
|
||||
icon={<MapPin size={14} strokeWidth={2} />}
|
||||
@@ -608,10 +834,11 @@ export default function TourWaypointList({
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<WaypointNumber
|
||||
index={index}
|
||||
stop={stopNumbers.get(point.id) ?? index + 1}
|
||||
selected={selected}
|
||||
ferrata={ferrataEnds[point.id] ?? null}
|
||||
loopEnd={loopEnd}
|
||||
shaping={!!point.shaping}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -638,30 +865,23 @@ export default function TourWaypointList({
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
|
||||
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
|
||||
{paddleLayout && (
|
||||
<PaddleRowExtras
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
layout={paddleLayout}
|
||||
editable={canEdit && !isSaving}
|
||||
/>
|
||||
)}
|
||||
<WaypointLegRows
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
crossings={legCrossings.get(point.id)}
|
||||
routedLegs={routedLegs}
|
||||
paddleLayout={paddleLayout}
|
||||
editable={canEdit && !isSaving}
|
||||
portageRouting={portageRouting}
|
||||
regionLoad={regionLoad}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||
{planner.isPaddle && (
|
||||
<div className="mt-2">
|
||||
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
<ShapeOnLineHint planner={planner} canEdit={canEdit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PaddleRole,
|
||||
type PaddleTimeEstimate,
|
||||
} from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/** The planner's control points as the shared paddle plan reads them. */
|
||||
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
|
||||
@@ -26,15 +26,17 @@ export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map<str
|
||||
|
||||
/**
|
||||
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the
|
||||
* planner adds those as it does for a hike). A speed the paddler typed that
|
||||
* planner adds those as it does for a hike), with the routed length of each
|
||||
* leg where the router planned it (PAD-11). A speed the paddler typed that
|
||||
* does not hold leaves the water time unknown rather than wrong.
|
||||
*/
|
||||
export function paddleEstimateOf(
|
||||
points: readonly TourPlannerWaypoint[],
|
||||
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
|
||||
speedInvalid: boolean
|
||||
speedInvalid: boolean,
|
||||
legLengths?: ReadonlyArray<number | null>
|
||||
): PaddleTimeEstimate {
|
||||
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), {
|
||||
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points), legLengths), {
|
||||
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
||||
portageSpeedKmh: paddle.portageSpeedKmh,
|
||||
breakMinutes: null,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
|
||||
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
|
||||
import {
|
||||
adoptChangedPoints,
|
||||
materializePaddleLegs,
|
||||
planPaddleStep,
|
||||
pruneAutoPoints,
|
||||
routePaddleTour,
|
||||
type PaddlePoint,
|
||||
} from './paddleRouting';
|
||||
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } }));
|
||||
|
||||
const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const };
|
||||
const A: PaddlePoint = { id: 'a', lat: 49.1, lng: 12.1 };
|
||||
const B: PaddlePoint = { id: 'b', lat: 49.05, lng: 12.12 };
|
||||
const C: PaddlePoint = { id: 'c', lat: 49.0, lng: 12.1 };
|
||||
|
||||
function segment(kind: PaddleRouteSegment['kind'], from: number, to: number, extra: Partial<PaddleRouteSegment> = {}) {
|
||||
return { kind, from, to, name: null, source: null, osm_ref: null, grade: null, ...extra };
|
||||
}
|
||||
|
||||
/** A routed leg through five points: water, a portage round a weir, water, a lock, water. */
|
||||
const WEIR_AND_LOCK: PaddleRouteLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[49.1, 12.1],
|
||||
[49.09, 12.11],
|
||||
[49.08, 12.11],
|
||||
[49.07, 12.115],
|
||||
[49.06, 12.118],
|
||||
[49.05, 12.12],
|
||||
],
|
||||
segments: [
|
||||
segment('water', 0, 1),
|
||||
segment('portage', 1, 2, { source: 'tagged' }),
|
||||
segment('water', 2, 3),
|
||||
segment('lock', 3, 4, { name: 'Schleuse' }),
|
||||
segment('water', 4, 5),
|
||||
],
|
||||
distance_m: 6100,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
|
||||
const straight = (from: PaddlePoint, to: PaddlePoint, distance = 1000): PaddleRouteLeg => ({
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
segments: [segment('water', 0, 1)],
|
||||
distance_m: distance,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
});
|
||||
|
||||
let ids = 0;
|
||||
const newId = () => `auto-${(ids += 1)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
ids = 0;
|
||||
vi.mocked(paddleRouteRepo.plan).mockReset();
|
||||
});
|
||||
|
||||
describe('routePaddleTour', () => {
|
||||
it('FE-PADDLE-ROUTING-001: asks only for the legs without a line, and keeps a drawn or carried leg as it is', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([straight(B, C, 5500)]);
|
||||
const drawn = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
};
|
||||
const route = await routePaddleTour([{ ...A, fixedLeg: drawn }, B, C], 7, KAYAK);
|
||||
|
||||
expect(vi.mocked(paddleRouteRepo.plan).mock.calls[0]?.[1]).toEqual([{ from: B, to: C }]);
|
||||
expect(route.legs[0]).toBeNull();
|
||||
expect(route.legs[1]?.status).toBe('ok');
|
||||
expect(route.parts.map((part) => [part.free, !!part.land])).toEqual([
|
||||
[true, false],
|
||||
[false, false],
|
||||
]);
|
||||
expect(route.legLengths[1]).toBe(5500);
|
||||
expect(route.distanceMeters).toBeCloseTo(route.legLengths[0]! + 5500);
|
||||
expect(route.coordinates[0]).toEqual([A.lat, A.lng]);
|
||||
expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-002: draws a portage as land, whitewater and a boat pass amber, a gap red, and merges the rest', async () => {
|
||||
const marked: PaddleRouteLeg = {
|
||||
...WEIR_AND_LOCK,
|
||||
segments: [
|
||||
segment('water', 0, 1),
|
||||
segment('portage', 1, 2),
|
||||
segment('rapids', 2, 3, { grade: 2 }),
|
||||
segment('pass', 3, 4),
|
||||
segment('gap', 4, 5),
|
||||
],
|
||||
};
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([marked]);
|
||||
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||
expect(route.parts.map((part) => part.land ?? part.mark ?? 'water')).toEqual(['water', true, 'rapids', 'gap']);
|
||||
// Whitewater and the boat pass next to it are one amber line.
|
||||
expect(route.parts[2]?.coordinates).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-003: a leg the router could not plan stays a straight unrouted stretch, counted as a failure', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([
|
||||
straight(A, B),
|
||||
{ status: 'failed', code: 'not_on_water', at: { lat: C.lat, lng: C.lng } },
|
||||
]);
|
||||
const route = await routePaddleTour([A, B, C], 7, KAYAK);
|
||||
expect(route.failures).toEqual([{ index: 1, code: 'not_on_water', at: { lat: C.lat, lng: C.lng } }]);
|
||||
expect(route.parts[route.parts.length - 1]).toMatchObject({
|
||||
mark: 'unrouted',
|
||||
coordinates: [
|
||||
[B.lat, B.lng],
|
||||
[C.lat, C.lng],
|
||||
],
|
||||
});
|
||||
// The straight line counts in the length, so the time does not shrink while a leg waits.
|
||||
expect(route.legLengths[1]).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-004: a leg the answer leaves out is unrouted, never silently straight water', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([]);
|
||||
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||
expect(route.failures).toEqual([{ index: 0, code: 'no_water_path', at: null }]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-005: a tour with every leg drawn asks nothing', async () => {
|
||||
const carried = {
|
||||
kind: 'portage' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
extra_minutes: null,
|
||||
};
|
||||
const route = await routePaddleTour([{ ...A, fixedLeg: carried }, B], 7, KAYAK);
|
||||
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||
expect(route.parts[0]?.land).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic points', () => {
|
||||
it("FE-PADDLE-ROUTING-006: sets a portage pair and a lock where the router found them, on the paddler's own leg", () => {
|
||||
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
expect(points.map((point) => point.id)).toEqual(['a', 'auto-1', 'auto-2', 'auto-3', 'b']);
|
||||
expect(points[1]).toMatchObject({ lat: 49.09, lng: 12.11, fixedLeg: { kind: 'portage', extra_minutes: null } });
|
||||
expect(points[2]).toMatchObject({ lat: 49.08, lng: 12.11 });
|
||||
expect(points[2]?.fixedLeg).toBeUndefined();
|
||||
expect(points[3]).toMatchObject({ lock: { wait_minutes: null, transit_minutes: null } });
|
||||
expect(points[1]?.auto).toEqual({
|
||||
from: { id: 'a', lat: A.lat, lng: A.lng },
|
||||
to: { id: 'b', lat: B.lat, lng: B.lng },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-007: planning the same leg again keeps the ids, and a leg at an automatic point adds nothing', () => {
|
||||
const once = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
const legs = once.slice(0, -1).map(() => WEIR_AND_LOCK);
|
||||
const twice = materializePaddleLegs(once, { legs }, newId);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-008: drops the automatic points of a leg whose end moved or went, and keeps them otherwise', () => {
|
||||
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
|
||||
expect(pruneAutoPoints(points)).toEqual(points);
|
||||
const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
|
||||
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']);
|
||||
const shaped = [points[0]!, { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)];
|
||||
expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-009: a point the paddler moves or edits is theirs; an untouched one stays automatic', () => {
|
||||
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
const edited = points.map((point) =>
|
||||
point.id === 'auto-3' ? { ...point, lock: { wait_minutes: 20, transit_minutes: null } } : point
|
||||
);
|
||||
const adopted = adoptChangedPoints(points, edited);
|
||||
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
|
||||
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaddleStep', () => {
|
||||
it('FE-PADDLE-ROUTING-010: prunes first, then sets what the router found, then answers the route', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([WEIR_AND_LOCK]);
|
||||
const first = await planPaddleStep([A, B], 7, KAYAK, signal, newId);
|
||||
expect('points' in first && first.points).toHaveLength(5);
|
||||
|
||||
const found = 'points' in first ? first.points : [];
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue(found.slice(0, -1).map(() => straight(A, B, 1500)));
|
||||
const second = await planPaddleStep(found, 7, KAYAK, signal, newId);
|
||||
expect('route' in second && second.route.legLengths).toHaveLength(4);
|
||||
|
||||
const stale = found.map((point) => (point.id === 'a' ? { ...point, lng: point.lng + 0.01 } : point));
|
||||
vi.mocked(paddleRouteRepo.plan).mockClear();
|
||||
const third = await planPaddleStep(stale, 7, KAYAK, signal, newId);
|
||||
expect('points' in third && third.points.map((point) => point.id)).toEqual(['a', 'b']);
|
||||
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { PaddleLock, PaddleRouteFailureCode, PaddleRouteLeg, TourFixedLeg } from '@trek/shared';
|
||||
import { paddleRouteRepo, type PaddleLegAnswer, type PaddleRouteProfile } from '../../../repo/paddleRouteRepo';
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
/**
|
||||
* PAD-11: a paddle tour's route in the planner. A leg without a line of its
|
||||
* own is planned on the water by the server's router, as a hike's legs are by
|
||||
* Valhalla; a leg drawn by hand or carried as a portage keeps its line. Where
|
||||
* the router carries round a weir or goes through a lock between two of the
|
||||
* paddler's own points, the planner sets points of its own there (`auto`), so
|
||||
* the portage and the lock count like any planned by hand: in the time, in the
|
||||
* checklist, on the map. They are derived again whenever the points they came
|
||||
* from change, and become the paddler's own once touched.
|
||||
*/
|
||||
|
||||
/** Where an automatic point came from: the two points of the paddler's leg it was found on, as they were. */
|
||||
export interface AutoBase {
|
||||
from: { id: string; lat: number; lng: number };
|
||||
to: { id: string; lat: number; lng: number };
|
||||
}
|
||||
|
||||
export interface PaddlePoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
lock?: PaddleLock | null;
|
||||
shaping?: boolean;
|
||||
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||
auto?: AutoBase;
|
||||
}
|
||||
|
||||
export type PaddleLegFailure = PaddleRouteFailureCode | 'offline';
|
||||
|
||||
export interface PaddleTourRoute {
|
||||
coordinates: [number, number][];
|
||||
parts: TourRoutePart[];
|
||||
distanceMeters: number;
|
||||
/** Per leg (index of its first point): the router's answer, or null for a leg with a line of its own. */
|
||||
legs: Array<PaddleLegAnswer | null>;
|
||||
/** The routed length of every leg, metres, for the time estimate. */
|
||||
legLengths: number[];
|
||||
failures: Array<{ index: number; code: PaddleLegFailure; at: { lat: number; lng: number } | null }>;
|
||||
}
|
||||
|
||||
/** What a leg holds when its answer is somehow missing: unrouted, never silently straight. */
|
||||
const NOT_ASKED: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
|
||||
|
||||
/** Two points this close are one spot, in degrees (about ten centimetres). */
|
||||
const SAME_SPOT = 1e-6;
|
||||
|
||||
function samePlace(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
|
||||
return Math.abs(a.lat - b.lat) < SAME_SPOT && Math.abs(a.lng - b.lng) < SAME_SPOT;
|
||||
}
|
||||
|
||||
type PartKind = 'water' | 'rapids' | 'gap' | 'unrouted' | 'land' | 'manual';
|
||||
|
||||
function partOf(kind: PartKind, coordinates: [number, number][]): TourRoutePart {
|
||||
if (kind === 'land') return { coordinates, free: false, land: true };
|
||||
if (kind === 'manual') return { coordinates, free: true };
|
||||
return { coordinates, free: false, ...(kind === 'water' ? {} : { mark: kind }) };
|
||||
}
|
||||
|
||||
/** The parts of one routed leg: water as one line, carried and marked stretches as their own. */
|
||||
function routedParts(leg: Extract<PaddleRouteLeg, { status: 'ok' }>): TourRoutePart[] {
|
||||
return leg.segments.map((segment) => {
|
||||
const line = leg.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||
if (segment.kind === 'portage') return partOf('land', line);
|
||||
if (segment.kind === 'rapids' || segment.kind === 'pass') return partOf('rapids', line);
|
||||
if (segment.kind === 'gap') return partOf('gap', line);
|
||||
return partOf('water', line);
|
||||
});
|
||||
}
|
||||
|
||||
/** Neighbouring parts drawn alike as one line, as the hike's route does. */
|
||||
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
|
||||
const merged: TourRoutePart[] = [];
|
||||
for (const part of parts) {
|
||||
const previous = merged[merged.length - 1];
|
||||
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
|
||||
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)];
|
||||
} else {
|
||||
merged.push({ ...part, coordinates: [...part.coordinates] });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Plans the legs that need it and puts the whole line together. */
|
||||
export async function routePaddleTour(
|
||||
points: readonly PaddlePoint[],
|
||||
tripId: number | string,
|
||||
profile: PaddleRouteProfile,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleTourRoute> {
|
||||
const routed = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index]));
|
||||
const answers = routed.length
|
||||
? await paddleRouteRepo.plan(
|
||||
tripId,
|
||||
routed.map((index) => ({ from: points[index], to: points[index + 1] })),
|
||||
profile,
|
||||
signal
|
||||
)
|
||||
: [];
|
||||
const byLeg = new Map(routed.map((index, at) => [index, answers[at]]));
|
||||
const parts: TourRoutePart[] = [];
|
||||
const legs: PaddleTourRoute['legs'] = [];
|
||||
const legLengths: number[] = [];
|
||||
const failures: PaddleTourRoute['failures'] = [];
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (leg) {
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
parts.push(partOf(leg.kind === 'portage' ? 'land' : 'manual', line));
|
||||
legs.push(null);
|
||||
legLengths.push(lineLengthM(line));
|
||||
continue;
|
||||
}
|
||||
const answer = byLeg.get(index) ?? NOT_ASKED;
|
||||
legs.push(answer);
|
||||
if (answer.status === 'ok') {
|
||||
parts.push(...routedParts(answer));
|
||||
legLengths.push(answer.distance_m);
|
||||
continue;
|
||||
}
|
||||
// A leg the router could not plan stays visible as what it is: a straight stretch nobody checked.
|
||||
failures.push({ index, code: answer.code, at: answer.at });
|
||||
parts.push(
|
||||
partOf('unrouted', [
|
||||
[point.lat, point.lng],
|
||||
[next.lat, next.lng],
|
||||
])
|
||||
);
|
||||
legLengths.push(haversineKm(point, next) * 1000);
|
||||
}
|
||||
const lines = mergeParts(parts);
|
||||
const coordinates = lines.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1)));
|
||||
return {
|
||||
coordinates,
|
||||
parts: lines,
|
||||
distanceMeters: legLengths.reduce((sum, length) => sum + length, 0),
|
||||
legs,
|
||||
legLengths,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOf(points: readonly PaddlePoint[], index: number): AutoBase {
|
||||
const from = points[index];
|
||||
const to = points[index + 1];
|
||||
return { from: { id: from.id, lat: from.lat, lng: from.lng }, to: { id: to.id, lat: to.lat, lng: to.lng } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic points that no longer stand on the leg they were found on
|
||||
* (an end of it moved, or went), dropped; the next plan finds them again
|
||||
* where they belong.
|
||||
*/
|
||||
export function pruneAutoPoints<T extends PaddlePoint>(points: readonly T[]): T[] {
|
||||
const own = points.filter((point) => !point.auto);
|
||||
return points.filter((point) => {
|
||||
const base = point.auto;
|
||||
if (!base) return true;
|
||||
const from = own.find((candidate) => candidate.id === base.from.id);
|
||||
const to = own.find((candidate) => candidate.id === base.to.id);
|
||||
if (!from || !to || !samePlace(from, base.from) || !samePlace(to, base.to)) return false;
|
||||
return own.indexOf(to) === own.indexOf(from) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the portages and locks the router found on the paddler's own legs as
|
||||
* points. An existing automatic point at the same spot keeps its id, so
|
||||
* planning the same leg again changes nothing.
|
||||
*/
|
||||
export function materializePaddleLegs<T extends PaddlePoint>(
|
||||
points: readonly T[],
|
||||
route: Pick<PaddleTourRoute, 'legs'>,
|
||||
newId: () => string
|
||||
): T[] {
|
||||
const existing = points.filter((point) => point.auto);
|
||||
const idAt = (lat: number, lng: number) => existing.find((point) => samePlace(point, { lat, lng }))?.id ?? newId();
|
||||
const result: T[] = [];
|
||||
points.forEach((point, index) => {
|
||||
result.push(point);
|
||||
const next = points[index + 1];
|
||||
const answer = route.legs[index];
|
||||
// Only a leg between two of the paddler's own points: a leg at an automatic point is part of one already set.
|
||||
if (!next || point.auto || next.auto || !answer || answer.status !== 'ok') return;
|
||||
const base = baseOf(points, index);
|
||||
const last = answer.geometry.length - 1;
|
||||
for (const segment of answer.segments) {
|
||||
if (segment.kind === 'portage' && segment.from > 0 && segment.to < last) {
|
||||
const line = answer.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||
const [outLat, outLng] = line[0];
|
||||
const [inLat, inLng] = line[line.length - 1];
|
||||
result.push(
|
||||
{
|
||||
id: idAt(outLat, outLng),
|
||||
lat: outLat,
|
||||
lng: outLng,
|
||||
auto: base,
|
||||
fixedLeg: { kind: 'portage', geometry: line, extra_minutes: null },
|
||||
} as unknown as T,
|
||||
{ id: idAt(inLat, inLng), lat: inLat, lng: inLng, auto: base } as unknown as T
|
||||
);
|
||||
}
|
||||
if (segment.kind === 'lock' && segment.from > 0 && segment.to < last) {
|
||||
const [lat, lng] = answer.geometry[Math.round((segment.from + segment.to) / 2)];
|
||||
result.push({
|
||||
id: idAt(lat, lng),
|
||||
lat,
|
||||
lng,
|
||||
auto: base,
|
||||
lock: { wait_minutes: null, transit_minutes: null },
|
||||
} as unknown as T);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
|
||||
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
|
||||
const previous = new Map(before.map((point) => [point.id, point]));
|
||||
return after.map((point) => {
|
||||
if (!point.auto) return point;
|
||||
const was = previous.get(point.id);
|
||||
const same =
|
||||
!!was &&
|
||||
samePlace(was, point) &&
|
||||
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
|
||||
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
|
||||
if (same) return point;
|
||||
const { auto: _auto, ...own } = point;
|
||||
return own as T;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One run of the paddle router over the points: either points to set first
|
||||
* (automatic ones dropped from a leg that changed, or found on a planned
|
||||
* leg), after which the planner runs again, or the finished route.
|
||||
*/
|
||||
export async function planPaddleStep<T extends PaddlePoint>(
|
||||
points: readonly T[],
|
||||
tripId: number | string,
|
||||
profile: PaddleRouteProfile,
|
||||
signal: AbortSignal,
|
||||
newId: () => string
|
||||
): Promise<{ points: T[] } | { route: PaddleTourRoute }> {
|
||||
const pruned = pruneAutoPoints(points);
|
||||
if (pruned.length !== points.length) return { points: pruned };
|
||||
const route = await routePaddleTour(points, tripId, profile, signal);
|
||||
// Points are only ever added: the same count means nothing new was found.
|
||||
const materialized = materializePaddleLegs(points, route, newId);
|
||||
return materialized.length !== points.length ? { points: materialized } : { route };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-ROUTE-LEG-AT-001 to FE-ROUTE-LEG-AT-003: which leg a click on the drawn route lands in (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { legStartAt } from './routeLegAt';
|
||||
|
||||
const A = { id: 'a', lat: 49.0, lng: 12.0 };
|
||||
const B = { id: 'b', lat: 49.0, lng: 12.1 };
|
||||
const C = { id: 'c', lat: 49.1, lng: 12.1 };
|
||||
// The route bends between the points, as a river does.
|
||||
const LINE: [number, number][] = [
|
||||
[49.0, 12.0],
|
||||
[49.01, 12.05],
|
||||
[49.0, 12.1],
|
||||
[49.05, 12.11],
|
||||
[49.1, 12.1],
|
||||
];
|
||||
|
||||
describe('legStartAt', () => {
|
||||
it('FE-ROUTE-LEG-AT-001: a spot on the line belongs to the leg whose two points enclose it', () => {
|
||||
expect(legStartAt([A, B, C], LINE, 49.009, 12.05)).toBe('a');
|
||||
expect(legStartAt([A, B, C], LINE, 49.05, 12.11)).toBe('b');
|
||||
});
|
||||
|
||||
it('FE-ROUTE-LEG-AT-002: a loop keeps its order, so a spot near the start on the way back is the last leg', () => {
|
||||
const back = { id: 'back', lat: A.lat, lng: A.lng };
|
||||
const loop: [number, number][] = [...LINE, [49.05, 12.02], [49.0, 12.0]];
|
||||
expect(legStartAt([A, B, C, back], loop, 49.05, 12.02)).toBe('c');
|
||||
});
|
||||
|
||||
it('FE-ROUTE-LEG-AT-003: without a line or a leg there is nothing to shape', () => {
|
||||
expect(legStartAt([A, B], null, 49, 12.05)).toBeNull();
|
||||
expect(legStartAt([A], LINE, 49, 12.05)).toBeNull();
|
||||
expect(legStartAt([A, B], [[49, 12]], 49, 12.05)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* P2A-03: which leg of a tour a spot on its drawn route belongs to. Every
|
||||
* waypoint is placed along the line (searching on from where the one before
|
||||
* lay, so a loop or a route that passes a point twice keeps its order), then
|
||||
* the spot; the leg is the one whose two points enclose it.
|
||||
*/
|
||||
|
||||
/** Where a spot falls along a line, as the distance from its start in the line's own degrees (flat earth is plenty for ordering). */
|
||||
function alongLine(
|
||||
line: readonly [number, number][],
|
||||
lat: number,
|
||||
lng: number,
|
||||
fromIndex: number
|
||||
): { at: number; index: number } {
|
||||
const cos = Math.cos((lat * Math.PI) / 180);
|
||||
let travelled = 0;
|
||||
let best = { at: 0, index: fromIndex, distance: Infinity };
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const [aLat, aLng] = line[i];
|
||||
const [bLat, bLng] = line[i + 1];
|
||||
const dx = (bLng - aLng) * cos;
|
||||
const dy = bLat - aLat;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (i >= fromIndex) {
|
||||
const t =
|
||||
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
|
||||
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
|
||||
if (distance < best.distance) best = { at: travelled + length * t, index: i, distance };
|
||||
}
|
||||
travelled += length;
|
||||
}
|
||||
return { at: best.at, index: best.index };
|
||||
}
|
||||
|
||||
/** The id of the waypoint that starts the leg holding the spot, or null when the route has no such leg. */
|
||||
export function legStartAt(
|
||||
points: ReadonlyArray<{ id: string; lat: number; lng: number }>,
|
||||
line: readonly [number, number][] | null,
|
||||
lat: number,
|
||||
lng: number
|
||||
): string | null {
|
||||
if (!line || line.length < 2 || points.length < 2) return null;
|
||||
const positions: number[] = [];
|
||||
let from = 0;
|
||||
for (const point of points) {
|
||||
const placed = alongLine(line, point.lat, point.lng, from);
|
||||
positions.push(placed.at);
|
||||
from = placed.index;
|
||||
}
|
||||
const spot = alongLine(line, lat, lng, 0).at;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
if (spot >= positions[i] && spot <= positions[i + 1]) return points[i].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
} from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import type { PaddleFieldValues } from './usePaddleFields';
|
||||
import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||
|
||||
/**
|
||||
* The tour planner's draft as it is kept in local storage between visits, and
|
||||
@@ -15,7 +15,7 @@ import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||
* half read.
|
||||
*/
|
||||
export interface DraftPayload {
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
name: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
@@ -30,7 +30,7 @@ export interface DraftPayload {
|
||||
paddle?: PaddleFieldValues;
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizePlannerWaypoints,
|
||||
sameWaypointSnapshot,
|
||||
tourWaypointKey,
|
||||
withDrawnLegsRouted,
|
||||
withShaping,
|
||||
withShapingPoint,
|
||||
type TourPlannerWaypoint,
|
||||
} from './tourPlannerWaypoints';
|
||||
|
||||
const A: TourPlannerWaypoint = { id: 'a', lat: 49, lng: 12, role: 'start' };
|
||||
const B: TourPlannerWaypoint = { id: 'b', lat: 49.01, lng: 12.01, role: 'via' };
|
||||
const C: TourPlannerWaypoint = { id: 'c', lat: 49.02, lng: 12.02, role: 'end' };
|
||||
|
||||
describe('tourPlannerWaypoints', () => {
|
||||
it('FE-TOUR-WAYPOINTS-001: a shaping point goes into the leg after the point named, and nowhere past the end', () => {
|
||||
const shaped = withShapingPoint([A, B, C], 'b', { id: 's', lat: 49.015, lng: 12.015 });
|
||||
expect(shaped.map((point) => point.id)).toEqual(['a', 'b', 's', 'c']);
|
||||
expect(shaped[2]).toEqual({ id: 's', lat: 49.015, lng: 12.015, role: 'via', shaping: true });
|
||||
const points = [A, B, C];
|
||||
expect(withShapingPoint(points, 'c', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||
expect(withShapingPoint(points, 'missing', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-002: a stop becomes a shaping point and back; the ends always stay stops', () => {
|
||||
const shaped = withShaping([A, B, C], 'b', true);
|
||||
expect(shaped[1]?.shaping).toBe(true);
|
||||
expect(withShaping(shaped, 'b', false)[1]).not.toHaveProperty('shaping');
|
||||
expect(withShaping([A, B, C], 'a', true)[0]).toBe(A);
|
||||
expect(withShaping([A, B, C], 'c', true)[2]).toBe(C);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-003: every drawn leg goes back to the router; a portage stays', () => {
|
||||
const free = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
const carried = {
|
||||
kind: 'portage' as const,
|
||||
geometry: [
|
||||
[49.01, 12.01],
|
||||
[49.02, 12.02],
|
||||
] as [number, number][],
|
||||
extra_minutes: 5,
|
||||
};
|
||||
const routed = withDrawnLegsRouted([{ ...A, fixedLeg: free }, { ...B, fixedLeg: carried }, C]);
|
||||
expect(routed[0]).not.toHaveProperty('fixedLeg');
|
||||
expect(routed[1]?.fixedLeg).toEqual(carried);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-004: the key changes when a point shapes instead of stops, or a line gains a bend', () => {
|
||||
const key = tourWaypointKey([A, B, C]);
|
||||
expect(tourWaypointKey(withShaping([A, B, C], 'b', true))).not.toBe(key);
|
||||
const two = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
const three = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.005, 12.0],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
expect(tourWaypointKey([{ ...A, fixedLeg: two }, B])).not.toBe(tourWaypointKey([{ ...A, fixedLeg: three }, B]));
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-005: a point the router set is part of the snapshot, and roles follow the order', () => {
|
||||
const auto = { from: { id: 'a', lat: 49, lng: 12 }, to: { id: 'c', lat: 49.02, lng: 12.02 } };
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, auto }, C])).toBe(false);
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
|
||||
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
|
||||
import type { AutoBase } from './paddleRouting';
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs';
|
||||
import { alignFreeLegs } from './tourRouteActions';
|
||||
|
||||
/** A control point of a planned tour, as the editor holds it. */
|
||||
export interface TourPlannerWaypoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: TourWaypointRole;
|
||||
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
||||
lock?: PaddleLock | null;
|
||||
/** P2A-03: a shaping point, there only to steer the route; no stop. */
|
||||
shaping?: boolean;
|
||||
/** PAD-11: set where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||
auto?: AutoBase;
|
||||
/** PAD-03: at the last point, the boat is carried on to the next point once it is set; never saved. */
|
||||
carryNext?: boolean;
|
||||
}
|
||||
|
||||
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
|
||||
export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () => string): TourPlannerWaypoint[] {
|
||||
return saved.map((point) => ({
|
||||
id: newId(),
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's waypoints as they are saved, in their order: what the server
|
||||
* keeps and nothing of the editor's own (ids, automatic marks, a portage
|
||||
* waiting for its next point).
|
||||
*/
|
||||
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
|
||||
return points.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
sequence,
|
||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
|
||||
...point,
|
||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||
}));
|
||||
}
|
||||
|
||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||
if (!leg) return '';
|
||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
|
||||
}
|
||||
|
||||
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
|
||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
return points
|
||||
.map(
|
||||
(point) =>
|
||||
`${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}${point.shaping ? ':shape' : ''}`
|
||||
)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock, a point the router set. */
|
||||
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
const extras = (points: TourPlannerWaypoint[]) =>
|
||||
JSON.stringify(
|
||||
points.map((point) => [
|
||||
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
|
||||
point.lock ?? null,
|
||||
!!point.auto,
|
||||
!!point.carryNext,
|
||||
])
|
||||
);
|
||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
|
||||
}
|
||||
|
||||
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
|
||||
export function withShapingPoint(
|
||||
points: TourPlannerWaypoint[],
|
||||
afterId: string,
|
||||
shape: Pick<TourPlannerWaypoint, 'id' | 'lat' | 'lng'>
|
||||
): TourPlannerWaypoint[] {
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
if (index < 0 || index >= points.length - 1) return points;
|
||||
return [...points.slice(0, index + 1), { ...shape, role: 'via', shaping: true }, ...points.slice(index + 1)];
|
||||
}
|
||||
|
||||
/** P2A-03: a stop made a shaping point, or a shaping point a stop. The ends of a tour are always stops. */
|
||||
export function withShaping(points: TourPlannerWaypoint[], id: string, shaping: boolean): TourPlannerWaypoint[] {
|
||||
return points.map((point, index) => {
|
||||
if (point.id !== id || index === 0 || index === points.length - 1) return point;
|
||||
if (shaping) return { ...point, shaping: true };
|
||||
const { shaping: _dropped, ...stop } = point;
|
||||
return stop;
|
||||
});
|
||||
}
|
||||
|
||||
/** PAD-11: every leg drawn by hand handed back to the router. */
|
||||
export function withDrawnLegsRouted(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
|
||||
return points.map((point) => {
|
||||
if (point.fixedLeg?.kind !== 'free') return point;
|
||||
const { fixedLeg: _drawn, ...routed } = point;
|
||||
return routed;
|
||||
});
|
||||
}
|
||||
@@ -108,10 +108,25 @@ describe('free legs (P2A-04)', () => {
|
||||
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it('draws every leg by hand for a paddle tour', () => {
|
||||
it('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => {
|
||||
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
|
||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
|
||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual([undefined, undefined, undefined]);
|
||||
const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true);
|
||||
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||
});
|
||||
|
||||
it('keeps the shape of a drawn paddle leg while its ends still meet their points, and redraws it once one moves', () => {
|
||||
const shaped = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[A.lat + 0.001, A.lng + 0.002],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
};
|
||||
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, B], true)[0].fixedLeg).toEqual(shaped);
|
||||
const moved = { ...B, lat: B.lat + 0.01 };
|
||||
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, moved, C], true)[0].fixedLeg).toEqual(freeLegBetween(A, moved));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +137,31 @@ describe('portages and locks (PAD-03, PAD-07)', () => {
|
||||
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
|
||||
expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B));
|
||||
expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||
expect(togglePortageLeg(carried, 'c')).toEqual(carried);
|
||||
});
|
||||
|
||||
it('asks for a portage at the last point and draws it to the point set next', () => {
|
||||
const asked = togglePortageLeg(alignFreeLegs([A, B], true), 'b');
|
||||
expect(asked[1]).toMatchObject({ carryNext: true });
|
||||
// Redrawn with no point after it, the ask waits.
|
||||
expect(alignFreeLegs(asked, true)[1]).toMatchObject({ carryNext: true });
|
||||
const placed = alignFreeLegs([...asked, C], true);
|
||||
expect(placed[1].fixedLeg).toEqual(portageLegBetween(B, C));
|
||||
expect(placed[1]).not.toHaveProperty('carryNext');
|
||||
// A second press takes the ask back, and a hike forgets it.
|
||||
expect(togglePortageLeg(asked, 'b')[1]).not.toHaveProperty('carryNext');
|
||||
expect(alignFreeLegs([...asked, C])[1]).not.toHaveProperty('carryNext');
|
||||
});
|
||||
|
||||
it('keeps a portage that follows a way while its ends stay, and draws it straight once one moves', () => {
|
||||
const geometry: [number, number][] = [
|
||||
[A.lat, A.lng],
|
||||
[47.004, 11.006],
|
||||
[B.lat, B.lng],
|
||||
];
|
||||
const points: RoutePoint[] = [{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry } }, B, C];
|
||||
expect(alignFreeLegs(points, true)[0].fixedLeg).toEqual(points[0].fixedLeg);
|
||||
const moved = alignFreeLegs([points[0], { ...B, lat: 47.012 }, C], true);
|
||||
expect(moved[0].fixedLeg).toEqual(portageLegBetween(A, { lat: 47.012, lng: B.lng }));
|
||||
});
|
||||
|
||||
it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => {
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface RoutePoint {
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
lock?: PaddleLock | null;
|
||||
/**
|
||||
* PAD-03: the boat is carried on from this point, the last of the tour so
|
||||
* far; the portage is drawn to the next point once it is set. The planner's
|
||||
* own, never saved.
|
||||
*/
|
||||
carryNext?: boolean;
|
||||
}
|
||||
|
||||
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
|
||||
@@ -114,25 +120,46 @@ function extrasOnFirstLeg<T extends RoutePoint>(points: T[]): T[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** A line still joins its points when its ends lie this close to them, in degrees (about two metres). */
|
||||
const JOINS_DEG = 2e-5;
|
||||
|
||||
function stillJoins(leg: TourFixedLeg, from: { lat: number; lng: number }, to: { lat: number; lng: number }): boolean {
|
||||
const first = leg.geometry[0];
|
||||
const last = leg.geometry[leg.geometry.length - 1];
|
||||
const near = (end: readonly unknown[] | undefined, point: { lat: number; lng: number }) =>
|
||||
!!end && Math.abs(Number(end[0]) - point.lat) < JOINS_DEG && Math.abs(Number(end[1]) - point.lng) < JOINS_DEG;
|
||||
return near(first, from) && near(last, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hand-drawn leg, and every portage, redrawn straight to the waypoint
|
||||
* that now follows it, so moving a point drags them along instead of tearing
|
||||
* them off. A leg on the last point leads nowhere and goes, and so does a lock
|
||||
* nobody paddles to on a tour that is no paddle tour. With `allFree` every
|
||||
* leg that is not a portage becomes hand-drawn, the way a paddle tour is planned.
|
||||
* Every hand-drawn leg redrawn straight to the waypoint that now follows it,
|
||||
* so moving a point drags it along instead of tearing it off; a leg on the
|
||||
* last point leads nowhere and goes. On a hike a portage and a lock are a
|
||||
* paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's
|
||||
* and goes back to the router; a portage and a lock stay, a portage is
|
||||
* dragged along like a drawn leg, and a portage or drawn leg whose line has
|
||||
* a shape of its own (a portage way the router followed) keeps it while its
|
||||
* ends still meet their points. A leg without a line stays routed. A portage
|
||||
* asked for at the last point of a paddle tour becomes one once a point follows.
|
||||
*/
|
||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
|
||||
const aligned = points.map((point, index) => {
|
||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle = false): T[] {
|
||||
const aligned = points.map((current, index) => {
|
||||
const next = points[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
const kept = allFree ? point : dropPaddleOnly(point);
|
||||
const free = allFree || leg?.kind === 'free';
|
||||
if (!free && !(allFree && isPortage(leg))) return kept;
|
||||
const { carryNext, ...own } = current;
|
||||
if (carryNext && paddle) {
|
||||
if (!next) return current;
|
||||
return { ...own, fixedLeg: portageLegBetween(current, next) } as T;
|
||||
}
|
||||
const point = (carryNext ? own : current) as T;
|
||||
const kept = paddle ? dropHikeOnly(point) : dropPaddleOnly(point);
|
||||
const leg = kept.fixedLeg;
|
||||
const follows = leg?.kind === 'free' || (paddle && isPortage(leg));
|
||||
if (!follows || !leg) return kept;
|
||||
if (!next) {
|
||||
if (!kept.fixedLeg) return kept;
|
||||
const { fixedLeg: _dropped, ...rest } = kept;
|
||||
return rest as T;
|
||||
}
|
||||
if (paddle && leg.geometry.length > 2 && stillJoins(leg, point, next)) return kept;
|
||||
return {
|
||||
...kept,
|
||||
fixedLeg: isPortage(leg)
|
||||
@@ -140,7 +167,14 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFre
|
||||
: freeLegBetween(point, next),
|
||||
};
|
||||
});
|
||||
return allFree ? extrasOnFirstLeg(aligned) : aligned;
|
||||
return paddle ? extrasOnFirstLeg(aligned) : aligned;
|
||||
}
|
||||
|
||||
/** A boat climbs no via ferrata: its leg goes back to the router, on the water. */
|
||||
function dropHikeOnly<T extends RoutePoint>(point: T): T {
|
||||
if (point.fixedLeg?.kind !== 'ferrata') return point;
|
||||
const { fixedLeg: _ferrata, ...routed } = point;
|
||||
return routed as T;
|
||||
}
|
||||
|
||||
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
|
||||
@@ -151,13 +185,21 @@ function dropPaddleOnly<T extends RoutePoint>(point: T): T {
|
||||
return plain as T;
|
||||
}
|
||||
|
||||
/** PAD-03: the leg after this point carried as a portage, or paddled again. */
|
||||
/**
|
||||
* PAD-03: the leg after this point carried as a portage, or paddled again. At
|
||||
* the last point there is no leg yet: the portage waits for the next point.
|
||||
*/
|
||||
export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next) return [...points];
|
||||
if (!point) return [...points];
|
||||
const copy = [...points];
|
||||
if (!next) {
|
||||
const { carryNext, ...own } = point;
|
||||
copy[index] = (carryNext ? own : { ...point, carryNext: true }) as T;
|
||||
return copy;
|
||||
}
|
||||
copy[index] = {
|
||||
...point,
|
||||
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
|
||||
@@ -189,14 +231,15 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg after this waypoint drawn by hand, or routed again. A via ferrata
|
||||
* keeps its line; the last point has no leg to change.
|
||||
* The leg after this waypoint drawn by hand, or routed again (on a paddle
|
||||
* tour: on the water). A via ferrata and a portage keep their line; the last
|
||||
* point has no leg to change.
|
||||
*/
|
||||
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points];
|
||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points];
|
||||
const copy = [...points];
|
||||
if (point.fixedLeg?.kind === 'free') {
|
||||
const { fixedLeg: _dropped, ...rest } = point;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tourRouteStyle } from './tourRouteStyle';
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
const PAINT = { portage: 'brown', rapids: 'amber', gap: 'red', casing: 'white' };
|
||||
const line: [number, number][] = [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
];
|
||||
const part = (extra: Partial<TourRoutePart> = {}): TourRoutePart => ({ coordinates: line, free: false, ...extra });
|
||||
|
||||
describe('tourRouteStyle', () => {
|
||||
it('FE-TOUR-ROUTE-STYLE-001: a routed tour is one plain line, and no parts draw nothing', () => {
|
||||
expect(tourRouteStyle([part(), part()], true, PAINT)).toEqual({ walking: null, colors: null });
|
||||
expect(tourRouteStyle(null, true, PAINT)).toEqual({ walking: null, colors: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-STYLE-002: a paddle tour dots and colours what no router planned and what the paddler should see', () => {
|
||||
const style = tourRouteStyle(
|
||||
[
|
||||
part(),
|
||||
part({ land: true }),
|
||||
part({ mark: 'rapids' }),
|
||||
part({ mark: 'gap' }),
|
||||
part({ mark: 'unrouted' }),
|
||||
part({ free: true }),
|
||||
],
|
||||
true,
|
||||
PAINT
|
||||
);
|
||||
expect(style.walking).toEqual([false, true, false, true, true, true]);
|
||||
expect(style.colors).toEqual([
|
||||
undefined,
|
||||
{ line: 'brown', casing: 'white' },
|
||||
{ line: 'amber', casing: 'white' },
|
||||
{ line: 'red', casing: 'white' },
|
||||
{ line: 'red', casing: 'white' },
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-STYLE-003: a hike dots its hand-drawn stretches and keeps its own colour', () => {
|
||||
const style = tourRouteStyle([part(), part({ free: true })], false, PAINT);
|
||||
expect(style).toEqual({ walking: [false, true], colors: null });
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,47 @@
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
/**
|
||||
* PAD-03: a portage on the map, in an earth brown over a rim in the map's own
|
||||
* white, dotted the way the map draws a walk: the boat is carried there, not
|
||||
* paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`).
|
||||
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
|
||||
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
|
||||
* boat passes in amber, a gap in the data or a leg no route was found for in
|
||||
* red, each over a rim in the map's own white.
|
||||
*/
|
||||
export interface PortagePaint {
|
||||
line: string;
|
||||
export interface RoutePaint {
|
||||
portage: string;
|
||||
rapids: string;
|
||||
gap: string;
|
||||
casing: string;
|
||||
}
|
||||
|
||||
export interface TourRouteStyle {
|
||||
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
|
||||
/** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */
|
||||
walking: boolean[] | null;
|
||||
/** Per line: the portage brown, or the route's own colour where undefined. */
|
||||
/** Per line: a paddle paint, or the route's own colour where undefined. */
|
||||
colors: ({ line: string; casing: string } | undefined)[] | null;
|
||||
}
|
||||
|
||||
function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; casing: string } | undefined {
|
||||
if (part.land) return { line: paint.portage, casing: paint.casing };
|
||||
if (part.mark === 'rapids') return { line: paint.rapids, casing: paint.casing };
|
||||
if (part.mark === 'gap' || part.mark === 'unrouted') return { line: paint.gap, casing: paint.casing };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the planner map draws the parts of a tour being edited. A hike draws
|
||||
* what no router planned dotted, so a routed path and a hand-drawn one never
|
||||
* look alike. A paddle tour is drawn by hand throughout, so its water is the
|
||||
* plain line and only its portages stand out, dotted and brown.
|
||||
* How the planner map draws the parts of a tour being edited. What no router
|
||||
* planned is dotted, so a routed path and a hand-drawn one never look alike:
|
||||
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
|
||||
* and stretches drawn by hand. A paddle tour also colours what the paddler
|
||||
* should look at.
|
||||
*/
|
||||
export function tourRouteStyle(
|
||||
parts: readonly TourRoutePart[] | null,
|
||||
isPaddle: boolean,
|
||||
portage: PortagePaint
|
||||
paint: RoutePaint
|
||||
): TourRouteStyle {
|
||||
if (!parts?.length) return { walking: null, colors: null };
|
||||
const walking = parts.map((part) => (isPaddle ? !!part.land : part.free));
|
||||
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined));
|
||||
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
|
||||
const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined));
|
||||
return {
|
||||
walking: walking.some(Boolean) ? walking : null,
|
||||
colors: colors.some(Boolean) ? colors : null,
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface TourRoutePart {
|
||||
free: boolean
|
||||
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
|
||||
land?: boolean
|
||||
/**
|
||||
* PAD-11: on a routed paddle tour, a stretch the paddler should look at:
|
||||
* whitewater or a boat pass, a gap in the data the router bridged, or a leg
|
||||
* it could not plan (drawn straight, and nothing checked it).
|
||||
*/
|
||||
mark?: 'rapids' | 'gap' | 'unrouted'
|
||||
}
|
||||
|
||||
export interface TourRouteResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
recommendedPaddleSpeedKmh,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
type TourListItem,
|
||||
} from '@trek/shared';
|
||||
@@ -13,6 +14,8 @@ export interface PaddleFieldValues {
|
||||
speedKmh: number | null;
|
||||
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
|
||||
portageSpeedKmh?: number | null;
|
||||
/** PAD-11: how lakes are routed; null (or absent, in an older draft) is straight over. */
|
||||
lakeStyle?: PaddleLakeStyle | null;
|
||||
}
|
||||
|
||||
export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
||||
@@ -20,17 +23,22 @@ export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
lakeStyle: null,
|
||||
};
|
||||
|
||||
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
||||
export function paddleFieldsOf(
|
||||
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
|
||||
tour: Pick<
|
||||
TourListItem,
|
||||
'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' | 'paddle_lake_style'
|
||||
>
|
||||
): PaddleFieldValues {
|
||||
return {
|
||||
boatType: tour.boat_type ?? null,
|
||||
waterContext: tour.water_context ?? null,
|
||||
speedKmh: tour.paddle_speed_kmh ?? null,
|
||||
portageSpeedKmh: tour.portage_speed_kmh ?? null,
|
||||
lakeStyle: tour.paddle_lake_style ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +59,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
merged.boatType === values.boatType &&
|
||||
merged.waterContext === values.waterContext &&
|
||||
Object.is(merged.speedKmh, values.speedKmh) &&
|
||||
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null);
|
||||
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null) &&
|
||||
(merged.lakeStyle ?? null) === (values.lakeStyle ?? null);
|
||||
if (!same && onEdit()) setValues(merged);
|
||||
},
|
||||
[onEdit, values]
|
||||
@@ -61,6 +70,7 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
return {
|
||||
...values,
|
||||
portageSpeedKmh: values.portageSpeedKmh ?? null,
|
||||
lakeStyle: values.lakeStyle ?? 'direct',
|
||||
recommendedSpeedKmh: recommended,
|
||||
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
|
||||
effectiveSpeedKmh: values.speedKmh ?? recommended,
|
||||
@@ -69,6 +79,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
|
||||
/** PAD-07: the carrying speed, or back to the starting value with null. */
|
||||
setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]),
|
||||
/** PAD-11: straight over the lakes, or along their shores. */
|
||||
setLakeStyle: useCallback((lakeStyle: PaddleLakeStyle) => edit({ lakeStyle }), [edit]),
|
||||
/** Back to the boat's suggestion, by choice. */
|
||||
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
|
||||
/** Loaded from a tour or a draft, or cleared; not an edit. */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, type RefObject } from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { withDrawnLegsRouted, withShaping, withShapingPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
type Commit = (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
|
||||
|
||||
/**
|
||||
* P2A-03, P2A-05 and PAD-11: the editor's tools for the line between the
|
||||
* stops. A shaping point steers the route without being a stop, a routed leg
|
||||
* can be planned afresh, and a paddle tour's legs drawn by hand go back to the
|
||||
* router in one step. Each change is one step of the history, through the
|
||||
* planner's own commit; nothing here plans a route itself.
|
||||
*/
|
||||
export function useRouteShaping({
|
||||
commit,
|
||||
waypointsRef,
|
||||
mayEdit,
|
||||
isPaddle,
|
||||
retry,
|
||||
forgetLeg,
|
||||
}: {
|
||||
commit: Commit;
|
||||
waypointsRef: RefObject<TourPlannerWaypoint[]>;
|
||||
/** Whether the route may change now: the permission held and no save under way. */
|
||||
mayEdit: () => boolean;
|
||||
isPaddle: () => boolean;
|
||||
/** Plans the route again as it stands. */
|
||||
retry: () => void;
|
||||
/** Drops the answer kept for a paddle leg, so the router is asked again. */
|
||||
forgetLeg: (from: TourPlannerWaypoint, to: TourPlannerWaypoint) => void;
|
||||
}) {
|
||||
/** A shaping point into the leg after `afterId`; its id, or null when there is no such leg. */
|
||||
const insertShapingPoint = useCallback(
|
||||
(afterId: string, lat: number, lng: number) => {
|
||||
if (!mayEdit() || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const points = waypointsRef.current;
|
||||
const shape = { id: randomId(), lat, lng };
|
||||
if (withShapingPoint(points, afterId, shape) === points) return null;
|
||||
commit((current) => withShapingPoint(current, afterId, shape));
|
||||
return shape.id;
|
||||
},
|
||||
[commit, mayEdit, waypointsRef]
|
||||
);
|
||||
|
||||
const setWaypointShaping = useCallback(
|
||||
(id: string, shaping: boolean) => {
|
||||
if (mayEdit()) commit((current) => withShaping(current, id, shaping));
|
||||
},
|
||||
[commit, mayEdit]
|
||||
);
|
||||
|
||||
const routeDrawnLegsOnWater = useCallback(() => {
|
||||
if (mayEdit() && isPaddle()) commit(withDrawnLegsRouted);
|
||||
}, [commit, isPaddle, mayEdit]);
|
||||
|
||||
/** The routed leg after this waypoint planned afresh, past any answer kept from before. */
|
||||
const recomputeLeg = useCallback(
|
||||
(id: string) => {
|
||||
const points = waypointsRef.current;
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next || point.fixedLeg) return;
|
||||
if (isPaddle()) forgetLeg(point, next);
|
||||
retry();
|
||||
},
|
||||
[forgetLeg, isPaddle, retry, waypointsRef]
|
||||
);
|
||||
|
||||
return { insertShapingPoint, setWaypointShaping, routeDrawnLegsOnWater, recomputeLeg };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { legStartAt } from './routeLegAt';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/**
|
||||
* P2A-03: a click on the route being edited sets a shaping point where it
|
||||
* landed, in the leg that runs there, and selects it, ready to be dragged
|
||||
* where the route should go. Undefined while the points cannot be moved, or
|
||||
* while a click on the line picks a spot for something else.
|
||||
*/
|
||||
export function useShapingClick(
|
||||
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
|
||||
{ editable, picking }: { editable: boolean; picking: boolean }
|
||||
): ((lat: number, lng: number) => void) | undefined {
|
||||
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
|
||||
const shapeAt = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const after = legStartAt(waypoints, route, lat, lng);
|
||||
if (!after) return;
|
||||
const id = insertShapingPoint(after, lat, lng);
|
||||
if (id) setSelectedWaypointId(id);
|
||||
},
|
||||
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
|
||||
);
|
||||
return editable && !picking ? shapeAt : undefined;
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
}));
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
|
||||
vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
paddlePlan: vi.fn(),
|
||||
paddleForget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
|
||||
vi.mock('../useTourPermissions', () => ({
|
||||
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
||||
canEdit,
|
||||
@@ -64,6 +68,20 @@ function plannerWith(points: Array<[number, number]>) {
|
||||
return hook;
|
||||
}
|
||||
|
||||
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
|
||||
/** The water router's answer for every leg asked: a straight stretch on the water, 1.2 km long. */
|
||||
const onWater = (legs: LegAsked[]) =>
|
||||
legs.map(({ from, to }) => ({
|
||||
status: 'ok' as const,
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
segments: [{ kind: 'water' as const, from: 0, to: 1 }],
|
||||
distance_m: 1200,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
}));
|
||||
|
||||
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
|
||||
|
||||
describe('useTourPlanner route tools', () => {
|
||||
@@ -71,6 +89,8 @@ describe('useTourPlanner route tools', () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
|
||||
paddleForget.mockReset();
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
@@ -221,7 +241,7 @@ describe('useTourPlanner route tools', () => {
|
||||
});
|
||||
|
||||
describe('paddle tours (PAD-01)', () => {
|
||||
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
|
||||
it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -229,9 +249,22 @@ describe('useTourPlanner route tools', () => {
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
expect(result.current.isPaddle).toBe(true);
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
routeWalkingTour.mockClear();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(paddlePlan).toHaveBeenLastCalledWith(
|
||||
31,
|
||||
expect.any(Array),
|
||||
{ boat: 'kayak', lakeStyle: 'direct' },
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.paddleRoute?.legLengths).toEqual([1200, 1200]);
|
||||
act(() => result.current.addWaypoint(48.03, 11.01));
|
||||
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
|
||||
expect(result.current.waypoints[2].fixedLeg).toBeUndefined();
|
||||
act(() => result.current.undo());
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.tourType).toBe('hike');
|
||||
@@ -240,7 +273,6 @@ describe('useTourPlanner route tools', () => {
|
||||
|
||||
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
|
||||
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -264,11 +296,14 @@ describe('useTourPlanner route tools', () => {
|
||||
boat_type: 'canoe',
|
||||
water_context: 'lake',
|
||||
paddle_speed_kmh: null,
|
||||
paddle_lake_style: 'direct',
|
||||
max_hiking_difficulty: null,
|
||||
});
|
||||
// A paddle tour carries no heights to look up.
|
||||
expect(enrichTourElevations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||
it('times the water by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -278,13 +313,13 @@ describe('useTourPlanner route tools', () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
|
||||
// 1.2 km on the water the router found, at 4 km/h.
|
||||
expect(result.current.walkingDurationMinutes).toBe(18);
|
||||
|
||||
act(() => result.current.paddle.setSpeedKmh(6));
|
||||
act(() => result.current.paddle.setBoatType('other'));
|
||||
expect(result.current.paddle.speedKmh).toBe(6);
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
|
||||
expect(result.current.walkingDurationMinutes).toBe(12);
|
||||
|
||||
act(() => result.current.paddle.adoptRecommendedSpeed());
|
||||
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
||||
@@ -343,4 +378,135 @@ describe('useTourPlanner route tools', () => {
|
||||
expect(old.result.current.name).toBe('Old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shaping and water routing (P2A-03, P2A-05, PAD-11)', () => {
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
|
||||
it('sets a shaping point into a leg, makes it a stop and back, and never past the end', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.02, 11.02],
|
||||
]);
|
||||
const [start, end] = result.current.waypoints;
|
||||
let id: string | null = null;
|
||||
act(() => {
|
||||
id = result.current.insertShapingPoint(start!.id, 48.01, 11.005);
|
||||
});
|
||||
expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]);
|
||||
expect(result.current.waypoints[1]?.id).toBe(id);
|
||||
act(() => {
|
||||
expect(result.current.insertShapingPoint(end!.id, 48.03, 11.03)).toBeNull();
|
||||
expect(result.current.insertShapingPoint(start!.id, Number.NaN, 11)).toBeNull();
|
||||
});
|
||||
act(() => result.current.setWaypointShaping(id!, false));
|
||||
expect(result.current.waypoints[1]).not.toHaveProperty('shaping');
|
||||
act(() => result.current.setWaypointShaping(start!.id, true));
|
||||
expect(result.current.waypoints[0]).not.toHaveProperty('shaping');
|
||||
// One undo takes back one change.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[1]?.shaping).toBe(true);
|
||||
await settle();
|
||||
expect(routeWalkingTour).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
|
||||
expect.any(AbortSignal),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it('plans a leg afresh: a paddle leg is forgotten and asked again, a fixed leg is left alone', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await settle();
|
||||
expect(paddlePlan).toHaveBeenCalledTimes(1);
|
||||
const [start, end] = result.current.waypoints;
|
||||
act(() => result.current.recomputeLeg(start!.id));
|
||||
expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' });
|
||||
await settle();
|
||||
expect(paddlePlan).toHaveBeenCalledTimes(2);
|
||||
act(() => result.current.recomputeLeg(end!.id));
|
||||
expect(paddleForget).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends every leg drawn by hand back to the water router in one step', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
for (const point of result.current.waypoints.slice(0, 2))
|
||||
act(() => result.current.toggleWaypointFreeLeg(point.id));
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
act(() => result.current.routeDrawnLegsOnWater());
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[0]?.fixedLeg?.kind).toBe('free');
|
||||
});
|
||||
|
||||
it('keeps a tour with an unplanned leg from being saved, and says it per leg', async () => {
|
||||
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||
legs.map(() => ({ status: 'failed', code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } }))
|
||||
);
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setName('Off the water'));
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
act(() => result.current.paddle.setBoatType('kayak'));
|
||||
act(() => result.current.paddle.setWaterContext('river'));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('routing-failed');
|
||||
expect(result.current.error).toBe('paddle-route');
|
||||
expect(result.current.paddleRoute?.failures).toEqual([
|
||||
{ index: 0, code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } },
|
||||
]);
|
||||
expect(result.current.routeCurrent).toBe(true);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
|
||||
it('sets the portage and the lock the router found as points of their own, and they go when their leg changes', async () => {
|
||||
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||
legs.map(({ from, to }) => {
|
||||
const mid = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const at = (t: number): [number, number] => [mid(from.lat, to.lat, t), mid(from.lng, to.lng, t)];
|
||||
const geometry = [at(0), at(0.3), at(0.4), at(1)];
|
||||
const segments = [
|
||||
{ kind: 'water', from: 0, to: 1 },
|
||||
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
|
||||
{ kind: 'water', from: 2, to: 3 },
|
||||
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
|
||||
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
|
||||
})
|
||||
);
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.1, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await settle();
|
||||
await settle();
|
||||
expect(result.current.waypoints).toHaveLength(4);
|
||||
expect(result.current.waypoints[1]).toMatchObject({ fixedLeg: { kind: 'portage' } });
|
||||
expect(result.current.waypoints.filter((point) => point.auto)).toHaveLength(2);
|
||||
// Found by the router, not done by the paddler: no step of the history of its own.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.tourType).toBe('hike');
|
||||
act(() => result.current.redo());
|
||||
await settle();
|
||||
await settle();
|
||||
const end = result.current.waypoints[result.current.waypoints.length - 1];
|
||||
act(() => result.current.setWaypointPosition(end.id, 48.2, 11));
|
||||
await settle();
|
||||
expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1596,7 +1596,7 @@ describe('useTourPlanner', () => {
|
||||
|
||||
expect(result.current.mode).toEqual({ type: 'new-draft' })
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
|
||||
version: 8,
|
||||
version: 9,
|
||||
tourType: 'hike',
|
||||
name: '',
|
||||
waypoints: [],
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs'
|
||||
import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||
import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints'
|
||||
import { useRouteShaping } from './useRouteShaping'
|
||||
import { useTourMapView } from './useTourMapView'
|
||||
import { validDraft, type DraftPayload } from './tourPlannerDraft'
|
||||
import { paddleEstimateOf } from './paddlePlanOf'
|
||||
import { adoptChangedPoints, planPaddleStep, type PaddleTourRoute } from './paddleRouting'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
import { hikingTimeMinutes } from '../hikingTime'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
|
||||
export interface TourPlannerWaypoint {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
role: TourWaypointRole
|
||||
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
||||
fixedLeg?: TourFixedLeg | null
|
||||
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
||||
lock?: PaddleLock | null
|
||||
}
|
||||
|
||||
/** What a tour planned from an import starts with. */
|
||||
export interface DerivedTourDraft {
|
||||
name: string
|
||||
@@ -69,29 +61,6 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
|
||||
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
|
||||
}
|
||||
|
||||
/** Roles from the order; free legs follow their points, and a paddle tour draws every leg by hand. */
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], allFree = false): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(alignFreeLegs(points, allFree)).map((point, index) => ({
|
||||
...point,
|
||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||
}))
|
||||
}
|
||||
|
||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}`).join('|')
|
||||
}
|
||||
|
||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||
if (!leg) return ''
|
||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}`
|
||||
}
|
||||
|
||||
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock. */
|
||||
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
const extras = (points: TourPlannerWaypoint[]) => JSON.stringify(points.map(point => [point.fixedLeg?.kind === 'portage' ? point.fixedLeg.extra_minutes ?? null : null, point.lock ?? null]))
|
||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b)
|
||||
}
|
||||
|
||||
/** Where a new tour was planned from; an edit leaves it as it was saved. */
|
||||
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
|
||||
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
||||
@@ -157,6 +126,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
|
||||
const [paddleRoute, setPaddleRoute] = useState<PaddleTourRoute | null>(null)
|
||||
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
|
||||
const [routeForKey, setRouteForKey] = useState<string | null>(null)
|
||||
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
|
||||
@@ -241,7 +211,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
||||
if (!editPermissionRef.current) return
|
||||
const current = waypointsRef.current
|
||||
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue, tourTypeRef.current === 'kayak')
|
||||
const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
|
||||
const paddle = tourTypeRef.current === 'kayak'
|
||||
// A point the router set that the paddler moves or edits is theirs from then on.
|
||||
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
|
||||
if (sameWaypointSnapshot(current, next)) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
@@ -256,6 +229,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
|
||||
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
const changed = change(waypointsRef.current)
|
||||
if (changed === waypointsRef.current) return
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current === 'kayak')
|
||||
setWaypoints(waypointsRef.current)
|
||||
setHasUnsavedChanges(true)
|
||||
setStatus('dirty')
|
||||
}, [interruptRouting])
|
||||
|
||||
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
||||
if (!editPermissionRef.current) return
|
||||
if (value === difficultyRef.current) return
|
||||
@@ -333,9 +319,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
commitWaypoints(change)
|
||||
}, [commitWaypoints])
|
||||
|
||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again. */
|
||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again (a paddle tour's on the water). */
|
||||
const toggleWaypointFreeLeg = useCallback((id: string) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
commitWaypoints(current => toggleFreeLeg(current, id))
|
||||
}, [commitWaypoints])
|
||||
|
||||
@@ -449,6 +435,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
||||
const loadPaddle = paddle.load
|
||||
const paddleBoat = paddle.boatType
|
||||
const paddleLakeStyle = paddle.lakeStyle
|
||||
|
||||
/**
|
||||
* PAD-01: hike or paddle tour, as one step of the history with the route,
|
||||
@@ -487,8 +475,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
|
||||
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
|
||||
const { effectiveSpeedKmh, portageSpeedKmh } = paddle
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, waypoints])
|
||||
// The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
|
||||
const routedLegLengths = paddleRoute && routeForKey === tourWaypointKey(waypoints) ? paddleRoute.legLengths : undefined
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
|
||||
const walkingDurationMinutes = paddleEstimate
|
||||
? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes
|
||||
: routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
@@ -501,11 +491,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
|
||||
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
|
||||
const { boatType, waterContext, speedKmh } = paddle
|
||||
const { boatType, waterContext, speedKmh, lakeStyle } = paddle
|
||||
const typeFields = useMemo(() => tourType === 'kayak'
|
||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh }
|
||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null },
|
||||
[boatType, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh, paddle_lake_style: lakeStyle }
|
||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, paddle_lake_style: null },
|
||||
[boatType, lakeStyle, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
||||
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
|
||||
const plannedTotalInvalid = plannedDurationMinutes === null
|
||||
@@ -537,6 +527,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('empty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setPaddleRoute(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -676,14 +667,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
interruptRouting()
|
||||
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
|
||||
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({
|
||||
id: randomId(),
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
})), openedType === 'kayak')
|
||||
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), openedType === 'kayak')
|
||||
localStorage.removeItem(storageKey)
|
||||
contextGenerationRef.current += 1
|
||||
draftRevisionRef.current += 1
|
||||
@@ -704,6 +688,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('dirty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setPaddleRoute(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -736,6 +721,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setRetryToken(value => value + 1)
|
||||
}, [interruptRouting])
|
||||
|
||||
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
|
||||
const shaping = useRouteShaping({
|
||||
commit: commitWaypoints, waypointsRef, retry,
|
||||
mayEdit: useCallback(() => editPermissionRef.current && !saveInFlightRef.current, []),
|
||||
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
|
||||
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
|
||||
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
|
||||
})
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
|
||||
const readOnlyGpxAnalysis = useMemo(
|
||||
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
|
||||
@@ -764,10 +758,40 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const controller = new AbortController()
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = controller
|
||||
const timer = window.setTimeout(async () => {
|
||||
const stale = () => controller.signal.aborted || generation !== generationRef.current
|
||||
// Points the router set, changed by its own planning and not by the paddler: no step of the history.
|
||||
const patch = (next: TourPlannerWaypoint[]) => {
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
}
|
||||
|
||||
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
|
||||
const routePaddle = async () => {
|
||||
setStatus('routing')
|
||||
const step = await planPaddleStep(waypoints, tripId, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }, controller.signal, randomId)
|
||||
if (stale()) return
|
||||
if ('points' in step) return patch(normalizePlannerWaypoints(step.points, true))
|
||||
const planned = step.route
|
||||
setRoute(planned.coordinates)
|
||||
setRouteParts(planned.parts)
|
||||
setPaddleRoute(planned)
|
||||
setDistanceMeters(planned.distanceMeters)
|
||||
setDurationSeconds(null)
|
||||
setRouteForKey(waypointKey)
|
||||
setEnrichedGeometry(planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]))
|
||||
if (planned.failures.length > 0) {
|
||||
setStatus('routing-failed')
|
||||
setError('paddle-route')
|
||||
return
|
||||
}
|
||||
setStatus('ready')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const routeHike = async () => {
|
||||
setStatus('routing')
|
||||
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
||||
if (stale()) return
|
||||
if (!routed) {
|
||||
setStatus('routing-failed')
|
||||
setError('routing')
|
||||
@@ -776,6 +800,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
setRoute(routed.coordinates)
|
||||
setRouteParts(routed.parts)
|
||||
setPaddleRoute(null)
|
||||
setDistanceMeters(routed.distanceMeters)
|
||||
setDurationSeconds(routed.durationSeconds)
|
||||
setRouteForKey(waypointKey)
|
||||
@@ -783,7 +808,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('enriching-elevation')
|
||||
|
||||
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
||||
if (stale()) return
|
||||
if (!enriched) {
|
||||
setStatus('elevation-failed')
|
||||
setError('elevation')
|
||||
@@ -797,24 +822,33 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}
|
||||
setStatus('ready')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const run = isPaddle ? routePaddle() : routeHike()
|
||||
run.catch(() => {
|
||||
if (stale()) return
|
||||
setStatus('routing-failed')
|
||||
setError('routing')
|
||||
})
|
||||
}, ROUTE_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints])
|
||||
}, [interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 8, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh },
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(draft))
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh])
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -844,14 +878,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
website: parsedWebsiteOrNull(website),
|
||||
...typeFields,
|
||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||
waypoints: waypoints.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
sequence,
|
||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
})),
|
||||
waypoints: savedWaypointsOf(waypoints),
|
||||
duration_seconds: savedDurationSeconds,
|
||||
planned_duration_minutes: plannedDurationMinutes,
|
||||
break_additional_minutes: breakAdditionalMinutes,
|
||||
@@ -909,7 +936,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
||||
waypoints, selectedWaypointId, setSelectedWaypointId,
|
||||
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, paddleEstimate,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
|
||||
...shaping, paddleRoute, routeCurrent: routeForKey === waypointKey,
|
||||
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
|
||||
status, error, retry, isSaving,
|
||||
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
||||
|
||||
@@ -360,6 +360,20 @@ describe('TourTerrainMap', () => {
|
||||
expect(screen.queryByText('18 to 27 %')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-TERRAIN-012b: a paddle tour keeps its tour colour, without the grade switch or the grade mix', async () => {
|
||||
await renderTerrain({ samples: TOUR, water: true });
|
||||
loadStyle();
|
||||
await act(async () => openTiles());
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Grade' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Grade mix' })).toBeNull();
|
||||
const setData = gl.sources.get('tour-terrain-track')?.setData as ReturnType<typeof vi.fn> | undefined;
|
||||
const drawCalls = setData?.mock.calls ?? [];
|
||||
const drawn = drawCalls[drawCalls.length - 1]?.[0] as { features: { properties: { color: string } }[] } | undefined;
|
||||
expect(drawn?.features.length).toBeGreaterThan(0);
|
||||
expect(drawn?.features.every((feature) => feature.properties.color === '#0a84ff')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => {
|
||||
const { rerender } = await renderTerrain({ samples: TOUR });
|
||||
loadStyle();
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TourTerrainMapProps {
|
||||
rightWidth: number;
|
||||
waypoints: TerrainWaypoints;
|
||||
framePoints: [number, number][];
|
||||
/** A paddle tour: no grade colours, no grade legend and no switch for them, the water is flat. */
|
||||
water?: boolean;
|
||||
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
|
||||
@@ -78,7 +80,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
||||
const stats = useMemo(() => terrainStats(samples), [samples]);
|
||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null;
|
||||
const legend =
|
||||
terrain.canReplay && !props.water ? (
|
||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
||||
) : null;
|
||||
const legendAbove = fit === 'stacked';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,7 +125,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<span />
|
||||
{terrain.canReplay ? (
|
||||
<TerrainDock terrain={terrain} />
|
||||
<TerrainDock terrain={terrain} water={props.water} />
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||
@@ -173,8 +178,14 @@ function Divider() {
|
||||
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
||||
}
|
||||
|
||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour. */
|
||||
function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
|
||||
function TerrainDock({
|
||||
terrain,
|
||||
water,
|
||||
}: {
|
||||
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
||||
water?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
|
||||
@@ -231,6 +242,16 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
||||
<span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span>
|
||||
</label>
|
||||
</Tooltip>
|
||||
{!water && <GradeSwitch terrain={terrain} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Colour the route by grade or in the tour colour. */
|
||||
function GradeSwitch({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,7 +263,7 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
||||
<Palette size={15} strokeWidth={2} />
|
||||
{t('tours.terrain.colorByGrade')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,12 +147,12 @@ describe('camera and markers', () => {
|
||||
});
|
||||
|
||||
it('numbers a control point and shows whether it can be dragged', () => {
|
||||
const pin = waypointPinElement(2, true, true);
|
||||
const pin = waypointPinElement({}, 2, true, true);
|
||||
expect(pin.textContent).toBe('3');
|
||||
expect(pin.getAttribute('aria-label')).toBe('Waypoint 3');
|
||||
expect(pin.style.width).toBe('30px');
|
||||
expect(pin.style.cursor).toBe('grab');
|
||||
const fixed = waypointPinElement(0, false, false);
|
||||
const fixed = waypointPinElement({}, 0, false, false);
|
||||
expect(fixed.style.width).toBe('26px');
|
||||
expect(fixed.style.cursor).toBe('pointer');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/map
|
||||
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry';
|
||||
import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape';
|
||||
import type { TileTemplate } from './terrainPrefetch';
|
||||
import { gradeBand, gradeSegments } from './tourTerrainModel';
|
||||
|
||||
@@ -198,11 +199,16 @@ export function riderElement(trackColor: string): HTMLElement {
|
||||
}
|
||||
|
||||
/** A numbered control point, the same pin the flat planner map draws. */
|
||||
export function waypointPinElement(index: number, selected: boolean, editable: boolean): HTMLButtonElement {
|
||||
const size = selected ? 30 : 26;
|
||||
export function waypointPinElement(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
editable: boolean
|
||||
): HTMLButtonElement {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
|
||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`;
|
||||
return el;
|
||||
|
||||
@@ -6,7 +6,15 @@ import type { TerrainGl } from './useTerrainMap';
|
||||
|
||||
export interface TerrainWaypoints {
|
||||
/** `name` and `detail` (its role) show on hover. */
|
||||
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
points: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
/** Absent while the tour cannot be edited; the pins then stay put. */
|
||||
@@ -38,7 +46,7 @@ export function useTerrainPins(
|
||||
gl.map.on('movestart', hideOnMove);
|
||||
const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)];
|
||||
pinsRef.current = points.map((point, index) => {
|
||||
const el = waypointPinElement(index, point.id === selectedId, editable);
|
||||
const el = waypointPinElement(point, index, point.id === selectedId, editable);
|
||||
const name = point.name ?? String(index + 1);
|
||||
el.setAttribute('aria-label', name);
|
||||
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
|
||||
|
||||
@@ -30,13 +30,17 @@ interface Options {
|
||||
tourLayers?: TourLayerOffer;
|
||||
/** The visible area, for the via ferrata layer to ask for. */
|
||||
onViewportChange?: (bbox: FerrataBbox) => void;
|
||||
/** A paddle tour: the route is never coloured by grade. */
|
||||
water?: boolean;
|
||||
}
|
||||
|
||||
/** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */
|
||||
export function useTourTerrain(options: Options) {
|
||||
const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options;
|
||||
const [exaggeration, setExaggeration] = useState(1.5);
|
||||
const [colorMode, setColorMode] = useState<TrackColorMode>('grade');
|
||||
const [chosenColorMode, setColorMode] = useState<TrackColorMode>('grade');
|
||||
// Water is flat: a paddle tour keeps its tour colour, whatever was picked for a hike.
|
||||
const colorMode: TrackColorMode = options.water ? 'tour' : chosenColorMode;
|
||||
const data = useMemo(
|
||||
() => trackData(samples, routes, colorMode, trackColor),
|
||||
[samples, routes, colorMode, trackColor]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
replica_failure: 'settings.notifyReplicaFailure',
|
||||
paddle_region_requested: 'settings.notifyPaddleRegionRequested',
|
||||
}
|
||||
|
||||
export const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
|
||||
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-018
|
||||
import type { Ferrata, PaddleFeature } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
@@ -486,6 +486,54 @@ describe('TourPlannerMapStage', () => {
|
||||
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-017: a click on the route being edited sets a shaping point in the leg there and selects it (P2A-03)', () => {
|
||||
const tourPlanner = planner('new-draft', {
|
||||
waypoints: [
|
||||
{ id: 'w1', lat: 46, lng: 7 },
|
||||
{ id: 'w2', lat: 46.1, lng: 7.1, shaping: true },
|
||||
{ id: 'w3', lat: 46.2, lng: 7.2 },
|
||||
],
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
[46.2, 7.2],
|
||||
],
|
||||
insertShapingPoint: vi.fn(() => 'shape-1'),
|
||||
});
|
||||
renderStage(tourPlanner);
|
||||
const props = stage.flatMap.props;
|
||||
expect(props?.plannerWaypoints?.map((point) => [point.label ?? null, !!point.shaping])).toEqual([
|
||||
['1', false],
|
||||
[null, true],
|
||||
['2', false],
|
||||
]);
|
||||
props?.onRouteClick?.(46.15, 7.15);
|
||||
expect(tourPlanner.insertShapingPoint).toHaveBeenCalledWith('w2', 46.15, 7.15);
|
||||
expect(tourPlanner.setSelectedWaypointId).toHaveBeenCalledWith('shape-1');
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-018: the route takes no shaping clicks outside editing or without edit rights', () => {
|
||||
renderStage(
|
||||
planner('view-saved', {
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(stage.flatMap.props?.onRouteClick).toBeUndefined();
|
||||
renderStage(
|
||||
planner('new-draft', {
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
],
|
||||
}),
|
||||
false
|
||||
);
|
||||
expect(stage.flatMap.props?.onRouteClick).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => {
|
||||
const flat = () => stage.flatMap.props as MapViewProps;
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
|
||||
import { useShapingClick } from '../../components/Tours/planner/useShapingClick';
|
||||
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
|
||||
import { isRtlLanguage, useTranslation } from '../../i18n';
|
||||
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
@@ -137,6 +139,14 @@ export function TourPlannerMapStage({
|
||||
const closingPress = useOutsidePressGuard();
|
||||
const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null);
|
||||
const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close);
|
||||
// PAD-05B: a put-in, slipway or egress taken over from its card as the tour's next point.
|
||||
const placeFromCard =
|
||||
canPlaceEdit && tourPlanner.isPaddle && !tourPlanner.isSaving && tourPlanner.mode.type !== 'view-gpx'
|
||||
? (lat: number, lng: number) => {
|
||||
tourPlanner.addWaypoint(lat, lng);
|
||||
selectPaddleObstacle(null);
|
||||
}
|
||||
: undefined;
|
||||
const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]);
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
// The "set points" tool, per mode; only a real click places, never the end of a drag.
|
||||
@@ -150,6 +160,8 @@ export function TourPlannerMapStage({
|
||||
});
|
||||
// PAD-03 on a recorded paddle track: with a marking tool on, a click on the line marks the spot on it.
|
||||
const { pickOnLine, onAddOrMark } = useTrackLinePick(tourPlanner, canPlaceEdit, closingPress.guard, waypoints.onAdd);
|
||||
// P2A-03: a click on the route being edited sets a shaping point there.
|
||||
const shapeAt = useShapingClick(tourPlanner, { editable: !!waypoints.onMove, picking: pickOnLine });
|
||||
// What each panel covers of the map, a collapsed one its rail.
|
||||
const leftInset = leftHidden ? 56 : leftPanelPx;
|
||||
const rightInset = rightHidden ? 56 : rightPanelPx;
|
||||
@@ -180,6 +192,7 @@ export function TourPlannerMapStage({
|
||||
rightWidth={rightInset}
|
||||
waypoints={waypoints}
|
||||
framePoints={tourMap.focusPoints}
|
||||
water={tourPlanner.isPaddle}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
onControlsHeightChange={setTerrainControlsPx}
|
||||
@@ -209,6 +222,7 @@ export function TourPlannerMapStage({
|
||||
selectedPlannerWaypointId={waypoints.selectedId}
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
onPlannerWaypointMove={waypoints.onMove}
|
||||
onRouteClick={shapeAt}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={baseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
@@ -235,11 +249,18 @@ export function TourPlannerMapStage({
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
|
||||
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
|
||||
extra={(detail) =>
|
||||
paddleChecklist ? (
|
||||
<ChecklistObstaclePanel checklist={paddleChecklist} featureRef={detail.feature.ref} />
|
||||
) : null
|
||||
}
|
||||
extra={(detail) => (
|
||||
<>
|
||||
{paddleChecklist && <ChecklistObstaclePanel checklist={paddleChecklist} featureRef={detail.feature.ref} />}
|
||||
{placeFromCard && (
|
||||
<PaddleObstaclePlaceButton
|
||||
kind={detail.feature.kind}
|
||||
geometry={detail.feature.geometry}
|
||||
onPlace={placeFromCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{/* "Point set", with the way back, above the bottom row and between the panels. */}
|
||||
{placedHint.shown && (
|
||||
|
||||
@@ -76,13 +76,24 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, gu
|
||||
const shown = isClosedLoop(waypoints) ? waypoints.slice(0, -1) : waypoints;
|
||||
// A paddle tour names a pin for the paddler (put-in, take-out, re-entry) and says where a lock is.
|
||||
const roles = isPaddle ? paddleRolesById(waypoints) : null;
|
||||
return shown.map((point, index) => {
|
||||
// Stops are numbered on their own: a shaping point (P2A-03) only steers and takes no number.
|
||||
let stop = 0;
|
||||
return shown.map((point) => {
|
||||
if (point.shaping) {
|
||||
return {
|
||||
...point,
|
||||
name: placeNames[point.id] ?? t('tours.planner.shapingPoint'),
|
||||
detail: t('tours.planner.shapingPoint'),
|
||||
};
|
||||
}
|
||||
stop += 1;
|
||||
const role = roles?.get(point.id);
|
||||
const detail = role ? t(`tours.paddle.role.${role}`) : roles ? '' : t(ROLE_KEY[point.role]);
|
||||
const lock = point.lock ? t('tours.paddle.lock') : '';
|
||||
return {
|
||||
...point,
|
||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
|
||||
label: String(stop),
|
||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: stop }),
|
||||
detail: [detail, lock].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,12 +67,12 @@ export function useTripPlannerPage() {
|
||||
tourConversion.line, trackParts,
|
||||
])
|
||||
const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
// A hand-drawn stretch of a hike is dotted, a paddle tour's portages dotted and brown.
|
||||
// What no router planned is dotted; a paddle tour's portages brown, whitewater amber, gaps red.
|
||||
const paddlePaint = usePaddlePaint()
|
||||
const partStyle = tourRouteStyle(
|
||||
!wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null,
|
||||
tourPlanner.isPaddle || !!trackParts,
|
||||
{ line: paddlePaint.portage, casing: paddlePaint.disc },
|
||||
{ portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc },
|
||||
)
|
||||
const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// FE-REPO-PADDLE-REGION-LOAD-001 to 003: the water data of places asked for online only (PAD-11C).
|
||||
import { paddleRegionLoadRequestSchema, paddleRegionLoadResponseSchema } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { contractHandler } from '../../tests/helpers/msw/contract';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { setForcedOffline } from '../sync/networkMode';
|
||||
import { paddleRegionLoadRepo } from './paddleRegionLoadRepo';
|
||||
|
||||
const LOAD = '/api/paddle/regions/load';
|
||||
const PLACE = { lat: 53.2, lng: 12.9 };
|
||||
|
||||
describe('paddleRegionLoadRepo', () => {
|
||||
afterEach(() => setForcedOffline(false));
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-001: asks the server for the places and answers its states in order', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
contractHandler(
|
||||
'post',
|
||||
LOAD,
|
||||
{ request: paddleRegionLoadRequestSchema, response: paddleRegionLoadResponseSchema },
|
||||
async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return { points: [{ state: 'ready', region: { id: 'mv', name: 'Mecklenburg' } }] };
|
||||
}
|
||||
)
|
||||
);
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], true)).resolves.toEqual([
|
||||
{ state: 'ready', region: { id: 'mv', name: 'Mecklenburg' } },
|
||||
]);
|
||||
expect(bodies).toEqual([{ points: [PLACE], retry: true }]);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-002: asks nothing offline or about no place', async () => {
|
||||
setForcedOffline(true);
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], false)).resolves.toBeNull();
|
||||
setForcedOffline(false);
|
||||
await expect(paddleRegionLoadRepo.load([], false)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-003: refuses an answer that is not the contract', async () => {
|
||||
server.use(http.post(LOAD, () => HttpResponse.json({ points: [{ state: 'maybe' }] })));
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], false)).rejects.toMatchObject({ name: 'ZodError' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaddleRegionLoadState, PaddleRoutePoint } from '@trek/shared';
|
||||
import { paddleRegionLoadApi } from '../api/paddleRegionLoad';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/**
|
||||
* PAD-11C: the water data around places no paddle region covers, online
|
||||
* only on purpose: the server loads it, and a load asked for offline would
|
||||
* only start long after the paddler stopped waiting for it. Offline it
|
||||
* answers null, and the planner keeps saying why the leg is unplanned.
|
||||
*/
|
||||
export const paddleRegionLoadRepo = {
|
||||
async load(
|
||||
points: readonly PaddleRoutePoint[],
|
||||
retry: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleRegionLoadState[] | null> {
|
||||
if (points.length === 0 || isEffectivelyOffline()) return null;
|
||||
return await paddleRegionLoadApi.load({ points: [...points], retry }, signal);
|
||||
},
|
||||
};
|
||||
@@ -32,15 +32,18 @@ const REGION: PaddleRegion = {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-09-30T20:21:02Z',
|
||||
imported_at: '2026-10-01T03:00:00.000Z',
|
||||
feature_count: 412,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
};
|
||||
const QUEUED: PaddleRegion = {
|
||||
...REGION,
|
||||
status: 'queued',
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
@@ -51,7 +54,7 @@ const ENTRY: PaddleRegionCatalogEntry = { id: 'oberpfalz', name: 'Oberpfalz', pa
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
vi.mocked(paddleRegionsApi.list).mockResolvedValue([REGION]);
|
||||
vi.mocked(paddleRegionsApi.list).mockResolvedValue({ regions: [REGION], requests: [] });
|
||||
vi.mocked(paddleRegionsApi.catalog).mockResolvedValue([ENTRY]);
|
||||
vi.mocked(paddleRegionsApi.add).mockResolvedValue(QUEUED);
|
||||
vi.mocked(paddleRegionsApi.refresh).mockResolvedValue(READING);
|
||||
@@ -61,7 +64,7 @@ beforeEach(() => {
|
||||
describe('paddleRegionRepo online', () => {
|
||||
it('FE-REPO-PADDLEREGION-001: the list and the catalogue search go to the api and answer what it answers', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] });
|
||||
await expect(paddleRegionRepo.catalog('ober', signal)).resolves.toEqual([ENTRY]);
|
||||
expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1);
|
||||
expect(paddleRegionsApi.catalog).toHaveBeenCalledWith('ober', signal);
|
||||
@@ -113,7 +116,7 @@ describe('paddleRegionRepo offline', () => {
|
||||
it('FE-REPO-PADDLEREGION-006: the connection is read at each call, so the next call once it is back goes through', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValueOnce(true);
|
||||
await expect(paddleRegionRepo.list()).rejects.toBeInstanceOf(PaddleRegionsOfflineError);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] });
|
||||
expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +162,7 @@ describe('paddleRegionsApi', () => {
|
||||
server.use(
|
||||
contractHandler('get', LIST, { response: paddleRegionListResponseSchema }, () => ({
|
||||
regions: [REGION, READING],
|
||||
requests: [],
|
||||
})),
|
||||
contractHandler('get', CATALOG, { response: paddleRegionCatalogResponseSchema }, ({ request }) => {
|
||||
searches.push(new URL(request.url).searchParams.get('q'));
|
||||
@@ -166,7 +170,7 @@ describe('paddleRegionsApi', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(api.list()).resolves.toEqual([REGION, READING]);
|
||||
await expect(api.list()).resolves.toEqual({ regions: [REGION, READING], requests: [] });
|
||||
await expect(api.catalog('ober')).resolves.toEqual([ENTRY]);
|
||||
expect(searches).toEqual(['ober']);
|
||||
});
|
||||
|
||||
@@ -31,4 +31,7 @@ export const paddleRegionRepo = {
|
||||
add: online(paddleRegionsApi.add),
|
||||
refresh: online(paddleRegionsApi.refresh),
|
||||
remove: online(paddleRegionsApi.remove),
|
||||
decline: online(paddleRegionsApi.decline),
|
||||
settings: online(paddleRegionsApi.settings),
|
||||
saveSettings: online(paddleRegionsApi.saveSettings),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// FE-REPO-PADDLEROUTE-001 to FE-REPO-PADDLEROUTE-006: paddle legs planned on the water, online only, remembered per leg.
|
||||
// FE-API-PADDLEROUTE-001 to FE-API-PADDLEROUTE-002: the call behind them, held to the @trek/shared contract.
|
||||
import {
|
||||
MAX_PADDLE_ROUTE_LEGS,
|
||||
paddleRouteRequestSchema,
|
||||
paddleRouteResponseSchema,
|
||||
type PaddleRouteLeg,
|
||||
type PaddleRoutePoint,
|
||||
} from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { contractHandler } from '../../tests/helpers/msw/contract';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { paddleRouteApi } from '../api/paddleRoute';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { paddleRouteRepo, type PaddleRouteProfile } from './paddleRouteRepo';
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../api/paddleRoute', () => ({ paddleRouteApi: { plan: vi.fn() } }));
|
||||
|
||||
const KAYAK: PaddleRouteProfile = { boat: 'kayak', lakeStyle: 'direct' };
|
||||
const A = { lat: 49.1283, lng: 12.1303 };
|
||||
const B = { lat: 49.0922, lng: 12.1108 };
|
||||
const C = { lat: 49.022, lng: 12.111 };
|
||||
|
||||
/** The router's answer for a leg: straight on the water, its length the leg's index in metres. */
|
||||
function okLeg(from: PaddleRoutePoint, to: PaddleRoutePoint, distance = 1000): PaddleRouteLeg {
|
||||
return {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
segments: [{ kind: 'water', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||
distance_m: distance,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Answers every leg it is asked for on the water. */
|
||||
function answerAll() {
|
||||
vi.mocked(paddleRouteApi.plan).mockImplementation(async (_trip, request) => ({
|
||||
legs: request.legs.map(({ from, to }) => okLeg(from, to)),
|
||||
graph_rev: 'rev-1',
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
answerAll();
|
||||
});
|
||||
|
||||
describe('paddleRouteRepo', () => {
|
||||
it('FE-REPO-PADDLEROUTE-001: asks the server for every leg once, with the boat and the lake style', async () => {
|
||||
const legs = await paddleRouteRepo.plan(
|
||||
101,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(legs.map((leg) => leg.status)).toEqual(['ok', 'ok']);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1]).toEqual({
|
||||
legs: [
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
boat: 'kayak',
|
||||
lake_style: 'direct',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-002: a leg planned before comes from memory; moving one point asks only for its legs', async () => {
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
vi.mocked(paddleRouteApi.plan).mockClear();
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(paddleRouteApi.plan).not.toHaveBeenCalled();
|
||||
|
||||
const moved = { lat: C.lat + 0.001, lng: C.lng };
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: moved },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1].legs).toEqual([{ from: B, to: moved }]);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-003: another boat or lake style is another leg; a failed leg is asked again next time', async () => {
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], KAYAK);
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'sup', lakeStyle: 'direct' });
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'kayak', lakeStyle: 'shore' });
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(3);
|
||||
|
||||
vi.mocked(paddleRouteApi.plan).mockResolvedValue({ legs: [{ status: 'failed', code: 'busy' }], graph_rev: null });
|
||||
await expect(paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK)).resolves.toEqual([
|
||||
{ status: 'failed', code: 'busy' },
|
||||
]);
|
||||
answerAll();
|
||||
const again = await paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK);
|
||||
expect(again[0]?.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-004: forgetting a leg asks the server again (P2A-05)', async () => {
|
||||
await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK);
|
||||
paddleRouteRepo.forget(104, A, B, KAYAK);
|
||||
await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-005: offline, a leg not planned before comes back failed as offline; a remembered one still answers', async () => {
|
||||
await paddleRouteRepo.plan(105, [{ from: A, to: B }], KAYAK);
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
vi.mocked(paddleRouteApi.plan).mockClear();
|
||||
const legs = await paddleRouteRepo.plan(
|
||||
105,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(legs.map((leg) => leg.status)).toEqual(['ok', 'failed']);
|
||||
expect(legs[1]).toEqual({ status: 'failed', code: 'offline', at: null });
|
||||
expect(paddleRouteApi.plan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-006: more legs than one request takes go in batches; a leg the answer leaves out stays unrouted', async () => {
|
||||
const many = Array.from({ length: MAX_PADDLE_ROUTE_LEGS + 3 }, (_, index) => ({
|
||||
from: { lat: 49 + index * 0.001, lng: 12 },
|
||||
to: { lat: 49 + (index + 1) * 0.001, lng: 12 },
|
||||
}));
|
||||
const legs = await paddleRouteRepo.plan(106, many, KAYAK);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[1]?.[1].legs).toHaveLength(3);
|
||||
expect(legs.every((leg) => leg.status === 'ok')).toBe(true);
|
||||
|
||||
vi.mocked(paddleRouteApi.plan).mockResolvedValue({ legs: [], graph_rev: null });
|
||||
await expect(paddleRouteRepo.plan(106, [{ from: C, to: A }], KAYAK)).resolves.toEqual([
|
||||
{ status: 'failed', code: 'no_water_path', at: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The real call; the repo tests above run against a mock of this module. */
|
||||
async function realApi() {
|
||||
const actual = await vi.importActual<typeof import('../api/paddleRoute')>('../api/paddleRoute');
|
||||
return actual.paddleRouteApi;
|
||||
}
|
||||
|
||||
describe('paddleRouteApi', () => {
|
||||
it('FE-API-PADDLEROUTE-001: posts the legs to the trip and answers the parsed reply', async () => {
|
||||
const api = await realApi();
|
||||
server.use(
|
||||
contractHandler(
|
||||
'post',
|
||||
'/api/trips/7/paddle/route',
|
||||
{ request: paddleRouteRequestSchema, response: paddleRouteResponseSchema },
|
||||
({ body }) => ({ legs: body.legs.map(({ from, to }) => okLeg(from, to, 2400)), graph_rev: 'abc' })
|
||||
)
|
||||
);
|
||||
const answer = await api.plan(7, { legs: [{ from: A, to: B }], boat: 'canoe' });
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok', distance_m: 2400 });
|
||||
expect(answer.graph_rev).toBe('abc');
|
||||
});
|
||||
|
||||
it('FE-API-PADDLEROUTE-002: an answer off the contract is refused instead of handed on', async () => {
|
||||
const api = await realApi();
|
||||
server.use(http.post('/api/trips/7/paddle/route', () => HttpResponse.json({ legs: [{ status: 'maybe' }] })));
|
||||
await expect(api.plan(7, { legs: [{ from: A, to: B }] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
MAX_PADDLE_ROUTE_LEGS,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleRouteLeg,
|
||||
type PaddleRoutePoint,
|
||||
} from '@trek/shared';
|
||||
import { paddleRouteApi } from '../api/paddleRoute';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
export interface PaddleRouteProfile {
|
||||
boat: PaddleBoatType;
|
||||
lakeStyle: PaddleLakeStyle;
|
||||
}
|
||||
|
||||
/** A leg the router could not be asked for: offline, or the answer did not come. */
|
||||
export type PaddleLegAnswer = PaddleRouteLeg | { status: 'failed'; code: 'offline'; at: null };
|
||||
|
||||
const OFFLINE: PaddleLegAnswer = { status: 'failed', code: 'offline', at: null };
|
||||
const NO_ANSWER: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
|
||||
|
||||
/** How many planned legs stay remembered; a leg asked again comes from here and costs no request. */
|
||||
const REMEMBERED_LEGS = 300;
|
||||
|
||||
const remembered = new Map<string, PaddleRouteLeg>();
|
||||
|
||||
function keyOf(
|
||||
tripId: number | string,
|
||||
from: PaddleRoutePoint,
|
||||
to: PaddleRoutePoint,
|
||||
profile: PaddleRouteProfile
|
||||
): string {
|
||||
return [
|
||||
tripId,
|
||||
from.lat.toFixed(6),
|
||||
from.lng.toFixed(6),
|
||||
to.lat.toFixed(6),
|
||||
to.lng.toFixed(6),
|
||||
profile.boat,
|
||||
profile.lakeStyle,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function remember(key: string, leg: PaddleRouteLeg): void {
|
||||
remembered.delete(key);
|
||||
remembered.set(key, leg);
|
||||
if (remembered.size > REMEMBERED_LEGS) remembered.delete(remembered.keys().next().value as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: legs planned on the water, online only on purpose: the network lives
|
||||
* on the server. A leg planned before comes from memory, so moving one point
|
||||
* asks only for the two legs at it; a leg that failed is asked again next time.
|
||||
* Offline every leg comes back failed as `offline`, and the planner keeps the
|
||||
* stretch visibly unrouted.
|
||||
*/
|
||||
export const paddleRouteRepo = {
|
||||
async plan(
|
||||
tripId: number | string,
|
||||
legs: ReadonlyArray<{ from: PaddleRoutePoint; to: PaddleRoutePoint }>,
|
||||
profile: PaddleRouteProfile,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleLegAnswer[]> {
|
||||
const keys = legs.map(({ from, to }) => keyOf(tripId, from, to, profile));
|
||||
const answers: Array<PaddleLegAnswer | undefined> = keys.map((key) => remembered.get(key));
|
||||
const missing = answers.flatMap((answer, index) => (answer ? [] : [index]));
|
||||
if (missing.length > 0 && isEffectivelyOffline()) return answers.map((answer) => answer ?? OFFLINE);
|
||||
for (let start = 0; start < missing.length; start += MAX_PADDLE_ROUTE_LEGS) {
|
||||
const batch = missing.slice(start, start + MAX_PADDLE_ROUTE_LEGS);
|
||||
const asked = batch.flatMap((index) => {
|
||||
const leg = legs[index];
|
||||
return leg ? [{ index, leg }] : [];
|
||||
});
|
||||
const planned = await paddleRouteApi.plan(
|
||||
tripId,
|
||||
{ legs: asked.map(({ leg }) => leg), boat: profile.boat, lake_style: profile.lakeStyle },
|
||||
signal
|
||||
);
|
||||
asked.forEach(({ index }, at) => {
|
||||
const leg = planned.legs[at];
|
||||
const key = keys[index];
|
||||
if (!leg || key === undefined) return;
|
||||
answers[index] = leg;
|
||||
if (leg.status === 'ok') remember(key, leg);
|
||||
});
|
||||
}
|
||||
return answers.map((answer) => answer ?? NO_ANSWER);
|
||||
},
|
||||
|
||||
/** P2A-05: forget a leg, so the next plan asks the server again (on its current data). */
|
||||
forget(tripId: number | string, from: PaddleRoutePoint, to: PaddleRoutePoint, profile: PaddleRouteProfile): void {
|
||||
const key = keyOf(tripId, from, to, profile);
|
||||
remembered.delete(key);
|
||||
},
|
||||
};
|
||||
@@ -7,12 +7,16 @@
|
||||
|
||||
/* Obstacles in slate, access rules in indigo, places to get in and out in
|
||||
teal, a carried stretch in earth brown; each marker a disc in the map's own
|
||||
white, which keeps it readable over a satellite tile. */
|
||||
white, which keeps it readable over a satellite tile. A routed paddle tour
|
||||
(PAD-11) marks what the paddler should look at: whitewater and boat passes in
|
||||
amber, a gap in the data or a stretch no route was found for in red. */
|
||||
:root {
|
||||
--map-paddle-obstacle: #334155;
|
||||
--map-paddle-access: #4338ca;
|
||||
--map-paddle-landing: #0f766e;
|
||||
--map-portage: #9a6233;
|
||||
--map-paddle-rapids: #d97706;
|
||||
--map-paddle-gap: #dc2626;
|
||||
--map-paddle-disc: #ffffff;
|
||||
--map-paddle-shadow: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
@@ -22,6 +26,8 @@
|
||||
--map-paddle-access: #a5b4fc;
|
||||
--map-paddle-landing: #5eead4;
|
||||
--map-portage: #d6a06e;
|
||||
--map-paddle-rapids: #f59e0b;
|
||||
--map-paddle-gap: #ef4444;
|
||||
--map-paddle-disc: #1e293b;
|
||||
--map-paddle-shadow: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ export function hikePlannerDefaults() {
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
lakeStyle: 'direct' as const,
|
||||
recommendedSpeedKmh: null,
|
||||
effectiveSpeedKmh: null,
|
||||
setBoatType: vi.fn(),
|
||||
setWaterContext: vi.fn(),
|
||||
setSpeedKmh: vi.fn(),
|
||||
setPortageSpeedKmh: vi.fn(),
|
||||
setLakeStyle: vi.fn(),
|
||||
adoptRecommendedSpeed: vi.fn(),
|
||||
load: vi.fn(),
|
||||
},
|
||||
@@ -28,10 +30,17 @@ export function hikePlannerDefaults() {
|
||||
paddleIncomplete: false,
|
||||
paddleEstimate: null,
|
||||
editPaddlePoints: vi.fn(),
|
||||
amendWaypoints: vi.fn(),
|
||||
isLoop: false,
|
||||
reverse: vi.fn(),
|
||||
returnToStart: vi.fn(),
|
||||
toggleWaypointFreeLeg: vi.fn(),
|
||||
insertShapingPoint: vi.fn(() => null),
|
||||
setWaypointShaping: vi.fn(),
|
||||
recomputeLeg: vi.fn(),
|
||||
routeDrawnLegsOnWater: vi.fn(),
|
||||
paddleRoute: null,
|
||||
routeCurrent: false,
|
||||
routeParts: null,
|
||||
mapOverlays: new Set<string>(),
|
||||
toggleMapOverlay: vi.fn(),
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"client/src/components/Roadtrip/useFollowTrack.ts": 2,
|
||||
"client/src/components/Roadtrip/useHotelBookends.ts": 1,
|
||||
"client/src/components/Studio/countryShapes.ts": 1,
|
||||
"client/src/components/Tours/planner/useTourPlanner.ts": 3,
|
||||
"client/src/components/Tours/planner/useTourPlanner.ts": 1,
|
||||
"client/src/components/shared/ErrorBoundary.tsx": 1,
|
||||
"client/src/components/shared/Toast.tsx": 1,
|
||||
"client/src/constants/mapDefaults.ts": 4,
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"PackingTemplateItems": "packing",
|
||||
"PackingTemplates": "packing",
|
||||
"PaddleFeatures": "paddle-obstacles",
|
||||
"PaddleRegionRequests": "paddle-obstacles",
|
||||
"PaddleRegions": "paddle-obstacles",
|
||||
"PaddleWaterGraphs": "paddle-routing",
|
||||
"PasswordResetTokens": "auth",
|
||||
"PhotoProviderFields": "addons",
|
||||
"PhotoProviders": "addons",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
|
||||
import { PaddleRegionRequestsRepository } from '../repositories/PaddleRegionRequests.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
|
||||
export class PaddleRegionRequests {
|
||||
[EntityRepositoryType]?: PaddleRegionRequestsRepository;
|
||||
[PrimaryKeyProp]?: 'region_id';
|
||||
region_id!: string;
|
||||
name!: string;
|
||||
reason!: TPaddleRegionRequestsReason;
|
||||
bytes?: number | null;
|
||||
requested_by?: string | null;
|
||||
requested_at!: string & Opt;
|
||||
declined_at?: string | null;
|
||||
}
|
||||
|
||||
export const PaddleRegionRequestsReason = {
|
||||
ASK: 'ask',
|
||||
TOO_LARGE: 'too_large',
|
||||
LIMIT: 'limit',
|
||||
} as const;
|
||||
|
||||
export type TPaddleRegionRequestsReason = (typeof PaddleRegionRequestsReason)[keyof typeof PaddleRegionRequestsReason];
|
||||
|
||||
export const PaddleRegionRequestsSchema = defineEntity({
|
||||
class: PaddleRegionRequests,
|
||||
repository: () => PaddleRegionRequestsRepository,
|
||||
properties: {
|
||||
region_id: p.text().primary(),
|
||||
name: p.text(),
|
||||
reason: p.enum(() => PaddleRegionRequestsReason),
|
||||
bytes: p.integer().nullable(),
|
||||
requested_by: p.text().nullable(),
|
||||
requested_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
declined_at: p.type(DbTimestampType).nullable(),
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Collection, EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core';
|
||||
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { PaddleRegionsRepository } from '../repositories/PaddleRegions.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
import { PaddleFeatures } from './PaddleFeatures.entity';
|
||||
import { PaddleWaterGraphs } from './PaddleWaterGraphs.entity';
|
||||
|
||||
export class PaddleRegions {
|
||||
[EntityRepositoryType]?: PaddleRegionsRepository;
|
||||
@@ -18,7 +19,9 @@ export class PaddleRegions {
|
||||
feature_count: number & Opt = 0;
|
||||
error?: string | null;
|
||||
created_at?: string | null;
|
||||
origin: TPaddleRegionsOrigin & Opt = PaddleRegionsOrigin.ADMIN;
|
||||
paddle_features_collection = new Collection<PaddleFeatures>(this);
|
||||
paddle_water_graphs: Ref<PaddleWaterGraphs> | null = null;
|
||||
}
|
||||
|
||||
export const PaddleRegionsStatus = {
|
||||
@@ -30,6 +33,13 @@ export const PaddleRegionsStatus = {
|
||||
|
||||
export type TPaddleRegionsStatus = (typeof PaddleRegionsStatus)[keyof typeof PaddleRegionsStatus];
|
||||
|
||||
export const PaddleRegionsOrigin = {
|
||||
ADMIN: 'admin',
|
||||
AUTO: 'auto',
|
||||
} as const;
|
||||
|
||||
export type TPaddleRegionsOrigin = (typeof PaddleRegionsOrigin)[keyof typeof PaddleRegionsOrigin];
|
||||
|
||||
export const PaddleRegionsSchema = defineEntity({
|
||||
class: PaddleRegions,
|
||||
repository: () => PaddleRegionsRepository,
|
||||
@@ -47,6 +57,8 @@ export const PaddleRegionsSchema = defineEntity({
|
||||
feature_count: p.integer().default(0),
|
||||
error: p.text().nullable(),
|
||||
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
origin: p.enum(() => PaddleRegionsOrigin).default('admin'),
|
||||
paddle_features_collection: () => p.oneToMany(PaddleFeatures).mappedBy('region').hidden(),
|
||||
paddle_water_graphs: () => p.oneToOne(PaddleWaterGraphs).ref().mappedBy('region').hidden(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { PaddleWaterGraphsRepository } from '../repositories/PaddleWaterGraphs.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
import { PaddleRegions } from './PaddleRegions.entity';
|
||||
|
||||
export class PaddleWaterGraphs {
|
||||
[EntityRepositoryType]?: PaddleWaterGraphsRepository;
|
||||
[PrimaryKeyProp]?: 'region';
|
||||
region?: Ref<PaddleRegions> | null;
|
||||
region_id?: string | null;
|
||||
file!: string;
|
||||
rev!: string;
|
||||
bytes!: number;
|
||||
built_at!: string;
|
||||
stats!: string;
|
||||
min_lat!: number;
|
||||
min_lng!: number;
|
||||
max_lat!: number;
|
||||
max_lng!: number;
|
||||
}
|
||||
|
||||
export const PaddleWaterGraphsSchema = defineEntity({
|
||||
class: PaddleWaterGraphs,
|
||||
repository: () => PaddleWaterGraphsRepository,
|
||||
indexes: [
|
||||
{
|
||||
name: 'idx_paddle_water_graphs_box',
|
||||
properties: ['min_lat', 'max_lat', 'min_lng', 'max_lng'],
|
||||
},
|
||||
],
|
||||
uniques: [{ properties: ['region'] }],
|
||||
properties: {
|
||||
region: () => p.oneToOne(PaddleRegions).primary().ref().deleteRule('cascade').nullable().hidden(),
|
||||
region_id: p.text().nullable().persist(false),
|
||||
file: p.text(),
|
||||
rev: p.text(),
|
||||
bytes: p.integer(),
|
||||
built_at: p.type(DbTimestampType),
|
||||
stats: p.text(),
|
||||
min_lat: p.double(),
|
||||
min_lng: p.double(),
|
||||
max_lat: p.double(),
|
||||
max_lng: p.double(),
|
||||
},
|
||||
});
|
||||
@@ -13,12 +13,16 @@ export class TourWaypoints {
|
||||
sequence!: number;
|
||||
fixed_leg?: string | null;
|
||||
lock?: string | null;
|
||||
shaping: number & Opt = 0;
|
||||
}
|
||||
|
||||
export const TourWaypointsSchema = defineEntity({
|
||||
class: TourWaypoints,
|
||||
repository: () => TourWaypointsRepository,
|
||||
uniques: [{ properties: ['place', 'sequence'] }],
|
||||
checks: [
|
||||
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
|
||||
],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(),
|
||||
@@ -29,5 +33,6 @@ export const TourWaypointsSchema = defineEntity({
|
||||
sequence: p.integer(),
|
||||
fixed_leg: p.text().nullable(),
|
||||
lock: p.text().nullable(),
|
||||
shaping: p.integer().default(0),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export class Tours {
|
||||
paddle_speed_kmh?: number | null;
|
||||
portage_speed_kmh?: number | null;
|
||||
track_marks?: string | null;
|
||||
paddle_lake_style?: TToursPaddleLakeStyle | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -63,6 +64,13 @@ export const ToursWaterContext = {
|
||||
|
||||
export type TToursWaterContext = (typeof ToursWaterContext)[keyof typeof ToursWaterContext];
|
||||
|
||||
export const ToursPaddleLakeStyle = {
|
||||
DIRECT: 'direct',
|
||||
SHORE: 'shore',
|
||||
} as const;
|
||||
|
||||
export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle];
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
@@ -98,6 +106,7 @@ export const ToursSchema = defineEntity({
|
||||
paddle_speed_kmh: p.double().nullable(),
|
||||
portage_speed_kmh: p.double().nullable(),
|
||||
track_marks: p.text().nullable(),
|
||||
paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
|
||||
@@ -62,7 +62,9 @@ import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.ent
|
||||
import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity';
|
||||
import { PackingTemplatesSchema } from './PackingTemplates.entity';
|
||||
import { PaddleFeaturesSchema } from './PaddleFeatures.entity';
|
||||
import { PaddleRegionRequestsSchema } from './PaddleRegionRequests.entity';
|
||||
import { PaddleRegionsSchema } from './PaddleRegions.entity';
|
||||
import { PaddleWaterGraphsSchema } from './PaddleWaterGraphs.entity';
|
||||
import { PasswordResetTokensSchema } from './PasswordResetTokens.entity';
|
||||
import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity';
|
||||
import { PhotoProvidersSchema } from './PhotoProviders.entity';
|
||||
@@ -204,7 +206,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
PackingTemplateItemsSchema,
|
||||
PackingTemplatesSchema,
|
||||
PaddleFeaturesSchema,
|
||||
PaddleRegionRequestsSchema,
|
||||
PaddleRegionsSchema,
|
||||
PaddleWaterGraphsSchema,
|
||||
PasswordResetTokensSchema,
|
||||
PhotoProviderFieldsSchema,
|
||||
PhotoProvidersSchema,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
paddleGeometrySchema,
|
||||
paddleLockSchema,
|
||||
paddleRegionBoundarySchema,
|
||||
paddleRegionGraphStatsSchema,
|
||||
tourFixedLegSchema,
|
||||
trackMarksSchema,
|
||||
type Ferrata,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type PaddleGeometry,
|
||||
type PaddleLock,
|
||||
type PaddleRegionBoundary,
|
||||
type PaddleRegionGraphStats,
|
||||
type TourFixedLeg,
|
||||
type TrackMarks,
|
||||
} from '@trek/shared';
|
||||
@@ -194,6 +196,13 @@ export const PADDLE_REGION_BOUNDARY: JsonColumn<PaddleRegionBoundary | null> = {
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** What a paddle region's water network holds (PAD-11). A broken row has none, and the region shows no network figures. */
|
||||
export const PADDLE_REGION_GRAPH_STATS: JsonColumn<PaddleRegionGraphStats | null> = {
|
||||
column: 'paddle_water_graphs.stats',
|
||||
schema: paddleRegionGraphStatsSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** What a paddle tour's obstacle decision keeps of its obstacle (PAD-06). A broken row has none, and the decision is left out. */
|
||||
export const TOUR_OBSTACLE_SNAPSHOT: JsonColumn<ObstacleSnapshot | null> = {
|
||||
column: 'tour_obstacle_reviews.obstacle_snapshot',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface PaddleRegionRequestsTable {
|
||||
region_id: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
bytes: InsertOptional<number | null>;
|
||||
requested_by: InsertOptional<string | null>;
|
||||
requested_at: InsertOptional<string>;
|
||||
declined_at: InsertOptional<string | null>;
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export interface PaddleRegionsTable {
|
||||
feature_count: InsertOptional<number>;
|
||||
error: InsertOptional<string | null>;
|
||||
created_at: InsertOptional<string | null>;
|
||||
origin: InsertOptional<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
|
||||
export interface PaddleWaterGraphsTable {
|
||||
region_id: string;
|
||||
file: string;
|
||||
rev: string;
|
||||
bytes: number;
|
||||
built_at: string;
|
||||
stats: string;
|
||||
min_lat: number;
|
||||
min_lng: number;
|
||||
max_lat: number;
|
||||
max_lng: number;
|
||||
}
|
||||
@@ -11,4 +11,5 @@ export interface TourWaypointsTable {
|
||||
sequence: number;
|
||||
fixed_leg: InsertOptional<string | null>;
|
||||
lock: InsertOptional<string | null>;
|
||||
shaping: InsertOptional<number>;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user