mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 309bef4db7 | |||
| 6e59240328 | |||
| bdb082d490 | |||
| 8957503dbe | |||
| 0a50119c52 | |||
| 1d090e0ac9 | |||
| d118c3cee9 | |||
| e3f3b0458b | |||
| 976a612351 | |||
| d87e276831 | |||
| 62b50e3f9b | |||
| 84d637f5fe | |||
| 77c4785a9d | |||
| 6d43f3a4e6 | |||
| a805b4b882 | |||
| e3983ef857 | |||
| 160bc89e78 | |||
| a9882a6415 | |||
| 6b7461ee03 | |||
| 0eca20f2a4 | |||
| 796bbb065d | |||
| 20d8bae46b | |||
| 98cc57942b | |||
| e7f2e17954 | |||
| f746c22a83 | |||
| 723bfa507b | |||
| 3c8fa07f44 | |||
| 4ceade9d8b | |||
| dae20c166a | |||
| 0d4ddb2089 | |||
| 775b35d5af | |||
| 76b7b305b2 | |||
| eb4e74b5c3 | |||
| 10a987fec1 | |||
| 7c6b7622fc | |||
| 341520f98b | |||
| 2fe8590c0b | |||
| b3ef13616f | |||
| db531d2bef | |||
| f463c44d03 | |||
| 07a82be64e | |||
| 4efc50c012 | |||
| 7a2846b3fa | |||
| e87c68b811 | |||
| 26833f374c | |||
| 7795061630 | |||
| 09227ec0d1 | |||
| dbee1664af | |||
| 4c487ceefa | |||
| dd43999f1f | |||
| 9a84890142 | |||
| eb13d9f785 | |||
| 57ea219a43 | |||
| af9f013c5e | |||
| f870dfc6d1 | |||
| bf26175724 |
@@ -5,8 +5,9 @@
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
|
||||
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
|
||||
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
|
||||
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
|
||||
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
|
||||
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
|
||||
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
|
||||
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
|
||||
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||
auto-generate a key persisted to the data PVC.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 876,
|
||||
"no-console": 88,
|
||||
"complexity": 231,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 148,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
|
||||
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Tours/planner/TourPlannerPanels.tsx',
|
||||
template: 'tours.planner.${role}',
|
||||
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dawarich.ts": 16,
|
||||
"dawarich.ts": 15,
|
||||
"dayplan.ts": 18,
|
||||
"docsync.ts": 25,
|
||||
"files.ts": 4,
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1461,
|
||||
"src/components/Map/MapView.tsx": 1450,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2425,
|
||||
"src/components/Map/MapViewGL.tsx": 2414,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2978,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,8 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1168,
|
||||
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
|
||||
"src/pages/TripPlannerPage.tsx": 1153,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
"src/components/Tours/tourParts.tsx": 11,
|
||||
"src/components/Tours/tourParts.tsx": 8,
|
||||
"src/components/Trips/TripFormModal.tsx": 3,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/Vacay/VacayCalendar.tsx": 1,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 23,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// FE-API-FERRATAS-001 to FE-API-FERRATAS-002
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { ferratasApi } from './ferratas';
|
||||
|
||||
const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||
|
||||
describe('ferratasApi.list', () => {
|
||||
it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => {
|
||||
let url = '';
|
||||
server.use(
|
||||
http.get('/api/trips/7/ferratas', ({ request }) => {
|
||||
url = request.url;
|
||||
return HttpResponse.json(reply);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply);
|
||||
expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900');
|
||||
});
|
||||
|
||||
it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/7/ferratas', () =>
|
||||
HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for GET /api/trips/:tripId/ferratas.
|
||||
*
|
||||
* The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS
|
||||
* (25 s by default) and answers 502 when none of them made it. The shared 8 s
|
||||
* on `apiClient` would give up on a slow but healthy mirror and show an error
|
||||
* the server was about to resolve, so this sits just above the server's own
|
||||
* ceiling.
|
||||
*/
|
||||
const FERRATAS_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside
|
||||
* one map area. In its own file rather than in the `client.ts` god module. The
|
||||
* answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared
|
||||
* contract before anything draws it.
|
||||
*/
|
||||
export const ferratasApi = {
|
||||
list: (tripId: number, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS })
|
||||
.then((response) => response.data as unknown),
|
||||
};
|
||||
@@ -646,6 +646,8 @@ describe('AddonManager', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
@@ -265,7 +265,7 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving,
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
@@ -288,15 +288,13 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
{provider !== 'anthropic' && (
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
|
||||
@@ -64,6 +64,19 @@ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name:
|
||||
export const MASKED = '••••••••';
|
||||
export const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1';
|
||||
|
||||
/** What an empty Base URL stands for with each provider, shown as its placeholder. */
|
||||
export const LLM_BASE_URL_PLACEHOLDER: Record<string, string> = {
|
||||
local: DEFAULT_OLLAMA_URL,
|
||||
openai: 'https://api.openai.com/v1',
|
||||
// The Anthropic client appends /v1/messages itself, so a gateway is given without /v1.
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
};
|
||||
|
||||
/** Under the Anthropic key: what only Anthropic can read, and what a gateway in its place needs (#2655). */
|
||||
export const ANTHROPIC_HINT =
|
||||
'Anthropic reads PDFs (including scans) natively. Local and OpenAI models receive extracted text, so scanned PDFs need Anthropic. ' +
|
||||
'A gateway in place of api.anthropic.com goes in Base URL without /v1 and has to accept PDF document blocks and forced tool calls.';
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
|
||||
@@ -52,15 +52,19 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(result.current.isInstalled('llama3')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => {
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
// The OpenAI address does not follow to another provider (#2655).
|
||||
act(() => result.current.setProvider('local'));
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1'));
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
act(() => result.current.setBaseUrl(' http://box:11434/v1 '));
|
||||
await act(() => result.current.loadModels());
|
||||
expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
@@ -148,7 +152,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => {
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
@@ -160,6 +164,50 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' }))
|
||||
);
|
||||
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({
|
||||
provider: 'anthropic',
|
||||
baseUrl: 'http://gateway.lan:4000',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
act(() => result.current.setBaseUrl(' http://gw:4000 '));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' });
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' }))
|
||||
);
|
||||
act(() => result.current.setProvider('local'));
|
||||
expect(result.current.baseUrl).toBe('');
|
||||
act(() => result.current.setProvider('openai'));
|
||||
expect(result.current.baseUrl).toBe('');
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
||||
act(() => result.current.setBaseUrl('http://typed:4000'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrl).toBe('http://typed:4000');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1');
|
||||
act(() => result.current.setProvider('openai'));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1');
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
||||
@@ -15,7 +15,7 @@ import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const toast = useToast();
|
||||
const cfg = addon.config ?? {};
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
||||
@@ -30,6 +30,18 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
const [pullPct, setPullPct] = useState(0);
|
||||
const [pullStatus, setPullStatus] = useState('');
|
||||
|
||||
/**
|
||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
|
||||
* saved provider, its saved address returns. Any provider, Anthropic included, may
|
||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||
*/
|
||||
const setProvider = (next: string) => {
|
||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
||||
setProviderState(next);
|
||||
};
|
||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
@@ -89,7 +101,7 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
config: {
|
||||
provider,
|
||||
model: model.trim(),
|
||||
baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
@@ -109,6 +121,7 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
setModel,
|
||||
baseUrl,
|
||||
setBaseUrl,
|
||||
baseUrlPlaceholder,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
vision,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-009: the card for one picked via ferrata.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import { type FerrataGroup, groupFerratas } from './ferrataGroups';
|
||||
|
||||
/** About 222 m of latitude per segment. */
|
||||
const segment = (id: number, from: number, overrides: Partial<Ferrata> = {}): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: 'Via ferrata Pisciadù',
|
||||
grade: { raw: '5', level: 5, plus: '+' },
|
||||
routeName: 'Sella group',
|
||||
geometry: [
|
||||
[from, 11.81],
|
||||
[from + 0.002, 11.81],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } });
|
||||
const UPPER = segment(78, 46.532);
|
||||
|
||||
function groupOf(...segments: Ferrata[]): FerrataGroup {
|
||||
const [group] = groupFerratas(segments);
|
||||
if (!group) throw new Error('no group');
|
||||
return group;
|
||||
}
|
||||
|
||||
describe('FerrataDetailCard', () => {
|
||||
it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Sella group')).toBeInTheDocument();
|
||||
// A badge, not a line colour: the grade is neutral, never the tour's green to red.
|
||||
expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary');
|
||||
expect(screen.getByText('445 m')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
|
||||
const link = screen.getByRole('link', { name: /View in OpenStreetMap/ });
|
||||
|
||||
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => {
|
||||
const unnamed = segment(77, 46.53, { name: null });
|
||||
render(<FerrataDetailCard group={groupOf(unnamed)} segment={unnamed} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Sella group')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => {
|
||||
const { rerender } = render(<FerrataDetailCard group={groupOf(UPPER)} segment={UPPER} />);
|
||||
expect(screen.getByText('K5+')).toBeInTheDocument();
|
||||
|
||||
const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } });
|
||||
rerender(<FerrataDetailCard group={groupOf(letter)} segment={letter} />);
|
||||
expect(screen.getByText('C/D')).toHaveClass('text-content-secondary');
|
||||
|
||||
const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } });
|
||||
rerender(<FerrataDetailCard group={groupOf(bare)} segment={bare} />);
|
||||
expect(screen.getByText('Grade unknown')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => {
|
||||
const onClose = vi.fn();
|
||||
const group = groupOf(UPPER);
|
||||
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<FerrataDetailCard group={group} segment={UPPER} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => {
|
||||
const { container } = render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={LOWER} />);
|
||||
expect(container.textContent).not.toContain('·');
|
||||
});
|
||||
it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => {
|
||||
const long = 'Herrmann von Barth Klettersteig über den Wetterstein';
|
||||
render(<FerrataDetailCard group={groupOf(segment(79, 46.6, { name: long }))} segment={UPPER} />);
|
||||
const heading = screen.getByRole('heading', { name: long });
|
||||
expect(heading).toHaveClass('line-clamp-2');
|
||||
expect(heading).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-008: offers "Add to tour" only where the tour can be edited, and hands over the ferrata', () => {
|
||||
const group = groupOf(LOWER, UPPER);
|
||||
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
|
||||
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
|
||||
|
||||
const onAdopt = vi.fn();
|
||||
rerender(<FerrataDetailCard group={group} segment={UPPER} onAdopt={onAdopt} />);
|
||||
fireEvent.click(screen.getByTestId('ferrata-adopt'));
|
||||
expect(onAdopt).toHaveBeenCalledWith(group);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-009: a ferrata the tour already climbs says so instead of offering it again', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} onAdopt={vi.fn()} onTour />);
|
||||
expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour');
|
||||
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Check, ExternalLink, MapPinPlus, MoveHorizontal, Route, X } from 'lucide-react';
|
||||
|
||||
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 { 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.
|
||||
* `segment` is the OSM way that was clicked, which the source link opens.
|
||||
* OpenStreetMap's data is ODbL, which asks for the attribution next to
|
||||
* anything shown from it, so the card carries it rather than relying on the
|
||||
* map's own corner.
|
||||
*/
|
||||
export default function FerrataDetailCard({
|
||||
group,
|
||||
segment,
|
||||
onClose,
|
||||
onAdopt,
|
||||
onTour = false,
|
||||
width,
|
||||
}: {
|
||||
group: FerrataGroup;
|
||||
segment: Ferrata;
|
||||
onClose?: () => void;
|
||||
/** Puts the ferrata onto the tour being planned; absent where the tour cannot be edited. */
|
||||
onAdopt?: (group: FerrataGroup) => void;
|
||||
/** The tour on the map already climbs this ferrata: the card says so instead of offering it again. */
|
||||
onTour?: boolean;
|
||||
/** Set when the card stands on another control and takes its width. */
|
||||
width?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
const label = ferrataLabel(group, t);
|
||||
const name = label.name;
|
||||
// The route line only adds something when it is not already the title.
|
||||
const route = group.routeName && group.routeName !== name ? group.routeName : null;
|
||||
// Without any grade the badge says so, rather than leaving a gap.
|
||||
const grade = label.grade ?? t('ferratas.unknownGrade');
|
||||
|
||||
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 }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
{route && <p className="truncate text-caption text-content-muted">{route}</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>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<SoftPill>{grade}</SoftPill>
|
||||
{group.lengthM > 0 && (
|
||||
<SoftPill icon={<MoveHorizontal size={11} aria-hidden="true" />}>
|
||||
{formatDistance(group.lengthM / 1000, unit ?? 'metric')}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
{onTour ? (
|
||||
<div className="mt-3 flex" data-testid="ferrata-on-tour">
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('ferratas.onTour')}
|
||||
</SoftPill>
|
||||
</div>
|
||||
) : (
|
||||
onAdopt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdopt(group)}
|
||||
data-testid="ferrata-adopt"
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('ferratas.adopt')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<a
|
||||
href={ferrataOsmUrl(segment)}
|
||||
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)}>
|
||||
{t('ferratas.attribution')}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// FE-FERRATA-OVERLAY-001 to FE-FERRATA-OVERLAY-004: where the picked ferrata's card sits over the map, and how it closes.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import FerrataDetailOverlay from './FerrataDetailOverlay';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
|
||||
const FERRATA: Ferrata = {
|
||||
id: 'way/42',
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: '4', level: 4, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[46.52, 11.8],
|
||||
[46.53, 11.81],
|
||||
],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/42',
|
||||
};
|
||||
|
||||
const [GROUP] = groupFerratas([FERRATA]);
|
||||
if (!GROUP) throw new Error('no group');
|
||||
const SELECTION: FerrataSelection = { group: GROUP, segment: FERRATA };
|
||||
|
||||
describe('FerrataDetailOverlay', () => {
|
||||
it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => {
|
||||
render(<FerrataDetailOverlay selection={null} onClose={vi.fn()} insetEnd={400} bottom={66} />);
|
||||
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => {
|
||||
render(<FerrataDetailOverlay selection={SELECTION} onClose={vi.fn()} insetEnd={400} bottom={66} />);
|
||||
const overlay = screen.getByTestId('ferrata-detail-overlay');
|
||||
expect(overlay.style.insetInlineEnd).toBe('400px');
|
||||
// Logical only: no physical side, so a right-to-left page mirrors it.
|
||||
expect(overlay.style.right).toBe('');
|
||||
expect(overlay.style.left).toBe('');
|
||||
expect(overlay.style.bottom).toBe('66px');
|
||||
expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card'));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOutsidePress = vi.fn();
|
||||
render(
|
||||
<div>
|
||||
<button type="button">map</button>
|
||||
<FerrataDetailOverlay
|
||||
selection={SELECTION}
|
||||
onClose={onClose}
|
||||
onOutsidePress={onOutsidePress}
|
||||
insetEnd={400}
|
||||
bottom={66}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByText('Via delle Trincee'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onOutsidePress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<FerrataDetailOverlay selection={SELECTION} onClose={onClose} insetEnd={400} bottom={66} />);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
|
||||
/**
|
||||
* The picked ferrata's card over the map, anchored at the bottom of the inline
|
||||
* end, above the row of map controls. `insetEnd` is what the panel on that
|
||||
* side covers plus the gap the layer pill keeps to the panel on the other, so
|
||||
* the card mirrors the pill and never slides under the panel; `bottom` clears
|
||||
* the control row (the layer pill on the flat map, the dock and the grade
|
||||
* legend in 3D).
|
||||
*
|
||||
* It closes on a press anywhere outside it and on Escape. A press on another
|
||||
* ferrata closes this card first and the click then opens that one's. The
|
||||
* press outside is handed to `onOutsidePress` as well, so the map under it can
|
||||
* leave the click that follows alone (see `useOutsidePressGuard`).
|
||||
*/
|
||||
export default function FerrataDetailOverlay({
|
||||
selection,
|
||||
onClose,
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
onAdopt,
|
||||
onTourIds,
|
||||
}: {
|
||||
selection: FerrataSelection | null;
|
||||
onClose: () => void;
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
|
||||
width?: number;
|
||||
onAdopt?: (group: FerrataGroup) => void;
|
||||
/** The ferratas the tour on the map already climbs. */
|
||||
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]);
|
||||
|
||||
if (!selection) 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="ferrata-detail-overlay"
|
||||
>
|
||||
<FerrataDetailCard
|
||||
group={selection.group}
|
||||
segment={selection.segment}
|
||||
onClose={onClose}
|
||||
onAdopt={onAdopt}
|
||||
onTour={onTourIds?.has(selection.group.id) ?? false}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The via ferrata mark as a React element: the ladder on a disc that the map
|
||||
* draws at a ferrata's midpoint, in the same `--map-ferrata` tokens, for the
|
||||
* places beside the map that point at one (a waypoint at its foot or top).
|
||||
*/
|
||||
export function FerrataGlyph({ size = 14, className = '' }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 22 22" aria-hidden="true" className={className}>
|
||||
<circle cx="11" cy="11" r="9.75" fill="var(--map-ferrata-casing)" stroke="var(--map-ferrata)" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6"
|
||||
fill="none"
|
||||
stroke="var(--map-ferrata)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same ladder as a line icon, drawn the way lucide draws its icons (24 grid,
|
||||
* round caps, the current colour), for controls that sit among lucide icons:
|
||||
* the via ferrata switch in the map's layer pill.
|
||||
*/
|
||||
export function FerrataLineIcon({ size = 15, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3v18M16 3v18M8 7h8M8 12h8M8 17h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL';
|
||||
|
||||
/**
|
||||
* The via ferrata layer as a mountable piece for a GL map view: it renders
|
||||
* nothing itself, the lines live on `map`. See useFerrataLayerGL for the
|
||||
* layering, the highlight and the click handling.
|
||||
*/
|
||||
export default function FerrataLayerGL({
|
||||
map,
|
||||
groups,
|
||||
onSelect,
|
||||
selectedId = null,
|
||||
}: {
|
||||
map: Map | null;
|
||||
groups: readonly FerrataGroup[];
|
||||
onSelect: FerrataSelectHandler;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
useFerrataLayerGL(map, groups, onSelect, selectedId);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on
|
||||
* the Leaflet renderer.
|
||||
*
|
||||
* Leaflet itself is not under test: the mock reflects each polyline's and
|
||||
* marker's props back as data attributes, so the assertions read which lines
|
||||
* are drawn, in which order and with what options. Casing and line stay
|
||||
* non-interactive so they cannot swallow clicks meant for places; only the
|
||||
* invisible hit line on top carries the tooltip, the hover and the click.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import FerrataLayerLeaflet from './FerrataLayerLeaflet';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${Object.values(params).join(',')})` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const zoom = { value: 12 };
|
||||
const zoomEnd: { handler: (() => void) | null } = { handler: null };
|
||||
|
||||
type Handlers = Record<string, ((event: { latlng: { lat: number; lng: number } }) => void) | undefined>;
|
||||
const at = { latlng: { lat: 46.505, lng: 11.705 } };
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({
|
||||
positions,
|
||||
pathOptions,
|
||||
interactive,
|
||||
bubblingMouseEvents,
|
||||
eventHandlers,
|
||||
children,
|
||||
}: {
|
||||
positions: unknown;
|
||||
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string };
|
||||
interactive?: boolean;
|
||||
bubblingMouseEvents?: boolean;
|
||||
eventHandlers?: Handlers;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="polyline"
|
||||
data-positions={JSON.stringify(positions)}
|
||||
data-color={pathOptions.color}
|
||||
data-weight={pathOptions.weight}
|
||||
data-opacity={pathOptions.opacity}
|
||||
data-dash={pathOptions.dashArray ?? ''}
|
||||
data-class={pathOptions.className ?? ''}
|
||||
data-interactive={interactive === false ? 'false' : 'true'}
|
||||
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
|
||||
onClick={() => eventHandlers?.click?.(at)}
|
||||
onMouseOver={() => eventHandlers?.mouseover?.(at)}
|
||||
onMouseOut={() => eventHandlers?.mouseout?.(at)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ferrata-icon"
|
||||
data-position={JSON.stringify(position)}
|
||||
onClick={() => eventHandlers?.click?.(at)}
|
||||
/>
|
||||
),
|
||||
Tooltip: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<span data-testid="tooltip" data-class={className ?? ''}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
// A pixel per 0.00001 degree: enough to tell icons apart that sit a few metres from each other.
|
||||
useMap: () => ({
|
||||
getZoom: () => zoom.value,
|
||||
project: ([lat, lng]: [number, number]) => ({ x: lng * 100_000, y: -lat * 100_000 }),
|
||||
}),
|
||||
useMapEvents: (handlers: { zoomend?: () => void }) => {
|
||||
zoomEnd.handler = handlers.zoomend ?? null;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const ferrata = (
|
||||
id: string,
|
||||
name: string | null,
|
||||
level: number | null,
|
||||
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
|
||||
): Ferrata => {
|
||||
const lat = options.lat ?? 46.5;
|
||||
const lng = options.lng ?? 11.7;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
|
||||
routeName: options.routeName ?? null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
};
|
||||
};
|
||||
|
||||
const lines = () => screen.getAllByTestId('polyline');
|
||||
|
||||
beforeEach(() => {
|
||||
zoom.value = 12;
|
||||
});
|
||||
|
||||
describe('FerrataLayerLeaflet', () => {
|
||||
it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => {
|
||||
const { container } = render(<FerrataLayerLeaflet groups={[]} onSelect={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
|
||||
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
|
||||
expect(lines().map((line) => line.dataset.color)).toEqual(['white', 'black', 'white']);
|
||||
expect(lines().map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
|
||||
expect(lines()[1]?.dataset.dash).toBe('6 5');
|
||||
expect(lines()[2]?.dataset.opacity).toBe('0');
|
||||
expect(JSON.parse(lines()[0]?.dataset.positions ?? '[]')).toEqual([
|
||||
[46.5, 11.7],
|
||||
[46.51, 11.71],
|
||||
]);
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.style.removeProperty('--map-ferrata-casing');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Pisciadù', 2),
|
||||
// Picks up where way/1 ends: one ferrata in two segments.
|
||||
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
|
||||
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
|
||||
ferrata('way/4', null, null, { lat: 48 }),
|
||||
]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([
|
||||
'Pisciadùferratas.gradeRange(K2,K4+)',
|
||||
'Pisciadùferratas.gradeRange(K2,K4+)',
|
||||
'Sentiero attrezzatoK3',
|
||||
'ferratas.unnamed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
|
||||
const onSelect = vi.fn();
|
||||
const target = ferrata('way/9', 'Tridentina', 2);
|
||||
const groups = groupFerratas([target]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
|
||||
|
||||
const hit = lines()[2];
|
||||
if (!hit) throw new Error('no hit line');
|
||||
fireEvent.click(hit);
|
||||
expect(onSelect).toHaveBeenCalledWith(groups[0], target, { lng: 11.705, lat: 46.505 });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-005: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
expect(lines()[2]?.dataset.bubbles).toBe('false');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-006: the grade sits beside the name as its own label, without a separator', () => {
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 4)])} onSelect={vi.fn()} />);
|
||||
const tip = screen.getByTestId('tooltip');
|
||||
expect(tip.textContent).not.toContain('·');
|
||||
expect(tip.querySelector('span')?.textContent).toBe('K4');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the deeper violet, and so does the open card', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata-lit', 'navy');
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Via A', 2),
|
||||
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
|
||||
ferrata('way/3', 'Via B', 3, { lat: 47 }),
|
||||
]);
|
||||
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
// Casings 0 to 2, lines 3 to 5, hit lines 6 to 8.
|
||||
const lineColours = () =>
|
||||
lines()
|
||||
.slice(3, 6)
|
||||
.map((line) => line.dataset.color);
|
||||
expect(lineColours()).not.toContain('navy');
|
||||
|
||||
const hit = lines()[7];
|
||||
if (!hit) throw new Error('no hit line');
|
||||
fireEvent.mouseOver(hit);
|
||||
expect(lineColours().map((colour) => colour === 'navy')).toEqual([true, true, false]);
|
||||
fireEvent.mouseOut(hit);
|
||||
expect(lineColours()).not.toContain('navy');
|
||||
|
||||
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
|
||||
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
|
||||
root.style.removeProperty('--map-ferrata-lit');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
|
||||
const onSelect = vi.fn();
|
||||
const groups = groupFerratas([ferrata('way/1', 'Via A', 2), ferrata('way/2', 'Via B', 3, { lat: 47 })]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
|
||||
expect(screen.queryAllByTestId('ferrata-icon')).toHaveLength(0);
|
||||
|
||||
zoom.value = 14;
|
||||
act(() => zoomEnd.handler?.());
|
||||
const icons = screen.getAllByTestId('ferrata-icon');
|
||||
expect(icons).toHaveLength(2);
|
||||
const [lat, lng] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
|
||||
expect(lat).toBeCloseTo(46.505, 4);
|
||||
expect(lng).toBeCloseTo(11.705, 4);
|
||||
|
||||
fireEvent.click(icons[1] as HTMLElement);
|
||||
expect(onSelect).toHaveBeenCalledWith(groups[1], groups[1]?.segments[0], { lng: 11.705, lat: 46.505 });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
expect(lines()[1]?.dataset.color).toBe('black');
|
||||
|
||||
await act(async () => {
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
});
|
||||
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
|
||||
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[0]?.id ?? null} />);
|
||||
expect(lines().map((line) => line.dataset.weight)).toEqual(['9', '4', '16']);
|
||||
expect(lines()[1]?.dataset.dash).toBe('');
|
||||
expect(lines()[2]?.dataset.class).toBe('trek-ferrata-hit');
|
||||
expect(screen.getByTestId('tooltip').dataset.class).toBe('map-tooltip');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-011: of two icons that would touch only the longer ferrata keeps its own', () => {
|
||||
const short = ferrata('way/1', 'Via A', 2);
|
||||
const long: Ferrata = {
|
||||
...ferrata('way/2', 'Via B', 3, { lat: 46.5001 }),
|
||||
geometry: [
|
||||
[46.4951, 11.695],
|
||||
[46.5151, 11.715],
|
||||
],
|
||||
};
|
||||
const groups = groupFerratas([short, long]);
|
||||
zoom.value = 14;
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
const icons = screen.getAllByTestId('ferrata-icon');
|
||||
expect(icons).toHaveLength(1);
|
||||
const [lat] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
|
||||
expect(lat).toBeCloseTo(46.5051, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import L from 'leaflet';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
import type { FerrataSelectHandler } from './useFerrataLayerGL';
|
||||
import type { FerrataMapLayer } from './useFerrataMapLayer';
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
/**
|
||||
* The route icon for Leaflet: the shared SVG in a divIcon, painted straight
|
||||
* from the tokens, so a theme change restyles it without rebuilding anything.
|
||||
* Built once; it carries no user content.
|
||||
*/
|
||||
/** The hit line's class, which index.css keeps free of the focus ring a clicked path draws. */
|
||||
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
|
||||
|
||||
const ICON = L.divIcon({
|
||||
html: ferrataIconSvg({ line: 'var(--map-ferrata)', casing: 'var(--map-ferrata-casing)' }),
|
||||
className: 'trek-ferrata-icon',
|
||||
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
|
||||
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
|
||||
});
|
||||
|
||||
/**
|
||||
* The ferratas whose icon shows: none below FERRATA_ICON_MIN_ZOOM, and above
|
||||
* it only those whose icon touches no other one at this zoom, the longer
|
||||
* ferrata first. Leaflet has no symbol placement of its own, so this is what
|
||||
* GL's `icon-allow-overlap: false` does there. Positions are projected at the
|
||||
* zoom, which a pan does not change, so only a zoom or new ferratas place again.
|
||||
*/
|
||||
function useIconGroups(groups: readonly FerrataGroup[]): readonly FerrataGroup[] {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
|
||||
return useMemo(() => {
|
||||
if (zoom < FERRATA_ICON_MIN_ZOOM) return [];
|
||||
const longestFirst = [...groups].sort((a, b) => b.lengthM - a.lengthM);
|
||||
const shown = placeFerrataIcons(
|
||||
longestFirst.map((group) => {
|
||||
const { x, y } = map.project(group.midpoint, zoom);
|
||||
return { id: group.id, x, y };
|
||||
})
|
||||
);
|
||||
return groups.filter((group) => shown.has(group.id));
|
||||
}, [map, groups, zoom]);
|
||||
}
|
||||
|
||||
/** The tooltip's words: the ferrata's name and its grade. */
|
||||
function FerrataTooltipText({ group }: { group: FerrataGroup }) {
|
||||
const { t } = useTranslation();
|
||||
const { name, grade } = ferrataLabel(group, t);
|
||||
return (
|
||||
<>
|
||||
{name}
|
||||
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer on the Leaflet renderer, in the same construction as
|
||||
* the GL layer: per OSM way a light casing and the dark dashed line, the whole
|
||||
* ferrata lit (solid, wider, on a wider casing, in the highlight colour) while
|
||||
* the pointer is on it or its card is open, a sticky tooltip in the map's
|
||||
* tooltip look naming it with its grade, and from FERRATA_ICON_MIN_ZOOM a
|
||||
* small route icon halfway along it where it does not touch another one. A
|
||||
* click hands the ferrata and the way to `onSelect`, like a click on the GL
|
||||
* layer does.
|
||||
*/
|
||||
export default function FerrataLayerLeaflet({
|
||||
groups,
|
||||
onSelect,
|
||||
selectedId = null,
|
||||
}: {
|
||||
groups: readonly FerrataGroup[];
|
||||
onSelect: FerrataSelectHandler;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const paint = useFerrataPaint();
|
||||
const iconGroups = useIconGroups(groups);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
|
||||
const lines = useMemo(() => {
|
||||
const casing = { color: paint.casing, weight: 6, opacity: 0.9, lineCap: 'round', lineJoin: 'round' } as const;
|
||||
const litCasing = { ...casing, weight: 9, opacity: 1 } as const;
|
||||
const line = {
|
||||
color: paint.line,
|
||||
weight: 2.5,
|
||||
opacity: 1,
|
||||
dashArray: '6 5',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
} as const;
|
||||
// Solid: an empty dash list clears the dashes Leaflet would otherwise keep from the line's options.
|
||||
const highlight = {
|
||||
color: paint.highlight,
|
||||
weight: 4,
|
||||
opacity: 1,
|
||||
dashArray: '',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
} as const;
|
||||
// The class lets the stylesheet drop the focus ring a clicked SVG path keeps; the card has its own focus.
|
||||
const hit = { color: paint.casing, weight: 16, opacity: 0, className: FERRATA_HIT_CLASS } as const;
|
||||
return groups.flatMap((group) =>
|
||||
group.segments.map((segment) => ({
|
||||
group,
|
||||
segment,
|
||||
positions: ferrataPositions(segment),
|
||||
casing,
|
||||
litCasing,
|
||||
line,
|
||||
highlight,
|
||||
hit,
|
||||
}))
|
||||
);
|
||||
}, [groups, paint]);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
|
||||
return (
|
||||
<>
|
||||
{lines.map(({ group, segment, positions, casing, litCasing }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-casing`}
|
||||
positions={positions}
|
||||
pathOptions={lit(group) ? litCasing : casing}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{lines.map(({ group, segment, positions, line, highlight }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-line`}
|
||||
positions={positions}
|
||||
pathOptions={lit(group) ? highlight : line}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* A wide, invisible line on top carries the tooltip, the hover and the click, so a
|
||||
thin dashed route is still easy to hit with a finger. The click stops here: it
|
||||
opens the ferrata's card and must not also drop a control point under it. */}
|
||||
{lines.map(({ group, segment, positions, hit }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-hit`}
|
||||
positions={positions}
|
||||
pathOptions={hit}
|
||||
bubblingMouseEvents={false}
|
||||
eventHandlers={{
|
||||
click: (event) => onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat }),
|
||||
mouseover: () => setHovered(group.id),
|
||||
mouseout: () => setHovered((current) => (current === group.id ? null : current)),
|
||||
}}
|
||||
>
|
||||
<Tooltip sticky direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
|
||||
<FerrataTooltipText group={group} />
|
||||
</Tooltip>
|
||||
</Polyline>
|
||||
))}
|
||||
{iconGroups.map((group) => (
|
||||
<Marker
|
||||
key={`${group.id}-icon`}
|
||||
position={group.midpoint}
|
||||
icon={ICON}
|
||||
keyboard={false}
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
const segment = group.segments[0];
|
||||
if (segment) onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */
|
||||
export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) {
|
||||
if (!layer?.active) return null;
|
||||
return <FerrataLayerLeaflet groups={layer.groups} onSelect={layer.onSelect} selectedId={layer.selectedId} />;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// FE-FERRATA-CROSS-001 to FE-FERRATA-CROSS-009: where a tour's line climbs a via ferrata, and the tiles along it.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { crossingLegs, ferrataEnds, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
|
||||
/** About 11 m of latitude. */
|
||||
const STEP = 0.0001;
|
||||
|
||||
function ferrata(id: number, from: [number, number], steps: number): Ferrata {
|
||||
return {
|
||||
id: `way/${id}`,
|
||||
name: 'Via Test',
|
||||
routeName: null,
|
||||
grade: { raw: '3', level: 3, plus: null },
|
||||
geometry: Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]] as [number, number]),
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A line north from a point, one point every STEP. */
|
||||
function northFrom(from: [number, number], steps: number): [number, number][] {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]]);
|
||||
}
|
||||
|
||||
describe('routeFerrataCrossings', () => {
|
||||
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 20)]);
|
||||
|
||||
it('FE-FERRATA-CROSS-001: a route climbing along the ferrata gives one stretch, measured along the route', () => {
|
||||
const route = [...northFrom([47.399, 11.0], 9), ...northFrom([47.4, 11.0], 20)];
|
||||
const [stretch, ...rest] = routeFerrataCrossings(route, groups);
|
||||
expect(rest).toEqual([]);
|
||||
expect(stretch.group.id).toBe(groups[0].id);
|
||||
expect(stretch.fromM).toBeGreaterThan(95);
|
||||
expect(stretch.toM - stretch.fromM).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-002: a trail that only crosses the ferrata is no stretch', () => {
|
||||
const across: [number, number][] = Array.from({ length: 21 }, (_, i) => [47.401, 10.999 + i * STEP]);
|
||||
expect(routeFerrataCrossings(across, groups)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-003: a route nowhere near a ferrata, or no route, gives nothing', () => {
|
||||
expect(routeFerrataCrossings(northFrom([46.0, 10.0], 20), groups)).toEqual([]);
|
||||
expect(routeFerrataCrossings(null, groups)).toEqual([]);
|
||||
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 20), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
|
||||
// Two pieces of one ferrata with 44 m missing between them: the route point
|
||||
// in the middle of the hole is out of reach, the ones beside it are not.
|
||||
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
|
||||
expect(pieces).toHaveLength(1);
|
||||
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeFerrataCrossings at the end of a ferrata', () => {
|
||||
it('FE-FERRATA-CROSS-007: a route that only runs beside the end of a long ferrata is no stretch', () => {
|
||||
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 40)]);
|
||||
// Ten points (about 100 m) along the ferrata's top, then away.
|
||||
const route = [...northFrom([47.4033, 11.0], 9), ...northFrom([47.405, 11.0], 10)];
|
||||
expect(routeFerrataCrossings(route, groups)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeFerrataCrossings beside a second ferrata', () => {
|
||||
it('FE-FERRATA-CROSS-008: where two ferratas run a few metres apart, the route belongs to the nearer one', () => {
|
||||
const near = ferrata(1, [47.4, 11.0], 30);
|
||||
// A second ferrata about 15 m to the east, along the same stretch.
|
||||
const beside = { ...ferrata(2, [47.4, 11.0002], 30), name: 'Via Beside' };
|
||||
const groups = groupFerratas([beside, near]);
|
||||
const [stretch, ...rest] = routeFerrataCrossings(northFrom([47.4, 11.0], 30), groups);
|
||||
expect(rest).toEqual([]);
|
||||
expect(stretch.group.name).toBe('Via Test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crossingLegs', () => {
|
||||
it('FE-FERRATA-CROSS-009: places waypoints in order along a route that passes the same spot twice, and a stretch by its middle', () => {
|
||||
// Out 300 m north and back: the turn point is waypoint b, and a and c stand at the same spot.
|
||||
const samples = [0, 100, 200, 300, 400, 500, 600].map((distanceMeters, i) => ({
|
||||
distanceMeters,
|
||||
lat: 47.4 + (i <= 3 ? i : 6 - i) * 0.0009,
|
||||
lng: 11,
|
||||
}));
|
||||
const waypoints = [
|
||||
{ id: 'a', lat: 47.4, lng: 11 },
|
||||
{ id: 'b', lat: 47.4027, lng: 11 },
|
||||
{ id: 'c', lat: 47.4, lng: 11 },
|
||||
];
|
||||
expect(
|
||||
crossingLegs(waypoints, samples, [
|
||||
{ fromM: 50, toM: 250 },
|
||||
{ fromM: 280, toM: 520 },
|
||||
])
|
||||
).toEqual(['a', 'b']);
|
||||
expect(crossingLegs([], samples, [{ fromM: 50, toM: 80 }])).toEqual([null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataTilesAlong', () => {
|
||||
it('FE-FERRATA-CROSS-005: names each grid tile the route passes once, and gives up past the cap', () => {
|
||||
const tiles = ferrataTilesAlong(northFrom([47.4, 11.0], 20));
|
||||
expect(tiles).toHaveLength(1);
|
||||
const long: [number, number][] = Array.from({ length: 400 }, (_, i) => [40 + i * 0.06, 11]);
|
||||
expect(ferrataTilesAlong(long)).toBeNull();
|
||||
expect(ferrataTilesAlong(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataEnds', () => {
|
||||
it('FE-FERRATA-CROSS-006: the two ends farthest apart, across the segments of one ferrata', () => {
|
||||
const [group] = groupFerratas([ferrata(1, [47.4, 11.0], 5), ferrata(2, [47.4005, 11.0], 5)]);
|
||||
const ends = ferrataEnds(group);
|
||||
expect(ends).not.toBeNull();
|
||||
const lats = ends ? ends.map(([lat]) => lat).sort() : [];
|
||||
expect(lats[0]).toBeCloseTo(47.4, 6);
|
||||
expect(lats[1]).toBeCloseTo(47.401, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { ferrataTileAt, ferrataTileKey, type FerrataTile } from '@trek/shared';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* Where a tour's line runs along a via ferrata. The router walks a ferrata
|
||||
* like any trail once the difficulty cap allows alpine routes (T4 and up), so
|
||||
* a tour can climb one without anyone having picked it; this finds those
|
||||
* stretches on the line the planner shows, for the warning badge and the band
|
||||
* on the elevation profile.
|
||||
*/
|
||||
export interface FerrataCrossing {
|
||||
group: FerrataGroup;
|
||||
/** Where the stretch starts and ends, in metres along the route, as the elevation profile counts them. */
|
||||
fromM: number;
|
||||
toM: number;
|
||||
}
|
||||
|
||||
/** How close a route point has to come to a ferrata to count as on it. */
|
||||
const ON_FERRATA_M = 20;
|
||||
/**
|
||||
* A shorter run is a trail crossing the ferrata or meeting it at an end, not a
|
||||
* climb along it. A ferrata shorter than that counts once most of it is walked.
|
||||
*/
|
||||
const MIN_STRETCH_M = 150;
|
||||
const MIN_SHARE_OF_SHORT_FERRATA = 0.6;
|
||||
/** Two stretches on the same ferrata this close together are one: a gap in the data, not a way off it. */
|
||||
const JOIN_GAP_M = 30;
|
||||
/** The most grid tiles a route asks for, about a thousand kilometres of line. */
|
||||
const MAX_ROUTE_TILES = 200;
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
function distanceM([lat1, lng1]: readonly [number, number], [lat2, lng2]: readonly [number, number]): number {
|
||||
const dLat = (lat2 - lat1) * RAD;
|
||||
const dLng = (lng2 - lng1) * RAD;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/** The distance from a point to a polyline, in metres, on a flat projection around the point (fine at these scales). */
|
||||
function distanceToLineM(point: readonly [number, number], line: readonly [number, number][]): number {
|
||||
const metresPerLat = EARTH_RADIUS_M * RAD;
|
||||
const metresPerLng = metresPerLat * Math.cos(point[0] * RAD);
|
||||
const project = ([lat, lng]: readonly [number, number]) => [
|
||||
(lng - point[1]) * metresPerLng,
|
||||
(lat - point[0]) * metresPerLat,
|
||||
];
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const [ax, ay] = project(line[i - 1]);
|
||||
const [bx, by] = project(line[i]);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const span = dx * dx + dy * dy;
|
||||
const t = span === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / span));
|
||||
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
|
||||
}
|
||||
if (line.length === 1) {
|
||||
const [x, y] = project(line[0]);
|
||||
best = Math.hypot(x, y);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Whether a point lies inside a group's box widened by the reach. */
|
||||
function nearBox(point: readonly [number, number], group: FerrataGroup): boolean {
|
||||
const padLat = ON_FERRATA_M / (EARTH_RADIUS_M * RAD);
|
||||
const padLng = padLat / Math.max(0.1, Math.cos(point[0] * RAD));
|
||||
const { south, north, west, east } = group.bbox;
|
||||
return (
|
||||
point[0] >= south - padLat && point[0] <= north + padLat && point[1] >= west - padLng && point[1] <= east + padLng
|
||||
);
|
||||
}
|
||||
|
||||
/** The ferrata a route point is on, if any. */
|
||||
function ferrataAt(
|
||||
point: readonly [number, number],
|
||||
groups: readonly FerrataGroup[],
|
||||
lines: ReadonlyMap<string, [number, number][][]>
|
||||
): FerrataGroup | null {
|
||||
// The nearest one within reach: two ferratas often start a few metres apart at the same foot.
|
||||
let nearest: FerrataGroup | null = null;
|
||||
let nearestM = ON_FERRATA_M;
|
||||
for (const group of groups) {
|
||||
if (!nearBox(point, group)) continue;
|
||||
for (const line of lines.get(group.id) ?? []) {
|
||||
const gap = distanceToLineM(point, line);
|
||||
if (gap <= nearestM) {
|
||||
nearestM = gap;
|
||||
nearest = group;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretches of the route that run along a ferrata, in route order. A
|
||||
* stretch is measured between the route points that lie on the ferrata, so a
|
||||
* trail that only crosses one or meets its foot stays out.
|
||||
*/
|
||||
export function routeFerrataCrossings(
|
||||
route: readonly (readonly [number, number])[] | null | undefined,
|
||||
groups: readonly FerrataGroup[]
|
||||
): FerrataCrossing[] {
|
||||
if (!route || route.length < 2 || groups.length === 0) return [];
|
||||
const lines = new Map(groups.map((group) => [group.id, group.segments.map(ferrataPositions)]));
|
||||
const stretches: FerrataCrossing[] = [];
|
||||
let along = 0;
|
||||
let open: FerrataCrossing | null = null;
|
||||
for (let i = 0; i < route.length; i += 1) {
|
||||
if (i > 0) along += distanceM(route[i - 1], route[i]);
|
||||
const group = ferrataAt(route[i], groups, lines);
|
||||
if (open && group === open.group) {
|
||||
open.toM = along;
|
||||
continue;
|
||||
}
|
||||
if (open) stretches.push(open);
|
||||
open = group ? { group, fromM: along, toM: along } : null;
|
||||
}
|
||||
if (open) stretches.push(open);
|
||||
|
||||
const joined: FerrataCrossing[] = [];
|
||||
for (const stretch of stretches) {
|
||||
const last = joined[joined.length - 1];
|
||||
if (last && last.group === stretch.group && stretch.fromM - last.toM <= JOIN_GAP_M) last.toM = stretch.toM;
|
||||
else joined.push({ ...stretch });
|
||||
}
|
||||
return joined.filter(
|
||||
(stretch) =>
|
||||
stretch.toM - stretch.fromM >= Math.min(MIN_STRETCH_M, stretch.group.lengthM * MIN_SHARE_OF_SHORT_FERRATA)
|
||||
);
|
||||
}
|
||||
|
||||
/** The grid tiles the route runs through, so the ferratas along it can be loaded with the layer off; null past the cap. */
|
||||
export function ferrataTilesAlong(
|
||||
route: readonly (readonly [number, number])[] | null | undefined
|
||||
): FerrataTile[] | null {
|
||||
if (!route || route.length < 2) return [];
|
||||
const tiles = new Map<string, FerrataTile>();
|
||||
for (const [lat, lng] of route) {
|
||||
const tile = ferrataTileAt(lat, lng);
|
||||
tiles.set(ferrataTileKey(tile), tile);
|
||||
if (tiles.size > MAX_ROUTE_TILES) return null;
|
||||
}
|
||||
return [...tiles.values()];
|
||||
}
|
||||
|
||||
/** The two ends of a ferrata that lie farthest apart, the start and the top of the climb in either order. */
|
||||
export function ferrataEnds(group: FerrataGroup): [[number, number], [number, number]] | null {
|
||||
const ends = group.segments.flatMap((segment) => {
|
||||
const line = ferrataPositions(segment);
|
||||
const first = line[0];
|
||||
const last = line[line.length - 1];
|
||||
return first && last ? [first, last] : [];
|
||||
});
|
||||
let best: [[number, number], [number, number]] | null = null;
|
||||
let bestM = -1;
|
||||
for (let i = 0; i < ends.length; i += 1) {
|
||||
for (let j = i + 1; j < ends.length; j += 1) {
|
||||
const span = distanceM(ends[i], ends[j]);
|
||||
if (span > bestM) {
|
||||
bestM = span;
|
||||
best = [ends[i], ends[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The widest gap two pieces of one ferrata may leave between them and still be walked as one line. */
|
||||
const CHAIN_GAP_M = 80;
|
||||
|
||||
/**
|
||||
* The ferrata as one line from `from` (one of its ends) to its other end: its
|
||||
* OSM pieces, which arrive in any order and either direction, put end to end,
|
||||
* each turned the way the walk goes. Null when the pieces do not join up.
|
||||
*/
|
||||
export function ferrataLine(group: FerrataGroup, from: [number, number]): [number, number][] | null {
|
||||
const pieces = group.segments.map((segment) => ferrataPositions(segment)).filter((line) => line.length >= 2);
|
||||
if (pieces.length === 0) return null;
|
||||
const line: [number, number][] = [];
|
||||
let tail = from;
|
||||
while (pieces.length > 0) {
|
||||
let bestIndex = -1;
|
||||
let bestReversed = false;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
pieces.forEach((piece, index) => {
|
||||
const startGap = distanceM(tail, piece[0]);
|
||||
const endGap = distanceM(tail, piece[piece.length - 1]);
|
||||
if (startGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, false, startGap];
|
||||
if (endGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, true, endGap];
|
||||
});
|
||||
if (bestGap > CHAIN_GAP_M) return null;
|
||||
const [piece] = pieces.splice(bestIndex, 1);
|
||||
const oriented = bestReversed ? [...piece].reverse() : piece;
|
||||
line.push(...(line.length > 0 ? oriented.slice(1) : oriented));
|
||||
tail = oriented[oriented.length - 1];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/** How close a waypoint has to stand to a ferrata's end to count as at its foot or top. */
|
||||
const AT_END_M = 40;
|
||||
|
||||
/**
|
||||
* The ferrata each waypoint stands at an end of, by waypoint id, among the
|
||||
* ferratas the route climbs: the ones "Add to tour" set, or any placed there.
|
||||
*/
|
||||
export function waypointsAtFerrataEnds<T>(
|
||||
waypoints: readonly { id: string; lat: number; lng: number }[],
|
||||
crossings: readonly FerrataCrossing[],
|
||||
describe: (group: FerrataGroup) => T
|
||||
): Record<string, T> {
|
||||
const marks: Record<string, T> = {};
|
||||
const groups = [...new Map(crossings.map(({ group }) => [group.id, group])).values()];
|
||||
for (const point of waypoints) {
|
||||
const at = groups.find((group) =>
|
||||
group.segments.some((segment) => {
|
||||
const line = ferrataPositions(segment);
|
||||
const first = line[0];
|
||||
const last = line[line.length - 1];
|
||||
return [first, last].some((end) => !!end && distanceM([point.lat, point.lng], end) <= AT_END_M);
|
||||
})
|
||||
);
|
||||
if (at) marks[point.id] = describe(at);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each stretch, the waypoint it follows on the route: each waypoint is
|
||||
* placed at the nearest point of the line after the waypoint before it
|
||||
* (`samples`, metres along it as the profile counts them), and a stretch
|
||||
* belongs to the leg its middle lies on. Null
|
||||
* before the first waypoint or without any.
|
||||
*/
|
||||
export function crossingLegs(
|
||||
waypoints: readonly { id: string; lat: number; lng: number }[],
|
||||
samples: readonly { distanceMeters: number; lat?: number; lng?: number }[],
|
||||
crossings: readonly Pick<FerrataCrossing, 'fromM' | 'toM'>[]
|
||||
): Array<string | null> {
|
||||
// In order along the line: each waypoint is looked for from where the one
|
||||
// before it stands, so a route that passes a spot twice keeps its legs apart.
|
||||
let from = 0;
|
||||
const along = waypoints.map((point) => {
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
let at = from;
|
||||
for (const sample of samples) {
|
||||
if (sample.distanceMeters < from || sample.lat == null || sample.lng == null) continue;
|
||||
const gap = distanceM([point.lat, point.lng], [sample.lat, sample.lng]);
|
||||
if (gap < best) {
|
||||
best = gap;
|
||||
at = sample.distanceMeters;
|
||||
}
|
||||
}
|
||||
from = at;
|
||||
return { id: point.id, at };
|
||||
});
|
||||
return crossings.map((crossing) => {
|
||||
// By its middle: the trail to a ferrata's foot often runs beside it for a few metres first.
|
||||
const middle = (crossing.fromM + crossing.toM) / 2;
|
||||
let after: string | null = null;
|
||||
for (const point of along) if (point.at <= middle) after = point.id;
|
||||
return after;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// FE-FERRATA-GROUP-001 to FE-FERRATA-GROUP-009: OSM's short segments read back as the ferratas a climber means.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import {
|
||||
FERRATA_NAME_REACH_M,
|
||||
ferrataBboxesMeet,
|
||||
ferrataGradeRange,
|
||||
ferrataGroupOf,
|
||||
ferrataGroupsInView,
|
||||
groupFerratas,
|
||||
} from './ferrataGroups';
|
||||
|
||||
/** About 111 m of latitude. */
|
||||
const STEP = 0.001;
|
||||
|
||||
function segment(
|
||||
id: number,
|
||||
from: [number, number],
|
||||
options: {
|
||||
name?: string | null;
|
||||
routeName?: string | null;
|
||||
level?: number | null;
|
||||
plus?: '+' | '-' | null;
|
||||
raw?: string;
|
||||
} = {}
|
||||
): Ferrata {
|
||||
const level = options.level === undefined ? 3 : options.level;
|
||||
return {
|
||||
id: `way/${id}`,
|
||||
name: options.name === undefined ? 'Via X' : options.name,
|
||||
routeName: options.routeName ?? null,
|
||||
grade: { raw: options.raw ?? (level === null ? null : String(level)), level, plus: options.plus ?? null },
|
||||
geometry: [from, [from[0] + STEP, from[1]]],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('groupFerratas', () => {
|
||||
it('FE-FERRATA-GROUP-001: joins touching segments of one name into one ferrata, named after them', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(2, [46.5, 11.7], { level: 4 }),
|
||||
segment(1, [46.5 + STEP, 11.7], { level: 2 }),
|
||||
segment(3, [46.5 + 2 * STEP, 11.7], { level: 3 }),
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
const [group] = groups;
|
||||
expect(group?.id).toBe('group/way/1');
|
||||
expect(group?.name).toBe('Via X');
|
||||
expect(group?.segments.map((s) => s.id)).toEqual(['way/2', 'way/1', 'way/3']);
|
||||
expect(group?.lengthM).toBeGreaterThan(330);
|
||||
expect(group?.lengthM).toBeLessThan(340);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-002: chains segments within the reach, and keeps a homonym far away apart', () => {
|
||||
const gap = (FERRATA_NAME_REACH_M - 50) / 111_195;
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7]),
|
||||
segment(2, [46.5 + STEP + gap, 11.7]),
|
||||
// The same name ten kilometres north is another ferrata.
|
||||
segment(3, [46.6, 11.7]),
|
||||
]);
|
||||
expect(groups.map((g) => g.segments.map((s) => s.id))).toEqual([['way/1', 'way/2'], ['way/3']]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-003: reads names without case or outer spaces, and keeps every unnamed segment alone', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7], { name: 'Via X' }),
|
||||
segment(2, [46.5 + STEP, 11.7], { name: ' via x ' }),
|
||||
segment(3, [46.5, 11.71], { name: null }),
|
||||
segment(4, [46.5 + STEP, 11.71], { name: null }),
|
||||
]);
|
||||
expect(groups.map((g) => g.segments.length)).toEqual([2, 1, 1]);
|
||||
expect(groups[1]?.name).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-004: groups by the route relation first, over a wider reach and whatever the ways are named', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(10, [46.5, 11.7], { routeName: 'Brigata Tridentina', name: 'Pitch 1' }),
|
||||
// 1.5 km on, the same route.
|
||||
segment(11, [46.5135, 11.7], { routeName: 'Brigata Tridentina', name: null }),
|
||||
// A way named like the route but outside the relation is its own ferrata.
|
||||
segment(12, [46.5 + STEP, 11.7], { name: 'Brigata Tridentina' }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.segments.map((s) => s.id)).toEqual(['way/10', 'way/11']);
|
||||
expect(groups[0]?.routeName).toBe('Brigata Tridentina');
|
||||
expect(groups[0]?.name).toBe('Pitch 1');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-005: spans the grades from the easiest to the hardest segment, minus before plus', () => {
|
||||
const [group] = groupFerratas([
|
||||
segment(1, [46.5, 11.7], { level: 4, plus: '+' }),
|
||||
segment(2, [46.5 + STEP, 11.7], { level: 2, plus: '-' }),
|
||||
segment(3, [46.5 + 2 * STEP, 11.7], { level: 2 }),
|
||||
segment(4, [46.5 + 3 * STEP, 11.7], { level: null }),
|
||||
]);
|
||||
expect(group && ferrataGradeRange(group)).toEqual({ from: 'K2-', to: 'K4+' });
|
||||
expect(group?.rawGrade).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-006: without a numeric grade keeps the tag as written, and has no range', () => {
|
||||
const [group] = groupFerratas([segment(1, [46.5, 11.7], { level: null, raw: 'C/D' })]);
|
||||
expect(group && ferrataGradeRange(group)).toBeNull();
|
||||
expect(group?.rawGrade).toBe('C/D');
|
||||
const [single] = groupFerratas([segment(2, [46.5, 11.7], { level: 3 })]);
|
||||
expect(single && ferrataGradeRange(single)).toEqual({ from: 'K3', to: 'K3' });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-007: puts the label halfway along the longest segment and spans every segment in its box', () => {
|
||||
const long: Ferrata = {
|
||||
...segment(1, [46.5, 11.7]),
|
||||
geometry: [
|
||||
[46.5, 11.7],
|
||||
[46.5 + 4 * STEP, 11.7],
|
||||
],
|
||||
};
|
||||
const [group] = groupFerratas([segment(2, [46.5 + 4 * STEP, 11.7]), long]);
|
||||
expect(group?.midpoint[0]).toBeCloseTo(46.502, 5);
|
||||
expect(group?.midpoint[1]).toBeCloseTo(11.7, 5);
|
||||
expect(group?.bbox.south).toBe(46.5);
|
||||
expect(group?.bbox.north).toBeCloseTo(46.505, 9);
|
||||
expect(group?.bbox.west).toBe(11.7);
|
||||
expect(group?.bbox.east).toBe(11.7);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-008: finds the ferrata a way belongs to, and counts the ferratas reaching into a view', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7]),
|
||||
segment(2, [46.5 + STEP, 11.7]),
|
||||
segment(3, [46.6, 11.8], { name: 'Other' }),
|
||||
]);
|
||||
expect(ferrataGroupOf(groups, 'way/2')?.id).toBe('group/way/1');
|
||||
expect(ferrataGroupOf(groups, 'way/99')).toBeNull();
|
||||
expect(ferrataGroupOf(groups, null)).toBeNull();
|
||||
expect(ferrataGroupsInView(groups, { south: 46.49, west: 11.69, north: 46.51, east: 11.71 })).toBe(1);
|
||||
expect(ferrataGroupsInView(groups, { south: 46.4, west: 11.6, north: 46.7, east: 11.9 })).toBe(2);
|
||||
expect(ferrataGroupsInView(groups, null)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-009: boxes meet on a shared edge, not across a gap; a line under two points is dropped', () => {
|
||||
const box = { south: 0, west: 0, north: 1, east: 1 };
|
||||
expect(ferrataBboxesMeet(box, { south: 1, west: 1, north: 2, east: 2 })).toBe(true);
|
||||
expect(ferrataBboxesMeet(box, { south: 1.1, west: 0, north: 2, east: 1 })).toBe(false);
|
||||
const point = { ...segment(1, [46.5, 11.7]), geometry: [[46.5, 11.7]] } as Ferrata;
|
||||
expect(groupFerratas([point])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Ferrata, FerrataBbox, FerrataGrade } from '@trek/shared';
|
||||
|
||||
import { ferrataGradeLabel, ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* OpenStreetMap maps one via ferrata as many short ways: the approach, each
|
||||
* pitch, a bridge, the exit. This turns those segments back into the routes a
|
||||
* climber means, so the layer counts, highlights and describes one ferrata
|
||||
* rather than a dozen pieces of it. Pure and React-free.
|
||||
*
|
||||
* Segments belong together when they share their route relation's name, or,
|
||||
* with no relation, their own name, and lie close to each other: a chain of
|
||||
* segments joins as long as each one comes within the reach of the next.
|
||||
* Unnamed segments stay on their own; nothing says two of them are one route.
|
||||
*/
|
||||
|
||||
/** How close two segments of the same name have to come to read as one ferrata. */
|
||||
export const FERRATA_NAME_REACH_M = 300;
|
||||
/**
|
||||
* The same for a route relation's name. A relation is one route by definition,
|
||||
* so the reach is wider; it only keeps two distant routes that happen to share
|
||||
* a name apart.
|
||||
*/
|
||||
const FERRATA_ROUTE_REACH_M = 2000;
|
||||
|
||||
export interface FerrataGroup {
|
||||
/** Stable for as long as the group keeps its lowest way: `group/<way id>`. */
|
||||
id: string;
|
||||
name: string | null;
|
||||
routeName: string | null;
|
||||
/** The OSM ways it is made of, in the order they arrived. */
|
||||
segments: readonly Ferrata[];
|
||||
/** The easiest and the hardest numeric grade among the segments, or null when none has one. */
|
||||
grades: { min: FerrataGrade; max: FerrataGrade } | null;
|
||||
/** A grade as tagged when no segment has a numeric one (a letter scale such as "C/D"). */
|
||||
rawGrade: string | null;
|
||||
/** The summed length of every segment, in metres. */
|
||||
lengthM: number;
|
||||
bbox: FerrataBbox;
|
||||
/** Where its label goes: halfway along its longest segment, as [lat, lng]. */
|
||||
midpoint: [number, number];
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
function distanceM([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
|
||||
const dLat = (lat2 - lat1) * RAD;
|
||||
const dLng = (lng2 - lng1) * RAD;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function lengthOf(points: readonly [number, number][]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i += 1) total += distanceM(points[i - 1], points[i]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box around the points, the ones that are not finite numbers skipped, or
|
||||
* null when none is left.
|
||||
*/
|
||||
export function ferrataPointsBbox(points: Iterable<readonly [number, number]>): FerrataBbox | null {
|
||||
let south = Infinity;
|
||||
let west = Infinity;
|
||||
let north = -Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [lat, lng] of points) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
west = Math.min(west, lng);
|
||||
east = Math.max(east, lng);
|
||||
}
|
||||
return south <= north && west <= east ? { south, west, north, east } : null;
|
||||
}
|
||||
|
||||
/** A segment's box; the schema guarantees it at least two placed points. */
|
||||
function bboxOf(points: readonly [number, number][]): FerrataBbox {
|
||||
return ferrataPointsBbox(points) ?? { south: 0, west: 0, north: 0, east: 0 };
|
||||
}
|
||||
|
||||
/** Whether two boxes share any point, edges included. */
|
||||
export function ferrataBboxesMeet(a: FerrataBbox, b: FerrataBbox): boolean {
|
||||
return a.south <= b.north && b.south <= a.north && a.west <= b.east && b.west <= a.east;
|
||||
}
|
||||
|
||||
/** The point halfway along a line, by length. */
|
||||
function halfway(points: readonly [number, number][]): [number, number] {
|
||||
const half = lengthOf(points) / 2;
|
||||
let walked = 0;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const step = distanceM(points[i - 1], points[i]);
|
||||
if (walked + step >= half && step > 0) {
|
||||
const t = (half - walked) / step;
|
||||
const [lat1, lng1] = points[i - 1];
|
||||
const [lat2, lng2] = points[i];
|
||||
return [lat1 + (lat2 - lat1) * t, lng1 + (lng2 - lng1) * t];
|
||||
}
|
||||
walked += step;
|
||||
}
|
||||
return points[0] ?? [0, 0];
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
ferrata: Ferrata;
|
||||
points: [number, number][];
|
||||
bbox: FerrataBbox;
|
||||
lengthM: number;
|
||||
}
|
||||
|
||||
/** Whether two boxes come within `reach` metres of each other, a cheap test before any point is compared. */
|
||||
function boxesWithin(a: FerrataBbox, b: FerrataBbox, reach: number): boolean {
|
||||
const latPad = reach / (EARTH_RADIUS_M * RAD);
|
||||
const lngPad = latPad / Math.max(0.01, Math.cos(((a.south + a.north) / 2) * RAD));
|
||||
return (
|
||||
a.south - latPad <= b.north && b.south - latPad <= a.north && a.west - lngPad <= b.east && b.west - lngPad <= a.east
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an end of either segment comes within `reach` of a point of the other: they touch or nearly do. */
|
||||
function segmentsMeet(a: Segment, b: Segment, reach: number): boolean {
|
||||
if (!boxesWithin(a.bbox, b.bbox, reach)) return false;
|
||||
const near = (ends: Segment, line: Segment) => {
|
||||
const tips = [ends.points[0], ends.points[ends.points.length - 1]];
|
||||
return tips.some((tip) => tip !== undefined && line.points.some((point) => distanceM(tip, point) <= reach));
|
||||
};
|
||||
return near(a, b) || near(b, a);
|
||||
}
|
||||
|
||||
/** What segments group by, or null for one that stays alone. Case and outer spaces do not tell two names apart. */
|
||||
function groupKey(ferrata: Ferrata): { key: string; reach: number } | null {
|
||||
const route = ferrata.routeName?.trim().toLocaleLowerCase();
|
||||
if (route) return { key: `route:${route}`, reach: FERRATA_ROUTE_REACH_M };
|
||||
const name = ferrata.name?.trim().toLocaleLowerCase();
|
||||
if (name) return { key: `name:${name}`, reach: FERRATA_NAME_REACH_M };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Segments of one key joined into chains: any two that meet end up in one set. */
|
||||
function chains(segments: Segment[], reach: number): Segment[][] {
|
||||
const parent = segments.map((_, index) => index);
|
||||
const find = (i: number): number => {
|
||||
let root = i;
|
||||
while (parent[root] !== root) root = parent[root];
|
||||
parent[i] = root;
|
||||
return root;
|
||||
};
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
for (let j = i + 1; j < segments.length; j += 1) {
|
||||
if (find(i) !== find(j) && segmentsMeet(segments[i], segments[j], reach)) parent[find(j)] = find(i);
|
||||
}
|
||||
}
|
||||
const sets = new Map<number, Segment[]>();
|
||||
segments.forEach((segment, index) => {
|
||||
const root = find(index);
|
||||
const set = sets.get(root);
|
||||
if (set) set.push(segment);
|
||||
else sets.set(root, [segment]);
|
||||
});
|
||||
return [...sets.values()];
|
||||
}
|
||||
|
||||
/** -, none, + in that order within a level. */
|
||||
const gradeRank = (grade: FerrataGrade) =>
|
||||
(grade.level ?? 0) * 3 + (grade.plus === '-' ? 0 : grade.plus === '+' ? 2 : 1);
|
||||
|
||||
function wayNumber(id: string): number {
|
||||
const n = Number(id.slice(id.indexOf('/') + 1));
|
||||
return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function toGroup(members: Segment[]): FerrataGroup {
|
||||
const segments = members.map((member) => member.ferrata);
|
||||
const graded = segments.map((segment) => segment.grade).filter((grade) => grade.level !== null);
|
||||
const sorted = [...graded].sort((a, b) => gradeRank(a) - gradeRank(b));
|
||||
const longest = members.reduce((best, member) => (member.lengthM > best.lengthM ? member : best), members[0]);
|
||||
// The name of the longest named segment speaks for the group.
|
||||
const named = [...members].sort((a, b) => b.lengthM - a.lengthM).find((member) => member.ferrata.name);
|
||||
const lowest = [...segments].sort((a, b) => wayNumber(a.id) - wayNumber(b.id))[0];
|
||||
return {
|
||||
id: `group/${lowest.id}`,
|
||||
name: named?.ferrata.name ?? null,
|
||||
routeName: segments.find((segment) => segment.routeName)?.routeName ?? null,
|
||||
segments,
|
||||
grades: sorted.length ? { min: sorted[0], max: sorted[sorted.length - 1] } : null,
|
||||
rawGrade: sorted.length ? null : (segments.find((segment) => segment.grade.raw)?.grade.raw ?? null),
|
||||
lengthM: members.reduce((sum, member) => sum + member.lengthM, 0),
|
||||
bbox: members.slice(1).reduce(
|
||||
(box, member) => ({
|
||||
south: Math.min(box.south, member.bbox.south),
|
||||
west: Math.min(box.west, member.bbox.west),
|
||||
north: Math.max(box.north, member.bbox.north),
|
||||
east: Math.max(box.east, member.bbox.east),
|
||||
}),
|
||||
members[0].bbox
|
||||
),
|
||||
midpoint: halfway(longest.points),
|
||||
};
|
||||
}
|
||||
|
||||
/** The segments as ferratas, in the order their first segment arrived. */
|
||||
export function groupFerratas(ferratas: readonly Ferrata[]): FerrataGroup[] {
|
||||
const keyed = new Map<string, { reach: number; segments: Segment[] }>();
|
||||
const ordered: Array<Segment[] | string> = [];
|
||||
for (const ferrata of ferratas) {
|
||||
const points = ferrataPositions(ferrata);
|
||||
if (points.length < 2) continue;
|
||||
const segment: Segment = { ferrata, points, bbox: bboxOf(points), lengthM: lengthOf(points) };
|
||||
const key = groupKey(ferrata);
|
||||
if (!key) {
|
||||
ordered.push([segment]);
|
||||
continue;
|
||||
}
|
||||
const bucket = keyed.get(key.key);
|
||||
if (bucket) bucket.segments.push(segment);
|
||||
else {
|
||||
keyed.set(key.key, { reach: key.reach, segments: [segment] });
|
||||
ordered.push(key.key);
|
||||
}
|
||||
}
|
||||
return ordered.flatMap((entry) => {
|
||||
if (typeof entry !== 'string') return [toGroup(entry)];
|
||||
const bucket = keyed.get(entry);
|
||||
return bucket ? chains(bucket.segments, bucket.reach).map(toGroup) : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The group a way belongs to, for a click or a selection that names one segment. */
|
||||
export function ferrataGroupOf(groups: readonly FerrataGroup[], segmentId: string | null): FerrataGroup | null {
|
||||
if (!segmentId) return null;
|
||||
return groups.find((group) => group.segments.some((segment) => segment.id === segmentId)) ?? null;
|
||||
}
|
||||
|
||||
/** How many ferratas reach into the area on screen. */
|
||||
export function ferrataGroupsInView(groups: readonly FerrataGroup[], view: FerrataBbox | null): number {
|
||||
if (!view) return 0;
|
||||
return groups.reduce((count, group) => (ferrataBboxesMeet(group.bbox, view) ? count + 1 : count), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's grade on the K scale: the easiest and the hardest segment
|
||||
* (`K2`, `K4`), the same label twice when they agree, null when no segment has
|
||||
* a numeric grade.
|
||||
*/
|
||||
export function ferrataGradeRange(group: Pick<FerrataGroup, 'grades'>): { from: string; to: string } | null {
|
||||
if (!group.grades) return null;
|
||||
const from = ferrataGradeLabel(group.grades.min);
|
||||
const to = ferrataGradeLabel(group.grades.max);
|
||||
return from && to ? { from, to } : null;
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* What a ferrata is called on the map and on its card: its own name, else its
|
||||
* route's, else "unnamed"; and its grade, `K3` or `K2 to K4`, or without a
|
||||
* numeric grade the tag as written (a letter scale such as "C/D" still says
|
||||
* something), or null when there is none at all.
|
||||
*/
|
||||
export function ferrataLabel(
|
||||
group: Pick<FerrataGroup, 'name' | 'routeName' | 'grades' | 'rawGrade'>,
|
||||
t: Translate
|
||||
): { name: string; grade: string | null } {
|
||||
const name = group.name ?? group.routeName ?? t('ferratas.unnamed');
|
||||
const range = ferrataGradeRange(group);
|
||||
if (!range) return { name, grade: group.rawGrade };
|
||||
return { name, grade: range.from === range.to ? range.from : t('ferratas.gradeRange', range) };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// FE-FERRATA-ICON-001 to FE-FERRATA-ICON-004: the route icon halfway along a via ferrata.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
FERRATA_ICON_GAP,
|
||||
FERRATA_ICON_PIXEL_RATIO,
|
||||
FERRATA_ICON_SIZE,
|
||||
ferrataIconSvg,
|
||||
loadFerrataIcon,
|
||||
placeFerrataIcons,
|
||||
} from './ferrataIcon';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ferrataIcon', () => {
|
||||
it('FE-FERRATA-ICON-001: draws a ladder in the line colour on a disc of the casing colour', () => {
|
||||
const svg = ferrataIconSvg({ line: 'var(--text-primary)', casing: 'rgb(255, 255, 255)' });
|
||||
expect(svg).toContain('fill="rgb(255, 255, 255)"');
|
||||
expect(svg).toContain('stroke="var(--text-primary)"');
|
||||
expect(svg).toContain(`width="${FERRATA_ICON_SIZE}"`);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-ICON-002: lets nothing but a colour into the markup', () => {
|
||||
const svg = ferrataIconSvg({ line: '"/><script>alert(1)</script>', casing: 'white' });
|
||||
expect(svg).not.toContain('<script>');
|
||||
expect(svg).toContain('stroke="currentColor"');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-ICON-003: hands GL a sharp image of the same SVG, and rejects when it cannot be decoded', async () => {
|
||||
const made: Array<{ src: string; width?: number }> = [];
|
||||
class FakeImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
constructor(public width?: number) {
|
||||
made.push(this as unknown as { src: string; width?: number });
|
||||
}
|
||||
set src(value: string) {
|
||||
(this as unknown as { _src: string })._src = value;
|
||||
queueMicrotask(() => (value.includes('fail') ? this.onerror?.() : this.onload?.()));
|
||||
}
|
||||
get src() {
|
||||
return (this as unknown as { _src: string })._src;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
|
||||
const image = await loadFerrataIcon({ line: 'black', casing: 'white' });
|
||||
expect(image).toBe(made[0]);
|
||||
expect(made[0]?.width).toBe(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
expect(decodeURIComponent(made[0]?.src ?? '')).toContain('stroke="black"');
|
||||
|
||||
await expect(loadFerrataIcon({ line: 'fail', casing: 'white' })).rejects.toThrow('could not be drawn');
|
||||
});
|
||||
it('FE-FERRATA-ICON-004: leaves out an icon that would touch one already placed, first come first placed', () => {
|
||||
const placed = placeFerrataIcons([
|
||||
{ id: 'a', x: 100, y: 100 },
|
||||
{ id: 'b', x: 100 + FERRATA_ICON_GAP - 1, y: 100 },
|
||||
{ id: 'c', x: 100, y: 100 + FERRATA_ICON_GAP },
|
||||
{ id: 'd', x: 400, y: 400 },
|
||||
]);
|
||||
expect([...placed]).toEqual(['a', 'c', 'd']);
|
||||
expect(FERRATA_ICON_GAP).toBeGreaterThan(FERRATA_ICON_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FerrataPaint } from './ferrataPaint';
|
||||
|
||||
/**
|
||||
* The small route icon a via ferrata carries at its midpoint once the map is
|
||||
* zoomed in: a ladder in the line colour on a disc of the casing colour, so it
|
||||
* reads as part of the line it sits on.
|
||||
*
|
||||
* One SVG for both renderers: Leaflet puts it in a divIcon, where it can take
|
||||
* `var(--token)` straight from the page; GL needs an image with the colours
|
||||
* resolved, which `loadFerrataIcon` makes from the resolved paint.
|
||||
*/
|
||||
|
||||
/** The zoom from which the icons show; below it they would crowd a valley full of ferratas. */
|
||||
export const FERRATA_ICON_MIN_ZOOM = 13;
|
||||
|
||||
/** The icon's size on screen, in CSS pixels. */
|
||||
export const FERRATA_ICON_SIZE = 22;
|
||||
|
||||
/** The name the GL renderer registers the image under. */
|
||||
export const FERRATA_ICON_IMAGE = 'trek-ferrata-icon';
|
||||
|
||||
/**
|
||||
* A colour as it may go into the markup: a resolved token (a hex, `rgb()` or
|
||||
* named value) or a `var(--token)`. Anything else, which a token never
|
||||
* resolves to, falls back to `currentColor` rather than reaching the SVG.
|
||||
*/
|
||||
function safeColour(value: string): string {
|
||||
return /^[#\w\s(),.%-]+$/.test(value) ? value : 'currentColor';
|
||||
}
|
||||
|
||||
export function ferrataIconSvg(paint: Pick<FerrataPaint, 'line' | 'casing'>, size = FERRATA_ICON_SIZE): string {
|
||||
const line = safeColour(paint.line);
|
||||
const casing = safeColour(paint.casing);
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22" aria-hidden="true">`,
|
||||
`<circle cx="11" cy="11" r="9.75" fill="${casing}" stroke="${line}" stroke-width="1.5"/>`,
|
||||
`<path d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6" fill="none" stroke="${line}" stroke-width="1.6" stroke-linecap="round"/>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** How sharp the GL image is drawn; the map scales it back to FERRATA_ICON_SIZE. */
|
||||
export const FERRATA_ICON_PIXEL_RATIO = 2;
|
||||
|
||||
/**
|
||||
* The icon as an image for a GL map, in the colours the tokens resolve to now.
|
||||
* Rejects when the browser cannot decode it, and the lines then simply go
|
||||
* without their icons.
|
||||
*/
|
||||
export function loadFerrataIcon(paint: Pick<FerrataPaint, 'line' | 'casing'>): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('The via ferrata icon could not be drawn'));
|
||||
const svg = ferrataIconSvg(paint, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** The room an icon keeps to the next one, in CSS pixels, so two discs never touch. */
|
||||
export const FERRATA_ICON_GAP = FERRATA_ICON_SIZE + 4;
|
||||
|
||||
/**
|
||||
* Which icons fit on screen without touching one another, for a renderer that
|
||||
* does not place symbols itself (Leaflet; GL keeps `icon-allow-overlap` off for
|
||||
* the same effect). `points` are the icons' screen positions in pixels, in the
|
||||
* order they should win: an icon closer than FERRATA_ICON_GAP to one already
|
||||
* placed is left out.
|
||||
*/
|
||||
export function placeFerrataIcons(points: ReadonlyArray<{ id: string; x: number; y: number }>): Set<string> {
|
||||
const placed: Array<{ x: number; y: number }> = [];
|
||||
const ids = new Set<string>();
|
||||
for (const point of points) {
|
||||
if (placed.some((other) => Math.hypot(other.x - point.x, other.y - point.y) < FERRATA_ICON_GAP)) continue;
|
||||
placed.push(point);
|
||||
ids.add(point.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-007: the pure half of the via ferrata layer.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared';
|
||||
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import {
|
||||
ferrataBboxFits,
|
||||
ferrataBboxParam,
|
||||
ferrataFeatureCollection,
|
||||
ferrataGradeLabel,
|
||||
ferrataLabelCollection,
|
||||
ferrataOsmUrl,
|
||||
ferrataPositions,
|
||||
} from './ferrataModel';
|
||||
|
||||
const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({
|
||||
level,
|
||||
plus,
|
||||
raw,
|
||||
});
|
||||
|
||||
const FERRATA: Ferrata = {
|
||||
id: 'way/123',
|
||||
name: 'Pisciadù',
|
||||
grade: grade(3, '+', '3+'),
|
||||
routeName: 'Brigata Tridentina',
|
||||
geometry: [
|
||||
[46.53, 11.81],
|
||||
[46.54, 11.82],
|
||||
],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/123',
|
||||
};
|
||||
|
||||
describe('ferrataModel', () => {
|
||||
it('FE-FERRATA-MODEL-001: labels a numeric grade on the K scale with its plus or minus', () => {
|
||||
expect(ferrataGradeLabel(grade(3))).toBe('K3');
|
||||
expect(ferrataGradeLabel(grade(4, '+'))).toBe('K4+');
|
||||
expect(ferrataGradeLabel(grade(2, '-'))).toBe('K2-');
|
||||
expect(ferrataGradeLabel(grade(0))).toBe('K0');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-002: has no label for a way without a numeric level, letter scales included', () => {
|
||||
expect(ferrataGradeLabel(grade(null))).toBeNull();
|
||||
expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-003: takes an area only up to the server limit and only a real box', () => {
|
||||
const max = FERRATA_BBOX_MAX_SPAN_DEG;
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true);
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false);
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false);
|
||||
expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false);
|
||||
// The server's own rule: a 0.500 span that reads 0.5000000000000071 still goes out.
|
||||
expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-004: writes the bbox parameter in south,west,north,east order', () => {
|
||||
expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-005: builds one GeoJSON line per way, in [lng, lat], carrying only the way and its group', () => {
|
||||
const collection = ferrataFeatureCollection(groupFerratas([FERRATA]));
|
||||
expect(collection).toEqual({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { id: 'way/123', group: 'group/way/123' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[11.81, 46.53],
|
||||
[11.82, 46.54],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ferrataPositions(FERRATA)).toEqual(FERRATA.geometry);
|
||||
expect(ferrataFeatureCollection([]).features).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-006: puts one label point per ferrata halfway along it, in [lng, lat]', () => {
|
||||
const [feature] = ferrataLabelCollection(groupFerratas([FERRATA])).features;
|
||||
expect(feature?.properties).toEqual({ group: 'group/way/123' });
|
||||
expect(feature?.geometry.type).toBe('Point');
|
||||
const [lng, lat] = feature?.geometry.coordinates ?? [0, 0];
|
||||
expect(lng).toBeCloseTo(11.815, 4);
|
||||
expect(lat).toBeCloseTo(46.535, 4);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-007: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
|
||||
expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123');
|
||||
expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
|
||||
/**
|
||||
* The pure half of the via ferrata layer: grade wording, the area checks a
|
||||
* request makes, and the GeoJSON the GL renderer draws. React-free, so both
|
||||
* renderers and the detail card read the same answers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has
|
||||
* no numeric level (untagged, or a letter scale such as "C/D" the server keeps
|
||||
* as `raw` and does not convert).
|
||||
*/
|
||||
export function ferrataGradeLabel(grade: FerrataGrade): string | null {
|
||||
if (grade.level === null || grade.level === undefined) return null;
|
||||
return `K${grade.level}${grade.plus ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two
|
||||
* numbers, maybe more" where the client compiles without strict mode, so the
|
||||
* pairs are narrowed here rather than cast; the schema has already guaranteed
|
||||
* both are numbers.
|
||||
*/
|
||||
export function ferrataPositions(ferrata: Pick<Ferrata, 'geometry'>): Array<[number, number]> {
|
||||
return ferrata.geometry.flatMap((pair): Array<[number, number]> =>
|
||||
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : []
|
||||
);
|
||||
}
|
||||
|
||||
const OSM_ORIGIN = 'https://www.openstreetmap.org/';
|
||||
|
||||
/**
|
||||
* The way's page on openstreetmap.org. Built from the id the contract pins to
|
||||
* `way/<digits>` rather than taken from `osmUrl` as sent, so the link can only
|
||||
* ever point at OpenStreetMap whatever an Overpass mirror put in the answer.
|
||||
*/
|
||||
export function ferrataOsmUrl(ferrata: Pick<Ferrata, 'id'>): string {
|
||||
return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server takes this area: a real box no wider than
|
||||
* FERRATA_BBOX_MAX_SPAN_DEG per side. Past it the layer asks the user to zoom
|
||||
* in instead of loading every tile of the view.
|
||||
*/
|
||||
export function ferrataBboxFits(bbox: FerrataBbox): boolean {
|
||||
// The span check is the server's own, so a box the client sends is never refused as too large.
|
||||
return bbox.north > bbox.south && bbox.east > bbox.west && ferrataBboxSpanOk(bbox);
|
||||
}
|
||||
|
||||
/** The `bbox` query parameter, in the south,west,north,east order the contract reads. */
|
||||
export function ferrataBboxParam(bbox: FerrataBbox): string {
|
||||
return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(',');
|
||||
}
|
||||
|
||||
interface FerrataLineFeature {
|
||||
type: 'Feature';
|
||||
properties: { id: string; group: string };
|
||||
geometry: { type: 'LineString'; coordinates: Array<[number, number]> };
|
||||
}
|
||||
|
||||
interface FerrataLabelFeature {
|
||||
type: 'Feature';
|
||||
properties: { group: string };
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
}
|
||||
|
||||
interface FeatureCollection<F> {
|
||||
type: 'FeatureCollection';
|
||||
features: F[];
|
||||
}
|
||||
|
||||
const lngLat = ([lat, lng]: [number, number]): [number, number] => [lng, lat];
|
||||
|
||||
/**
|
||||
* The segments as a GeoJSON FeatureCollection for MapLibre or Mapbox GL, one
|
||||
* line per OSM way. The contract carries [lat, lng] like the rest of TREK;
|
||||
* GeoJSON wants [lng, lat], and this is the one place the layer flips it. Only
|
||||
* the way and its group ride along: a click looks the ferrata up by id, so
|
||||
* names never pass through the map's own feature state, and a highlight
|
||||
* filters on the group.
|
||||
*/
|
||||
export function ferrataFeatureCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLineFeature> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: groups.flatMap((group) =>
|
||||
group.segments.map((segment) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { id: segment.id, group: group.id },
|
||||
geometry: { type: 'LineString' as const, coordinates: ferrataPositions(segment).map(lngLat) },
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** One point per ferrata, halfway along it, where the GL renderer puts the route icon. */
|
||||
export function ferrataLabelCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLabelFeature> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: groups.map((group) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { group: group.id },
|
||||
geometry: { type: 'Point' as const, coordinates: lngLat(group.midpoint) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-003: the line colours come from the --map-ferrata tokens.
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ferrataPaint, sameFerrataPaint } from './ferrataPaint';
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of [
|
||||
'--map-ferrata',
|
||||
'--map-ferrata-casing',
|
||||
'--map-ferrata-lit',
|
||||
'--success',
|
||||
'--warning',
|
||||
'--danger',
|
||||
])
|
||||
root.style.removeProperty(name);
|
||||
});
|
||||
|
||||
describe('ferrataPaint', () => {
|
||||
it('FE-FERRATA-PAINT-001: draws the line, its halo and the lit line in the via ferrata tokens', () => {
|
||||
root.style.setProperty('--map-ferrata', 'violet');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
root.style.setProperty('--map-ferrata-lit', 'indigo');
|
||||
expect(ferrataPaint()).toEqual({ line: 'violet', casing: 'white', highlight: 'indigo' });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
|
||||
root.style.setProperty('--success', 'green');
|
||||
root.style.setProperty('--warning', 'orange');
|
||||
root.style.setProperty('--danger', 'crimson');
|
||||
const paint = Object.values(ferrataPaint());
|
||||
expect(paint).not.toContain('green');
|
||||
expect(paint).not.toContain('orange');
|
||||
expect(paint).not.toContain('crimson');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-PAINT-003: falls back to currentColor where a token is not defined, and compares by colour', () => {
|
||||
const paint = ferrataPaint();
|
||||
expect(paint).toEqual({ line: 'currentColor', casing: 'currentColor', highlight: 'currentColor' });
|
||||
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
|
||||
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The colours the via ferrata lines are drawn in.
|
||||
*
|
||||
* Ferratas have a look of their own: a dashed violet line on a white halo.
|
||||
* Violet is the one colour nothing else on the planner map uses (the route is
|
||||
* blue, its grades run green to red, the waypoints are blue), and the halo
|
||||
* keeps it readable over a satellite tile or dark hillshade. The ferrata under
|
||||
* the pointer or open in the card is drawn solid, wider and in a deeper violet.
|
||||
* The grade is a badge on the card, never a line colour.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export interface FerrataPaint {
|
||||
/** The dashed line itself. */
|
||||
line: string;
|
||||
/** The halo under the line, so it stays readable over a satellite or hillshade tile. */
|
||||
casing: string;
|
||||
/** The ferrata under the pointer or open in the card. */
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
const 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),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
|
||||
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* FE-FERRATA-TILES-001 to FE-FERRATA-TILES-012: the per-trip tile store behind
|
||||
* the via ferrata layer.
|
||||
*
|
||||
* Pinned: nothing goes out before the device cache was read, a tile is asked
|
||||
* for once and never dropped, the view goes before any prefetch and runs
|
||||
* beside one on its way rather than behind it, answers are
|
||||
* kept per tile, the view's failures are reported and retried, and a stop
|
||||
* calls off what is on its way but keeps what is loaded.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
|
||||
|
||||
import type { FerrataTileRow } from '../../repo/ferrataRepo';
|
||||
import { FerrataTileStore, ferrataRowsOf } from './ferrataTileStore';
|
||||
|
||||
const ferrata = (id: number, lat: number, lng: number, lat2 = lat + 0.01, lng2 = lng): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: `Ferrata ${id}`,
|
||||
grade: { raw: '3', level: 3, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat2, lng2],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
});
|
||||
|
||||
const reply = (ferratas: Ferrata[], truncated = false): FerratasResponse => ({
|
||||
ferratas,
|
||||
truncated,
|
||||
source: 'openstreetmap',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
});
|
||||
|
||||
interface Call {
|
||||
bbox: FerrataBbox;
|
||||
signal: AbortSignal;
|
||||
resolve: (value: FerratasResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function harness(options: { persist?: boolean } = {}) {
|
||||
const calls: Call[] = [];
|
||||
const persisted: FerrataTileRow[][] = [];
|
||||
const fetch = vi.fn(
|
||||
(bbox: FerrataBbox, signal: AbortSignal) =>
|
||||
new Promise<FerratasResponse>((resolve, reject) => calls.push({ bbox, signal, resolve, reject }))
|
||||
);
|
||||
const persist = vi.fn((rows: FerrataTileRow[]) => {
|
||||
persisted.push(rows);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const store = new FerrataTileStore({
|
||||
fetch,
|
||||
persist: options.persist === false ? undefined : persist,
|
||||
now: () => 1_000,
|
||||
retryDelaysMs: [100, 200],
|
||||
});
|
||||
return { store, calls, fetch, persist, persisted };
|
||||
}
|
||||
|
||||
/** Lets a settled fetch's handlers run. */
|
||||
const flush = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const A = { row: 930, col: 236 }; // 46.50 to 46.55, 11.80 to 11.85
|
||||
const B = { row: 930, col: 237 };
|
||||
const FAR = { row: 940, col: 236 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('FerrataTileStore', () => {
|
||||
it('FE-FERRATA-TILES-001: asks for nothing before the cache was read, and nothing the cache already had', () => {
|
||||
const { store, fetch } = harness();
|
||||
expect(store.beginHydration()).toBe(true);
|
||||
expect(store.beginHydration()).toBe(false);
|
||||
store.want([A, B], 'visible');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot().waiting.size).toBe(2);
|
||||
|
||||
const cached = ferrata(1, 46.52, 11.82);
|
||||
store.hydrate([
|
||||
{ tile: '930:236', fetchedAt: 1, ferratas: [cached] },
|
||||
{ tile: '930:237', fetchedAt: 1, ferratas: [] },
|
||||
]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const snapshot = store.getSnapshot();
|
||||
expect(snapshot.hydrated).toBe(true);
|
||||
expect([...snapshot.loaded].sort()).toEqual(['930:236', '930:237']);
|
||||
expect(snapshot.waiting.size).toBe(0);
|
||||
expect(snapshot.ferratas).toEqual([cached]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-002: asks for the missing tiles in one box, keeps them, and never asks for them again', async () => {
|
||||
const { store, calls, fetch } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A, B], 'visible');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(calls[0]?.bbox).toEqual({ south: 46.5, west: 11.8, north: 46.55, east: 11.9 });
|
||||
|
||||
const found = ferrata(1, 46.52, 11.82);
|
||||
calls[0]?.resolve(reply([found]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().ferratas).toEqual([found]);
|
||||
expect(store.getSnapshot().loaded.has('930:237')).toBe(true);
|
||||
|
||||
store.want([A, B], 'visible');
|
||||
store.want([A], 'background');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-003: keeps every ferrata it ever loaded, a later answer adding to them by id', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
|
||||
await flush();
|
||||
store.want([FAR], 'visible');
|
||||
// The same way again, as a tile across its end returns it, and one more.
|
||||
calls[1]?.resolve(reply([ferrata(1, 46.52, 11.82), ferrata(2, 47.01, 11.82)]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/1', 'way/2']);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-004: the view goes out beside a prefetch on its way, and prefetches go one at a time', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([FAR], 'background');
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
// The view does not wait for the prefetch already on its way.
|
||||
store.want([B], 'visible');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.bbox).toEqual({ south: 46.5, west: 11.85, north: 46.55, east: 11.9 });
|
||||
calls[1]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls[2]?.bbox.south).toBe(47);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-012: runs two of the view requests at once and queues the rest behind them', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
// Three blocks of the grid apart: three requests.
|
||||
store.want([A, { row: 930, col: 240 }, { row: 930, col: 244 }], 'visible');
|
||||
expect(calls).toHaveLength(2);
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2]?.bbox.west).toBe(12.2);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-005: a tile a queued prefetch holds moves up with it when the view wants it', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([FAR], 'background');
|
||||
store.want([B], 'background');
|
||||
store.want([B], 'visible');
|
||||
expect(store.getSnapshot().waiting.has('930:237')).toBe(true);
|
||||
// Moved up, it takes the view's lane at once instead of waiting behind the other prefetches.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.bbox.west).toBe(11.85);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-006: keeps each answer per tile, an empty tile included', async () => {
|
||||
const { store, calls, persisted } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A, B], 'visible');
|
||||
const inA = ferrata(1, 46.52, 11.82);
|
||||
calls[0]?.resolve(reply([inA]));
|
||||
await flush();
|
||||
expect(persisted).toEqual([
|
||||
[
|
||||
{ tile: '930:236', fetchedAt: 1_000, ferratas: [inA] },
|
||||
{ tile: '930:237', fetchedAt: 1_000, ferratas: [] },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-007: reports the view failing and asks again after each delay; a 400 reads as zoom in', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('unavailable');
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(calls).toHaveLength(2);
|
||||
calls[1]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBeNull();
|
||||
|
||||
store.want([FAR], 'visible');
|
||||
calls[2]?.reject({ response: { status: 400 } });
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('refused');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-008: a failed prefetch says nothing and is asked for again on the next want', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBeNull();
|
||||
store.want([A], 'background');
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-009: a stop aborts what is on its way and drops the queue, and keeps what is loaded', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
|
||||
store.want([A], 'visible');
|
||||
store.want([B], 'background');
|
||||
expect(calls).toHaveLength(2);
|
||||
store.stop();
|
||||
expect(calls.map((call) => call.signal.aborted)).toEqual([true, true]);
|
||||
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/9']);
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
// Wanted again, the tiles go out again.
|
||||
store.want([A], 'visible');
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-010: tells its listeners only of a change, and keeps the list while only the tiles change', async () => {
|
||||
const { store, calls } = harness({ persist: false });
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
|
||||
const first = store.getSnapshot();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.want([FAR], 'visible');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.getSnapshot()).toBe(first);
|
||||
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(store.getSnapshot()).not.toBe(first);
|
||||
expect(store.getSnapshot().ferratas).toBe(first.ferratas);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-011: a prefetch on its way that holds a tile of the view reports its failure as the view', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([A], 'visible');
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(store.getSnapshot().waiting.has('930:236')).toBe(true);
|
||||
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('unavailable');
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataRowsOf', () => {
|
||||
it('gives a ferrata to every tile of the box its own line reaches into', () => {
|
||||
// From 46.52 to 46.57: across the row line at 46.55.
|
||||
const across = ferrata(1, 46.52, 11.82, 46.57, 11.82);
|
||||
const rows = ferrataRowsOf({ minRow: 930, maxRow: 931, minCol: 236, maxCol: 237 }, [across], 5);
|
||||
expect(rows.map((row) => [row.tile, row.ferratas.length])).toEqual([
|
||||
['930:236', 1],
|
||||
['930:237', 0],
|
||||
['931:236', 1],
|
||||
['931:237', 0],
|
||||
]);
|
||||
expect(rows.every((row) => row.fetchedAt === 5)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
type Ferrata,
|
||||
type FerrataBbox,
|
||||
type FerratasResponse,
|
||||
type FerrataTile,
|
||||
type FerrataTileRange,
|
||||
ferrataTileKey,
|
||||
ferrataTileRange,
|
||||
ferrataTileRequests,
|
||||
ferrataTilesOf,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { FerrataTileRow } from '../../repo/ferrataRepo';
|
||||
import { ferrataPointsBbox } from './ferrataGroups';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* The via ferrata layer's tiles for one trip, outside React.
|
||||
*
|
||||
* Every ferrata loaded for the trip stays, in a map by OSM id, whatever the
|
||||
* map does afterwards: a pan or a zoom only asks for the tiles of the shared
|
||||
* 0.05 degree grid that are not loaded yet, never for ones it has, and never
|
||||
* drops any. Tiles wanted for the view on screen go first; the ring around it
|
||||
* and the trip's area load behind them. The two have lanes of their own: the
|
||||
* view's requests run beside a prefetch on its way rather than behind it,
|
||||
* VISIBLE_AT_ONCE of them at a time, while prefetches go one at a time, so
|
||||
* the view is never kept waiting by a prefetch. Answers go to `persist` per tile, and a
|
||||
* trip's cached tiles come back through `hydrate`, which holds every request
|
||||
* until the cache has been read, so a cached tile is never asked for again.
|
||||
*
|
||||
* Built for `useSyncExternalStore`: `subscribe` and `getSnapshot`, and the
|
||||
* snapshot is a new object only when something in it changed.
|
||||
*/
|
||||
|
||||
type FerrataTilePriority = 'visible' | 'background';
|
||||
|
||||
/**
|
||||
* Why the last request for the view failed: `refused` (a 400, the server would
|
||||
* not take the box, read as "zoom in") or `unavailable` (anything else: the
|
||||
* Overpass mirrors, worth another try).
|
||||
*/
|
||||
type FerrataTileFailure = 'refused' | 'unavailable';
|
||||
|
||||
export interface FerrataTileSnapshot {
|
||||
/** Every tile loaded for the trip, cached or fetched. */
|
||||
loaded: ReadonlySet<string>;
|
||||
/** Every ferrata those tiles hold, each once. */
|
||||
ferratas: readonly Ferrata[];
|
||||
/** Tiles wanted for the view that are queued or on their way. */
|
||||
waiting: ReadonlySet<string>;
|
||||
failure: FerrataTileFailure | null;
|
||||
/** True when an answer for the view was cut at the server's cap. */
|
||||
truncated: boolean;
|
||||
/** Whether the cached tiles have been read. */
|
||||
hydrated: boolean;
|
||||
}
|
||||
|
||||
interface FerrataTileStoreDeps {
|
||||
fetch: (bbox: FerrataBbox, signal: AbortSignal) => Promise<FerratasResponse>;
|
||||
persist?: (rows: FerrataTileRow[]) => Promise<void>;
|
||||
now?: () => number;
|
||||
/** How long to wait before asking again for the view after the mirrors failed it. */
|
||||
retryDelaysMs?: readonly number[];
|
||||
}
|
||||
|
||||
interface Job {
|
||||
bbox: FerrataBbox;
|
||||
range: FerrataTileRange;
|
||||
keys: string[];
|
||||
priority: FerrataTilePriority;
|
||||
}
|
||||
|
||||
const FERRATA_RETRY_DELAYS_MS = [5_000, 15_000, 45_000] as const;
|
||||
|
||||
/** Requests for the view on screen that may be on their way at once. */
|
||||
const VISIBLE_AT_ONCE = 2;
|
||||
/** Prefetches on their way at once: one, beside the view's own. */
|
||||
const BACKGROUND_AT_ONCE = 1;
|
||||
|
||||
/** A job on its way, and the lane it took: the lane is what it counts against, even once it is moved up. */
|
||||
interface Flight {
|
||||
job: Job;
|
||||
lane: FerrataTilePriority;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
function failureOf(error: unknown): FerrataTileFailure {
|
||||
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
|
||||
return status === 400 ? 'refused' : 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* The ferratas of an answer per tile of the box it was for: each one goes to
|
||||
* every tile its own box reaches into, and every tile gets a row, an empty one
|
||||
* included, because "no ferrata here" is an answer worth keeping too.
|
||||
*/
|
||||
export function ferrataRowsOf(range: FerrataTileRange, ferratas: readonly Ferrata[], at: number): FerrataTileRow[] {
|
||||
const rows = new Map(ferrataTilesOf(range).map((tile) => [ferrataTileKey(tile), [] as Ferrata[]]));
|
||||
for (const ferrata of ferratas) {
|
||||
const box = ferrataPointsBbox(ferrataPositions(ferrata));
|
||||
if (!box) continue;
|
||||
const own = ferrataTileRange(box);
|
||||
const minRow = Math.max(own.minRow, range.minRow);
|
||||
const maxRow = Math.min(own.maxRow, range.maxRow);
|
||||
const minCol = Math.max(own.minCol, range.minCol);
|
||||
const maxCol = Math.min(own.maxCol, range.maxCol);
|
||||
if (minRow > maxRow || minCol > maxCol) continue;
|
||||
for (const tile of ferrataTilesOf({ minRow, maxRow, minCol, maxCol }))
|
||||
rows.get(ferrataTileKey(tile))?.push(ferrata);
|
||||
}
|
||||
return [...rows].map(([tile, list]) => ({ tile, fetchedAt: at, ferratas: list }));
|
||||
}
|
||||
|
||||
export class FerrataTileStore {
|
||||
private readonly loaded = new Set<string>();
|
||||
private readonly byId = new Map<string, Ferrata>();
|
||||
/** Tiles in a queued or in-flight job, so no tile is asked for twice at once. */
|
||||
private readonly pending = new Set<string>();
|
||||
private readonly waiting = new Set<string>();
|
||||
private queue: Job[] = [];
|
||||
private readonly inFlight = new Set<Flight>();
|
||||
private failure: FerrataTileFailure | null = null;
|
||||
private truncated = false;
|
||||
private hydrated = false;
|
||||
private hydrating = false;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryAttempt = 0;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FerrataTileSnapshot;
|
||||
/** Writes to the device cache that failed; the tiles still draw, they only load again next time. */
|
||||
persistFailures = 0;
|
||||
|
||||
constructor(private readonly deps: FerrataTileStoreDeps) {
|
||||
this.snapshot = this.build();
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
getSnapshot = (): FerrataTileSnapshot => this.snapshot;
|
||||
|
||||
/** Starts reading the cache; requests wait until `hydrate` hands its rows over. */
|
||||
beginHydration(): boolean {
|
||||
if (this.hydrated || this.hydrating) return false;
|
||||
this.hydrating = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The cached tiles. Queued jobs whose tiles all came back from the cache are dropped. */
|
||||
hydrate(rows: readonly FerrataTileRow[]): void {
|
||||
for (const row of rows) {
|
||||
this.loaded.add(row.tile);
|
||||
this.waiting.delete(row.tile);
|
||||
for (const ferrata of row.ferratas) this.byId.set(ferrata.id, ferrata);
|
||||
}
|
||||
this.hydrating = false;
|
||||
this.hydrated = true;
|
||||
this.queue = this.queue.filter((job) => {
|
||||
if (!job.keys.every((key) => this.loaded.has(key))) return true;
|
||||
for (const key of job.keys) this.pending.delete(key);
|
||||
return false;
|
||||
});
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the tiles not loaded or on their way yet. `visible` ones jump the
|
||||
* queue, and a background job already holding one of them is moved up with
|
||||
* it; the error of an earlier view is cleared, its retry called off.
|
||||
*/
|
||||
want(tiles: readonly FerrataTile[], priority: FerrataTilePriority): void {
|
||||
const keys = tiles.map(ferrataTileKey);
|
||||
if (priority === 'visible') {
|
||||
this.clearRetry();
|
||||
// The ones on their way too, so their failure is reported and retried like the view's own.
|
||||
const jobs = [...[...this.inFlight].map((flight) => flight.job), ...this.queue];
|
||||
for (const job of jobs) {
|
||||
if (job.priority === 'background' && job.keys.some((key) => keys.includes(key))) job.priority = 'visible';
|
||||
}
|
||||
for (const key of keys) if (!this.loaded.has(key)) this.waiting.add(key);
|
||||
}
|
||||
const missing = tiles.filter((tile, index) => !this.loaded.has(keys[index]) && !this.pending.has(keys[index]));
|
||||
for (const request of ferrataTileRequests(missing)) {
|
||||
const jobKeys = ferrataTilesOf(request.range).map(ferrataTileKey);
|
||||
for (const key of jobKeys) this.pending.add(key);
|
||||
this.queue.push({ ...request, keys: jobKeys, priority });
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/** Calls off everything queued, in flight or due for a retry; what is loaded stays. */
|
||||
stop(): void {
|
||||
this.clearRetry();
|
||||
for (const flight of this.inFlight) flight.controller.abort();
|
||||
this.inFlight.clear();
|
||||
this.queue = [];
|
||||
this.pending.clear();
|
||||
this.waiting.clear();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
/** How many jobs of a lane are on their way. */
|
||||
private flying(lane: FerrataTilePriority): number {
|
||||
let count = 0;
|
||||
for (const flight of this.inFlight) if (flight.lane === lane) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** The next queued job of a priority, taken off the queue, or null for none. */
|
||||
private take(priority: FerrataTilePriority): Job | null {
|
||||
const index = this.queue.findIndex((job) => job.priority === priority);
|
||||
if (index < 0) return null;
|
||||
const [job] = this.queue.splice(index, 1);
|
||||
return job ?? null;
|
||||
}
|
||||
|
||||
/** Starts what the lanes have room for: the view's jobs in theirs, the prefetches in theirs. */
|
||||
private pump(): void {
|
||||
if (!this.hydrated) return;
|
||||
for (const [lane, room] of [
|
||||
['visible', VISIBLE_AT_ONCE],
|
||||
['background', BACKGROUND_AT_ONCE],
|
||||
] as const) {
|
||||
while (this.flying(lane) < room) {
|
||||
const job = this.take(lane);
|
||||
if (!job) break;
|
||||
this.launch(job, lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private launch(job: Job, lane: FerrataTilePriority): void {
|
||||
const controller = new AbortController();
|
||||
const flight: Flight = { job, lane, controller };
|
||||
this.inFlight.add(flight);
|
||||
void this.deps.fetch(job.bbox, controller.signal).then(
|
||||
(reply) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.inFlight.delete(flight);
|
||||
this.settle(job, reply);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.inFlight.delete(flight);
|
||||
this.fail(job, failureOf(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private settle(job: Job, reply: FerratasResponse): void {
|
||||
const at = (this.deps.now ?? Date.now)();
|
||||
for (const ferrata of reply.ferratas) this.byId.set(ferrata.id, ferrata);
|
||||
for (const key of job.keys) {
|
||||
this.loaded.add(key);
|
||||
this.pending.delete(key);
|
||||
this.waiting.delete(key);
|
||||
}
|
||||
if (job.priority === 'visible') {
|
||||
this.failure = null;
|
||||
this.retryAttempt = 0;
|
||||
this.truncated = reply.truncated;
|
||||
}
|
||||
const persist = this.deps.persist;
|
||||
if (persist) {
|
||||
void persist(ferrataRowsOf(job.range, reply.ferratas, at)).catch(() => {
|
||||
this.persistFailures += 1;
|
||||
});
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
private fail(job: Job, failure: FerrataTileFailure): void {
|
||||
for (const key of job.keys) this.pending.delete(key);
|
||||
if (job.priority === 'visible') {
|
||||
// The view's tiles are no longer on their way: the status reads the failure instead.
|
||||
for (const key of job.keys) this.waiting.delete(key);
|
||||
this.failure = failure;
|
||||
const delay = (this.deps.retryDelaysMs ?? FERRATA_RETRY_DELAYS_MS)[this.retryAttempt];
|
||||
if (failure === 'unavailable' && delay !== undefined) {
|
||||
this.retryAttempt += 1;
|
||||
const tiles = ferrataTilesOf(job.range);
|
||||
this.clearRetry();
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.want(tiles, 'visible');
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
private build(): FerrataTileSnapshot {
|
||||
return {
|
||||
loaded: new Set(this.loaded),
|
||||
ferratas: [...this.byId.values()],
|
||||
waiting: new Set(this.waiting),
|
||||
failure: this.failure,
|
||||
truncated: this.truncated,
|
||||
hydrated: this.hydrated,
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const next = this.build();
|
||||
const prev = this.snapshot;
|
||||
// An unchanged list keeps its reference, so what is drawn from it is not rebuilt.
|
||||
const sameFerratas =
|
||||
prev.ferratas.length === next.ferratas.length &&
|
||||
prev.ferratas.every((ferrata, index) => ferrata === next.ferratas[index]);
|
||||
const same =
|
||||
sameFerratas &&
|
||||
prev.failure === next.failure &&
|
||||
prev.truncated === next.truncated &&
|
||||
prev.hydrated === next.hydrated &&
|
||||
sameSet(prev.loaded, next.loaded) &&
|
||||
sameSet(prev.waiting, next.waiting);
|
||||
if (same) return;
|
||||
this.snapshot = sameFerratas ? { ...next, ferratas: prev.ferratas } : next;
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const value of a) if (!b.has(value)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
|
||||
* MapLibre or Mapbox GL map.
|
||||
*
|
||||
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
|
||||
* still loading, the failure the hazard overlay had), the line is the dark
|
||||
* dashed via ferrata violet and never a grade colour, new data replaces the
|
||||
* sources' contents instead of rebuilding the layers, a click on the hit line
|
||||
* hands the ferrata and the way to `onSelect`, hover and selection highlight
|
||||
* the whole ferrata (solid, on a wider casing) and name it above the pointer,
|
||||
* the 3D terrain draws wider lines, the route icon joins once its image is drawn, a theme
|
||||
* change repaints the installed layers in place, and an unmount leaves nothing
|
||||
* behind.
|
||||
*/
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import FerrataLayerGL from './FerrataLayerGL';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import { useFerrataLayerGL } from './useFerrataLayerGL';
|
||||
|
||||
vi.mock('./ferrataIcon', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./ferrataIcon')>()),
|
||||
loadFerrataIcon: vi.fn(() => Promise.resolve({ width: 44, height: 44 })),
|
||||
}));
|
||||
|
||||
const SOURCE = 'trek-ferratas';
|
||||
const LABEL_SOURCE = 'trek-ferratas-labels';
|
||||
const CASING = 'trek-ferratas-casing';
|
||||
const LINE = 'trek-ferratas-line';
|
||||
const HIGHLIGHT_CASING = 'trek-ferratas-highlight-casing';
|
||||
const HIGHLIGHT = 'trek-ferratas-highlight';
|
||||
const HIT = 'trek-ferratas-hit';
|
||||
const LABEL = 'trek-ferratas-label';
|
||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
|
||||
|
||||
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
|
||||
id,
|
||||
name,
|
||||
grade: { raw: '2', level: 2, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat + 0.01, lng + 0.01],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
});
|
||||
|
||||
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
|
||||
const GROUPS = groupFerratas([
|
||||
ferrata('way/1', 46.5, 'Via A'),
|
||||
ferrata('way/2', 46.51, 'Via A', 11.71),
|
||||
ferrata('way/3', 47, 'Via B'),
|
||||
]);
|
||||
|
||||
type Handler = (event?: unknown) => void;
|
||||
|
||||
function fakeGlMap(styleLoaded = true) {
|
||||
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
|
||||
const layers = new Set<string>();
|
||||
const images = new Set<string>();
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
const once = new Map<string, Set<Handler>>();
|
||||
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
|
||||
const canvas = document.createElement('canvas');
|
||||
const container = document.createElement('div');
|
||||
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
|
||||
const map = {
|
||||
isStyleLoaded: vi.fn(() => state.styleLoaded),
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string) => {
|
||||
sources.set(id, { setData: vi.fn() });
|
||||
}),
|
||||
removeSource: vi.fn((id: string) => {
|
||||
sources.delete(id);
|
||||
}),
|
||||
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
|
||||
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
|
||||
getLayer: vi.fn((id: string) => {
|
||||
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
|
||||
return layers.has(id) ? {} : undefined;
|
||||
}),
|
||||
addLayer: vi.fn((spec: { id: string }) => {
|
||||
layers.add(spec.id);
|
||||
}),
|
||||
removeLayer: vi.fn((id: string) => {
|
||||
layers.delete(id);
|
||||
}),
|
||||
setPaintProperty: vi.fn(),
|
||||
setFilter: vi.fn(),
|
||||
hasImage: vi.fn((id: string) => images.has(id)),
|
||||
addImage: vi.fn((id: string) => {
|
||||
images.add(id);
|
||||
}),
|
||||
removeImage: vi.fn((id: string) => {
|
||||
images.delete(id);
|
||||
}),
|
||||
queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])),
|
||||
getCanvas: vi.fn(() => canvas),
|
||||
getContainer: vi.fn(() => container),
|
||||
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
|
||||
const key = keyOf(type, layerOrFn);
|
||||
const set = handlers.get(key) ?? new Set<Handler>();
|
||||
set.add((fn ?? layerOrFn) as Handler);
|
||||
handlers.set(key, set);
|
||||
}),
|
||||
once: vi.fn((type: string, fn: Handler) => {
|
||||
const set = once.get(type) ?? new Set<Handler>();
|
||||
set.add(fn);
|
||||
once.set(type, set);
|
||||
}),
|
||||
off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
|
||||
const target = (fn ?? layerOrFn) as Handler;
|
||||
handlers.get(keyOf(type, layerOrFn))?.delete(target);
|
||||
once.get(type)?.delete(target);
|
||||
}),
|
||||
};
|
||||
const emit = (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event));
|
||||
return {
|
||||
map: map as unknown as MapboxMap,
|
||||
spies: map,
|
||||
state,
|
||||
sources,
|
||||
layers,
|
||||
images,
|
||||
canvas,
|
||||
container,
|
||||
emit,
|
||||
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
|
||||
emitIdle: () => {
|
||||
const pending = [...(once.get('idle') ?? [])];
|
||||
once.delete('idle');
|
||||
pending.forEach((fn) => fn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent;
|
||||
|
||||
const addedIds = (gl: ReturnType<typeof fakeGlMap>) => gl.spies.addLayer.mock.calls.map(([layer]) => layer.id);
|
||||
const spec = (gl: ReturnType<typeof fakeGlMap>, id: string) =>
|
||||
gl.spies.addLayer.mock.calls.map(([layer]) => layer).find((layer) => layer.id === id) as unknown as {
|
||||
paint: Record<string, unknown>;
|
||||
filter?: unknown;
|
||||
minzoom?: number;
|
||||
layout?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const filterOf = (ids: string[]) => ['in', ['get', 'group'], ['literal', ids]];
|
||||
|
||||
describe('useFerrataLayerGL', () => {
|
||||
it('FE-FERRATA-GL-001: adds the line and label sources and the casing, line, highlight casing, highlight and hit layers, in that order', () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
|
||||
expect(gl.spies.addSource).toHaveBeenCalledWith(LABEL_SOURCE, expect.objectContaining({ type: 'geojson' }));
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-002: draws a dashed violet line on a white casing, never by grade', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
const line = spec(gl, LINE);
|
||||
expect(line.paint['line-color']).toBe('black');
|
||||
expect(line.paint['line-dasharray']).toEqual([2, 1.5]);
|
||||
expect(spec(gl, CASING).paint['line-color']).toBe('white');
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.style.removeProperty('--map-ferrata-casing');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => {
|
||||
const gl = fakeGlMap(false);
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(gl.spies.addSource).not.toHaveBeenCalled();
|
||||
|
||||
gl.state.styleLoaded = true;
|
||||
gl.emitIdle();
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-004: new ferratas replace the sources data without rebuilding the layers', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { rerender } = renderHook(({ groups }) => useFerrataLayerGL(gl.map, groups, vi.fn()), {
|
||||
initialProps: { groups: GROUPS.slice(0, 1) },
|
||||
});
|
||||
rerender({ groups: GROUPS });
|
||||
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
features: [
|
||||
expect.objectContaining({ properties: { id: 'way/1', group: 'group/way/1' } }),
|
||||
expect.objectContaining({ properties: { id: 'way/2', group: 'group/way/1' } }),
|
||||
expect.objectContaining({ properties: { id: 'way/3', group: 'group/way/3' } }),
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(gl.sources.get(LABEL_SOURCE)?.setData).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ features: [expect.anything(), expect.anything()] })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
|
||||
const gl = fakeGlMap();
|
||||
const onSelect = vi.fn();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, onSelect));
|
||||
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
gl.emit('click', click);
|
||||
expect(onSelect).toHaveBeenCalledWith(GROUPS[0], GROUPS[0]?.segments[1], { lng: 11.705, lat: 46.505 });
|
||||
|
||||
onSelect.mockClear();
|
||||
gl.state.hit = null;
|
||||
gl.emit('click', click);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-006: the pointer turns into a hand over a line, and the whole ferrata under it lights up', () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(spec(gl, HIGHLIGHT).filter).toEqual(filterOf([]));
|
||||
|
||||
gl.emit(`mouseenter:${HIT}`);
|
||||
expect(gl.canvas.style.cursor).toBe('pointer');
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
act(() => gl.emit(`mousemove:${HIT}`, click));
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/1']));
|
||||
|
||||
act(() => gl.emit(`mouseleave:${HIT}`));
|
||||
expect(gl.canvas.style.cursor).toBe('');
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf([]));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-007: the ferrata open in the card stays highlighted', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { rerender } = renderHook(({ selected }) => useFerrataLayerGL(gl.map, GROUPS, vi.fn(), selected), {
|
||||
initialProps: { selected: null as string | null },
|
||||
});
|
||||
rerender({ selected: 'group/way/3' });
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/3']));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-008: adds the route icon layer from the zoom it shows at, once its image is drawn', async () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
expect(gl.images.has('trek-ferrata-icon')).toBe(true);
|
||||
const label = spec(gl, LABEL);
|
||||
expect(label.minzoom).toBe(13);
|
||||
expect(label.layout?.['icon-image']).toBe('trek-ferrata-icon');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-009: unmounting removes the layers, the sources, the icon and every listener; no map, no work', async () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
unmount();
|
||||
|
||||
expect(gl.layers.size).toBe(0);
|
||||
expect(gl.sources.size).toBe(0);
|
||||
expect(gl.images.size).toBe(0);
|
||||
expect(gl.listeners()).toBe(0);
|
||||
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
gl.state.removed = true;
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(gl.listeners()).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(gl.spies.setPaintProperty).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory'));
|
||||
expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS);
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
});
|
||||
it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
act(() => gl.emit(`mousemove:${HIT}`, click));
|
||||
|
||||
const label = gl.container.querySelector<HTMLElement>('[data-testid="ferrata-hover-label"]');
|
||||
expect(label?.style.display).toBe('');
|
||||
expect(label?.textContent).toBe('Via AK2');
|
||||
expect(label?.querySelector('span')?.textContent).toBe('K2');
|
||||
expect(label?.style.transform).toContain('translate(4px, -8px)');
|
||||
expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint');
|
||||
|
||||
act(() => gl.emit(`mouseleave:${HIT}`));
|
||||
expect(label?.style.display).toBe('none');
|
||||
unmount();
|
||||
expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-012: lights a ferrata solid on a wider casing, and draws wider lines on the 3D terrain', () => {
|
||||
const flat = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(flat.map, GROUPS, vi.fn()));
|
||||
expect(spec(flat, CASING).paint['line-width']).toBe(6);
|
||||
expect(spec(flat, LINE).paint['line-width']).toBe(2.5);
|
||||
expect(spec(flat, HIGHLIGHT_CASING).paint['line-width']).toBe(9);
|
||||
expect(spec(flat, HIGHLIGHT).paint['line-width']).toBe(4);
|
||||
expect(spec(flat, HIGHLIGHT).paint['line-dasharray']).toBeUndefined();
|
||||
|
||||
const terrain = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(terrain.map, GROUPS, vi.fn(), null, 'terrain'));
|
||||
expect(spec(terrain, CASING).paint['line-width']).toBe(8);
|
||||
expect(spec(terrain, LINE).paint['line-width']).toBe(3.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { mapHoverLabel } from '../Map/mapHoverLabel';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
|
||||
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
|
||||
import type { FerrataPaint } from './ferrataPaint';
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
const SOURCE = 'trek-ferratas';
|
||||
const LABEL_SOURCE = `${SOURCE}-labels`;
|
||||
const CASING = `${SOURCE}-casing`;
|
||||
const LINE = `${SOURCE}-line`;
|
||||
/** The wider casing under a lit ferrata, so it stands out by its shape and not only its colour. */
|
||||
const HIGHLIGHT_CASING = `${SOURCE}-highlight-casing`;
|
||||
/** The whole ferrata under the pointer or open in the card, drawn solid over its own line. */
|
||||
const HIGHLIGHT = `${SOURCE}-highlight`;
|
||||
/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */
|
||||
const HIT = `${SOURCE}-hit`;
|
||||
const LABEL = `${SOURCE}-label`;
|
||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT, LABEL];
|
||||
const LIT_LAYERS = [HIGHLIGHT_CASING, HIGHLIGHT];
|
||||
|
||||
/**
|
||||
* The line widths, in pixels. On the 3D terrain the map is seen at a slant
|
||||
* and over a hillshade, so the lines there are drawn wider to stay readable.
|
||||
*/
|
||||
interface FerrataWidths {
|
||||
casing: number;
|
||||
line: number;
|
||||
litCasing: number;
|
||||
lit: number;
|
||||
}
|
||||
|
||||
const FLAT_WIDTHS: FerrataWidths = { casing: 6, line: 2.5, litCasing: 9, lit: 4 };
|
||||
const TERRAIN_WIDTHS: FerrataWidths = { casing: 8, line: 3.5, litCasing: 11, lit: 5 };
|
||||
|
||||
/** Where the layer is drawn: the flat map, or the 3D terrain, which draws its lines wider. */
|
||||
type FerrataSurface = 'flat' | 'terrain';
|
||||
|
||||
/** Where on the map a ferrata was clicked, for a popup or card anchored there. */
|
||||
interface FerrataClickPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
/** A click on a line: the ferrata it belongs to, the OSM way under the pointer and where it landed. */
|
||||
export type FerrataSelectHandler = (group: FerrataGroup, segment: Ferrata, at: FerrataClickPoint) => void;
|
||||
|
||||
/** The groups to highlight, as a GL filter on the features' `group`. */
|
||||
function highlightFilter(ids: readonly string[]): ExpressionSpecification {
|
||||
return ['in', ['get', 'group'], ['literal', [...ids]]];
|
||||
}
|
||||
|
||||
const LINE_LAYOUT = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
|
||||
/**
|
||||
* The two sources and the five line layers, bottom to top: casing, dashed
|
||||
* line, the lit ferrata's wider casing and its solid line, hit line.
|
||||
*/
|
||||
function addFerrataLayers(
|
||||
map: Map,
|
||||
paint: FerrataPaint,
|
||||
widths: FerrataWidths,
|
||||
data: GeoJSON.GeoJSON,
|
||||
labels: GeoJSON.GeoJSON
|
||||
): void {
|
||||
map.addSource(SOURCE, { type: 'geojson', data });
|
||||
map.addSource(LABEL_SOURCE, { type: 'geojson', data: labels });
|
||||
map.addLayer({
|
||||
id: CASING,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.casing, 'line-width': widths.casing, 'line-opacity': 0.9 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: LINE,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.line, 'line-width': widths.line, 'line-dasharray': [2, 1.5] },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIGHLIGHT_CASING,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
filter: highlightFilter([]),
|
||||
paint: { 'line-color': paint.casing, 'line-width': widths.litCasing },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIGHLIGHT,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
filter: highlightFilter([]),
|
||||
paint: { 'line-color': paint.highlight, 'line-width': widths.lit },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIT,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the route icon in `paint` and, the first time, the symbol layer that
|
||||
* shows it from FERRATA_ICON_MIN_ZOOM. Done once the image is decoded; a
|
||||
* layer torn down meanwhile (`gone`) is left alone.
|
||||
*/
|
||||
function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): void {
|
||||
void loadFerrataIcon(paint).then(
|
||||
(image) => {
|
||||
if (gone() || !map.getSource(LABEL_SOURCE)) return;
|
||||
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
||||
map.addImage(FERRATA_ICON_IMAGE, image, { pixelRatio: FERRATA_ICON_PIXEL_RATIO });
|
||||
if (map.getLayer(LABEL)) return;
|
||||
map.addLayer({
|
||||
id: LABEL,
|
||||
type: 'symbol',
|
||||
source: LABEL_SOURCE,
|
||||
minzoom: FERRATA_ICON_MIN_ZOOM,
|
||||
layout: { 'icon-image': FERRATA_ICON_IMAGE, 'icon-allow-overlap': false, 'icon-ignore-placement': false },
|
||||
});
|
||||
},
|
||||
// Undecodable: the lines simply go without their icons.
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
function removeFerrataLayers(map: Map): void {
|
||||
// A map already removed with its view (leaving 3D) took its style and the layers with it;
|
||||
// asking it for a layer then throws.
|
||||
if (!map.getStyle?.()) return;
|
||||
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
|
||||
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
|
||||
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer on a MapLibre or Mapbox GL map: a source of the OSM
|
||||
* ways and one of a point per ferrata, a light casing, the dark dashed line,
|
||||
* the highlight of the ferrata under the pointer or `selectedId` (solid and
|
||||
* wider, on a wider casing), a hit line that turns a click into
|
||||
* `onSelect(group, way, point)` and shows the name and grade above the
|
||||
* pointer, and from FERRATA_ICON_MIN_ZOOM a small route icon halfway along
|
||||
* each ferrata. `surface` 'terrain' draws the lines wider for the 3D map.
|
||||
*
|
||||
* The layers are installed once per map and again after a style swap (a
|
||||
* basemap or theme change drops every custom layer); new ferratas only replace
|
||||
* the sources' data, so tiles loading after a pan do not rebuild the layers.
|
||||
* Like the hazard overlay, a draw that finds the style busy loading tiles
|
||||
* waits for `idle` rather than for a `style.load` that may never come. A theme
|
||||
* change repaints the installed layers in place and redraws the icon.
|
||||
*/
|
||||
export function useFerrataLayerGL(
|
||||
map: Map | null,
|
||||
groups: readonly FerrataGroup[],
|
||||
onSelect: FerrataSelectHandler,
|
||||
selectedId: string | null = null,
|
||||
surface: FerrataSurface = 'flat'
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const data = useMemo(() => ferrataFeatureCollection(groups), [groups]);
|
||||
const labels = useMemo(() => ferrataLabelCollection(groups), [groups]);
|
||||
const paint = useFerrataPaint();
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// The handlers read the newest props without reinstalling the layers on every render.
|
||||
const latest = useRef({ groups, data, labels, onSelect, paint, t, surface });
|
||||
// The paint the installed layers were last drawn in, so a mount does not repaint what it just drew.
|
||||
const painted = useRef<FerrataPaint | null>(null);
|
||||
// Set by the install effect: redraws the icon in the current paint on a theme change.
|
||||
const iconRepaint = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
latest.current = { groups, data, labels, onSelect, paint, t, surface };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
let waiting = false;
|
||||
let disposed = false;
|
||||
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
|
||||
const draw = () => {
|
||||
if (map.getSource(SOURCE)) return;
|
||||
if (!map.isStyleLoaded()) {
|
||||
if (!waiting) {
|
||||
waiting = true;
|
||||
map.once('idle', retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { paint, data, labels } = latest.current;
|
||||
painted.current = paint;
|
||||
const widths = latest.current.surface === 'terrain' ? TERRAIN_WIDTHS : FLAT_WIDTHS;
|
||||
addFerrataLayers(map, paint, widths, data, labels);
|
||||
addFerrataIcon(map, paint, () => disposed);
|
||||
};
|
||||
const retry = () => {
|
||||
waiting = false;
|
||||
draw();
|
||||
};
|
||||
const featureAt = (event: MapMouseEvent) => {
|
||||
if (!map.getLayer(HIT)) return null;
|
||||
const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0];
|
||||
return hit?.properties ?? null;
|
||||
};
|
||||
const click = (event: MapMouseEvent) => {
|
||||
const id: unknown = featureAt(event)?.id;
|
||||
for (const group of latest.current.groups) {
|
||||
const segment = group.segments.find((candidate) => candidate.id === id);
|
||||
if (segment) {
|
||||
latest.current.onSelect(group, segment, { lng: event.lngLat.lng, lat: event.lngLat.lat });
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
const move = (event: MapMouseEvent) => {
|
||||
const id: unknown = featureAt(event)?.group;
|
||||
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
|
||||
setHovered(group ? group.id : null);
|
||||
if (group) {
|
||||
const label = ferrataLabel(group, latest.current.t);
|
||||
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
|
||||
} else hoverLabel.hide();
|
||||
};
|
||||
const enter = () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
const leave = () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
setHovered(null);
|
||||
hoverLabel.hide();
|
||||
};
|
||||
const repaintIcon = () => addFerrataIcon(map, latest.current.paint, () => disposed);
|
||||
|
||||
draw();
|
||||
map.on('style.load', draw);
|
||||
map.on('click', click);
|
||||
map.on('mousemove', HIT, move);
|
||||
map.on('mouseenter', HIT, enter);
|
||||
map.on('mouseleave', HIT, leave);
|
||||
iconRepaint.current = repaintIcon;
|
||||
return () => {
|
||||
disposed = true;
|
||||
iconRepaint.current = null;
|
||||
map.off('style.load', draw);
|
||||
map.off('idle', retry);
|
||||
map.off('click', click);
|
||||
map.off('mousemove', HIT, move);
|
||||
map.off('mouseenter', HIT, enter);
|
||||
map.off('mouseleave', HIT, leave);
|
||||
hoverLabel.remove();
|
||||
removeFerrataLayers(map);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
(map?.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(data);
|
||||
(map?.getSource(LABEL_SOURCE) as GeoJSONSource | undefined)?.setData(labels);
|
||||
}, [map, data, labels]);
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => [...new Set([hovered, selectedId].filter((id): id is string => !!id))],
|
||||
[hovered, selectedId]
|
||||
);
|
||||
useEffect(() => {
|
||||
for (const layer of LIT_LAYERS) if (map?.getLayer(layer)) map.setFilter(layer, highlightFilter(highlighted));
|
||||
}, [map, highlighted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || painted.current === paint || !map.getLayer(LINE)) return;
|
||||
painted.current = paint;
|
||||
for (const layer of [CASING, HIGHLIGHT_CASING])
|
||||
if (map.getLayer(layer)) map.setPaintProperty(layer, 'line-color', paint.casing);
|
||||
map.setPaintProperty(LINE, 'line-color', paint.line);
|
||||
if (map.getLayer(HIGHLIGHT)) map.setPaintProperty(HIGHLIGHT, 'line-color', paint.highlight);
|
||||
iconRepaint.current?.();
|
||||
}, [map, paint]);
|
||||
}
|
||||
|
||||
/** The GL map the layer draws on. MapLibre's map answers the same calls the layer makes. */
|
||||
export type FerrataGlMap = Map;
|
||||
|
||||
/**
|
||||
* Just the two calls a hit test needs, so the Mapbox and the MapLibre map both
|
||||
* fit, each with the point type of its own click events. Optional because a stand-in map (a test double, a map torn down mid
|
||||
* click) may lack them, and a hit test must then answer no rather than throw.
|
||||
*/
|
||||
interface HitTestMap<Point> {
|
||||
getLayer?(id: string): unknown;
|
||||
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click at `point` landed on a drawn ferrata. A map's own click
|
||||
* handler asks this first, so picking a ferrata opens its card instead of also
|
||||
* dropping a control point or a place under it.
|
||||
*/
|
||||
export function isFerrataHit<Point>(map: HitTestMap<Point>, point: Point): boolean {
|
||||
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
|
||||
return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0;
|
||||
}
|
||||
|
||||
const NO_GROUPS: readonly FerrataGroup[] = [];
|
||||
const IGNORE_SELECT: FerrataSelectHandler = () => {};
|
||||
|
||||
/**
|
||||
* The layer as a map view mounts it from a `FerrataMapLayer`: drawn only while
|
||||
* it is switched on, so a map with the layer off carries no source or layers.
|
||||
*/
|
||||
export function useFerrataMapLayerGL(
|
||||
map: Map | null,
|
||||
layer:
|
||||
| { active: boolean; groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; selectedId: string | null }
|
||||
| undefined,
|
||||
surface: FerrataSurface = 'flat'
|
||||
) {
|
||||
useFerrataLayerGL(
|
||||
layer?.active ? map : null,
|
||||
layer?.groups ?? NO_GROUPS,
|
||||
layer?.onSelect ?? IGNORE_SELECT,
|
||||
layer?.selectedId ?? null,
|
||||
surface
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-008: the via ferrata layer as
|
||||
* one map surface holds it: off by default, the area the map reports, the
|
||||
* trip's area to prefetch, the ferratas grouped from OSM's segments with their
|
||||
* count in view, and the card a click opens.
|
||||
*
|
||||
* useTripFerratas is mocked: what it asks the server is its own suite's
|
||||
* subject. This one checks what the layer hands it (the view, whether it is on
|
||||
* at all, the trip's area) and what it hands the map back.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata, FerrataBbox } from '@trek/shared';
|
||||
|
||||
import { ferrataAreaOf, useFerrataMapLayer } from './useFerrataMapLayer';
|
||||
import { useTripFerratas } from './useTripFerratas';
|
||||
|
||||
vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
|
||||
|
||||
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
|
||||
const OTHER: FerrataBbox = { south: 47.4, west: 11.6, north: 47.5, east: 11.8 };
|
||||
|
||||
const segment = (id: number, lat: number): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: String(id), level: id, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, 11.8],
|
||||
[lat + 0.001, 11.8],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
});
|
||||
// Two touching segments of one ferrata.
|
||||
const LOWER = segment(2, 46.52);
|
||||
const UPPER = segment(4, 46.521);
|
||||
|
||||
const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER], status: 'ready', truncated: false });
|
||||
});
|
||||
|
||||
describe('useFerrataMapLayer', () => {
|
||||
it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
expect(result.current.layer.active).toBe(false);
|
||||
expect(result.current.layer.count).toBe(0);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(lastCall()).toEqual([7, null, false, null, []]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
const renders = vi.mocked(useTripFerratas).mock.calls.length;
|
||||
|
||||
act(() => result.current.onViewportChange(BOX));
|
||||
expect(vi.mocked(useTripFerratas).mock.calls.length).toBe(renders);
|
||||
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.active).toBe(true);
|
||||
expect(lastCall()?.slice(0, 3)).toEqual([7, BOX, true]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
act(() => result.current.onViewportChange(OTHER));
|
||||
expect(lastCall()?.slice(0, 3)).toEqual([7, OTHER, true]);
|
||||
});
|
||||
|
||||
it("FE-FERRATA-MAPLAYER-004: hands the trip's area around its lines to the loader", () => {
|
||||
const lines = [
|
||||
[[46.5, 11.7]],
|
||||
[
|
||||
[46.7, 11.6],
|
||||
[46.6, 11.9],
|
||||
],
|
||||
] as Array<Array<[number, number]>>;
|
||||
renderHook(() => useFerrataMapLayer(7, lines));
|
||||
expect(lastCall()?.[3]).toEqual({ south: 46.5, west: 11.6, north: 46.7, east: 11.9 });
|
||||
expect(ferrataAreaOf([])).toBeNull();
|
||||
expect(ferrataAreaOf([[[Number.NaN, 1]]])).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-005: groups the segments into one ferrata and counts the ferratas in view', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.onViewportChange(BOX));
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.groups).toHaveLength(1);
|
||||
expect(result.current.layer.groups[0]?.segments).toEqual([LOWER, UPPER]);
|
||||
expect(result.current.layer.count).toBe(1);
|
||||
|
||||
act(() => result.current.onViewportChange(OTHER));
|
||||
expect(result.current.layer.count).toBe(0);
|
||||
// Out of view, still drawn.
|
||||
expect(result.current.layer.groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-006: a click opens the whole ferrata in the card, highlighted, and close shuts it', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
|
||||
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
|
||||
expect(result.current.selected?.group.id).toBe(group.id);
|
||||
expect(result.current.selected?.segment).toBe(UPPER);
|
||||
expect(result.current.layer.selectedId).toBe(group.id);
|
||||
|
||||
act(() => result.current.close());
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(result.current.layer.selectedId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-007: the card follows its ferrata when more of its segments load', () => {
|
||||
const { result, rerender } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
|
||||
|
||||
// A lower way loads and joins the ferrata; its id is now the group's.
|
||||
const lowest = segment(1, 46.519);
|
||||
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER, lowest], status: 'ready', truncated: false });
|
||||
rerender();
|
||||
expect(result.current.selected?.group.segments).toHaveLength(3);
|
||||
expect(result.current.selected?.segment).toBe(UPPER);
|
||||
expect(result.current.layer.selectedId).toBe('group/way/1');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-008: switching the layer off closes the card and stops asking', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
act(() => result.current.layer.onSelect(group, LOWER, { lat: 46.52, lng: 11.8 }));
|
||||
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.active).toBe(false);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(lastCall()?.[2]).toBe(false);
|
||||
|
||||
// On again, the card stays closed until the next click.
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.selected).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Ferrata, FerrataBbox } from '@trek/shared';
|
||||
|
||||
import { type FerrataCrossing, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
|
||||
import {
|
||||
type FerrataGroup,
|
||||
ferrataGroupOf,
|
||||
ferrataGroupsInView,
|
||||
ferrataPointsBbox,
|
||||
groupFerratas,
|
||||
} from './ferrataGroups';
|
||||
import type { FerrataSelectHandler } from './useFerrataLayerGL';
|
||||
import { type FerrataStatus, useTripFerratas } from './useTripFerratas';
|
||||
|
||||
/**
|
||||
* The via ferrata layer as a map hands it around: whether it is on, the switch
|
||||
* the layer pill shows with its count badge, the ferratas to draw (grouped
|
||||
* from OSM's segments), the one open in the card, and what a click on a line
|
||||
* does. Both flat renderers and the 3D terrain map take this one object, so
|
||||
* the three cannot drift apart on what the layer is.
|
||||
*/
|
||||
export interface FerrataMapLayer {
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
status: FerrataStatus;
|
||||
/** How many ferratas reach into the view on screen. */
|
||||
count: number;
|
||||
groups: readonly FerrataGroup[];
|
||||
/** The ferrata open in the card, drawn highlighted. */
|
||||
selectedId: string | null;
|
||||
onSelect: FerrataSelectHandler;
|
||||
}
|
||||
|
||||
/** The ferrata open in the card, and the OSM way that was picked on it. */
|
||||
export interface FerrataSelection {
|
||||
group: FerrataGroup;
|
||||
segment: Ferrata;
|
||||
}
|
||||
|
||||
/** The box around every point of every line the stage hands over, or null for none. */
|
||||
export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>): FerrataBbox | null {
|
||||
return ferrataPointsBbox(lines.flat());
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer of one map surface: off by default, and nothing is
|
||||
* read or asked for while it is off.
|
||||
*
|
||||
* `onViewportChange` takes the visible area from whichever map is on screen
|
||||
* (the flat planner map or the 3D terrain). While the layer is off the area is
|
||||
* only remembered, not put into state, so panning a map with the layer off
|
||||
* re-renders nothing; switching it on starts from the last area the map
|
||||
* reported instead of waiting for the next pan. `areaLines` are the lines the
|
||||
* trip's area is made of (tour routes, waypoints, places), prefetched once
|
||||
* the layer is on. The selection names the OSM way that was clicked, so it
|
||||
* follows its ferrata when more segments load and join it, and closes when the
|
||||
* layer is switched off.
|
||||
*/
|
||||
export function useFerrataMapLayer(
|
||||
tripId: number | null | undefined,
|
||||
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [],
|
||||
/** The tour's line as the elevation profile measures it; the ferratas along it load with the layer off too. */
|
||||
routeLine: readonly (readonly [number, number])[] | null = null
|
||||
) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
|
||||
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
|
||||
const lastArea = useRef<FerrataBbox | null>(null);
|
||||
const activeRef = useRef(false);
|
||||
const area = ferrataAreaOf(areaLines);
|
||||
const routeTiles = useMemo(() => ferrataTilesAlong(routeLine), [routeLine]);
|
||||
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area, routeTiles);
|
||||
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
|
||||
const crossings = useMemo<FerrataCrossing[]>(() => routeFerrataCrossings(routeLine, groups), [routeLine, groups]);
|
||||
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
|
||||
|
||||
const onViewportChange = useCallback((next: FerrataBbox) => {
|
||||
lastArea.current = next;
|
||||
if (activeRef.current) setBbox(next);
|
||||
}, []);
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
const next = !activeRef.current;
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
setSelectedSegment(null);
|
||||
if (next) setBbox(lastArea.current);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => setSelectedSegment(null), []);
|
||||
const onSelect = useCallback<FerrataSelectHandler>((_group, segment) => setSelectedSegment(segment.id), []);
|
||||
|
||||
const selected = useMemo<FerrataSelection | null>(() => {
|
||||
if (!active) return null;
|
||||
const group = ferrataGroupOf(groups, selectedSegment);
|
||||
const segment = group?.segments.find((candidate) => candidate.id === selectedSegment);
|
||||
return group && segment ? { group, segment } : null;
|
||||
}, [active, groups, selectedSegment]);
|
||||
const selectedId = selected?.group.id ?? null;
|
||||
|
||||
const layer = useMemo<FerrataMapLayer>(
|
||||
() => ({ active, onToggle, status, count, groups, selectedId, onSelect }),
|
||||
[active, onToggle, status, count, groups, selectedId, onSelect]
|
||||
);
|
||||
|
||||
return { layer, onViewportChange, selected, close, crossings };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-FERRATA-USEPAINT-001 to FE-FERRATA-USEPAINT-003: the line colours follow the theme on <html>.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
afterEach(() => {
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.classList.remove('dark', 'unrelated');
|
||||
root.removeAttribute('data-scheme');
|
||||
});
|
||||
|
||||
describe('useFerrataPaint', () => {
|
||||
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
expect(result.current.line).toBe('black');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
|
||||
await act(async () => {
|
||||
root.setAttribute('data-scheme', 'forest');
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.line).toBe('ivory'));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
const first = result.current;
|
||||
|
||||
await act(async () => {
|
||||
root.classList.add('unrelated');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-FERRATA-GUARD-001: the press that closed a ferrata's card is kept from also acting on the map.
|
||||
import { act, fireEvent, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useOutsidePressGuard } from './useOutsidePressGuard';
|
||||
|
||||
describe('useOutsidePressGuard', () => {
|
||||
it('FE-FERRATA-GUARD-001: the guard drops the one map click after the closing press and is off once that click went by', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useOutsidePressGuard());
|
||||
const guarded = result.current.guard(handler);
|
||||
expect(result.current.guard(undefined)).toBeUndefined();
|
||||
|
||||
act(() => result.current.arm());
|
||||
guarded?.('first');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
guarded?.('second');
|
||||
expect(handler).toHaveBeenCalledWith('second');
|
||||
|
||||
// Armed, but the click lands somewhere that is not the map: the guard comes off right after it.
|
||||
act(() => result.current.arm());
|
||||
fireEvent.click(document.body);
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
guarded?.('third');
|
||||
expect(handler).toHaveBeenCalledWith('third');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Keeps the press that closed the card from also acting on the map.
|
||||
*
|
||||
* `arm` goes to the overlay's `onOutsidePress`; `guard` wraps the map's own
|
||||
* click handlers (adding a waypoint, say). The click that ends the closing
|
||||
* press is dropped once, and the guard is off again as soon as that click has
|
||||
* gone by, or at the next press when the first one never became a click (a
|
||||
* pan that ended off the page). A press on another ferrata keeps working: its
|
||||
* click opens that ferrata's card, which never runs a guarded handler.
|
||||
*/
|
||||
export function useOutsidePressGuard() {
|
||||
const armed = useRef(false);
|
||||
const disarm = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => disarm.current?.(), []);
|
||||
|
||||
const arm = useCallback(() => {
|
||||
disarm.current?.();
|
||||
armed.current = true;
|
||||
let timer: number | undefined;
|
||||
const off = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
document.removeEventListener('click', afterClick, true);
|
||||
document.removeEventListener('pointerdown', nextPress, true);
|
||||
disarm.current = null;
|
||||
};
|
||||
// The map's handler runs during this click's dispatch; the guard comes off right after it.
|
||||
const afterClick = () => {
|
||||
document.removeEventListener('click', afterClick, true);
|
||||
timer = window.setTimeout(() => {
|
||||
armed.current = false;
|
||||
off();
|
||||
}, 0);
|
||||
};
|
||||
const nextPress = () => {
|
||||
armed.current = false;
|
||||
off();
|
||||
};
|
||||
document.addEventListener('click', afterClick, true);
|
||||
// Listened for from the next task on, so the press being dispatched now does not count.
|
||||
window.setTimeout(() => {
|
||||
if (armed.current && disarm.current === off) document.addEventListener('pointerdown', nextPress, true);
|
||||
}, 0);
|
||||
disarm.current = off;
|
||||
}, []);
|
||||
|
||||
const guard = useCallback(<A extends unknown[]>(handler: ((...args: A) => void) | undefined) => {
|
||||
if (!handler) return undefined;
|
||||
return (...args: A) => {
|
||||
if (armed.current) {
|
||||
armed.current = false;
|
||||
return;
|
||||
}
|
||||
handler(...args);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { arm, guard };
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* FE-FERRATA-HOOK-001 to FE-FERRATA-HOOK-010: when the via ferrata layer asks
|
||||
* the server, what it keeps, and what it reports while it does.
|
||||
*
|
||||
* The requests reach a public Overpass mirror behind the server, so the hook
|
||||
* has to be frugal: nothing while the layer is off, a trip's cached tiles
|
||||
* first, then only the tiles of the shared grid that are not loaded yet, never
|
||||
* one twice, and none at all for an area the server would refuse or a device
|
||||
* that is offline.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { ferrataRepo } from '../../repo/ferrataRepo';
|
||||
import { FERRATA_AREA_PREFETCH_MAX_TILES, useTripFerratas } from './useTripFerratas';
|
||||
|
||||
vi.mock('../../repo/ferrataRepo', () => ({
|
||||
ferrataRepo: { list: vi.fn(), cachedTiles: vi.fn(), saveTiles: vi.fn(), cacheName: vi.fn(() => 'trek-offline') },
|
||||
}));
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: vi.fn() }));
|
||||
|
||||
/** Exactly the tile 930:236. */
|
||||
const TILE: FerrataBbox = { south: 46.5, west: 11.8, north: 46.55, east: 11.85 };
|
||||
const TILE_PARAM = '46.500,11.800,46.550,11.850';
|
||||
/** The first block of the ring around it, south west of it: the ring goes out a 0.1 degree block at a time. */
|
||||
const RING_FIRST_PARAM = '46.450,11.750,46.500,11.800';
|
||||
/** A view inside the same tile, after a small pan. */
|
||||
const INSIDE: FerrataBbox = { south: 46.51, west: 11.81, north: 46.54, east: 11.84 };
|
||||
const WIDE: FerrataBbox = { south: 46, west: 11, north: 47, east: 12 };
|
||||
|
||||
const FERRATA: Ferrata = {
|
||||
id: 'way/42',
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: '4', level: 4, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[46.52, 11.82],
|
||||
[46.53, 11.82],
|
||||
],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/42',
|
||||
};
|
||||
|
||||
const reply = (ferratas: Ferrata[] = [FERRATA]): FerratasResponse => ({
|
||||
ferratas,
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
});
|
||||
|
||||
const network = (offline: boolean) =>
|
||||
vi.mocked(useNetworkMode).mockReturnValue({ offline, forced: false, setForced: vi.fn() });
|
||||
|
||||
const params = () => vi.mocked(ferrataRepo.list).mock.calls.map(([, bbox]) => bbox);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
network(false);
|
||||
vi.mocked(ferrataRepo.list).mockResolvedValue(reply());
|
||||
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([]);
|
||||
vi.mocked(ferrataRepo.saveTiles).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Lets the cache read and settled requests run their handlers. */
|
||||
async function drain() {
|
||||
await act(async () => {
|
||||
// Enough turns for the prefetches, which go out one after another, to settle too.
|
||||
for (let i = 0; i < 40; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Lets the debounce run out, then everything it started settle. */
|
||||
async function settle(ms = 200) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
await drain();
|
||||
}
|
||||
|
||||
describe('useTripFerratas', () => {
|
||||
it('FE-FERRATA-HOOK-001: reads and asks for nothing while the layer is off', async () => {
|
||||
const { result } = renderHook(() => useTripFerratas(7, TILE, false));
|
||||
await settle(1_000);
|
||||
|
||||
expect(ferrataRepo.cachedTiles).not.toHaveBeenCalled();
|
||||
expect(ferrataRepo.list).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-002: switched on, asks for the missing tiles of the view after the debounce, then the ring', async () => {
|
||||
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
|
||||
await drain();
|
||||
expect(ferrataRepo.cachedTiles).toHaveBeenCalledWith(7);
|
||||
expect(ferrataRepo.list).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('loading');
|
||||
|
||||
await settle();
|
||||
expect(params()[0]).toBe(TILE_PARAM);
|
||||
expect(params().slice(1)).toContain(RING_FIRST_PARAM);
|
||||
expect(vi.mocked(ferrataRepo.list).mock.calls[0]?.[0]).toBe(7);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.ferratas).toEqual([FERRATA]);
|
||||
expect(ferrataRepo.saveTiles).toHaveBeenCalledWith(7, expect.any(Array), 'trek-offline');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-003: draws the cached tiles at once and does not ask for them again', async () => {
|
||||
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
|
||||
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
|
||||
await drain();
|
||||
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.ferratas).toEqual([FERRATA]);
|
||||
await settle();
|
||||
expect(params()).not.toContain(TILE_PARAM);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-004: a pan inside loaded tiles asks for nothing, and keeps every line drawn', async () => {
|
||||
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
|
||||
initialProps: { view: TILE },
|
||||
});
|
||||
await settle();
|
||||
const asked = params().length;
|
||||
|
||||
rerender({ view: INSIDE });
|
||||
await settle();
|
||||
expect(params()).toHaveLength(asked);
|
||||
expect(result.current.ferratas).toEqual([FERRATA]);
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-005: a fast pan ends in one request for where the map stopped', async () => {
|
||||
const { rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
|
||||
initialProps: { view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } },
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
rerender({ view: TILE });
|
||||
await settle();
|
||||
expect(params()[0]).toBe(TILE_PARAM);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-006: offline it asks for nothing and still draws what the device kept', async () => {
|
||||
network(true);
|
||||
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
|
||||
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
|
||||
await settle(1_000);
|
||||
|
||||
expect(ferrataRepo.list).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('offline');
|
||||
expect(result.current.ferratas).toEqual([FERRATA]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-007: a view wider than the server takes reads as zoom in and asks for nothing', async () => {
|
||||
const { result } = renderHook(() => useTripFerratas(7, WIDE, true));
|
||||
await settle(1_000);
|
||||
|
||||
expect(ferrataRepo.list).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('tooLarge');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-008: reports the mirrors failing as an error and a refused box as zoom in', async () => {
|
||||
vi.mocked(ferrataRepo.list).mockRejectedValue(new Error('502'));
|
||||
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
|
||||
initialProps: { view: TILE },
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.status).toBe('error');
|
||||
|
||||
vi.mocked(ferrataRepo.list).mockRejectedValue({ response: { status: 400 } });
|
||||
rerender({ view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } });
|
||||
await settle();
|
||||
expect(result.current.status).toBe('tooLarge');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-HOOK-009: switched off and on again asks for no tile it already has', async () => {
|
||||
const { result, rerender } = renderHook(({ on }) => useTripFerratas(7, TILE, on), {
|
||||
initialProps: { on: true },
|
||||
});
|
||||
await settle();
|
||||
const asked = params().length;
|
||||
|
||||
rerender({ on: false });
|
||||
expect(result.current.status).toBe('idle');
|
||||
rerender({ on: true });
|
||||
await settle();
|
||||
expect(params()).toHaveLength(asked);
|
||||
expect(ferrataRepo.cachedTiles).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.ferratas).toEqual([FERRATA]);
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it("FE-FERRATA-HOOK-010: prefetches the trip's area in the background, but not one wider than the cap", async () => {
|
||||
const area: FerrataBbox = { south: 46.0, west: 11.0, north: 46.1, east: 11.1 };
|
||||
const first = renderHook(() => useTripFerratas(7, TILE, true, area));
|
||||
await settle();
|
||||
expect(params()).toContain('46.000,11.000,46.100,11.100');
|
||||
first.unmount();
|
||||
|
||||
vi.mocked(ferrataRepo.list).mockClear();
|
||||
// 2 by 2 degrees is 1600 tiles, past FERRATA_AREA_PREFETCH_MAX_TILES.
|
||||
expect(FERRATA_AREA_PREFETCH_MAX_TILES).toBeLessThan(1600);
|
||||
renderHook(() => useTripFerratas(8, null, true, { south: 44, west: 10, north: 46, east: 12 }));
|
||||
await settle(1_000);
|
||||
expect(ferrataRepo.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
ferrataTileCount,
|
||||
ferrataTileKey,
|
||||
ferrataTileRange,
|
||||
ferrataTilesOf,
|
||||
growFerrataTileRange,
|
||||
parseFerrataTileKey,
|
||||
type Ferrata,
|
||||
type FerrataBbox,
|
||||
type FerrataTile,
|
||||
type FerrataTileRange,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { ferrataRepo } from '../../repo/ferrataRepo';
|
||||
import { ferrataBboxFits, ferrataBboxParam } from './ferrataModel';
|
||||
import { FerrataTileStore, type FerrataTileSnapshot } from './ferrataTileStore';
|
||||
|
||||
/**
|
||||
* Where the layer stands for the view on screen. `tooLarge` and `offline` are
|
||||
* answers to "why are there no new lines" that need no request at all: the
|
||||
* server refuses a box wider than its limit, and an offline device should not
|
||||
* fire one. Whatever was loaded before stays drawn in both.
|
||||
*/
|
||||
export type FerrataStatus = 'idle' | 'loading' | 'ready' | 'error' | 'tooLarge' | 'offline';
|
||||
|
||||
/** Long enough that a pan or a zoom gesture settles before the missing tiles are asked for. */
|
||||
const DEBOUNCE_MS = 200;
|
||||
/** The ring of tiles around the view that loads behind it, so a short pan finds its lines already there. */
|
||||
const PREFETCH_RINGS = 1;
|
||||
/**
|
||||
* The trip's area is prefetched up to this many tiles (about 0.4 by 0.4
|
||||
* degree, at most sixteen requests of 0.1 degree); a trip spread wider loads
|
||||
* as the map gets there.
|
||||
*/
|
||||
export const FERRATA_AREA_PREFETCH_MAX_TILES = 64;
|
||||
|
||||
const NO_TILES: readonly string[] = [];
|
||||
|
||||
const EMPTY_SNAPSHOT: FerrataTileSnapshot = {
|
||||
loaded: new Set(),
|
||||
ferratas: [],
|
||||
waiting: new Set(),
|
||||
failure: null,
|
||||
truncated: false,
|
||||
hydrated: false,
|
||||
};
|
||||
const subscribeNothing = () => () => {};
|
||||
const emptySnapshot = () => EMPTY_SNAPSHOT;
|
||||
|
||||
/** A range as a dependency-friendly string, and back. */
|
||||
const rangeKeyOf = (range: FerrataTileRange) => `${range.minRow},${range.maxRow},${range.minCol},${range.maxCol}`;
|
||||
function rangeOfKey(key: string): FerrataTileRange {
|
||||
const [minRow = 0, maxRow = 0, minCol = 0, maxCol = 0] = key.split(',').map(Number);
|
||||
return { minRow, maxRow, minCol, maxCol };
|
||||
}
|
||||
|
||||
/** `missing`: some tile of the view is not loaded yet. */
|
||||
function statusOf(
|
||||
active: boolean,
|
||||
offline: boolean,
|
||||
fits: boolean,
|
||||
missing: boolean,
|
||||
snapshot: FerrataTileSnapshot
|
||||
): FerrataStatus {
|
||||
if (!active) return 'idle';
|
||||
if (offline) return 'offline';
|
||||
if (!fits || (missing && snapshot.failure === 'refused')) return 'tooLarge';
|
||||
if (!missing) return 'ready';
|
||||
return snapshot.failure === 'unavailable' ? 'error' : 'loading';
|
||||
}
|
||||
|
||||
/** One tile store per trip; it lives as long as the map surface does, so 2D, 3D and a toggle share it. */
|
||||
function useTileStore(tripId: number | null | undefined): FerrataTileStore | null {
|
||||
return useMemo(() => {
|
||||
if (!tripId) return null;
|
||||
const cacheName = ferrataRepo.cacheName();
|
||||
return new FerrataTileStore({
|
||||
fetch: (bbox, signal) => ferrataRepo.list(tripId, ferrataBboxParam(bbox), signal),
|
||||
persist: (rows) => ferrataRepo.saveTiles(tripId, rows, cacheName),
|
||||
});
|
||||
}, [tripId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferratas of a trip, for the map layer, loaded per tile of the shared
|
||||
* 0.05 degree grid (see `FerrataTileStore`).
|
||||
*
|
||||
* Nothing is read or requested while the layer is off. Switched on, the trip's
|
||||
* cached tiles come back from the device first and draw at once, offline too;
|
||||
* then the view's missing tiles are asked for DEBOUNCE_MS after the map
|
||||
* settles, the ring around them behind that, and `area` (the trip's own
|
||||
* extent) in the background once, after the same pause, so the view's tiles
|
||||
* are always asked for first. Every ferrata loaded stays drawn: a pan, a
|
||||
* zoom, a switch between 2D and 3D or the layer off and on again never drops
|
||||
* one, and never asks again for a tile it has.
|
||||
*/
|
||||
export function useTripFerratas(
|
||||
tripId: number | null | undefined,
|
||||
view: FerrataBbox | null,
|
||||
enabled: boolean,
|
||||
area: FerrataBbox | null = null,
|
||||
routeTiles: readonly FerrataTile[] | null = null
|
||||
): { ferratas: readonly Ferrata[]; status: FerrataStatus; truncated: boolean } {
|
||||
const { offline } = useNetworkMode();
|
||||
const store = useTileStore(tripId);
|
||||
const snapshot = useSyncExternalStore(
|
||||
store ? store.subscribe : subscribeNothing,
|
||||
store ? store.getSnapshot : emptySnapshot
|
||||
);
|
||||
// The tiles a tour's line runs through load with the layer off too, for the warning
|
||||
// that a route climbs a ferrata; only the layer's own state decides what it shows.
|
||||
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
|
||||
const layerOn = enabled && !!store;
|
||||
const active = layerOn || (!!routeKey && !!store);
|
||||
const fits = !!view && ferrataBboxFits(view);
|
||||
const viewKey = view && fits ? rangeKeyOf(ferrataTileRange(view)) : null;
|
||||
const areaRange = area && ferrataBboxFitsArea(area) ? ferrataTileRange(area) : null;
|
||||
const areaKey =
|
||||
areaRange && ferrataTileCount(areaRange) <= FERRATA_AREA_PREFETCH_MAX_TILES ? rangeKeyOf(areaRange) : null;
|
||||
const visibleKeys = useMemo(
|
||||
() => (viewKey ? ferrataTilesOf(rangeOfKey(viewKey)).map(ferrataTileKey) : NO_TILES),
|
||||
[viewKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !store || !tripId) return;
|
||||
if (store.beginHydration()) {
|
||||
void ferrataRepo.cachedTiles(tripId).then(
|
||||
(rows) => store.hydrate(rows),
|
||||
// An unreadable cache is an empty one: the tiles load from the network instead.
|
||||
() => store.hydrate([])
|
||||
);
|
||||
}
|
||||
return () => store.stop();
|
||||
}, [active, store, tripId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layerOn || !store || offline || !viewKey) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const range = rangeOfKey(viewKey);
|
||||
store.want(ferrataTilesOf(range), 'visible');
|
||||
store.want(ferrataTilesOf(growFerrataTileRange(range, PREFETCH_RINGS)), 'background');
|
||||
}, DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layerOn, store, offline, viewKey]);
|
||||
|
||||
// Declared after the view's effect, so with both due its timer fires second.
|
||||
useEffect(() => {
|
||||
if (!layerOn || !store || offline || !areaKey) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
store.want(ferrataTilesOf(rangeOfKey(areaKey)), 'background');
|
||||
}, DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layerOn, store, offline, areaKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !store || offline || !routeKey) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const tiles = routeKey.split('|').flatMap((key) => parseFerrataTileKey(key) ?? []);
|
||||
store.want(tiles, 'background');
|
||||
}, DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [active, store, offline, routeKey]);
|
||||
|
||||
const missing = visibleKeys.some((key) => !snapshot.loaded.has(key));
|
||||
const status = statusOf(layerOn, offline, fits, missing, snapshot);
|
||||
return { ferratas: snapshot.ferratas, status, truncated: layerOn && snapshot.truncated };
|
||||
}
|
||||
|
||||
/** A trip's area is any real box; only its tile count limits the prefetch. */
|
||||
function ferrataBboxFitsArea(bbox: FerrataBbox): boolean {
|
||||
return bbox.north >= bbox.south && bbox.east >= bbox.west;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-024: the journey entry form logic behind both
|
||||
// the desktop editor dialog and the phone entry sheet.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
|
||||
@@ -341,6 +341,51 @@ describe('useJourneyEntryForm', () => {
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-JRN-ENTRYFORM-016b: the weather it fills in is a suggestion, so an untouched entry closes without asking', async () => {
|
||||
vi.spyOn(weatherApi, 'get').mockResolvedValue({ main: 'Rain', description: 'light rain' } as never);
|
||||
const { result } = setup({ entry: entry({ weather: '' }), autoFillWeather: true });
|
||||
await waitFor(() => expect(result.current.weather).toBe('rainy'));
|
||||
// 5.0.0-pre.1 feedback: Cancel asked about unsaved changes nobody made.
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
|
||||
act(() => result.current.setWeather('sunny'));
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
act(() => result.current.setWeather(''));
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
act(() => result.current.setWeather('rainy'));
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-JRN-ENTRYFORM-016d: an icon picked while the forecast is out, then cleared, stays cleared', async () => {
|
||||
let answer: (value: unknown) => void = () => {};
|
||||
const get = vi
|
||||
.spyOn(weatherApi, 'get')
|
||||
.mockImplementation(() => new Promise((resolve) => (answer = resolve)) as never);
|
||||
const { result } = setup({ entry: entry({ weather: '' }), autoFillWeather: true });
|
||||
act(() => result.current.setWeather('sunny'));
|
||||
await act(async () => answer({ main: 'Rain', description: 'light rain' }));
|
||||
act(() => result.current.setWeather(''));
|
||||
expect(result.current.weather).toBe('');
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("FE-JRN-ENTRYFORM-016c: StrictMode's double mount does not swallow the weather", async () => {
|
||||
vi.spyOn(weatherApi, 'get').mockResolvedValue({ main: 'Rain', description: 'light rain' } as never);
|
||||
const props: JourneyEntryFormOptions = {
|
||||
entry: entry({ weather: '' }),
|
||||
journeyId: 3,
|
||||
trips: [],
|
||||
galleryPhotos: [],
|
||||
onSave: vi.fn().mockResolvedValue(4),
|
||||
onUploadPhotos: vi.fn().mockResolvedValue({ ok: [], failed: [] }),
|
||||
onDone: vi.fn(),
|
||||
autoFillWeather: true,
|
||||
};
|
||||
// The first mount's answer is dropped by its cleanup; the second mount has to ask again.
|
||||
const { result } = renderHook(() => useJourneyEntryForm(props), { wrapper: React.StrictMode });
|
||||
await waitFor(() => expect(result.current.weather).toBe('rainy'));
|
||||
});
|
||||
|
||||
it('FE-JRN-ENTRYFORM-017: quick capture locates the traveller and names the place and the weather', async () => {
|
||||
locate.mockResolvedValue({ lat: 5, lng: 6 });
|
||||
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: 'Main St' } as never);
|
||||
|
||||
@@ -106,6 +106,9 @@ export function useJourneyEntryForm({
|
||||
const locationSearchSeqRef = useRef(0);
|
||||
const [mood, setMood] = useState(entry.mood || '');
|
||||
const [weather, setWeather] = useState(entry.weather || '');
|
||||
// The icon the form filled in by itself (see the weather effect below). It is a
|
||||
// suggestion, not an edit, so leaving the dialog must not ask about it.
|
||||
const [suggestedWeather, setSuggestedWeather] = useState('');
|
||||
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false);
|
||||
// The trip this day belongs to, when the journey does not follow it yet (#2265).
|
||||
const tripSuggestion = useJourneyTripSuggestion(
|
||||
@@ -155,7 +158,7 @@ export function useJourneyEntryForm({
|
||||
((locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null))) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
(weather !== (entry.weather || '') && weather !== suggestedWeather) ||
|
||||
statsExcluded !== (entry.stats_excluded ?? false) ||
|
||||
isDraft !== (entry.is_draft ?? false) ||
|
||||
joinRows(pros) !== (entry.pros_cons?.pros ?? []).join('\n') ||
|
||||
@@ -234,9 +237,17 @@ export function useJourneyEntryForm({
|
||||
* day from the ERA5 archive, so writing up last Tuesday gets last Tuesday's weather.
|
||||
*
|
||||
* Only ever fills an empty field, and each place and day is tried once, so a cleared
|
||||
* icon stays cleared and a chosen one is never overwritten.
|
||||
* icon stays cleared and a chosen one is never overwritten. The tried place and day
|
||||
* are forgotten on unmount only, so StrictMode's mount, cleanup, mount in development
|
||||
* asks again, while picking or clearing an icon does not.
|
||||
*/
|
||||
const weatherTriedRef = useRef<string | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
weatherTriedRef.current = null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!autoFillWeather) return;
|
||||
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return;
|
||||
@@ -253,9 +264,10 @@ export function useJourneyEntryForm({
|
||||
// does not stop it reaching here in production.
|
||||
if (!active || !result || result.error || typeof result.main !== 'string') return;
|
||||
const category = journeyWeatherCategory(result.main, result.description ?? '');
|
||||
// Re-checked rather than trusted from the closure: the request is a
|
||||
// round trip and the traveller may have picked an icon while it was out.
|
||||
setWeather((current) => current || category);
|
||||
// No need to re-check the field: picking an icon while the request was out
|
||||
// re-runs this effect, whose cleanup has already set `active` to false.
|
||||
setSuggestedWeather(category);
|
||||
setWeather(category);
|
||||
})
|
||||
.catch(() => {
|
||||
/* no weather is a fine outcome for a journal entry */
|
||||
|
||||
@@ -106,6 +106,17 @@ describe('Navbar', () => {
|
||||
expect(backBtns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-012b: in a trip the back arrow and the trip name are one pill', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
render(<Navbar tripTitle="Paris 2026" showBack onBack={onBack} />);
|
||||
const pill = screen.getByRole('button', { name: 'Back: Paris 2026' });
|
||||
expect(pill).toHaveTextContent('Paris 2026');
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
|
||||
await user.click(pill);
|
||||
expect(onBack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-013: clicking back button calls onBack', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
@@ -612,6 +623,27 @@ describe('Navbar layout (#1983)', () => {
|
||||
expect(pill.style.overflowX).toBe('auto');
|
||||
});
|
||||
|
||||
it('keeps both columns at their content width, so the actions never spill over the pill', () => {
|
||||
withAddons(8);
|
||||
const { container } = render(<Navbar />);
|
||||
const nav = container.querySelector('nav') as HTMLElement;
|
||||
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
|
||||
// A min-w-0 column shrinks below its content and its justify-end children
|
||||
// overflow leftwards across the pill (5.0.0-pre.1 feedback).
|
||||
for (const c of columns) {
|
||||
expect(c.classList.contains('min-w-0')).toBe(false);
|
||||
expect(c.classList.contains('min-w-max')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the prerelease badge in the brand column, beside the logo', () => {
|
||||
seedStore(useAuthStore, { isPrerelease: true, appVersion: '5.0.0-pre.1' });
|
||||
const { container } = render(<Navbar />);
|
||||
const nav = container.querySelector('nav') as HTMLElement;
|
||||
const brandColumn = nav.children[0] as HTMLElement;
|
||||
expect(brandColumn.contains(screen.getByText('5.0.0-pre.1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('still renders every addon as a reachable link, however many there are', () => {
|
||||
withAddons(8);
|
||||
render(<Navbar />);
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||
|
||||
@@ -95,6 +96,16 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
return translated !== key ? translated : addon.name
|
||||
}
|
||||
|
||||
const tabs = [{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
|
||||
// Empty in this repository — see client/src/managed.
|
||||
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
|
||||
]
|
||||
// Labels go when they no longer fit beside the logo and the actions; the
|
||||
// title and aria-label on each tab keep it named.
|
||||
const { ref: pillRef, collapsed: pillCollapsed } = useCollapseOnOverflow<HTMLDivElement>(tabs.map(tab => tab.label).join('|'))
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark
|
||||
@@ -113,9 +124,13 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 basis-0">
|
||||
{showBack && (
|
||||
being centred on top of both (#1983). Both columns keep at least their
|
||||
content width (min-w-max): a column squeezed below it spilled over the
|
||||
pill, so the pill is the one that gives way, first by dropping its
|
||||
labels and then by scrolling. On a trip page there is no pill, and the
|
||||
trip title truncates instead. */}
|
||||
<div className={`flex items-center gap-3 flex-1 basis-0 ${tripTitle ? 'min-w-0' : 'min-w-max'}`}>
|
||||
{showBack && !tripTitle && (
|
||||
<button type="button" onClick={onBack}
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0 text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
@@ -130,14 +145,34 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
|
||||
</Link>
|
||||
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-content-faint">/</span>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48 text-content-muted">
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
{/* Prerelease badge. Beside the logo it names the build, and it stays out
|
||||
of the action cluster on the right, which has no room to spare once
|
||||
plugins add tabs to the pill. */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* In a trip the way back lives in the trip's own pill: arrow and name in one. */}
|
||||
{tripTitle && (showBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label={`${t('common.back')}: ${tripTitle}`}
|
||||
className="trek-back-btn inline-flex min-w-0 max-w-64 items-center gap-1 rounded-full bg-surface-card py-1 ps-1.5 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronLeft className="trek-back-icon h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="truncate text-caption font-semibold">{tripTitle}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="hidden sm:inline-flex min-w-0 max-w-56 items-center rounded-full bg-surface-card px-2.5 py-1 shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -157,6 +192,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
than push the actions off the bar. */}
|
||||
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
|
||||
<div
|
||||
ref={pillRef}
|
||||
className="trek-nav-pill min-w-0"
|
||||
style={{
|
||||
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
|
||||
@@ -166,17 +202,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
|
||||
}}
|
||||
>
|
||||
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
|
||||
// Empty in this repository — see client/src/managed.
|
||||
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
|
||||
].map(tab => {
|
||||
{tabs.map(tab => {
|
||||
const isActive = location.pathname === tab.path
|
||||
return (
|
||||
<Link key={tab.id} to={tab.path}
|
||||
title={tab.label} aria-label={tab.label}
|
||||
className="flex items-center gap-1.5 transition-colors"
|
||||
className="flex items-center gap-1.5 whitespace-nowrap transition-colors"
|
||||
style={{
|
||||
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
@@ -186,7 +217,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<tab.Icon className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">{tab.label}</span>
|
||||
<span className={pillCollapsed ? 'hidden' : 'hidden lg:inline'}>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -212,7 +243,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
is centred on the bar (#1983). Was a bare flex-1 spacer followed by
|
||||
loose siblings, which centred nothing and left the pill to fend for
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-0 justify-end">
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
@@ -225,16 +256,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Prerelease badge */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
@@ -268,7 +289,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{user.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm hidden sm:inline max-w-24 truncate text-content-secondary">
|
||||
<span className="text-sm hidden sm:inline max-w-40 truncate text-content-secondary">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-content-faint" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
|
||||
describe('MapLayerSwitcher', () => {
|
||||
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
||||
@@ -71,3 +72,134 @@ describe('MapLayerSwitcher', () => {
|
||||
expect(parseFloat(button.style.width) + 2 * parseFloat(shell.style.padding)).toBe(MAP_ROUND_CONTROL_SIZE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher 3D switch', () => {
|
||||
it('FE-COMP-MAPLAYER-009: offers no 3D switch on a map that has no terrain mode', () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
|
||||
expect(screen.queryByRole('button', { name: '3D' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-010: toggles the terrain mode and shows whether it is on', () => {
|
||||
const onToggleTerrain = vi.fn()
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="topo" onChange={() => {}} onToggleTerrain={onToggleTerrain} />)
|
||||
const off = screen.getByRole('button', { name: '3D' })
|
||||
expect(off).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(off).toHaveClass('text-content-muted')
|
||||
expect(off.style.background).toBe('transparent')
|
||||
fireEvent.click(off)
|
||||
expect(onToggleTerrain).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="topo" onChange={() => {}} onToggleTerrain={onToggleTerrain} terrainActive />)
|
||||
const on = screen.getByRole('button', { name: '3D' })
|
||||
expect(on).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(on).toHaveClass('bg-accent', 'text-accent-text')
|
||||
expect(on.style.background).toBe('')
|
||||
// The base layer stays selectable beside it.
|
||||
expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
const ferrataLayer = (overrides: Partial<FerrataMapLayer> = {}): FerrataMapLayer => ({
|
||||
active: false,
|
||||
onToggle: vi.fn(),
|
||||
status: 'idle',
|
||||
count: 0,
|
||||
groups: [],
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher via ferrata switch', () => {
|
||||
it('FE-COMP-MAPLAYER-011: offers no via ferrata switch without a layer', () => {
|
||||
const { container } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
|
||||
expect(screen.queryByRole('button', { name: 'Via ferratas' })).toBeNull()
|
||||
expect(container.firstElementChild).toHaveAttribute('role', 'group')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-012: the switch toggles the layer, pressed and in the accent while it is on', () => {
|
||||
const layer = ferrataLayer()
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="topo" onChange={() => {}} ferrataLayer={layer} />)
|
||||
const off = screen.getByRole('button', { name: 'Via ferratas' })
|
||||
expect(off).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(off).toHaveClass('text-content-muted')
|
||||
fireEvent.click(off)
|
||||
expect(layer.onToggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="topo" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading' })} />)
|
||||
const on = screen.getByRole('button', { name: 'Via ferratas' })
|
||||
expect(on).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(on).toHaveClass('bg-accent', 'text-accent-text')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-013: the count rides in the switch as a badge, nothing beside the pill, and none while off', () => {
|
||||
const { container, rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ count: 4 })} />)
|
||||
expect(screen.queryByTestId('ferrata-count-badge')).toBeNull()
|
||||
expect(container.firstElementChild).toHaveAttribute('role', 'group')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 4 })} />)
|
||||
const badge = screen.getByTestId('ferrata-count-badge')
|
||||
expect(badge).toHaveTextContent('4')
|
||||
expect(badge).toHaveAttribute('data-tone', 'default')
|
||||
expect(screen.getByRole('button', { name: 'Via ferratas' })).toContainElement(badge)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('4 via ferrata routes')
|
||||
expect(screen.queryByTestId('ferrata-status-pill')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-014: a tiny spinner in the badge while tiles load', () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading', count: 2 })} />)
|
||||
const badge = screen.getByTestId('ferrata-count-badge')
|
||||
expect(badge).toContainElement(screen.getByTestId('ferrata-count-spinner'))
|
||||
expect(badge).not.toHaveTextContent('2')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading via ferratas…')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-015: offline, the mirrors down or the view too wide: a warning tone, the reason in the tooltip', async () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'offline', count: 1 })} />)
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveClass('bg-warning-soft', 'text-warning')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Via ferratas need a connection')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'tooLarge' })} />)
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Via ferratas' }))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to load via ferratas')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'error' })} />)
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Overpass unreachable')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-016: sits beside the 3D switch behind one divider', () => {
|
||||
render(
|
||||
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
|
||||
ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 2 })} />
|
||||
)
|
||||
const group = screen.getByRole('group')
|
||||
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
|
||||
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D'])
|
||||
})
|
||||
it('FE-COMP-MAPLAYER-017: the "set points" switch leads the tools on its icon, pressed in the accent while on', async () => {
|
||||
const onToggle = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
|
||||
ferrataLayer={ferrataLayer()} placeTool={{ active: true, onToggle }} />
|
||||
)
|
||||
const group = screen.getByRole('group')
|
||||
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
|
||||
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
expect(buttons.slice(-3)).toEqual(['Set points', 'Via ferratas', '3D'])
|
||||
const tool = screen.getByRole('button', { name: 'Set points' })
|
||||
expect(tool).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(tool).toHaveClass('bg-accent', 'text-accent-text')
|
||||
expect(tool).not.toHaveTextContent('Set points')
|
||||
fireEvent.click(tool)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
fireEvent.mouseEnter(tool)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Set points')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} placeTool={{ active: false, onToggle }} />)
|
||||
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Map as MapIcon, Mountain, Satellite } from 'lucide-react'
|
||||
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
import type { FerrataStatus } from '../Ferratas/useTripFerratas'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
|
||||
@@ -71,13 +76,133 @@ const TOUR_LAYERS = [
|
||||
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||
] as const
|
||||
|
||||
/** Per-view Tours control. It never reads or writes the global map setting. */
|
||||
/** A pill segment of the Tours control; `filled` lets its class paint the background. */
|
||||
const segmentStyle = (filled: boolean): CSSProperties => ({
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: filled ? undefined : 'transparent', padding: '0 10px',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
})
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/** What the via ferrata layer is doing, in words, for the toggle's tooltip and the screen reader. */
|
||||
function ferrataStatusLabel(status: Exclude<FerrataStatus, 'idle'>, count: number, t: Translate): string {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return t('ferratas.loading')
|
||||
case 'error':
|
||||
return t('ferratas.error')
|
||||
case 'offline':
|
||||
return t('ferratas.offline')
|
||||
case 'tooLarge':
|
||||
return t('ferratas.zoomIn')
|
||||
case 'ready':
|
||||
return t('ferratas.count', { count })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata switch with its count as a badge: how many ferratas reach
|
||||
* into the view, a small spinner while tiles load, and the warning tone when
|
||||
* there is a reason it cannot load (offline, the mirrors down, the view too
|
||||
* wide), which the tooltip names. Nothing beside the switch, so the bottom row
|
||||
* stays one pill wide. `compact` drops the word and keeps the icon and the
|
||||
* badge; the tooltip and the accessible name still say what it is.
|
||||
*/
|
||||
function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; compact?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { active, status, count } = layer
|
||||
const live = active && status !== 'idle' ? ferrataStatusLabel(status, count, t) : null
|
||||
const problem = status === 'error' || status === 'offline' || status === 'tooLarge'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={live ?? t('ferratas.layer')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={layer.onToggle}
|
||||
aria-label={t('ferratas.layer')}
|
||||
aria-pressed={active}
|
||||
className={active ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(active)}
|
||||
>
|
||||
<FerrataLineIcon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{t('ferratas.layer')}</span>}
|
||||
{live && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="ferrata-count-badge"
|
||||
data-tone={problem ? 'warning' : 'default'}
|
||||
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
|
||||
problem ? 'bg-warning-soft text-warning' : 'bg-accent-text text-accent'
|
||||
}`}
|
||||
style={fs(10)}
|
||||
>
|
||||
{status === 'loading'
|
||||
? <Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" data-testid="ferrata-count-spinner" />
|
||||
: count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Polite, so a screen reader hears the count arrive without being interrupted. */}
|
||||
<span role="status" aria-live="polite" className="sr-only">{live ?? ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The tour map's "set points" tool: while on, a click on the map places a point. */
|
||||
export interface MapPlaceTool {
|
||||
active: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
/** The switch for the "set points" tool, on its icon, named by its tooltip. */
|
||||
function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
||||
const { t } = useTranslation()
|
||||
const label = t('tours.planner.placePoints')
|
||||
return (
|
||||
<Tooltip label={label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={tool.onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={tool.active}
|
||||
data-testid="map-place-tool"
|
||||
className={tool.active ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(tool.active)}
|
||||
>
|
||||
<MapPinPlus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||
* width (the 3D map's, beside the flyover dock).
|
||||
*/
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
onChange,
|
||||
onToggleTerrain,
|
||||
terrainActive = false,
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
compact = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
/** Offers the 3D terrain switch beside the base layers. */
|
||||
onToggleTerrain?: () => void
|
||||
terrainActive?: boolean
|
||||
/** Offers the via ferrata switch, with its count badge, beside the base layers. */
|
||||
ferrataLayer?: FerrataMapLayer
|
||||
/** Offers the "set points" switch, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -87,24 +212,36 @@ export function TourMapLayerSwitcher({
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: selected ? undefined : 'transparent', padding: '0 10px',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-pressed={selected}
|
||||
aria-label={compact ? label : undefined}
|
||||
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
|
||||
style={segmentStyle(selected)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{onToggleTerrain && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTerrain}
|
||||
aria-pressed={terrainActive}
|
||||
className={terrainActive ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(terrainActive)}
|
||||
>
|
||||
<Rotate3d size={15} strokeWidth={2} />
|
||||
<span>3D</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Check, Undo2 } from 'lucide-react';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow';
|
||||
|
||||
export interface MapUndoHintProps {
|
||||
message: string;
|
||||
undoLabel: string;
|
||||
onUndo: () => void;
|
||||
/** True while the pointer rests on the hint, so it does not vanish under it. */
|
||||
onHold?: (held: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short note at the foot of the map that something just happened there, with
|
||||
* the way back: a glass pill like the map's other controls. The caller places it
|
||||
* and decides how long it stays.
|
||||
*/
|
||||
export function MapUndoHint({ message, undoLabel, onUndo, onHold }: Readonly<MapUndoHintProps>) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="map-undo-hint"
|
||||
className="trek-toast-enter pointer-events-auto flex max-w-full items-center gap-2 rounded-full p-1 ps-3 text-content"
|
||||
style={{
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}
|
||||
onMouseEnter={() => onHold?.(true)}
|
||||
onMouseLeave={() => onHold?.(false)}
|
||||
onFocus={() => onHold?.(true)}
|
||||
onBlur={() => onHold?.(false)}
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} className="flex-none text-accent" aria-hidden />
|
||||
<span className="min-w-0 truncate font-semibold" style={fs(12, 'body')}>
|
||||
{message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
className="bg-accent-soft flex h-[30px] flex-none items-center gap-1.5 rounded-full px-3 font-semibold text-accent hover:opacity-90"
|
||||
style={fs(11)}
|
||||
>
|
||||
<Undo2 size={13} strokeWidth={2.2} aria-hidden />
|
||||
{undoLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,13 @@ vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
|
||||
}))
|
||||
|
||||
// What the via ferrata layer draws is FerrataLayerLeaflet's own suite; here only what the
|
||||
// renderer hands it. jsdom has no SVG renderer for Leaflet to draw real lines into.
|
||||
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({
|
||||
FerrataMapLayerLeaflet: ({ layer }: { layer?: { active: boolean; groups: readonly unknown[] } }) =>
|
||||
layer?.active ? <div data-testid="ferrata-leaflet-layer" data-count={layer.groups.length} /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
@@ -34,6 +41,8 @@ vi.mock('../../services/photoService', () => ({
|
||||
import L from 'leaflet'
|
||||
import { MapView } from './MapView'
|
||||
import { ContextMenu } from '../shared/ContextMenu'
|
||||
import type { Ferrata } from '@trek/shared'
|
||||
import { groupFerratas } from '../Ferratas/ferrataGroups'
|
||||
|
||||
// Every map this file builds, newest last, for the tests that reach past the component.
|
||||
const maps: L.Map[] = []
|
||||
@@ -94,6 +103,27 @@ function clickOn(el: Element) {
|
||||
})
|
||||
}
|
||||
|
||||
/** The Leaflet marker that renders `el`; throws when the map has none. */
|
||||
function markerOf(map: L.Map, el: Element): L.Marker {
|
||||
let found: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === el) found = layer
|
||||
})
|
||||
if (!found) throw new Error('no marker renders this element')
|
||||
return found
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
|
||||
const places = [
|
||||
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
|
||||
@@ -114,6 +144,136 @@ async function renderMap() {
|
||||
}
|
||||
|
||||
describe('MapView on a real Leaflet map', () => {
|
||||
it('RC-08: previews waypoint drags locally and commits only at pointer up without panning', async () => {
|
||||
const waypoint = { id: 'via-1', lat: 34.689, lng: 135.8398, role: 'via' as const }
|
||||
const waypoints = [waypoint]
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
/>
|
||||
)
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
const marker = markerOf(map, pin)
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
const expected = map.containerPointToLatLng(end)
|
||||
const center = map.getCenter()
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
|
||||
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
})
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
expect(waypoints[0]).toEqual(waypoint)
|
||||
|
||||
view.rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
|
||||
/>
|
||||
)
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
|
||||
pin.dispatchEvent(mouse('mouseup', end.x, end.y))
|
||||
pin.dispatchEvent(mouse('click', end.x, end.y))
|
||||
})
|
||||
expect(onMove).toHaveBeenCalledOnce()
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, expected.lat, expected.lng)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
|
||||
|
||||
view.rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={[{ ...waypoint, lat: expected.lat, lng: expected.lng }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[expected.lat, expected.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
|
||||
/>
|
||||
)
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
|
||||
})
|
||||
|
||||
it('RC-08: cancellation restores the marker and a plain click still selects without affecting map-add', async () => {
|
||||
const waypoint = { id: 'start', lat: 34.689, lng: 135.8398, role: 'start' as const }
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
/>
|
||||
)
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
const marker = markerOf(map, pin)
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
|
||||
})
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
|
||||
clickOn(pin)
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
act(() => { map.fire('click', { latlng: L.latLng(34.7, 135.85) }) })
|
||||
expect(onMapClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
|
||||
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
|
||||
@@ -297,4 +457,30 @@ describe('MapView on a real Leaflet map', () => {
|
||||
panMap(container)
|
||||
expect(map.getCenter()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('RC-10: the tour layer pill offers the via ferrata switch, and the layer is drawn only while it is on', async () => {
|
||||
const ferrata: Ferrata = { id: 'way/42', name: 'Via delle Trincee', grade: { raw: '4', level: 4, plus: null }, routeName: null,
|
||||
geometry: [[46.52, 11.8], [46.53, 11.81]], osmUrl: 'https://www.openstreetmap.org/way/42' }
|
||||
const layer = {
|
||||
active: false, onToggle: vi.fn(), status: 'idle' as const, count: 0, groups: groupFerratas([ferrata]),
|
||||
selectedId: null, onSelect: vi.fn(),
|
||||
}
|
||||
const view = render(
|
||||
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
|
||||
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={layer} />,
|
||||
)
|
||||
const toggle = await view.findByRole('button', { name: 'Via ferratas' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(view.queryByTestId('ferrata-leaflet-layer')).toBeNull()
|
||||
act(() => toggle.click())
|
||||
expect(layer.onToggle).toHaveBeenCalledOnce()
|
||||
|
||||
view.rerender(
|
||||
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
|
||||
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={{ ...layer, active: true, status: 'ready', count: 1 }} />,
|
||||
)
|
||||
await waitFor(() => expect(view.getByTestId('ferrata-leaflet-layer')).toHaveAttribute('data-count', '1'))
|
||||
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -442,6 +442,48 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', () => {
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
|
||||
const mapContainer = screen.getByTestId('map-container');
|
||||
mapMock.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('map-container')).toBe(mapContainer);
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
|
||||
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -6,7 +6,10 @@ import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
@@ -108,12 +111,6 @@ function routeViaIcon(tone: string): L.DivIcon {
|
||||
return icon
|
||||
}
|
||||
|
||||
function formatViaDwell(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.round((seconds % 3600) / 60)
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
function nightPauseIcon(via: RouteVia): L.DivIcon {
|
||||
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
|
||||
const cached = viaIconCache.get(key)
|
||||
@@ -449,7 +446,7 @@ interface BoundsControllerProps {
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number
|
||||
focusKey?: number | string
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean
|
||||
/**
|
||||
@@ -757,14 +754,9 @@ const MemoMarker = memo(function MemoMarker({
|
||||
)
|
||||
})
|
||||
|
||||
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer">${index + 1}</span>`,
|
||||
})
|
||||
/** The look of a planner control point, drawn by PlannerWaypointMarker and kept beside the route blue. */
|
||||
function plannerWaypointPinCss(size: number, selected: boolean): string {
|
||||
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
|
||||
}
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
@@ -836,9 +828,9 @@ export const MapView = memo(function MapView({
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
}: MapViewProps) {
|
||||
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
@@ -1306,21 +1298,16 @@ export const MapView = memo(function MapView({
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
|
||||
<Marker
|
||||
{plannerWaypoints.map((point, index) => (
|
||||
<PlannerWaypointMarker
|
||||
key={`planner-waypoint-${point.id}`}
|
||||
position={[point.lat, point.lng]}
|
||||
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
|
||||
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
|
||||
eventHandlers={{
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
onPlannerWaypointClick?.(point.id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
|
||||
</Marker>
|
||||
point={point}
|
||||
index={index}
|
||||
selected={point.id === selectedPlannerWaypointId}
|
||||
onClick={onPlannerWaypointClick}
|
||||
onMove={onPlannerWaypointMove}
|
||||
pinCss={plannerWaypointPinCss}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
@@ -1346,6 +1333,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
|
||||
{/* The route as it was actually recorded (#2279). Drawn in the casing
|
||||
pane's sibling order so it sits under the planned route rather than
|
||||
@@ -1392,7 +1380,8 @@ export const MapView = memo(function MapView({
|
||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
|
||||
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
// reachable from tests via dispatchEvent.
|
||||
const glCanvasContainer = vi.hoisted(() => document.createElement('div'));
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn().mockReturnValue(true),
|
||||
fitBounds: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
|
||||
addControl: vi.fn(),
|
||||
removeControl: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
getSource: vi.fn().mockReturnValue(null),
|
||||
addLayer: vi.fn(),
|
||||
// Every overlay that adds a layer takes it away again on cleanup; a real
|
||||
// MapLibre/Mapbox map has both halves, and a double with only the adding half
|
||||
// fails the teardown rather than the behaviour under test.
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
setFilter: vi.fn(),
|
||||
hasImage: vi.fn().mockReturnValue(false),
|
||||
addImage: vi.fn(),
|
||||
removeImage: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
||||
// The MapLibre place pins position themselves with this, so a mock without it
|
||||
// never exercises the path the real map takes.
|
||||
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
|
||||
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
||||
easeTo: vi.fn(),
|
||||
getCanvas: vi.fn(() => document.createElement('canvas')),
|
||||
getBearing: vi.fn(() => 0),
|
||||
setTerrain: vi.fn(),
|
||||
setConfigProperty: vi.fn(),
|
||||
queryTerrainElevation: vi.fn((): number | null => null),
|
||||
}));
|
||||
|
||||
const glBounds = vi.hoisted(() => {
|
||||
const state = {
|
||||
instances: [] as Array<{ extend: ReturnType<typeof vi.fn> }>,
|
||||
};
|
||||
return {
|
||||
get instances() {
|
||||
return state.instances;
|
||||
},
|
||||
clear: () => {
|
||||
state.instances = [];
|
||||
},
|
||||
create: () => {
|
||||
const bounds = {
|
||||
extend: vi.fn(() => bounds),
|
||||
};
|
||||
state.instances.push(bounds);
|
||||
return bounds;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Markers and popups are created through the mocked GL constructors, so the
|
||||
// tests can reach the very instances the component built (their DOM element,
|
||||
// the coordinates they were placed at, the popup content they were given).
|
||||
interface FakeMarker {
|
||||
element: HTMLElement;
|
||||
lngLat: number[] | null;
|
||||
setLngLat: (ll: number[]) => FakeMarker;
|
||||
getLngLat: () => { lng: number; lat: number };
|
||||
addTo: (map: unknown) => FakeMarker;
|
||||
remove: () => void;
|
||||
getElement: () => HTMLElement;
|
||||
}
|
||||
|
||||
interface FakePopup {
|
||||
setLngLat: (ll: unknown) => FakePopup;
|
||||
setHTML: (html: string) => FakePopup;
|
||||
setText: (text: string) => FakePopup;
|
||||
setDOMContent: (node: HTMLElement) => FakePopup;
|
||||
addTo: (map: unknown) => FakePopup;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
const glMarkers = vi.hoisted(() => {
|
||||
const created: FakeMarker[] = [];
|
||||
return {
|
||||
get created() {
|
||||
return created;
|
||||
},
|
||||
clear: () => {
|
||||
created.length = 0;
|
||||
},
|
||||
make: (opts?: { element?: HTMLElement }): FakeMarker => {
|
||||
const element = opts?.element ?? document.createElement('div');
|
||||
const marker: FakeMarker = {
|
||||
element,
|
||||
lngLat: null,
|
||||
setLngLat: vi.fn((ll: number[]) => {
|
||||
marker.lngLat = ll;
|
||||
return marker;
|
||||
}),
|
||||
getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })),
|
||||
addTo: vi.fn((_map: unknown) => marker),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => element),
|
||||
};
|
||||
created.push(marker);
|
||||
return marker;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const glPopup = vi.hoisted((): FakePopup => {
|
||||
const popup: FakePopup = {
|
||||
setLngLat: vi.fn((_ll: unknown) => popup),
|
||||
setHTML: vi.fn((_html: string) => popup),
|
||||
setText: vi.fn((_text: string) => popup),
|
||||
setDOMContent: vi.fn((_node: HTMLElement) => popup),
|
||||
addTo: vi.fn((_map: unknown) => popup),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
return popup;
|
||||
});
|
||||
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
vi.mock('./mapboxSetup', () => ({
|
||||
isStandardFamily: vi.fn(() => false),
|
||||
supportsCustom3d: vi.fn(() => false),
|
||||
wantsTerrain: vi.fn(() => false),
|
||||
addCustom3dBuildings: vi.fn(),
|
||||
addTerrainAndSky: vi.fn(),
|
||||
}));
|
||||
|
||||
const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() }));
|
||||
|
||||
vi.mock('./locationMarkerMapbox', () => ({
|
||||
attachLocationMarker: vi.fn(() => locationMarker),
|
||||
}));
|
||||
|
||||
const reservationOverlay = vi.hoisted(() => ({
|
||||
update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}),
|
||||
destroy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./reservationsMapbox', () => ({
|
||||
ReservationMapboxOverlay: vi.fn(function () {
|
||||
return reservationOverlay;
|
||||
}),
|
||||
}));
|
||||
|
||||
const geoStub = vi.hoisted(() => ({
|
||||
position: null as GeoPosition | null,
|
||||
mode: 'off' as TrackingMode,
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(async () => {}),
|
||||
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: vi.fn(() => geoStub),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import * as photoService from '../../services/photoService';
|
||||
import { groupFerratas } from '../Ferratas/ferrataGroups';
|
||||
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
|
||||
import * as mapboxSetup from './mapboxSetup';
|
||||
|
||||
// The engine is a prop, not a module import (see MapViewGL.test.tsx). This shim makes
|
||||
// the same choice glLazy.tsx makes in production.
|
||||
function MapViewGL(props: Omit<React.ComponentProps<typeof MapViewGLWithEngine>, 'gl'>) {
|
||||
return <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
glMap.on.mockImplementation(() => glMap);
|
||||
glMap.off.mockImplementation(() => glMap);
|
||||
glMap.once.mockImplementation(() => glMap);
|
||||
glMap.loaded.mockReturnValue(true);
|
||||
glMap.getSource.mockReturnValue(null);
|
||||
glMap.getLayer.mockReturnValue(null);
|
||||
glMap.queryRenderedFeatures.mockReturnValue([]);
|
||||
glMap.querySourceFeatures.mockReturnValue([]);
|
||||
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'));
|
||||
glMap.getBearing.mockReturnValue(0);
|
||||
glMap.queryTerrainElevation.mockReturnValue(null);
|
||||
// A busy style, a refused source and a custom layer list are each set by one test
|
||||
// for the case it covers. Put back here, or every test after it would silently run
|
||||
// against a map that never settles.
|
||||
glMap.isStyleLoaded.mockReturnValue(true);
|
||||
glMap.getStyle.mockReturnValue({ layers: [] });
|
||||
glMap.addSource.mockImplementation(() => undefined);
|
||||
glMarkers.clear();
|
||||
// clearAllMocks() wipes call history but keeps implementations, so anything a
|
||||
// test overrides with mockReturnValue has to be put back here.
|
||||
vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false);
|
||||
vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false);
|
||||
vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false);
|
||||
vi.mocked(photoService.getCached).mockReturnValue(undefined);
|
||||
vi.mocked(photoService.isLoading).mockReturnValue(false);
|
||||
vi.mocked(photoService.onThumbReady).mockReturnValue(() => {});
|
||||
vi.mocked(photoService.getAllThumbs).mockReturnValue({});
|
||||
geoStub.position = null;
|
||||
geoStub.mode = 'off';
|
||||
geoStub.error = null;
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
map_provider: 'mapbox-gl',
|
||||
mapbox_access_token: 'pk.test_token',
|
||||
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
||||
mapbox_3d_enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
glBounds.clear();
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('MapViewGL', () => {
|
||||
function loadOnAttach() {
|
||||
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)();
|
||||
return glMap;
|
||||
});
|
||||
}
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', async () => {
|
||||
loadOnAttach();
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
|
||||
const mapConstructions = vi.mocked(maplibregl.Map).mock.calls.length;
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions);
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
||||
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
||||
async (provider) => {
|
||||
loadOnAttach();
|
||||
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }));
|
||||
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 });
|
||||
glMap.getContainer.mockReturnValue(glCanvasContainer);
|
||||
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const };
|
||||
const waypoints = [waypoint];
|
||||
const onMove = vi.fn(() => true);
|
||||
const onSelect = vi.fn();
|
||||
const onMapClick = vi.fn();
|
||||
const view = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
glMap.fitBounds.mockClear();
|
||||
const pin =
|
||||
provider === 'maplibre-gl'
|
||||
? (glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement)
|
||||
: (glMarkers.created.find((marker) => marker.element.getAttribute('aria-label') === 'Waypoint 1')
|
||||
?.element as HTMLButtonElement);
|
||||
const startTransform = pin.style.transform;
|
||||
const libraryMarker =
|
||||
provider === 'mapbox-gl' ? glMarkers.created.find((marker) => marker.element === pin) : undefined;
|
||||
const startLngLat = libraryMarker?.lngLat;
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
||||
});
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
||||
expect(onMove).not.toHaveBeenCalled();
|
||||
expect(waypoints[0]).toEqual(waypoint);
|
||||
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', 30, 40));
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onMove).toHaveBeenCalledOnce();
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[48.4, 11.3]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
|
||||
loadOnAttach();
|
||||
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const };
|
||||
const onMove = vi.fn(() => true);
|
||||
const onSelect = vi.fn();
|
||||
const onMapClick = vi.fn();
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement;
|
||||
const initialTransform = pin.style.transform;
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
||||
pin.dispatchEvent(pointer('pointercancel', 30, 40));
|
||||
});
|
||||
expect(pin.style.transform).toBe(initialTransform);
|
||||
expect(onMove).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointerup', 10, 10));
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id);
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const FERRATA = {
|
||||
id: 'way/42',
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: '4', level: 4, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[46.52, 11.8],
|
||||
[46.53, 11.81],
|
||||
] as [number, number][],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/42',
|
||||
};
|
||||
const FERRATA_GROUPS = groupFerratas([FERRATA]);
|
||||
const ferrataLayer = (active: boolean) => ({
|
||||
active,
|
||||
onToggle: vi.fn(),
|
||||
status: active ? ('ready' as const) : ('idle' as const),
|
||||
count: active ? 1 : 0,
|
||||
groups: FERRATA_GROUPS,
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
});
|
||||
const addedFerratas = () => glMap.addSource.mock.calls.filter(([id]) => id === 'trek-ferratas');
|
||||
|
||||
it('RC-10: the via ferrata layer is drawn on the ready map only while it is on, its switch in the tour pill', async () => {
|
||||
loadOnAttach();
|
||||
const off = ferrataLayer(false);
|
||||
const view = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
viewBaseLayer="topo"
|
||||
onViewBaseLayerChange={vi.fn()}
|
||||
ferrataLayer={off}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(addedFerratas()).toHaveLength(0);
|
||||
const toggle = view.getByRole('button', { name: 'Via ferratas' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
act(() => toggle.click());
|
||||
expect(off.onToggle).toHaveBeenCalledOnce();
|
||||
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
viewBaseLayer="topo"
|
||||
onViewBaseLayerChange={vi.fn()}
|
||||
ferrataLayer={ferrataLayer(true)}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(addedFerratas()).toHaveLength(1);
|
||||
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('RC-10: a click on a drawn ferrata opens it and is not a map click, so no control point lands under it', async () => {
|
||||
loadOnAttach();
|
||||
glMap.getLayer.mockImplementation((id: string) => (id === 'trek-ferratas-hit' ? { id } : null));
|
||||
glMap.queryRenderedFeatures.mockImplementation((_point: unknown, options?: { layers?: string[] }) =>
|
||||
options?.layers?.includes('trek-ferratas-hit') ? [{ properties: { id: FERRATA.id } }] : []
|
||||
);
|
||||
const layer = ferrataLayer(true);
|
||||
const onMapClick = vi.fn();
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
viewBaseLayer="topo"
|
||||
onViewBaseLayerChange={vi.fn()}
|
||||
ferrataLayer={layer}
|
||||
onMapClick={onMapClick}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
const mapClicks = glMap.on.mock.calls
|
||||
.filter(([event, handler]) => event === 'click' && typeof handler === 'function')
|
||||
.map(([, handler]) => handler as (event: unknown) => void);
|
||||
expect(mapClicks.length).toBeGreaterThan(0);
|
||||
const click = {
|
||||
point: { x: 5, y: 5 },
|
||||
lngLat: { lat: 46.52, lng: 11.8 },
|
||||
originalEvent: { target: document.createElement('div') },
|
||||
};
|
||||
act(() => mapClicks.forEach((handler) => handler(click)));
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
expect(layer.onSelect).toHaveBeenCalledWith(FERRATA_GROUPS[0], FERRATA, { lat: 46.52, lng: 11.8 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
@@ -31,6 +32,8 @@ 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, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
@@ -418,12 +421,6 @@ function buildPluginLayerData(layers: PluginMapLayer[]) {
|
||||
return { type: 'FeatureCollection' as const, features }
|
||||
}
|
||||
|
||||
function formatViaDwellGl(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.round((seconds % 3600) / 60)
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
/**
|
||||
* A place pin on the map, whatever is carrying it.
|
||||
*
|
||||
@@ -568,6 +565,11 @@ export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The look of a planner control point, drawn by usePlannerWaypointMarkersGL. */
|
||||
function plannerWaypointPinCss(size: number, selected: boolean): string {
|
||||
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
@@ -622,9 +624,9 @@ export function MapViewGL({
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -656,6 +658,7 @@ export function MapViewGL({
|
||||
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const [readyMap, setReadyMap] = useState<FerrataGlMap | null>(null) // the ready map, read without touching mapRef
|
||||
// Hover tooltip — a cursor-following name/category/address card, matching the
|
||||
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
|
||||
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
|
||||
@@ -682,6 +685,7 @@ export function MapViewGL({
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
|
||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -739,33 +743,14 @@ export function MapViewGL({
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId
|
||||
const size = selected ? 30 : 26
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.textContent = String(index + 1)
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
|
||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
|
||||
const swallow = (event: Event) => event.stopPropagation()
|
||||
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
|
||||
el.addEventListener('pointerdown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('click', select)
|
||||
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
|
||||
}
|
||||
|
||||
return () => {
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
}
|
||||
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
|
||||
const attachPlannerWaypointPin = useCallback(
|
||||
(el: HTMLElement, lng: number, lat: number) => attachPin(mapRef.current, gl, pinLayerRef.current, el, lng, lat),
|
||||
[gl],
|
||||
)
|
||||
usePlannerWaypointMarkersGL({
|
||||
mapRef, mapReady, pinsRef: plannerWaypointPinsRef, attachPin: attachPlannerWaypointPin, pinCss: plannerWaypointPinCss,
|
||||
plannerWaypoints, selectedPlannerWaypointId, onPlannerWaypointClick, onPlannerWaypointMove,
|
||||
})
|
||||
|
||||
useEffect(() => () => {
|
||||
profileFocusPinRef.current?.remove()
|
||||
@@ -1500,6 +1485,7 @@ export function MapViewGL({
|
||||
// safely add their own sources. Style rebuilds reset this via the
|
||||
// cleanup below.
|
||||
setMapReady(true)
|
||||
setReadyMap(map)
|
||||
})
|
||||
|
||||
// Layer for the hand-positioned place pins, inside the canvas container so it
|
||||
@@ -1548,6 +1534,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
|
||||
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
|
||||
@@ -1997,7 +1984,7 @@ export function MapViewGL({
|
||||
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
|
||||
}
|
||||
if (v.label || v.dwellSeconds != null) {
|
||||
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
|
||||
const text = [v.label, v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : null].filter(Boolean).join(' · ')
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
@@ -2366,7 +2353,8 @@ export function MapViewGL({
|
||||
}}>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange} />
|
||||
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function PlannerWaypointMarker({
|
||||
point,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
onMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
pinCss: PinCss;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null);
|
||||
const handlersRef = useRef({ onClick, onMove });
|
||||
useLayoutEffect(() => {
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation();
|
||||
handlersRef.current.onClick?.(point.id);
|
||||
},
|
||||
}),
|
||||
[point.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!marker || !onMove) return;
|
||||
const element = marker.getElement();
|
||||
if (!element) return;
|
||||
return wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
|
||||
return { lat: at.lat, lng: at.lng };
|
||||
},
|
||||
setPosition: ({ lat, lng }) => {
|
||||
marker.setLatLng([lat, lng]);
|
||||
},
|
||||
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
|
||||
});
|
||||
}, [map, marker, onMove, point.id, point.lat, point.lng]);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={setMarker}
|
||||
position={position}
|
||||
icon={icon}
|
||||
zIndexOffset={selected ? 1200 : 1100}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
|
||||
<span className="font-medium">{point.name ?? index + 1}</span>
|
||||
{point.detail && <span className={MAP_LABEL_BADGE_CLASS}>{point.detail}</span>}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* A name and a short detail floating over a GL map (the via ferrata under the
|
||||
* pointer and its grade, a tour waypoint and its role), the twin of the
|
||||
* Leaflet `map-tooltip`: the look of the shared Tooltip (card surface, faint
|
||||
* edge, caption tier), just above the point it is shown for.
|
||||
*
|
||||
* Built from the DOM rather than React, because it follows every mouse move:
|
||||
* a React state per move would render the whole map view each time. The words
|
||||
* go in as text, never as markup. It lives in the map's own container, so it
|
||||
* scrolls, hides and goes away with the map.
|
||||
*/
|
||||
|
||||
/** How far above the pointer the label floats, in CSS pixels. */
|
||||
const POINTER_GAP = 12;
|
||||
|
||||
/** The detail beside the name (a role, a grade) as a badge, as the shared pills draw one. */
|
||||
export const MAP_LABEL_BADGE_CLASS =
|
||||
'ms-1.5 inline-flex h-[18px] items-center rounded-full bg-surface-secondary px-1.5 font-semibold text-content-secondary ring-1 ring-inset ring-edge-faint';
|
||||
|
||||
const LABEL_CLASS =
|
||||
'pointer-events-none absolute z-20 flex items-center whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
|
||||
|
||||
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
|
||||
interface LabelHost {
|
||||
getContainer?(): HTMLElement;
|
||||
}
|
||||
|
||||
export interface MapHoverLabel {
|
||||
/** `owner` is what the label is shown for, so a stale hide from another pin leaves it alone. */
|
||||
show(text: { name: string; detail: string | null }, point: { x: number; y: number }, owner?: object): void;
|
||||
/** Whether the label is up for this owner. */
|
||||
shownFor(owner: object): boolean;
|
||||
hide(): void;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
const NO_LABEL: MapHoverLabel = {
|
||||
show: () => undefined,
|
||||
shownFor: () => false,
|
||||
hide: () => undefined,
|
||||
remove: () => undefined,
|
||||
};
|
||||
|
||||
export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHoverLabel {
|
||||
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
|
||||
const container = map.getContainer();
|
||||
let element: HTMLDivElement | null = null;
|
||||
let shownOwner: object | null = null;
|
||||
|
||||
const ensure = () => {
|
||||
if (element) return element;
|
||||
element = document.createElement('div');
|
||||
element.className = LABEL_CLASS;
|
||||
element.setAttribute('role', 'tooltip');
|
||||
element.dataset.testid = testId;
|
||||
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
|
||||
element.style.inset = '0px auto auto 0px';
|
||||
// The map's own stylesheet sets Helvetica/Arial on its container; the label speaks in the app's face.
|
||||
element.style.fontFamily = 'var(--font-system)';
|
||||
container.appendChild(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
return {
|
||||
show({ name, detail }, { x, y }, owner) {
|
||||
const label = ensure();
|
||||
shownOwner = owner ?? null;
|
||||
label.replaceChildren(document.createTextNode(name));
|
||||
if (detail) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = MAP_LABEL_BADGE_CLASS;
|
||||
badge.textContent = detail;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
|
||||
// Not the hidden attribute: the label's `flex` class would win over it and keep it on screen.
|
||||
label.style.display = '';
|
||||
},
|
||||
shownFor(owner) {
|
||||
return !!element && element.style.display !== 'none' && shownOwner === owner;
|
||||
},
|
||||
hide() {
|
||||
shownOwner = null;
|
||||
if (element) element.style.display = 'none';
|
||||
},
|
||||
remove() {
|
||||
element?.remove();
|
||||
element = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a map pin names on hover: its place and, beside it, its role. */
|
||||
export interface PinHoverText {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the label above a pin while the pointer rests on it, and hides it the
|
||||
* moment the pin is pressed, so it never trails a drag. A map that moves its
|
||||
* markers every frame (the 3D terrain) can leave a pin without a leave event,
|
||||
* so a move anywhere else on the map, or off it, hides the label too, as long
|
||||
* as it is still this pin's. Returns the unwiring.
|
||||
*/
|
||||
export function bindPinHoverLabel(
|
||||
pin: HTMLElement,
|
||||
label: MapHoverLabel,
|
||||
container: HTMLElement,
|
||||
text: PinHoverText
|
||||
): () => void {
|
||||
const show = () => {
|
||||
const box = pin.getBoundingClientRect();
|
||||
const frame = container.getBoundingClientRect();
|
||||
label.show(text, { x: box.left + box.width / 2 - frame.left, y: box.top - frame.top }, pin);
|
||||
};
|
||||
const hide = () => label.hide();
|
||||
const away = (event: Event) => {
|
||||
if (!label.shownFor(pin)) return;
|
||||
if (event.type === 'pointerleave' || !(event.target instanceof Node && pin.contains(event.target))) label.hide();
|
||||
};
|
||||
pin.addEventListener('pointerenter', show);
|
||||
pin.addEventListener('pointerleave', hide);
|
||||
pin.addEventListener('pointerdown', hide);
|
||||
container.addEventListener('pointermove', away);
|
||||
container.addEventListener('pointerleave', away);
|
||||
return () => {
|
||||
pin.removeEventListener('pointerenter', show);
|
||||
pin.removeEventListener('pointerleave', hide);
|
||||
pin.removeEventListener('pointerdown', hide);
|
||||
container.removeEventListener('pointermove', away);
|
||||
container.removeEventListener('pointerleave', away);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// FE-MAP-PRESS-001 to FE-MAP-PRESS-008: only a real click on the map places a point, never a drag or a long hold.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPressTracker, isPlacementTap, TAP_MAX_DURATION_MS, TAP_MAX_TRAVEL_PX } from './mapPress';
|
||||
|
||||
function pointer(type: string, x: number, y: number, pointerId = 1): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: pointerId },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
/** A tracker on a fresh element, with a clock the test moves. */
|
||||
function setup() {
|
||||
const el = document.createElement('div');
|
||||
let clock = 0;
|
||||
const tracker = createPressTracker(el, () => clock);
|
||||
return {
|
||||
el,
|
||||
tracker,
|
||||
at: (ms: number) => {
|
||||
clock = ms;
|
||||
},
|
||||
send: (type: string, x: number, y: number, id = 1) => el.dispatchEvent(pointer(type, x, y, id)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('isPlacementTap', () => {
|
||||
it('FE-MAP-PRESS-001: a short press that barely moved places', () => {
|
||||
expect(isPlacementTap({ travelPx: 0, durationMs: 80, pointers: 1 })).toBe(true);
|
||||
expect(
|
||||
isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX - 0.5, durationMs: TAP_MAX_DURATION_MS - 1, pointers: 1 })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-PRESS-002: 4 px of travel, 300 ms of hold or a second finger never place', () => {
|
||||
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX, durationMs: 50, pointers: 1 })).toBe(false);
|
||||
expect(isPlacementTap({ travelPx: 0, durationMs: TAP_MAX_DURATION_MS, pointers: 1 })).toBe(false);
|
||||
expect(isPlacementTap({ travelPx: 0, durationMs: 50, pointers: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-MAP-PRESS-003: a click no press was seen for (keyboard, synthetic) is the map's own call", () => {
|
||||
expect(isPlacementTap(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPressTracker', () => {
|
||||
it('FE-MAP-PRESS-004: hands out the finished press once, with its travel and length', () => {
|
||||
const { tracker, at, send } = setup();
|
||||
at(1000);
|
||||
send('pointerdown', 10, 10);
|
||||
at(1100);
|
||||
send('pointerup', 12, 11);
|
||||
const press = tracker.take();
|
||||
expect(press?.durationMs).toBe(100);
|
||||
expect(press?.travelPx).toBeCloseTo(Math.hypot(2, 1));
|
||||
expect(press?.pointers).toBe(1);
|
||||
expect(isPlacementTap(press)).toBe(true);
|
||||
expect(tracker.take()).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-PRESS-005: a drag that came back to where it started still counts as a drag', () => {
|
||||
const { tracker, at, send } = setup();
|
||||
at(0);
|
||||
send('pointerdown', 100, 100);
|
||||
send('pointermove', 140, 100);
|
||||
at(120);
|
||||
send('pointerup', 100, 100);
|
||||
const press = tracker.take();
|
||||
expect(press?.travelPx).toBe(40);
|
||||
expect(isPlacementTap(press)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-PRESS-006: a pinch or a two-finger rotate is no tap, and ends with the last finger', () => {
|
||||
const { tracker, at, send } = setup();
|
||||
at(0);
|
||||
send('pointerdown', 10, 10, 1);
|
||||
send('pointerdown', 50, 50, 2);
|
||||
at(80);
|
||||
send('pointerup', 50, 50, 2);
|
||||
expect(tracker.take()).toBeNull();
|
||||
send('pointerup', 10, 10, 1);
|
||||
const press = tracker.take();
|
||||
expect(press?.pointers).toBe(2);
|
||||
expect(isPlacementTap(press)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-PRESS-007: a cancelled press (the browser took it for a gesture) never places', () => {
|
||||
const { tracker, at, send } = setup();
|
||||
at(0);
|
||||
send('pointerdown', 10, 10);
|
||||
at(50);
|
||||
send('pointercancel', 10, 10);
|
||||
expect(isPlacementTap(tracker.take())).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-PRESS-008: a new press drops the one nobody read, and dispose stops listening', () => {
|
||||
const { tracker, at, send } = setup();
|
||||
at(0);
|
||||
send('pointerdown', 0, 0);
|
||||
at(500);
|
||||
send('pointerup', 0, 0);
|
||||
send('pointerdown', 0, 0);
|
||||
expect(tracker.take()).toBeNull();
|
||||
at(550);
|
||||
send('pointerup', 0, 0);
|
||||
expect(tracker.take()?.durationMs).toBe(50);
|
||||
|
||||
tracker.dispose();
|
||||
send('pointerdown', 0, 0);
|
||||
send('pointerup', 0, 0);
|
||||
expect(tracker.take()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Telling a click that should put something on the map from the end of a pan,
|
||||
* a rotation or a tilt. Both map engines fire their click after short drags
|
||||
* and long holds too, which in 3D, where every look around is a drag, kept
|
||||
* dropping waypoints nobody meant to set.
|
||||
*/
|
||||
|
||||
/** How far the pointer may travel during a press that still counts as a click. */
|
||||
export const TAP_MAX_TRAVEL_PX = 4;
|
||||
/** How long a press that still counts as a click may last. */
|
||||
export const TAP_MAX_DURATION_MS = 300;
|
||||
|
||||
/** One finished press on the map. */
|
||||
export interface MapPress {
|
||||
/** The farthest the first pointer got from where it went down. */
|
||||
travelPx: number;
|
||||
durationMs: number;
|
||||
/** The most pointers down at once: two fingers pinch or rotate, they never place. */
|
||||
pointers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a press places a point. A press that was never seen (a keyboard
|
||||
* activation, a synthetic click) is left to the map, which only fires a click
|
||||
* for it on purpose.
|
||||
*/
|
||||
export function isPlacementTap(press: MapPress | null): boolean {
|
||||
if (!press) return true;
|
||||
return press.pointers <= 1 && press.travelPx < TAP_MAX_TRAVEL_PX && press.durationMs < TAP_MAX_DURATION_MS;
|
||||
}
|
||||
|
||||
export interface PressTracker {
|
||||
/** The press that ended last, handed out once: the click it caused reads it, the next one does not. */
|
||||
take: () => MapPress | null;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
interface Active {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
at: number;
|
||||
travelPx: number;
|
||||
pointers: Set<number>;
|
||||
most: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the presses on an element in the capture phase, so neither engine
|
||||
* can swallow them first, and keeps the last finished one for the click that
|
||||
* follows it.
|
||||
*/
|
||||
export function createPressTracker(target: EventTarget, now: () => number = () => performance.now()): PressTracker {
|
||||
let active: Active | null = null;
|
||||
let last: MapPress | null = null;
|
||||
|
||||
const down = (event: Event) => {
|
||||
const e = event as PointerEvent;
|
||||
if (active) {
|
||||
active.pointers.add(e.pointerId);
|
||||
active.most = Math.max(active.most, active.pointers.size);
|
||||
return;
|
||||
}
|
||||
last = null;
|
||||
active = {
|
||||
id: e.pointerId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
at: now(),
|
||||
travelPx: 0,
|
||||
pointers: new Set([e.pointerId]),
|
||||
most: 1,
|
||||
cancelled: false,
|
||||
};
|
||||
};
|
||||
const move = (event: Event) => {
|
||||
const e = event as PointerEvent;
|
||||
if (!active || e.pointerId !== active.id) return;
|
||||
active.travelPx = Math.max(active.travelPx, Math.hypot(e.clientX - active.x, e.clientY - active.y));
|
||||
};
|
||||
const up = (event: Event) => {
|
||||
const e = event as PointerEvent;
|
||||
if (!active) return;
|
||||
if (e.pointerId === active.id && !active.cancelled) {
|
||||
move(e);
|
||||
}
|
||||
if (event.type === 'pointercancel') active.cancelled = true;
|
||||
active.pointers.delete(e.pointerId);
|
||||
if (active.pointers.size > 0) return;
|
||||
last = {
|
||||
travelPx: active.cancelled ? Number.POSITIVE_INFINITY : active.travelPx,
|
||||
durationMs: now() - active.at,
|
||||
pointers: active.most,
|
||||
};
|
||||
active = null;
|
||||
};
|
||||
|
||||
const options = { capture: true, passive: true } as const;
|
||||
target.addEventListener('pointerdown', down, options);
|
||||
target.addEventListener('pointermove', move, options);
|
||||
target.addEventListener('pointerup', up, options);
|
||||
target.addEventListener('pointercancel', up, options);
|
||||
|
||||
return {
|
||||
take: () => {
|
||||
const press = last;
|
||||
last = null;
|
||||
return press;
|
||||
},
|
||||
dispose: () => {
|
||||
target.removeEventListener('pointerdown', down, options);
|
||||
target.removeEventListener('pointermove', move, options);
|
||||
target.removeEventListener('pointerup', up, options);
|
||||
target.removeEventListener('pointercancel', up, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared';
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import type { RouteProfileFocus } from '../../utils/routeGeometry';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag';
|
||||
import type { CompassMap } from './MapCompassPill';
|
||||
import type { TourBaseLayer } from './MapLayerSwitcher';
|
||||
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
|
||||
import type { Poi } from './poiCategories';
|
||||
|
||||
/*
|
||||
@@ -108,12 +109,26 @@ export interface MapViewProps {
|
||||
/** Reports which option the pointer is over, so the list and the map agree. */
|
||||
onHighlightAlternative?: (index: number | null) => void;
|
||||
/** Generic numbered control points for list-first route editors. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
|
||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
onPlannerWaypointMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
routeProfileFocus?: RouteProfileFocus | null;
|
||||
viewBaseLayer?: TourBaseLayer;
|
||||
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
|
||||
/** Offers the 3D terrain switch in the tour layer pill. */
|
||||
onToggleTerrain3d?: () => void;
|
||||
/** Offers the "set points" switch in the tour layer pill, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool;
|
||||
/**
|
||||
* The via ferrata layer (RC-10): its switch, with the count as a badge, joins
|
||||
* the tour layer pill, and while it is on its lines are drawn and a click on
|
||||
* one hands the ferrata to `onSelect`. Both renderers mount it through the
|
||||
* Ferratas modules.
|
||||
*/
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
@@ -123,7 +138,7 @@ export interface MapViewProps {
|
||||
*/
|
||||
focusPoints?: [number, number][];
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number;
|
||||
focusKey?: number | string;
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
function pointer(type: string, x: number, y: number, pointerId = 1, pointerType: 'mouse' | 'touch' = 'mouse'): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: pointerId },
|
||||
pointerType: { value: pointerType },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('Tour waypoint marker drag lifecycle', () => {
|
||||
it('previews during a drag and commits once on pointer up, swallowing only the generated click', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const onCommit = vi.fn(() => true);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY / 100, lng: clientX / 100 }),
|
||||
setPosition: preview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 11, 11));
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith({ lat: 0.3, lng: 0.2 });
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
element.dispatchEvent(pointer('pointerup', 30, 40));
|
||||
expect(preview).toHaveBeenLastCalledWith({ lat: 0.4, lng: 0.3 });
|
||||
expect(onCommit).toHaveBeenCalledOnce();
|
||||
expect(onCommit).toHaveBeenCalledWith({ lat: 0.4, lng: 0.3 });
|
||||
|
||||
const generatedClick = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(generatedClick);
|
||||
expect(generatedClick.defaultPrevented).toBe(true);
|
||||
const nextClick = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(nextClick);
|
||||
expect(nextClick.defaultPrevented).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores the starting coordinate on pointer cancellation and does not commit', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
|
||||
setPosition: preview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
element.dispatchEvent(pointer('pointercancel', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith(initial);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores the preview when the canonical commit is rejected', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
|
||||
setPosition: preview,
|
||||
onCommit: () => false,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
element.dispatchEvent(pointer('pointerup', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith(initial);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps pointer, mouse, and touch starts from bubbling into map pan handlers', () => {
|
||||
const mapSurface = document.createElement('div');
|
||||
const element = document.createElement('button');
|
||||
mapSurface.append(element);
|
||||
const mapPan = vi.fn();
|
||||
mapSurface.addEventListener('pointerdown', mapPan);
|
||||
mapSurface.addEventListener('mousedown', mapPan);
|
||||
mapSurface.addEventListener('touchstart', mapPan);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: () => ({ lat: 48, lng: 11 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
||||
element.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true }));
|
||||
expect(mapPan).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('commits a touch-pointer drag once at release', () => {
|
||||
const element = document.createElement('button');
|
||||
const onCommit = vi.fn(() => true);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY / 10, lng: clientX / 10 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 5, 5, 9, 'touch'));
|
||||
element.dispatchEvent(pointer('pointermove', 25, 35, 9, 'touch'));
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
element.dispatchEvent(pointer('pointerup', 25, 35, 9, 'touch'));
|
||||
expect(onCommit).toHaveBeenCalledOnce();
|
||||
expect(onCommit).toHaveBeenCalledWith({ lat: 3.5, lng: 2.5 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores existing touch and selection styles when editing drag wiring is removed', () => {
|
||||
const element = document.createElement('button');
|
||||
element.style.touchAction = 'pan-x';
|
||||
element.style.userSelect = 'text';
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: () => ({ lat: 48, lng: 11 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
});
|
||||
expect(element.style.touchAction).toBe('none');
|
||||
expect(element.style.userSelect).toBe('none');
|
||||
cleanup();
|
||||
expect(element.style.touchAction).toBe('pan-x');
|
||||
expect(element.style.userSelect).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
interface PlannerWaypointCoordinate {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface PlannerWaypointDragOptions {
|
||||
element: HTMLElement;
|
||||
initial: PlannerWaypointCoordinate;
|
||||
coordinateAt: (clientX: number, clientY: number) => PlannerWaypointCoordinate;
|
||||
setPosition: (coordinate: PlannerWaypointCoordinate) => void;
|
||||
onCommit: (coordinate: PlannerWaypointCoordinate) => boolean | void;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
export function wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt,
|
||||
setPosition,
|
||||
onCommit,
|
||||
}: PlannerWaypointDragOptions): () => void {
|
||||
let pointerId: number | null = null;
|
||||
let start: { x: number; y: number } | null = null;
|
||||
let dragged = false;
|
||||
let suppressClick = false;
|
||||
const originalTouchAction = element.style.touchAction;
|
||||
const originalUserSelect = element.style.userSelect;
|
||||
|
||||
const stopMapGesture = (event: Event) => event.stopPropagation();
|
||||
const restore = () => setPosition(initial);
|
||||
const cancel = (event?: Event) => {
|
||||
if (pointerId === null) return;
|
||||
event?.stopPropagation();
|
||||
restore();
|
||||
pointerId = null;
|
||||
start = null;
|
||||
dragged = false;
|
||||
suppressClick = false;
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || pointerId !== null) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
pointerId = event.pointerId;
|
||||
start = { x: event.clientX, y: event.clientY };
|
||||
dragged = false;
|
||||
suppressClick = false;
|
||||
try {
|
||||
element.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture is unavailable in some test DOMs and older browsers.
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId || !start) return;
|
||||
event.stopPropagation();
|
||||
if (!dragged && Math.hypot(event.clientX - start.x, event.clientY - start.y) < DRAG_THRESHOLD_PX) return;
|
||||
dragged = true;
|
||||
event.preventDefault();
|
||||
setPosition(coordinateAt(event.clientX, event.clientY));
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId || !start) return;
|
||||
event.stopPropagation();
|
||||
const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y);
|
||||
if (dragged || travelled >= DRAG_THRESHOLD_PX) {
|
||||
const coordinate = coordinateAt(event.clientX, event.clientY);
|
||||
setPosition(coordinate);
|
||||
const accepted = onCommit(coordinate) !== false;
|
||||
suppressClick = true;
|
||||
if (!accepted) restore();
|
||||
}
|
||||
pointerId = null;
|
||||
start = null;
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
const onClickCapture = (event: MouseEvent) => {
|
||||
if (!suppressClick) return;
|
||||
suppressClick = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onTouchCancel = (event: Event) => cancel(event);
|
||||
|
||||
element.addEventListener('pointerdown', onPointerDown);
|
||||
element.addEventListener('pointermove', onPointerMove);
|
||||
element.addEventListener('pointerup', onPointerUp);
|
||||
element.addEventListener('pointercancel', cancel);
|
||||
element.addEventListener('lostpointercapture', cancel);
|
||||
element.addEventListener('click', onClickCapture, true);
|
||||
element.addEventListener('mousedown', stopMapGesture);
|
||||
element.addEventListener('touchstart', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchmove', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchend', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
element.style.touchAction = 'none';
|
||||
element.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
cancel();
|
||||
element.removeEventListener('pointerdown', onPointerDown);
|
||||
element.removeEventListener('pointermove', onPointerMove);
|
||||
element.removeEventListener('pointerup', onPointerUp);
|
||||
element.removeEventListener('pointercancel', cancel);
|
||||
element.removeEventListener('lostpointercapture', cancel);
|
||||
element.removeEventListener('click', onClickCapture, true);
|
||||
element.removeEventListener('mousedown', stopMapGesture);
|
||||
element.removeEventListener('touchstart', stopMapGesture);
|
||||
element.removeEventListener('touchmove', stopMapGesture);
|
||||
element.removeEventListener('touchend', stopMapGesture);
|
||||
element.removeEventListener('touchcancel', onTouchCancel);
|
||||
element.style.touchAction = originalTouchAction;
|
||||
element.style.userSelect = originalUserSelect;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// FE-COMP-VIADWELL-001 to FE-COMP-VIADWELL-002: the dwell label both map renderers put on a route via.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatViaDwell } from './routeViaDwell';
|
||||
|
||||
describe('formatViaDwell', () => {
|
||||
it('FE-COMP-VIADWELL-001: under an hour reads as minutes, rounded', () => {
|
||||
expect(formatViaDwell(0)).toBe('0 min');
|
||||
expect(formatViaDwell(25 * 60 + 29)).toBe('25 min');
|
||||
expect(formatViaDwell(25 * 60 + 31)).toBe('26 min');
|
||||
});
|
||||
|
||||
it('FE-COMP-VIADWELL-002: an hour or more splits the hours off', () => {
|
||||
expect(formatViaDwell(3600)).toBe('1 h 0 min');
|
||||
expect(formatViaDwell(2 * 3600 + 15 * 60)).toBe('2 h 15 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* How long a route via (a charging stop, a rest area a plugin route places on
|
||||
* the drawn day) holds the drive, as both map renderers label it: whole
|
||||
* minutes, with the hours split off once there are any. One function for both,
|
||||
* so the Leaflet tooltip and the GL label cannot disagree on the wording.
|
||||
*/
|
||||
export function formatViaDwell(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.round((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
interface PlannerWaypointMap {
|
||||
getContainer: () => HTMLElement;
|
||||
unproject: (point: [number, number]) => { lat: number; lng: number };
|
||||
/** Optional so a stand-in map without events still takes the pins. */
|
||||
on?: (type: 'movestart', listener: () => void) => unknown;
|
||||
off?: (type: 'movestart', listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
/** The slice of a hand-positioned pin the waypoint markers drive. */
|
||||
interface PlannerWaypointPin {
|
||||
remove: () => void;
|
||||
setLngLat: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
interface PlannerWaypointMarkersOptions<Pin extends PlannerWaypointPin> {
|
||||
mapRef: { readonly current: PlannerWaypointMap | null };
|
||||
mapReady: boolean;
|
||||
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
|
||||
pinsRef: { current: Pin[] };
|
||||
/** Puts a waypoint element on the map at the given position. */
|
||||
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
pinCss: (size: number, selected: boolean) => string;
|
||||
plannerWaypoints: NonNullable<MapViewProps['plannerWaypoints']>;
|
||||
selectedPlannerWaypointId: string | null;
|
||||
onPlannerWaypointClick: MapViewProps['onPlannerWaypointClick'];
|
||||
onPlannerWaypointMove: MapViewProps['onPlannerWaypointMove'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbered control points of a list-first route editor, drawn as pins on the GL map.
|
||||
*
|
||||
* The handlers are read through a ref so a parent re-rendering with fresh callbacks does
|
||||
* not tear the pins down mid-drag; only a change in whether they can be dragged does.
|
||||
*/
|
||||
export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
mapRef,
|
||||
mapReady,
|
||||
pinsRef,
|
||||
attachPin,
|
||||
pinCss,
|
||||
plannerWaypoints,
|
||||
selectedPlannerWaypointId,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
}: PlannerWaypointMarkersOptions<Pin>): void {
|
||||
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove });
|
||||
useLayoutEffect(() => {
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove };
|
||||
}, [onPlannerWaypointClick, onPlannerWaypointMove]);
|
||||
const plannerWaypointsDraggable = !!onPlannerWaypointMove;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
pinsRef.current.forEach((pin) => pin.remove());
|
||||
pinsRef.current = [];
|
||||
const dragCleanups: (() => void)[] = [];
|
||||
const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label');
|
||||
// A label left behind by a pan or a zoom would float over the wrong spot.
|
||||
const hideOnMove = () => hoverLabel.hide();
|
||||
map.on?.('movestart', hideOnMove);
|
||||
dragCleanups.push(() => map.off?.('movestart', hideOnMove));
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
const swallow = (event: Event) => event.stopPropagation();
|
||||
const select = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id);
|
||||
};
|
||||
el.addEventListener('pointerdown', swallow);
|
||||
el.addEventListener('touchstart', swallow, { passive: true });
|
||||
el.addEventListener('click', select);
|
||||
const pin = attachPin(el, point.lng, point.lat);
|
||||
pinsRef.current.push(pin);
|
||||
dragCleanups.push(bindPinHoverLabel(el, hoverLabel, map.getContainer(), { name, detail: point.detail ?? null }));
|
||||
if (plannerWaypointsDraggable) {
|
||||
el.style.cursor = 'grab';
|
||||
dragCleanups.push(
|
||||
wirePlannerWaypointDrag({
|
||||
element: el,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
|
||||
return { lat: at.lat, lng: at.lng };
|
||||
},
|
||||
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
||||
onCommit: ({ lat, lng }) =>
|
||||
plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
dragCleanups.forEach((cleanup) => cleanup());
|
||||
hoverLabel.remove();
|
||||
pinsRef.current.forEach((pin) => pin.remove());
|
||||
pinsRef.current = [];
|
||||
};
|
||||
}, [
|
||||
attachPin,
|
||||
mapReady,
|
||||
mapRef,
|
||||
pinCss,
|
||||
pinsRef,
|
||||
plannerWaypoints,
|
||||
plannerWaypointsDraggable,
|
||||
selectedPlannerWaypointId,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourDayRowFacts from '../Tours/TourDayRowFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||
|
||||
interface DayPlanPlaceSublineProps {
|
||||
place: Place;
|
||||
cat: Category | undefined;
|
||||
tourTimes: DayRowTourTimes | undefined;
|
||||
isTourPlace: boolean;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||
if (!tourTimes) return false;
|
||||
return tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null;
|
||||
}
|
||||
|
||||
// Walking, break and planned-total pills of a tour stop that has no tour row of its own.
|
||||
function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
const { t } = useTranslation();
|
||||
const manual = tourTimes.manuallyOverridden;
|
||||
return (
|
||||
<>
|
||||
{tourTimes.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-walking-time"
|
||||
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-breaks"
|
||||
aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-planned-duration"
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Description line; tour stops get two clamped lines instead of one.
|
||||
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={isTourPlace ? 'trip-plan-tour-description' : undefined}
|
||||
className="collab-note-md min-w-0 flex-1 text-content-faint"
|
||||
style={{
|
||||
...fs(10.5),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
|
||||
lineHeight: 1.3,
|
||||
maxHeight: isTourPlace ? '2.6em' : '1.3em',
|
||||
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
|
||||
}}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Second line of a place row in the day plan: time, tour facts or planned
|
||||
// tour durations, then the clamped description.
|
||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
||||
const { locale } = useTranslation();
|
||||
const dayRowTour = tourTimes?.tour;
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1572,10 +1572,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column sits at the row's right edge,
|
||||
// with only the row's "…" after it.
|
||||
expect(row.lastElementChild).toHaveAccessibleName('More options')
|
||||
expect(row.lastElementChild?.previousElementSibling).toHaveClass('reorder-buttons')
|
||||
// Positional: the actions close the row, the reorder column right before their "…".
|
||||
const actions = row.lastElementChild
|
||||
expect(actions?.lastElementChild).toHaveAccessibleName('More options')
|
||||
expect(actions?.lastElementChild?.previousElementSibling).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
@@ -4015,8 +4015,7 @@ describe('DayPlanSidebar', () => {
|
||||
expect(document.querySelectorAll('img[src*="a.jpg"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut appears while the row is hovered', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut comes out with the row hover in CSS, like the "…"', () => {
|
||||
const place = buildPlace({ id: 1, name: 'Hover place' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
|
||||
@@ -4024,13 +4023,12 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
|
||||
})} />)
|
||||
const row = dragRow(screen.getByText('Hover place'))
|
||||
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
|
||||
fireEvent.mouseEnter(row)
|
||||
await user.click(screen.getByLabelText('Add booking'))
|
||||
// Already in the row, hidden and inert until the row is hovered. Waiting for a
|
||||
// React hover state put it on screen a beat after the arrows and the "…".
|
||||
const button = screen.getByLabelText('Add booking')
|
||||
expect(button).toHaveClass('dp-add-booking')
|
||||
fireEvent.click(button)
|
||||
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
noteUi: {} as Record<string, unknown>,
|
||||
dayNotes: {} as Record<string, unknown[]>,
|
||||
setNoteUi: vi.fn(),
|
||||
noteInputRef: { current: null } as { current: null },
|
||||
openAddNote: vi.fn(),
|
||||
openEditNote: vi.fn(),
|
||||
cancelNote: vi.fn(),
|
||||
saveNote: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
moveNote: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
assignmentsApi: {
|
||||
reorder: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue({}),
|
||||
updateTime: vi.fn().mockResolvedValue({}),
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
reservationsApi: {
|
||||
list: vi.fn().mockResolvedValue({ reservations: [] }),
|
||||
updatePositions: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
daysApi: {
|
||||
...actual.daysApi,
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock('../Map/RouteCalculator', () => ({
|
||||
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
walkingText: '25 min',
|
||||
})),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
beforeAll(() => {
|
||||
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIO;
|
||||
});
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useDayNotes', () => ({
|
||||
useDayNotes: () => mockDayNotesState,
|
||||
}));
|
||||
|
||||
vi.mock('../Weather/WeatherWidget', () => ({
|
||||
default: (props: { locationName?: string | null }) => (
|
||||
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
|
||||
),
|
||||
}));
|
||||
|
||||
// A stable toast object so tests can assert on the messages the sidebar raises.
|
||||
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
|
||||
vi.mock('../shared/Toast', () => ({
|
||||
useToast: () => mockToast,
|
||||
}));
|
||||
|
||||
vi.mock('../../store/permissionsStore', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../store/permissionsStore')>()),
|
||||
useCanDo: () => () => true,
|
||||
}));
|
||||
|
||||
const trip = buildTrip({ id: 1, currency: 'EUR' });
|
||||
|
||||
function makeDefaultProps(overrides = {}) {
|
||||
return {
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
selectedDayId: null,
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
onSelectDay: vi.fn(),
|
||||
onPlaceClick: vi.fn(),
|
||||
onDayDetail: vi.fn(),
|
||||
accommodations: [],
|
||||
onReorder: vi.fn(),
|
||||
onUpdateDayTitle: vi.fn(),
|
||||
onRouteCalculated: vi.fn(),
|
||||
onAssignToDay: vi.fn(),
|
||||
onRemoveAssignment: vi.fn(),
|
||||
onEditPlace: vi.fn(),
|
||||
onDeletePlace: vi.fn(),
|
||||
reservations: [],
|
||||
onAddReservation: vi.fn(),
|
||||
onNavigateToFiles: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
clearExchangeRateCache();
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])));
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as never);
|
||||
});
|
||||
|
||||
describe('DayPlanSidebar', () => {
|
||||
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
|
||||
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
tourPlaceIds: new Set([place.id]),
|
||||
plannedTourDurations: new Map([[place.id, times]]),
|
||||
assignments: { '10': [assignment] },
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
expect(tourPreview.style.display).toBe('-webkit-box');
|
||||
expect(tourPreview.style.webkitLineClamp).toBe('2');
|
||||
expect(tourPreview.style.maxHeight).toBe('2.6em');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
|
||||
view.rerender(
|
||||
<DayPlanSidebar
|
||||
{...{
|
||||
...props,
|
||||
plannedTourDurations: new Map([[place.id, { ...times, plannedTotalMinutes: 125, manuallyOverridden: true }]]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-planned-duration"]')).toBeNull();
|
||||
expect(screen.getByText(description).closest('.collab-note-md')).toHaveStyle({
|
||||
whiteSpace: 'nowrap',
|
||||
maxHeight: '1.3em',
|
||||
});
|
||||
});
|
||||
|
||||
it('RC-01: uses facet type and compact facts only for Tours, preserving Tours-off Place presentation', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description: 'Above the lake', tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const tour = {
|
||||
place_id: 45,
|
||||
name: place.name,
|
||||
tour_type: 'hike' as const,
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: 53,
|
||||
break_additional_minutes: 10,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
tourPlaceIds: new Set([45]),
|
||||
assignments: { '10': [assignment] },
|
||||
plannedTourDurations: new Map([
|
||||
[45, { tour, walkingMinutes: 53, breakMinutes: 10, plannedTotalMinutes: 63, manuallyOverridden: false }],
|
||||
]),
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
|
||||
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import { routeModeIcon, useRouteModeOptions } from './routeModes'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
|
||||
import './dayPlanRows.css'
|
||||
|
||||
/** The round white button a row carries on its right (a route on the map, a journey's legs). */
|
||||
const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors'
|
||||
@@ -60,7 +61,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { withDayPlanDefaults } from './DayPlanSidebar.defaults'
|
||||
import { noteSurface } from './noteSurface'
|
||||
import { dayBodyStyle, noteSurface } from './noteSurface'
|
||||
import { findTodayDayId } from './today'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
@@ -80,6 +81,10 @@ import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import type { TourPlannedTimes } from '../Tours/tourPresentation'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
|
||||
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
|
||||
|
||||
export interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
@@ -87,6 +92,7 @@ export interface DayPlanSidebarProps {
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
tourPlaceIds?: ReadonlySet<number>
|
||||
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
@@ -1277,7 +1283,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const canManageShare = can('share_manage', trip)
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
@@ -1726,7 +1731,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
className="border-t border-edge-faint px-1.5 pb-1.5 pt-1.5"
|
||||
// The activity list — the largest region and the one behind the
|
||||
// densest text, so its tint is the faintest of the three.
|
||||
style={{ background: dayTintBackground(dayTint, 'activity', '--day-tint-activity') ?? undefined }}
|
||||
style={dayBodyStyle(dayTintBackground(dayTint, 'activity', '--day-tint-activity'))}
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
@@ -1823,6 +1828,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const assignment = item.data
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const isTourPlace = toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)
|
||||
const tourTimes = isTourPlace ? props.plannedTourDurations?.get(place.id) : undefined
|
||||
const dayRowTour = tourTimes?.tour
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
@@ -1967,8 +1975,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
selectPlace()
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, placeMenu())}
|
||||
onMouseEnter={() => setHoveredAssignmentId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredAssignmentId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
paddingBlock: 6, paddingInlineEnd: 4, paddingInlineStart: gripsShown ? 2 : 8,
|
||||
@@ -1994,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onMouseLeave={() => setLockHoverId(null)}
|
||||
className="relative flex flex-none rounded-full"
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={32} />
|
||||
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
|
||||
{/* Hover/locked overlay */}
|
||||
{(lockHoverId === assignment.id || isLocked) && (
|
||||
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
|
||||
@@ -2006,7 +2012,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{cat && (() => {
|
||||
{!dayRowTour && cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<Tooltip label={cat.name}>
|
||||
@@ -2027,20 +2033,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{(place.place_time || place.description || place.address || cat?.name) && (
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div className="collab-note-md min-w-0 flex-1 text-content-faint" style={{ ...fs(10.5), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3, maxHeight: '1.3em' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
@@ -2139,7 +2132,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)}
|
||||
</div>
|
||||
{/* The row's own controls, with room between them so each is its own target. */}
|
||||
<div className="flex flex-none items-center gap-2.5 ps-1">
|
||||
<div className="dp-row-actions flex flex-none items-center gap-2.5 ps-1">
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={moveUp} disabled={idx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge">
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
@@ -2148,14 +2141,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && (hoveredAssignmentId === assignment.id || isPlaceSelected) && (
|
||||
{canEditDays && onAddBookingToAssignment && (
|
||||
<Tooltip label={t('reservations.addBooking')} placement="top">
|
||||
<button type="button" aria-label={t('reservations.addBooking')}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
|
||||
className="dp-add-booking grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
|
||||
>
|
||||
<Ticket size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -2505,7 +2498,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
background: noteSkin.background,
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
|
||||
}}
|
||||
'--dp-note-tint': noteSkin.background,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip flex flex-none cursor-grab items-center text-content-faint opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-40">
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
@@ -2526,17 +2520,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} aria-label={t('dayplan.moveDown')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>}
|
||||
{canEditDays && (
|
||||
{canEditDays && <div className="dp-note-actions flex flex-none items-center">
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} aria-label={t('dayplan.moveDown')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
<MoreButton label={t('files.menu')} size={24} items={[
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
]} />
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
@@ -364,4 +364,43 @@ describe('LocationSelect', () => {
|
||||
expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Gare de Lyon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the keyboard-highlighted result as active and selects it with Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(searchRoute([GARE, LYON]));
|
||||
const onPick = vi.fn();
|
||||
|
||||
render(<LocationSelect value={null} onChange={onPick} ariaLabel="Search map location" showSearchStatus />);
|
||||
const input = screen.getByRole('combobox', { name: 'Search map location' });
|
||||
await user.type(input, 'Gare');
|
||||
const firstOption = await screen.findByRole('option', { name: /Gare du Nord/ });
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
expect(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id);
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Gare du Nord' }));
|
||||
});
|
||||
|
||||
it('shows a localized no-result status after an empty successful search', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(searchRoute([]));
|
||||
|
||||
render(<LocationSelect value={null} onChange={vi.fn()} ariaLabel="Search map location" showSearchStatus />);
|
||||
await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Nowhere');
|
||||
|
||||
expect(await screen.findByText(/Nothing found for/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a non-mutating error status when the provider request fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ error: 'unavailable' }, { status: 503 })));
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<LocationSelect value={null} onChange={onChange} ariaLabel="Search map location" showSearchStatus />);
|
||||
await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Kyoto');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Error');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { MapPin, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
import { SoftPill } from './planParts'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -18,6 +20,11 @@ interface Props {
|
||||
value: LocationPoint | null
|
||||
onChange: (loc: LocationPoint | null) => void
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
/** Announces the field as a combobox and says "nothing found" or an error in its list. */
|
||||
showSearchStatus?: boolean
|
||||
/** Floats over a map as a frosted pill, like the map controls. */
|
||||
floating?: boolean
|
||||
style?: React.CSSProperties
|
||||
/**
|
||||
* The trip's own places, offered while the field is empty or holds fewer than three
|
||||
@@ -25,9 +32,11 @@ interface Props {
|
||||
* name; from the third character on the map search answers instead.
|
||||
*/
|
||||
places?: LocationPoint[]
|
||||
/** Marks each search result with where it came from (OSM, Google, TREK, a plugin), as the place form does. */
|
||||
showSource?: boolean
|
||||
}
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
|
||||
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places, showSource = false }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
@@ -35,7 +44,14 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
// What answered the last search, for the rows that do not name their own source.
|
||||
const [listSource, setListSource] = useState('')
|
||||
const { sourceLabel } = usePlaceSuggestions()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState(false)
|
||||
// The text the last answer was for, so a status never describes older text.
|
||||
const [searchedQuery, setSearchedQuery] = useState<string | null>(null)
|
||||
const listId = `${useId()}-location-results`
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,6 +63,9 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < 3 || (value && trimmed === value.name)) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setSearchError(false)
|
||||
setSearchedQuery(null)
|
||||
return
|
||||
}
|
||||
// Clearing the timer does nothing to a request already out: an answer for
|
||||
@@ -54,13 +73,21 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
let stale = false
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
setSearchError(false)
|
||||
setSearchedQuery(null)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, placeLang, locationBias)
|
||||
if (stale) return
|
||||
setResults(data.places || [])
|
||||
setListSource(data.source || '')
|
||||
setHighlight(-1)
|
||||
setSearchedQuery(trimmed)
|
||||
} catch {
|
||||
if (!stale) setResults([])
|
||||
if (!stale) {
|
||||
setResults([])
|
||||
setSearchError(true)
|
||||
setSearchedQuery(trimmed)
|
||||
}
|
||||
} finally {
|
||||
if (!stale) setLoading(false)
|
||||
}
|
||||
@@ -79,38 +106,68 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
onChange(loc)
|
||||
setQuery(loc.name)
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
onChange(null)
|
||||
setQuery('')
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setSearchError(false)
|
||||
setSearchedQuery(null)
|
||||
}
|
||||
|
||||
// The trip's places stand in for the search below three characters. Derived rather
|
||||
// than written into `results`, so a search answer that lands after the field was
|
||||
// shortened cannot replace them, and a place already picked does not reopen them.
|
||||
const typed = query.trim()
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
: results
|
||||
const { showPicks, rows } = pickRows(value, typed, places, results)
|
||||
const { showNoResults, showError, listOpen } = searchStatus({
|
||||
showSearchStatus, showPicks, searchedQuery, typed, rowCount: rows.length, loading, searchError, open,
|
||||
})
|
||||
const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation')
|
||||
const statusLine = renderStatusLine(showError, showNoResults, typed, t)
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Escape closes the list even while it only shows a status line.
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
handleKey(e, rows, pick)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||
<SuggestionInputBox
|
||||
icon={<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
|
||||
icon={floating
|
||||
? <Search size={16} strokeWidth={2} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
: <MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
|
||||
floating={floating}
|
||||
query={query}
|
||||
placeholder={placeholder ?? t('reservations.searchLocation')}
|
||||
onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
|
||||
onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); setSearchError(false); setSearchedQuery(null); if (value) onChange(null) }}
|
||||
onOpen={() => setOpen(true)}
|
||||
onKeyDown={(e) => handleKey(e, rows, pick)}
|
||||
onKeyDown={onKeyDown}
|
||||
onClear={value ? clear : undefined}
|
||||
inputAria={showSearchStatus ? comboboxAria(accessibleLabel, listOpen, listId, highlight) : undefined}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<SuggestionList loading={loading && !showPicks} rowCount={rows.length}>
|
||||
<SuggestionList
|
||||
loading={loading && !showPicks}
|
||||
rowCount={rows.length}
|
||||
status={statusLine}
|
||||
listbox={showSearchStatus ? { id: listId, label: accessibleLabel } : undefined}
|
||||
floating={floating}
|
||||
>
|
||||
{rows.map((r, i) => (
|
||||
<SuggestionRow
|
||||
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
|
||||
@@ -118,8 +175,10 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
onPick={() => pick(r)}
|
||||
onHover={() => setHighlight(i)}
|
||||
align="flex-start"
|
||||
optionId={showSearchStatus ? `${listId}-option-${i}` : undefined}
|
||||
>
|
||||
<PlaceSuggestion name={r.name} address={r.address} />
|
||||
{showSource && !showPicks && <SourceMark label={sourceLabel(r, listSource)} />}
|
||||
</SuggestionRow>
|
||||
))}
|
||||
</SuggestionList>
|
||||
@@ -127,3 +186,69 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Where a result came from, quiet on purpose, beside its name. */
|
||||
function SourceMark({ label }: { label: string | null }) {
|
||||
if (!label) return null
|
||||
return <span className="flex-none self-center"><SoftPill>{label}</SoftPill></span>
|
||||
}
|
||||
|
||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows: (T | LocationPoint)[] = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
: results
|
||||
return { showPicks, rows }
|
||||
}
|
||||
|
||||
interface SearchStatusInput {
|
||||
showSearchStatus: boolean
|
||||
showPicks: boolean
|
||||
searchedQuery: string | null
|
||||
typed: string
|
||||
rowCount: number
|
||||
loading: boolean
|
||||
searchError: boolean
|
||||
open: boolean
|
||||
}
|
||||
|
||||
function searchStatus({ showSearchStatus, showPicks, searchedQuery, typed, rowCount, loading, searchError, open }: SearchStatusInput) {
|
||||
const showNoResults =
|
||||
showSearchStatus && !showPicks && searchedQuery === typed && rowCount === 0 && !loading && !searchError
|
||||
const showError = showSearchStatus && searchError && searchedQuery === typed
|
||||
const listOpen = open && ((loading && !showPicks) || rowCount > 0 || showNoResults || showError)
|
||||
return { showNoResults, showError, listOpen }
|
||||
}
|
||||
|
||||
function renderStatusLine(
|
||||
showError: boolean,
|
||||
showNoResults: boolean,
|
||||
typed: string,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) {
|
||||
return showError ? (
|
||||
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('common.error')}
|
||||
</div>
|
||||
) : showNoResults ? (
|
||||
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('help.center.searchEmpty', { query: typed })}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
function comboboxAria(
|
||||
accessibleLabel: string,
|
||||
listOpen: boolean,
|
||||
listId: string,
|
||||
highlight: number,
|
||||
): React.AriaAttributes & { role?: React.AriaRole } {
|
||||
return {
|
||||
role: 'combobox',
|
||||
'aria-label': accessibleLabel,
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-expanded': listOpen,
|
||||
'aria-controls': listOpen ? listId : undefined,
|
||||
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,5 +73,19 @@ describe('PlaceContactFields (#2472)', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Take over from the place details' }))
|
||||
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-CONTACT-005: the place\'s phone and e-mail keep password managers out', () => {
|
||||
render(<Harness onChange={vi.fn()} />)
|
||||
// A place's address is not the user's login; 1Password filled it in (5.0.0-pre.1 feedback).
|
||||
for (const label of ['Phone', 'E-mail']) {
|
||||
const field = screen.getByLabelText(label)
|
||||
expect(field).toHaveAttribute('data-1p-ignore', 'true')
|
||||
expect(field).toHaveAttribute('data-lpignore', 'true')
|
||||
expect(field).toHaveAttribute('data-bwignore', 'true')
|
||||
expect(field).toHaveAttribute('autocomplete', 'off')
|
||||
}
|
||||
// Still an e-mail field, so the keyboard and the browser's check stay.
|
||||
expect(screen.getByLabelText('E-mail')).toHaveAttribute('type', 'email')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DialogSection, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { cleanWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { NO_AUTOFILL } from '../../utils/noAutofill'
|
||||
|
||||
interface Props {
|
||||
/** Anchor for the "add by hand" jump from an empty search. */
|
||||
@@ -80,14 +81,14 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
|
||||
<div className="relative">
|
||||
<Phone size={13} strokeWidth={2} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input id={`${id}-phone`} type="tel" value={phone} onChange={e => onChange('phone', e.target.value)}
|
||||
maxLength={50} placeholder="+49 …" className={`${INPUT} ps-8`} />
|
||||
maxLength={50} placeholder="+49 …" className={`${INPUT} ps-8`} {...NO_AUTOFILL} />
|
||||
</div>
|
||||
</EditorField>
|
||||
<EditorField label={t('places.formEmail')} htmlFor={`${id}-email`}>
|
||||
<div className="relative">
|
||||
<Mail size={13} strokeWidth={2} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input id={`${id}-email`} type="email" value={email} onChange={e => onChange('email', e.target.value)}
|
||||
maxLength={254} placeholder="info@…" className={`${INPUT} ps-8`} />
|
||||
maxLength={254} placeholder="info@…" className={`${INPUT} ps-8`} {...NO_AUTOFILL} />
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
@@ -901,6 +901,13 @@ describe('PlaceInspector', () => {
|
||||
expect(container.querySelector('input[type="file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-044b: with upload but no files, the files row does not open to nothing', () => {
|
||||
render(<PlaceInspector {...defaultProps} files={[]} />);
|
||||
const row = screen.getByRole('button', { name: 'Files' });
|
||||
expect(row).toBeDisabled();
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// ── Participants section hidden when tripMembers <= 1 ─────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => {
|
||||
|
||||
@@ -1189,12 +1189,12 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
|
||||
>
|
||||
<div className={BOX}>
|
||||
<button type="button" onClick={() => setFilesExpanded(f => !f)} aria-expanded={filesExpanded}
|
||||
className="flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors hover:bg-surface-hover">
|
||||
disabled={!placeFiles.length} className="group flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors enabled:hover:bg-surface-hover">
|
||||
<FileText size={13} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
|
||||
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
|
||||
</span>
|
||||
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint" /> : <ChevronDown size={13} className="flex-none text-content-faint" />}
|
||||
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint group-disabled:hidden" /> : <ChevronDown size={13} className="flex-none text-content-faint group-disabled:hidden" />}
|
||||
</button>
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-edge-faint p-1">
|
||||
|
||||
@@ -497,6 +497,20 @@ describe('Category filter dropdown', () => {
|
||||
expect(screen.getByRole('button', { name: 'Museum' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-023b: the list opens across the whole filter row, not off its icon', async () => {
|
||||
const user = userEvent.setup();
|
||||
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
||||
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
||||
await openCategories(user);
|
||||
// Hung off an icon near the end of the row, the list ran out of the sidebar.
|
||||
const list = screen.getByRole('button', { name: 'Museum' }).closest('.trek-popover-enter') as HTMLElement;
|
||||
expect(list).toHaveClass('inset-x-0');
|
||||
// Its positioning ancestor is the row that holds every filter.
|
||||
let anchor = list.parentElement;
|
||||
while (anchor && !anchor.classList.contains('relative')) anchor = anchor.parentElement;
|
||||
expect(anchor).toContainElement(screen.getByRole('button', { name: 'Show' }));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-024: selecting a category filters places', async () => {
|
||||
const user = userEvent.setup();
|
||||
const cat = buildCategory({ name: 'Park', color: '#22c55e' });
|
||||
|
||||
@@ -87,7 +87,8 @@ export function PlacesHeader(S: SidebarState) {
|
||||
</BandButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* The filters' lists open across this whole row (see FilterDropdown). */}
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<ShowDropdown {...S} />
|
||||
<CategoryFilter {...S} />
|
||||
<RatingFilter {...S} />
|
||||
@@ -172,9 +173,10 @@ function ShowDropdown(S: SidebarState) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter as one icon on the tabs row, opening a small list under it; closes on a
|
||||
* A filter as one icon on the tabs row, opening a list under the row; closes on a
|
||||
* click outside or Escape. A badge marks it while it is narrowing the list, and the
|
||||
* tooltip says to what.
|
||||
* tooltip says to what. The list spans the whole row rather than hanging off its
|
||||
* trigger: hung off an icon near the end, it ran out of the sidebar and was cut off.
|
||||
*/
|
||||
function FilterDropdown({ label, active, badge, icon, open, setOpen, name, children, wide = false, count }: {
|
||||
/** What is in force: on the trigger when `wide`, else in the tooltip ("All categories", "4+"). */
|
||||
@@ -210,7 +212,7 @@ function FilterDropdown({ label, active, badge, icon, open, setOpen, name, child
|
||||
}
|
||||
}, [open, setOpen])
|
||||
return (
|
||||
<div ref={wrapRef} className={`relative ${wide ? 'min-w-0 flex-1' : 'flex-none'}`}>
|
||||
<div ref={wrapRef} className={wide ? 'min-w-0 flex-1' : 'flex-none'}>
|
||||
{wide ? (
|
||||
<button type="button" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox" aria-label={name}
|
||||
className={`${CONTROL} flex w-full min-w-0 items-center gap-1.5 bg-surface-card px-2.5 font-semibold transition-colors ${active || open ? 'text-content' : 'text-content-secondary hover:text-content'}`}
|
||||
@@ -233,7 +235,7 @@ function FilterDropdown({ label, active, badge, icon, open, setOpen, name, child
|
||||
</Tooltip>
|
||||
)}
|
||||
{open && (
|
||||
<div className={`trek-popover-enter absolute ${wide ? 'start-0' : 'end-0'} top-full z-50 mt-1.5 flex max-h-[min(60vh,360px)] flex-col overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover`} style={{ width: 'max-content', minWidth: 180, maxWidth: 260 }}>
|
||||
<div className="trek-popover-enter absolute inset-x-0 top-full z-50 mt-1.5 flex max-h-[min(60vh,360px)] flex-col overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { MapPin, X } from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
|
||||
|
||||
// Over a map the field and its list take the map controls' frosted glass instead of a form box.
|
||||
const FLOATING_GLASS: React.CSSProperties = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
border: 'none',
|
||||
};
|
||||
|
||||
// The markup the planner's search fields share: the boxed input with its icon and
|
||||
// clear button (AirportSelect, LocationSelect), the floating list (all three) and
|
||||
@@ -15,6 +25,10 @@ interface SuggestionInputBoxProps {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
/** Shown as the clear button while a value is picked. */
|
||||
onClear?: () => void;
|
||||
/** Floats over a map: a frosted pill like the map controls. */
|
||||
floating?: boolean;
|
||||
/** Combobox wiring for a field that announces its list (role, aria-expanded, ...). */
|
||||
inputAria?: React.AriaAttributes & { role?: React.AriaRole };
|
||||
}
|
||||
|
||||
export function SuggestionInputBox({
|
||||
@@ -25,22 +39,37 @@ export function SuggestionInputBox({
|
||||
onOpen,
|
||||
onKeyDown,
|
||||
onClear,
|
||||
inputAria,
|
||||
floating = false,
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className="bg-surface-tertiary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
|
||||
style={
|
||||
floating
|
||||
? {
|
||||
...FLOATING_GLASS,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
height: 42,
|
||||
padding: '0 8px 0 14px',
|
||||
borderRadius: 999,
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<input
|
||||
type="text"
|
||||
{...inputAria}
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onType(e.target.value)}
|
||||
@@ -76,19 +105,32 @@ interface SuggestionListProps {
|
||||
/** Shows the loading line while there are no rows yet. */
|
||||
loading: boolean;
|
||||
rowCount: number;
|
||||
/** A line above the rows, such as "nothing found"; keeps the list open without rows. */
|
||||
status?: React.ReactNode;
|
||||
/** Wraps the rows in a listbox when the field is a combobox. */
|
||||
listbox?: { id: string; label: string };
|
||||
/** Floats over a map: the same frosted glass as the field above it. */
|
||||
floating?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** The floating list under the field. Rendered only while it is loading or has rows; the caller checks `open`. */
|
||||
export function SuggestionList({ loading, rowCount, children }: SuggestionListProps) {
|
||||
/** The floating list under the field. Rendered only while it is loading, has rows or a status; the caller checks `open`. */
|
||||
export function SuggestionList({
|
||||
loading,
|
||||
rowCount,
|
||||
status,
|
||||
listbox,
|
||||
floating = false,
|
||||
children,
|
||||
}: SuggestionListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!loading && rowCount === 0) return null;
|
||||
if (!loading && rowCount === 0 && !status) return null;
|
||||
return (
|
||||
<div
|
||||
className="bg-surface-card"
|
||||
className={floating ? undefined : 'bg-surface-card'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
top: floating ? 'calc(100% + 6px)' : 'calc(100% + 4px)',
|
||||
insetInline: 0,
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
@@ -96,14 +138,26 @@ export function SuggestionList({ loading, rowCount, children }: SuggestionListPr
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
...(floating ? { ...FLOATING_GLASS, borderRadius: 16, padding: 4 } : {}),
|
||||
}}
|
||||
>
|
||||
{loading && rowCount === 0 && (
|
||||
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
<div
|
||||
role={listbox ? 'status' : undefined}
|
||||
className="text-content-faint"
|
||||
style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{status}
|
||||
{listbox ? (
|
||||
<div id={listbox.id} role="listbox" aria-label={listbox.label}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,13 +168,18 @@ interface SuggestionRowProps {
|
||||
onHover: () => void;
|
||||
/** How the row lines up its icon and text. */
|
||||
align: 'center' | 'flex-start';
|
||||
/** Set inside a listbox: the row becomes an option the input can point at. */
|
||||
optionId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SuggestionRow({ active, onPick, onHover, align, children }: SuggestionRowProps) {
|
||||
export function SuggestionRow({ active, onPick, onHover, align, optionId, children }: SuggestionRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={optionId}
|
||||
role={optionId ? 'option' : undefined}
|
||||
aria-selected={optionId ? active : undefined}
|
||||
onClick={onPick}
|
||||
onMouseEnter={onHover}
|
||||
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Day plan rows: the controls that only show on hover take no room at rest
|
||||
either, so a row's text runs to the edge of its card. A place gives way while
|
||||
they are out (its one line truncates). A note's text wraps over several
|
||||
lines, so its controls fade in over the end of the text instead of
|
||||
reflowing it. Hover, keyboard focus and an open menu bring them out, the
|
||||
focus a click leaves on a row does not; on touch they stay in the row, like
|
||||
the reorder arrows (index.css). */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dp-row:not(:hover):not(:focus-visible):not(:has(:focus-visible)):not(:has([data-menu-open])) .dp-row-actions {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
/* The fade is the note's own surface: its tint over the day's tint (a plugin
|
||||
may set one) over the card, drawn as layers because a tint alone is
|
||||
see-through. All come from theme variables, so it matches in light and
|
||||
dark and for every note colour. */
|
||||
.dp-note-actions {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-inline: 32px 4px;
|
||||
border-start-end-radius: 11px;
|
||||
border-end-end-radius: 11px;
|
||||
background:
|
||||
linear-gradient(to left, var(--dp-note-tint) 60%, transparent),
|
||||
linear-gradient(to left, var(--dp-day-tint, transparent) 60%, transparent),
|
||||
linear-gradient(to left, var(--bg-card) 60%, transparent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
[dir='rtl'] .dp-note-actions {
|
||||
background:
|
||||
linear-gradient(to right, var(--dp-note-tint) 60%, transparent),
|
||||
linear-gradient(to right, var(--dp-day-tint, transparent) 60%, transparent),
|
||||
linear-gradient(to right, var(--bg-card) 60%, transparent);
|
||||
}
|
||||
.dp-row:hover .dp-note-actions,
|
||||
.dp-row:focus-visible .dp-note-actions,
|
||||
.dp-row:has(:focus-visible) .dp-note-actions,
|
||||
.dp-row:has([data-menu-open]) .dp-note-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The add-booking ticket beside a place's arrows and "...": out with the row's
|
||||
hover in CSS like them. A React hover state re-rendered the whole day list
|
||||
first, so it always came out a beat behind the others. A picked place shows
|
||||
no controls of its own; on touch, with no hover, the ticket stays out. */
|
||||
.dp-add-booking {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.dp-row:hover .dp-add-booking,
|
||||
.dp-row:focus-visible .dp-add-booking,
|
||||
.dp-row:has(:focus-visible) .dp-add-booking,
|
||||
.dp-row:has([data-menu-open]) .dp-add-booking {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.dp-add-booking {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
/**
|
||||
* The four surfaces a note colour produces (#1629).
|
||||
*
|
||||
@@ -34,3 +36,12 @@ export function noteSurface(color: string | null | undefined): NoteSurface {
|
||||
iconColor: color,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's activity area in the plan: its tint as the background, and named as
|
||||
* a variable for the note fades drawn over it (dayPlanRows.css), so a fade
|
||||
* matches a day a plugin has tinted too.
|
||||
*/
|
||||
export function dayBodyStyle(tint: string | null | undefined): CSSProperties {
|
||||
return { background: tint ?? undefined, '--dp-day-tint': tint ?? 'transparent' } as CSSProperties
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ export interface MenuEntry {
|
||||
/**
|
||||
* The "…" of a row or card: the actions a right-click also offers, for the
|
||||
* mouse that doesn't right-click and for the keyboard. Quiet at rest, it comes
|
||||
* forward with its row's hover or focus (the row carries the `group` class).
|
||||
* forward with its row's hover or keyboard focus (the row carries the `group`
|
||||
* class). Keyboard focus only: a row a click selected keeps the focus, and the
|
||||
* button stayed out on it after the mouse had left.
|
||||
*/
|
||||
export function MoreButton({ label, items, size = 26, alwaysVisible = false, className = '' }: {
|
||||
label: string
|
||||
@@ -66,8 +68,8 @@ export function MoreButton({ label, items, size = 26, alwaysVisible = false, cla
|
||||
<>
|
||||
<Tooltip label={label} disabled={!!menu.menu}>
|
||||
<button type="button" aria-label={label} aria-haspopup="menu"
|
||||
onClick={e => menu.open(e, entries, true)}
|
||||
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
|
||||
onClick={e => menu.open(e, entries, true)} data-menu-open={menu.menu ? '' : undefined}
|
||||
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 group-has-[:focus-visible]:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
|
||||
style={{ width: size, height: size }}>
|
||||
<MoreHorizontal size={15} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -98,7 +100,7 @@ export function TimePill({ children, className = '', quiet = false }: { children
|
||||
return <WhiteBadge className={className} quiet={quiet} icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{children}</WhiteBadge>
|
||||
}
|
||||
|
||||
export type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
|
||||
type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
|
||||
|
||||
// White badges on whatever tint the row has; the tone only colours the words and the icon.
|
||||
const PILL_TONES: Record<PillTone, string> = {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 42,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: 53,
|
||||
break_additional_minutes: 10,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
describe('RC-01 Tour day-row facts', () => {
|
||||
it('selects controlled type icons without enabling any types', () => {
|
||||
expect(tourDayRowIcon('hike')).toBe(Mountain);
|
||||
expect(tourDayRowIcon('bike')).toBe(Bike);
|
||||
expect(tourDayRowIcon('walk')).toBe(Footprints);
|
||||
expect(tourDayRowIcon('unknown')).toBe(Route);
|
||||
render(<TourDayRowIcon type="hike" />);
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
|
||||
render(<TourDayRowFacts tour={tour} mobile={mobile} />);
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
|
||||
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveAccessibleName('Breaks / additional time: 10 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
|
||||
expect(screen.queryByText('80 m')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([null, 0])('omits missing metrics and empty breaks (%s)', (breaks) => {
|
||||
render(
|
||||
<TourDayRowFacts tour={{ ...tour, distance: null, elevation_gain: null, break_additional_minutes: breaks }} />
|
||||
);
|
||||
expect(screen.queryByTestId('plan-tour-distance')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plan-tour-ascent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('53 min');
|
||||
});
|
||||
|
||||
it('supports hover, keyboard focus and touch help without opening the containing row', async () => {
|
||||
const openRow = vi.fn();
|
||||
render(
|
||||
<div onClick={openRow}>
|
||||
<TourDayRowFacts tour={tour} />
|
||||
</div>
|
||||
);
|
||||
const walking = screen.getByTestId('trip-plan-tour-walking-time');
|
||||
fireEvent.mouseEnter(walking);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
|
||||
fireEvent.mouseLeave(walking);
|
||||
const matches = vi.spyOn(walking, 'matches').mockImplementation((selector) => selector === ':focus-visible');
|
||||
fireEvent.focus(walking);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
|
||||
fireEvent.blur(walking);
|
||||
matches.mockRestore();
|
||||
fireEvent.click(walking);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('Walking time: 53 min');
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
|
||||
return (
|
||||
<span
|
||||
data-testid="plan-tour-icon"
|
||||
aria-label={known ? t(`tourTypes.${type}`) : t('tours.mode.tours')}
|
||||
className="grid flex-none place-items-center rounded-full"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{createElement(tourDayRowIcon(type), { size: Math.round(size * 0.6), 'aria-hidden': 'true' })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
mobile,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
testId: string;
|
||||
mobile: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span className="relative inline-flex max-w-full">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-[6px] px-1.5 py-0.5 text-caption tabular-nums focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${mobile ? 'bg-[color:var(--m-ic)] text-m-muted' : 'bg-surface-card text-content-secondary'}`}
|
||||
>
|
||||
<Icon size={11} aria-hidden="true" />
|
||||
{value}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span
|
||||
id={id}
|
||||
hidden={!open}
|
||||
role="note"
|
||||
className={`absolute start-0 top-full z-30 mt-1 w-40 whitespace-normal rounded-[6px] border p-2 text-caption shadow-sm ${mobile ? 'bg-[color:var(--m-ic)] text-m-ink' : 'border-edge bg-surface-card text-content'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
|
||||
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
|
||||
facts.push({ icon, value, label, testId });
|
||||
if (tour.distance != null)
|
||||
add(Ruler, formatDistance(tour.distance, unit), t('tours.detail.distance'), 'plan-tour-distance');
|
||||
if (tour.elevation_gain != null)
|
||||
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
|
||||
if (times.walkingMinutes != null)
|
||||
add(
|
||||
Footprints,
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
t('tours.planner.inspector.duration'),
|
||||
'trip-plan-tour-walking-time'
|
||||
);
|
||||
if (times.breakMinutes != null && times.breakMinutes > 0)
|
||||
add(
|
||||
Pause,
|
||||
formatPlannedTourDuration(times.breakMinutes),
|
||||
t('tours.planner.breaksAdditional'),
|
||||
'trip-plan-tour-breaks'
|
||||
);
|
||||
if (times.plannedTotalMinutes != null)
|
||||
add(
|
||||
Clock,
|
||||
formatPlannedTourDuration(times.plannedTotalMinutes),
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
return (
|
||||
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{facts.map((fact) => (
|
||||
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,43 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
render(
|
||||
<TourDetailDialog
|
||||
{...defaultProps}
|
||||
tour={{ ...tour, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
|
||||
place={{ ...place, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
|
||||
expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('RS-02: labels calculated walking time and planned total as separate values', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
|
||||
|
||||
expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
|
||||
expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
|
||||
render(
|
||||
<TourDetailDialog
|
||||
{...defaultProps}
|
||||
tour={{ ...tour, website: 'javascript:alert(1)' }}
|
||||
place={{ ...place, website: 'javascript:alert(1)' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses existing assignment callbacks for add and remove actions', () => {
|
||||
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronUp, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronUp, ExternalLink, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -22,7 +22,7 @@ import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||
import { filesForPlace } from '../../utils/placeFiles'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'
|
||||
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
|
||||
import { TourMetricFields, TourNotice, TourTile } from './tourParts'
|
||||
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
|
||||
|
||||
@@ -137,6 +137,8 @@ export default function TourDetailDialog({
|
||||
const navigationTargets = getNavigationTargets(place)
|
||||
const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')
|
||||
const source = tourSource(tour)
|
||||
const description = tour.description !== undefined ? tour.description : place.description
|
||||
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website)
|
||||
const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto')
|
||||
|
||||
const saveName = async () => {
|
||||
@@ -217,7 +219,7 @@ export default function TourDetailDialog({
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>{t(`tourTypes.${tour.tour_type}`)}</SoftPill>
|
||||
{tour.difficulty && <SoftPill>{t('tours.detail.difficulty')}: {tour.difficulty}</SoftPill>}
|
||||
{tour.duration != null && <TimePill>{t('tours.durationMinutes', { count: tour.duration })}</TimePill>}
|
||||
<TourTimePills tour={tour} />
|
||||
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
|
||||
{tour.caution && (
|
||||
<Tooltip label={t('tours.caution.tooltip')}>
|
||||
@@ -313,7 +315,15 @@ export default function TourDetailDialog({
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
{place.description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{place.description}</MarkdownBox></DialogSection>}
|
||||
{description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{description}</MarkdownBox></DialogSection>}
|
||||
{website && (
|
||||
<DialogSection className="flex-none" label={t('places.formWebsite')}>
|
||||
<a href={website.href} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1.5 text-content hover:underline">
|
||||
<ExternalLink size={13} className="flex-none" />
|
||||
<span className="truncate">{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}</span>
|
||||
</a>
|
||||
</DialogSection>
|
||||
)}
|
||||
{place.notes && <DialogSection className="flex-none" label={t('places.formNotes')}><MarkdownBox>{place.notes}</MarkdownBox></DialogSection>}
|
||||
{assignment?.notes && <DialogSection className="flex-none" label={t('places.assignmentNotes')}><MarkdownBox>{assignment.notes}</MarkdownBox></DialogSection>}
|
||||
|
||||
@@ -365,6 +375,37 @@ export default function TourDetailDialog({
|
||||
)
|
||||
}
|
||||
|
||||
/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */
|
||||
function TourTimePills({ tour }: { tour: TourListItem }) {
|
||||
const { t } = useTranslation()
|
||||
const tourTimes = tourPlannedTimes(tour)
|
||||
return (
|
||||
<>
|
||||
{tourTimes.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
|
||||
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
|
||||
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
|
||||
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */
|
||||
function MarkdownBox({ children }: { children: string }) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { TourFactPills, TourTile } from './tourParts'
|
||||
import { SoftPill } from '../Planner/planParts'
|
||||
import { TourFactPills, TourGradeTile } from './tourParts'
|
||||
import { tourWebsitePresentation } from './tourPresentation'
|
||||
|
||||
interface TourListRowProps {
|
||||
tour: TourListItem
|
||||
@@ -16,6 +19,7 @@ interface TourListRowProps {
|
||||
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
|
||||
const nameId = useId()
|
||||
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
|
||||
const website = tourWebsitePresentation(tour.website)
|
||||
return (
|
||||
<li
|
||||
role="option"
|
||||
@@ -33,18 +37,31 @@ export default function TourListRow({ tour, selected = false, disabled = false,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<TourTile />
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
|
||||
<TourFactPills tour={tour} className="mt-1.5" />
|
||||
</div>
|
||||
{/* The tile, the name and the day actions share the head line; the rest runs the
|
||||
full width under them, below the tile too. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<TourGradeTile tour={tour} />
|
||||
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
|
||||
{action && (
|
||||
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5 pt-1" onClick={event => event.stopPropagation()}>
|
||||
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tour.description && (
|
||||
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
|
||||
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{tour.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
|
||||
{website && (
|
||||
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
</TourFactPills>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -56,6 +56,17 @@ describe('ToursSidebar', () => {
|
||||
expect(row).toHaveClass('bg-surface-selected')
|
||||
})
|
||||
|
||||
it('RS-01: previews the Tour description and classified information-link host', () => {
|
||||
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
||||
expect(row).toHaveTextContent('A quiet path above the lake')
|
||||
expect(row).toHaveTextContent('Komoot')
|
||||
expect(row).not.toHaveTextContent('komoot.com')
|
||||
expect(row).toHaveTextContent('1 h 35 min')
|
||||
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
|
||||
})
|
||||
|
||||
it('selects the tour from keyboard activation', () => {
|
||||
const onSelectTour = vi.fn()
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={null} onAssignToDay={vi.fn()} onSelectTour={onSelectTour} />)
|
||||
@@ -92,7 +103,7 @@ describe('ToursSidebar', () => {
|
||||
expect(labels[0]).toContain('1.9 km')
|
||||
expect(labels[1]).toContain('13 m')
|
||||
expect(labels[2]).toContain('11 m')
|
||||
expect(screen.getByText('TREK')).toBeInTheDocument()
|
||||
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
|
||||
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
|
||||
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
|
||||
@@ -102,6 +113,16 @@ describe('ToursSidebar', () => {
|
||||
expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no T-badge for an imported tour that carries no difficulty', () => {
|
||||
const imported = { ...tour, name: 'Imported GPX walk', has_waypoints: false, max_hiking_difficulty: null }
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[imported]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
|
||||
const metrics = within(screen.getByRole('option', { name: /Imported GPX walk/ })).getByTestId('tour-metrics')
|
||||
expect(metrics).not.toHaveTextContent(/T\d/)
|
||||
expect(metrics).not.toHaveTextContent('Tnull')
|
||||
expect(within(metrics).queryByLabelText(/^T\d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the post-write refresh to the shared assignment handler', async () => {
|
||||
let resolveAssignment!: () => void
|
||||
const assignment = new Promise<void>(resolve => { resolveAssignment = resolve })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hikingTimeMinutes } from './hikingTime';
|
||||
|
||||
describe('hikingTimeMinutes', () => {
|
||||
it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => {
|
||||
expect(hikingTimeMinutes(8, 0, 0)).toBe(120);
|
||||
});
|
||||
|
||||
it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => {
|
||||
// 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance.
|
||||
expect(hikingTimeMinutes(2, 450, 0)).toBe(105);
|
||||
});
|
||||
|
||||
it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => {
|
||||
// 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height.
|
||||
expect(hikingTimeMinutes(12, 300, 500)).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Walking time the way hiking signposts in the Alps state it (DIN 33466, as the
|
||||
* DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of
|
||||
* climb and 500 m of descent an hour, and of the time for the distance and the
|
||||
* time for the height only the larger counts in full, the smaller by half.
|
||||
*
|
||||
* The router's own estimate is a walking pace along the line and barely sees a
|
||||
* slope, so a short steep climb came out far too quick.
|
||||
*/
|
||||
const FLAT_KM_PER_HOUR = 4;
|
||||
const CLIMB_M_PER_HOUR = 300;
|
||||
const DESCENT_M_PER_HOUR = 500;
|
||||
|
||||
export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number {
|
||||
const flat = distanceKm / FLAT_KM_PER_HOUR;
|
||||
const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR;
|
||||
const hours = Math.max(flat, height) + Math.min(flat, height) / 2;
|
||||
return Math.round(hours * 60);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { INPUT, LABEL } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
|
||||
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
interface TourFieldProps {
|
||||
planner: TourPlannerController;
|
||||
/** True while the viewer may not edit or a save is in flight. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** The tallest the description grows before it scrolls, about ten lines. */
|
||||
const DESCRIPTION_MAX_HEIGHT = 240;
|
||||
const DETAILS_OPEN_KEY = 'trek.tours.planner.detailsOpen';
|
||||
|
||||
function readDetailsOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DETAILS_OPEN_KEY) !== 'false';
|
||||
} catch {
|
||||
// Storage blocked (private window, preview): the section starts open.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDetailsOpen(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(DETAILS_OPEN_KEY, String(open));
|
||||
} catch {
|
||||
// Storage blocked: the choice lasts as long as the panel does.
|
||||
}
|
||||
}
|
||||
|
||||
/** A field label with what belongs beside it (an info dot, a badge) on the same line. */
|
||||
function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
|
||||
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor={htmlFor}>
|
||||
{children}
|
||||
</label>
|
||||
{aside}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }: { id: string; children: ReactNode }) {
|
||||
return (
|
||||
<span id={id} role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TourNameField({ planner, locked }: TourFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-name">
|
||||
{t('tours.planner.name')}
|
||||
</label>
|
||||
<input
|
||||
id="tour-planner-name"
|
||||
disabled={locked}
|
||||
value={planner.name}
|
||||
onChange={(event) => {
|
||||
if (!locked) planner.setName(event.target.value);
|
||||
}}
|
||||
maxLength={255}
|
||||
placeholder={t('tours.planner.namePlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration typed as "2:56", "2 h 56" or "176" minutes and shown as "2 h 56 min".
|
||||
* A readable value reaches the planner while it is typed; text that is no
|
||||
* duration reaches it as NaN on leaving the field, so the planner marks it
|
||||
* invalid and holds Save back, and the text stays for the user to fix.
|
||||
*/
|
||||
function TourDurationInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
invalid,
|
||||
errorId,
|
||||
locked,
|
||||
onCommit,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
invalid: boolean;
|
||||
errorId: string;
|
||||
locked: boolean;
|
||||
onCommit: (minutes: number | null) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const showDraft = draft !== null && (focused || (value !== null && Number.isNaN(value)));
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={label}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
maxLength={TOUR_DURATION_INPUT_MAX_LENGTH}
|
||||
placeholder="0:00"
|
||||
disabled={locked}
|
||||
value={showDraft ? draft : formatTourDuration(value)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
setDraft(formatTourDuration(value));
|
||||
}}
|
||||
onChange={(event) => {
|
||||
if (locked) return;
|
||||
const text = event.target.value;
|
||||
setDraft(text);
|
||||
const parsed = parseTourDuration(text);
|
||||
if (parsed !== 'invalid') onCommit(parsed);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
if (draft === null) return;
|
||||
if (!locked && parseTourDuration(draft) === 'invalid') onCommit(Number.NaN);
|
||||
else setDraft(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? errorId : undefined}
|
||||
className={`${INPUT} tabular-nums`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Breaks on top of the walking time, and the planned total that defaults to their sum, side by side. */
|
||||
function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const manual = planner.plannedDurationMinutes != null;
|
||||
return (
|
||||
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-breaks"
|
||||
aside={
|
||||
<InfoDot
|
||||
label={t('tours.planner.breaksAdditionalInfoLabel')}
|
||||
description={t('tours.planner.breaksAdditionalInfo')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('tours.planner.breaksShort')}
|
||||
</FieldLabel>
|
||||
<TourDurationInput
|
||||
id="tour-planner-breaks"
|
||||
label={t('tours.planner.breaksAdditional')}
|
||||
value={planner.breakAdditionalMinutes}
|
||||
invalid={planner.breakAdditionalInvalid}
|
||||
errorId="tour-planner-breaks-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setBreakAdditionalMinutes}
|
||||
/>
|
||||
{planner.breakAdditionalInvalid && (
|
||||
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-planned-duration"
|
||||
aside={
|
||||
<>
|
||||
<InfoDot
|
||||
label={t('tours.planner.plannedTotalInfoLabel')}
|
||||
description={t('tours.planner.plannedTotalInfo')}
|
||||
/>
|
||||
{manual && (
|
||||
<span data-testid="tour-planner-planned-manual" className="ms-auto inline-flex min-w-0">
|
||||
<SoftPill tone="info">{t('tours.planner.plannedTotalManual')}</SoftPill>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}
|
||||
</FieldLabel>
|
||||
<TourDurationInput
|
||||
id="tour-planner-planned-duration"
|
||||
label={t('tours.planner.plannedTotalDuration')}
|
||||
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
|
||||
invalid={planner.plannedDurationInvalid}
|
||||
errorId="tour-planner-planned-duration-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setPlannedDurationMinutes}
|
||||
/>
|
||||
{planner.plannedDurationInvalid && (
|
||||
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The description, growing with its text up to about ten lines before it scrolls. */
|
||||
function TourDescriptionField({ planner, locked }: TourFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const value = planner.description;
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const borders = element.offsetHeight - element.clientHeight;
|
||||
element.style.height = 'auto';
|
||||
const wanted = element.scrollHeight + borders;
|
||||
element.style.height = `${Math.min(wanted, DESCRIPTION_MAX_HEIGHT)}px`;
|
||||
element.style.overflowY = wanted > DESCRIPTION_MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}, [value]);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-description">
|
||||
{t('places.formDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={ref}
|
||||
id="tour-planner-description"
|
||||
data-testid="tour-planner-description"
|
||||
disabled={locked}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (!locked) planner.setDescription(event.target.value);
|
||||
}}
|
||||
maxLength={2000}
|
||||
rows={2}
|
||||
className={`${INPUT} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TourWebsiteField({ planner, locked }: TourFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-website">
|
||||
{t('places.formWebsite')}
|
||||
</label>
|
||||
<input
|
||||
id="tour-planner-website"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
disabled={locked}
|
||||
value={planner.website}
|
||||
onChange={(event) => {
|
||||
if (!locked) planner.setWebsite(event.target.value);
|
||||
}}
|
||||
maxLength={500}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
aria-invalid={planner.websiteInvalid || undefined}
|
||||
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
|
||||
className={INPUT}
|
||||
/>
|
||||
{planner.websiteInvalid && (
|
||||
<FieldError id="tour-planner-website-error">{t('dawarich.error.invalid_url')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The difficulty cap, with the routing safety note behind the info dot beside its label. */
|
||||
function TourDifficultyField({
|
||||
planner,
|
||||
locked,
|
||||
onDifficultyChange,
|
||||
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-difficulty"
|
||||
aside={<InfoDot label={t('tours.planner.safetyInfoLabel')} description={t('tours.planner.safetyNote')} />}
|
||||
>
|
||||
{t('tours.planner.maxDifficulty')}
|
||||
</FieldLabel>
|
||||
<CustomSelect
|
||||
id="tour-planner-difficulty"
|
||||
disabled={locked}
|
||||
ariaLabel={t('tours.planner.maxDifficulty')}
|
||||
value={planner.maxHikingDifficulty}
|
||||
onChange={(nextValue) => {
|
||||
if (!locked) onDifficultyChange(Number(nextValue) as Difficulty);
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5, 6].map((value) => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{planner.maxHikingDifficulty === 3 && (
|
||||
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.planner.difficulty.t3Warning')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Description, website, the difficulty cap and the time, folded away with the
|
||||
* chevron. The section opens by itself while one of its fields holds a value
|
||||
* the planner refuses, so the reason Save is held back is never out of sight.
|
||||
*/
|
||||
export function TourDetailsSection({
|
||||
planner,
|
||||
locked,
|
||||
onDifficultyChange,
|
||||
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(readDetailsOpen);
|
||||
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
|
||||
const expanded = open || forcedOpen;
|
||||
const toggle = () => {
|
||||
const next = !expanded;
|
||||
setOpen(next);
|
||||
writeDetailsOpen(next);
|
||||
};
|
||||
return (
|
||||
<TourSection
|
||||
label={<span className="text-content">{t('tours.planner.details')}</span>}
|
||||
action={
|
||||
<FoldButton
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
controls="tour-planner-details"
|
||||
label={t(expanded ? 'tours.planner.collapseDetails' : 'tours.planner.expandDetails')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div id="tour-planner-details" hidden={!expanded} className="flex flex-col gap-3">
|
||||
{expanded && (
|
||||
<>
|
||||
<TourDescriptionField planner={planner} locked={locked} />
|
||||
<TourWebsiteField planner={planner} locked={locked} />
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
<TourTimeFields planner={planner} locked={locked} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TourSection>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
|
||||
import type { TourPlannerController } from './useTourPlanner'
|
||||
import type { Day } from '../../../types'
|
||||
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
|
||||
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
|
||||
|
||||
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
@@ -48,6 +49,20 @@ function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerCon
|
||||
maxHikingDifficulty: 2,
|
||||
setMaxHikingDifficulty: vi.fn(),
|
||||
name: '',
|
||||
description: '',
|
||||
setDescription: vi.fn(),
|
||||
website: '',
|
||||
setWebsite: vi.fn(),
|
||||
websiteInvalid: false,
|
||||
plannedDurationMinutes: null,
|
||||
setPlannedDurationMinutes: vi.fn(),
|
||||
plannedDurationInvalid: false,
|
||||
breakAdditionalMinutes: null,
|
||||
setBreakAdditionalMinutes: vi.fn(),
|
||||
breakAdditionalInvalid: false,
|
||||
walkingDurationMinutes: null,
|
||||
plannedTotalMinutes: null,
|
||||
plannedTotalInvalid: false,
|
||||
startNewTour: vi.fn(),
|
||||
newTourConfirmationOpen: false,
|
||||
cancelNewTour: vi.fn(),
|
||||
@@ -65,6 +80,9 @@ function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerCon
|
||||
editingPlaceId: null,
|
||||
openingTourId: null,
|
||||
openTour: vi.fn().mockResolvedValue(true),
|
||||
reorderWaypoint: vi.fn(),
|
||||
moveWaypoint: vi.fn(),
|
||||
removeWaypoint: vi.fn(),
|
||||
saveOutcome: null,
|
||||
hasUnsavedChanges: false,
|
||||
discard: vi.fn(),
|
||||
@@ -92,7 +110,17 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('TourPlannerToursRail', () => {
|
||||
it('disables saved-tour switching and Plan another while a Save is pending', () => {
|
||||
it('RS-02: keeps calculated Walking time visible in the right Tour metrics rail', () => {
|
||||
render(<TourPlannerToursRail planner={planner({
|
||||
mode: { type: 'new-draft' }, durationSeconds: 4800,
|
||||
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
|
||||
})} tours={[tour]} {...railProps} />)
|
||||
|
||||
expect(screen.getByText(/^(Estimated walking time|Est. walking time)$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables saved-tour switching while a Save is pending', () => {
|
||||
const controller = planner({
|
||||
isSaving: true,
|
||||
saveOutcome: tour,
|
||||
@@ -107,14 +135,12 @@ describe('TourPlannerToursRail', () => {
|
||||
expect(row).toHaveAttribute('tabindex', '-1')
|
||||
fireEvent.click(row)
|
||||
expect(controller.openTour).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Plan another' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-001: shows saved Tours rows and route stats without hiding the safety note in navigation', () => {
|
||||
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('TREK')
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
|
||||
expect(screen.getByText('Ascent')).toBeInTheDocument()
|
||||
expect(screen.getByText('50 m')).toBeInTheDocument()
|
||||
@@ -122,6 +148,19 @@ describe('TourPlannerToursRail', () => {
|
||||
expect(screen.queryByText('Routing is planning assistance, not a safety guarantee.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-020: warns over the overview when the route climbs a via ferrata, with the stretch in the tooltip', () => {
|
||||
publishTourFerrataCrossings([{ id: 'group/way/1', name: 'Alpspitz Ferrata', grade: 'K2', fromM: 1000, toM: 1600, afterWaypoint: null }])
|
||||
try {
|
||||
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
|
||||
const badges = screen.getByTestId('tour-ferrata-crossings')
|
||||
expect(badges).toHaveTextContent('Via ferrata K2')
|
||||
expect(within(badges).getByLabelText(/climbs 600 m of “Alpspitz Ferrata”/)).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('[data-profile-band]').length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
publishTourFerrataCrossings([])
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes permanent deletion in the visible TOUR-PLANNER Tours rail', () => {
|
||||
const onDeleteTour = vi.fn()
|
||||
render(<TourPlannerToursRail planner={planner()} tours={[tour]} {...railProps} canEdit onDeleteTour={onDeleteTour} />)
|
||||
@@ -149,11 +188,9 @@ describe('TourPlannerToursRail', () => {
|
||||
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
|
||||
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
|
||||
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Plan another' })).not.toBeInTheDocument()
|
||||
expect(onAssignToDay).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }))
|
||||
expect(controller.startNewTour).toHaveBeenCalledOnce()
|
||||
expect(controller.discard).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' }))
|
||||
const dayOption = screen.getByRole('button', { name: /Summit day/ })
|
||||
@@ -172,7 +209,6 @@ describe('TourPlannerToursRail', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Summit day/ }))
|
||||
|
||||
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
|
||||
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled()
|
||||
})
|
||||
@@ -183,7 +219,7 @@ describe('TourPlannerToursRail', () => {
|
||||
const onViewGpxTour = vi.fn()
|
||||
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
|
||||
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX import')
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX')
|
||||
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
|
||||
expect(onViewGpxTour).not.toHaveBeenCalled()
|
||||
expect(controller.openTour).not.toHaveBeenCalled()
|
||||
@@ -289,6 +325,223 @@ describe('TourPlannerToursRail', () => {
|
||||
})
|
||||
|
||||
describe('TourPlannerRail', () => {
|
||||
it('RS-01: edits Tour description and HTTPS information link without exposing editable fields in GPX mode', () => {
|
||||
const setDescription = vi.fn()
|
||||
const setWebsite = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TourPlannerRail
|
||||
planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, setDescription, setWebsite })}
|
||||
/>
|
||||
)
|
||||
const description = screen.getByLabelText('Description')
|
||||
const website = screen.getByLabelText('Website')
|
||||
fireEvent.change(description, { target: { value: 'A quiet ridge route' } })
|
||||
fireEvent.change(website, { target: { value: 'https://www.komoot.com/tour/42' } })
|
||||
expect(setDescription).toHaveBeenCalledWith('A quiet ridge route')
|
||||
expect(setWebsite).toHaveBeenCalledWith('https://www.komoot.com/tour/42')
|
||||
|
||||
rerender(
|
||||
<TourPlannerRail
|
||||
planner={planner({
|
||||
mode: { type: 'view-gpx', placeId: 42, tour },
|
||||
readOnlyGpxTour: { tour, routeGeometry: null },
|
||||
waypoints: [],
|
||||
websiteInvalid: true,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByLabelText('Description')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Website')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-02: edits breaks and planned total directly, and clearing total returns it to automatic', () => {
|
||||
const setPlannedDurationMinutes = vi.fn()
|
||||
const setBreakAdditionalMinutes = vi.fn()
|
||||
const controller = planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, durationSeconds: 3600,
|
||||
walkingDurationMinutes: 60, breakAdditionalMinutes: 35, setBreakAdditionalMinutes,
|
||||
plannedDurationMinutes: null, plannedTotalMinutes: 95, setPlannedDurationMinutes,
|
||||
})
|
||||
const { rerender } = render(<TourPlannerRail planner={controller} />)
|
||||
|
||||
expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
|
||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-2')
|
||||
const breaks = screen.getByLabelText('Breaks / additional time')
|
||||
// A duration field shows "1 h 35 min" and reads minutes, a clock or units back.
|
||||
expect(breaks).toHaveValue('35 min')
|
||||
fireEvent.change(breaks, { target: { value: '50' } })
|
||||
expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
|
||||
fireEvent.change(breaks, { target: { value: '' } })
|
||||
expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
|
||||
fireEvent.change(breaks, { target: { value: '50' } })
|
||||
const input = screen.getByLabelText('Planned total duration')
|
||||
expect(input).toHaveValue('1 h 35 min')
|
||||
expect(controller.durationSeconds).toBe(3600)
|
||||
fireEvent.change(input, { target: { value: '2:05' } })
|
||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 125,
|
||||
setPlannedDurationMinutes,
|
||||
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
|
||||
})} />)
|
||||
const overriddenInput = screen.getByLabelText(/Planned total duration/)
|
||||
expect(overriddenInput).toHaveValue('2 h 5 min')
|
||||
expect(screen.getByTestId('tour-planner-planned-manual')).toHaveTextContent('Manual override')
|
||||
fireEvent.change(overriddenInput, { target: { value: '' } })
|
||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 1441,
|
||||
plannedDurationInvalid: true,
|
||||
})} />)
|
||||
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, breakAdditionalMinutes: 1441,
|
||||
breakAdditionalInvalid: true,
|
||||
})} />)
|
||||
expect(screen.getByLabelText('Breaks / additional time')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
|
||||
})
|
||||
|
||||
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
|
||||
render(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
|
||||
breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
|
||||
})} />)
|
||||
|
||||
const breaksInfo = screen.getByRole('button', { name: 'About breaks and additional time' })
|
||||
expect(breaksInfo.tabIndex).toBe(0)
|
||||
expect(breaksInfo).not.toHaveAttribute('aria-expanded')
|
||||
expect(breaksInfo).toHaveAccessibleDescription('Optional whole minutes added to calculated walking time.')
|
||||
expect(breaksInfo).toHaveClass('focus-visible:outline')
|
||||
fireEvent.mouseEnter(breaksInfo)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Optional whole minutes added to calculated walking time.')
|
||||
fireEvent.mouseLeave(breaksInfo)
|
||||
|
||||
// A click shows nothing beyond the tooltip: no explanation unfolds in the form.
|
||||
fireEvent.click(breaksInfo)
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument()
|
||||
|
||||
const totalInfo = screen.getByRole('button', { name: 'About planned total duration' })
|
||||
expect(totalInfo).toHaveAccessibleDescription('Calculated planned total duration.')
|
||||
})
|
||||
|
||||
it('RS-01: marks unsafe website values invalid and does not offer Save', () => {
|
||||
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, website: 'http://example.org', websiteInvalid: true })} />)
|
||||
expect(screen.getByLabelText('Website')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('TREK cannot use that address.')
|
||||
})
|
||||
|
||||
const reorderWaypoints = [
|
||||
{ id: 'a', lat: 48, lng: 11, role: 'start' as const },
|
||||
{ id: 'b', lat: 48.1, lng: 11.1, role: 'via' as const },
|
||||
{ id: 'c', lat: 48.2, lng: 11.2, role: 'via' as const },
|
||||
{ id: 'd', lat: 48.3, lng: 11.3, role: 'end' as const },
|
||||
];
|
||||
|
||||
it('RC-07: dragging before and after rows commits only on drop by stable waypoint ids', () => {
|
||||
const reorderWaypoint = vi.fn();
|
||||
render(
|
||||
<TourPlannerRail
|
||||
planner={planner({
|
||||
mode: { type: 'new-draft' },
|
||||
hasUnsavedChanges: true,
|
||||
waypoints: reorderWaypoints,
|
||||
reorderWaypoint,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
|
||||
const dispatchDrag = (type: string, target: HTMLElement, dataTransfer: typeof transfer, clientY = 0) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
|
||||
Object.defineProperty(event, 'clientY', { value: clientY });
|
||||
fireEvent(target, event);
|
||||
};
|
||||
for (const row of [rows[1], rows[2]]) {
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
height: 20,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
dispatchDrag('dragstart', within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), transfer);
|
||||
dispatchDrag('dragover', rows[2], transfer, 1);
|
||||
expect(reorderWaypoint).not.toHaveBeenCalled();
|
||||
dispatchDrag('drop', rows[2], transfer, 1);
|
||||
expect(reorderWaypoint).toHaveBeenCalledOnce();
|
||||
expect(reorderWaypoint).toHaveBeenLastCalledWith('a', 'c', 'before');
|
||||
|
||||
reorderWaypoint.mockClear();
|
||||
const lastTransfer = { ...transfer, getData: vi.fn(() => 'd') };
|
||||
dispatchDrag('dragstart', within(rows[3]).getByTestId('tour-planner-waypoint-drag-handle'), lastTransfer);
|
||||
dispatchDrag('dragover', rows[1], lastTransfer, 15);
|
||||
expect(reorderWaypoint).not.toHaveBeenCalled();
|
||||
dispatchDrag('drop', rows[1], lastTransfer, 15);
|
||||
expect(reorderWaypoint).toHaveBeenCalledOnce();
|
||||
expect(reorderWaypoint).toHaveBeenLastCalledWith('d', 'b', 'after');
|
||||
});
|
||||
|
||||
it('RC-07: cancelled drags do not reorder and keyboard arrow controls remain buttons', async () => {
|
||||
const user = userEvent.setup();
|
||||
const reorderWaypoint = vi.fn();
|
||||
const moveWaypoint = vi.fn();
|
||||
render(
|
||||
<TourPlannerRail
|
||||
planner={planner({
|
||||
mode: { type: 'new-draft' },
|
||||
hasUnsavedChanges: true,
|
||||
waypoints: reorderWaypoints,
|
||||
reorderWaypoint,
|
||||
moveWaypoint,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
|
||||
Object.defineProperty(rows[2], 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
height: 20,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.dragStart(within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), { dataTransfer: transfer });
|
||||
fireEvent.dragOver(rows[2], { dataTransfer: transfer, clientY: -1 });
|
||||
fireEvent.dragEnd(rows[0], { dataTransfer: transfer });
|
||||
expect(reorderWaypoint).not.toHaveBeenCalled();
|
||||
|
||||
const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0];
|
||||
expect(moveDown).toBeEnabled();
|
||||
moveDown.focus();
|
||||
await user.keyboard(' ');
|
||||
expect(moveWaypoint).toHaveBeenCalledWith('a', 1);
|
||||
expect(reorderWaypoint).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables draft mutation and replacement controls while saving', () => {
|
||||
const controller = planner({
|
||||
mode: { type: 'new-draft' },
|
||||
@@ -316,13 +569,13 @@ describe('TourPlannerRail', () => {
|
||||
|
||||
expect(screen.getByLabelText('Tour name')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Maximum trail difficulty' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Save tour' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Back to Tours' })).toBeDisabled()
|
||||
expect(screen.getAllByLabelText('Move waypoint up')).toHaveLength(2)
|
||||
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Saving…')
|
||||
expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => {
|
||||
@@ -347,8 +600,9 @@ describe('TourPlannerRail', () => {
|
||||
})
|
||||
render(<TourPlannerRail planner={controller} />)
|
||||
|
||||
const save = screen.getByRole('button', { name: 'Save tour' })
|
||||
expect(save.parentElement?.parentElement).toHaveTextContent('Routing is planning assistance, not a safety guarantee.')
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
// The safety note lives in the info dot beside the hiking difficulty, as its description.
|
||||
expect(screen.getByRole('button', { name: 'About safety' })).toHaveAccessibleDescription('Routing is planning assistance, not a safety guarantee.')
|
||||
expect(save).toHaveClass('bg-accent', 'text-accent-text')
|
||||
expect(save).toHaveClass('hover:opacity-90')
|
||||
const firstNumber = screen.getByLabelText('Waypoint 1')
|
||||
@@ -428,8 +682,8 @@ describe('TourPlannerRail', () => {
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Click the map to add your starting point.')
|
||||
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Add another point to calculate a route.')
|
||||
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Turn on Add point and click the map.')
|
||||
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('From two points on, TREK works out the route.')
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, waypoints: [{ id: 'first', lat: 48, lng: 11, role: 'start' }] })} />)
|
||||
expect(screen.queryByTestId('tour-planner-empty-state')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
|
||||
@@ -555,7 +809,7 @@ describe('TourPlannerRail', () => {
|
||||
expect(dirty.returnToNeutral).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard unsaved changes' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }))
|
||||
expect(dirty.returnToNeutral).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -572,11 +826,11 @@ describe('TourPlannerRail', () => {
|
||||
|
||||
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
|
||||
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Route, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
|
||||
import type { Day } from '../../../types'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
|
||||
import { formatDistance, formatElevation } from '../../../utils/units'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../../shared/dialogParts'
|
||||
import EmptyState from '../../shared/EmptyState'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
@@ -16,8 +15,10 @@ import { BOX } from '../../Planner/bookings/bookingParts'
|
||||
import { BarButton, SoftPill } from '../../Planner/planParts'
|
||||
import TourListRow from '../TourListRow'
|
||||
import ElevationProfile from '../../shared/ElevationProfile'
|
||||
import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
|
||||
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
import TourWaypointList from './TourWaypointList'
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
@@ -28,9 +29,7 @@ const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
'routing-failed': 'tours.planner.status.routingFailed',
|
||||
'enriching-elevation': 'tours.planner.status.elevation',
|
||||
'elevation-failed': 'tours.planner.status.elevationFailed',
|
||||
ready: 'tours.planner.status.ready',
|
||||
saving: 'tours.planner.status.saving',
|
||||
saved: 'tours.planner.saved',
|
||||
}
|
||||
|
||||
/** The tinted head band both rails open with, like every panel head in the planner. */
|
||||
@@ -38,10 +37,6 @@ const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint p
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number | null>) => string
|
||||
|
||||
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
|
||||
return t(`tours.planner.${role}`)
|
||||
}
|
||||
|
||||
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
|
||||
return t('tours.planner.inspector.profileFocus', {
|
||||
distance: formatDistance(focus.distanceMeters / 1000, unit),
|
||||
@@ -57,11 +52,106 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
|
||||
<h2 className="m-0 min-w-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(14, 'body')}>{title}</h2>
|
||||
{aside}
|
||||
</div>
|
||||
{sub && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>}
|
||||
{typeof sub === 'string'
|
||||
? <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>
|
||||
: sub}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferratas the tour climbs, one warning badge each over the overview,
|
||||
* the stretch and what it asks of a hiker in its tooltip. The router takes a
|
||||
* ferrata like any trail once the difficulty allows alpine routes, so this is
|
||||
* the one place that says so.
|
||||
*/
|
||||
function FerrataCrossingBadges() {
|
||||
const { t } = useTranslation()
|
||||
const unit = useSettingsStore(state => state.settings.distance_unit)
|
||||
const crossings = useTourFerrataCrossingStore(state => state.crossings)
|
||||
if (crossings.length === 0) return null
|
||||
const byFerrata = new Map<string, { name: string; grade: string | null; metres: number }>()
|
||||
for (const crossing of crossings) {
|
||||
const entry = byFerrata.get(crossing.id) ?? { name: crossing.name, grade: crossing.grade, metres: 0 }
|
||||
entry.metres += crossing.toM - crossing.fromM
|
||||
byFerrata.set(crossing.id, entry)
|
||||
}
|
||||
return (
|
||||
<div data-testid="tour-ferrata-crossings" className="mb-2.5 flex min-w-0 flex-wrap gap-1">
|
||||
{[...byFerrata].map(([id, ferrata]) => {
|
||||
const info = t('tours.ferrata.crossingInfo', { length: formatDistance(ferrata.metres / 1000, unit ?? 'metric'), name: ferrata.name })
|
||||
return (
|
||||
<Tooltip key={id} label={info}>
|
||||
<span aria-label={info} className="inline-flex min-w-0">
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>
|
||||
<span className="min-w-0 truncate">{ferrata.grade ? t('tours.ferrata.badge', { grade: ferrata.grade }) : t('tours.ferrata.badgeNoGrade')}</span>
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */
|
||||
function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) {
|
||||
const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted'
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span aria-label={label} className={`grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint ${look}`}>
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor is doing right now, as icon badges beside the head's title:
|
||||
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
||||
* when there is nothing to say.
|
||||
*/
|
||||
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
const { t } = useTranslation()
|
||||
const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
|
||||
const restored = planner.draftRestored && planner.hasUnsavedChanges
|
||||
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
|
||||
const failed = planner.error === 'save'
|
||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
if (!restored && !statusKey && !failed && !onFerrata) return null
|
||||
const warning = planner.error != null || canRetry
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
|
||||
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
||||
return (
|
||||
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
||||
{restored && (
|
||||
<StatusDot label={t('tours.planner.restored')} tone="info">
|
||||
<RotateCcw size={11} strokeWidth={2.2} />
|
||||
</StatusDot>
|
||||
)}
|
||||
{onFerrata && (
|
||||
<StatusDot label={t('tours.ferrata.crossingShort')} tone="warning">
|
||||
<Route size={11} strokeWidth={2.2} />
|
||||
</StatusDot>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<StatusDot label={statusLabel} tone={warning ? 'warning' : 'neutral'}>
|
||||
{warning
|
||||
? <AlertTriangle size={11} strokeWidth={2.2} />
|
||||
: busy ? <Loader2 size={11} strokeWidth={2.2} className="animate-spin" /> : <Circle size={7} strokeWidth={3} />}
|
||||
</StatusDot>
|
||||
)}
|
||||
{canRetry && (
|
||||
<button type="button" onClick={planner.retry}
|
||||
className="inline-flex h-[20px] items-center rounded-full bg-accent px-2 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(10.5)}>
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) {
|
||||
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||
const { t } = useTranslation()
|
||||
@@ -70,13 +160,15 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
|
||||
const alpineAcknowledged = useRef(false)
|
||||
const isSaving = planner.isSaving
|
||||
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
|
||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
const requestClose = () => {
|
||||
if (isSaving) return
|
||||
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
|
||||
else planner.returnToNeutral()
|
||||
}
|
||||
const changeDifficulty = (value: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
|
||||
else planner.setMaxHikingDifficulty(value)
|
||||
}
|
||||
|
||||
if (planner.mode.type === 'neutral') {
|
||||
return (
|
||||
@@ -143,7 +235,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
}
|
||||
|
||||
const isSavedEdit = planner.mode.type === 'edit-saved'
|
||||
const lastIndex = planner.waypoints.length - 1
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -152,7 +243,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
<ArrowLeft size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={t('tours.planner.mapHint')} />
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
|
||||
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
|
||||
<Undo2 size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
@@ -162,124 +253,20 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
|
||||
{planner.draftRestored && planner.hasUnsavedChanges && (
|
||||
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
|
||||
)}
|
||||
<TourNameField planner={planner} locked={!canEdit || isSaving} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
|
||||
<input
|
||||
id="tour-planner-name"
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.name}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setName(event.target.value) }}
|
||||
maxLength={255}
|
||||
placeholder={t('tours.planner.namePlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
|
||||
<CustomSelect
|
||||
id="tour-planner-difficulty"
|
||||
disabled={!canEdit || isSaving}
|
||||
ariaLabel={t('tours.planner.maxDifficulty')}
|
||||
value={planner.maxHikingDifficulty}
|
||||
onChange={nextValue => {
|
||||
if (!canEdit || isSaving) return
|
||||
const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6
|
||||
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
|
||||
else planner.setMaxHikingDifficulty(value)
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{planner.maxHikingDifficulty === 3 && (
|
||||
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
|
||||
|
||||
<TourSection label={t('tours.planner.waypoints')}>
|
||||
{planner.waypoints.length === 0 ? (
|
||||
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
|
||||
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
|
||||
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="m-0 flex list-none flex-col gap-px p-0">
|
||||
{planner.waypoints.map((point, index) => {
|
||||
const selected = planner.selectedWaypointId === point.id
|
||||
return (
|
||||
<li key={point.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.setSelectedWaypointId(point.id)}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5 text-start outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
|
||||
data-waypoint-number={index + 1}
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>{roleLabel(point.role, t)}</span>
|
||||
<span className="mt-0.5 block truncate font-geist tabular-nums text-content-faint" style={fs(10.5)}>{point.lat.toFixed(5)}, {point.lng.toFixed(5)}</span>
|
||||
</span>
|
||||
{canEdit && (
|
||||
<span className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100'}`}>
|
||||
<WaypointAction label={t('tours.planner.moveUp')} disabled={isSaving || index === 0} onAct={() => planner.moveWaypoint(point.id, -1)}>
|
||||
<ArrowUp size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction label={t('tours.planner.moveDown')} disabled={isSaving || index === lastIndex} onAct={() => planner.moveWaypoint(point.id, 1)}>
|
||||
<ArrowDown size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction label={t('tours.planner.remove')} disabled={isSaving} danger onAct={() => planner.removeWaypoint(point.id)}>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</WaypointAction>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</TourSection>
|
||||
|
||||
{(statusKey || planner.error) && (
|
||||
<TourNotice
|
||||
role="status"
|
||||
tone={planner.error ? 'warning' : 'neutral'}
|
||||
icon={planner.error ? <AlertTriangle size={13} strokeWidth={2} /> : <Info size={13} strokeWidth={2} className="text-content-faint" />}
|
||||
>
|
||||
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
|
||||
{canRetry && (
|
||||
<button type="button" onClick={planner.retry}
|
||||
className="ms-2 inline-flex items-center rounded-full bg-surface-card px-2 py-[1px] font-semibold text-content shadow-sm hover:bg-surface-secondary"
|
||||
style={fs(11)}>
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</TourNotice>
|
||||
)}
|
||||
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-col gap-2.5 border-t border-edge-faint px-3 py-3">
|
||||
<p className="m-0 flex items-start gap-1.5 leading-snug text-content-faint" style={fs(11)}>
|
||||
<ShieldAlert size={13} strokeWidth={2} className="mt-px flex-none" />
|
||||
<span>{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}</span>
|
||||
</p>
|
||||
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
|
||||
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
|
||||
{t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')}
|
||||
{t('common.discard')}
|
||||
</DialogButton>
|
||||
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}>
|
||||
{t(isSavedEdit ? 'tours.planner.saveChanges' : 'tours.planner.save')}
|
||||
{t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +277,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
onConfirm={planner.returnToNeutral}
|
||||
title={t('tours.planner.discardTitle')}
|
||||
message={t('tours.planner.discardBody')}
|
||||
confirmLabel={t('tours.planner.discardUnsaved')}
|
||||
confirmLabel={t('tours.planner.discardChanges')}
|
||||
/>
|
||||
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
|
||||
<ConfirmDialog
|
||||
@@ -312,25 +299,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
|
||||
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled}
|
||||
onClick={event => { event.stopPropagation(); if (!disabled) onAct() }}
|
||||
onKeyDown={event => { if (!disabled && event.key === 'Enter') { event.stopPropagation(); onAct() } }}
|
||||
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** The elevation profile under its label, folded away with the chevron beside it. */
|
||||
function ElevationSection({ id, samples, gradientId, planner }: {
|
||||
id: string
|
||||
@@ -340,6 +308,8 @@ function ElevationSection({ id, samples, gradientId, planner }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
|
||||
// The stretches that climb a via ferrata, banded behind the line in the ferrata violet.
|
||||
const crossings = useTourFerrataCrossingStore(state => state.crossings)
|
||||
if (samples.length < 2) {
|
||||
return (
|
||||
<TourSection label={t('tours.planner.inspector.elevation')}>
|
||||
@@ -362,6 +332,7 @@ function ElevationSection({ id, samples, gradientId, planner }: {
|
||||
focus={planner.routeProfileFocus}
|
||||
onFocusChange={planner.setRouteProfileFocus}
|
||||
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
|
||||
bands={crossings}
|
||||
/>}
|
||||
</div>
|
||||
</TourSection>
|
||||
@@ -402,6 +373,17 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
|
||||
const routeAnalysis = planner.routeAnalysis
|
||||
|
||||
// A finished save is said once, as the app says it everywhere: a toast.
|
||||
// Once per save: the toast helper and t are fresh objects on every render, so the
|
||||
// effect reruns often; only a save it has not announced yet gets the toast.
|
||||
const savedTour = planner.saveOutcome
|
||||
const announced = useRef<typeof savedTour>(null)
|
||||
useEffect(() => {
|
||||
if (!savedTour || announced.current === savedTour) return
|
||||
announced.current = savedTour
|
||||
toast.success(t('tours.planner.saved'))
|
||||
}, [savedTour, t, toast])
|
||||
|
||||
const openTour = async (tour: TourListItem) => {
|
||||
const opened = await planner.openTour(tour)
|
||||
if (!opened) toast.error(t('tours.planner.openFailed'))
|
||||
@@ -421,19 +403,46 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
selectTour(tour)
|
||||
}
|
||||
|
||||
const walkingTime = { label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }) }
|
||||
const tourTimeFields = [
|
||||
{
|
||||
label: t('tours.planner.walkingEstimated'),
|
||||
shortLabel: t('tours.planner.walkingEstimatedShort'),
|
||||
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
},
|
||||
{
|
||||
label: t('tours.planner.breaksShort'),
|
||||
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
|
||||
},
|
||||
{
|
||||
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
|
||||
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
|
||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
||||
<RailTitle title={t('tours.planner.tripTours')} sub={t('tours.subtitle')} />
|
||||
<RailTitle title={t('tours.planner.tripTours')} />
|
||||
{canEdit && planner.mode.type !== 'neutral' && (
|
||||
<Tooltip label={t('tours.planner.newTour')}>
|
||||
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
|
||||
disabled={isSaving} onClick={() => { if (!isSaving) planner.startNewTour() }}
|
||||
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-accent text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40">
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
|
||||
{hasRoute && (
|
||||
<>
|
||||
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
|
||||
<TourSection label={t('tours.planner.inspector.title')}>
|
||||
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={[walkingTime]} columns={2} />
|
||||
<FerrataCrossingBadges />
|
||||
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
|
||||
</TourSection>
|
||||
{routeAnalysis && (
|
||||
<ElevationSection
|
||||
@@ -443,18 +452,6 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
planner={planner}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{planner.saveOutcome && (
|
||||
<div className={`${BOX} flex flex-col gap-3 p-3`} role="status">
|
||||
<div className="flex items-center gap-2 font-semibold text-content" style={fs(13, 'body')}>
|
||||
<CheckCircle2 size={16} strokeWidth={2} className="flex-none text-success" />
|
||||
{t('tours.planner.saved')}
|
||||
</div>
|
||||
<DialogButton onClick={() => { if (canEdit && !isSaving) planner.startNewTour() }} disabled={!canEdit || isSaving}>
|
||||
{t('tours.planner.planAnother')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
|
||||
import { Fragment, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import {
|
||||
useTourFerrataCrossingStore,
|
||||
type TourFerrataCrossing,
|
||||
type TourWaypointFerrata,
|
||||
} from '../../../store/tourFerrataCrossingStore';
|
||||
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { TourSection } from '../tourParts';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||
|
||||
type DropTarget = { id: string; placement: 'before' | 'after' };
|
||||
|
||||
const ROLE_KEY = {
|
||||
start: 'tours.planner.start',
|
||||
via: 'tours.planner.via',
|
||||
end: 'tours.planner.end',
|
||||
} as const;
|
||||
|
||||
/** A quiet icon control on a waypoint row; it stops the click so the row is not selected too. */
|
||||
function WaypointAction({
|
||||
label,
|
||||
disabled,
|
||||
danger = false,
|
||||
onAct,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
danger?: boolean;
|
||||
onAct: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) onAct();
|
||||
}}
|
||||
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The quiet hint and first-use guidance before a route exists. */
|
||||
function WaypointEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="tour-planner-empty-state"
|
||||
className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
|
||||
<p className="m-0">{t('tours.planner.emptyHint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
|
||||
* the ladder, its name, and its grade and length as chips inside, in the
|
||||
* ferrata violet the map draws it in.
|
||||
*/
|
||||
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
|
||||
return (
|
||||
<li
|
||||
data-testid="tour-planner-ferrata-leg"
|
||||
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
|
||||
className="flex min-w-0 py-0.5 ps-[34px]"
|
||||
>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
|
||||
<FerrataGlyph size={18} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{crossing.name}
|
||||
</span>
|
||||
{crossing.grade && (
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{crossing.grade}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{length}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A waypoint's number, as the map pins show it. At the foot or the top of a via
|
||||
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
|
||||
* by the tooltip.
|
||||
*/
|
||||
function WaypointNumber({
|
||||
index,
|
||||
selected,
|
||||
ferrata,
|
||||
}: {
|
||||
index: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
||||
const number = (
|
||||
<span
|
||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||
data-waypoint-number={index + 1}
|
||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{index + 1}
|
||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||
</span>
|
||||
);
|
||||
if (!ferrata) return number;
|
||||
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
|
||||
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row under the last waypoint that turns on "set points" for the map the
|
||||
* planner shows, the same switch as the one in the map's layer pill; while it
|
||||
* is on, the row says where to click and a second click turns it off again.
|
||||
*/
|
||||
function AddPointRow({ disabled }: { disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const active = useTourPlaceToolStore((state) => state.placing[state.mode]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tour-planner-add-point"
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={() => toggleTourPlacing()}
|
||||
className={`mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-[12px] border border-dashed px-3 py-2.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'border-accent bg-surface-card text-content' : 'border-edge text-content-muted enabled:hover:border-content-faint enabled:hover:text-content'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={14} strokeWidth={2} className="flex-none" />
|
||||
<span className="min-w-0 truncate">{t(active ? 'tours.planner.addPointActive' : 'tours.planner.addPoint')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered waypoints, each under its place name with its role as a badge,
|
||||
* reorderable by drag or by the arrow controls on each row. The coordinates
|
||||
* stand in for the name while it loads, offline, or when the lookup has none.
|
||||
*/
|
||||
export default function TourWaypointList({
|
||||
planner,
|
||||
canEdit,
|
||||
isSaving,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
canEdit: boolean;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const placeNames = useWaypointPlaceNames(planner.waypoints);
|
||||
const ferrataEnds = useTourFerrataCrossingStore((state) => state.waypoints);
|
||||
const crossings = useTourFerrataCrossingStore((state) => state.crossings);
|
||||
const legCrossings = new Map<string, TourFerrataCrossing[]>();
|
||||
for (const crossing of crossings) {
|
||||
if (!crossing.afterWaypoint) continue;
|
||||
legCrossings.set(crossing.afterWaypoint, [...(legCrossings.get(crossing.afterWaypoint) ?? []), crossing]);
|
||||
}
|
||||
const lastIndex = planner.waypoints.length - 1;
|
||||
const movable = canEdit && !isSaving;
|
||||
const endDrag = () => {
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
if (planner.waypoints.length === 0) {
|
||||
return (
|
||||
<TourSection label={t('tours.planner.waypoints')}>
|
||||
<WaypointEmptyState />
|
||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||
</TourSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TourSection label={t('tours.planner.waypoints')}>
|
||||
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
|
||||
{planner.waypoints.map((point, index) => {
|
||||
const selected = planner.selectedWaypointId === point.id;
|
||||
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
|
||||
const placeName = placeNames[point.id];
|
||||
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
|
||||
return (
|
||||
<Fragment key={point.id}>
|
||||
<li
|
||||
data-testid="tour-planner-waypoint-row"
|
||||
onDragStart={(event) => {
|
||||
if (!movable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', point.id);
|
||||
}
|
||||
setDraggedWaypointId(point.id);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
|
||||
setDropTarget((current) =>
|
||||
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
|
||||
);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
|
||||
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
|
||||
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
|
||||
}
|
||||
endDrag();
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
|
||||
>
|
||||
{isDropTarget && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
|
||||
/>
|
||||
)}
|
||||
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
|
||||
<span
|
||||
draggable={movable}
|
||||
aria-hidden="true"
|
||||
data-testid="tour-planner-waypoint-drag-handle"
|
||||
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<WaypointNumber index={index} selected={selected} ferrata={ferrataEnds[point.id] ?? null} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.setSelectedWaypointId(point.id)}
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
data-testid="tour-planner-waypoint-name"
|
||||
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{placeName ?? coordinates}
|
||||
</span>
|
||||
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
|
||||
</span>
|
||||
{placeName && (
|
||||
<span
|
||||
data-testid="tour-planner-waypoint-coordinates"
|
||||
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{coordinates}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<span
|
||||
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
|
||||
>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveUp')}
|
||||
disabled={isSaving || index === 0}
|
||||
onAct={() => planner.moveWaypoint(point.id, -1)}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveDown')}
|
||||
disabled={isSaving || index === lastIndex}
|
||||
onAct={() => planner.moveWaypoint(point.id, 1)}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.remove')}
|
||||
disabled={isSaving}
|
||||
danger
|
||||
onAct={() => planner.removeWaypoint(point.id)}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</WaypointAction>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||
</TourSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
|
||||
describe('parseTourDuration', () => {
|
||||
it.each([
|
||||
['176', 176],
|
||||
['0', 0],
|
||||
[' 45 ', 45],
|
||||
['2:56', 176],
|
||||
['0:05', 5],
|
||||
['2:5', 125],
|
||||
['2 h 56', 176],
|
||||
['2h56', 176],
|
||||
['2 h 56 min', 176],
|
||||
['2H 56MIN', 176],
|
||||
['2 h', 120],
|
||||
['3h', 180],
|
||||
['1,5 h', 90],
|
||||
['1.5h', 90],
|
||||
['2 Std 10', 130],
|
||||
['2 Std. 10 Min.', 130],
|
||||
['45 min', 45],
|
||||
['45m', 45],
|
||||
['2 h 5', 125],
|
||||
])('reads %j as %d minutes', (text, minutes) => {
|
||||
expect(parseTourDuration(text)).toBe(minutes);
|
||||
});
|
||||
|
||||
it('clears the value for an empty field', () => {
|
||||
expect(parseTourDuration('')).toBeNull();
|
||||
expect(parseTourDuration(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['h', 'min', 'abc', '2:60', '2:', ':30', '-5', '2 56', '2,5', '1 h 2 h', '12345678901234567'])(
|
||||
'rejects %j',
|
||||
(text) => {
|
||||
expect(parseTourDuration(text)).toBe('invalid');
|
||||
}
|
||||
);
|
||||
|
||||
it('reads back everything it formats', () => {
|
||||
for (const minutes of [0, 5, 59, 60, 61, 176, 1440]) {
|
||||
expect(parseTourDuration(formatTourDuration(minutes))).toBe(minutes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTourDuration', () => {
|
||||
it('writes hours and minutes the way the planner shows durations', () => {
|
||||
expect(formatTourDuration(176)).toBe('2 h 56 min');
|
||||
expect(formatTourDuration(120)).toBe('2 h');
|
||||
expect(formatTourDuration(45)).toBe('45 min');
|
||||
expect(formatTourDuration(0)).toBe('0 min');
|
||||
});
|
||||
|
||||
it('writes nothing for an empty or unreadable value', () => {
|
||||
expect(formatTourDuration(null)).toBe('');
|
||||
expect(formatTourDuration(Number.NaN)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
|
||||
/** What a typed duration means: nothing (cleared), a number of minutes, or no duration at all. */
|
||||
export type ParsedTourDuration = number | null | 'invalid';
|
||||
|
||||
/** The longest text a duration field takes; nobody types more for a day's walk. */
|
||||
export const TOUR_DURATION_INPUT_MAX_LENGTH = 16;
|
||||
|
||||
const PLAIN_MINUTES = /^\d+$/;
|
||||
const CLOCK = /^(\d+):(\d{1,2})$/;
|
||||
// Hours with a unit, then minutes with an optional unit, or minutes alone with a unit:
|
||||
// "2 h 56", "2h56min", "1,5 h", "45 min". Each part is anchored on its unit, so the
|
||||
// pattern cannot match one text two ways.
|
||||
const WITH_UNITS =
|
||||
/^(?:(\d+(?:[.,]\d+)?) ?(?:h|hr|hrs|hours?|std|stunden?)\.?)? ?(?:(\d+) ?(?:m|min|mins|minutes?|minuten?)?\.?)?$/;
|
||||
|
||||
/**
|
||||
* Reads what somebody typed into a duration field as whole minutes.
|
||||
*
|
||||
* "176" is minutes, "2:56" is hours and minutes, and "2 h 56", "2h 56 min" or
|
||||
* "1,5 h" spell the units out. An empty field clears the value. Anything else,
|
||||
* a clock with 60 minutes or more included, is invalid. The range is the
|
||||
* planner's to check, not this one's.
|
||||
*/
|
||||
export function parseTourDuration(text: string): ParsedTourDuration {
|
||||
const value = text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
if (value === '') return null;
|
||||
if (value.length > TOUR_DURATION_INPUT_MAX_LENGTH) return 'invalid';
|
||||
if (PLAIN_MINUTES.test(value)) return Number(value);
|
||||
const clock = CLOCK.exec(value);
|
||||
if (clock) {
|
||||
const minutes = Number(clock[2]);
|
||||
return minutes < 60 ? Number(clock[1]) * 60 + minutes : 'invalid';
|
||||
}
|
||||
const units = WITH_UNITS.exec(value);
|
||||
if (!units || (units[1] === undefined && units[2] === undefined)) return 'invalid';
|
||||
const hours = units[1] === undefined ? 0 : Number(units[1].replace(',', '.'));
|
||||
const minutes = units[2] === undefined ? 0 : Number(units[2]);
|
||||
return Math.round(hours * 60 + minutes);
|
||||
}
|
||||
|
||||
/** "2 h 56 min" for a duration in minutes, and nothing for an empty or unreadable one. */
|
||||
export function formatTourDuration(minutes: number | null): string {
|
||||
if (minutes === null || !Number.isFinite(minutes)) return '';
|
||||
return formatPlannedTourDuration(minutes);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// FE-TOURS-FIXEDLEG-001 to -002: a fixed leg holds only while it joins its two waypoints.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
|
||||
|
||||
const leg = {
|
||||
kind: 'ferrata' as const,
|
||||
ref: 'way/1',
|
||||
name: 'Via A',
|
||||
geometry: [
|
||||
[47.4, 11.0],
|
||||
[47.401, 11.0],
|
||||
] as [number, number][],
|
||||
};
|
||||
|
||||
describe('keepJoinedFixedLegs', () => {
|
||||
it('FE-TOURS-FIXEDLEG-001: keeps a leg that joins its waypoints and drops one that a move, a removal or a bad row broke', () => {
|
||||
const joined = [
|
||||
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
|
||||
{ lat: 47.401, lng: 11.0 },
|
||||
];
|
||||
expect(keepJoinedFixedLegs(joined)[0]?.fixedLeg).toBe(leg);
|
||||
|
||||
const moved = [
|
||||
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
|
||||
{ lat: 47.41, lng: 11.0 },
|
||||
];
|
||||
expect(keepJoinedFixedLegs(moved)[0]).not.toHaveProperty('fixedLeg');
|
||||
|
||||
const last = [{ lat: 47.401, lng: 11.0, fixedLeg: leg }];
|
||||
expect(keepJoinedFixedLegs(last)[0]).not.toHaveProperty('fixedLeg');
|
||||
|
||||
const broken = [
|
||||
{ lat: 47.4, lng: 11.0, fixedLeg: { ...leg, kind: 'boat' } as never },
|
||||
{ lat: 47.401, lng: 11.0 },
|
||||
];
|
||||
expect(keepJoinedFixedLegs(broken)[0]).not.toHaveProperty('fixedLeg');
|
||||
});
|
||||
|
||||
it('FE-TOURS-FIXEDLEG-002: measures a line in metres', () => {
|
||||
expect(lineLengthM(leg.geometry)).toBeCloseTo(111.2, 0);
|
||||
expect(lineLengthM([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TOUR_FIXED_LEG_END_TOLERANCE_M, tourFixedLegSchema, type TourFixedLeg } from '@trek/shared';
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
|
||||
/**
|
||||
* Legs the router does not plan: a via ferrata put onto the tour, whose own
|
||||
* line from OpenStreetMap the tour follows from one waypoint to the next. A
|
||||
* fixed leg only holds while it still joins its two waypoints, so moving,
|
||||
* removing or reordering either of them turns the leg back into a routed one.
|
||||
*/
|
||||
export interface FixedLegPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
}
|
||||
|
||||
/** Read as a plain array: this workspace compiles without strict mode, where the schema's tuples are "maybe two numbers". */
|
||||
function gapM(a: { lat: number; lng: number }, end: readonly unknown[]): number {
|
||||
return haversineKm(a, { lat: Number(end[0]), lng: Number(end[1]) }) * 1000;
|
||||
}
|
||||
|
||||
/** Whether a leg still runs from this waypoint to the next one. */
|
||||
function fixedLegJoins(point: FixedLegPoint, next: FixedLegPoint | undefined, leg: TourFixedLeg): boolean {
|
||||
const first = leg.geometry[0];
|
||||
const last = leg.geometry[leg.geometry.length - 1];
|
||||
return (
|
||||
!!next &&
|
||||
!!first &&
|
||||
!!last &&
|
||||
gapM(point, first) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
|
||||
gapM(next, last) <= TOUR_FIXED_LEG_END_TOLERANCE_M
|
||||
);
|
||||
}
|
||||
|
||||
/** The points with every leg that no longer joins its waypoints (or does not parse) dropped. */
|
||||
export function keepJoinedFixedLegs<T extends FixedLegPoint>(points: readonly T[]): T[] {
|
||||
return points.map((point, index) => {
|
||||
if (!point.fixedLeg) return point;
|
||||
const parsed = tourFixedLegSchema.safeParse(point.fixedLeg);
|
||||
if (parsed.success && fixedLegJoins(point, points[index + 1], parsed.data)) return point;
|
||||
const { fixedLeg: _dropped, ...rest } = point;
|
||||
return rest as T;
|
||||
});
|
||||
}
|
||||
|
||||
/** The length of a line, in metres. */
|
||||
export function lineLengthM(line: readonly (readonly [number, number])[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
total += haversineKm({ lat: line[i - 1][0], lng: line[i - 1][1] }, { lat: line[i][0], lng: line[i][1] }) * 1000;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -35,6 +35,41 @@ describe('routeWalkingTour', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-020: takes a fixed leg as its own line and routes only the runs around it', async () => {
|
||||
const leg = {
|
||||
kind: 'ferrata' as const,
|
||||
ref: 'way/1',
|
||||
name: 'Via A',
|
||||
geometry: [[47.39, 11.01], [47.391, 11.011], [47.392, 11.012]] as [number, number][],
|
||||
}
|
||||
valhallaRun
|
||||
.mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 } })
|
||||
.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 } })
|
||||
|
||||
const routed = await routeWalkingTour([
|
||||
{ lat: 47.38, lng: 11.0 },
|
||||
{ lat: 47.39, lng: 11.01, fixedLeg: leg },
|
||||
{ lat: 47.392, lng: 11.012 },
|
||||
{ lat: 47.4, lng: 11.02 },
|
||||
])
|
||||
|
||||
expect(valhallaRun).toHaveBeenCalledTimes(2)
|
||||
expect(valhallaRun.mock.calls[0]?.[0]).toEqual([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01 }])
|
||||
expect(valhallaRun.mock.calls[1]?.[0]).toEqual([{ lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
|
||||
expect(routed?.coordinates).toEqual([[47.38, 11.0], [47.39, 11.01], [47.391, 11.011], [47.392, 11.012], [47.4, 11.02]])
|
||||
expect(routed?.distanceMeters).toBeGreaterThan(2500)
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-021: a tour that is all fixed leg asks the router nothing, and a failed run fails the whole route', async () => {
|
||||
const leg = { kind: 'ferrata' as const, ref: 'way/1', name: null, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
|
||||
const fixedOnly = await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
|
||||
expect(valhallaRun).not.toHaveBeenCalled()
|
||||
expect(fixedOnly?.coordinates).toEqual([[47.39, 11.01], [47.392, 11.012]])
|
||||
|
||||
valhallaRun.mockResolvedValueOnce(null)
|
||||
expect(await routeWalkingTour([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])).toBeNull()
|
||||
})
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
|
||||
await routeWalkingTour(waypoints, undefined, difficulty)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TourFixedLeg } from '@trek/shared'
|
||||
import type { Waypoint } from '../../../types'
|
||||
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
|
||||
import { lineLengthM } from './tourFixedLegs'
|
||||
|
||||
export interface TourRouteResult {
|
||||
coordinates: [number, number][]
|
||||
@@ -13,21 +15,55 @@ export interface TourRouteResult {
|
||||
*/
|
||||
const TOURS_MAX_HIKING_DIFFICULTY = 2
|
||||
|
||||
/** Tours deliberately bypasses the OSRM-first RouteCalculator. */
|
||||
/** How fast a fixed leg is reckoned until the heights are in (a via ferrata climbs slowly). */
|
||||
const FIXED_LEG_KM_PER_HOUR = 1.5
|
||||
|
||||
/**
|
||||
* Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
|
||||
* leg is fixed (a via ferrata on the tour) hands its own line over for that
|
||||
* leg; the router only plans the runs between fixed legs, one request each.
|
||||
*/
|
||||
export async function routeWalkingTour(
|
||||
waypoints: Pick<Waypoint, 'lat' | 'lng'>[],
|
||||
waypoints: Array<Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }>,
|
||||
signal?: AbortSignal,
|
||||
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
|
||||
): Promise<TourRouteResult | null> {
|
||||
const run = await valhallaRun(waypoints, 'walking', [], signal, {
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
})
|
||||
if (!run) return null
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distanceMeters: run.total.distance,
|
||||
durationSeconds: run.total.duration,
|
||||
const lines: [number, number][][] = []
|
||||
let distanceMeters = 0
|
||||
let durationSeconds = 0
|
||||
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
|
||||
if (run.length < 2) return true
|
||||
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
})
|
||||
if (!routed) return false
|
||||
lines.push(routed.total.coordinates)
|
||||
distanceMeters += routed.total.distance
|
||||
durationSeconds += routed.total.duration
|
||||
return true
|
||||
}
|
||||
|
||||
let run: Array<Pick<Waypoint, 'lat' | 'lng'>> = waypoints.slice(0, 1)
|
||||
for (let i = 0; i < waypoints.length - 1; i += 1) {
|
||||
const leg = waypoints[i].fixedLeg
|
||||
if (!leg) {
|
||||
run.push(waypoints[i + 1])
|
||||
continue
|
||||
}
|
||||
if (!(await routeRun(run))) return null
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
|
||||
const length = lineLengthM(line)
|
||||
lines.push(line)
|
||||
distanceMeters += length
|
||||
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR) * 3600
|
||||
run = [waypoints[i + 1]]
|
||||
}
|
||||
if (!(await routeRun(run))) return null
|
||||
if (lines.length === 0) return null
|
||||
|
||||
// One line: each part after the first starts where the one before it ended, so its first point goes.
|
||||
const coordinates = lines.flatMap((line, index) => (index === 0 ? line : line.slice(1)))
|
||||
return { coordinates, distanceMeters, durationSeconds }
|
||||
}
|
||||
|
||||
const HEIGHT_CHUNK_SIZE = 500
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user