mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
Merge dev into fix/prettier
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were resolved to dev's content, then every file dev brought in was run through prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { MAP_ROUND_CONTROL_SIZE, MapLayerSwitcher } from './MapLayerSwitcher';
|
||||
import { MAP_ROUND_CONTROL_SIZE, MapLayerSwitcher, TourMapLayerSwitcher } from './MapLayerSwitcher';
|
||||
|
||||
describe('MapLayerSwitcher', () => {
|
||||
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
||||
@@ -41,7 +41,23 @@ describe('MapLayerSwitcher', () => {
|
||||
expect(button.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => {
|
||||
it('FE-COMP-MAPLAYER-006: offers all three Tours-only base layers', () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Satellite' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPLAYER-007: reports a per-view Topo selection', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TourMapLayerSwitcher active="default" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Topo' }));
|
||||
expect(onChange).toHaveBeenCalledWith('topo');
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
|
||||
render(<MapLayerSwitcher active="default" onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
const shell = button.parentElement as HTMLElement;
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Map as MapIcon, Satellite } from 'lucide-react';
|
||||
import { Map as MapIcon, Mountain, Satellite } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow';
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite';
|
||||
export type TourBaseLayer = BaseLayer | 'topo';
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
pointerEvents: 'auto' as const,
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
};
|
||||
|
||||
// Where the switcher sits and how much room it takes, so a control placed beside it
|
||||
// (the phone's compass) is positioned off the same numbers instead of guessing them.
|
||||
@@ -28,19 +41,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
const label = isSatellite ? t('map.baseLayer.switchToDefault') : t('map.baseLayer.switchToSatellite');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}
|
||||
>
|
||||
<div style={FROSTED_SHELL}>
|
||||
{/* Same tooltip as its neighbours on the map, for the same reasons — see
|
||||
TripRouteOverviewPill. To the right, though: this one sits at the
|
||||
bottom left of the map, where a tooltip to the left would land on the
|
||||
@@ -78,3 +79,55 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TOUR_LAYERS = [
|
||||
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
||||
{ id: 'topo', labelKey: null, Icon: Mountain },
|
||||
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||
] as const;
|
||||
|
||||
/** Per-view Tours control. It never reads or writes the global map setting. */
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: TourBaseLayer;
|
||||
onChange: (layer: TourBaseLayer) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
|
||||
import { buildPlace } from '../../../tests/helpers/factories';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { AMAP_ROAD } from '../../constants/mapDefaults';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({
|
||||
@@ -141,6 +143,73 @@ async function renderMap() {
|
||||
}
|
||||
|
||||
describe('MapView on a real Leaflet map', () => {
|
||||
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,
|
||||
};
|
||||
const onMapClick = vi.fn();
|
||||
const route: [number, number][][] = [
|
||||
[
|
||||
[39.9042, 116.4074],
|
||||
[39.9052, 116.4084],
|
||||
],
|
||||
];
|
||||
const waypoints = [{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' as const }];
|
||||
const renderLayer = (viewBaseLayer: 'default' | 'topo' | 'satellite') => (
|
||||
<MapView
|
||||
places={[beijing]}
|
||||
center={[39.9042, 116.4074]}
|
||||
zoom={15}
|
||||
tileUrl={AMAP_ROAD}
|
||||
viewBaseLayer={viewBaseLayer}
|
||||
onViewBaseLayerChange={vi.fn()}
|
||||
route={route}
|
||||
plannerWaypoints={waypoints}
|
||||
onMapClick={onMapClick}
|
||||
/>
|
||||
);
|
||||
const view = render(renderLayer('default'));
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).toBeInTheDocument());
|
||||
let map = maps[maps.length - 1];
|
||||
expect(map.options.crs).toBeTruthy();
|
||||
expect(map.options.crs?.code).toBe('TREK:GCJ02');
|
||||
|
||||
const retainedCenter = L.latLng(39.91, 116.41);
|
||||
act(() => map.setView(retainedCenter, 13));
|
||||
view.rerender(renderLayer('topo'));
|
||||
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map));
|
||||
map = maps[maps.length - 1];
|
||||
expect(map.options.crs).toBe(L.CRS.EPSG3857);
|
||||
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5);
|
||||
|
||||
const expectedClick = L.latLng(39.9042, 116.4074);
|
||||
const pixel = map.latLngToContainerPoint(expectedClick);
|
||||
const roundTrip = map.containerPointToLatLng(pixel);
|
||||
const pixelTolerance = map.distance(expectedClick, map.containerPointToLatLng(pixel.add([1, 1])));
|
||||
map.fire('click', { latlng: roundTrip });
|
||||
expect(onMapClick).toHaveBeenCalledOnce();
|
||||
expect(map.distance(onMapClick.mock.calls[0][0].latlng, expectedClick)).toBeLessThanOrEqual(pixelTolerance);
|
||||
|
||||
view.rerender(renderLayer('satellite'));
|
||||
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map));
|
||||
map = maps[maps.length - 1];
|
||||
expect(map.options.crs?.code).toBe('TREK:GCJ02');
|
||||
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5);
|
||||
expect(waypoints).toEqual([{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' }]);
|
||||
expect(route).toEqual([
|
||||
[
|
||||
[39.9042, 116.4074],
|
||||
[39.9052, 116.4084],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
|
||||
const { pins, onMarkerClick, onMapClick } = await renderMap();
|
||||
clickOn(pins[0]);
|
||||
|
||||
@@ -6,14 +6,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults';
|
||||
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults';
|
||||
import * as photoService from '../../services/photoService';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
|
||||
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
getContainer: vi.fn(() => document.createElement('div')),
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
@@ -99,12 +101,18 @@ const clusterMock = vi.hoisted(() => {
|
||||
vi.mock('react-leaflet', () => ({
|
||||
// center/zoom are surfaced so tests can assert the camera the map is built
|
||||
// with; maxZoom because a cluster refuses to attach to a map without one.
|
||||
MapContainer: ({ children, center, zoom, maxZoom }: any) => (
|
||||
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>
|
||||
MapContainer: ({ children, center, zoom, maxZoom, crs }: any) => (
|
||||
<div
|
||||
data-testid="map-container"
|
||||
data-center={JSON.stringify(center)}
|
||||
data-zoom={zoom}
|
||||
data-maxzoom={maxZoom}
|
||||
data-crs={crs?.code ?? 'EPSG:3857'}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
TileLayer: ({ url }: any) => <div data-testid="tile-layer" data-url={url} />,
|
||||
Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
|
||||
<div
|
||||
ref={(node) => {
|
||||
@@ -166,7 +174,14 @@ vi.mock('react-leaflet', () => ({
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
/>
|
||||
),
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
CircleMarker: ({ center, interactive, pathOptions }: any) => (
|
||||
<div
|
||||
data-testid={pathOptions?.className === 'tour-profile-focus-marker' ? 'profile-focus-marker' : 'circle-marker'}
|
||||
data-center={JSON.stringify(center)}
|
||||
data-interactive={String(interactive)}
|
||||
data-path-options={JSON.stringify(pathOptions ?? null)}
|
||||
/>
|
||||
),
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
useMap: () => mapMock,
|
||||
@@ -220,6 +235,10 @@ vi.mock('../../services/photoService', () => ({
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('./gcj02Crs', () => ({
|
||||
crsForBasemap: (gcj02: boolean) => (gcj02 ? { code: 'TREK:GCJ02' } : undefined),
|
||||
}));
|
||||
|
||||
import { MapView } from './MapView';
|
||||
|
||||
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
|
||||
@@ -256,6 +275,78 @@ describe('MapView', () => {
|
||||
expect(screen.getByTestId('map-container')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses the CRS of the Tours-visible layer and preserves the viewport across datum switches', () => {
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } });
|
||||
const beijing = buildMapPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 });
|
||||
const onViewBaseLayerChange = vi.fn();
|
||||
const route = [
|
||||
[
|
||||
[39.9042, 116.4074],
|
||||
[39.9052, 116.4084],
|
||||
],
|
||||
] as [number, number][][];
|
||||
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }];
|
||||
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 };
|
||||
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
|
||||
places: [beijing],
|
||||
center: [39.9042, 116.4074] as [number, number],
|
||||
zoom: 15,
|
||||
route,
|
||||
plannerWaypoints,
|
||||
routeProfileFocus,
|
||||
tileUrl: AMAP_ROAD,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
});
|
||||
const { rerender } = render(<MapView {...props('default')} />);
|
||||
const defaultMap = screen.getByTestId('map-container');
|
||||
expect(defaultMap).toHaveAttribute('data-crs', 'TREK:GCJ02');
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('marker')
|
||||
.some(
|
||||
(marker) => marker.getAttribute('data-lat') === '39.9042' && marker.getAttribute('data-lng') === '116.4074'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD);
|
||||
|
||||
const movedCenter = { lat: 39.91, lng: 116.41 };
|
||||
mapMock.getCenter.mockReturnValue(movedCenter);
|
||||
mapMock.getZoom.mockReturnValue(13);
|
||||
const saveCamera = mapMock.on.mock.calls.find(([event]) => event === 'moveend zoomend')?.[1] as
|
||||
(() => void) | undefined;
|
||||
act(() => saveCamera?.());
|
||||
|
||||
rerender(<MapView {...props('topo')} />);
|
||||
const topoMap = screen.getByTestId('map-container');
|
||||
expect(topoMap).not.toBe(defaultMap);
|
||||
expect(topoMap).toHaveAttribute('data-crs', 'EPSG:3857');
|
||||
expect(topoMap).toHaveAttribute('data-center', JSON.stringify([movedCenter.lat, movedCenter.lng]));
|
||||
expect(topoMap).toHaveAttribute('data-zoom', '13');
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', OPENTOPOMAP_TILE_URL);
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('polyline')
|
||||
.every((polyline) => polyline.getAttribute('data-points') === JSON.stringify(route[0]))
|
||||
).toBe(true);
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute(
|
||||
'data-center',
|
||||
JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng])
|
||||
);
|
||||
|
||||
rerender(<MapView {...props('satellite')} />);
|
||||
const satelliteMap = screen.getByTestId('map-container');
|
||||
expect(satelliteMap).not.toBe(topoMap);
|
||||
expect(satelliteMap).toHaveAttribute('data-crs', 'TREK:GCJ02');
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_SATELLITE);
|
||||
|
||||
rerender(<MapView {...props('default')} />);
|
||||
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'TREK:GCJ02');
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD);
|
||||
expect(useSettingsStore.getState().settings.map_base_layer).toBe('default');
|
||||
expect(onViewBaseLayerChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
@@ -404,6 +495,224 @@ describe('MapView', () => {
|
||||
expect(screen.queryByTestId('polyline')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves a passive semantic profile focus marker without changing the map camera', () => {
|
||||
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 };
|
||||
const focusPoints: [number, number][] = [
|
||||
[48.1, 11.4],
|
||||
[48.2, 11.5],
|
||||
];
|
||||
const route: [number, number][][] = [
|
||||
[
|
||||
[48.1, 11.4],
|
||||
[48.2, 11.5],
|
||||
],
|
||||
];
|
||||
mapMock.fitBounds.mockClear();
|
||||
mapMock.panTo.mockClear();
|
||||
mapMock.setView.mockClear();
|
||||
const { rerender } = render(
|
||||
<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={focus} />
|
||||
);
|
||||
const mapContainer = screen.getByTestId('map-container');
|
||||
mapMock.fitBounds.mockClear();
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.123,11.456]');
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-interactive', 'false');
|
||||
expect(JSON.parse(screen.getByTestId('profile-focus-marker').getAttribute('data-path-options')!)).toMatchObject({
|
||||
color: 'var(--bg-card)',
|
||||
fillColor: 'var(--text-muted)',
|
||||
});
|
||||
|
||||
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 };
|
||||
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={movedFocus} />);
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.16,11.47]');
|
||||
expect(screen.getByTestId('map-container')).toBe(mapContainer);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
expect(mapMock.panTo).not.toHaveBeenCalled();
|
||||
expect(mapMock.setView).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={null} />);
|
||||
expect(screen.queryByTestId('profile-focus-marker')).toBeNull();
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
fitKey={8}
|
||||
focusPoints={[
|
||||
[48, 11],
|
||||
[48.3, 11.8],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(
|
||||
<MapView places={[]} route={null} followSelection={false} focusKey={1} focusPoints={[]} />
|
||||
);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={route}
|
||||
followSelection={false}
|
||||
focusKey={1}
|
||||
focusPoints={[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
mapMock.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={[
|
||||
[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
[48.4, 11.6],
|
||||
],
|
||||
]}
|
||||
followSelection={false}
|
||||
focusKey={1}
|
||||
focusPoints={[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
[48.4, 11.6],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
|
||||
const mapContainer = screen.getByTestId('map-container');
|
||||
const retainedCenter = { lat: 35.2, lng: 135.8 };
|
||||
mapMock.getCenter.mockReturnValue(retainedCenter);
|
||||
mapMock.getZoom.mockReturnValue(12);
|
||||
mapMock.setView([retainedCenter.lat, retainedCenter.lng], 12);
|
||||
mapMock.fitBounds.mockClear();
|
||||
mapMock.setView.mockClear();
|
||||
mapMock.panTo.mockClear();
|
||||
|
||||
rerender(<MapView places={[]} followSelection={false} focusKey={2} focusPoints={[]} />);
|
||||
expect(screen.getByTestId('map-container')).toBe(mapContainer);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
expect(mapMock.setView).not.toHaveBeenCalled();
|
||||
expect(mapMock.panTo).not.toHaveBeenCalled();
|
||||
expect(mapMock.getCenter()).toEqual(retainedCenter);
|
||||
expect(mapMock.getZoom()).toBe(12);
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey={2}
|
||||
focusPoints={[
|
||||
[48, 11],
|
||||
[48.5, 11.8],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
|
||||
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 });
|
||||
const route: [number, number][][] = [
|
||||
[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
],
|
||||
];
|
||||
const { rerender } = render(<MapView places={[]} route={null} fitKey={0} />);
|
||||
mapMock.fitBounds.mockClear();
|
||||
|
||||
rerender(<MapView places={[selectedPlace]} route={null} fitKey={1} />);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled();
|
||||
mapMock.fitBounds.mockClear();
|
||||
|
||||
rerender(<MapView places={[selectedPlace]} route={route} fitKey={2} />);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'two-point',
|
||||
[
|
||||
[48, 2],
|
||||
[49, 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'elevation',
|
||||
[
|
||||
[48, 2, 12.5],
|
||||
[49, 3, 22.75],
|
||||
],
|
||||
],
|
||||
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
|
||||
[
|
||||
'loop',
|
||||
[
|
||||
[48, 2],
|
||||
[48.1, 2.1],
|
||||
[48, 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'point-to-point Tour',
|
||||
[
|
||||
[48, 2],
|
||||
[48.5, 2.5],
|
||||
[49, 3],
|
||||
],
|
||||
],
|
||||
])('renders valid %s geometry unchanged', (_label, geometry) => {
|
||||
render(<MapView places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} />);
|
||||
const lines = screen.getAllByTestId('polyline');
|
||||
expect(lines).toHaveLength(3);
|
||||
const expected = geometry.map(([lat, lng]) => [lat, lng]);
|
||||
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', 'not json'],
|
||||
['non-array JSON', '{"coordinates":[]}'],
|
||||
['one point', '[[48,2]]'],
|
||||
['missing coordinate dimension', '[[48],[49,3]]'],
|
||||
['string coordinates', '[[48,"2"],[49,3]]'],
|
||||
['NaN token', '[[48,2],[NaN,3]]'],
|
||||
['Infinity token', '[[48,2],[49,Infinity]]'],
|
||||
['out-of-range latitude', '[[48,2],[91,3]]'],
|
||||
['out-of-range longitude', '[[48,2],[49,181]]'],
|
||||
['all-invalid coordinates', '[[91,2],[49,181]]'],
|
||||
])('skips %s geometry without passing invalid points to Leaflet', (_label, geometry) => {
|
||||
expect(() => render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />)).not.toThrow();
|
||||
expect(screen.queryByTestId('polyline')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters invalid points while preserving the original order of a renderable track', () => {
|
||||
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]';
|
||||
render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />);
|
||||
const lines = screen.getAllByTestId('polyline');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual([
|
||||
[48, 2],
|
||||
[48.5, 2.5],
|
||||
[49, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Track colours (#776) ──────────────────────────────────────────────────
|
||||
// The style has to travel through pathOptions: react-leaflet only calls
|
||||
// setStyle when that object's reference changes, so bare color/weight props
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
DEFAULT_MAP_ZOOM,
|
||||
MAP_MAX_ZOOM,
|
||||
OFM_POSITRON,
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
SATELLITE_TILE_MAXZOOM,
|
||||
SATELLITE_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
@@ -21,6 +24,7 @@ import type { Day, Place, Reservation, RouteVia } from '../../types';
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport';
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes';
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl';
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors';
|
||||
@@ -33,7 +37,12 @@ import type { DayBoundaryControls } from './dayBoundaryDrag';
|
||||
import { crsForBasemap } from './gcj02Crs';
|
||||
import HazardLayers from './HazardLayers';
|
||||
import type { MapHoverInfo } from './mapHover';
|
||||
import { MAP_LAYER_SWITCHER_INSET, MapLayerSwitcher } from './MapLayerSwitcher';
|
||||
import {
|
||||
MAP_LAYER_SWITCHER_INSET,
|
||||
MapLayerSwitcher,
|
||||
TourMapLayerSwitcher,
|
||||
type TourBaseLayer,
|
||||
} from './MapLayerSwitcher';
|
||||
import { MapLockPill } from './MapLockPill';
|
||||
import { PluginMapLayers } from './MapPluginLayers';
|
||||
import { PluginMapMarkers } from './MapPluginMarkers';
|
||||
@@ -423,6 +432,22 @@ function ViewportController({
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
const center = map.getCenter();
|
||||
onChange([center.lat, center.lng], map.getZoom());
|
||||
};
|
||||
map.whenReady(save);
|
||||
map.on('moveend zoomend', save);
|
||||
return () => {
|
||||
map.off('moveend zoomend', save);
|
||||
};
|
||||
}, [map, onChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
|
||||
follow: boolean;
|
||||
@@ -507,6 +532,8 @@ interface BoundsControllerProps {
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][];
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number;
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean;
|
||||
/**
|
||||
@@ -536,6 +563,7 @@ function BoundsController({
|
||||
paddingOpts,
|
||||
framedOnMount = false,
|
||||
focusPoints,
|
||||
focusKey,
|
||||
fitPadding,
|
||||
follow = true,
|
||||
}: BoundsControllerProps) {
|
||||
@@ -543,6 +571,8 @@ function BoundsController({
|
||||
const prevFitKey = useRef(-1);
|
||||
const awaitingRoute = useRef(false);
|
||||
const fitRan = useRef(false);
|
||||
const didInitialFocus = useRef(false);
|
||||
const prevFocusKey = useRef(focusKey);
|
||||
|
||||
const fitTo = useCallback(
|
||||
(coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
|
||||
@@ -610,10 +640,16 @@ function BoundsController({
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
if (!focusPoints?.length) return;
|
||||
if (focusKey !== undefined) {
|
||||
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current;
|
||||
prevFocusKey.current = focusKey;
|
||||
if (!shouldFit) return;
|
||||
didInitialFocus.current = true;
|
||||
}
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false;
|
||||
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts);
|
||||
}, [focusPoints, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [focusPoints, focusKey, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -860,6 +896,16 @@ 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>`,
|
||||
});
|
||||
}
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -887,6 +933,7 @@ export const MapView = memo(function MapView({
|
||||
// the shape a caller without one gets.
|
||||
tileUrl = OFM_POSITRON,
|
||||
fitKey = 0,
|
||||
focusKey,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -925,7 +972,19 @@ export const MapView = memo(function MapView({
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
}: any) {
|
||||
const globalBaseLayer = useSettingsStore((s) => s.settings.map_base_layer) || 'default';
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer;
|
||||
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function';
|
||||
const isSatellite = baseLayer === 'satellite';
|
||||
const isTopo = baseLayer === 'topo';
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
// the default is a vector style.
|
||||
@@ -937,7 +996,7 @@ export const MapView = memo(function MapView({
|
||||
* decided before the map is constructed, because Leaflet cannot change a map's
|
||||
* CRS afterwards — hence the whole map, not just the tile layer.
|
||||
*/
|
||||
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url);
|
||||
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url);
|
||||
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap]);
|
||||
const poiMarkers = useMemo(
|
||||
() =>
|
||||
@@ -1013,6 +1072,10 @@ export const MapView = memo(function MapView({
|
||||
});
|
||||
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null };
|
||||
});
|
||||
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
|
||||
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom };
|
||||
}, []);
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null);
|
||||
@@ -1218,14 +1281,10 @@ export const MapView = memo(function MapView({
|
||||
const gpxTracks = useMemo(
|
||||
() =>
|
||||
places.flatMap((place) => {
|
||||
if (!place.route_geometry) return [];
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][];
|
||||
if (!coords || coords.length < 2) return [];
|
||||
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const geometry = parseRenderableRouteGeometry(place.route_geometry);
|
||||
if (!geometry) return [];
|
||||
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng]);
|
||||
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }];
|
||||
}),
|
||||
[places]
|
||||
);
|
||||
@@ -1315,9 +1374,6 @@ export const MapView = memo(function MapView({
|
||||
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 84px) + 12px)';
|
||||
|
||||
const baseLayer = useSettingsStore((s) => s.settings.map_base_layer) || 'default';
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const isSatellite = baseLayer === 'satellite';
|
||||
const toggleBaseLayer = useCallback(() => {
|
||||
// Store flips state synchronously (instant, works offline); a failed save is logged there.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {});
|
||||
@@ -1346,8 +1402,8 @@ export const MapView = memo(function MapView({
|
||||
// the cluster group away on every template or API-key edit.
|
||||
key={isGcjBasemap ? 'gcj02' : 'wgs84'}
|
||||
id="trek-map"
|
||||
center={initialView.center}
|
||||
zoom={initialView.zoom}
|
||||
center={cameraSnapshotRef.current?.center ?? initialView.center}
|
||||
zoom={cameraSnapshotRef.current?.zoom ?? initialView.zoom}
|
||||
zoomControl={false}
|
||||
// On the map itself, not left to the base layer. Leaflet reads its zoom
|
||||
// ceiling from the map options or, failing that, from a GridLayer that
|
||||
@@ -1381,6 +1437,18 @@ export const MapView = memo(function MapView({
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : isTopo ? (
|
||||
<TileLayer
|
||||
key="topo"
|
||||
url={OPENTOPOMAP_TILE_URL}
|
||||
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
|
||||
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
) : (
|
||||
@@ -1397,6 +1465,7 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController
|
||||
places={dayPlaces.length > 0 ? dayPlaces : places}
|
||||
routeCoords={dayPlaces.length > 0 ? routeCoords : []}
|
||||
@@ -1404,6 +1473,7 @@ export const MapView = memo(function MapView({
|
||||
paddingOpts={paddingOpts}
|
||||
framedOnMount={initialView.framed}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={focusKey}
|
||||
fitPadding={fitPadding}
|
||||
follow={followSelection}
|
||||
/>
|
||||
@@ -1572,10 +1642,45 @@ 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
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
{routeProfileFocus && (
|
||||
<CircleMarker
|
||||
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
className: 'tour-profile-focus-marker',
|
||||
color: 'var(--bg-card)',
|
||||
weight: 3,
|
||||
fillColor: 'var(--text-muted)',
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
interactive={false}
|
||||
bubblingMouseEvents={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReservationOverlay
|
||||
reservations={visibleReservations}
|
||||
showConnections
|
||||
@@ -1651,10 +1756,6 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
{/* Bottom left, whatever else is on screen. Opening a place used to send it to the
|
||||
top of the map, which read as the control moving house rather than as room being
|
||||
made: the inspector is a centred card at most 800 wide, so the corner it would
|
||||
have been clearing is one the card never reaches. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1668,7 +1769,11 @@ export const MapView = memo(function MapView({
|
||||
}}
|
||||
>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
|
||||
{hasViewBaseLayer ? (
|
||||
<TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
|
||||
) : (
|
||||
<MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const glMap = vi.hoisted(() => ({
|
||||
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(),
|
||||
@@ -913,6 +914,153 @@ describe('MapViewGL', () => {
|
||||
return calls[calls.length - 1][0] as { features: GeoFeature[] };
|
||||
}
|
||||
|
||||
it('moves a passive semantic profile focus pin without changing the map camera', async () => {
|
||||
loadOnAttach();
|
||||
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 };
|
||||
const focusPoints: [number, number][] = [
|
||||
[48.1, 11.4],
|
||||
[48.2, 11.5],
|
||||
];
|
||||
const route: [number, number][][] = [
|
||||
[
|
||||
[48.1, 11.4],
|
||||
[48.2, 11.5],
|
||||
],
|
||||
];
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={route}
|
||||
fitKey={1}
|
||||
focusPoints={focusPoints}
|
||||
glProvider="maplibre-gl"
|
||||
routeProfileFocus={focus}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
const marker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]');
|
||||
expect(marker).not.toBeNull();
|
||||
const projected = glMap.project([focus.lng, focus.lat]);
|
||||
expect(marker?.style.transform).toContain(`translate(${projected.x}px, ${projected.y}px)`);
|
||||
expect(marker?.style.pointerEvents).toBe('none');
|
||||
expect(marker?.style.background).toBe('var(--text-muted)');
|
||||
expect(marker?.style.border).toBe('3px solid var(--bg-card)');
|
||||
expect(glCanvasContainer.contains(marker)).toBe(true);
|
||||
glMap.fitBounds.mockClear();
|
||||
glMap.flyTo.mockClear();
|
||||
glMap.jumpTo.mockClear();
|
||||
glMap.easeTo.mockClear();
|
||||
|
||||
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 };
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={route}
|
||||
fitKey={1}
|
||||
focusPoints={focusPoints}
|
||||
glProvider="maplibre-gl"
|
||||
routeProfileFocus={movedFocus}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
const movedMarker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]');
|
||||
expect(movedMarker).toBe(marker);
|
||||
expect(movedMarker?.style.transform).toContain(
|
||||
`translate(${glMap.project([movedFocus.lng, movedFocus.lat]).x}px, ${glMap.project([movedFocus.lng, movedFocus.lat]).y}px)`
|
||||
);
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
expect(glMap.easeTo).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={route}
|
||||
fitKey={1}
|
||||
focusPoints={focusPoints}
|
||||
glProvider="maplibre-gl"
|
||||
routeProfileFocus={null}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glCanvasContainer.contains(marker)).toBe(false);
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
expect(glMap.easeTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
|
||||
loadOnAttach();
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey={1}
|
||||
focusPoints={[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
glMap.fitBounds.mockClear();
|
||||
const mapConstructions = (maplibregl.Map as any).mock.calls.length;
|
||||
const retainedCenter = { lng: 135.8, lat: 35.2 };
|
||||
glMap.getCenter.mockReturnValue(retainedCenter);
|
||||
glMap.getZoom.mockReturnValue(12);
|
||||
|
||||
rerender(<MapViewGL places={[]} followSelection={false} focusKey={2} focusPoints={[]} glProvider="maplibre-gl" />);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
expect(glMap.easeTo).not.toHaveBeenCalled();
|
||||
expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions);
|
||||
expect(glMap.getCenter()).toEqual(retainedCenter);
|
||||
expect(glMap.getZoom()).toBe(12);
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey={2}
|
||||
focusPoints={[[35.01, 135.76]]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
loadOnAttach();
|
||||
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 });
|
||||
const route: [number, number][][] = [
|
||||
[
|
||||
[48, 11],
|
||||
[48.2, 11.3],
|
||||
],
|
||||
];
|
||||
const { rerender } = render(<MapViewGL places={[]} route={null} fitKey={0} glProvider="maplibre-gl" />);
|
||||
await act(async () => {});
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(<MapViewGL places={[selectedPlace]} route={null} fitKey={1} glProvider="maplibre-gl" />);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalled();
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(<MapViewGL places={[selectedPlace]} route={route} fitKey={2} glProvider="maplibre-gl" />);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// jsdom drives requestAnimationFrame off a timer, so the marker reconcile and
|
||||
// the batched photo updates only land after a short tick.
|
||||
const flushFrames = () =>
|
||||
@@ -2089,6 +2237,53 @@ describe('MapViewGL', () => {
|
||||
expect(features[1].properties.color).toBeNull();
|
||||
});
|
||||
|
||||
it('uses view-local Topo tiles and controls without writing settings or moving the camera', async () => {
|
||||
glMap.on.mockImplementation((event, handlerOrLayer) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') handlerOrLayer();
|
||||
return glMap;
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const settings = useSettingsStore.getState().settings.map_base_layer;
|
||||
const { container, rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
viewBaseLayer="topo"
|
||||
onViewBaseLayerChange={onChange}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.addSource).toHaveBeenCalledWith(
|
||||
'trip-topo',
|
||||
expect.objectContaining({
|
||||
type: 'raster',
|
||||
tiles: ['a', 'b', 'c'].map((subdomain) => `https://${subdomain}.tile.opentopomap.org/{z}/{x}/{y}.png`),
|
||||
})
|
||||
);
|
||||
const button = Array.from(container.querySelectorAll('button')).find((element) => element.textContent === 'Topo')!;
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(button);
|
||||
expect(onChange).toHaveBeenCalledWith('topo');
|
||||
expect(useSettingsStore.getState().settings.map_base_layer).toBe(settings);
|
||||
glMap.fitBounds.mockClear();
|
||||
glMap.flyTo.mockClear();
|
||||
glMap.jumpTo.mockClear();
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
viewBaseLayer="default"
|
||||
onViewBaseLayerChange={onChange}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
|
||||
const gpxSource = geoSource();
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null));
|
||||
@@ -2107,6 +2302,77 @@ describe('MapViewGL', () => {
|
||||
expect(features[0].properties.place_id).toBe(74);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'two-point',
|
||||
[
|
||||
[48, 2],
|
||||
[49, 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'elevation',
|
||||
[
|
||||
[48, 2, 12.5],
|
||||
[49, 3, 22.75],
|
||||
],
|
||||
],
|
||||
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
|
||||
[
|
||||
'loop',
|
||||
[
|
||||
[48, 2],
|
||||
[48.1, 2.1],
|
||||
[48, 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'point-to-point Tour',
|
||||
[
|
||||
[48, 2],
|
||||
[48.5, 2.5],
|
||||
[49, 3],
|
||||
],
|
||||
],
|
||||
])('renders valid %s geometry with MapLibre coordinate order', async (_label, geometry) => {
|
||||
const features = gpxFeatures();
|
||||
render(<MapViewGL places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} fitKey={1} />);
|
||||
await act(async () => {});
|
||||
expect(features()).toHaveLength(1);
|
||||
expect(features()[0].geometry.coordinates).toEqual(geometry.map(([lat, lng]) => [lng, lat]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', 'not json'],
|
||||
['non-array JSON', '{"coordinates":[]}'],
|
||||
['one point', '[[48,2]]'],
|
||||
['missing coordinate dimension', '[[48],[49,3]]'],
|
||||
['string coordinates', '[[48,"2"],[49,3]]'],
|
||||
['NaN token', '[[48,2],[NaN,3]]'],
|
||||
['Infinity token', '[[48,2],[49,Infinity]]'],
|
||||
['out-of-range latitude', '[[48,2],[91,3]]'],
|
||||
['out-of-range longitude', '[[48,2],[49,181]]'],
|
||||
['all-invalid coordinates', '[[91,2],[49,181]]'],
|
||||
])('omits %s geometry from the MapLibre source', async (_label, geometry) => {
|
||||
const features = gpxFeatures();
|
||||
expect(() => render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />)).not.toThrow();
|
||||
await act(async () => {});
|
||||
expect(features()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters invalid points and preserves valid order in the MapLibre source', async () => {
|
||||
const features = gpxFeatures();
|
||||
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]';
|
||||
render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />);
|
||||
await act(async () => {});
|
||||
expect(features()).toHaveLength(1);
|
||||
expect(features()[0].geometry.coordinates).toEqual([
|
||||
[2, 48],
|
||||
[2.5, 48.5],
|
||||
[3, 49],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-050: without a Mapbox token it shows the settings hint and touches no camera', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default;
|
||||
useSettingsStore.setState({
|
||||
|
||||
@@ -5,6 +5,9 @@ import { pluginsApi, type PluginMapLayer, type PluginMapMarker } from '../../api
|
||||
import {
|
||||
DEFAULT_MAP_CENTER,
|
||||
DEFAULT_MAP_ZOOM,
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
SATELLITE_TILE_ATTRIBUTION,
|
||||
SATELLITE_TILE_MAXZOOM,
|
||||
SATELLITE_TILE_URL,
|
||||
@@ -20,6 +23,7 @@ import type { Day, Place, Reservation, RouteVia } from '../../types';
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport';
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes';
|
||||
import { parseRenderableRouteGeometry, type RouteProfileFocus } from '../../utils/routeGeometry';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors';
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
|
||||
@@ -38,7 +42,13 @@ import {
|
||||
wantsTerrain,
|
||||
} from './mapboxSetup';
|
||||
import type { MapHoverInfo } from './mapHover';
|
||||
import { MAP_LAYER_SWITCHER_INSET, MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher';
|
||||
import {
|
||||
MAP_LAYER_SWITCHER_INSET,
|
||||
MapLayerSwitcher,
|
||||
TourMapLayerSwitcher,
|
||||
type BaseLayer,
|
||||
type TourBaseLayer,
|
||||
} from './MapLayerSwitcher';
|
||||
import { MapLockPill } from './MapLockPill';
|
||||
import { draggedPoiId, makeMarkerDraggable, makePoiDraggable } from './markerDrag';
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook';
|
||||
@@ -220,6 +230,13 @@ interface Props {
|
||||
onChooseAlternative?: (index: number) => void;
|
||||
/** 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 }>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
routeProfileFocus?: RouteProfileFocus | null;
|
||||
viewBaseLayer?: TourBaseLayer;
|
||||
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
@@ -228,6 +245,8 @@ interface Props {
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][];
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number;
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
@@ -351,23 +370,27 @@ function addPlaceClusterLayers(map: any): void {
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applySatellite(map: any, on: boolean): void {
|
||||
function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
|
||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
|
||||
try {
|
||||
if (map.getLayer(SATELLITE_LAYER_ID)) {
|
||||
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none');
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!on) return; // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
if (!map.getSource(SATELLITE_SOURCE_ID)) {
|
||||
map.addSource(SATELLITE_SOURCE_ID, {
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: [SATELLITE_TILE_URL],
|
||||
tiles: topo
|
||||
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
||||
: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: SATELLITE_TILE_MAXZOOM,
|
||||
attribution: SATELLITE_TILE_ATTRIBUTION,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
});
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
@@ -376,9 +399,9 @@ function applySatellite(map: any, on: boolean): void {
|
||||
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
|
||||
map.addLayer(
|
||||
{
|
||||
id: SATELLITE_LAYER_ID,
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: SATELLITE_SOURCE_ID,
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
},
|
||||
firstOwn?.id
|
||||
@@ -751,6 +774,7 @@ export function MapViewGL({
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
focusKey,
|
||||
fitPadding,
|
||||
clusterLoosely = false,
|
||||
hazards,
|
||||
@@ -778,6 +802,12 @@ export function MapViewGL({
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -791,7 +821,9 @@ export function MapViewGL({
|
||||
const rawMaplibreStyle = useSettingsStore((s) => s.settings.maplibre_style || '');
|
||||
const mapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token || '');
|
||||
// The same stored choice the Leaflet map reads, so the two renderers agree.
|
||||
const baseLayer = useSettingsStore((s) => s.settings.map_base_layer) || 'default';
|
||||
const globalBaseLayer = useSettingsStore((s) => s.settings.map_base_layer) || 'default';
|
||||
const baseLayer = viewBaseLayer ?? globalBaseLayer;
|
||||
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function';
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const isSatellite = baseLayer === 'satellite';
|
||||
const toggleBaseLayer = useCallback(() => {
|
||||
@@ -878,6 +910,8 @@ export function MapViewGL({
|
||||
const routeViaMarkersRef = useRef<PlacePin[]>([]);
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([]);
|
||||
const plannerWaypointPinsRef = useRef<PlacePin[]>([]);
|
||||
const profileFocusPinRef = useRef<PlacePin | null>(null);
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([]);
|
||||
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
|
||||
@@ -888,6 +922,8 @@ export function MapViewGL({
|
||||
pluginMarkersRef.current.forEach((pin) => pin.reposition());
|
||||
routeViaMarkersRef.current.forEach((pin) => pin.reposition());
|
||||
viaPinsRef.current.forEach((pin) => pin.reposition());
|
||||
plannerWaypointPinsRef.current.forEach((pin) => pin.reposition());
|
||||
profileFocusPinRef.current?.reposition();
|
||||
altLabelsRef.current.forEach((pin) => pin.reposition());
|
||||
}, []);
|
||||
// Single reusable hover popup for POI markers. Planned places use the
|
||||
@@ -906,6 +942,68 @@ 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]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
profileFocusPinRef.current?.remove();
|
||||
profileFocusPinRef.current = null;
|
||||
},
|
||||
[gl, mapReady]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
if (!routeProfileFocus) {
|
||||
profileFocusPinRef.current?.remove();
|
||||
profileFocusPinRef.current = null;
|
||||
return;
|
||||
}
|
||||
let pin = profileFocusPinRef.current;
|
||||
if (!pin) {
|
||||
const element = document.createElement('div');
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
element.dataset.tourProfileFocus = 'true';
|
||||
element.style.cssText =
|
||||
'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;';
|
||||
pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat);
|
||||
profileFocusPinRef.current = pin;
|
||||
} else {
|
||||
pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat]);
|
||||
}
|
||||
pin.el.style.pointerEvents = 'none';
|
||||
}, [gl, mapReady, routeProfileFocus]);
|
||||
|
||||
/**
|
||||
* The list only changes when a via does, and the callbacks are read through a ref.
|
||||
*
|
||||
@@ -2389,24 +2487,19 @@ export function MapViewGL({
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
const features = places.flatMap((place) => {
|
||||
if (!place.route_geometry) return [];
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][];
|
||||
if (!coords || coords.length < 2) return [];
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
color: resolveTrackColor(place),
|
||||
cased: hasManualTrackColor(place),
|
||||
place_id: place.id,
|
||||
},
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
const coords = parseRenderableRouteGeometry(place.route_geometry);
|
||||
if (!coords) return [];
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
color: resolveTrackColor(place),
|
||||
cased: hasManualTrackColor(place),
|
||||
place_id: place.id,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
},
|
||||
];
|
||||
});
|
||||
src.setData({ type: 'FeatureCollection', features });
|
||||
}, [places, mapReady]);
|
||||
@@ -2539,12 +2632,20 @@ export function MapViewGL({
|
||||
const fitPaddingKey = fitPadding
|
||||
? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ')
|
||||
: '';
|
||||
const didInitialFocusRef = useRef(false);
|
||||
const prevFocusKeyRef = useRef(focusKey);
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !focusPoints?.length) return;
|
||||
if (focusKey !== undefined) {
|
||||
const shouldFit = !didInitialFocusRef.current || focusKey !== prevFocusKeyRef.current;
|
||||
prevFocusKeyRef.current = focusKey;
|
||||
if (!shouldFit) return;
|
||||
didInitialFocusRef.current = true;
|
||||
}
|
||||
const bounds = new gl.LngLatBounds();
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]));
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
@@ -2559,7 +2660,7 @@ export function MapViewGL({
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [focusPoints, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [focusPoints, focusKey, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
@@ -2643,13 +2744,16 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
const apply = () => applySatellite(map, isSatellite);
|
||||
const apply = () => {
|
||||
applySatellite(map, isSatellite);
|
||||
applySatellite(map, baseLayer === 'topo', true);
|
||||
};
|
||||
apply();
|
||||
map.on('styledata', apply);
|
||||
return () => {
|
||||
map.off('styledata', apply);
|
||||
};
|
||||
}, [isSatellite, mapReady, glProvider]);
|
||||
}, [isSatellite, baseLayer, mapReady, glProvider]);
|
||||
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
@@ -2704,7 +2808,11 @@ export function MapViewGL({
|
||||
}}
|
||||
>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
|
||||
{hasViewBaseLayer ? (
|
||||
<TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange} />
|
||||
) : (
|
||||
<MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />
|
||||
)}
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
the Leaflet map's overlay (no anchored popup, no photo). */}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
|
||||
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
@@ -224,3 +227,212 @@ describe('buildDayRouteRuns', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tour day itinerary segments', () => {
|
||||
const points = {
|
||||
A: [48.1, 11.1], B: [48.101, 11.101], C: [48.13, 11.13],
|
||||
S: [48.103, 11.103], E: [48.12, 11.12],
|
||||
S2: [48.121, 11.121], E2: [48.125, 11.125],
|
||||
} as const
|
||||
type Name = keyof typeof points
|
||||
const tour = (name: Name, end: Name, order: number, geometry?: string) => {
|
||||
const assignment = at(points[name][0], points[name][1], order)
|
||||
return { ...assignment, tour_place_id: assignment.place.id,
|
||||
tour_route_geometry: geometry ?? JSON.stringify([points[name], points[end]]) }
|
||||
}
|
||||
const ordinary = (name: Name, order: number) => at(points[name][0], points[name][1], order)
|
||||
const inspect = (list: AssignmentsMap['1'], enabled = true, profile: 'walking' | 'driving' = 'walking') => {
|
||||
const input = { ...inputs({ assignments: { '1': list } }), toursEnabled: enabled }
|
||||
const runs = buildDayRouteRuns(1, input)
|
||||
const plan = planTripRoute(input, profile)
|
||||
const lines = assembleTripRoute(plan, emptyAnswers(plan))[0]?.lines ?? []
|
||||
return { runs: runs.map(run => run.map(p => [p.lat, p.lng])),
|
||||
chunks: plan.flatMap(day => day.runs.flatMap(run => run.map(chunk => chunk.mode))), lines }
|
||||
}
|
||||
|
||||
it('keeps ordinary A-B-C unchanged with Tours on or off', () => {
|
||||
const list = [ordinary('A', 0), ordinary('B', 1), ordinary('C', 2)]
|
||||
expect(inspect(list).runs).toEqual(inspect(list, false).runs)
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.B, points.C]])
|
||||
})
|
||||
|
||||
it('skips an excluded ordinary activity while preserving the upstream A-C run', () => {
|
||||
const list = [ordinary('A', 0), { ...ordinary('B', 1), route_excluded: true }, ordinary('C', 2)]
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.C]])
|
||||
expect(inspect(list, false).runs).toEqual([[points.A, points.C]])
|
||||
})
|
||||
|
||||
it('retains an excluded Tour and its line but uses no Tour endpoint in routing', () => {
|
||||
const excluded = { ...tour('S', 'E', 1), route_excluded: true }
|
||||
const list = [ordinary('A', 0), excluded, ordinary('C', 2)]
|
||||
expect(projectDayItinerary(list, true)[1]).toMatchObject({
|
||||
kind: 'tour', assignment: excluded, geometry: [points.S, points.E], valid: true,
|
||||
})
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.C]])
|
||||
expect(inspect(list).lines).toEqual([[points.A, points.C]])
|
||||
expect(inspect(list, false).runs).toEqual([[points.A, points.C]])
|
||||
})
|
||||
|
||||
it('frames an excluded Tour-only day without generating any calculated route line', () => {
|
||||
const excluded = { ...tour('S', 'E', 0), route_excluded: true }
|
||||
const plan = planTripRoute({ ...inputs({ assignments: { '1': [excluded] } }), toursEnabled: true }, 'walking')
|
||||
expect(plan).toHaveLength(1)
|
||||
expect(plan[0].runs).toEqual([])
|
||||
const overview = summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan)))
|
||||
expect(overview.lines).toEqual([])
|
||||
expect(overview.days[0].tourLines).toEqual([[points.S, points.E]])
|
||||
expect(overview.focusPoints).toEqual([points.S, points.E])
|
||||
})
|
||||
|
||||
it('omits excluded first, last, consecutive and invalid Tours from connector anchors', () => {
|
||||
const excluded = (start: Name, end: Name, order: number, geometry?: string) => ({
|
||||
...tour(start, end, order, geometry), route_excluded: true,
|
||||
})
|
||||
expect(inspect([excluded('S', 'E', 0), ordinary('A', 1), ordinary('C', 2)]).runs).toEqual([[points.A, points.C]])
|
||||
expect(inspect([ordinary('A', 0), ordinary('C', 1), excluded('S', 'E', 2)]).runs).toEqual([[points.A, points.C]])
|
||||
expect(inspect([ordinary('A', 0), excluded('S', 'E', 1), excluded('S2', 'E2', 2), ordinary('C', 3)]).runs).toEqual([[points.A, points.C]])
|
||||
expect(inspect([ordinary('A', 0), excluded('S', 'E', 1, '{bad'), ordinary('C', 2)]).runs).toEqual([[points.A, points.C]])
|
||||
})
|
||||
|
||||
it.each(['cycling', 'cable_car', 'plugin:custom/walk'])('preserves %s overrides on connectors outside Tours', mode => {
|
||||
const list = [{ ...ordinary('A', 0), leg_transport_mode: mode }, tour('S', 'E', 1), ordinary('C', 2)]
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.E, points.C]])
|
||||
expect(inspect(list).chunks).toEqual([mode, 'walking'])
|
||||
})
|
||||
|
||||
it.each(['walking', 'driving'] as const)('uses connectors and authoritative point-to-point geometry in %s', profile => {
|
||||
const list = [ordinary('A', 0), tour('S', 'E', 1), ordinary('C', 2)]
|
||||
const projection = projectDayItinerary(list, true)
|
||||
expect(projection[1]).toMatchObject({ kind: 'tour', valid: true, start: { lat: 48.103, lng: 11.103 }, end: { lat: 48.12, lng: 11.12 }, geometry: [points.S, points.E] })
|
||||
expect(inspect(list, true, profile).runs).toEqual([[points.A, points.S], [points.E, points.C]])
|
||||
expect(inspect(list, true, profile).lines).toEqual([[points.A, points.S], [points.E, points.C]])
|
||||
expect(inspect(list, true, profile).chunks).toEqual([profile, profile])
|
||||
expect(inspect(list, false, profile).runs).toEqual([[points.A, points.S, points.C]])
|
||||
})
|
||||
|
||||
it('joins a loop at its shared start/end without routing across the Tour', () => {
|
||||
const list = [ordinary('A', 0), tour('S', 'S', 1, JSON.stringify([points.S, points.E, points.S])), ordinary('C', 2)]
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.S, points.C]])
|
||||
expect(projectDayItinerary(list, true)[1]).toMatchObject({ kind: 'tour', loop: true })
|
||||
})
|
||||
|
||||
it('handles first, last, single, and consecutive Tours', () => {
|
||||
expect(inspect([tour('S', 'E', 0), ordinary('C', 1)]).runs).toEqual([[points.E, points.C]])
|
||||
expect(inspect([ordinary('A', 0), tour('S', 'E', 1)]).runs).toEqual([[points.A, points.S]])
|
||||
expect(inspect([tour('S', 'E', 0)]).runs).toEqual([])
|
||||
expect(inspect([ordinary('A', 0), tour('S', 'E', 1), tour('S2', 'E2', 2), ordinary('C', 3)]).runs)
|
||||
.toEqual([[points.A, points.S], [points.E, points.S2], [points.E2, points.C]])
|
||||
})
|
||||
|
||||
it('bookends a single Tour at start/end and ignores excluded edge Tours for hotel anchors', () => {
|
||||
const hotel = [48, 11]
|
||||
const days = [1, 2, 3].map(id => buildDay({ id, day_number: id }))
|
||||
const accommodations = [{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3,
|
||||
place_lat: hotel[0], place_lng: hotel[1] }] as Accommodation[]
|
||||
const withHotels = (list: AssignmentsMap['1']) => buildDayRouteRuns(2, {
|
||||
...inputs({ days, assignments: { '2': list.map(assignment => ({ ...assignment, day_id: 2 })) },
|
||||
accommodations, optimizeFromAccommodation: true }), toursEnabled: true,
|
||||
}).map(run => run.map(point => [point.lat, point.lng]))
|
||||
expect(withHotels([tour('S', 'E', 0)])).toEqual([[hotel, points.S], [points.E, hotel]])
|
||||
expect(withHotels([
|
||||
{ ...tour('S', 'E', 0), route_excluded: true }, ordinary('A', 1), ordinary('C', 2),
|
||||
{ ...tour('S2', 'E2', 3), route_excluded: true },
|
||||
])).toEqual([[hotel, points.A], [points.A, points.C], [points.C, hotel]])
|
||||
expect(withHotels([tour('S', 'E', 0, '{bad')])).toEqual([[hotel, points.S]])
|
||||
})
|
||||
|
||||
it('preserves incoming/outgoing Tour modes on hotel connectors and overview chunks', () => {
|
||||
const hotel = { id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 48.09, place_lng: 11.09 } as Accommodation
|
||||
const days = [
|
||||
buildDay({ id: 1, day_number: 1 }),
|
||||
buildDay({ id: 2, day_number: 2, default_transport_mode: 'driving' }),
|
||||
buildDay({ id: 3, day_number: 3 }),
|
||||
]
|
||||
const assignedTour = {
|
||||
...tour('S', 'E', 0),
|
||||
incoming_leg_transport_mode: 'walking',
|
||||
leg_transport_mode: 'cycling',
|
||||
}
|
||||
const input = {
|
||||
...inputs({ days, assignments: { '2': [assignedTour] }, accommodations: [hotel], optimizeFromAccommodation: true }),
|
||||
toursEnabled: true,
|
||||
}
|
||||
|
||||
const runs = buildDayRouteRuns(2, input)
|
||||
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([
|
||||
[[hotel.place_lat, hotel.place_lng], points.S],
|
||||
[points.E, [hotel.place_lat, hotel.place_lng]],
|
||||
])
|
||||
expect(resolveLegMode(runs[0][0], runs[0][1], 'driving')).toBe('walking')
|
||||
expect(resolveLegMode(runs[1][0], runs[1][1], 'driving')).toBe('cycling')
|
||||
expect(runs[0][1].incoming_leg_transport_mode).toBe('walking')
|
||||
expect(runs[1][0].leg_transport_mode).toBe('cycling')
|
||||
|
||||
const overview = planTripRoute(input, 'driving').find(day => day.day.id === 2)!
|
||||
expect(overview.runs.map(run => run.map(chunk => chunk.mode))).toEqual([['walking'], ['cycling']])
|
||||
expect(overview.tourLines).toEqual([[points.S, points.E]])
|
||||
|
||||
const loopTour = { ...tour('S', 'S', 0, JSON.stringify([points.S, points.E, points.S])),
|
||||
incoming_leg_transport_mode: 'walking', leg_transport_mode: 'cycling' }
|
||||
const loopInput = { ...input, assignments: { '2': [loopTour] } }
|
||||
const loopRuns = buildDayRouteRuns(2, loopInput)
|
||||
expect(loopRuns.map(run => resolveLegMode(run[0], run[1], 'driving'))).toEqual(['walking', 'cycling'])
|
||||
expect(planTripRoute(loopInput, 'driving')[0].tourLines).toEqual([[points.S, points.E, points.S]])
|
||||
|
||||
const nextTour = { ...tour('S2', 'E2', 1), incoming_leg_transport_mode: 'walking', leg_transport_mode: 'cycling' }
|
||||
const consecutiveInput = { ...input, assignments: { '2': [assignedTour, nextTour] } }
|
||||
const consecutiveRuns = buildDayRouteRuns(2, consecutiveInput)
|
||||
expect(consecutiveRuns.map(run => resolveLegMode(run[0], run[1], 'driving'))).toEqual(['walking', 'cycling', 'cycling'])
|
||||
expect(planTripRoute(consecutiveInput, 'driving')[0].tourLines).toEqual([[points.S, points.E], [points.S2, points.E2]])
|
||||
})
|
||||
|
||||
it('does not let an unlocated transport after an invalid Tour create an evening hotel connector', () => {
|
||||
const hotel = { id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 48.09, place_lng: 11.09 } as Accommodation
|
||||
const days = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })]
|
||||
const invalidTour = tour('S', 'E', 0, '{bad')
|
||||
const unlocatedTrain = buildReservation({ id: 90, type: 'train', day_id: 2, day_plan_position: 1, endpoints: [] })
|
||||
const runs = buildDayRouteRuns(2, {
|
||||
...inputs({ days, assignments: { '2': [invalidTour] }, reservations: [unlocatedTrain], accommodations: [hotel], optimizeFromAccommodation: true }),
|
||||
toursEnabled: true,
|
||||
})
|
||||
|
||||
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[hotel.place_lat, hotel.place_lng], points.S]])
|
||||
})
|
||||
|
||||
it('marks malformed Tour geometry invalid without inventing an outgoing end', () => {
|
||||
const list = [ordinary('A', 0), tour('S', 'E', 1, '{bad'), ordinary('C', 2)]
|
||||
expect(projectDayItinerary(list, true)[1]).toMatchObject({ kind: 'tour', valid: false, start: { lat: 48.103, lng: 11.103 }, end: null })
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.S]])
|
||||
expect(inspect(list, false).runs).toEqual([[points.A, points.S, points.C]])
|
||||
})
|
||||
|
||||
it('uses first and last usable coordinates when Tour geometry contains malformed points', () => {
|
||||
const malformedPointToPoint = JSON.stringify([
|
||||
[91, 200], points.S, [Number.NaN, points.E[1]], points.E, [48.2],
|
||||
])
|
||||
const list = [ordinary('A', 0), tour('S', 'E', 1, malformedPointToPoint), ordinary('C', 2)]
|
||||
expect(projectDayItinerary(list, true)[1]).toMatchObject({
|
||||
kind: 'tour', valid: true, start: { lat: points.S[0], lng: points.S[1] },
|
||||
end: { lat: points.E[0], lng: points.E[1] }, geometry: [points.S, points.E],
|
||||
})
|
||||
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.E, points.C]])
|
||||
})
|
||||
|
||||
it('uses the current Place geometry when a saved Tour changes before assignments refresh', () => {
|
||||
const assignment = tour('S', 'E', 1)
|
||||
const updatedEnd: [number, number] = [48.127, 11.127]
|
||||
const places = [buildPlace({ id: assignment.place.id, lat: points.S[0], lng: points.S[1],
|
||||
route_geometry: JSON.stringify([points.S, updatedEnd]) })]
|
||||
const list = [ordinary('A', 0), assignment, ordinary('C', 2)]
|
||||
expect(projectDayItinerary(list, true, places)[1]).toMatchObject({ end: { lat: 48.127, lng: 11.127 } })
|
||||
expect(buildDayRouteRuns(1, { ...inputs({ assignments: { '1': list } }), toursEnabled: true, places })
|
||||
.map(run => run.map(point => [point.lat, point.lng])))
|
||||
.toEqual([[points.A, points.S], [updatedEnd, points.C]])
|
||||
})
|
||||
|
||||
it('preserves the outgoing Tour mode override for connectors only', () => {
|
||||
const list = [ordinary('A', 0), { ...tour('S', 'E', 1), leg_transport_mode: 'plugin:custom/walk' }, ordinary('C', 2)]
|
||||
expect(inspect(list).chunks).toEqual(['walking', 'plugin:custom/walk'])
|
||||
expect(projectDayItinerary(list, true)[1]).toMatchObject({ geometry: [points.S, points.E] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { withHotelBookends } from './RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface DayRouteInputs {
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
toursEnabled?: boolean
|
||||
places?: Place[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,16 +81,23 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'tour'; start: { lat: number; lng: number } | null; end: { lat: number; lng: number } | null; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
if (item.type === 'place') {
|
||||
const a = item.data
|
||||
if (a.route_excluded) return []
|
||||
const tour = projected.get(a.id)
|
||||
if (tour?.kind === 'tour') return [{
|
||||
kind: 'tour', start: tour.start, end: tour.end, pos: item.sortKey,
|
||||
time: a.place?.place_time ?? null,
|
||||
mode: a.leg_transport_mode ?? null, incoming: a.incoming_leg_transport_mode ?? null,
|
||||
}]
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
// Kept on the day, shown on the map, but not driven to (#2532).
|
||||
if ((a as { route_excluded?: boolean }).route_excluded) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
@@ -117,7 +127,19 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
runHasPlace = false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
if (entry.kind === 'tour') {
|
||||
if (entry.start) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry.start)) closeRun()
|
||||
currentRun.push({ ...entry.start, isPlace: true, incoming_leg_transport_mode: entry.incoming })
|
||||
runHasPlace = true
|
||||
}
|
||||
closeRun()
|
||||
if (entry.end) {
|
||||
currentRun.push({ ...entry.end, isPlace: true, leg_transport_mode: entry.mode })
|
||||
runHasPlace = true
|
||||
}
|
||||
} else if (entry.kind === 'place') {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
@@ -142,6 +164,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const flatPts: DayRoutePoint[] = []
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
||||
else if (e.kind === 'tour') {
|
||||
if (e.start) flatPts.push({ ...e.start, isPlace: true, incoming_leg_transport_mode: e.incoming })
|
||||
if (e.end) flatPts.push({ ...e.end, isPlace: true, leg_transport_mode: e.mode })
|
||||
}
|
||||
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
@@ -153,12 +179,16 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
||||
const contributes = (e: Entry) => e.kind === 'place' || e.kind === 'tour' || !!e.from || !!e.to
|
||||
const firstStop = entries.find(contributes)
|
||||
const lastStop = [...entries].reverse().find(contributes)
|
||||
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
|
||||
if (!e) return undefined
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
||||
if (e.kind === 'tour') {
|
||||
const point = side === 'first' ? e.start : e.end
|
||||
return point ? { isPlace: true, time: e.time, ...point } : undefined
|
||||
}
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
@@ -172,9 +202,9 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning, 'morning')
|
||||
const eveningHotel = hotelPt(bookends?.evening, 'evening')
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
const drawMorning = !!bookends && !!day && !(firstStop?.kind === 'tour' && !firstStop.start) && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
const drawEvening = !!bookends && !!day && !(lastStop?.kind === 'tour' && !lastStop.end) && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
||||
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
|
||||
runs,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||
import type { Assignment, Place } from '../../types'
|
||||
|
||||
export type DayItineraryItem =
|
||||
| { kind: 'stop'; assignment: Assignment }
|
||||
| {
|
||||
kind: 'tour'
|
||||
assignment: Assignment
|
||||
placeId: number
|
||||
start: { lat: number; lng: number } | null
|
||||
end: { lat: number; lng: number } | null
|
||||
geometry: [number, number][]
|
||||
loop: boolean
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
export function projectDayItinerary(
|
||||
assignments: Assignment[],
|
||||
toursEnabled: boolean,
|
||||
places: readonly Place[] = [],
|
||||
): DayItineraryItem[] {
|
||||
const placesById = new Map(places.map(place => [place.id, place]))
|
||||
return assignments.map(assignment => {
|
||||
const placeId = assignment.place?.id
|
||||
if (!toursEnabled || placeId == null || assignment.tour_place_id !== placeId) return { kind: 'stop', assignment }
|
||||
const currentPlace = placesById.get(placeId)
|
||||
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
|
||||
const geometry = analysis?.routeCoordinates ?? []
|
||||
const start = geometry.length >= 2
|
||||
? { lat: geometry[0][0], lng: geometry[0][1] }
|
||||
: assignment.place?.lat != null && assignment.place?.lng != null
|
||||
? { lat: assignment.place.lat, lng: assignment.place.lng }
|
||||
: null
|
||||
const end = geometry.length >= 2
|
||||
? { lat: geometry[geometry.length - 1][0], lng: geometry[geometry.length - 1][1] }
|
||||
: null
|
||||
return { kind: 'tour', assignment, placeId, start, end, geometry,
|
||||
loop: !!start && !!end && start.lat === end.lat && start.lng === end.lng,
|
||||
valid: !!end } as const
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Day, RouteSegment } from '../../types'
|
||||
@@ -15,6 +16,8 @@ export interface TripOverviewDay {
|
||||
color: { line: string; casing: string }
|
||||
/** One polyline per run of the day, `[lat, lng]`. */
|
||||
lines: [number, number][][]
|
||||
/** Saved Tours are not routed legs and keep their own map style. */
|
||||
tourLines?: [number, number][][]
|
||||
segments: RouteSegment[]
|
||||
/** Metres and seconds, summed over the day's legs. */
|
||||
distance: number
|
||||
@@ -49,7 +52,7 @@ export interface TripRouteSummary {
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
|
||||
/** A day reduced to the routing requests it needs, in the order they draw. */
|
||||
export interface TripRoutePlanDay { day: Day; runs: Chunk[][] }
|
||||
export interface TripRoutePlanDay { day: Day; runs: Chunk[][]; tourLines?: [number, number][][] }
|
||||
|
||||
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
|
||||
/** Every leg's answer so far, indexed like the plan: day, run, chunk. */
|
||||
@@ -83,11 +86,20 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p =
|
||||
export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
|
||||
return [...input.days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({ day, runs: buildDayRouteRuns(day.id, input) }))
|
||||
.filter(entry => entry.runs.length > 0)
|
||||
.map(({ day, runs }) => ({
|
||||
.map(day => ({
|
||||
day,
|
||||
runs: buildDayRouteRuns(day.id, input),
|
||||
tourLines: projectDayItinerary(
|
||||
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
|
||||
input.toursEnabled ?? false,
|
||||
input.places,
|
||||
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||
.map(({ day, runs, tourLines }) => ({
|
||||
day,
|
||||
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)),
|
||||
tourLines,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -97,7 +109,7 @@ export const emptyAnswers = (plan: TripRoutePlanDay[]): Answer[][][] =>
|
||||
|
||||
/** Stitch the routed answers back onto the plan, in the order the days are travelled. */
|
||||
export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]): TripOverviewDay[] {
|
||||
return plan.map(({ day, runs }, d) => {
|
||||
return plan.map(({ day, runs, tourLines }, d) => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
let unroutedLegs = 0
|
||||
@@ -127,6 +139,7 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
|
||||
title: day.title ?? null,
|
||||
color: dayRouteColor(day),
|
||||
lines,
|
||||
tourLines,
|
||||
segments,
|
||||
distance: segments.reduce((sum, s) => sum + s.distance, 0),
|
||||
duration: segments.reduce((sum, s) => sum + s.duration, 0),
|
||||
@@ -143,7 +156,7 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
lines,
|
||||
lineColors: days.flatMap(d => d.lines.map(() => d.color)),
|
||||
segments: days.flatMap(d => d.segments),
|
||||
focusPoints: lines.flat(),
|
||||
focusPoints: [...lines.flat(), ...days.flatMap(day => (day.tourLines ?? []).flat())],
|
||||
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
|
||||
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
|
||||
unroutedLegs: days.reduce((sum, d) => sum + (d.unroutedLegs ?? 0), 0),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
@@ -38,6 +39,7 @@ const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [] })
|
||||
vi.mocked(calculateRouteWithLegs).mockReset()
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
@@ -47,6 +49,38 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useTripRouteOverview', () => {
|
||||
it('updates an excluded Tour-only overview from current Place geometry without routing or reframing edits', async () => {
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled: true }] })
|
||||
const geometry = [[48.1, 11.1], [48.2, 11.2], [48.3, 11.3]]
|
||||
const place = buildPlace({ id: 71, lat: 48.1, lng: 11.1, route_geometry: JSON.stringify(geometry) })
|
||||
const assignments = { '1': [buildAssignment({ day_id: 1, place, tour_place_id: 71,
|
||||
tour_route_geometry: '[[49,12],[49.1,12.1]]', route_excluded: true })] }
|
||||
const days = [DAYS[0]]
|
||||
const { result, rerender } = renderHook(
|
||||
({ places, enabled }: { places: typeof place[]; enabled: boolean }) =>
|
||||
useTripRouteOverview(7, days, assignments, [], [], 'walking', enabled, places),
|
||||
{ initialProps: { places: [place], enabled: true } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].tourLines).toEqual([geometry])
|
||||
expect(result.current.focusPoints).toEqual(geometry)
|
||||
expect(result.current.lines).toEqual([])
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
const frame = result.current.focusPoints
|
||||
const changed = [[48.1, 11.1], [48.25, 11.28], [48.3, 11.3]]
|
||||
const places = [{ ...place, route_geometry: JSON.stringify(changed) }]
|
||||
rerender({ places, enabled: true })
|
||||
await waitFor(() => expect(result.current.days[0].tourLines).toEqual([changed]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.focusPoints).toBe(frame)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
rerender({ places, enabled: false })
|
||||
rerender({ places, enabled: true })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.focusPoints).toEqual(changed)
|
||||
expect(result.current.focusPoints).not.toBe(frame)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
|
||||
const { result } = render(false)
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type TripRouteAnswers, type TripRouteSummary,
|
||||
} from './tripRouteGeometry'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
|
||||
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface TripRouteOverview extends TripRouteSummary {
|
||||
}
|
||||
|
||||
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
|
||||
const EMPTY_PLACES: Place[] = []
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
@@ -34,8 +36,10 @@ export function useTripRouteOverview(
|
||||
accommodations: Accommodation[],
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
places: Place[] = EMPTY_PLACES,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
|
||||
@@ -51,17 +55,17 @@ export function useTripRouteOverview(
|
||||
|
||||
const plan = useMemo(
|
||||
() => (enabled
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation }, profile)
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places }, profile)
|
||||
: []),
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile],
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places],
|
||||
)
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
() => plan.map(({ day, runs, tourLines }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
|
||||
.join('/')}`).join(';'),
|
||||
.join('/')}:tours=${JSON.stringify(tourLines ?? [])}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
|
||||
|
||||
@@ -264,6 +264,49 @@ describe('valhallaRun over a whole day', () => {
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('retains Tours costing options through the upstream spaced retry', async () => {
|
||||
const bodies: unknown[] = []
|
||||
const times: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
times.push(Date.now())
|
||||
return bodies.length === 1 ? new HttpResponse(null, { status: 429 }) : HttpResponse.json(chain(1))
|
||||
}))
|
||||
const result = await valhallaRun(stops(2), 'walking', [], undefined, { max_hiking_difficulty: 6 })
|
||||
expect(result).not.toBeNull()
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
expect(bodies[1]).toMatchObject({ costing_options: { pedestrian: { max_hiking_difficulty: 6 } } })
|
||||
expect(times[1] - times[0]).toBeGreaterThanOrEqual(1000)
|
||||
})
|
||||
|
||||
it('does not send another Tours request when aborted during the retry pause', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => {
|
||||
calls++
|
||||
setTimeout(() => controller.abort(), 25)
|
||||
return new HttpResponse(null, { status: 429 })
|
||||
}))
|
||||
await expect(valhallaRun(stops(2), 'walking', [], controller.signal, { max_hiking_difficulty: 4 })).resolves.toBeNull()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6])('keeps Tours T%i options in the exact pedestrian request with avoidance', async difficulty => {
|
||||
let body: unknown
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json(chain(1))
|
||||
}))
|
||||
await valhallaRun(stops(2), 'walking', ['ferry'], undefined, { max_hiking_difficulty: difficulty })
|
||||
expect(body).toEqual({
|
||||
locations: stops(2).map(point => ({ lat: point.lat, lon: point.lng, radius: 50 })),
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: difficulty, use_ferry: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
@@ -279,6 +322,21 @@ describe('valhallaRun over a whole day', () => {
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-008: forwards caller-specific costing options only when supplied', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(chain(1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(2), 'walking', [], undefined, { max_hiking_difficulty: 2 })
|
||||
|
||||
expect(body).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function valhallaRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
@@ -204,7 +205,7 @@ export async function valhallaRun(
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), signal)
|
||||
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, costingOptions), signal)
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
@@ -285,13 +286,14 @@ function routeBody(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
const options: Record<string, number> = { ...costingOptions }
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
return {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
@@ -333,8 +335,9 @@ async function requestRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid), signal)
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
||||
return data === null ? null : runFrom(data)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user