mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(tours): paddle obstacle card, admin regions panel and obstacle marks on the route band
PAD-05B: a click on an obstacle on the map or on the route band opens its card in the look of the via ferrata card: the kind with its glyph, the name, how it stands to the route (on it at a distance along it, near it with the link marked unclear, or only in the area), the paddling rule its tags give with canoe read before boat, a condition as written, height, grade, hours and fees, the source with the date of the data and the OpenStreetMap link. A missing rule reads as unknown, never as allowed, and a portage way recorded as private says so. Objects only in the area show from zoom 13 on, on Leaflet and GL alike; on GL the lines only near the route are dashed as on Leaflet, one click handler picks the marker over the line, and a picked marker no longer flickers. The route band marks the obstacles on and near the route by their share of the measured line. The obstacle notice also names an answer cut at its limit. PAD-05A: the admin area gets a Paddle water data card under the addons, on desktop and phone: a search in Geofabrik's list with each region's path, the regions with their state (waiting, reading with its progress, ready with objects and data date, failed with the reason), reading again and removing after a confirmation. The list follows a read without reloading. Region changes write audit rows. Map colours of paddle objects and portages come from theme tokens read at draw time, as the via ferratas do; the ferrata layer now shares that reader. The rail takes the planner page's parts as one bag, the map hit tests of the tour layers are one helper, and the unused noWaterData string is gone from every locale.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
} 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. */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions),
|
||||
add: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
refresh: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
remove: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -3,6 +3,10 @@ import apiClient from './client';
|
||||
|
||||
/** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */
|
||||
export const tourPaddlePlanApi = {
|
||||
update: (tripId: number | string, placeId: number | string, body: TourPaddlePlanRequest): Promise<TourDetailResponse> =>
|
||||
update: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourPaddlePlanRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
SettingRows,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingsCard,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
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.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
<ErrorBoundary boundaryId="paddle-regions">
|
||||
<Panel />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
const { t } = useTranslation();
|
||||
const admin = usePaddleRegionsAdmin();
|
||||
if (!admin.toursOn) return null;
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Waves}
|
||||
title={t('admin.paddleRegions.title')}
|
||||
hint={t('admin.paddleRegions.hint')}
|
||||
badge={admin.regions.length > 0 ? <StatusPill>{admin.regions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{admin.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<WifiOff size={14} className="flex-none" />
|
||||
{t('admin.paddleRegions.offline')}
|
||||
</p>
|
||||
)}
|
||||
{admin.error && !admin.offline && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
|
||||
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
{t('admin.paddleRegions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
<SettingsHint>{t('admin.paddleRegions.attribution')}</SettingsHint>
|
||||
<ConfirmDialog
|
||||
isOpen={admin.removing !== null}
|
||||
onClose={admin.cancelRemove}
|
||||
onConfirm={admin.confirmRemove}
|
||||
title={t('admin.paddleRegions.remove')}
|
||||
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
|
||||
confirmLabel={t('common.remove')}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */
|
||||
function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) {
|
||||
if (hit.path.length === 0) return null;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-0.5 text-content-faint" style={fs(11.5)}>
|
||||
{hit.path.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
|
||||
{index > 0 && <ChevronRight size={11} className="flex-none rtl:rotate-180" aria-hidden="true" />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const { search } = admin;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EditorField label={t('admin.paddleRegions.search')} htmlFor={id}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
type="search"
|
||||
className={`${INPUT} ps-9`}
|
||||
value={search.query}
|
||||
onChange={(event) => search.setQuery(event.target.value)}
|
||||
placeholder={t('admin.paddleRegions.searchPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{search.searching && (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{search.error && (
|
||||
<p role="alert" className="m-0 text-danger" style={fs(12.5, 'body')}>
|
||||
{search.error}
|
||||
</p>
|
||||
)}
|
||||
{search.hits && search.hits.length === 0 && <SettingsHint>{t('admin.paddleRegions.noHits')}</SettingsHint>}
|
||||
{search.hits && search.hits.length > 0 && (
|
||||
<SettingRows>
|
||||
{search.hits.map((hit) => (
|
||||
<div key={hit.id} className="flex items-center gap-3 py-2 pe-2 ps-3.5" data-testid="paddle-region-hit">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{hit.name}
|
||||
</span>
|
||||
<RegionPath hit={hit} />
|
||||
</div>
|
||||
{admin.added.has(hit.id) ? (
|
||||
<StatusPill icon={<Check size={11} strokeWidth={2.4} />}>{t('admin.paddleRegions.added')}</StatusPill>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.add(hit.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{admin.busyId === hit.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
)}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.loading) {
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (admin.regions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center">
|
||||
<SettingsHint>{t('admin.paddleRegions.empty')}</SettingsHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingRows>
|
||||
{admin.regions.map((region) => (
|
||||
<RegionRow key={region.id} region={region} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const percent = progressPercent(region);
|
||||
const label =
|
||||
region.status === 'importing' && region.progress
|
||||
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
|
||||
: t(`admin.paddleRegions.status.${region.status}`);
|
||||
const share =
|
||||
percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`;
|
||||
return (
|
||||
<StatusPill
|
||||
tone={regionStatusTone(region.status)}
|
||||
icon={region.status === 'importing' ? <Loader2 size={11} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{label}
|
||||
{share}
|
||||
</StatusPill>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
const date = region.source_timestamp
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-row"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"
|
||||
>
|
||||
<Waves size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{region.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
{region.error}
|
||||
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip label={t('admin.paddleRegions.refresh')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
|
||||
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
|
||||
onClick={() => void admin.refresh(region.id)}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('admin.paddleRegions.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => admin.askRemove(region)}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaddleRegion } from '@trek/shared';
|
||||
|
||||
/** A region waiting for its read or being read: the list asks again until none is. */
|
||||
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
|
||||
return region.status === 'queued' || region.status === 'importing';
|
||||
}
|
||||
|
||||
/** The share of a read under way as a whole percent, or null while its size is unknown. */
|
||||
export function progressPercent(region: Pick<PaddleRegion, 'progress'>): number | null {
|
||||
const fraction = region.progress?.fraction;
|
||||
return fraction === null || fraction === undefined ? null : Math.round(fraction * 100);
|
||||
}
|
||||
|
||||
/** The status badge's tone: what state the read is in, never whether the water is safe. */
|
||||
export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' {
|
||||
if (status === 'importing') return 'accent';
|
||||
if (status === 'ready') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
return 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
|
||||
/** 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;
|
||||
/** How long typing rests before Geofabrik's list is searched. */
|
||||
export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350;
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function errorText(t: Translate, err: unknown): string {
|
||||
const code = paddleRegionErrorCode(err);
|
||||
if (code === 'offline') return t('admin.paddleRegions.offline');
|
||||
if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable');
|
||||
return getApiErrorMessage(err, t('common.error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(errorText(t, err));
|
||||
setLoaded(true);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, asked]);
|
||||
const reading = regions.some(isReading);
|
||||
useEffect(() => {
|
||||
if (!active || offline || !reading) return;
|
||||
const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [result, setResult] = useState<{
|
||||
needle: string;
|
||||
hits: PaddleRegionCatalogEntry[] | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const needle = query.trim();
|
||||
const wanted = !offline && needle.length >= 2;
|
||||
useEffect(() => {
|
||||
if (!wanted) return;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
paddleRegionRepo
|
||||
.catalog(needle, controller.signal)
|
||||
.then((hits) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) });
|
||||
});
|
||||
}, CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [needle, wanted, t]);
|
||||
// An answer counts only for the words it was asked for.
|
||||
const current = wanted && result?.needle === needle ? result : null;
|
||||
return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
const pending = useRef(false);
|
||||
const { reload } = list;
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, action: () => Promise<unknown>, done?: string) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await action();
|
||||
if (done) toast.success(done);
|
||||
} catch (err) {
|
||||
toast.error(errorText(t, err));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusyId(null);
|
||||
reload();
|
||||
}
|
||||
},
|
||||
[reload, t, toast]
|
||||
);
|
||||
|
||||
const { setQuery } = search;
|
||||
const add = useCallback(
|
||||
(id: string) =>
|
||||
run(
|
||||
id,
|
||||
async () => {
|
||||
await paddleRegionRepo.add(id);
|
||||
setQuery('');
|
||||
},
|
||||
t('admin.paddleRegions.toast.added')
|
||||
),
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed'));
|
||||
}, [removing, run, t]);
|
||||
const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]);
|
||||
|
||||
return {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
search,
|
||||
added,
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
confirmRemove,
|
||||
};
|
||||
}
|
||||
@@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
|
||||
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { ferrataOsmUrl } from './ferrataModel';
|
||||
|
||||
/** The map controls' frosted glass, so the card reads as one of them. */
|
||||
const MAP_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
};
|
||||
|
||||
/**
|
||||
* One via ferrata, picked on the map: its name, the route it belongs to, its
|
||||
* grade range and total length as badges, and the way back to the source.
|
||||
@@ -57,7 +49,7 @@ export default function FerrataDetailCard({
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_GLASS, width }}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
@@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({
|
||||
onTourIds?: ReadonlySet<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const open = !!selection;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress]);
|
||||
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
|
||||
|
||||
if (!selection) return null;
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMapPaint, sameMapPaint } from '../Map/mapPaint';
|
||||
|
||||
/**
|
||||
* The colours the via ferrata lines are drawn in.
|
||||
*
|
||||
@@ -10,9 +12,9 @@
|
||||
*
|
||||
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
|
||||
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
|
||||
* `--map-ferrata*` tokens are read off the document when the layer draws (the
|
||||
* precedent is the hazard overlay in useHazardLayerGL), which keeps light and
|
||||
* dark mode apart without a colour literal in the layer.
|
||||
* `--map-ferrata*` tokens are read off the document when the layer draws
|
||||
* (`mapPaint.ts`; the precedent is the hazard overlay in useHazardLayerGL),
|
||||
* which keeps light and dark mode apart without a colour literal in the layer.
|
||||
*/
|
||||
export interface FerrataPaint {
|
||||
/** The dashed line itself. */
|
||||
@@ -23,27 +25,16 @@ export interface FerrataPaint {
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
const TOKENS = {
|
||||
export const FERRATA_TOKENS = {
|
||||
line: '--map-ferrata',
|
||||
casing: '--map-ferrata-casing',
|
||||
highlight: '--map-ferrata-lit',
|
||||
} as const;
|
||||
|
||||
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
|
||||
function token(name: string, style: CSSStyleDeclaration | null): string {
|
||||
const value = style?.getPropertyValue(name).trim();
|
||||
return value || 'currentColor';
|
||||
}
|
||||
|
||||
export function ferrataPaint(): FerrataPaint {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
return {
|
||||
line: token(TOKENS.line, style),
|
||||
casing: token(TOKENS.casing, style),
|
||||
highlight: token(TOKENS.highlight, style),
|
||||
};
|
||||
return readMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
|
||||
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
|
||||
return sameMapPaint(a, b);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMapPaint } from '../Map/mapPaint';
|
||||
import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint';
|
||||
|
||||
import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint';
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/**
|
||||
* The line colours, resolved again whenever the light/dark class, the colour
|
||||
* scheme or a custom accent on `<html>` changes. Map paint holds resolved
|
||||
* strings rather than `var(--token)`, so without this a theme toggle would leave
|
||||
* the lines in the old scheme's colours. Same observer approach as `useIsDark`,
|
||||
* which only watches the class. The reference only changes when a colour does,
|
||||
* so a memo keyed on it does not churn.
|
||||
*/
|
||||
/** The line colours, resolved again whenever the theme on `<html>` changes (see `useMapPaint`). */
|
||||
export function useFerrataPaint(): FerrataPaint {
|
||||
const [paint, setPaint] = useState(ferrataPaint);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = ferrataPaint();
|
||||
setPaint((current) => (sameFerrataPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return paint;
|
||||
return useMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applySatellite } from './glRasterLayers'
|
||||
import { useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
@@ -33,8 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { groupCoincidentPlaces } from './coincidentPlaces'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { type FerrataGlMap, isFerrataHit } from '../Ferratas/useFerrataLayerGL'
|
||||
import { isPaddleHit } from '../Tours/paddle/usePaddleLayerGL'
|
||||
import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
@@ -1430,8 +1429,7 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
|
||||
if (isPaddleHit(map, e.point)) return // so does a picked paddle obstacle
|
||||
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
|
||||
/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */
|
||||
export const MAP_CARD_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Colours for map paint, read off the theme. Map paint cannot take a
|
||||
* `var(--token)`: Leaflet writes the colour into an SVG attribute and GL
|
||||
* paint wants a resolved colour string. So the map layers name the tokens
|
||||
* they draw with, and the colours are read off the document when they draw,
|
||||
* which keeps light and dark mode apart without a colour literal in a layer.
|
||||
*/
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/** What each token resolves to on the root, `currentColor` where nothing defines it (a test DOM). */
|
||||
export function readMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
const keys = Object.keys(tokens) as K[];
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, style?.getPropertyValue(tokens[key]).trim() || 'currentColor'])
|
||||
) as Record<K, string>;
|
||||
}
|
||||
|
||||
export function sameMapPaint<K extends string>(
|
||||
a: Readonly<Record<K, string>>,
|
||||
b: Readonly<Record<K, string>>
|
||||
): boolean {
|
||||
return (Object.keys(a) as K[]).every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours, read again whenever the light or dark class, the colour scheme
|
||||
* or a custom accent on `<html>` changes; without this a theme toggle would
|
||||
* leave a layer in the old scheme's colours. The answer only changes identity
|
||||
* when a colour does, so a memo keyed on it does not churn. `tokens` is a
|
||||
* constant of the caller's module.
|
||||
*/
|
||||
export function useMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const [paint, setPaint] = useState(() => readMapPaint(tokens));
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = readMapPaint(tokens);
|
||||
setPaint((current) => (sameMapPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, [tokens]);
|
||||
return paint;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import { type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
||||
import { useEffect } from 'react';
|
||||
import { useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
|
||||
import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
||||
import { OWN_LAYER_PREFIXES } from './glRasterLayers';
|
||||
|
||||
/**
|
||||
@@ -115,3 +115,12 @@ export function useTourMapLayersGL(
|
||||
useRasterOverlaysGL(map, overlays);
|
||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click at `point` landed on an object of the tour layers, a via
|
||||
* ferrata or a paddle obstacle. The maps ask this before their own click
|
||||
* handler, so picking one opens its card instead of also setting a waypoint.
|
||||
*/
|
||||
export function isTourLayerHit<Point>(map: Parameters<typeof isFerrataHit<Point>>[0], point: Point): boolean {
|
||||
return isFerrataHit(map, point) || isPaddleHit(map, point);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Closes a card floating over the map on a press anywhere outside it and on
|
||||
* Escape, while it is open. The press outside is handed to `onOutsidePress`
|
||||
* as well, so the map under it can leave the click that follows alone (see
|
||||
* `useOutsidePressGuard`). A press on another object closes this card first,
|
||||
* and the click then opens that object's.
|
||||
*/
|
||||
export function useMapCardDismiss(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onOutsidePress?: () => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress, ref]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import TourWorkspaceRail from './TourWorkspaceRail';
|
||||
import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail';
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
@@ -23,18 +23,13 @@ function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
|
||||
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
|
||||
}
|
||||
|
||||
function workspace(browser: WandererBrowser): TourWorkspaceRailParts {
|
||||
return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack };
|
||||
}
|
||||
|
||||
describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
|
||||
render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ open: true })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
render(<TourWorkspaceRail workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
|
||||
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -42,16 +37,7 @@ describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
|
||||
const browser = wanderer({});
|
||||
seen.rail.length = 0;
|
||||
const view = render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={browser}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign={false}
|
||||
/>
|
||||
);
|
||||
const view = render(<TourWorkspaceRail workspace={workspace(browser)} canEdit canAssign={false} />);
|
||||
expect(screen.getByText('tour editor')).toBeInTheDocument();
|
||||
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
|
||||
planner,
|
||||
@@ -62,16 +48,7 @@ describe('TourWorkspaceRail', () => {
|
||||
onOpenWanderer: browser.openBrowser,
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ enabled: false })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
view.rerender(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import { TourPlannerRail } from './planner/TourPlannerPanels';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import WandererBrowserRail from './wanderer/WandererBrowserRail';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
|
||||
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
|
||||
export default function TourWorkspaceRail({
|
||||
planner,
|
||||
wanderer,
|
||||
conversion,
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
canEdit,
|
||||
canAssign,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
wanderer: WandererBrowser;
|
||||
conversion: GpxConversion;
|
||||
/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */
|
||||
export interface TourWorkspaceRailParts {
|
||||
tourPlanner: TourPlannerController;
|
||||
wandererBrowser: WandererBrowser;
|
||||
tourConversion: GpxConversion;
|
||||
recordedTrack: RecordedTrack;
|
||||
/** The portages and locks of a recorded paddle track in view. */
|
||||
trackPaddlePlan?: TrackPaddlePlan;
|
||||
}
|
||||
|
||||
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
|
||||
export default function TourWorkspaceRail({
|
||||
workspace,
|
||||
canEdit,
|
||||
canAssign,
|
||||
}: {
|
||||
workspace: TourWorkspaceRailParts;
|
||||
canEdit: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const { tourPlanner, wandererBrowser: wanderer, tourConversion, recordedTrack, trackPaddlePlan } = workspace;
|
||||
return wanderer.open ? (
|
||||
<WandererBrowserRail browser={wanderer} />
|
||||
) : (
|
||||
<TourPlannerRail
|
||||
planner={planner}
|
||||
planner={tourPlanner}
|
||||
canEdit={canEdit}
|
||||
canAssign={canAssign}
|
||||
conversion={conversion}
|
||||
conversion={tourConversion}
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* A figure as a badge holding its value as a white badge, the planner's
|
||||
* badge-in-badge look: the wind of the day, a weir's height, an access rule.
|
||||
* What it means exactly can sit in its tooltip.
|
||||
*/
|
||||
export function FigureBadge({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
info,
|
||||
tone = 'neutral',
|
||||
testId,
|
||||
}: {
|
||||
label?: string;
|
||||
value: string;
|
||||
icon?: ReactNode;
|
||||
info?: string;
|
||||
/** `warning` colours the value: a rule that forbids, a way recorded as private. */
|
||||
tone?: 'neutral' | 'warning';
|
||||
testId?: string;
|
||||
}) {
|
||||
const badge = (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{label && <span className="ps-1">{label}</span>}
|
||||
<span
|
||||
className={`inline-flex h-[18px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums shadow-sm ${tone === 'warning' ? 'text-warning' : 'text-content'}`}
|
||||
style={fs(11)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
return info ? <Tooltip label={info}>{badge}</Tooltip> : badge;
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import L from 'leaflet';
|
||||
import { useMemo } from 'react';
|
||||
import { Marker, Polyline, Tooltip } from 'react-leaflet';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { markerSize, paddleMarkerHtml, type PaddleMapItem, type PaddleMapLayer } from './paddleMapLayer';
|
||||
import {
|
||||
markerSize,
|
||||
paddleItemsAtZoom,
|
||||
paddleMarkerHtml,
|
||||
type PaddleMapItem,
|
||||
type PaddleMapLayer,
|
||||
} from './paddleMapLayer';
|
||||
|
||||
const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const;
|
||||
const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
|
||||
@@ -30,13 +36,18 @@ function useLabel() {
|
||||
* The paddle obstacles on the Leaflet map (PAD-05B): each line on a white
|
||||
* casing in its category's colour, dashed while it is only near the route,
|
||||
* and a round marker with its glyph; the quieter, the less it bears on the
|
||||
* route. A click picks the object and goes no further, so it never also sets
|
||||
* a waypoint under it.
|
||||
* route, and the objects only in the area wait for a closer zoom. A click
|
||||
* picks the object and goes no further, so it never also sets a waypoint
|
||||
* under it.
|
||||
*/
|
||||
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
const label = useLabel();
|
||||
const items = layer?.items;
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
|
||||
const selectedRef = layer?.selectedRef ?? null;
|
||||
const all = layer?.items;
|
||||
const items = useMemo(() => (all ? paddleItemsAtZoom(all, zoom, selectedRef) : undefined), [all, zoom, selectedRef]);
|
||||
// Rebuilt only when the items or the selection change, not on every render of the map.
|
||||
const icons = useMemo(
|
||||
() => new Map((items ?? []).map((item) => [item.feature.ref, iconOf(item, item.feature.ref === selectedRef)])),
|
||||
@@ -56,7 +67,11 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
<Polyline
|
||||
key={`${item.feature.ref}-casing`}
|
||||
positions={item.line}
|
||||
pathOptions={{ color: '#ffffff', weight: LINE_WEIGHT[item.relation] + 3, opacity: LINE_OPACITY[item.relation] }} // theme-lint-disable map casing
|
||||
pathOptions={{
|
||||
color: item.disc,
|
||||
weight: LINE_WEIGHT[item.relation] + 3,
|
||||
opacity: LINE_OPACITY[item.relation],
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
) : null
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
|
||||
import { KNOWN_ACCESS_VALUES, metresOf, osmUrlOf, type PaddleObstacleDetail } from './paddleObstacleModel';
|
||||
import { paddleKindCss } from './paddlePaint';
|
||||
|
||||
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
|
||||
|
||||
/** How the object stands to the route: on it at a distance along it, near it and uncertain, or only in the area. */
|
||||
function RelationBadges({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-relation">
|
||||
{detail.relation === 'on_route' && (
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.onRoute')}
|
||||
value={formatDistance((detail.alongM ?? 0) / 1000, unit)}
|
||||
info={t('tours.paddle.obstacleCard.alongInfo')}
|
||||
/>
|
||||
)}
|
||||
{detail.relation === 'near' && (
|
||||
<>
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.near')}
|
||||
value={formatDistance((detail.distanceM ?? 0) / 1000, unit)}
|
||||
info={t('tours.paddle.obstacleCard.distanceInfo')}
|
||||
/>
|
||||
<SoftPill>{t('tours.paddle.obstacleCard.uncertain')}</SoftPill>
|
||||
</>
|
||||
)}
|
||||
{detail.relation === 'area' && <SoftPill>{t('tours.paddle.obstacleCard.area')}</SoftPill>}
|
||||
{detail.privateWay && <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The paddling rule the tags give (canoe read before boat), its condition as written, and the facts worth knowing. */
|
||||
function AccessAndFacts({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
const { access } = detail;
|
||||
const accessValue = (value: string) =>
|
||||
ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
|
||||
const factValue = (key: string, value: string) => {
|
||||
const metres = key === 'height' || key === 'width' ? metresOf(value) : null;
|
||||
return metres === null ? value : formatDistance(metres / 1000, unit);
|
||||
};
|
||||
const ruled = access.source !== null && access.value !== null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-access">
|
||||
{ruled && (
|
||||
<FigureBadge
|
||||
label={t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
|
||||
value={accessValue(access.value ?? '')}
|
||||
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
|
||||
/>
|
||||
)}
|
||||
{!ruled && !access.condition && <SoftPill>{t('tours.paddle.obstacleCard.noAccessInfo')}</SoftPill>}
|
||||
{access.condition && (
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.condition')}
|
||||
value={access.condition}
|
||||
info={access.condition}
|
||||
/>
|
||||
)}
|
||||
{detail.facts.map((fact) => (
|
||||
<FigureBadge
|
||||
key={fact.key}
|
||||
label={t(`tours.paddle.obstacleCard.fact.${fact.key === 'whitewater:section_grade' ? 'grade' : fact.key}`)}
|
||||
value={factValue(fact.key, fact.value)}
|
||||
info={fact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The way to the source and how current it is; a record in the data is no check on site. */
|
||||
function SourceLines({ detail }: { detail: PaddleObstacleDetail }) {
|
||||
const { t, language } = useTranslation();
|
||||
const date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={osmUrlOf(detail.feature.ref)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('ferratas.viewInOsm')}
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
|
||||
{date ? t('tours.paddle.obstacleCard.source', { date }) : t('tours.paddle.obstacleCard.sourceUndated')}{' '}
|
||||
{t('ferratas.attribution')}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One paddle object picked on the map or in the checklist (PAD-05B), in the
|
||||
* look of the via ferrata card: what it is, how it stands to the route (an
|
||||
* object only near it is said to be uncertain, never on it), the paddling
|
||||
* rule its tags give with canoe read before boat, the facts worth knowing,
|
||||
* and where the data comes from and how current it is. A missing rule reads
|
||||
* as unknown, never as allowed; the card says it is data, not a check on site.
|
||||
* `children` takes what the checklist adds below the facts.
|
||||
*/
|
||||
export default function PaddleObstacleCard({
|
||||
detail,
|
||||
onClose,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
detail: PaddleObstacleDetail;
|
||||
onClose?: () => void;
|
||||
width?: number;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||
const { feature } = detail;
|
||||
const kind = t(`tours.paddle.obstacle.kind.${feature.kind}`);
|
||||
const color = paddleKindCss(feature.kind);
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="paddle-obstacle-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-surface-card shadow-sm"
|
||||
style={{ color, boxShadow: `inset 0 0 0 2px ${color}` }}
|
||||
>
|
||||
<PaddleGlyph kind={OBSTACLE_GLYPH[feature.kind]} size={15} strokeWidth={2.3} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={feature.name ?? kind}>
|
||||
{feature.name ?? kind}
|
||||
</h3>
|
||||
{feature.name && <p className="truncate text-caption text-content-muted">{kind}</p>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<RelationBadges detail={detail} unit={unit} />
|
||||
<AccessAndFacts detail={detail} unit={unit} />
|
||||
{detail.note && (
|
||||
<p className="mt-2 line-clamp-3 break-words text-content-muted" style={fs(11.5, 'body')} title={detail.note}>
|
||||
{detail.note}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<SourceLines detail={detail} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,31 @@ import { usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { TourNotice } from '../tourParts';
|
||||
|
||||
/** The oldest data date among the regions, as a short local date; the route is only as current as its oldest part. */
|
||||
function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string {
|
||||
const times = regions.map((region) => region.source_timestamp).filter((time): time is string => !!time).sort();
|
||||
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : '';
|
||||
function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string | null {
|
||||
const times = regions
|
||||
.map((region) => region.source_timestamp)
|
||||
.filter((time): time is string => !!time)
|
||||
.sort();
|
||||
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05B: what TREK knows about the water along the route, in one line under
|
||||
* the boat fields. It never reads as an all-clear: covered or not, the
|
||||
* paddler checks the water, and where nothing is known it says so instead of
|
||||
* leaving an empty map to be read as open water.
|
||||
* the boat fields and in the recorded track's rail. It never reads as an
|
||||
* all-clear: covered or not, the paddler checks the water, where nothing is
|
||||
* known it says so instead of leaving an empty map to be read as open water,
|
||||
* and an answer cut at its limit says that more is there.
|
||||
*/
|
||||
export function PaddleObstacleNotice() {
|
||||
const { t, language } = useTranslation();
|
||||
const { status, coverage, related, regions } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({ status: state.status, coverage: state.coverage, related: state.related, regions: state.regions }))
|
||||
const { status, coverage, related, regions, truncated } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
status: state.status,
|
||||
coverage: state.coverage,
|
||||
related: state.related,
|
||||
regions: state.regions,
|
||||
truncated: state.truncated,
|
||||
}))
|
||||
);
|
||||
const warn = <AlertTriangle size={13} strokeWidth={2} />;
|
||||
if (status === 'idle' || status === 'loading') {
|
||||
@@ -29,11 +39,14 @@ export function PaddleObstacleNotice() {
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
|
||||
if (status === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
|
||||
if (status === 'error') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.error')}</TourNotice>;
|
||||
const date = dataDate(regions, language);
|
||||
const date = dataDate(regions, language) ?? t('tours.paddle.obstacles.dateUnknown');
|
||||
if (coverage === 'none') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.none')}</TourNotice>;
|
||||
if (coverage === 'partial') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
|
||||
if (coverage === 'partial')
|
||||
return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
|
||||
if (truncated) return <TourNotice icon={warn}>{t('tours.paddle.obstacles.truncated')}</TourNotice>;
|
||||
return (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{related.length
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
|
||||
import PaddleObstacleCard from './PaddleObstacleCard';
|
||||
import { paddleObstacleDetail, type PaddleObstacleDetail } from './paddleObstacleModel';
|
||||
|
||||
/**
|
||||
* The picked paddle object's card over the planner map (PAD-05B), where a
|
||||
* via ferrata's card sits and closing as that one does: on a press outside
|
||||
* it and on Escape, the press handed on so the map leaves its click alone.
|
||||
* The pick lives in the obstacle store, so the map and the checklist open
|
||||
* and close the same card. `extra` adds the checklist's part for the object.
|
||||
*/
|
||||
export default function PaddleObstacleOverlay({
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
extra,
|
||||
}: {
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
width?: number;
|
||||
extra?: (detail: PaddleObstacleDetail) => ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { features, related, regions, selectedRef } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
features: state.features,
|
||||
related: state.related,
|
||||
regions: state.regions,
|
||||
selectedRef: state.selectedRef,
|
||||
}))
|
||||
);
|
||||
const detail = useMemo(
|
||||
() => paddleObstacleDetail({ features, related, regions, selectedRef }),
|
||||
[features, related, regions, selectedRef]
|
||||
);
|
||||
const close = useCallback(() => selectPaddleObstacle(null), []);
|
||||
useMapCardDismiss(ref, !!detail, close, onOutsidePress);
|
||||
|
||||
if (!detail) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="paddle-obstacle-overlay"
|
||||
>
|
||||
<PaddleObstacleCard detail={detail} onClose={close} width={width}>
|
||||
{extra?.(detail)}
|
||||
</PaddleObstacleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { PaddleLayout } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
|
||||
import { routeStripObstacles } from './paddleObstacleModel';
|
||||
import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
|
||||
/** Water in the band and in the tiles that explain it: the info blue of the theme. */
|
||||
export const WATER_COLOR = 'var(--info)';
|
||||
@@ -16,13 +21,26 @@ const MIN_SHARE = 1.5;
|
||||
/**
|
||||
* A paddle tour from put-in to take-out as one band, to scale: water in the
|
||||
* planner's blue, portages in the earth brown the map draws them in, each lock
|
||||
* a mark at its place (struck through where a portage carries round it). It
|
||||
* a mark at its place (struck through where a portage carries round it). The
|
||||
* obstacles the data knows on or near the route sit on it too, ringed in the
|
||||
* colour of their kind, dashed when only near; a click opens one's card. It
|
||||
* stands where a hike shows its elevation profile; a paddle tour has none.
|
||||
*/
|
||||
export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLayout; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const { related, routeLengthM, selectedRef } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
related: state.related,
|
||||
routeLengthM: state.routeLengthM,
|
||||
selectedRef: state.selectedRef,
|
||||
}))
|
||||
);
|
||||
const total = layout.totalM;
|
||||
const obstacles = useMemo(
|
||||
() => (compact ? [] : routeStripObstacles(related, routeLengthM, total)),
|
||||
[compact, related, routeLengthM, total]
|
||||
);
|
||||
if (total <= 0 || layout.stretches.length === 0) return null;
|
||||
const share = (m: number) => Math.max(MIN_SHARE, (m / total) * 100);
|
||||
const km = (m: number) => formatDistance(m / 1000, unit);
|
||||
@@ -35,13 +53,39 @@ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLa
|
||||
<span
|
||||
data-testid="paddle-route-strip-lock"
|
||||
className={`absolute top-1/2 z-10 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge rtl:translate-x-1/2 ${lock.bypassed ? 'opacity-45' : ''}`}
|
||||
// rtl-lint-disable the band runs from the put-in, along the line, in both directions alike
|
||||
style={{ insetInlineStart: `${(lock.atM / total) * 100}%` }}
|
||||
>
|
||||
<PaddleGlyph kind="lock" size={10} strokeWidth={2.6} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
{obstacles.map((obstacle) => {
|
||||
const kind = t(`tours.paddle.obstacle.kind.${obstacle.kind}`);
|
||||
const color = paddleKindCss(obstacle.kind);
|
||||
const picked = obstacle.ref === selectedRef;
|
||||
return (
|
||||
<Tooltip
|
||||
key={obstacle.ref}
|
||||
label={`${obstacle.name ? `${kind}: ${obstacle.name}` : kind} ${km(obstacle.atM)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="paddle-route-strip-obstacle"
|
||||
aria-label={obstacle.name ? `${kind}: ${obstacle.name}` : kind}
|
||||
aria-pressed={picked}
|
||||
onClick={() => selectPaddleObstacle(picked ? null : obstacle.ref)}
|
||||
className={`absolute top-1/2 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card shadow-sm rtl:translate-x-1/2 ${picked ? 'z-30 scale-125' : 'z-20'} ${obstacle.relation === 'near' && !picked ? 'opacity-60' : ''}`}
|
||||
style={{
|
||||
insetInlineStart: `${(obstacle.atM / total) * 100}%`,
|
||||
color,
|
||||
border: `1.5px ${obstacle.relation === 'near' && !picked ? 'dashed' : 'solid'} ${color}`,
|
||||
}}
|
||||
>
|
||||
<PaddleGlyph kind={OBSTACLE_GLYPH[obstacle.kind]} size={9} strokeWidth={2.6} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div className="flex h-2.5 w-full gap-[2px] overflow-hidden rounded-full">
|
||||
{layout.stretches.map((stretch) => {
|
||||
const land = stretch.kind === 'portage';
|
||||
@@ -57,7 +101,7 @@ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLa
|
||||
style={{
|
||||
flexGrow: share(length),
|
||||
flexBasis: 0,
|
||||
...(land ? { background: PORTAGE_LINE_COLOR } : {}),
|
||||
...(land ? { background: PORTAGE_CSS_COLOR } : {}),
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -18,8 +18,8 @@ import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { InfoDot, RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
import { PaddleRouteStrip, WATER_COLOR } from './PaddleRouteStrip';
|
||||
import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle';
|
||||
|
||||
const minutes = (value: number | null) => (value === null ? '–' : formatPlannedTourDuration(value));
|
||||
|
||||
@@ -284,7 +284,7 @@ function PartTiles({
|
||||
<PartTile
|
||||
testId="paddle-time-carry"
|
||||
icon={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_LINE_COLOR}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
label={t('tours.paddle.time.carry')}
|
||||
measures={
|
||||
<MeasureGroup
|
||||
@@ -310,7 +310,7 @@ function PartTiles({
|
||||
<PartTile
|
||||
testId="paddle-time-out-in"
|
||||
icon={<PaddleGlyph kind="take_out" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_LINE_COLOR}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
label={t('tours.paddle.time.outIn')}
|
||||
measures={<MeasureBadge>{`${estimate.portageCount}×`}</MeasureBadge>}
|
||||
time={minutes(estimate.portageExtraMinutes)}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import {
|
||||
DEFAULT_PORTAGE_EXTRA_MINUTES,
|
||||
MAX_PADDLE_STOP_MINUTES,
|
||||
type PaddleLock,
|
||||
type PaddleRole,
|
||||
} from '@trek/shared';
|
||||
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
||||
import { ChevronDown, 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 { fs } from '../../shared/DialogShell';
|
||||
import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
|
||||
const ROLE_GLYPH = { put_in: 'put_in', take_out: 'take_out', re_entry: 'put_in', final_take_out: 'take_out' } as const;
|
||||
|
||||
@@ -29,7 +24,7 @@ export function PaddleRolePill({ role }: { role: PaddleRole }) {
|
||||
kind={ROLE_GLYPH[role]}
|
||||
size={11}
|
||||
strokeWidth={2.4}
|
||||
style={land ? { color: PORTAGE_LINE_COLOR } : undefined}
|
||||
style={land ? { color: PORTAGE_CSS_COLOR } : undefined}
|
||||
/>
|
||||
{t(`tours.paddle.role.${role}`)}
|
||||
</span>
|
||||
@@ -233,7 +228,7 @@ export function PortageLegRow({
|
||||
<StopCard
|
||||
testId="tour-planner-portage-leg"
|
||||
glyph={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_LINE_COLOR}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
title={t('tours.paddle.portage')}
|
||||
pills={
|
||||
<>
|
||||
@@ -306,7 +301,6 @@ export function LockRow({
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
<StopField label={t('tours.paddle.lockWait')}>
|
||||
<MinutesInput
|
||||
testId="tour-planner-lock-wait"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { useTourWind, type TourWind } from './useTourWind';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -45,37 +46,6 @@ function directionName(degrees: number | null, t: Translate): string | null {
|
||||
return degrees === null ? null : t(`tours.paddle.wind.dir.${compassPointOf(degrees)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A figure of the day as a badge holding its value as a white badge, in the
|
||||
* planner's badge-in-badge look; what it means exactly is in its tooltip.
|
||||
*/
|
||||
function DayFigure({ label, value, icon, info, testId }: {
|
||||
label?: string;
|
||||
value: string;
|
||||
icon?: ReactNode;
|
||||
info: string;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={info}>
|
||||
<span
|
||||
data-testid={testId}
|
||||
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{label && <span className="ps-1">{label}</span>}
|
||||
<span
|
||||
className="inline-flex h-[18px] flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums text-content shadow-sm"
|
||||
style={fs(11)}
|
||||
>
|
||||
{icon}
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's hours as columns: the mean wind as a bar, the gusts as the paler
|
||||
* bar behind it, an arrow the way it blows on top. An hour the source left
|
||||
@@ -108,10 +78,16 @@ function HourStrip({ hours, t }: { hours: readonly WindHour[]; t: Translate }) {
|
||||
className={`relative flex h-12 w-full items-end justify-center overflow-hidden rounded-[3px] ${known ? 'bg-surface-secondary' : 'border border-dashed border-edge'}`}
|
||||
>
|
||||
{hour.gusts_kmh !== null && (
|
||||
<span className="absolute inset-x-0 bottom-0 bg-info-soft" style={{ height: height(hour.gusts_kmh) }} />
|
||||
<span
|
||||
className="absolute inset-x-0 bottom-0 bg-info-soft"
|
||||
style={{ height: height(hour.gusts_kmh) }}
|
||||
/>
|
||||
)}
|
||||
{known && (
|
||||
<span className="relative w-full rounded-t-[2px] bg-info" style={{ height: height(hour.speed_kmh) }} />
|
||||
<span
|
||||
className="relative w-full rounded-t-[2px] bg-info"
|
||||
style={{ height: height(hour.speed_kmh) }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(9.5)}>
|
||||
@@ -134,20 +110,20 @@ function Forecast({ forecast, t, locale }: { forecast: WindForecastResponse; t:
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{from !== null && (
|
||||
<DayFigure
|
||||
<FigureBadge
|
||||
testId="paddle-wind-direction"
|
||||
value={directionName(from, t) ?? ''}
|
||||
icon={<FlowArrow from={from} size={11} />}
|
||||
info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })}
|
||||
/>
|
||||
)}
|
||||
<DayFigure
|
||||
<FigureBadge
|
||||
testId="paddle-wind-mean"
|
||||
label={t('tours.paddle.wind.mean')}
|
||||
value={day?.speed_max_kmh == null ? '–' : `${day.speed_max_kmh} km/h`}
|
||||
info={t('tours.paddle.wind.meanInfo')}
|
||||
/>
|
||||
<DayFigure
|
||||
<FigureBadge
|
||||
testId="paddle-wind-gusts"
|
||||
label={t('tours.paddle.wind.gusts')}
|
||||
value={day?.gusts_max_kmh == null ? '–' : `${day.gusts_max_kmh} km/h`}
|
||||
@@ -198,8 +174,9 @@ function Unavailable({ wind, t, locale }: { wind: TourWind; t: Translate; locale
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
const key = state.kind === 'ready' ? 'tours.paddle.wind.past' : `tours.paddle.wind.${state.kind}`;
|
||||
return <TourNotice icon={<Wind size={13} strokeWidth={2} />}>{t(key)}</TourNotice>;
|
||||
// A day without a forecast to ask for: past, not on a day, a day without a date, a tour without a start.
|
||||
const notice = state.kind === 'ready' ? t('tours.paddle.wind.past') : t(`tours.paddle.wind.${state.kind}`);
|
||||
return <TourNotice icon={<Wind size={13} strokeWidth={2} />}>{notice}</TourNotice>;
|
||||
}
|
||||
|
||||
/** What wind means for a paddler, in general terms; it rates no passage and clears nothing. */
|
||||
@@ -215,7 +192,11 @@ function WindHints({ t }: { t: Translate }) {
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{t('tours.paddle.wind.hintsTitle')}</span>
|
||||
<ChevronDown size={13} strokeWidth={2.2} className={`flex-none transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="m-0 mt-2 flex list-disc flex-col gap-1.5 ps-4 text-content-muted" style={fs(11.5, 'body')}>
|
||||
@@ -263,7 +244,11 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
}
|
||||
>
|
||||
<div data-testid="paddle-wind-card" className="flex flex-col gap-3">
|
||||
{ready ? <Forecast forecast={ready} t={t} locale={locale} /> : <Unavailable wind={wind} t={t} locale={locale} />}
|
||||
{ready ? (
|
||||
<Forecast forecast={ready} t={t} locale={locale} />
|
||||
) : (
|
||||
<Unavailable wind={wind} t={t} locale={locale} />
|
||||
)}
|
||||
<WindHints t={t} />
|
||||
</div>
|
||||
</RailCard>
|
||||
@@ -271,7 +256,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
}
|
||||
|
||||
/** The wind card for one tour: its days read from the trip, its start as the place the forecast is for. */
|
||||
export function PaddleWindSection({ placeId, start }: { placeId: number | null; start: { lat: number; lng: number } | null }) {
|
||||
export function PaddleWindSection({
|
||||
placeId,
|
||||
start,
|
||||
}: {
|
||||
placeId: number | null;
|
||||
start: { lat: number; lng: number } | null;
|
||||
}) {
|
||||
const wind = useTourWind(placeId, start);
|
||||
return <PaddleWindCard wind={wind} />;
|
||||
}
|
||||
|
||||
@@ -69,10 +69,7 @@ function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) {
|
||||
const pick = (kind: TrackPick) => () => plan.startPick(kind);
|
||||
const empty = plan.marks.portages.length === 0 && plan.marks.locks.length === 0;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<PaddleGlyph kind="portage" size={14} />}
|
||||
title={t('tours.paddle.marks.title')}
|
||||
>
|
||||
<RailCard icon={<PaddleGlyph kind="portage" size={14} />} title={t('tours.paddle.marks.title')}>
|
||||
<div data-testid="track-marks-card" className="flex flex-col gap-2.5">
|
||||
{plan.canEdit && (
|
||||
<div className="flex gap-1.5">
|
||||
@@ -97,7 +94,10 @@ function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) {
|
||||
</div>
|
||||
)}
|
||||
{hint && (
|
||||
<TourNotice tone={plan.refused ? 'warning' : 'neutral'} icon={<MousePointerClick size={13} strokeWidth={2} />}>
|
||||
<TourNotice
|
||||
tone={plan.refused ? 'warning' : 'neutral'}
|
||||
icon={<MousePointerClick size={13} strokeWidth={2} />}
|
||||
>
|
||||
{hint}
|
||||
</TourNotice>
|
||||
)}
|
||||
|
||||
@@ -5,16 +5,7 @@ import type { PaddleObstacleKind } from '@trek/shared';
|
||||
* React glyph and the map markers (Leaflet takes markup, not components).
|
||||
*/
|
||||
export type PaddleGlyphKind =
|
||||
| 'lock'
|
||||
| 'weir'
|
||||
| 'dam'
|
||||
| 'rapids'
|
||||
| 'waterfall'
|
||||
| 'put_in'
|
||||
| 'take_out'
|
||||
| 'portage'
|
||||
| 'hazard'
|
||||
| 'access';
|
||||
'lock' | 'weir' | 'dam' | 'rapids' | 'waterfall' | 'put_in' | 'take_out' | 'portage' | 'hazard' | 'access';
|
||||
|
||||
const WAVE = 'M3 18c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PaddleFeature, PaddleObstacleKind, RelatedObstacle } from '@trek/shared';
|
||||
import type { PaddleFeature, RelatedObstacle } from '@trek/shared';
|
||||
import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg, type PaddleGlyphKind } from './paddleGlyphPaths';
|
||||
import { paddleKindPaint, type PaddlePaint } from './paddlePaint';
|
||||
|
||||
/**
|
||||
* PAD-05B: the paddle obstacles as the planner maps draw them, one model for
|
||||
@@ -10,34 +11,15 @@ import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg, type PaddleGlyphKin
|
||||
*/
|
||||
|
||||
export type PaddleMapRelation = 'on_route' | 'near' | 'area';
|
||||
type Category = 'obstacle' | 'access' | 'landing' | 'portage';
|
||||
|
||||
const CATEGORY: Record<PaddleObstacleKind, Category> = {
|
||||
weir: 'obstacle',
|
||||
dam: 'obstacle',
|
||||
lock: 'obstacle',
|
||||
lock_gate: 'obstacle',
|
||||
waterfall: 'obstacle',
|
||||
rapids: 'obstacle',
|
||||
hazard: 'obstacle',
|
||||
access: 'access',
|
||||
put_in: 'landing',
|
||||
egress: 'landing',
|
||||
slipway: 'landing',
|
||||
portage: 'portage',
|
||||
};
|
||||
|
||||
export const CATEGORY_COLOR: Record<Category, string> = {
|
||||
obstacle: '#334155', // theme-lint-disable map colour
|
||||
access: '#4338ca', // theme-lint-disable map colour
|
||||
landing: '#0f766e', // theme-lint-disable map colour
|
||||
portage: '#9a6233', // theme-lint-disable map colour
|
||||
};
|
||||
|
||||
export interface PaddleMapItem {
|
||||
feature: PaddleFeature;
|
||||
relation: PaddleMapRelation;
|
||||
/** Its kind's colour, resolved for map paint. */
|
||||
color: string;
|
||||
/** The marker's disc and the lines' casing, in the map's own white (or dark). */
|
||||
disc: string;
|
||||
shadow: string;
|
||||
glyph: PaddleGlyphKind;
|
||||
/** Where its marker sits: the point itself, or the middle of its line. */
|
||||
marker: [number, number];
|
||||
@@ -65,14 +47,20 @@ function markerOf(feature: PaddleFeature, line: [number, number][] | null): [num
|
||||
}
|
||||
|
||||
/** The items to draw: the related ones as they stand to the route, the rest of the area quietly. */
|
||||
export function paddleMapItems(features: readonly PaddleFeature[], related: readonly RelatedObstacle[]): PaddleMapItem[] {
|
||||
export function paddleMapItems(
|
||||
features: readonly PaddleFeature[],
|
||||
related: readonly RelatedObstacle[],
|
||||
paint: PaddlePaint
|
||||
): PaddleMapItem[] {
|
||||
const relations = new Map(related.map((entry) => [entry.feature.ref, entry.relation]));
|
||||
return features.map((feature) => {
|
||||
const line = lineOf(feature);
|
||||
return {
|
||||
feature,
|
||||
relation: relations.get(feature.ref) ?? 'area',
|
||||
color: CATEGORY_COLOR[CATEGORY[feature.kind]],
|
||||
color: paddleKindPaint(feature.kind, paint),
|
||||
disc: paint.disc,
|
||||
shadow: paint.shadow,
|
||||
glyph: OBSTACLE_GLYPH[feature.kind],
|
||||
marker: markerOf(feature, line),
|
||||
line,
|
||||
@@ -80,6 +68,27 @@ export function paddleMapItems(features: readonly PaddleFeature[], related: read
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Objects that are only in the area show from this zoom on, on Leaflet and GL
|
||||
* alike, so an overview of a long tour stays calm; the ones on or near the
|
||||
* route always show, and so does a picked one.
|
||||
*/
|
||||
export const PADDLE_AREA_MIN_ZOOM = 13;
|
||||
|
||||
/** Whether an item is drawn as one of the area's quiet objects, which wait for PADDLE_AREA_MIN_ZOOM. */
|
||||
export function isAreaOnly(item: PaddleMapItem, selectedRef: string | null): boolean {
|
||||
return item.relation === 'area' && item.feature.ref !== selectedRef;
|
||||
}
|
||||
|
||||
/** The items a map at `zoom` shows. */
|
||||
export function paddleItemsAtZoom(
|
||||
items: readonly PaddleMapItem[],
|
||||
zoom: number,
|
||||
selectedRef: string | null
|
||||
): PaddleMapItem[] {
|
||||
return zoom >= PADDLE_AREA_MIN_ZOOM ? [...items] : items.filter((item) => !isAreaOnly(item, selectedRef));
|
||||
}
|
||||
|
||||
/** The marker's size in pixels by how it stands to the route; a selected one is larger still. */
|
||||
export function markerSize(relation: PaddleMapRelation, selected: boolean): number {
|
||||
if (selected) return 36;
|
||||
@@ -97,7 +106,7 @@ export function paddleMarkerHtml(item: PaddleMapItem, selected: boolean): string
|
||||
const dash = item.relation === 'near' && !selected ? 'dashed' : 'solid';
|
||||
const opacity = item.relation === 'area' && !selected ? 0.6 : 1;
|
||||
const glyph = paddleGlyphSvg(item.glyph, Math.round(size * 0.58), item.color);
|
||||
const style = `width:${size}px;height:${size}px;border-radius:9999px;background:#fff;border:${ring}px ${dash} ${item.color};box-shadow:0 1px 3px rgba(15,23,42,.35);display:grid;place-items:center;box-sizing:border-box;opacity:${opacity}`; // theme-lint-disable map marker on the map's own white
|
||||
const style = `width:${size}px;height:${size}px;border-radius:9999px;background:${item.disc};border:${ring}px ${dash} ${item.color};box-shadow:0 1px 3px ${item.shadow};display:grid;place-items:center;box-sizing:border-box;opacity:${opacity}`;
|
||||
return `<div style="${style}">${glyph}</div>`;
|
||||
}
|
||||
|
||||
@@ -117,12 +126,13 @@ export function paddleMarkerSvg(item: PaddleMapItem, selected: boolean): string
|
||||
const paths = PADDLE_GLYPH_PATHS[item.glyph].map((d) => `<path d="${d}"/>`).join('');
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" opacity="${opacity}">` +
|
||||
`<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="#fff" stroke="${item.color}" stroke-width="${ring}"${dash}/>` + // theme-lint-disable marker disc
|
||||
`<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="${item.disc}" stroke="${item.color}" stroke-width="${ring}"${dash}/>` +
|
||||
`<svg x="${offset}" y="${offset}" width="${glyph}" height="${glyph}" viewBox="0 0 24 24" fill="none" stroke="${item.color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg></svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/** The name of a marker's image on a GL map, one per look. */
|
||||
/** The name of a marker's image on a GL map, one per look; a theme change draws new ones. */
|
||||
export function paddleMarkerImageId(item: PaddleMapItem, selected: boolean): string {
|
||||
return `trek-paddle-${item.glyph}-${item.color.slice(1)}-${item.relation}${selected ? '-sel' : ''}`;
|
||||
const tint = `${item.color}-${item.disc}`.replace(/[^a-z0-9]/gi, '');
|
||||
return `trek-paddle-${item.glyph}-${tint}-${item.relation}${selected ? '-sel' : ''}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
PADDLE_BLOCKING_KINDS,
|
||||
paddleAccessOf,
|
||||
type PaddleAccess,
|
||||
type PaddleCoverageRegion,
|
||||
type PaddleFeature,
|
||||
type PaddleObstacleKind,
|
||||
type RelatedObstacle,
|
||||
} from '@trek/shared';
|
||||
import type { PaddleMapRelation } from './paddleMapLayer';
|
||||
|
||||
/**
|
||||
* PAD-05B: what the obstacle card and the route band show of one paddle
|
||||
* object, worked out of the obstacle store without React. Nothing here turns
|
||||
* a missing value into a permission: an object without an access tag says it
|
||||
* has none, and an object only near the route stays "near", never "on it".
|
||||
*/
|
||||
|
||||
/** The tags the card names, each with a label of its own; everything else of the kept tags stays behind. */
|
||||
export const OBSTACLE_FACT_KEYS = [
|
||||
'height',
|
||||
'width',
|
||||
'whitewater:section_grade',
|
||||
'opening_hours',
|
||||
'seasonal',
|
||||
'fee',
|
||||
'operator',
|
||||
] as const;
|
||||
export type ObstacleFactKey = (typeof OBSTACLE_FACT_KEYS)[number];
|
||||
|
||||
/** The access values the card puts into words; any other value is shown as OSM has it. */
|
||||
export const KNOWN_ACCESS_VALUES = ['yes', 'no', 'private', 'permissive', 'designated'] as const;
|
||||
|
||||
export interface PaddleObstacleDetail {
|
||||
feature: PaddleFeature;
|
||||
relation: PaddleMapRelation;
|
||||
/** Metres between the route and the object; null for an object only in the area. */
|
||||
distanceM: number | null;
|
||||
/** Metres from the start of the route to where it comes closest; null for an object only in the area. */
|
||||
alongM: number | null;
|
||||
/** The date the object's region is current to, ISO 8601; null when unknown. */
|
||||
dataAsOf: string | null;
|
||||
access: PaddleAccess;
|
||||
/** A carrying way someone recorded as private: never a way round that is open to anyone. */
|
||||
privateWay: boolean;
|
||||
facts: { key: ObstacleFactKey; value: string }[];
|
||||
/** The free text OSM holds about it, shown as written. */
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** The object in OpenStreetMap, for the source link. */
|
||||
export function osmUrlOf(ref: string): string {
|
||||
return `https://www.openstreetmap.org/${ref}`;
|
||||
}
|
||||
|
||||
/** The one an obstacle checklist decides about: a lock's parts together, everything else on its own. */
|
||||
export function obstacleKeyOf(feature: Pick<PaddleFeature, 'ref' | 'group_key'>): string {
|
||||
return feature.group_key ?? feature.ref;
|
||||
}
|
||||
|
||||
export function isBlockingKind(kind: PaddleObstacleKind): boolean {
|
||||
return PADDLE_BLOCKING_KINDS.includes(kind);
|
||||
}
|
||||
|
||||
/** The picked object with everything the card says about it, or null when nothing (or nothing known) is picked. */
|
||||
export function paddleObstacleDetail(state: {
|
||||
features: readonly PaddleFeature[];
|
||||
related: readonly RelatedObstacle[];
|
||||
regions: readonly PaddleCoverageRegion[];
|
||||
selectedRef: string | null;
|
||||
}): PaddleObstacleDetail | null {
|
||||
const feature = state.selectedRef ? state.features.find((entry) => entry.ref === state.selectedRef) : undefined;
|
||||
if (!feature) return null;
|
||||
const related = state.related.find((entry) => entry.feature.ref === feature.ref);
|
||||
const tags = feature.tags;
|
||||
const access = paddleAccessOf(tags);
|
||||
const portageAccess = tags['portage:access'] ?? tags.access;
|
||||
return {
|
||||
feature,
|
||||
relation: related?.relation ?? 'area',
|
||||
distanceM: related ? related.distanceM : null,
|
||||
alongM: related ? related.alongM : null,
|
||||
dataAsOf: state.regions.find((region) => region.id === feature.region_id)?.source_timestamp ?? null,
|
||||
access,
|
||||
privateWay: feature.kind === 'portage' && (access.private || portageAccess === 'private'),
|
||||
facts: OBSTACLE_FACT_KEYS.flatMap((key) => {
|
||||
const value = tags[key]?.trim();
|
||||
return value ? [{ key, value }] : [];
|
||||
}),
|
||||
note: tags.description?.trim() || tags.note?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RouteStripObstacle {
|
||||
ref: string;
|
||||
kind: PaddleObstacleKind;
|
||||
name: string | null;
|
||||
relation: 'on_route' | 'near';
|
||||
/** Where it sits on the band, in the band's own metres. */
|
||||
atM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The obstacles to mark on the route band: the ones a paddler has to decide
|
||||
* about, on the route or near it, placed by their share of the measured route
|
||||
* so they sit right on a band drawn to another length. Access rules run along
|
||||
* whole stretches and are left to the map and the card.
|
||||
*/
|
||||
export function routeStripObstacles(
|
||||
related: readonly RelatedObstacle[],
|
||||
measuredLengthM: number,
|
||||
bandLengthM: number
|
||||
): RouteStripObstacle[] {
|
||||
if (measuredLengthM <= 0 || bandLengthM <= 0) return [];
|
||||
return related.flatMap((entry) => {
|
||||
const { feature } = entry;
|
||||
if (feature.kind === 'access' || !isBlockingKind(feature.kind)) return [];
|
||||
const share = Math.min(1, Math.max(0, entry.alongM / measuredLengthM));
|
||||
return [
|
||||
{ ref: feature.ref, kind: feature.kind, name: feature.name, relation: entry.relation, atM: share * bandLengthM },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** A length tag as a number of metres when it is one ("1.5", "2"); null for anything with its own unit or words. */
|
||||
export function metresOf(value: string): number | null {
|
||||
return /^\d+(\.\d+)?$/.test(value) ? Number(value) : null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PaddleObstacleKind } from '@trek/shared';
|
||||
import { useMapPaint } from '../../Map/mapPaint';
|
||||
|
||||
/**
|
||||
* The colours of paddle objects and carried stretches (styles/paddle.css):
|
||||
* a kind of object has its colour, never one for safe or unsafe. Map paint
|
||||
* reads them resolved (`usePaddlePaint`); markup on the page takes the CSS
|
||||
* variable itself (`paddleKindCss`), which follows the theme on its own.
|
||||
*/
|
||||
export const PADDLE_PAINT_TOKENS = {
|
||||
obstacle: '--map-paddle-obstacle',
|
||||
access: '--map-paddle-access',
|
||||
landing: '--map-paddle-landing',
|
||||
portage: '--map-portage',
|
||||
disc: '--map-paddle-disc',
|
||||
shadow: '--map-paddle-shadow',
|
||||
} as const;
|
||||
|
||||
export type PaddlePaint = Record<keyof typeof PADDLE_PAINT_TOKENS, string>;
|
||||
type Category = 'obstacle' | 'access' | 'landing' | 'portage';
|
||||
|
||||
const CATEGORY: Record<PaddleObstacleKind, Category> = {
|
||||
weir: 'obstacle',
|
||||
dam: 'obstacle',
|
||||
lock: 'obstacle',
|
||||
lock_gate: 'obstacle',
|
||||
waterfall: 'obstacle',
|
||||
rapids: 'obstacle',
|
||||
hazard: 'obstacle',
|
||||
access: 'access',
|
||||
put_in: 'landing',
|
||||
egress: 'landing',
|
||||
slipway: 'landing',
|
||||
portage: 'portage',
|
||||
};
|
||||
|
||||
/** The resolved colour of a kind, for map paint. */
|
||||
export function paddleKindPaint(kind: PaddleObstacleKind, paint: PaddlePaint): string {
|
||||
return paint[CATEGORY[kind]];
|
||||
}
|
||||
|
||||
/** The colour of a kind as a CSS variable, for markup on the page (the card, the route band). */
|
||||
export function paddleKindCss(kind: PaddleObstacleKind): string {
|
||||
return `var(${PADDLE_PAINT_TOKENS[CATEGORY[kind]]})`;
|
||||
}
|
||||
|
||||
/** A carried stretch's brown, for markup on the page. */
|
||||
export const PORTAGE_CSS_COLOR = `var(${PADDLE_PAINT_TOKENS.portage})`;
|
||||
|
||||
/** The paddle colours resolved for map paint, again whenever the theme changes. */
|
||||
export function usePaddlePaint(): PaddlePaint {
|
||||
return useMapPaint(PADDLE_PAINT_TOKENS);
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
import type { ExpressionSpecification, Map, MapMouseEvent } from 'mapbox-gl';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { markerSize, paddleMarkerImageId, paddleMarkerSvg, type PaddleMapItem, type PaddleMapLayer } from './paddleMapLayer';
|
||||
import {
|
||||
isAreaOnly,
|
||||
markerSize,
|
||||
PADDLE_AREA_MIN_ZOOM,
|
||||
paddleMarkerImageId,
|
||||
paddleMarkerSvg,
|
||||
type PaddleMapItem,
|
||||
type PaddleMapLayer,
|
||||
} from './paddleMapLayer';
|
||||
|
||||
/** One map type for both engines, as the ferrata layer has: MapLibre answers every call made here. */
|
||||
export type PaddleGlMap = Map;
|
||||
|
||||
const SOURCE = 'trek-paddle';
|
||||
const CASING = 'trek-paddle-casing';
|
||||
const CASING_AREA = 'trek-paddle-casing-area';
|
||||
const LINE = 'trek-paddle-line';
|
||||
const LINE_NEAR = 'trek-paddle-line-near';
|
||||
const LINE_AREA = 'trek-paddle-line-area';
|
||||
const PINS = 'trek-paddle-pins';
|
||||
const LAYERS = [CASING, LINE, PINS];
|
||||
const PINS_AREA = 'trek-paddle-pins-area';
|
||||
/** Bottom to top, as they are added. */
|
||||
const LAYERS = [CASING_AREA, CASING, LINE_AREA, LINE, LINE_NEAR, PINS_AREA, PINS];
|
||||
/** The layers a click can land on, the markers first. */
|
||||
const HIT_LAYERS = [PINS, PINS_AREA, LINE, LINE_NEAR, LINE_AREA];
|
||||
|
||||
const WIDTH = { on_route: 4, near: 3, area: 2 } as const;
|
||||
const OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
|
||||
@@ -18,6 +33,7 @@ function collection(items: readonly PaddleMapItem[], selectedRef: string | null)
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const item of items) {
|
||||
const selected = item.feature.ref === selectedRef;
|
||||
const area = isAreaOnly(item, selectedRef);
|
||||
if (item.line) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
@@ -27,7 +43,9 @@ function collection(items: readonly PaddleMapItem[], selectedRef: string | null)
|
||||
color: item.color,
|
||||
width: WIDTH[item.relation],
|
||||
opacity: OPACITY[item.relation],
|
||||
near: item.relation === 'near',
|
||||
near: item.relation === 'near' && !selected,
|
||||
casing: item.disc,
|
||||
area,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,61 +56,112 @@ function collection(items: readonly PaddleMapItem[], selectedRef: string | null)
|
||||
ref: item.feature.ref,
|
||||
icon: paddleMarkerImageId(item, selected),
|
||||
order: selected ? 3 : item.relation === 'on_route' ? 2 : item.relation === 'near' ? 1 : 0,
|
||||
area,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
/** Draws each marker look the map does not have yet; a look is an image of its own. */
|
||||
function addImages(map: PaddleGlMap, items: readonly PaddleMapItem[], selectedRef: string | null, gone: () => boolean) {
|
||||
/**
|
||||
* Draws each marker look the map does not have yet, the picked look beside the
|
||||
* plain one, so picking an object never shows a marker whose image is still
|
||||
* loading.
|
||||
*/
|
||||
function addImages(map: PaddleGlMap, items: readonly PaddleMapItem[], gone: () => boolean) {
|
||||
for (const item of items) {
|
||||
const selected = item.feature.ref === selectedRef;
|
||||
const id = paddleMarkerImageId(item, selected);
|
||||
if (map.hasImage(id)) continue;
|
||||
const image = new Image(markerSize(item.relation, selected) * 2, markerSize(item.relation, selected) * 2);
|
||||
image.onload = () => {
|
||||
if (!gone() && !map.hasImage(id)) map.addImage(id, image, { pixelRatio: 2 });
|
||||
};
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`;
|
||||
for (const selected of [false, true]) {
|
||||
const id = paddleMarkerImageId(item, selected);
|
||||
if (map.hasImage(id)) continue;
|
||||
const size = markerSize(item.relation, selected) * 2;
|
||||
const image = new Image(size, size);
|
||||
image.onload = () => {
|
||||
if (!gone() && !map.hasImage(id)) map.addImage(id, image, { pixelRatio: 2 });
|
||||
};
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLine: ExpressionSpecification = ['==', ['geometry-type'], 'LineString'];
|
||||
const isPoint: ExpressionSpecification = ['==', ['geometry-type'], 'Point'];
|
||||
const inArea: ExpressionSpecification = ['==', ['get', 'area'], true];
|
||||
const notArea: ExpressionSpecification = ['!=', ['get', 'area'], true];
|
||||
const isNear: ExpressionSpecification = ['==', ['get', 'near'], true];
|
||||
const notNear: ExpressionSpecification = ['!=', ['get', 'near'], true];
|
||||
|
||||
function addLayers(map: PaddleGlMap, data: GeoJSON.FeatureCollection): void {
|
||||
map.addSource(SOURCE, { type: 'geojson', data });
|
||||
const lines: ['==', ['geometry-type'], string] = ['==', ['geometry-type'], 'LineString'];
|
||||
map.addLayer({
|
||||
const lineLayout = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
const casingPaint = {
|
||||
'line-color': ['get', 'casing'] as ExpressionSpecification,
|
||||
'line-width': ['+', ['get', 'width'], 3] as ExpressionSpecification,
|
||||
'line-opacity': ['get', 'opacity'] as ExpressionSpecification,
|
||||
};
|
||||
const linePaint = {
|
||||
'line-color': ['get', 'color'] as ExpressionSpecification,
|
||||
'line-width': ['get', 'width'] as ExpressionSpecification,
|
||||
'line-opacity': ['get', 'opacity'] as ExpressionSpecification,
|
||||
};
|
||||
const pinLayout = {
|
||||
'icon-image': ['get', 'icon'] as ExpressionSpecification,
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'symbol-sort-key': ['get', 'order'] as ExpressionSpecification,
|
||||
};
|
||||
// The objects only in the area wait for PADDLE_AREA_MIN_ZOOM; a hidden layer is also no click target.
|
||||
const area = { minzoom: PADDLE_AREA_MIN_ZOOM };
|
||||
const add = map.addLayer.bind(map);
|
||||
add({
|
||||
id: CASING_AREA,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
filter: ['all', isLine, inArea],
|
||||
layout: lineLayout,
|
||||
paint: casingPaint,
|
||||
...area,
|
||||
});
|
||||
add({
|
||||
id: CASING,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
filter: lines,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: {
|
||||
'line-color': '#ffffff', // theme-lint-disable map casing
|
||||
'line-width': ['+', ['get', 'width'], 3],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
filter: ['all', isLine, notArea],
|
||||
layout: lineLayout,
|
||||
paint: casingPaint,
|
||||
});
|
||||
map.addLayer({
|
||||
add({
|
||||
id: LINE_AREA,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
filter: ['all', isLine, inArea],
|
||||
layout: lineLayout,
|
||||
paint: linePaint,
|
||||
...area,
|
||||
});
|
||||
add({
|
||||
id: LINE,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
filter: lines,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': ['get', 'color'], 'line-width': ['get', 'width'], 'line-opacity': ['get', 'opacity'] },
|
||||
filter: ['all', isLine, notArea, notNear],
|
||||
layout: lineLayout,
|
||||
paint: linePaint,
|
||||
});
|
||||
map.addLayer({
|
||||
id: PINS,
|
||||
type: 'symbol',
|
||||
// A dash cannot follow a property, so the lines only near the route have a layer of their own, as on Leaflet.
|
||||
add({
|
||||
id: LINE_NEAR,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
filter: ['==', ['geometry-type'], 'Point'],
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'symbol-sort-key': ['get', 'order'],
|
||||
},
|
||||
filter: ['all', isLine, notArea, isNear],
|
||||
layout: lineLayout,
|
||||
paint: { ...linePaint, 'line-dasharray': [2, 1.6] },
|
||||
});
|
||||
add({ id: PINS_AREA, type: 'symbol', source: SOURCE, filter: ['all', isPoint, inArea], layout: pinLayout, ...area });
|
||||
add({ id: PINS, type: 'symbol', source: SOURCE, filter: ['all', isPoint, notArea], layout: pinLayout });
|
||||
}
|
||||
|
||||
/** The paddle layers this map has now; a style still loading or one rebuilt by a basemap change may have none. */
|
||||
function presentHitLayers(map: { getLayer?(id: string): unknown }): string[] {
|
||||
return HIT_LAYERS.filter((id) => map.getLayer?.(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,19 +170,23 @@ function addLayers(map: PaddleGlMap, data: GeoJSON.FeatureCollection): void {
|
||||
* also setting a waypoint under it.
|
||||
*/
|
||||
export function isPaddleHit<Point>(
|
||||
map: { getLayer?(id: string): unknown; queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[] },
|
||||
map: {
|
||||
getLayer?(id: string): unknown;
|
||||
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
|
||||
},
|
||||
point: Point
|
||||
): boolean {
|
||||
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
|
||||
const layers = [PINS, LINE].filter((id) => map.getLayer?.(id));
|
||||
const layers = presentHitLayers(map);
|
||||
return layers.length > 0 && map.queryRenderedFeatures(point, { layers }).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05B on a GL map (MapLibre or Mapbox, the 2D planner and the 3D
|
||||
* terrain alike): the paddle objects as lines on a white casing and as round
|
||||
* glyph markers, in the same look as on Leaflet. Kept through a basemap
|
||||
* change, which rebuilds the style and drops them, and removed with the layer.
|
||||
* glyph markers, in the same look as on Leaflet, the ones only in the area
|
||||
* from PADDLE_AREA_MIN_ZOOM on. Kept through a basemap change, which rebuilds
|
||||
* the style and drops them, and removed with the layer.
|
||||
*/
|
||||
export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer | undefined) {
|
||||
const onSelect = useRef(layer?.onSelect);
|
||||
@@ -127,12 +200,16 @@ export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer
|
||||
if (!map || !items?.length) return;
|
||||
let gone = false;
|
||||
const data = collection(items, selectedRef);
|
||||
let applied: GeoJSON.FeatureCollection | null = null;
|
||||
const apply = () => {
|
||||
try {
|
||||
addImages(map, items, selectedRef, () => gone);
|
||||
const source = map.getSource(SOURCE) as { setData?: (next: GeoJSON.FeatureCollection) => void } | undefined;
|
||||
// Every image the map takes fires styledata again; data already handed over is not sent twice.
|
||||
if (source?.setData && applied === data) return;
|
||||
addImages(map, items, () => gone);
|
||||
if (source?.setData) source.setData(data);
|
||||
else addLayers(map, data);
|
||||
applied = data;
|
||||
} catch {
|
||||
// A style still loading takes the layers on its next styledata event.
|
||||
}
|
||||
@@ -147,8 +224,12 @@ export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const click = (event: { features?: Array<{ properties?: { ref?: unknown } }> }) => {
|
||||
const ref = event.features?.[0]?.properties?.ref;
|
||||
// One handler for all of them, so a marker over a line picks the marker's object, never both.
|
||||
const click = (event: MapMouseEvent) => {
|
||||
const layers = presentHitLayers(map);
|
||||
if (layers.length === 0) return;
|
||||
const [hit] = map.queryRenderedFeatures(event.point, { layers });
|
||||
const ref: unknown = hit?.properties?.ref;
|
||||
if (typeof ref === 'string') onSelect.current?.(ref);
|
||||
};
|
||||
const enter = () => {
|
||||
@@ -157,14 +238,14 @@ export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer
|
||||
const leave = () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
};
|
||||
for (const id of [PINS, LINE]) {
|
||||
map.on('click', id, click);
|
||||
map.on('click', click);
|
||||
for (const id of HIT_LAYERS) {
|
||||
map.on('mouseenter', id, enter);
|
||||
map.on('mouseleave', id, leave);
|
||||
}
|
||||
return () => {
|
||||
for (const id of [PINS, LINE]) {
|
||||
map.off('click', id, click);
|
||||
map.off('click', click);
|
||||
for (const id of HIT_LAYERS) {
|
||||
map.off('mouseenter', id, enter);
|
||||
map.off('mouseleave', id, leave);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { relateObstaclesToRoute, type PaddleCoverageState } from '@trek/shared';
|
||||
import { measuredRouteLengthM, relateObstaclesToRoute, type PaddleCoverageState } from '@trek/shared';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
PaddleObstaclesOfflineError,
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type PaddleObstaclesAround,
|
||||
type Tile,
|
||||
} from '../../../repo/paddleObstacleRepo';
|
||||
import { clearPaddleObstacles, publishPaddleObstacles, type PaddleObstacleStatus } from '../../../store/paddleObstacleStore';
|
||||
import {
|
||||
clearPaddleObstacles,
|
||||
publishPaddleObstacles,
|
||||
type PaddleObstacleStatus,
|
||||
} from '../../../store/paddleObstacleStore';
|
||||
|
||||
type Line = readonly (readonly [number, number])[];
|
||||
|
||||
@@ -59,6 +63,7 @@ export function usePaddleObstacleSync(line: Line | null, active: boolean) {
|
||||
() => (current?.data && line ? relateObstaclesToRoute(line, current.data.features) : []),
|
||||
[current, line]
|
||||
);
|
||||
const routeLengthM = useMemo(() => (line ? measuredRouteLengthM(line) : 0), [line]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
@@ -70,11 +75,12 @@ export function usePaddleObstacleSync(line: Line | null, active: boolean) {
|
||||
status: current?.status ?? 'loading',
|
||||
features: current?.data?.features ?? [],
|
||||
related,
|
||||
routeLengthM,
|
||||
coverage,
|
||||
regions: current?.data?.regions ?? [],
|
||||
truncated: current?.data?.truncated ?? false,
|
||||
});
|
||||
}, [current, key, related]);
|
||||
}, [current, key, related, routeLengthM]);
|
||||
|
||||
useEffect(() => () => clearPaddleObstacles(), []);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,11 @@ export function useTrackPaddlePlan({ tripId, planner, canEdit, onSaved }: TrackP
|
||||
if (!paddleTour || saving || !canEdit) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { paddle_speed_kmh: state.speeds.paddle, portage_speed_kmh: state.speeds.portage, track_marks: state.marks };
|
||||
const body = {
|
||||
paddle_speed_kmh: state.speeds.paddle,
|
||||
portage_speed_kmh: state.speeds.portage,
|
||||
track_marks: state.marks,
|
||||
};
|
||||
const result = await tourRepo.updatePaddlePlan(tripId, paddleTour.place_id, body);
|
||||
markTrackMarksSaved(result.tour.track_marks, {
|
||||
paddle: result.tour.paddle_speed_kmh ?? null,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTrackMarkToolStore } from '../../../store/trackMarkToolStore';
|
||||
import { splitTrackByPortages } from '../../../utils/paddleTrackMarks';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import type { TourRoutePart } from '../planner/tourRouting';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
|
||||
/**
|
||||
* The recorded paddle track in view, cut where portages are marked on it, as
|
||||
|
||||
@@ -32,7 +32,10 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lake' }));
|
||||
expect(paddleTour.paddle.setBoatType).toHaveBeenCalledWith('canoe');
|
||||
expect(paddleTour.paddle.setWaterContext).toHaveBeenCalledWith('lake');
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
// Two notes speak here: what the tour still needs, and what is known about obstacles on the water.
|
||||
const notes = screen.getAllByRole('status').map((note) => note.textContent);
|
||||
expect(notes).toContain('Choose the boat and the water to save this paddle tour.');
|
||||
expect(notes).toContain('Loading the obstacles along the route …');
|
||||
});
|
||||
|
||||
it('reads a typed speed with either decimal separator, and an empty field as the suggestion', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type PaddleWaterContext,
|
||||
type PlannableTourType,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, Info, Mountain, Waves } from 'lucide-react';
|
||||
import { Info, Mountain, Waves } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
|
||||
@@ -24,13 +24,13 @@ import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPla
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
||||
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
@@ -324,7 +324,12 @@ function WaypointRowActions({
|
||||
const portage = point.fixedLeg?.kind === 'portage';
|
||||
const hasLeg = index < lastIndex;
|
||||
const items = [
|
||||
{ label: t('tours.planner.moveUp'), icon: ArrowUp, disabled: index === 0, onClick: () => planner.moveWaypoint(point.id, -1) },
|
||||
{
|
||||
label: t('tours.planner.moveUp'),
|
||||
icon: ArrowUp,
|
||||
disabled: index === 0,
|
||||
onClick: () => planner.moveWaypoint(point.id, -1),
|
||||
},
|
||||
{
|
||||
label: t('tours.planner.moveDown'),
|
||||
icon: ArrowDown,
|
||||
@@ -332,21 +337,26 @@ function WaypointRowActions({
|
||||
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),
|
||||
}]
|
||||
? [
|
||||
{ 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(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'),
|
||||
icon: PortageIcon,
|
||||
onClick: () => paddle.togglePortage(point.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleLockSchema, plannableTourTypeSchema, type PlannableTourType, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import type { PaddleFieldValues } from './usePaddleFields'
|
||||
import type { TourPlannerWaypoint } from './useTourPlanner'
|
||||
import {
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
paddleLockSchema,
|
||||
plannableTourTypeSchema,
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
} from '@trek/shared';
|
||||
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
|
||||
@@ -9,59 +15,71 @@ import type { TourPlannerWaypoint } from './useTourPlanner'
|
||||
* half read.
|
||||
*/
|
||||
export interface DraftPayload {
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||
name: string
|
||||
description?: string
|
||||
website?: string
|
||||
plannedDurationMinutes?: number | null
|
||||
breakAdditionalMinutes?: number | null
|
||||
waypoints: TourPlannerWaypoint[]
|
||||
maxHikingDifficulty?: TourMaxHikingDifficulty
|
||||
editingPlaceId?: number | null
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
name: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
plannedDurationMinutes?: number | null;
|
||||
breakAdditionalMinutes?: number | null;
|
||||
waypoints: TourPlannerWaypoint[];
|
||||
maxHikingDifficulty?: TourMaxHikingDifficulty;
|
||||
editingPlaceId?: number | null;
|
||||
/** The import a new tour is being planned from; carried until it is saved. */
|
||||
derivedFromPlaceId?: number | null
|
||||
tourType?: PlannableTourType
|
||||
paddle?: PaddleFieldValues
|
||||
derivedFromPlaceId?: number | null;
|
||||
tourType?: PlannableTourType;
|
||||
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];
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string'
|
||||
return value === undefined || typeof value === 'string';
|
||||
}
|
||||
|
||||
function optionalMinutes(value: number | null | undefined): boolean {
|
||||
return value === undefined || value === null
|
||||
|| (Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
);
|
||||
}
|
||||
|
||||
function optionalPlaceId(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value > 0)
|
||||
return value == null || (Number.isInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
function optionalDifficulty(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6)
|
||||
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6);
|
||||
}
|
||||
|
||||
function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
|
||||
return !!point && typeof point.id === 'string'
|
||||
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
|
||||
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180
|
||||
&& (point.lock == null || paddleLockSchema.safeParse(point.lock).success)
|
||||
return (
|
||||
!!point &&
|
||||
typeof point.id === 'string' &&
|
||||
Number.isFinite(point.lat) &&
|
||||
Math.abs(point.lat) <= 90 &&
|
||||
Number.isFinite(point.lng) &&
|
||||
Math.abs(point.lng) <= 180 &&
|
||||
(point.lock == null || paddleLockSchema.safeParse(point.lock).success)
|
||||
);
|
||||
}
|
||||
|
||||
export function validDraft(value: unknown): value is DraftPayload {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const draft = value as Partial<DraftPayload>
|
||||
return draft.version !== undefined && DRAFT_VERSIONS.includes(draft.version) && typeof draft.name === 'string'
|
||||
&& optionalString(draft.description)
|
||||
&& optionalString(draft.website)
|
||||
&& optionalMinutes(draft.plannedDurationMinutes)
|
||||
&& optionalMinutes(draft.breakAdditionalMinutes)
|
||||
&& optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId)
|
||||
&& optionalDifficulty(draft.maxHikingDifficulty)
|
||||
&& (draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success)
|
||||
&& Array.isArray(draft.waypoints)
|
||||
&& draft.waypoints.every(validDraftWaypoint)
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const draft = value as Partial<DraftPayload>;
|
||||
return (
|
||||
draft.version !== undefined &&
|
||||
DRAFT_VERSIONS.includes(draft.version) &&
|
||||
typeof draft.name === 'string' &&
|
||||
optionalString(draft.description) &&
|
||||
optionalString(draft.website) &&
|
||||
optionalMinutes(draft.plannedDurationMinutes) &&
|
||||
optionalMinutes(draft.breakAdditionalMinutes) &&
|
||||
optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId) &&
|
||||
optionalDifficulty(draft.maxHikingDifficulty) &&
|
||||
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
|
||||
Array.isArray(draft.waypoints) &&
|
||||
draft.waypoints.every(validDraftWaypoint)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFre
|
||||
}
|
||||
return {
|
||||
...kept,
|
||||
fixedLeg: isPortage(leg) ? portageLegBetween(point, next, leg.extra_minutes ?? null) : freeLegBetween(point, next),
|
||||
fixedLeg: isPortage(leg)
|
||||
? portageLegBetween(point, next, leg.extra_minutes ?? null)
|
||||
: freeLegBetween(point, next),
|
||||
};
|
||||
});
|
||||
return allFree ? extrasOnFirstLeg(aligned) : aligned;
|
||||
@@ -170,7 +172,9 @@ export function setPortageExtraMinutes<T extends RoutePoint>(
|
||||
minutes: number | null
|
||||
): T[] {
|
||||
return points.map((point) =>
|
||||
point.id === id && isPortage(point.fixedLeg) ? { ...point, fixedLeg: { ...point.fixedLeg, extra_minutes: minutes } } : point
|
||||
point.id === id && isPortage(point.fixedLeg)
|
||||
? { ...point, fixedLeg: { ...point.fixedLeg, extra_minutes: minutes } }
|
||||
: point
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
/**
|
||||
* PAD-03: a portage on the map, in an earth brown over a white rim, dotted the
|
||||
* way the map draws a walk: the boat is carried there, not paddled. Map
|
||||
* colours are literals by design.
|
||||
* 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`).
|
||||
*/
|
||||
export const PORTAGE_LINE_COLOR = '#9a6233'; // theme-lint-disable map colour of a carried stretch
|
||||
const PORTAGE_CASING_COLOR = '#ffffff'; // theme-lint-disable rim under the carried stretch
|
||||
export interface PortagePaint {
|
||||
line: string;
|
||||
casing: string;
|
||||
}
|
||||
|
||||
export interface TourRouteStyle {
|
||||
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
|
||||
@@ -21,10 +23,14 @@ export interface TourRouteStyle {
|
||||
* 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.
|
||||
*/
|
||||
export function tourRouteStyle(parts: readonly TourRoutePart[] | null, isPaddle: boolean): TourRouteStyle {
|
||||
export function tourRouteStyle(
|
||||
parts: readonly TourRoutePart[] | null,
|
||||
isPaddle: boolean,
|
||||
portage: PortagePaint
|
||||
): 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_COLOR, casing: PORTAGE_CASING_COLOR } : undefined));
|
||||
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined));
|
||||
return {
|
||||
walking: walking.some(Boolean) ? walking : null,
|
||||
colors: colors.some(Boolean) ? colors : null,
|
||||
|
||||
@@ -15,7 +15,12 @@ export interface PaddleFieldValues {
|
||||
portageSpeedKmh?: number | null;
|
||||
}
|
||||
|
||||
export const NO_PADDLE_FIELDS: PaddleFieldValues = { boatType: null, waterContext: null, speedKmh: null, portageSpeedKmh: null };
|
||||
export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
||||
boatType: null,
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
};
|
||||
|
||||
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
||||
export function paddleFieldsOf(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, plannableTourTypeSchema, 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 TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { isFerrataHit } from '../../Ferratas/useFerrataLayerGL';
|
||||
import { isPaddleHit } from '../paddle/usePaddleLayerGL';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
|
||||
import {
|
||||
addTerrainLayers,
|
||||
DEM_SOURCE,
|
||||
@@ -70,9 +69,9 @@ function createTerrainMap(
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
// Clicking the terrain adds a control point, the same as on the flat planner map;
|
||||
// a click on a via ferrata opens its card instead.
|
||||
// a click on a via ferrata or a paddle obstacle opens its card instead.
|
||||
map.on('click', (event) => {
|
||||
if (isFerrataHit(map, event.point) || isPaddleHit(map, event.point)) return;
|
||||
if (isTourLayerHit(map, event.point)) return;
|
||||
scene.current.onAdd?.(event.lngLat.lat, event.lngLat.lng);
|
||||
});
|
||||
map.on('style.load', () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FerrataBbox } from '@trek/shared';
|
||||
import { type PaddleGlMap, usePaddleLayerGL } from '../paddle/usePaddleLayerGL';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
||||
import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer';
|
||||
import type { TourBaseLayer, TourLayerOffer } from '../../Map/MapLayerSwitcher';
|
||||
import { useRasterOverlaysGL, type RasterOverlayGlMap } from '../../Map/mapRasterOverlay';
|
||||
import { usePaddleLayerGL, type PaddleGlMap } from '../paddle/usePaddleLayerGL';
|
||||
import { trackData, type PanelWidths, type TrackColorMode } from './terrainLayers';
|
||||
import { useTerrainFerratas } from './useTerrainFerratas';
|
||||
import { frameCamera, useTerrainMap, type TerrainScene } from './useTerrainMap';
|
||||
|
||||
@@ -26,6 +26,7 @@ import '@fontsource/geist-sans/600.css'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
|
||||
// day reordering was dead on Android and iOS. The `drag-drop-touch` polyfill synthesises
|
||||
// the standard drag events from touch gestures over draggable elements (#1265). It is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog'
|
||||
import PaddleRegionsPanel from '../../../components/Admin/paddle/PaddleRegionsPanel'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -209,12 +210,15 @@ export default function MAdmin() {
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'addons' && (
|
||||
<MAdminAddonManager
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
onToggleBagTracking={toggleBagTracking}
|
||||
collabFeatures={collabFeatures}
|
||||
onToggleCollabFeature={toggleCollabFeature}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<MAdminAddonManager
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
onToggleBagTracking={toggleBagTracking}
|
||||
collabFeatures={collabFeatures}
|
||||
onToggleCollabFeature={toggleCollabFeature}
|
||||
/>
|
||||
<PaddleRegionsPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'plugins' && <MAdminPluginsPanel />}
|
||||
{activeTab === 'storage' && <MAdminStoragePanel />}
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock('../components/Admin/BackupPanel', () => ({
|
||||
vi.mock('../components/Admin/GitHubPanel', () => ({
|
||||
default: () => <div data-testid="github-panel" />,
|
||||
}));
|
||||
vi.mock('../components/Admin/paddle/PaddleRegionsPanel', () => ({
|
||||
default: () => <div data-testid="paddle-regions-panel" />,
|
||||
}));
|
||||
|
||||
// AddonManager is stubbed but keeps its two callbacks reachable — the page owns
|
||||
// the optimistic toggle logic behind them.
|
||||
@@ -221,6 +224,8 @@ describe('AdminPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
||||
|
||||
expect(screen.getByTestId('addon-manager')).toBeInTheDocument();
|
||||
// The paddle water data sit under the addons; the panel hides itself while Tours is off.
|
||||
expect(screen.getByTestId('paddle-regions-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SchoolHolidayCatalog from '../components/Admin/SchoolHolidayCatalog'
|
||||
import PaddleRegionsPanel from '../components/Admin/paddle/PaddleRegionsPanel'
|
||||
import React, { Fragment } from 'react'
|
||||
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
|
||||
import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab'
|
||||
@@ -157,6 +158,7 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
{activeTab === 'addons' && (
|
||||
<div className="space-y-6">
|
||||
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={toggleBagTracking} collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} />
|
||||
<PaddleRegionsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -137,10 +137,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tourGpxImport,
|
||||
wandererBrowser,
|
||||
viewImportedTour,
|
||||
tourConversion,
|
||||
tourDetailActions,
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
permissions: { canPlaceEdit, canDayEdit },
|
||||
tourDetails,
|
||||
tourMap,
|
||||
@@ -649,11 +646,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
transition: 'width 0.25s ease', opacity: leftHidden ? 0 : 1,
|
||||
}}>
|
||||
{!leftHidden && <PanelResizeHandle side="left" width={leftWidth} min={resizeMin} max={resizeMax} onStart={startResizeLeft} onNudge={nudgeLeft} />}
|
||||
<LazyPanel id="tour-planner-rail"><TourWorkspaceRail
|
||||
planner={tourPlanner}
|
||||
wanderer={wandererBrowser}
|
||||
conversion={tourConversion}
|
||||
recordedTrack={recordedTrack} trackPaddlePlan={trackPaddlePlan} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
|
||||
<LazyPanel id="tour-planner-rail"><TourWorkspaceRail workspace={pageState} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-[10px] right-[10px] top-[10px] z-20 hidden md:block">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FerrataBbox } from '@trek/shared';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay';
|
||||
import { crossingLegs, waypointsAtFerrataEnds } from '../../components/Ferratas/ferrataCrossings';
|
||||
import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay';
|
||||
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
|
||||
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
|
||||
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
||||
@@ -9,13 +9,15 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../componen
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
|
||||
import { isRtlLanguage, useTranslation } from '../../i18n';
|
||||
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../store/trackMarkToolStore';
|
||||
import { projectOntoTrack, TRACK_PICK_TOLERANCE_M } from '../../utils/paddleTrackMarks';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { useTourMapPoints } from './useTourMapPoints';
|
||||
import { useTourStageLayers } from './useTourStageLayers';
|
||||
import type { useTripPlanner } from './useTripPlanner';
|
||||
@@ -128,8 +130,9 @@ export function TourPlannerMapStage({
|
||||
);
|
||||
}, [analysis, ferrataCrossings, tourWaypoints, translate]);
|
||||
useEffect(() => () => publishTourFerrataCrossings([]), []);
|
||||
// The click that closes a ferrata's card must not also add a waypoint under it.
|
||||
// The click that closes a ferrata's or a paddle obstacle's card must not also add a waypoint under it.
|
||||
const closingPress = useOutsidePressGuard();
|
||||
const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null);
|
||||
const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close);
|
||||
const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]);
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
@@ -138,7 +141,7 @@ export function TourPlannerMapStage({
|
||||
tourPlanner,
|
||||
canPlaceEdit,
|
||||
mode: terrain3d ? '3d' : '2d',
|
||||
ferrataOpen: !!ferrataSelected,
|
||||
cardOpen: !!ferrataSelected || paddleOpen,
|
||||
guard: closingPress.guard,
|
||||
previewPoints: tourMap.previewPoints,
|
||||
});
|
||||
@@ -147,13 +150,15 @@ export function TourPlannerMapStage({
|
||||
const trackPick = useTrackMarkToolStore((state) => state.pick);
|
||||
const trackLine = tourPlanner.mode.type === 'view-gpx' ? analysis?.routeCoordinates : null;
|
||||
const pickOnLine = canPlaceEdit && trackPick !== null && !!trackLine?.length;
|
||||
const onTrackClick = pickOnLine
|
||||
? (lat: number, lng: number) => {
|
||||
const projected = projectOntoTrack(trackLine, { lat, lng });
|
||||
if (!projected || projected.offM > TRACK_PICK_TOLERANCE_M) refuseTrackPick();
|
||||
else pickOnTrack(projected.atM);
|
||||
}
|
||||
: undefined;
|
||||
const onTrackClick = closingPress.guard(
|
||||
pickOnLine
|
||||
? (lat: number, lng: number) => {
|
||||
const projected = projectOntoTrack(trackLine, { lat, lng });
|
||||
if (!projected || projected.offM > TRACK_PICK_TOLERANCE_M) refuseTrackPick();
|
||||
else pickOnTrack(projected.atM);
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
const onAddOrMark = onAdd ?? onTrackClick;
|
||||
// What each panel covers of the map, a collapsed one its rail.
|
||||
const leftInset = leftHidden ? 56 : leftPanelPx;
|
||||
@@ -236,6 +241,11 @@ export function TourPlannerMapStage({
|
||||
onAdopt={adoptFerrata}
|
||||
onTourIds={ferrataOnTour}
|
||||
/>
|
||||
<PaddleObstacleOverlay
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
|
||||
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
|
||||
/>
|
||||
{/* "Point set", with the way back, above the bottom row and between the panels. */}
|
||||
{placedHint.shown && (
|
||||
<div
|
||||
|
||||
@@ -33,9 +33,9 @@ interface Options {
|
||||
tourPlanner: PointsPlanner;
|
||||
canPlaceEdit: boolean;
|
||||
mode: TourMapMode;
|
||||
/** A via ferrata card is open: the "point set" hint gives way to it. */
|
||||
ferrataOpen: boolean;
|
||||
/** Drops the click that closed a ferrata card. */
|
||||
/** A card is open over the map (a via ferrata's, a paddle obstacle's): the "point set" hint gives way to it. */
|
||||
cardOpen: boolean;
|
||||
/** Drops the click that closed such a card. */
|
||||
guard: ReturnType<typeof useOutsidePressGuard>['guard'];
|
||||
/** Control points of an editable version previewed over an import: shown, never moved. */
|
||||
previewPoints?: readonly { lat: number; lng: number }[] | null;
|
||||
@@ -47,7 +47,7 @@ interface Options {
|
||||
* after a point went in. A click places a point only while the tool is on,
|
||||
* only when it was a click and not a drag, and never when it closed a card.
|
||||
*/
|
||||
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard, previewPoints }: Options) {
|
||||
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, guard, previewPoints }: Options) {
|
||||
const { t } = useTranslation();
|
||||
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
|
||||
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
|
||||
@@ -59,7 +59,7 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen,
|
||||
waypoints: tourPlanner.waypoints,
|
||||
difficulty: tourPlanner.maxHikingDifficulty,
|
||||
undo: tourPlanner.undo,
|
||||
enabled: editing && !ferrataOpen,
|
||||
enabled: editing && !cardOpen,
|
||||
});
|
||||
const { notePlacement } = placedHint;
|
||||
const { addWaypoint } = tourPlanner;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { paddleMapItems } from '../../components/Tours/paddle/paddleMapLayer';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
|
||||
import { paddleMapItems } from '../../components/Tours/paddle/paddleMapLayer';
|
||||
import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint';
|
||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers';
|
||||
import { useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
|
||||
const NO_CROSSINGS: never[] = [];
|
||||
|
||||
@@ -38,7 +39,8 @@ export function useTourStageLayers(
|
||||
const { features, related, selectedRef } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({ features: state.features, related: state.related, selectedRef: state.selectedRef }))
|
||||
);
|
||||
const paddleItems = useMemo(() => paddleMapItems(features, related), [features, related]);
|
||||
const paddlePaint = usePaddlePaint();
|
||||
const paddleItems = useMemo(() => paddleMapItems(features, related, paddlePaint), [features, related, paddlePaint]);
|
||||
const paddle = useMemo(
|
||||
() => (paddleItems.length ? { items: paddleItems, selectedRef, onSelect: selectPaddleObstacle } : undefined),
|
||||
[paddleItems, selectedRef]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { useCallback } from 'react';
|
||||
import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion';
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
|
||||
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
|
||||
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
|
||||
import { placeActions } from '../../components/Planner/usePlaceActions'
|
||||
import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle'
|
||||
import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint'
|
||||
import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPortageParts'
|
||||
import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync'
|
||||
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
|
||||
@@ -96,9 +97,11 @@ export function useTripPlannerPage() {
|
||||
])
|
||||
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.
|
||||
const paddlePaint = usePaddlePaint()
|
||||
const partStyle = tourRouteStyle(
|
||||
!wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null,
|
||||
tourPlanner.isPaddle || !!trackParts,
|
||||
{ line: paddlePaint.portage, casing: paddlePaint.disc },
|
||||
)
|
||||
const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null)
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { paddleRegionsApi } from '../api/paddleRegions';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** Thrown instead of asking while the app is offline, so the view can say so rather than show a failure. */
|
||||
export class PaddleRegionsOfflineError extends Error {
|
||||
constructor() {
|
||||
super('Paddle regions are managed with a connection only');
|
||||
this.name = 'PaddleRegionsOfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
function online<A extends unknown[], R>(call: (...args: A) => Promise<R>): (...args: A) => Promise<R> {
|
||||
return (...args) => (isEffectivelyOffline() ? Promise.reject(new PaddleRegionsOfflineError()) : call(...args));
|
||||
}
|
||||
|
||||
/** Why a region call was refused, from the code the server puts next to its error. */
|
||||
export function paddleRegionErrorCode(err: unknown): 'offline' | 'catalog_unavailable' | 'unknown_region' | null {
|
||||
if (err instanceof PaddleRegionsOfflineError) return 'offline';
|
||||
const code = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code;
|
||||
return code === 'catalog_unavailable' || code === 'unknown_region' ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05A: the instance's paddle regions. Online only on purpose: they are
|
||||
* instance administration, and a queued add or remove replayed later could
|
||||
* undo what another admin decided meanwhile.
|
||||
*/
|
||||
export const paddleRegionRepo = {
|
||||
list: online(paddleRegionsApi.list),
|
||||
catalog: online(paddleRegionsApi.catalog),
|
||||
add: online(paddleRegionsApi.add),
|
||||
refresh: online(paddleRegionsApi.refresh),
|
||||
remove: online(paddleRegionsApi.remove),
|
||||
};
|
||||
@@ -10,6 +10,8 @@ interface PaddleObstacleState {
|
||||
features: PaddleFeature[];
|
||||
/** The ones on or near the route, in the order the route reaches them. */
|
||||
related: RelatedObstacle[];
|
||||
/** The length of the route the relations were measured along, in metres: what each `alongM` is a part of. */
|
||||
routeLengthM: number;
|
||||
coverage: PaddleCoverageState;
|
||||
regions: PaddleCoverageRegion[];
|
||||
truncated: boolean;
|
||||
@@ -21,6 +23,7 @@ const EMPTY: PaddleObstacleState = {
|
||||
status: 'idle',
|
||||
features: [],
|
||||
related: [],
|
||||
routeLengthM: 0,
|
||||
coverage: 'none',
|
||||
regions: [],
|
||||
truncated: false,
|
||||
|
||||
@@ -53,7 +53,11 @@ export const useTrackMarkToolStore = create<TrackMarkToolState>(() => initial())
|
||||
|
||||
const set = useTrackMarkToolStore.setState;
|
||||
|
||||
export function loadTrackMarks(placeId: number | null, marks: TrackMarks | null | undefined, speeds: TrackSpeeds): void {
|
||||
export function loadTrackMarks(
|
||||
placeId: number | null,
|
||||
marks: TrackMarks | null | undefined,
|
||||
speeds: TrackSpeeds
|
||||
): void {
|
||||
set({ ...initial(), placeId, marks: marks ?? NO_TRACK_MARKS, speeds });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* ============================================================================
|
||||
Paddle tours on the maps (PAD-03, PAD-05B): the obstacle colours and the
|
||||
carried stretches. Global, since the 2D, GL and 3D maps and the planner's
|
||||
cards and route band all read the tokens. The colours sort objects by what
|
||||
they are, never into safe or unsafe.
|
||||
========================================================================== */
|
||||
|
||||
/* 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. */
|
||||
:root {
|
||||
--map-paddle-obstacle: #334155;
|
||||
--map-paddle-access: #4338ca;
|
||||
--map-paddle-landing: #0f766e;
|
||||
--map-portage: #9a6233;
|
||||
--map-paddle-disc: #ffffff;
|
||||
--map-paddle-shadow: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--map-paddle-obstacle: #cbd5e1;
|
||||
--map-paddle-access: #a5b4fc;
|
||||
--map-paddle-landing: #5eead4;
|
||||
--map-portage: #d6a06e;
|
||||
--map-paddle-disc: #1e293b;
|
||||
--map-paddle-shadow: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
@@ -80,7 +80,11 @@ describe('trackMarkToolStore', () => {
|
||||
});
|
||||
|
||||
it('says why a click placed nothing: off the line, or on a portage already there', () => {
|
||||
loadTrackMarks(7, { portages: [{ from_m: 100, to_m: 500, extra_minutes: null }], locks: [] }, { paddle: null, portage: null });
|
||||
loadTrackMarks(
|
||||
7,
|
||||
{ portages: [{ from_m: 100, to_m: 500, extra_minutes: null }], locks: [] },
|
||||
{ paddle: null, portage: null }
|
||||
);
|
||||
startTrackPick('portage');
|
||||
refuseTrackPick();
|
||||
expect(useTrackMarkToolStore.getState().refused).toBe('off_line');
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { StoreApi } from 'zustand';
|
||||
import { useAddonStore } from '../../src/store/addonStore';
|
||||
import { useAuthStore } from '../../src/store/authStore';
|
||||
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
|
||||
import { usePaddleObstacleStore } from '../../src/store/paddleObstacleStore';
|
||||
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
||||
import { useSettingsStore } from '../../src/store/settingsStore';
|
||||
import { useTourMapVisibilityStore } from '../../src/store/tourMapVisibilityStore';
|
||||
import { useTrackMarkToolStore } from '../../src/store/trackMarkToolStore';
|
||||
import { useTripStore } from '../../src/store/tripStore';
|
||||
import { useVacayStore } from '../../src/store/vacayStore';
|
||||
|
||||
@@ -36,6 +38,8 @@ export const RESET_STORES: Record<string, ResettableStore> = {
|
||||
useInAppNotificationStore,
|
||||
usePermissionsStore,
|
||||
useTourMapVisibilityStore,
|
||||
usePaddleObstacleStore,
|
||||
useTrackMarkToolStore,
|
||||
};
|
||||
|
||||
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
|
||||
|
||||
@@ -14,16 +14,20 @@ export function hikePlannerDefaults() {
|
||||
boatType: null,
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
recommendedSpeedKmh: null,
|
||||
effectiveSpeedKmh: null,
|
||||
setBoatType: vi.fn(),
|
||||
setWaterContext: vi.fn(),
|
||||
setSpeedKmh: vi.fn(),
|
||||
setPortageSpeedKmh: vi.fn(),
|
||||
adoptRecommendedSpeed: vi.fn(),
|
||||
load: vi.fn(),
|
||||
},
|
||||
paddleSpeedInvalid: false,
|
||||
paddleIncomplete: false,
|
||||
paddleEstimate: null,
|
||||
editPaddlePoints: vi.fn(),
|
||||
isLoop: false,
|
||||
reverse: vi.fn(),
|
||||
returnToStart: vi.fn(),
|
||||
|
||||
@@ -60,6 +60,9 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({
|
||||
}));
|
||||
|
||||
// The addon manager is reduced to the two controls MAdmin wires itself.
|
||||
vi.mock('../../../../src/components/Admin/paddle/PaddleRegionsPanel', () => ({
|
||||
default: () => <div data-testid="paddle-regions-panel" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({
|
||||
default: ({
|
||||
bagTrackingEnabled,
|
||||
@@ -215,6 +218,9 @@ describe('MAdmin', () => {
|
||||
expect(screen.getByTestId('packing-templates')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('category-manager')).toBeInTheDocument();
|
||||
|
||||
await openSection('Addons');
|
||||
expect(screen.getByTestId('paddle-regions-panel')).toBeInTheDocument();
|
||||
|
||||
await openSection('Plugins');
|
||||
expect(screen.getByTestId('plugins-panel')).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { AdminGuard } from '../auth-core/admin.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { PaddleObstaclesQueryDto, PaddleRegionCatalogQueryDto, PaddleRegionRequestDto } from './paddle-obstacles.dto';
|
||||
import { PaddleObstaclesService } from './paddle-obstacles.service';
|
||||
import { PaddleRegionsService } from './paddle-regions.service';
|
||||
import { Body, Controller, Get, HttpCode, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
paddleObstaclesResponseSchema,
|
||||
paddleRegionCatalogResponseSchema,
|
||||
@@ -16,6 +20,8 @@ import {
|
||||
successResponseSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
* GET /api/paddle/obstacles?bbox=: the paddle objects of a map area and its
|
||||
* coverage (PAD-05A), for anyone signed in while Tours is on. The data is
|
||||
@@ -37,14 +43,17 @@ export class PaddleObstaclesController {
|
||||
/**
|
||||
* /api/admin/paddle-regions: which regions TREK reads paddle data for. The
|
||||
* list, Geofabrik's catalogue to pick from, adding (which starts the first
|
||||
* read), reading again and removing. Ids travel in the body or the query:
|
||||
* Geofabrik's are plain words, but a route segment is no place for a choice
|
||||
* that names an outside source.
|
||||
* read), reading again and removing, each change with its audit row. Ids
|
||||
* travel in the body or the query: some of Geofabrik's hold a slash, and a
|
||||
* route segment is no place for a choice that names an outside source.
|
||||
*/
|
||||
@Controller('api/admin/paddle-regions')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class PaddleRegionsController {
|
||||
constructor(private readonly regions: PaddleRegionsService) {}
|
||||
constructor(
|
||||
private readonly regions: PaddleRegionsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(paddleRegionListResponseSchema)
|
||||
@@ -60,22 +69,31 @@ export class PaddleRegionsController {
|
||||
|
||||
@Post()
|
||||
@ResponseContract(paddleRegionSchema)
|
||||
async add(@Body() body: PaddleRegionRequestDto) {
|
||||
return await this.regions.add(body.id);
|
||||
async add(@CurrentUser() user: User, @Body() body: PaddleRegionRequestDto, @Req() req: Request) {
|
||||
const region = await this.regions.add(body.id);
|
||||
await this.record(user, 'admin.paddle_region_add', body.id, req);
|
||||
return region;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(paddleRegionSchema)
|
||||
async refresh(@Body() body: PaddleRegionRequestDto) {
|
||||
return await this.regions.refresh(body.id);
|
||||
async refresh(@CurrentUser() user: User, @Body() body: PaddleRegionRequestDto, @Req() req: Request) {
|
||||
const region = await this.regions.refresh(body.id);
|
||||
await this.record(user, 'admin.paddle_region_refresh', body.id, req);
|
||||
return region;
|
||||
}
|
||||
|
||||
@Post('remove')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(successResponseSchema)
|
||||
async remove(@Body() body: PaddleRegionRequestDto) {
|
||||
async remove(@CurrentUser() user: User, @Body() body: PaddleRegionRequestDto, @Req() req: Request) {
|
||||
await this.regions.remove(body.id);
|
||||
await this.record(user, 'admin.paddle_region_remove', body.id, req);
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
private async record(user: User, action: string, region: string, req: Request): Promise<void> {
|
||||
await this.audit.writeAudit({ userId: user.id, action, ip: getClientIp(req), details: { region } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../db/entities/PaddleRegions.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { GeofabrikClient } from './geofabrik.client';
|
||||
import { PaddleObstaclesController, PaddleRegionsController } from './paddle-obstacles.controller';
|
||||
@@ -17,7 +18,7 @@ import { Module } from '@nestjs/common';
|
||||
* domain's route checks (PAD-06, PAD-09).
|
||||
*/
|
||||
@Module({
|
||||
imports: [AddonsModule, SchedulingModule, MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures])],
|
||||
imports: [AddonsModule, AuditModule, SchedulingModule, MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures])],
|
||||
controllers: [PaddleObstaclesController, PaddleRegionsController],
|
||||
providers: [GeofabrikClient, PaddleObstaclesService, PaddleRegionsService, PaddleRegionsJob],
|
||||
exports: [PaddleObstaclesService],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* writes an extract the test builds (tests/helpers/osm-pbf-fixture.ts), which
|
||||
* the real reader then reads.
|
||||
*/
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../src/nest/app-config/data-paths.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
REGION_UNKNOWN_MESSAGE,
|
||||
} from '../../src/nest/paddle-obstacles/paddle-regions.service';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { deleteRows, findRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
||||
import { pbfFile } from '../helpers/osm-pbf-fixture';
|
||||
@@ -153,6 +155,7 @@ beforeEach(async () => {
|
||||
);
|
||||
geofabrik.download.mockImplementation((_url, target) => writeFile(target, EXTRACT));
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
await deleteRows(orm, AuditLog);
|
||||
await admin.remove(REGION.id);
|
||||
await admin.remove(BAYERN.id);
|
||||
await settle();
|
||||
@@ -212,6 +215,9 @@ describe('/api/admin/paddle-regions', () => {
|
||||
it('PADE2E-005: an admin adds a region, its read lands, and the map reads the weir as a line with the date of the data', async () => {
|
||||
const added = await admin.add(REGION.id);
|
||||
expect(added.status).toBe(201);
|
||||
expect(await findRows(orm, AuditLog, { action: 'admin.paddle_region_add' })).toMatchObject([
|
||||
{ details: JSON.stringify({ region: 'oberpfalz' }) },
|
||||
]);
|
||||
expect(added.body).toMatchObject({ id: 'oberpfalz', name: 'Oberpfalz', feature_count: 0 });
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
||||
import type { AuditService } from '../../../../src/nest/audit/audit.service';
|
||||
import { AdminGuard } from '../../../../src/nest/auth-core/admin.guard';
|
||||
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '../../../../src/nest/paddle-obstacles/paddle-obstacles.controller';
|
||||
import type { PaddleObstaclesService } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
||||
import type { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import {
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
type PaddleRegion,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const region: PaddleRegion = {
|
||||
@@ -84,7 +87,7 @@ describe('PaddleRegionsController', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('PADCTL-004: wraps the lists in { regions }, hands the ids on and answers { success: true } on remove', async () => {
|
||||
it('PADCTL-004: wraps the lists in { regions }, hands the ids on, records each change and answers { success: true } on remove', async () => {
|
||||
const service = {
|
||||
list: vi.fn().mockResolvedValue([region]),
|
||||
catalog: vi.fn().mockResolvedValue([{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }]),
|
||||
@@ -92,18 +95,44 @@ describe('PaddleRegionsController', () => {
|
||||
refresh: vi.fn().mockResolvedValue(region),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const controller = new PaddleRegionsController(service as unknown as PaddleRegionsService);
|
||||
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new PaddleRegionsController(
|
||||
service as unknown as PaddleRegionsService,
|
||||
audit as unknown as AuditService,
|
||||
);
|
||||
const admin = { id: 2 } as User;
|
||||
const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request;
|
||||
|
||||
expect(await controller.list()).toEqual({ regions: [region] });
|
||||
expect(await controller.catalog({ q: 'ober' })).toEqual({
|
||||
regions: [{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }],
|
||||
});
|
||||
expect(service.catalog).toHaveBeenCalledWith('ober');
|
||||
expect(await controller.add({ id: 'oberpfalz' })).toBe(region);
|
||||
expect(await controller.add(admin, { id: 'oberpfalz' }, req)).toBe(region);
|
||||
expect(service.add).toHaveBeenCalledWith('oberpfalz');
|
||||
expect(await controller.refresh({ id: 'oberpfalz' })).toBe(region);
|
||||
expect(await controller.refresh(admin, { id: 'oberpfalz' }, req)).toBe(region);
|
||||
expect(service.refresh).toHaveBeenCalledWith('oberpfalz');
|
||||
expect(await controller.remove({ id: 'oberpfalz' })).toEqual({ success: true });
|
||||
expect(await controller.remove(admin, { id: 'oberpfalz' }, req)).toEqual({ success: true });
|
||||
expect(service.remove).toHaveBeenCalledWith('oberpfalz');
|
||||
expect(audit.writeAudit.mock.calls.map(([entry]) => entry)).toEqual(
|
||||
['admin.paddle_region_add', 'admin.paddle_region_refresh', 'admin.paddle_region_remove'].map((action) => ({
|
||||
userId: 2,
|
||||
action,
|
||||
ip: expect.any(String),
|
||||
details: { region: 'oberpfalz' },
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('PADCTL-005: a refused change leaves no audit row', async () => {
|
||||
const refusal = new DomainError(404, 'Region not found');
|
||||
const service = { remove: vi.fn().mockRejectedValue(refusal) };
|
||||
const audit = { writeAudit: vi.fn() };
|
||||
const controller = new PaddleRegionsController(
|
||||
service as unknown as PaddleRegionsService,
|
||||
audit as unknown as AuditService,
|
||||
);
|
||||
await expect(controller.remove({ id: 2 } as User, { id: 'bayern' }, {} as Request)).rejects.toBe(refusal);
|
||||
expect(audit.writeAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,8 +298,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'أدخل سرعة بين 0.5 و20 كم/س.',
|
||||
'tours.paddle.chooseBoat': 'اختر القارب والمسطح المائي لحفظ جولة التجديف هذه.',
|
||||
'tours.paddle.chooseBoatImport': 'اختر القارب والمسطح المائي للاستيراد كجولة تجديف.',
|
||||
'tours.paddle.noWaterData':
|
||||
'لا يعرض TREK بعد السدود الصغيرة والأهوسة ونقاط حمل القارب. تحقق بنفسك من الماء وقواعده قبل الانطلاق.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'تُرسم جولة التجديف يدويًا: يصل TREK نقاطك بخطوط مستقيمة. لا يُحسب أي مسار، ولا شيء يقول إن الماء صالح للتجديف.',
|
||||
'tours.paddle.timeEstimated': 'وقت التجديف (تقديري)',
|
||||
|
||||
@@ -287,8 +287,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '0,5 ilə 20 km/saat arasında sürət daxil et.',
|
||||
'tours.paddle.chooseBoat': 'Bu avarlı turu yadda saxlamaq üçün qayığı və suyu seç.',
|
||||
'tours.paddle.chooseBoatImport': 'Avarlı tur kimi idxal etmək üçün qayığı və suyu seç.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK hələ bəndləri, şlüzləri və daşıma yerlərini göstərmir. Yola çıxmazdan əvvəl suyu və qaydalarını özün yoxla.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Avarlı tur əllə çəkilir: TREK nöqtələrini düz xətlərlə birləşdirir. Heç nə marşrutlaşdırılmır və heç nə suyun keçilə bilən olduğunu demir.',
|
||||
'tours.paddle.timeEstimated': 'Avar vaxtı (təxmini)',
|
||||
|
||||
@@ -293,8 +293,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Informe uma velocidade de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Escolha a embarcação e a água para salvar este passeio.',
|
||||
'tours.paddle.chooseBoatImport': 'Escolha a embarcação e a água para importar como passeio de remo.',
|
||||
'tours.paddle.noWaterData':
|
||||
'O TREK ainda não mostra barragens, eclusas nem trechos de transbordo. Confira você mesmo a água e as regras antes de sair.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Um passeio de remo é desenhado à mão: o TREK liga seus pontos com linhas retas. Nada é roteado, e nada diz que a água é navegável.',
|
||||
'tours.paddle.timeEstimated': 'Tempo de remada (estimado)',
|
||||
|
||||
@@ -293,8 +293,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Introdueix una velocitat de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Tria l’embarcació i l’aigua per desar aquesta ruta.',
|
||||
'tours.paddle.chooseBoatImport': 'Tria l’embarcació i l’aigua per importar com a ruta en piragua.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK encara no mostra rescloses, preses ni transports per terra. Comprova tu mateix l’aigua i les normes abans de sortir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Una ruta en piragua es dibuixa a mà: TREK uneix els punts amb línies rectes. No es calcula res i res no diu que l’aigua sigui navegable.',
|
||||
'tours.paddle.timeEstimated': 'Temps de rem (estimat)',
|
||||
|
||||
@@ -287,8 +287,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Zadejte rychlost od 0,5 do 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Vyberte loď a vodu, abyste mohli tuto túru uložit.',
|
||||
'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu pro import jako vodácké túry.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK zatím nezobrazuje jezy, zdymadla ani přenášky. Před vyplutím si vodu a pravidla ověřte sami.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Vodácká túra se kreslí ručně: TREK spojí vaše body přímými čarami. Nic se nepočítá a nic neříká, že je voda sjízdná.',
|
||||
'tours.paddle.timeEstimated': 'Čas na vodě (odhad)',
|
||||
|
||||
@@ -862,5 +862,34 @@ const admin: TranslationStrings = {
|
||||
'admin.amapKeyHintShort':
|
||||
'Für die Ortssuche in Festlandchina. Braucht einen Web-Service-Schlüssel, keinen JS-API-Schlüssel.',
|
||||
'admin.collab.links.subtitle': 'Geteilte Links und Lesezeichen',
|
||||
'admin.paddleRegions.title': 'Paddel-Gewässerdaten',
|
||||
'admin.paddleRegions.hint':
|
||||
'Wehre, Schleusen und Befahrungsregeln aus OpenStreetMap, je Region gelesen und jeden Monat neu.',
|
||||
'admin.paddleRegions.search': 'Region hinzufügen',
|
||||
'admin.paddleRegions.searchPlaceholder': 'Geofabrik durchsuchen, zum Beispiel Oberpfalz',
|
||||
'admin.paddleRegions.noHits': 'Keine Region mit diesem Namen',
|
||||
'admin.paddleRegions.added': 'Hinzugefügt',
|
||||
'admin.paddleRegions.empty': 'Noch keine Region. Paddeltouren zeigen dann, dass keine Hindernisdaten bekannt sind.',
|
||||
'admin.paddleRegions.status.queued': 'Wartet',
|
||||
'admin.paddleRegions.status.importing': 'Liest',
|
||||
'admin.paddleRegions.status.ready': 'Bereit',
|
||||
'admin.paddleRegions.status.failed': 'Fehler',
|
||||
'admin.paddleRegions.phase.download': 'Lädt herunter',
|
||||
'admin.paddleRegions.phase.read': 'Liest aus',
|
||||
'admin.paddleRegions.objects': '{count} Objekte',
|
||||
'admin.paddleRegions.objects.one': '{count} Objekt',
|
||||
'admin.paddleRegions.asOf': 'Stand {date}',
|
||||
'admin.paddleRegions.keptData': 'Der zuvor gelesene Stand bleibt in Gebrauch.',
|
||||
'admin.paddleRegions.refresh': 'Neu lesen',
|
||||
'admin.paddleRegions.remove': 'Region entfernen',
|
||||
'admin.paddleRegions.removeMessage':
|
||||
'{name} und ihre Hindernisdaten werden entfernt. Paddeltouren dort zeigen dann keine Daten.',
|
||||
'admin.paddleRegions.retry': 'Erneut versuchen',
|
||||
'admin.paddleRegions.offline': 'Regionen lassen sich nur mit Verbindung verwalten.',
|
||||
'admin.paddleRegions.catalogUnavailable': 'Geofabrik ist gerade nicht erreichbar. Versuche es später erneut.',
|
||||
'admin.paddleRegions.attribution':
|
||||
'Daten © OpenStreetMap-Mitwirkende (ODbL), regionale Auszüge von Geofabrik. Neu gelesen am 3. jedes Monats.',
|
||||
'admin.paddleRegions.toast.added': 'Region hinzugefügt. TREK liest sie im Hintergrund.',
|
||||
'admin.paddleRegions.toast.removed': 'Region entfernt',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -295,8 +295,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Gib ein Tempo von 0,5 bis 20 km/h ein.',
|
||||
'tours.paddle.chooseBoat': 'Wähle Boot und Gewässer, um diese Paddeltour zu speichern.',
|
||||
'tours.paddle.chooseBoatImport': 'Wähle Boot und Gewässer, um als Paddeltour zu importieren.',
|
||||
'tours.paddle.noWaterData':
|
||||
'Wehre, Schleusen und Umtragestellen zeigt TREK noch nicht an. Prüfe das Gewässer und seine Regeln selbst, bevor du losfährst.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Eine Paddeltour wird von Hand gezeichnet: TREK verbindet deine Punkte mit geraden Linien. Nichts wird geroutet, und nichts sagt, dass das Gewässer befahrbar ist.',
|
||||
'tours.paddle.timeEstimated': 'Paddelzeit (geschätzt)',
|
||||
@@ -448,6 +446,36 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.obstacle.kind.slipway': 'Slipanlage',
|
||||
'tours.paddle.obstacle.kind.portage': 'Umtragestelle',
|
||||
'tours.paddle.obstacle.kind.access': 'Befahrungsregel',
|
||||
'tours.paddle.obstacles.truncated':
|
||||
'TREK zeigt hier nur einen Teil der Objekte, das Gebiet enthält mehr. Prüfe das Gewässer selbst.',
|
||||
'tours.paddle.obstacles.dateUnknown': 'unbekannt',
|
||||
'tours.paddle.obstacleCard.onRoute': 'Auf der Route bei',
|
||||
'tours.paddle.obstacleCard.near': 'In der Nähe',
|
||||
'tours.paddle.obstacleCard.area': 'Im Gebiet',
|
||||
'tours.paddle.obstacleCard.uncertain': 'Bezug unsicher',
|
||||
'tours.paddle.obstacleCard.alongInfo': 'Wie weit es vom Start an der Route liegt',
|
||||
'tours.paddle.obstacleCard.distanceInfo':
|
||||
'Wie weit es von der Route entfernt liegt. Es kann an einem anderen Arm des Gewässers liegen.',
|
||||
'tours.paddle.obstacleCard.privateWay': 'Privater Weg',
|
||||
'tours.paddle.obstacleCard.noAccessInfo': 'Keine Befahrungsangabe',
|
||||
'tours.paddle.obstacleCard.condition': 'Bedingung',
|
||||
'tours.paddle.obstacleCard.access.canoe': 'Kanu',
|
||||
'tours.paddle.obstacleCard.access.boat': 'Boot',
|
||||
'tours.paddle.obstacleCard.access.access': 'Zugang',
|
||||
'tours.paddle.obstacleCard.accessValue.yes': 'ja',
|
||||
'tours.paddle.obstacleCard.accessValue.no': 'nein',
|
||||
'tours.paddle.obstacleCard.accessValue.private': 'privat',
|
||||
'tours.paddle.obstacleCard.accessValue.permissive': 'geduldet',
|
||||
'tours.paddle.obstacleCard.accessValue.designated': 'ausgewiesen',
|
||||
'tours.paddle.obstacleCard.fact.height': 'Höhe',
|
||||
'tours.paddle.obstacleCard.fact.width': 'Breite',
|
||||
'tours.paddle.obstacleCard.fact.grade': 'Schwierigkeit',
|
||||
'tours.paddle.obstacleCard.fact.opening_hours': 'Öffnungszeiten',
|
||||
'tours.paddle.obstacleCard.fact.seasonal': 'Saisonal',
|
||||
'tours.paddle.obstacleCard.fact.fee': 'Gebühr',
|
||||
'tours.paddle.obstacleCard.fact.operator': 'Betreiber',
|
||||
'tours.paddle.obstacleCard.source': 'OpenStreetMap-Daten, Stand {date}, keine Prüfung vor Ort.',
|
||||
'tours.paddle.obstacleCard.sourceUndated': 'OpenStreetMap-Daten unbekannten Stands, keine Prüfung vor Ort.',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -833,5 +833,34 @@ const admin: TranslationStrings = {
|
||||
'admin.mapsKeyHintShort': 'Adds photos, ratings and opening hours. Every lookup then goes to Google.',
|
||||
'admin.amapKeyHintShort': 'For place search inside mainland China. Needs a web service key, not a JS API key.',
|
||||
'admin.collab.links.subtitle': 'Shared links and bookmarks',
|
||||
'admin.paddleRegions.title': 'Paddle water data',
|
||||
'admin.paddleRegions.hint':
|
||||
'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.',
|
||||
'admin.paddleRegions.search': 'Add a region',
|
||||
'admin.paddleRegions.searchPlaceholder': 'Search Geofabrik, for example Oberpfalz',
|
||||
'admin.paddleRegions.noHits': 'No region by that name',
|
||||
'admin.paddleRegions.added': 'Added',
|
||||
'admin.paddleRegions.empty': 'No region yet. Paddle tours then show that no obstacle data is known.',
|
||||
'admin.paddleRegions.status.queued': 'Waiting',
|
||||
'admin.paddleRegions.status.importing': 'Reading',
|
||||
'admin.paddleRegions.status.ready': 'Ready',
|
||||
'admin.paddleRegions.status.failed': 'Failed',
|
||||
'admin.paddleRegions.phase.download': 'Downloading',
|
||||
'admin.paddleRegions.phase.read': 'Reading',
|
||||
'admin.paddleRegions.objects': '{count} objects',
|
||||
'admin.paddleRegions.objects.one': '{count} object',
|
||||
'admin.paddleRegions.asOf': 'As of {date}',
|
||||
'admin.paddleRegions.keptData': 'The data read before stays in use.',
|
||||
'admin.paddleRegions.refresh': 'Read again',
|
||||
'admin.paddleRegions.remove': 'Remove region',
|
||||
'admin.paddleRegions.removeMessage':
|
||||
'{name} and its obstacle data are removed. Paddle tours there then show no data.',
|
||||
'admin.paddleRegions.retry': 'Try again',
|
||||
'admin.paddleRegions.offline': 'Regions can only be managed with a connection.',
|
||||
'admin.paddleRegions.catalogUnavailable': 'Geofabrik cannot be reached right now. Try again later.',
|
||||
'admin.paddleRegions.attribution':
|
||||
'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.',
|
||||
'admin.paddleRegions.toast.added': 'Region added. TREK reads it in the background.',
|
||||
'admin.paddleRegions.toast.removed': 'Region removed',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -286,8 +286,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Enter a speed from 0.5 to 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Choose the boat and the water to save this paddle tour.',
|
||||
'tours.paddle.chooseBoatImport': 'Choose the boat and the water to import as a paddle tour.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK does not show weirs, locks or portages yet. Check the water and its rules yourself before you set off.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'A paddle tour is drawn by hand: TREK joins your points with straight lines. Nothing is routed, and nothing says the water can be paddled.',
|
||||
'tours.paddle.timeEstimated': 'Paddling time (estimated)',
|
||||
@@ -437,5 +435,34 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.obstacle.kind.slipway': 'Slipway',
|
||||
'tours.paddle.obstacle.kind.portage': 'Portage',
|
||||
'tours.paddle.obstacle.kind.access': 'Access rule',
|
||||
'tours.paddle.obstacles.truncated':
|
||||
'TREK shows only part of the objects here; the area holds more. Check the water yourself.',
|
||||
'tours.paddle.obstacles.dateUnknown': 'an unknown date',
|
||||
'tours.paddle.obstacleCard.onRoute': 'On the route at',
|
||||
'tours.paddle.obstacleCard.near': 'Near the route',
|
||||
'tours.paddle.obstacleCard.area': 'In the area',
|
||||
'tours.paddle.obstacleCard.uncertain': 'Link unclear',
|
||||
'tours.paddle.obstacleCard.alongInfo': 'How far along the route it lies, from the start',
|
||||
'tours.paddle.obstacleCard.distanceInfo': 'How far from the route it lies. It may be on another arm of the water.',
|
||||
'tours.paddle.obstacleCard.privateWay': 'Private way',
|
||||
'tours.paddle.obstacleCard.noAccessInfo': 'No paddling rule known',
|
||||
'tours.paddle.obstacleCard.condition': 'Condition',
|
||||
'tours.paddle.obstacleCard.access.canoe': 'Canoe',
|
||||
'tours.paddle.obstacleCard.access.boat': 'Boat',
|
||||
'tours.paddle.obstacleCard.access.access': 'Access',
|
||||
'tours.paddle.obstacleCard.accessValue.yes': 'yes',
|
||||
'tours.paddle.obstacleCard.accessValue.no': 'no',
|
||||
'tours.paddle.obstacleCard.accessValue.private': 'private',
|
||||
'tours.paddle.obstacleCard.accessValue.permissive': 'tolerated',
|
||||
'tours.paddle.obstacleCard.accessValue.designated': 'designated',
|
||||
'tours.paddle.obstacleCard.fact.height': 'Height',
|
||||
'tours.paddle.obstacleCard.fact.width': 'Width',
|
||||
'tours.paddle.obstacleCard.fact.grade': 'Grade',
|
||||
'tours.paddle.obstacleCard.fact.opening_hours': 'Hours',
|
||||
'tours.paddle.obstacleCard.fact.seasonal': 'Seasonal',
|
||||
'tours.paddle.obstacleCard.fact.fee': 'Fee',
|
||||
'tours.paddle.obstacleCard.fact.operator': 'Operator',
|
||||
'tours.paddle.obstacleCard.source': 'OpenStreetMap data as of {date}, not a check on site.',
|
||||
'tours.paddle.obstacleCard.sourceUndated': 'OpenStreetMap data of an unknown date, not a check on site.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -292,8 +292,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Introduce una velocidad de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Elige la embarcación y el agua para guardar esta ruta.',
|
||||
'tours.paddle.chooseBoatImport': 'Elige la embarcación y el agua para importar como ruta en piragua.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK aún no muestra presas, esclusas ni porteos. Comprueba tú mismo el agua y sus normas antes de salir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Una ruta en piragua se dibuja a mano: TREK une tus puntos con líneas rectas. Nada se calcula y nada indica que el agua sea navegable.',
|
||||
'tours.paddle.timeEstimated': 'Tiempo de remo (estimado)',
|
||||
|
||||
@@ -289,8 +289,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Sisesta kiirus vahemikus 0,5 kuni 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Vali paat ja veekogu, et see matk salvestada.',
|
||||
'tours.paddle.chooseBoatImport': 'Vali paat ja veekogu, et importida aerutamismatkana.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK ei näita veel paise, lüüse ega ülekandekohti. Kontrolli enne teele asumist veekogu ja selle reegleid ise.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Aerutamismatk joonistatakse käsitsi: TREK ühendab sinu punktid sirgjoontega. Midagi ei marsruudita ja miski ei ütle, et vesi on läbitav.',
|
||||
'tours.paddle.timeEstimated': 'Aerutamisaeg (hinnanguline)',
|
||||
|
||||
@@ -298,8 +298,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Saisissez une vitesse de 0,5 à 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Choisissez l’embarcation et le plan d’eau pour enregistrer cette sortie.',
|
||||
'tours.paddle.chooseBoatImport': 'Choisissez l’embarcation et le plan d’eau pour importer en sortie en pagaie.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK n’affiche pas encore les barrages, écluses ni portages. Vérifiez vous-même le plan d’eau et ses règles avant de partir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Une sortie en pagaie se trace à la main : TREK relie vos points en lignes droites. Rien n’est calculé, et rien ne dit que l’eau est navigable.',
|
||||
'tours.paddle.timeEstimated': 'Temps de pagaie (estimé)',
|
||||
|
||||
@@ -299,8 +299,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Δώστε ταχύτητα από 0,5 έως 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Επιλέξτε σκάφος και νερό για να αποθηκεύσετε τη διαδρομή.',
|
||||
'tours.paddle.chooseBoatImport': 'Επιλέξτε σκάφος και νερό για εισαγωγή ως διαδρομή με κουπί.',
|
||||
'tours.paddle.noWaterData':
|
||||
'Το TREK δεν δείχνει ακόμη φράγματα, υδατοφράκτες ή σημεία μεταφοράς. Ελέγξτε μόνοι σας το νερό και τους κανόνες πριν ξεκινήσετε.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Μια διαδρομή με κουπί σχεδιάζεται με το χέρι: το TREK ενώνει τα σημεία σας με ευθείες. Τίποτα δεν δρομολογείται και τίποτα δεν λέει ότι το νερό είναι πλωτό.',
|
||||
'tours.paddle.timeEstimated': 'Χρόνος κωπηλασίας (εκτίμηση)',
|
||||
|
||||
@@ -293,8 +293,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Adj meg egy tempót 0,5 és 20 km/h között.',
|
||||
'tours.paddle.chooseBoat': 'Válaszd ki a hajót és a vizet a túra mentéséhez.',
|
||||
'tours.paddle.chooseBoatImport': 'Válaszd ki a hajót és a vizet az evezős túraként való importáláshoz.',
|
||||
'tours.paddle.noWaterData':
|
||||
'A TREK még nem mutatja a gátakat, zsilipeket és átemelőket. Indulás előtt magad ellenőrizd a vizet és a szabályokat.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Az evezős túrát kézzel rajzolod: a TREK egyenes vonalakkal köti össze a pontjaidat. Semmi sincs útvonalra tervezve, és semmi sem mondja, hogy a víz járható.',
|
||||
'tours.paddle.timeEstimated': 'Evezési idő (becslés)',
|
||||
|
||||
@@ -286,8 +286,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Masukkan kecepatan dari 0,5 hingga 20 km/jam.',
|
||||
'tours.paddle.chooseBoat': 'Pilih perahu dan perairan untuk menyimpan tur dayung ini.',
|
||||
'tours.paddle.chooseBoatImport': 'Pilih perahu dan perairan untuk mengimpor sebagai tur dayung.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK belum menampilkan bendung, pintu air, atau titik angkut. Periksa sendiri perairan dan aturannya sebelum berangkat.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Tur dayung digambar dengan tangan: TREK menghubungkan titik-titikmu dengan garis lurus. Tidak ada yang dirutekan, dan tidak ada yang menyatakan perairan bisa didayung.',
|
||||
'tours.paddle.timeEstimated': 'Waktu mendayung (perkiraan)',
|
||||
|
||||
@@ -293,8 +293,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Inserisci una velocità da 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Scegli imbarcazione e acqua per salvare questa uscita.',
|
||||
'tours.paddle.chooseBoatImport': 'Scegli imbarcazione e acqua per importare come uscita in pagaia.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK non mostra ancora sbarramenti, chiuse e trasbordi. Controlla tu stesso l’acqua e le sue regole prima di partire.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Un’uscita in pagaia si disegna a mano: TREK collega i tuoi punti con linee rette. Niente viene calcolato e niente dice che l’acqua sia navigabile.',
|
||||
'tours.paddle.timeEstimated': 'Tempo di pagaiata (stimato)',
|
||||
|
||||
@@ -280,8 +280,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '0.5〜20 km/h の速度を入力してください。',
|
||||
'tours.paddle.chooseBoat': 'このパドルツアーを保存するには、ボートと水域を選んでください。',
|
||||
'tours.paddle.chooseBoatImport': 'パドルツアーとして取り込むには、ボートと水域を選んでください。',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK はまだ堰、閘門、陸上運搬区間を表示しません。出発前に水域とそのルールをご自身で確認してください。',
|
||||
'tours.paddle.drawnByHand':
|
||||
'パドルツアーは手で描きます。TREK はポイントを直線で結ぶだけで、ルート計算はせず、その水域を漕げることも示しません。',
|
||||
'tours.paddle.timeEstimated': 'パドル時間(推定)',
|
||||
|
||||
@@ -278,8 +278,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '0.5~20 km/h 사이의 속도를 입력하세요.',
|
||||
'tours.paddle.chooseBoat': '이 패들 투어를 저장하려면 보트와 수역을 선택하세요.',
|
||||
'tours.paddle.chooseBoatImport': '패들 투어로 가져오려면 보트와 수역을 선택하세요.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK은 아직 보, 갑문, 육상 운반 구간을 표시하지 않습니다. 출발 전에 수역과 규칙을 직접 확인하세요.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'패들 투어는 손으로 그립니다. TREK은 지점들을 직선으로 이을 뿐 경로를 계산하지 않으며, 그 물길을 지날 수 있다고 알려주지도 않습니다.',
|
||||
'tours.paddle.timeEstimated': '패들 시간(추정)',
|
||||
|
||||
@@ -294,8 +294,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Vul een tempo van 0,5 tot 20 km/h in.',
|
||||
'tours.paddle.chooseBoat': 'Kies boot en water om deze peddeltocht op te slaan.',
|
||||
'tours.paddle.chooseBoatImport': 'Kies boot en water om als peddeltocht te importeren.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK toont nog geen stuwen, sluizen of overdraagplaatsen. Controleer het water en de regels zelf voor je vertrekt.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Een peddeltocht teken je met de hand: TREK verbindt je punten met rechte lijnen. Er wordt niets gerouteerd en niets zegt dat het water bevaarbaar is.',
|
||||
'tours.paddle.timeEstimated': 'Peddeltijd (geschat)',
|
||||
|
||||
@@ -300,8 +300,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Podaj tempo od 0,5 do 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Wybierz łódź i wodę, aby zapisać ten spływ.',
|
||||
'tours.paddle.chooseBoatImport': 'Wybierz łódź i wodę, aby zaimportować jako spływ.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK nie pokazuje jeszcze jazów, śluz ani przenosek. Przed wyruszeniem sprawdź sam wodę i obowiązujące zasady.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Spływ rysuje się ręcznie: TREK łączy Twoje punkty prostymi liniami. Nic nie jest wyznaczane i nic nie mówi, że woda jest spławna.',
|
||||
'tours.paddle.timeEstimated': 'Czas na wodzie (szacunkowo)',
|
||||
|
||||
@@ -303,8 +303,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Введите скорость от 0,5 до 20 км/ч.',
|
||||
'tours.paddle.chooseBoat': 'Выберите лодку и водоём, чтобы сохранить этот сплав.',
|
||||
'tours.paddle.chooseBoatImport': 'Выберите лодку и водоём, чтобы импортировать как сплав.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK пока не показывает плотины, шлюзы и обносы. Перед выходом сами проверьте водоём и правила на нём.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Сплав рисуется вручную: TREK соединяет ваши точки прямыми линиями. Ничего не прокладывается, и ничто не говорит, что по воде можно пройти.',
|
||||
'tours.paddle.timeEstimated': 'Время на воде (оценка)',
|
||||
|
||||
@@ -288,8 +288,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Zadajte rýchlosť od 0,5 do 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Vyberte loď a vodu, aby ste mohli túto túru uložiť.',
|
||||
'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu na import ako vodáckej túry.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK zatiaľ nezobrazuje hate, plavebné komory ani prenášky. Pred vyplávaním si vodu a pravidlá overte sami.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Vodácka túra sa kreslí ručne: TREK spojí vaše body priamymi čiarami. Nič sa nepočíta a nič nehovorí, že je voda splavná.',
|
||||
'tours.paddle.timeEstimated': 'Čas na vode (odhad)',
|
||||
|
||||
@@ -285,8 +285,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Ange ett tempo från 0,5 till 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Välj båt och vatten för att spara paddelturen.',
|
||||
'tours.paddle.chooseBoatImport': 'Välj båt och vatten för att importera som paddeltur.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK visar ännu inte dammar, slussar eller lyft. Kontrollera vattnet och dess regler själv innan du ger dig av.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'En paddeltur ritas för hand: TREK förbinder dina punkter med raka linjer. Inget ruttas, och inget säger att vattnet går att paddla.',
|
||||
'tours.paddle.timeEstimated': 'Paddeltid (uppskattad)',
|
||||
|
||||
@@ -274,8 +274,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'ใส่ความเร็วระหว่าง 0.5 ถึง 20 กม./ชม.',
|
||||
'tours.paddle.chooseBoat': 'เลือกเรือและแหล่งน้ำเพื่อบันทึกทริปพายเรือนี้',
|
||||
'tours.paddle.chooseBoatImport': 'เลือกเรือและแหล่งน้ำเพื่อนำเข้าเป็นทริปพายเรือ',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK ยังไม่แสดงฝาย ประตูน้ำ หรือจุดแบกเรือ โปรดตรวจสอบแหล่งน้ำและกฎด้วยตัวเองก่อนออกเดินทาง',
|
||||
'tours.paddle.drawnByHand':
|
||||
'ทริปพายเรือวาดด้วยมือ: TREK เชื่อมจุดของคุณด้วยเส้นตรง ไม่มีการคำนวณเส้นทาง และไม่มีอะไรบอกว่าแหล่งน้ำพายผ่านได้',
|
||||
'tours.paddle.timeEstimated': 'เวลาพาย (โดยประมาณ)',
|
||||
|
||||
@@ -287,8 +287,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '0,5 ile 20 km/sa arasında bir hız gir.',
|
||||
'tours.paddle.chooseBoat': 'Bu kürek turunu kaydetmek için tekneyi ve suyu seç.',
|
||||
'tours.paddle.chooseBoatImport': 'Kürek turu olarak içe aktarmak için tekneyi ve suyu seç.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK henüz savakları, kilitleri ve taşıma noktalarını göstermiyor. Yola çıkmadan önce suyu ve kurallarını kendin kontrol et.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Kürek turu elle çizilir: TREK noktalarını düz çizgilerle birleştirir. Hiçbir şey rotalanmaz ve hiçbir şey suyun geçilebilir olduğunu söylemez.',
|
||||
'tours.paddle.timeEstimated': 'Kürek süresi (tahmini)',
|
||||
|
||||
@@ -300,8 +300,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Введіть швидкість від 0,5 до 20 км/год.',
|
||||
'tours.paddle.chooseBoat': 'Виберіть човен і водойму, щоб зберегти цей сплав.',
|
||||
'tours.paddle.chooseBoatImport': 'Виберіть човен і водойму, щоб імпортувати як сплав.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK поки не показує греблі, шлюзи й обноси. Перед виходом самі перевірте водойму та правила на ній.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Сплав малюється вручну: TREK з’єднує ваші точки прямими лініями. Нічого не прокладається, і ніщо не каже, що водою можна пройти.',
|
||||
'tours.paddle.timeEstimated': 'Час на воді (оцінка)',
|
||||
|
||||
@@ -287,8 +287,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': 'Nhập tốc độ từ 0,5 đến 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Chọn thuyền và mặt nước để lưu chuyến chèo này.',
|
||||
'tours.paddle.chooseBoatImport': 'Chọn thuyền và mặt nước để nhập thành chuyến chèo thuyền.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK chưa hiển thị đập tràn, âu thuyền hay điểm khiêng thuyền. Hãy tự kiểm tra mặt nước và quy định trước khi xuất phát.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Chuyến chèo thuyền được vẽ bằng tay: TREK nối các điểm của bạn bằng đường thẳng. Không có gì được định tuyến, và không gì cho biết mặt nước có chèo được hay không.',
|
||||
'tours.paddle.timeEstimated': 'Thời gian chèo (ước tính)',
|
||||
|
||||
@@ -264,7 +264,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '請輸入 0.5 至 20 km/h 之間的速度。',
|
||||
'tours.paddle.chooseBoat': '請選擇船隻和水域以儲存此划槳行程。',
|
||||
'tours.paddle.chooseBoatImport': '請選擇船隻和水域以匯入為划槳行程。',
|
||||
'tours.paddle.noWaterData': 'TREK 尚未顯示堰壩、船閘或陸上搬運點。出發前請自行確認水域及其規定。',
|
||||
'tours.paddle.drawnByHand': '划槳行程需手動繪製:TREK 以直線連接你的點,不進行路線規劃,也不表示該水域可以通行。',
|
||||
'tours.paddle.timeEstimated': '划槳時間(估算)',
|
||||
'tours.paddle.timeEstimatedShort': '划槳',
|
||||
|
||||
@@ -265,7 +265,6 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.speedInvalid': '请输入 0.5 至 20 km/h 之间的速度。',
|
||||
'tours.paddle.chooseBoat': '请选择船只和水域以保存此划桨路线。',
|
||||
'tours.paddle.chooseBoatImport': '请选择船只和水域以导入为划桨路线。',
|
||||
'tours.paddle.noWaterData': 'TREK 暂不显示堰坝、船闸或陆上搬运点。出发前请自行确认水域及其规定。',
|
||||
'tours.paddle.drawnByHand': '划桨路线需手动绘制:TREK 用直线连接你的点,不做路线规划,也不表示该水域可以通行。',
|
||||
'tours.paddle.timeEstimated': '划桨用时(估算)',
|
||||
'tours.paddle.timeEstimatedShort': '划桨',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PaddleFeature, PaddleGeometry } from './paddle-obstacles.schema';
|
||||
import { NEAR_M, ON_ROUTE_M, relateObstaclesToRoute } from './paddle-route-relation';
|
||||
import { measuredRouteLengthM, NEAR_M, ON_ROUTE_M, relateObstaclesToRoute } from './paddle-route-relation';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -92,3 +92,11 @@ describe('relateObstaclesToRoute', () => {
|
||||
expect(relateObstaclesToRoute(route, [otherArm])[0]).toMatchObject({ relation: 'near', distanceM: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('measuredRouteLengthM', () => {
|
||||
it('measures the route the way its relations do', () => {
|
||||
expect(measuredRouteLengthM(route)).toBeCloseTo(2000, 0);
|
||||
expect(measuredRouteLengthM([at(0, 0)])).toBe(0);
|
||||
expect(measuredRouteLengthM([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,11 +54,16 @@ function crosses(a: Local, b: Local, c: Local, d: Local): number | null {
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : null;
|
||||
}
|
||||
|
||||
/** The rings of an object: a point is one ring of one point, a line one open ring, an area its outline and holes. */
|
||||
function ringsOf(geometry: PaddleGeometry): ReadonlyArray<ReadonlyArray<readonly [number, number]>> {
|
||||
if (geometry.type === 'Point') return [[geometry.coordinates, geometry.coordinates]];
|
||||
if (geometry.type === 'LineString') return [geometry.coordinates];
|
||||
return geometry.coordinates;
|
||||
/**
|
||||
* The rings of an object: a point is one ring of one point, a line one open
|
||||
* ring, an area its outline and holes. Read as plain numbers, since the client
|
||||
* compiles this without strict mode, where the schema's pairs are "maybe two".
|
||||
*/
|
||||
function ringsOf(geometry: PaddleGeometry): Array<Array<readonly [number, number]>> {
|
||||
const pair = (position: readonly unknown[]): readonly [number, number] => [Number(position[0]), Number(position[1])];
|
||||
if (geometry.type === 'Point') return [[pair(geometry.coordinates), pair(geometry.coordinates)]];
|
||||
if (geometry.type === 'LineString') return [geometry.coordinates.map(pair)];
|
||||
return geometry.coordinates.map((ring) => ring.map(pair));
|
||||
}
|
||||
|
||||
function segmentsOf(rings: ReadonlyArray<readonly Local[]>): Array<[Local, Local]> {
|
||||
@@ -154,6 +159,29 @@ function closestApproach(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The flat view of a route and the distance along it at each point, as the relations measure it. */
|
||||
function measured(route: readonly LatLng[]): { path: Local[]; along: number[] } {
|
||||
const project = projector(route[0]?.[0] ?? 0);
|
||||
const path = route.map(([lat, lng]) => project(lat, lng));
|
||||
const along: number[] = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1] as Local;
|
||||
const b = path[i] as Local;
|
||||
along.push((along[i - 1] ?? 0) + Math.hypot(b.x - a.x, b.y - a.y));
|
||||
}
|
||||
return { path, along };
|
||||
}
|
||||
|
||||
/**
|
||||
* The length of a route as the relations measure along it, in metres: what
|
||||
* their `alongM` is a part of, so a view that draws the route at another
|
||||
* scale places an obstacle by its share of this length.
|
||||
*/
|
||||
export function measuredRouteLengthM(route: readonly LatLng[]): number {
|
||||
const { along } = measured(route);
|
||||
return along[along.length - 1] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The obstacles on or near a route, in the order the route reaches them. The
|
||||
* route is `[lat, lng]` pairs as the planner draws it; an empty or one-point
|
||||
@@ -167,13 +195,7 @@ export function relateObstaclesToRoute(
|
||||
): RelatedObstacle[] {
|
||||
if (route.length < 2) return [];
|
||||
const project = projector(route[0]?.[0] ?? 0);
|
||||
const path = route.map(([lat, lng]) => project(lat, lng));
|
||||
const along: number[] = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1] as Local;
|
||||
const b = path[i] as Local;
|
||||
along.push((along[i - 1] ?? 0) + Math.hypot(b.x - a.x, b.y - a.y));
|
||||
}
|
||||
const { path, along } = measured(route);
|
||||
const routeBounds = boundsOf(path, NEAR_M);
|
||||
const related: RelatedObstacle[] = [];
|
||||
for (const feature of features) {
|
||||
|
||||
Reference in New Issue
Block a user