mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515398f8ee | |||
| e4f2eaf336 | |||
| 16fee0800d | |||
| 888cd4ee08 | |||
| 8fe90d1783 | |||
| 4cda3c4b2e | |||
| bc85ec1eea | |||
| 04c22b34af | |||
| 56a23cb809 | |||
| 251378be1d | |||
| 973ce5f8ff | |||
| bbc32310e9 | |||
| ad6e51c574 | |||
| 6da11994d9 | |||
| eef1f16836 | |||
| 4a8d77c59c | |||
| a70c2eb9e0 | |||
| 99b040f684 | |||
| 944e0a0970 | |||
| b9f69b0c76 | |||
| fcefc7e771 | |||
| 6f0a171c74 | |||
| 3e1c06a0ee | |||
| 1aafab83a5 | |||
| c1d0435480 | |||
| 798859517f | |||
| d3fdf78db6 | |||
| 1b71b8633d | |||
| d77f1c1758 | |||
| e09c0688ae | |||
| 0e66ae72ca | |||
| 61bef334d5 | |||
| fa406b708f | |||
| 09ce5cb733 |
+1
-2
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
@@ -46,7 +46,6 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.2.0
|
||||
version: 4.2.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.2.0"
|
||||
appVersion: "4.2.1"
|
||||
|
||||
@@ -80,14 +80,18 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -134,25 +134,6 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+2
-3
@@ -283,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,7 +300,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -314,7 +314,6 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
|
||||
+6
-202
@@ -1,8 +1,6 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
@@ -61,12 +59,6 @@ import {
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripDayTrack,
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -472,15 +464,8 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
@@ -661,18 +646,12 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
|
||||
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -1054,134 +1033,11 @@ export const memoriesApi = {
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* Place search that still answers with no network.
|
||||
*
|
||||
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
|
||||
* is the one place it is read from, so all ten call sites got the offline path
|
||||
* without any of them learning about it. `source` says 'offline-cache' rather
|
||||
* than pretending to be the index — several screens show it, and a stale answer
|
||||
* that claims to be live is worse than one that says what it is.
|
||||
*
|
||||
* Only network-level failures fall through. A 4xx or 5xx means the server did
|
||||
* answer and the caller has to see it.
|
||||
*/
|
||||
/**
|
||||
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
|
||||
*
|
||||
* Separate because the shape is different: this one is keyed by id rather than
|
||||
* by a query, and a miss has to stay a miss — a place the cache does not hold
|
||||
* must fail the way it did before, not answer with somebody else's record.
|
||||
*/
|
||||
/**
|
||||
* The rejection both search surfaces already know to swallow.
|
||||
*
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
return err
|
||||
}
|
||||
|
||||
async function withCachedPlace(
|
||||
placeId: string,
|
||||
online: () => Promise<{ place: Record<string, unknown> | null }>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
|
||||
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const hit = await getCachedPlace(placeId)
|
||||
return hit ? { place: cachedToPlaceRecord(hit) } : null
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
const cached = await fromCache()
|
||||
if (cached) return cached
|
||||
return online()
|
||||
}
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached) throw err
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
async function withCachedPlaces<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
|
||||
// The keystroke this answers may already be two keystrokes old. Both search
|
||||
// surfaces order their suggestions by aborting the previous request and
|
||||
// discarding the rejection — there is no request counter — so an offline
|
||||
// answer that resolves regardless of the signal can overwrite a newer list.
|
||||
// The two cache reads are also very unequal: a prefix hit comes off the
|
||||
// index, the substring pass walks every cached place of every trip.
|
||||
if (signal?.aborted) throw abortedError()
|
||||
return found
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) return shape(await fromCache())
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached.length) throw err
|
||||
return shape(cached)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
* is the difference between finding the temple the user stands next to and
|
||||
* one 400km away. The route has always accepted it; nothing passed it.
|
||||
*
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
withCachedPlaces(
|
||||
input,
|
||||
(places) => ({
|
||||
suggestions: places.map((p) => ({
|
||||
placeId: String(p.osm_id),
|
||||
mainText: String(p.name),
|
||||
secondaryText: String(p.address || ''),
|
||||
})),
|
||||
source: 'offline-cache',
|
||||
}),
|
||||
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
signal,
|
||||
),
|
||||
// Answered from the cache when the network is not there, for the ids the
|
||||
// offline suggestion list hands out. Without it, picking an offline
|
||||
// suggestion failed here and the callers fell back to searching for
|
||||
// "name, address" — which the cache matches on the folded NAME alone, so it
|
||||
// found nothing and the user got an error toast for a place that was sitting
|
||||
// in the cache all along.
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) =>
|
||||
withCachedPlace(placeId, () =>
|
||||
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
|
||||
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1201,60 +1057,10 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
/**
|
||||
* Every place in a box, for the offline cache. One call per trip area, not
|
||||
* per keystroke, so the timeout is generous where the search ones are short.
|
||||
*/
|
||||
area: (
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
apiClient
|
||||
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data as {
|
||||
results: Record<string, unknown>[]
|
||||
truncated: boolean
|
||||
unavailable?: boolean
|
||||
},
|
||||
),
|
||||
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
|
||||
* line produces dozens of them, and one request each would re-route the whole trip once
|
||||
* per point at better than a second apart.
|
||||
*/
|
||||
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
@@ -1424,13 +1230,11 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — proxied through the server, which picks the
|
||||
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
|
||||
// backend as the languageCode, so station names come back in the user's script.
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -43,7 +43,6 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -120,7 +119,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,7 +128,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -153,7 +150,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -369,32 +365,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -3,34 +3,7 @@ import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
|
||||
/**
|
||||
* The drive-time pill for one offered route.
|
||||
*
|
||||
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
|
||||
* road, and it is the same shape the GL renderers build by hand — one look across all
|
||||
* three maps.
|
||||
*/
|
||||
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
|
||||
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
|
||||
const second = note
|
||||
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
|
||||
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
|
||||
// one piece of TREK chrome on the map that is not in the app's typeface.
|
||||
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
}
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
@@ -103,21 +76,9 @@ function formatViaDwell(seconds: number): string {
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// A stop that interrupts the drive is drawn as its own small disc, before the photo
|
||||
// branch below ever gets a look at it — the brand logo a fuel search comes back with
|
||||
// is exactly what this replaces. No number badge either, for the same reason the rail
|
||||
// gives it none: it is part of the drive, not one of the day's stops.
|
||||
const service = serviceMarkerHtml(place.stop_type, isSelected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(isSelected)
|
||||
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
|
||||
iconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
const size = isSelected ? 44 : 36
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
@@ -198,13 +159,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// One flat disc in the category's colour with its icon, and never the chain's logo.
|
||||
// The brands turned a corridor full of petrol stations into a row of advertisements,
|
||||
// they were unreadable at pin size, and a brand with no logo on file fell back to a
|
||||
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
|
||||
// the signature because the callers still have it; it simply no longer changes anything.
|
||||
void brandWikidata
|
||||
function createPoiIcon(category: string) {
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
@@ -212,7 +167,7 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
@@ -238,41 +193,6 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -357,17 +277,9 @@ interface BoundsControllerProps {
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -424,15 +336,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 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(() => {
|
||||
if (!focusPoints?.length) return
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints)
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -635,18 +538,6 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
alternativeRoutes,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
}: any) {
|
||||
// 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,
|
||||
@@ -656,21 +547,13 @@ export const MapView = memo(function MapView({
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{
|
||||
click: () => onPoiClick?.(poi),
|
||||
// Wired on add rather than in a layout effect: Leaflet builds the icon element
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
)), [pois, onPoiClick])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
@@ -989,127 +872,43 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
Dashed and thin, over the route rather than under it, because it is the one
|
||||
piece of the line that is not driving: the router snapped the stop to the
|
||||
nearest road and the drive really ends there. Same blue as the route, so it
|
||||
reads as the end of that route and not as a second one. */}
|
||||
{(accessLines ?? []).map((spur, i) => (
|
||||
<Polyline
|
||||
key={`access-${i}`}
|
||||
positions={spur.line}
|
||||
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The offered ways of driving one leg, over the route they replace. Clicking one
|
||||
takes it, which is the same choice the bar above the map offers. */}
|
||||
{/* Every option in blue over a white casing, after Apple Maps: they are all real
|
||||
roads, so the difference is emphasis, not category. Grey was the first attempt
|
||||
and it disappeared on Positron, which is almost entirely greys. */}
|
||||
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
|
||||
const active = activeAlternative === alt.index
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
opacity: active ? 1 : 0.9,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: () => onChooseAlternative(alt.index),
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
]
|
||||
})}
|
||||
|
||||
{/* The handles that shape the drive. After the route so they sit on top of it. */}
|
||||
{roadtripVias ? (
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -1855,104 +1855,4 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
|
||||
// Same drift the planned-place pins had: a library Marker repositions on every
|
||||
// `move` event — one per pointer sample — while the canvas draws once a frame, so
|
||||
// the suggestions swam over the map during a drag and snapped back on release.
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
expect(layer.children.length).toBe(1)
|
||||
const pin = layer.firstElementChild as HTMLElement
|
||||
expect(pin.style.transform).toContain('translate(100px, 80px)')
|
||||
|
||||
glMap.project.mockReturnValue({ x: 140, y: 60 })
|
||||
act(() => { mapHandler('render')() })
|
||||
|
||||
expect(pin.style.transform).toContain('translate(140px, 60px)')
|
||||
})
|
||||
|
||||
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
|
||||
// on. Rebuilding the handles on an unrelated render pulled that element out from under
|
||||
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
|
||||
// landed about a second later, which is exactly when somebody reaches for it.
|
||||
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
// The same via in a fresh object, with a fresh callback: what every render of the
|
||||
// planner hands down while the day re-routes.
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
expect(layer.firstElementChild).toBe(handle)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
// The other half of the contract: holding the element still must not mean holding it
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -86,8 +81,6 @@ interface RouteSegment {
|
||||
// mouseleave never fires" case (#1404).
|
||||
const NO_PLACES: Place[] = []
|
||||
const NO_ROUTE_VIAS: RouteVia[] = []
|
||||
/** Stable empty default: a fresh array each render would refire the spur effect. */
|
||||
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
|
||||
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
|
||||
const NO_DAY_ORDER: Record<number, number[] | null> = {}
|
||||
const NO_RESERVATIONS: Reservation[] = []
|
||||
@@ -104,8 +97,6 @@ interface Props {
|
||||
tripId?: number | string
|
||||
// Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
routeVias?: RouteVia[]
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
@@ -130,39 +121,6 @@ interface Props {
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
/**
|
||||
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
|
||||
* The caller decides whether that point is near enough to the drive to mean anything.
|
||||
*/
|
||||
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
|
||||
/** A click on the drawn route, for putting a via point there (#1797). */
|
||||
onRouteClick?: (lat: number, lng: number) => void
|
||||
/** The ways of driving one leg, drawn while the picker is open. */
|
||||
alternativeRoutes?: AlternativeOverlay[]
|
||||
/** Which option is being considered, so it can be lit up in its own colour. */
|
||||
activeAlternative?: number | null
|
||||
onChooseAlternative?: (index: number) => void
|
||||
/** Reports which option the pointer is over, so the list and the map agree. */
|
||||
onHighlightAlternative?: (index: number | null) => void
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/**
|
||||
* Let markers stay apart longer than usual.
|
||||
*
|
||||
* A road trip is read along a line: two stops fifty kilometres apart on the same
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -176,76 +134,7 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
// No number badge either, for the same reason the rail gives it none.
|
||||
const service = serviceMarkerHtml(place.stop_type, selected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(selected)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
|
||||
wrap.innerHTML = service
|
||||
return wrap
|
||||
}
|
||||
|
||||
const size = selected ? 44 : 36
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
@@ -467,20 +356,6 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an element on the map as a pin.
|
||||
*
|
||||
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
|
||||
* along is the whole provider decision: with a layer we position the element ourselves on
|
||||
* the map's render clock, without one the library's own Marker does it.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
|
||||
return layer
|
||||
? makePlacePin(map, layer, el, lng, lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
|
||||
}
|
||||
|
||||
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
|
||||
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
|
||||
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
|
||||
@@ -520,37 +395,21 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
// A chain shows its logo instead of the category icon: on a corridor full of petrol
|
||||
// stations the brand is what the eye is looking for, and the server proxies it so the
|
||||
// browser never asks Wikimedia which ones are on screen.
|
||||
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
|
||||
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
|
||||
*
|
||||
* The logos turned a corridor full of petrol stations into a row of advertisements, were
|
||||
* unreadable at pin size, and a brand with no logo on file fell back to a different
|
||||
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
|
||||
* with its icon is what the rail, the corridor list and the map all show now.
|
||||
*/
|
||||
export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
@@ -561,8 +420,6 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -577,15 +434,6 @@ export function MapViewGL({
|
||||
onReservationClick,
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
alternativeRoutes,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
gl,
|
||||
@@ -632,336 +480,31 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<PlacePin[]>([])
|
||||
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
|
||||
const poiCleanupRef = useRef<(() => void)[]>([])
|
||||
const onPoiDropRef = useRef(onPoiDropOnRoute)
|
||||
onPoiDropRef.current = onPoiDropOnRoute
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poiMarkersRef = useRef<any[]>([])
|
||||
// Plugin map contributions — data fetched per trip, elements owned imperatively
|
||||
// like the POI markers so they survive the React render cycle.
|
||||
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
|
||||
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
|
||||
const pluginMarkersRef = useRef<PlacePin[]>([])
|
||||
const routeViaMarkersRef = useRef<PlacePin[]>([])
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
/** 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
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
viaPinsRef.current.forEach(pin => pin.reposition())
|
||||
altLabelsRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pluginMarkersRef = useRef<any[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const routeViaMarkersRef = useRef<any[]>([])
|
||||
// Single reusable hover popup for POI markers. Planned places use the
|
||||
// cursor-following React tooltip below so they match the Leaflet map.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
|
||||
/**
|
||||
* The via handles.
|
||||
*
|
||||
* Built as plain elements and attached with the same `attachPin` the place pins use,
|
||||
* rather than the library's own marker: under MapLibre those float above the map during
|
||||
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
/**
|
||||
* The list only changes when a via does, and the callbacks are read through a ref.
|
||||
*
|
||||
* Both matter for the same reason: this effect destroys every handle and builds new DOM
|
||||
* elements. It used to re-run on every render of the planner — the vias arrived as a
|
||||
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
|
||||
* when a freshly placed via finished re-routing pulled the element out from under the
|
||||
* pointer, and the first drag went nowhere.
|
||||
*/
|
||||
const vias = useStableVias(roadtripVias)
|
||||
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
viaHandlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const viasDraggable = !!onMoveVia
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
viaCleanupRef.current.forEach(off => off())
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
for (const via of vias) {
|
||||
const el = document.createElement('span')
|
||||
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
|
||||
viaPinsRef.current.push(pin)
|
||||
if (!viasDraggable) continue
|
||||
|
||||
let dragging = false
|
||||
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
|
||||
// sits on that surface. Stopping only pointerdown left both running at once: the
|
||||
// handle moved AND the map slid out from under it.
|
||||
const swallow = (e: Event) => e.stopPropagation()
|
||||
// Where the gesture began, so a click can be told from a drag. Without it
|
||||
// every pointerup committed a move to wherever inside the 17px handle the
|
||||
// pointer happened to be: at a low zoom that is kilometres, and the write
|
||||
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
|
||||
// which is why the two renderers behaved differently for one gesture.
|
||||
let startedAt: { x: number; y: number } | null = null
|
||||
const MOVED_ENOUGH = 4
|
||||
const onDown = (e: PointerEvent) => {
|
||||
// Primary button only. Right-clicking ran onDown, then contextmenu ->
|
||||
// remove AND pointerup -> move, so one gesture sent two writes; on the
|
||||
// platforms where contextmenu comes first, the move landed on an id that
|
||||
// no longer existed and reported a failure for a deletion that worked.
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
dragging = true
|
||||
startedAt = { x: e.clientX, y: e.clientY }
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
pin.setLngLat([at.lng, at.lat])
|
||||
}
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
el.style.cursor = 'grab'
|
||||
const from = startedAt
|
||||
startedAt = null
|
||||
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
|
||||
if (travelled < MOVED_ENOUGH) {
|
||||
// A click, not a drag. Put the pin back where the via actually is: the
|
||||
// move handler has been following the pointer across the handle.
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
return
|
||||
}
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
}
|
||||
// The browser fires this when the captured element is torn out of the DOM,
|
||||
// which is exactly what a delete does. Committing a position there wrote a
|
||||
// move for a via that was being removed.
|
||||
const onCancel = () => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
startedAt = null
|
||||
el.style.cursor = 'grab'
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
}
|
||||
const onContext = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
}
|
||||
el.addEventListener('pointerdown', onDown)
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
el.addEventListener('pointercancel', onCancel)
|
||||
el.addEventListener('contextmenu', onContext)
|
||||
el.addEventListener('mousedown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('dblclick', swallow)
|
||||
viaCleanupRef.current.push(() => {
|
||||
el.removeEventListener('pointerdown', onDown)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
el.removeEventListener('pointercancel', onCancel)
|
||||
el.removeEventListener('contextmenu', onContext)
|
||||
el.removeEventListener('mousedown', swallow)
|
||||
el.removeEventListener('touchstart', swallow)
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
}, [vias, viasDraggable, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
|
||||
* route, drawn in blue on top, and three more solid colours next to it would read as
|
||||
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
|
||||
* which is the only moment a second colour helps.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const source = map.getSource?.('route-alternatives')
|
||||
if (!source?.setData) return
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (alternativeRoutes ?? []).map((alt, i) => ({
|
||||
type: 'Feature',
|
||||
properties: { index: i, active: activeAlternative === i, color: alt.color },
|
||||
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The drive time on each offered route, the way Apple Maps labels them.
|
||||
*
|
||||
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
|
||||
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
|
||||
* and the pill has a shape a text layer cannot draw. They join the render clock like
|
||||
* every other pin here, so they stay on their road while the map moves.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
for (const alt of alternativeRoutes ?? []) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
const active = activeAlternative === alt.index
|
||||
el.style.cssText = [
|
||||
// A rounded rectangle rather than a pill, and roomy enough for a second line —
|
||||
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
|
||||
// label instead of two stacked chips.
|
||||
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
|
||||
`background:${alt.labelBg}`,
|
||||
`color:${ALT_LABEL_TEXT}`,
|
||||
'border:none;border-radius:11px',
|
||||
// A <button> does not inherit the page's font, so without this the label reads in
|
||||
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
|
||||
'font-family:var(--font-system)',
|
||||
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||
'cursor:pointer;pointer-events:auto',
|
||||
// The pin is positioned by writing `transform` every frame. Any inherited
|
||||
// transition on it makes the label chase the map instead of sticking to the road,
|
||||
// and `transform` is exactly what a button picks up from the app's own styles.
|
||||
'transition:none !important;animation:none !important',
|
||||
'will-change:transform;backface-visibility:hidden',
|
||||
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
|
||||
].filter(Boolean).join(';')
|
||||
const time = document.createElement('span')
|
||||
time.textContent = alt.label
|
||||
el.appendChild(time)
|
||||
if (alt.note) {
|
||||
const note = document.createElement('span')
|
||||
note.textContent = alt.note
|
||||
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
|
||||
el.appendChild(note)
|
||||
}
|
||||
// Same reason as the via handles: without this the map pans as the label is pressed.
|
||||
el.addEventListener('mousedown', e => e.stopPropagation())
|
||||
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
|
||||
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
|
||||
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
|
||||
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
|
||||
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
|
||||
}
|
||||
return () => {
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
}
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onChooseAlternative) return
|
||||
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
|
||||
const index = e.features?.[0]?.properties?.index
|
||||
if (typeof index === 'number') onChooseAlternative(index)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
if (!map.getLayer?.('route-alt-hit')) return
|
||||
map.on('click', 'route-alt-hit', onClick)
|
||||
map.on('mouseenter', 'route-alt-hit', enter)
|
||||
map.on('mouseleave', 'route-alt-hit', leave)
|
||||
return () => {
|
||||
map.off('click', 'route-alt-hit', onClick)
|
||||
map.off('mouseenter', 'route-alt-hit', enter)
|
||||
map.off('mouseleave', 'route-alt-hit', leave)
|
||||
}
|
||||
}, [mapReady, onChooseAlternative])
|
||||
|
||||
/**
|
||||
* A click on the route line, which in a GL renderer is a layer rather than an element.
|
||||
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
|
||||
* click, so the handler can keep the add-place menu from opening underneath the via.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onRouteClick) return
|
||||
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
|
||||
e.preventDefault?.()
|
||||
// Layer handlers are independent: MapLibre evaluates each registration
|
||||
// against the same click, so a point that hits both the offered-route band
|
||||
// and the current-route band fires both. Every alternative starts and ends
|
||||
// at the same two stops, and when the leg already carries vias the current
|
||||
// road is itself one of the offers, so the overlap is the whole leg. One
|
||||
// click then dropped a via AND chose a different road, two writes and two
|
||||
// trip reloads for a gesture the user made once. The picker is a modal
|
||||
// choice about that leg; dropping a via mid-choice is not offered anywhere.
|
||||
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
|
||||
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
if (!map.getLayer?.(layer)) continue
|
||||
map.on('click', layer, onClick)
|
||||
map.on('mouseenter', layer, enter)
|
||||
map.on('mouseleave', layer, leave)
|
||||
}
|
||||
return () => {
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
map.off('click', layer, onClick)
|
||||
map.off('mouseenter', layer, enter)
|
||||
map.off('mouseleave', layer, leave)
|
||||
}
|
||||
}
|
||||
}, [mapReady, onRouteClick])
|
||||
|
||||
/**
|
||||
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
||||
* much once it is projected onto the routed geometry, and it works the same in all
|
||||
* three renderers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const container = map?.getContainer?.()
|
||||
if (!container || !mapReady || !onPoiDropOnRoute) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [mapReady, onPoiDropOnRoute])
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
@@ -973,13 +516,6 @@ export function MapViewGL({
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
const hoverDisabledRef = useRef(hoverDisabled)
|
||||
hoverDisabledRef.current = hoverDisabled
|
||||
// Read inside the map's own click handler, which is registered once at setup.
|
||||
const onRouteClickRef = useRef(onRouteClick)
|
||||
onRouteClickRef.current = onRouteClick
|
||||
const onChooseAlternativeRef = useRef(onChooseAlternative)
|
||||
onChooseAlternativeRef.current = onChooseAlternative
|
||||
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
|
||||
onHighlightAlternativeRef.current = onHighlightAlternative
|
||||
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
|
||||
// day plan the marker would be dropped on is not on screen on a phone anyway.
|
||||
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
|
||||
@@ -1063,49 +599,6 @@ export function MapViewGL({
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// The ways of driving one leg, offered while the picker is open (#1797). Added
|
||||
// BEFORE the route source so its layers sit underneath: the current route stays the
|
||||
// brightest thing on the map, and the options read as what they are — suggestions.
|
||||
if (!map.getSource('route-alternatives')) {
|
||||
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// A wide invisible band per option, so a pointer can pick one without hitting
|
||||
// the 3px line exactly.
|
||||
map.addLayer({
|
||||
id: 'route-alt-hit',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// A white casing under the option lines, the same trick the main route uses.
|
||||
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
|
||||
// grey dashed line on it is invisible. The casing gives every option an edge, so
|
||||
// it reads on a pale map, a dark one and a satellite tile alike.
|
||||
map.addLayer({
|
||||
id: 'route-alt-casing',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route-alt-line',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
// Dark and dashed by default so it stays subordinate to the blue route it is an
|
||||
// alternative to; the one being considered takes its own colour and thickens.
|
||||
// Every option in blue, after Apple Maps: they are all real roads, so the
|
||||
// difference between them is emphasis rather than category. The one being
|
||||
// considered thickens; nothing turns grey, because grey vanishes on Positron.
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
|
||||
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
|
||||
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -1118,16 +611,6 @@ export function MapViewGL({
|
||||
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
||||
// and a pointer is not that accurate, so aiming at the road was most of why
|
||||
// putting a via there felt like it did not work.
|
||||
map.addLayer({
|
||||
id: 'trip-route-hit',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
@@ -1136,21 +619,6 @@ export function MapViewGL({
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// The last bit to a place the road does not reach (#1797). Added after the route
|
||||
// so it draws over it: it is the one piece of the line that is not driving, and it
|
||||
// has to be readable exactly where it meets the road it leaves. Same blue, because
|
||||
// it is the end of that route rather than a second one; dashed short and thin,
|
||||
// which is how a map says "on foot from here" without a legend.
|
||||
if (!map.getSource('trip-access')) {
|
||||
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-access-line',
|
||||
type: 'line',
|
||||
source: 'trip-access',
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
|
||||
layout: { 'line-cap': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -1213,7 +681,53 @@ export function MapViewGL({
|
||||
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
|
||||
}
|
||||
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
|
||||
addPlaceClusterLayers(map)
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: 30,
|
||||
clusterMaxZoom: 10,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const zoomToCluster = (e: any) => {
|
||||
const features = typeof map.queryRenderedFeatures === 'function'
|
||||
@@ -1331,15 +845,6 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
|
||||
) return
|
||||
// And for the drive itself while it can be reshaped: that click just put a via
|
||||
// there, and it must not also drop a place on top of it (#1797).
|
||||
if (
|
||||
onRouteClickRef.current
|
||||
&& typeof map.getLayer === 'function'
|
||||
&& map.getLayer('trip-route-hit')
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -1663,7 +1168,12 @@ export function MapViewGL({
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
|
||||
const pin = isMapLibre && pinLayerRef.current
|
||||
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map))
|
||||
markersRef.current.set(place.id, pin)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1675,18 +1185,6 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1725,7 +1223,7 @@ export function MapViewGL({
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
@@ -1735,19 +1233,15 @@ export function MapViewGL({
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
|
||||
@@ -1793,7 +1287,8 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
})
|
||||
}
|
||||
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
|
||||
routeViaMarkersRef.current.push(m)
|
||||
}
|
||||
}, [routeVias, mapReady, glProvider])
|
||||
|
||||
@@ -1811,7 +1306,7 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
|
||||
})
|
||||
}
|
||||
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
|
||||
pluginMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pluginMarkers, mapReady, glProvider])
|
||||
@@ -1830,22 +1325,6 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route, mapReady])
|
||||
|
||||
// Update access-spur geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (accessLines || []).map(spur => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { meters: Math.round(spur.meters) },
|
||||
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [accessLines, mapReady])
|
||||
|
||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Update GPX geometries
|
||||
@@ -1973,25 +1452,6 @@ export function MapViewGL({
|
||||
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
|
||||
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 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
|
||||
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.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The handles that shape a drive.
|
||||
*
|
||||
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
|
||||
* like one would read as a place on the itinerary. Dragging it redraws the route through
|
||||
* the new spot, which is the whole interaction — the same one a paper map gets when you
|
||||
* put a finger on the road you actually want to take.
|
||||
*/
|
||||
const VIA_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="
|
||||
display:block;width:12px;height:12px;border-radius:9999px;
|
||||
background:#0a84ff;border:2.5px solid #ffffff;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
|
||||
"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
interface RoadtripViaMarkersProps {
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const vias = useStableVias(viasByDay)
|
||||
// Read through a ref so the handlers below can stay the same objects across renders.
|
||||
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
|
||||
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
|
||||
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
|
||||
// drag is in flight is how a freshly placed via lost its first drag.
|
||||
const handlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
handlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const draggable = !!onMoveVia
|
||||
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
position: [via.lat, via.lng] as [number, number],
|
||||
handlers: {
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
// and the same gesture removes things elsewhere on the map.
|
||||
contextmenu: (e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent.preventDefault()
|
||||
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ via, position, handlers }) => (
|
||||
<Marker
|
||||
key={`via-${via.id}`}
|
||||
position={position}
|
||||
icon={VIA_ICON}
|
||||
draggable={draggable}
|
||||
zIndexOffset={400}
|
||||
eventHandlers={handlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{t('roadtrip.via.hint')}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
} from './RouteCalculator'
|
||||
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
|
||||
const FOSSGIS = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
@@ -47,7 +46,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -57,7 +56,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
|
||||
)
|
||||
)
|
||||
@@ -67,7 +66,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(800, 360))
|
||||
)
|
||||
)
|
||||
@@ -79,7 +78,7 @@ describe('calculateRoute', () => {
|
||||
const distance = 5000
|
||||
const osrmDuration = 999
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/walking/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
|
||||
)
|
||||
)
|
||||
@@ -91,7 +90,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
)
|
||||
@@ -100,7 +99,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
)
|
||||
)
|
||||
@@ -109,7 +108,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -129,7 +128,7 @@ describe('calculateSegments', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
HttpResponse.json({
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
@@ -326,7 +325,7 @@ describe('parsePluginProfile', () => {
|
||||
describe('calculateRoute profiles', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.cycling}/:coords`, () =>
|
||||
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
|
||||
)
|
||||
)
|
||||
@@ -341,12 +340,12 @@ describe('calculateRoute profiles', () => {
|
||||
|
||||
describe('calculateSegments failures', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
|
||||
})
|
||||
})
|
||||
@@ -614,251 +613,3 @@ describe('generateCoMapsUrl', () => {
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a self-hosted routing engine', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
const useOwnRouter = (base: string) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('osrm.example.org')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
|
||||
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
|
||||
|
||||
expect(asked).toContain('/route/v1/foot/')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
|
||||
useOwnRouter('https://osrm.example.org///')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).not.toContain('//route')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
|
||||
useOwnRouter(' ')
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('routing.openstreetmap.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAlternatives', () => {
|
||||
const twoRoutes = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
// The direct one, straight down the middle.
|
||||
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
|
||||
// A detour that swings well to the east before rejoining.
|
||||
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
|
||||
],
|
||||
}
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(twoRoutes)
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toContain('alternatives=3')
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].distance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
// The first is what the router gives anyway, so it needs no pinning point.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
// The second diverges at its eastern swing — saving that as a via forces this road.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
/** The router's own answer, and whatever it says when a road class is left out. */
|
||||
const withExclusions = (byExclude: Record<string, unknown>) =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
const answer = byExclude[exclude]
|
||||
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
|
||||
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
|
||||
// the identical line a second time would be a choice that is not a choice.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
|
||||
// OSRM answers most long legs with exactly one route, which used to mean the picker
|
||||
// said there was no other way — while a perfectly good slower road existed.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
// Pinnable like any other offer: the point where it leaves the direct line.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toEqual(['motorway', 'toll'])
|
||||
expect(routes[1].avoids).toBe('toll')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
|
||||
// The foot profile has no excludable classes; asking earns an InvalidOptions for
|
||||
// nothing, and a walk has no motorway to avoid in the first place.
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
|
||||
|
||||
expect(asked).toEqual([])
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
|
||||
// Both public hosts TREK ships with answer 400 here, so without this every long leg
|
||||
// pays two extra requests against a one-per-second limit to be refused twice.
|
||||
const asked: string[] = []
|
||||
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
asked.push(exclude)
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}))
|
||||
|
||||
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
|
||||
const afterFirst = asked.length
|
||||
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
|
||||
|
||||
expect(afterFirst).toBe(1)
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
// route, while the drive really starts somewhere else.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [
|
||||
{ location: [10.02, 53.51], distance: 812.5 },
|
||||
{ location: [13.0, 52.0], distance: 4 },
|
||||
],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toEqual([
|
||||
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
|
||||
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
|
||||
// real number rather than the whole answer being discarded.
|
||||
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
|
||||
expect(r.snapped?.[0].meters).toBeLessThan(1200)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
|
||||
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
|
||||
// bad entry would hang the spur off the wrong stop.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toBeUndefined()
|
||||
expect(r.coordinates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
|
||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
// the matching profile so walking routes follow footpaths, not the road network.
|
||||
@@ -13,74 +14,6 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
|
||||
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'driving',
|
||||
walking: 'foot',
|
||||
cycling: 'bike',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to ask for a route.
|
||||
*
|
||||
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
|
||||
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
|
||||
* `routing_base_url` and everything routes against that instead; the server has to name
|
||||
* the same origin in its connect-src, or the browser blocks the requests silently.
|
||||
*
|
||||
* A configured base is expected to serve the standard OSRM layout,
|
||||
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
|
||||
*/
|
||||
/**
|
||||
* A configured base URL without its trailing slashes.
|
||||
*
|
||||
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
|
||||
* slashes, and the value comes from an instance setting somebody types. Linear either
|
||||
* way in practice, but the regex is the shape a scanner is right to flag.
|
||||
*/
|
||||
function withoutTrailingSlashes(value: string): string {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end -= 1
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
if (!configured) return OSRM_PROFILE_BASE[profile]
|
||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
* The public OSRM instances allow roughly one request a second and answer 429 above that.
|
||||
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
|
||||
* try again" from "these two points are not connected by road" — and a road trip, which
|
||||
* asks for every leg of every day, hits the first case constantly while the second is
|
||||
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
|
||||
*/
|
||||
export class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
|
||||
/** True when waiting and asking again is the right response. */
|
||||
get isRateLimit(): boolean {
|
||||
return this.status === 429 || this.status === 503
|
||||
}
|
||||
}
|
||||
|
||||
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
|
||||
function retryAfterMs(response: Response): number | null {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
if (!raw) return null
|
||||
const seconds = Number(raw)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
||||
const at = Date.parse(raw)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
@@ -112,11 +45,11 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
throw new Error('Route could not be calculated')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -319,10 +252,10 @@ export async function calculateSegments(
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -411,13 +344,10 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
|
||||
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -444,8 +374,7 @@ export async function calculateRouteWithLegs(
|
||||
}
|
||||
)
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
@@ -454,37 +383,6 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
|
||||
*
|
||||
* OSRM reports this in every response and TREK has thrown it away since the first route
|
||||
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
|
||||
* used; it is recomputed from the two points when the field is missing or nonsense, so a
|
||||
* mirror that trims its answers still produces a usable gap rather than a wrong one.
|
||||
*
|
||||
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
|
||||
* and one bad entry would put a spur on the wrong stop.
|
||||
*/
|
||||
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
|
||||
const raw = (data as { waypoints?: unknown })?.waypoints
|
||||
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
|
||||
const out: SnappedWaypoint[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const loc = (raw[i] as { location?: unknown })?.location
|
||||
if (!Array.isArray(loc) || loc.length < 2) return undefined
|
||||
const [lng, lat] = loc as [number, number]
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
|
||||
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const at: [number, number] = [lat, lng]
|
||||
const reported = (raw[i] as { distance?: unknown })?.distance
|
||||
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
|
||||
? reported
|
||||
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
|
||||
out.push({ asked, at, meters })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function getDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
}
|
||||
@@ -505,176 +403,3 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
}
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
*
|
||||
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
|
||||
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
|
||||
* built through the MLD pipeline supports it. Without this, every long leg on a default
|
||||
* install pays two extra requests to be told twice that it cannot have what it asked for,
|
||||
* against a host that allows one request per second.
|
||||
*
|
||||
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
|
||||
* because only a 400 is about the host rather than about this particular question: a 429
|
||||
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
|
||||
* way round the motorway while the next one might.
|
||||
*
|
||||
* Per base URL rather than global, so pointing the instance at an own router that does
|
||||
* support it starts asking again without a reload.
|
||||
*/
|
||||
const excludeUnsupported = new Set<string>()
|
||||
|
||||
/**
|
||||
* One route with a road class left out, or null when the router will not or cannot.
|
||||
*
|
||||
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
|
||||
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
if (response.status === 400) excludeUnsupported.add(base)
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
|
||||
if (!route?.geometry?.coordinates?.length) return null
|
||||
return {
|
||||
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
divergence: null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, not one day.
|
||||
*
|
||||
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
|
||||
* asking for a whole day would mean splitting it into one request per leg — which is the
|
||||
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
|
||||
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
|
||||
*
|
||||
* `divergence` is the point on each alternative that lies furthest from the default route.
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
|
||||
|
||||
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
|
||||
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
}))
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
|
||||
/**
|
||||
* jsdom has no drag implementation, so these drive the events the browser would
|
||||
@@ -82,93 +82,3 @@ describe('makeMarkerDraggable', () => {
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePoiDraggable', () => {
|
||||
afterEach(() => {
|
||||
window.__dragData = null
|
||||
document.body.classList.remove('dragging-poi')
|
||||
})
|
||||
|
||||
const dragEvent = (type: string) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(e, 'dataTransfer', {
|
||||
value: {
|
||||
setData: (k: string, v: string) => { store[k] = v },
|
||||
getData: (k: string) => store[k] ?? '',
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
expect(el.getAttribute('draggable')).toBe('true')
|
||||
|
||||
const e = dragEvent('dragstart')
|
||||
el.dispatchEvent(e)
|
||||
|
||||
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
|
||||
// Mirrored on window because dataTransfer is unreadable during dragover, and the
|
||||
// map has to know what is coming while it is still being hovered.
|
||||
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(true)
|
||||
|
||||
el.dispatchEvent(dragEvent('dragend'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(false)
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const seen = vi.fn()
|
||||
document.body.appendChild(el)
|
||||
document.body.addEventListener('mousedown', seen)
|
||||
|
||||
el.dispatchEvent(down)
|
||||
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
document.body.removeEventListener('mousedown', seen)
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
|
||||
const el = document.createElement('div')
|
||||
const off = makePoiDraggable(el, 'node:9')
|
||||
off()
|
||||
|
||||
expect(el.hasAttribute('draggable')).toBe(false)
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const start = dragEvent('dragstart')
|
||||
el.dispatchEvent(start)
|
||||
|
||||
expect(draggedPoiId(start)).toBe('node:9')
|
||||
|
||||
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBe('node:9')
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,51 +48,3 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
|
||||
*
|
||||
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
|
||||
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
|
||||
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
|
||||
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
|
||||
* just as well once it is projected onto the routed geometry.
|
||||
*/
|
||||
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('poiOsmId', osmId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { poiOsmId: osmId }
|
||||
el.classList.add('marker-dragging')
|
||||
document.body.classList.add('dragging-poi')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
el.classList.remove('marker-dragging')
|
||||
document.body.classList.remove('dragging-poi')
|
||||
}
|
||||
// Same reason as above: without this the map pans out from under the pointer as the
|
||||
// drag begins, and the route is impossible to aim at.
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('dragend', onDragEnd)
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
|
||||
return () => {
|
||||
el.removeAttribute('draggable')
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('dragend', onDragEnd)
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/** The OSM id being dragged, when a corridor hit is in flight. */
|
||||
export function draggedPoiId(e: DragEvent): string | null {
|
||||
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
|
||||
if (fromTransfer) return fromTransfer
|
||||
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
|
||||
return window.__dragData?.poiOsmId ?? null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
|
||||
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
@@ -19,27 +15,15 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
// The colour lives with the road-trip categories, because the corridor search offers
|
||||
// the same one and two definitions of a blue is how they drift apart.
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
/**
|
||||
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
|
||||
* the explore pill, they are what the road trip corridor searches for. They are in the
|
||||
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
|
||||
* instead of the nameless grey dot an unknown category falls back to.
|
||||
*/
|
||||
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
@@ -50,25 +34,10 @@ export interface Poi {
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
|
||||
brand?: string | null
|
||||
brand_wikidata?: string | null
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
/**
|
||||
* What a charging station offers, when it is one and OSM says so.
|
||||
*
|
||||
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
|
||||
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
|
||||
* own answer here and must not be read as a no.
|
||||
*/
|
||||
charging?: {
|
||||
sockets: { type: string; count: number | null; kw: number | null }[]
|
||||
capacity: number | null
|
||||
fee: boolean | null
|
||||
} | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The list both map renderers rebuild their via handles from.
|
||||
*
|
||||
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
|
||||
* changes, and a pointerdown belongs to the element it happened on. A list that changed
|
||||
* on every render meant a freshly placed via lost its first drag to the re-route landing
|
||||
* underneath it.
|
||||
*/
|
||||
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
|
||||
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
|
||||
})
|
||||
|
||||
describe('useStableVias', () => {
|
||||
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
|
||||
// Exactly what a reload hands down: identical rows, a new object every time.
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 })] } })
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current[0].lat).toBe(48.9)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
|
||||
})
|
||||
expect(result.current.map(v => v.id)).toEqual([5, 6])
|
||||
|
||||
rerender({ byDay: undefined })
|
||||
const empty = result.current
|
||||
expect(empty).toEqual([])
|
||||
rerender({ byDay: {} })
|
||||
expect(result.current).toBe(empty)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useRef } from 'react'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The trip's via points as an array that only changes when a via does.
|
||||
*
|
||||
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
|
||||
* replaces the DOM element outright. The list it was given changed on every render: the
|
||||
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
|
||||
* on every reload even when the rows came back identical.
|
||||
*
|
||||
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
|
||||
* re-route, the re-route lands about a second later, the render it causes replaced the
|
||||
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
|
||||
* the drag went to an element that no longer existed. The second attempt worked because
|
||||
* by then nothing was pending. An older via never showed it: nothing was re-routing.
|
||||
*
|
||||
* Keyed by id and position because those are the only fields a handle is built from. A
|
||||
* via that genuinely moved gets a new list, which is what redraws it where it now is.
|
||||
*/
|
||||
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
|
||||
const flat = Object.values(viasByDay ?? {}).flat()
|
||||
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
|
||||
// Read and written during render on purpose: this is a cache of the argument, not
|
||||
// state — it never triggers a render of its own, and it has to be settled before the
|
||||
// effects that consume the list run.
|
||||
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
|
||||
if (held.current.key !== key) held.current = { key, list: flat }
|
||||
return held.current.list
|
||||
}
|
||||
@@ -353,22 +353,6 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
// A rental with stops is a drive, and the printout is what people take
|
||||
// into the car (#1797). Without this the route reads as pick-up and return
|
||||
// with everything in between missing.
|
||||
//
|
||||
// Consecutive repeats collapse, and a chain that names one place only is
|
||||
// dropped: the ordinary rental is picked up and returned at the same desk,
|
||||
// which satisfies "two endpoints" and would otherwise print the counter's
|
||||
// name twice with an arrow between. A genuine round trip keeps both ends,
|
||||
// because Dresden sits between them.
|
||||
const stops = (r.endpoints || []).slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => e.name)
|
||||
.filter((name, i, all) => name && name !== all[i - 1])
|
||||
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -17,8 +16,6 @@ interface Props {
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
@@ -50,7 +47,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
const myReq = ++reqIdRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
if (myReq !== reqIdRef.current) return
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -20,8 +19,6 @@ interface Props {
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
@@ -52,7 +49,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
} catch {
|
||||
|
||||
@@ -169,28 +169,6 @@ describe('PlaceDetailsColumn', () => {
|
||||
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
|
||||
})
|
||||
|
||||
it('FE-PDC-010b: credits a website description by host', async () => {
|
||||
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
|
||||
// restaurant's own marketing copy would have been credited to an
|
||||
// encyclopaedia. There is no fixed name for "the place's own website", and
|
||||
// the host is the honest one.
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: {
|
||||
text: 'Pizza in Rostock, seit 2015.',
|
||||
source: 'website',
|
||||
sourceUrl: 'https://www.losteria.net/rostock',
|
||||
license: null,
|
||||
},
|
||||
})
|
||||
renderColumn()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /losteria\.net/ })
|
||||
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
|
||||
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
|
||||
renderColumn()
|
||||
|
||||
@@ -379,37 +379,6 @@ function PhotoTile({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the description came from, as the reader would name them.
|
||||
*
|
||||
* `website` has no fixed name — it is the place's own site — so it is credited
|
||||
* by host: "losteria.net" says where the text is from far better than a generic
|
||||
* word would. Deliberately not a ternary chain: the previous one fell through
|
||||
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
|
||||
* encyclopaedia.
|
||||
*/
|
||||
function descriptionSourceLabel(
|
||||
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
|
||||
sourceUrl: string | null,
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'osm':
|
||||
return 'OpenStreetMap'
|
||||
case 'wikivoyage':
|
||||
return 'Wikivoyage'
|
||||
case 'wikipedia':
|
||||
return 'Wikipedia'
|
||||
case 'website':
|
||||
try {
|
||||
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google'
|
||||
if (source === 'wikipedia') return 'Wikipedia'
|
||||
@@ -690,7 +659,14 @@ function DescriptionBlock({
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
|
||||
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
|
||||
const sourceLabel =
|
||||
description.source === 'google'
|
||||
? 'Google'
|
||||
: description.source === 'osm'
|
||||
? 'OpenStreetMap'
|
||||
: description.source === 'wikivoyage'
|
||||
? 'Wikivoyage'
|
||||
: 'Wikipedia'
|
||||
|
||||
// A description of the chain is not a description of this place, and the
|
||||
// heading is where that gets said. Without it the reader has no way to tell
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
export interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
@@ -26,14 +24,6 @@ export interface PlaceFormData {
|
||||
// submit payload when unchanged, and useTripPlanner strips it off the place
|
||||
// update and PUTs it per assignment instead.
|
||||
assignment_notes?: string
|
||||
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
|
||||
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
|
||||
// place created outside a road trip has no opinion about fuel stops.
|
||||
stop_type?: RoadtripStopType | null
|
||||
// Offered only while creating, and only alongside a stop_type: the corridor popup
|
||||
// suggests how long that kind of pause usually takes, and the full form should not
|
||||
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
||||
duration_minutes?: number
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { STOP_KINDS } from '../Roadtrip/stopKinds'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -19,7 +16,6 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
@@ -40,7 +36,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -90,20 +86,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
||||
onOpenExpense,
|
||||
} = props
|
||||
// Hidden while the addon is off, because the kinds only mean anything to the road trip
|
||||
// rail: on an instance without it they would be six labels that change nothing.
|
||||
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
/**
|
||||
* What produced the list currently on screen, kept for the shadow log: the
|
||||
* query as typed and the provider the envelope named. A ref rather than
|
||||
* state because nothing renders from it and a pick must read the value that
|
||||
* belonged to the list, not a value a re-render replaced.
|
||||
*/
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
@@ -158,11 +143,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// Carried through every edit. Without it, opening a fuel stop to fix a typo
|
||||
// submits an empty kind and turns it back into a numbered destination.
|
||||
// duration_minutes deliberately stays out: how long a stay takes belongs to the
|
||||
// rail's own dialog, and sending it from here would overwrite what was set there.
|
||||
stop_type: place.stop_type ?? null,
|
||||
// The day-specific note rides only with an assignment in context (#2163);
|
||||
// otherwise the key stays absent so submit never sends a notes write.
|
||||
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
|
||||
@@ -177,8 +157,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
stop_type: prefillCoords.stop_type ?? null,
|
||||
duration_minutes: prefillCoords.duration_minutes,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
@@ -235,11 +213,32 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
// Where the trip is happening — the hint that tells the search which of a
|
||||
// thousand identically named places is meant. Autocomplete wants a box, the
|
||||
// search wants a point; useLocationBias derives both from the same places.
|
||||
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
|
||||
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||
const dlat = maxLat - minLat
|
||||
const dlng = maxLng - minLng
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
|
||||
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
@@ -253,7 +252,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
@@ -312,8 +310,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -322,13 +319,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `pick` is present only when the click came from a ranked list. The
|
||||
* collection picker and the autocomplete detour reach this function with a
|
||||
* place that was never ranked against a query, and a made-up rank would be
|
||||
* worse than no row at all.
|
||||
*/
|
||||
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
const handleSelectMapsResult = (result) => {
|
||||
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
|
||||
// The one point every pick flows through, so the detail column hangs here.
|
||||
// A new pick drops whatever hero image belonged to the previous place.
|
||||
@@ -345,36 +336,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
if (meta) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
// The bias the search actually ran under is a box around the trip's
|
||||
// existing places; the corpus stores its centre, which is what an
|
||||
// evaluation needs to bias its own index the same way.
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: result.name || '',
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: result.google_place_id || result.osm_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -401,11 +368,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
const search = await mapsApi.search(query, language)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
handleSelectMapsResult(place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -512,15 +479,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat: form.lat ? Number.parseFloat(form.lat) : null,
|
||||
lng: form.lng ? Number.parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
// An explicit null is how a stop stops being a fuel stop; the service reads it
|
||||
// that way rather than as "leave alone", which is what a missing key means.
|
||||
stop_type: form.stop_type || null,
|
||||
// Only on the way in, and only with a kind: it is the popup's suggestion for how
|
||||
// long that kind of pause takes. On an edit it is left out entirely, because the
|
||||
// stay belongs to the rail's dialog and sending it here would overwrite it.
|
||||
...(!place && form.stop_type && form.duration_minutes
|
||||
? { duration_minutes: form.duration_minutes }
|
||||
: {}),
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
}
|
||||
// #2163: the per-assignment note only travels when an assignment is in
|
||||
@@ -569,7 +527,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -642,7 +599,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -812,7 +768,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
@@ -914,30 +870,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What kind of stop this is on a drive.
|
||||
Under the category rather than beside it, because the two answer different
|
||||
questions: a category is the traveller's own label, editable and shared across
|
||||
the whole instance, while this is a fact about the place. Refuelling is not a
|
||||
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
|
||||
is still a fuel stop when the mode is switched on later, and this is the only
|
||||
place in the web UI where the kind can be taken off again. */}
|
||||
{roadtripEnabled ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
|
||||
<CustomSelect
|
||||
value={form.stop_type ?? ''}
|
||||
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
|
||||
placeholder={t('roadtrip.stop.none')}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.stop.none') },
|
||||
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
|
||||
|
||||
@@ -43,9 +43,6 @@ export function ListImportModal(S: SidebarState) {
|
||||
)}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
|
||||
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
{/* Same box, same Share button: which screen the link came from is the URL's
|
||||
business, and until it said so nobody knew a route could be pasted here. */}
|
||||
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -175,29 +175,6 @@ describe('TransitSearchPanel', () => {
|
||||
await user.click(await screen.findByText(/London Victoria/))
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty result is ambiguous without the backend's name: the fallback to
|
||||
* Transitous is silent, so "no connections" could mean the provider has no
|
||||
* data here or that the provider you picked never ran (#1699).
|
||||
*/
|
||||
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Accommodation } from '../../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -37,14 +36,6 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
|
||||
|
||||
export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
|
||||
// Backend names as they are written, not translated — the empty state says
|
||||
// which one answered so "nothing here" can be told apart from "not the one you
|
||||
// picked" (#1699).
|
||||
const PROVIDER_NAMES: Record<TransitProvider, string> = {
|
||||
transitous: 'Transitous',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
|
||||
@@ -349,7 +340,7 @@ interface TransitSearchPanelProps {
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -364,7 +355,6 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
const [provider, setProvider] = useState<TransitProvider | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
@@ -397,7 +387,6 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
setLoading(true)
|
||||
setItineraries(null)
|
||||
setExpandedIdx(null)
|
||||
setProvider(null)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const tzTo = tzAt(to.lat, to.lng)
|
||||
@@ -406,11 +395,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
|
||||
const allModes = activeModes.size === MODE_GROUPS.length
|
||||
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
|
||||
// Which backend actually answered (#1699). Named in the empty state so
|
||||
// "nothing found" can be told apart from "the provider you picked never
|
||||
// ran" — the fallback to Transitous is silent by design.
|
||||
setProvider(d.provider ?? null)
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
|
||||
// MOTIS names the request coordinates START/END — swap in the places the
|
||||
// user actually picked so walks read "Walk to Zoologischer Garten".
|
||||
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
|
||||
@@ -621,9 +606,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
)}
|
||||
{!loading && ranked && ranked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{provider
|
||||
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
|
||||
: t('transit.noResults')}
|
||||
{t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
|
||||
@@ -1192,93 +1192,6 @@ describe('TransportModal', () => {
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
||||
// Pick-up and return are the rental frame; the stops sit between them.
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
// Pick-up and return come first in the form, the stops follow — so the saved
|
||||
// route is from · stop · stop · to even though the fields read in another order.
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
expect(fields).toHaveLength(4);
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
// The order of the stops is the order they are driven, and `sequence` is derived
|
||||
// from it on save — so without this the only way to swap two was to retype both.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const up = screen.getAllByRole('button', { name: /Move up/i });
|
||||
const down = screen.getAllByRole('button', { name: /Move down/i });
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[down.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
|
||||
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
|
||||
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -116,19 +116,6 @@ interface StationWaypointForm {
|
||||
seat: string
|
||||
confirmation_number: string
|
||||
}
|
||||
// ── Car stops along the drive ──────────────────────────────────────────────
|
||||
//
|
||||
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
|
||||
// train — the stops in between are not legs of their own: they are places the drive
|
||||
// passes through, each with an optional time the driver plans to be there. They persist
|
||||
// as the same `role: 'stop'` endpoints every other transport type already uses.
|
||||
interface CarStopForm {
|
||||
location: LocationPoint | null
|
||||
time: string
|
||||
}
|
||||
|
||||
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
|
||||
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
|
||||
}
|
||||
@@ -213,20 +200,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
// A car keeps its pick-up and return as the frame of the rental and gains the stops
|
||||
// in between (#1797): one booking, one continuous drive, several places along it.
|
||||
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
||||
|
||||
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
|
||||
const moveCarStop = (index: number, delta: number): void => {
|
||||
setCarStops(prev => {
|
||||
const to = index + delta
|
||||
if (to < 0 || to >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
;[next[index], next[to]] = [next[to], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -373,17 +346,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
// Stops persist for every type; only a car offers an editor for them, so only a
|
||||
// car reads them back into one. The others keep passing theirs through untouched.
|
||||
setCarStops(
|
||||
src.type === 'car'
|
||||
? (src.endpoints ?? [])
|
||||
.filter(e => e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
@@ -392,7 +354,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
setCarStops([])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -543,14 +504,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// A car writes the stops the driver planned; every other type keeps passing the
|
||||
// itinerary's transfer stops through while the route is unchanged (#1065).
|
||||
const carEndpoints = form.type === 'car'
|
||||
? carStops
|
||||
.filter(s => s.location)
|
||||
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
|
||||
: []
|
||||
const stops = keepTransit && form.type !== 'car'
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
const stops = keepTransit
|
||||
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
: []
|
||||
stops.forEach((s, i) => endpoints.push({
|
||||
@@ -558,9 +513,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
||||
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
||||
}))
|
||||
carEndpoints.forEach(e => endpoints.push(e))
|
||||
const stopCount = stops.length + carEndpoints.length
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
@@ -1002,76 +955,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
|
||||
pick-up and return; these are the places in between, in order. */}
|
||||
{form.type === 'car' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
|
||||
{carStops.map((stop, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
{/* The order of these stops IS the route: `sequence` is the array index
|
||||
at save time, so the only way to change which one is driven first
|
||||
was to delete both and re-enter them. Buttons rather than dragging,
|
||||
because the row already carries a location picker and a time picker
|
||||
and there is nothing left to grab. */}
|
||||
{carStops.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, 1)}
|
||||
disabled={i === carStops.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect
|
||||
value={stop.location}
|
||||
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 110, flexShrink: 0 }}>
|
||||
<CustomTimePicker
|
||||
value={stop.time}
|
||||
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
|
||||
aria-label={t('roadtrip.stops.remove')}
|
||||
className="text-content-faint hover:text-danger"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
|
||||
className="text-content-faint hover:text-content-secondary"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
|
||||
*
|
||||
* Every value shown comes from the same overlay the map draws. Working them out
|
||||
* here separately is what once made the list label the driven route "Fastest"
|
||||
* while offering another one as quicker beside it, because it assumed the first
|
||||
* entry was the quickest — which stopped being true the moment the road
|
||||
* currently driven was put at the top.
|
||||
*/
|
||||
|
||||
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
|
||||
index: 0,
|
||||
coordinates: [],
|
||||
color: '#0a84ff',
|
||||
label: '3 h',
|
||||
note: '',
|
||||
duration: 10_800,
|
||||
distance: 290_000,
|
||||
slowerThanQuickest: 0,
|
||||
labelBg: '#0a84ff',
|
||||
at: { lat: 53, lng: 11 },
|
||||
...over,
|
||||
})
|
||||
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
|
||||
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
})
|
||||
|
||||
describe('RoadtripAlternativesBar', () => {
|
||||
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
|
||||
const { container } = render(
|
||||
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
|
||||
// Offering a single option would ask the traveller to pick the road they are
|
||||
// already on.
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/only/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Fastest')).toBeInTheDocument()
|
||||
expect(screen.getByText('No motorway')).toBeInTheDocument()
|
||||
expect(screen.getByText(/290/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/310/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
|
||||
// The map and the list agree on `index`; a position would drift the moment
|
||||
// the driven road is prepended.
|
||||
const onChoose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
|
||||
onChoose={onChoose}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(onChoose).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
|
||||
const onHighlight = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onHighlight={onHighlight}
|
||||
/>,
|
||||
)
|
||||
const first = screen.getAllByRole('button')[1]
|
||||
fireEvent.mouseEnter(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
fireEvent.mouseLeave(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(null)
|
||||
// Keyboard too: the same road lights up on focus.
|
||||
fireEvent.focus(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Shuffle, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
/** The very rows the map draws, so the two can never disagree about which is fastest. */
|
||||
overlays: AlternativeOverlay[]
|
||||
onChoose: (index: number) => void
|
||||
onClose: () => void
|
||||
/** Reports which option the pointer is on, so the map can light that road up. */
|
||||
onHighlight?: (index: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, offered over the map.
|
||||
*
|
||||
* A bar rather than a dialog: the answer is on the map behind it — which road each option
|
||||
* takes — so covering the map to ask about it would hide the only thing worth looking at.
|
||||
*
|
||||
* Every value here comes from the same overlay the map draws. Working them out separately
|
||||
* is what made the list label the driven route "Fastest" while offering another one
|
||||
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
|
||||
* being true the moment the road currently driven was put at the top.
|
||||
*/
|
||||
export default function RoadtripAlternativesBar({
|
||||
open, overlays, onChoose, onClose, onHighlight,
|
||||
}: RoadtripAlternativesBarProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.alt.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open.loading ? (
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
|
||||
) : open.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.alt.failed')}
|
||||
</p>
|
||||
) : overlays.length < 2 ? (
|
||||
// One route back means there is genuinely only one sensible way to drive it.
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{overlays.map(alt => (
|
||||
<button
|
||||
key={alt.index}
|
||||
type="button"
|
||||
onClick={() => onChoose(alt.index)}
|
||||
onMouseEnter={() => onHighlight?.(alt.index)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
onFocus={() => onHighlight?.(alt.index)}
|
||||
onBlur={() => onHighlight?.(null)}
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-caption font-medium tabular-nums text-content">
|
||||
{formatDistance(alt.distance / 1000, distanceUnit)}
|
||||
</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{alt.note
|
||||
? alt.note
|
||||
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Fuel, Zap, Utensils } from 'lucide-react'
|
||||
import { fireEvent, render, screen, within } from '../../../tests/helpers/render'
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
|
||||
/**
|
||||
* FE-CATPICK-001..008 — what the corridor is asked to look for.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one
|
||||
* gesture rather than two round trips through a menu. The summary names the
|
||||
* kinds rather than counting them, because "Fuel, Charging" is the answer and
|
||||
* "2 kinds" is a riddle.
|
||||
*/
|
||||
|
||||
const KEYS = ['fuel', 'charging', 'food'] as const
|
||||
const META = {
|
||||
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
|
||||
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
|
||||
food: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
|
||||
}
|
||||
|
||||
function picker(selected: string[] = [], onToggle = vi.fn()) {
|
||||
render(<RoadtripCategoryPicker keys={KEYS} meta={META} selected={selected} onToggle={onToggle} />)
|
||||
return { onToggle, trigger: () => screen.getByRole('button', { expanded: false }) }
|
||||
}
|
||||
|
||||
/** The control that opens the list, whatever its current state. */
|
||||
const trigger = () => screen.getAllByRole('button')[0]
|
||||
|
||||
describe('RoadtripCategoryPicker', () => {
|
||||
it('FE-CATPICK-001: closed by default, so the column belongs to the results', () => {
|
||||
picker()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-002: with nothing picked it asks rather than reporting an empty set', () => {
|
||||
picker()
|
||||
expect(screen.getByText('Looking for')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
|
||||
// Reversed on the way in: the control lists them the way it offers them, not
|
||||
// the way they happened to be clicked.
|
||||
picker(['food', 'fuel'])
|
||||
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-CATPICK-004: opening lists every kind', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
for (const label of ['Fuel', 'Charging', 'Food']) {
|
||||
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-CATPICK-005: picking one reports it and leaves the list open', () => {
|
||||
// The whole reason it is multi-select: two kinds is one gesture.
|
||||
const { onToggle } = picker()
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByText('Charging'))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith('charging')
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-006: picking a kind that is already on reports it too, for the toggle', () => {
|
||||
const { onToggle } = picker(['fuel'])
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getAllByText('Fuel')[1] ?? screen.getByText('Fuel'))
|
||||
expect(onToggle).toHaveBeenCalledWith('fuel')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-007: a click outside closes it, a click inside does not', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
|
||||
fireEvent.mouseDown(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-008: Escape closes it', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
interface RoadtripCategoryPickerProps {
|
||||
/** Every kind the corridor can look for, in the order they are offered. */
|
||||
keys: readonly string[]
|
||||
meta: Record<string, { labelKey: string; Icon: LucideIcon }>
|
||||
selected: readonly string[]
|
||||
onToggle: (key: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What to look for, as one full-width control.
|
||||
*
|
||||
* Six pills wrapped onto three lines and took the height of the whole card for a set of
|
||||
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
|
||||
* to the results — which is what the column is actually for.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
|
||||
* rather than two round trips through a menu. Each row leads with its own coloured icon,
|
||||
* the same one the rail puts on its dashed line and the map puts on the route.
|
||||
*/
|
||||
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onPointer)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
|
||||
// is a riddle. Truncated when it runs long, which is what the icons in front are for.
|
||||
const summary = selected.length
|
||||
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
|
||||
: t('roadtrip.poi.looking')
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
|
||||
const Icon = meta[k].Icon
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="grid h-[18px] w-[18px] place-items-center rounded-md"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`.
|
||||
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={10} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
|
||||
>
|
||||
{keys.map(key => {
|
||||
const { labelKey, Icon } = meta[key]
|
||||
const on = selected.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
// Stays open: picking two kinds is one gesture, not two.
|
||||
onClick={() => onToggle(key)}
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripCorridorPanel from './RoadtripCorridorPanel'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const stop = (id: number, name: string): RoadtripStop => ({
|
||||
assignmentId: id,
|
||||
placeId: id * 10,
|
||||
name,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
})
|
||||
|
||||
const day = (dayId: number, dayNumber: number): RoadtripDay => ({
|
||||
dayId,
|
||||
dayNumber,
|
||||
date: null,
|
||||
title: null,
|
||||
stops: [stop(dayId * 10 + 1, 'Hamburg'), stop(dayId * 10 + 2, 'Berlin')],
|
||||
legs: [],
|
||||
schedule: { entries: [], warnings: [] },
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
// Routed. Searching before the day has a geometry builds the corridor from the
|
||||
// straight line between the stops, and the routing answer landing a second
|
||||
// later clears the search mid-flight — so the button waits for this.
|
||||
geometry: [[53.55, 9.99], [52.52, 13.4]] as [number, number][],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
})
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days,
|
||||
lines: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
segments: [],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
totalStops: days.length * 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
|
||||
return {
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
category: 'fuel',
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
offRouteKm: 1.2,
|
||||
alongKm: 40,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<RoadtripCorridor['search']> = {}): RoadtripCorridor {
|
||||
const days = over.day ? [over.day] : [day(1, 1)]
|
||||
const searchState: RoadtripCorridor['search'] = {
|
||||
results: [],
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
spine: [],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
...search,
|
||||
}
|
||||
return {
|
||||
dayId: String(days[0].dayId),
|
||||
setDayId: vi.fn(),
|
||||
day: days[0],
|
||||
categories: ['fuel'],
|
||||
toggleCategory: vi.fn(),
|
||||
widthKm: 5,
|
||||
setWidthKm: vi.fn(),
|
||||
nameFilter: '',
|
||||
anchors: [],
|
||||
section: null,
|
||||
setSection: vi.fn(),
|
||||
sectionKm: 50,
|
||||
setSectionKm: vi.fn(),
|
||||
socketFilter: '',
|
||||
setSocketFilter: vi.fn(),
|
||||
minKw: 0,
|
||||
setMinKw: vi.fn(),
|
||||
setNameFilter: vi.fn(),
|
||||
// Same as the hook with an empty filter: everything found is on show.
|
||||
visible: searchState.results,
|
||||
insertIndexFor: vi.fn(() => 1),
|
||||
stopsAlongKm: [0, 100],
|
||||
...over,
|
||||
search: searchState,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripCorridorPanel', () => {
|
||||
it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(c.search.search).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /search/i }))
|
||||
expect(c.search.search).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-004: a day that has not routed yet cannot be searched along', () => {
|
||||
// Until the day routes, the corridor is the straight line between the stops,
|
||||
// so the boxes march across whatever lies between them instead of along the
|
||||
// roads driven. Worse, the routing answer landing a second later changes the
|
||||
// line, which clears the search mid-flight and drops every result with no
|
||||
// error and no explanation. Routing runs one day at a time, about a second
|
||||
// apart, so on a long trip that window is wide open.
|
||||
const unrouted = { ...day(1, 1), geometry: [] as [number, number][] }
|
||||
const c = corridor({ day: unrouted })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([unrouted])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-005: nor while the routing is still running', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={{ ...routes([day(1, 1)]), loading: true }} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-002: nothing selected means nothing to search for', () => {
|
||||
const c = corridor({ categories: [] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-003: a day with a single stop has no drive to search along', () => {
|
||||
const lonely = { ...day(1, 1), stops: [stop(11, 'Hamburg')] }
|
||||
const c = corridor({ day: lonely })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([lonely])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-004: toggling a kind reports which one', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// Six kinds used to be six pills wrapped over three lines; they are one dropdown now,
|
||||
// so the options only exist once it is open.
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /campsite/i }))
|
||||
expect(c.toggleCategory).toHaveBeenCalledWith('campsite')
|
||||
// What is already selected reads as selected rather than only looking different.
|
||||
expect(screen.getByRole('option', { name: /fuel/i })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-005: the corridor width is a choice, and the current one is marked', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const five = screen.getByRole('button', { name: '5 km' })
|
||||
expect(five).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '10 km' }))
|
||||
expect(c.setWidthKm).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-006: the day picker only appears once there is more than one drive', () => {
|
||||
const one = corridor()
|
||||
const { unmount } = wrap(<RoadtripCorridorPanel corridor={one} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.queryByText('Day 1')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
|
||||
expect(screen.getByText('Day 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-007: groups the hits by kind and counts each group', () => {
|
||||
const c = corridor({ categories: ['fuel', 'campsite'] }, {
|
||||
results: [
|
||||
poi({ osm_id: 'a', name: 'Aral', alongKm: 10 }),
|
||||
poi({ osm_id: 'b', name: 'Shell', alongKm: 20 }),
|
||||
poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite', alongKm: 30 }),
|
||||
],
|
||||
})
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one
|
||||
// inside a <header>.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
|
||||
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
|
||||
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-008: each hit says how far off the route and how far along it is', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', offRouteKm: 1.2, alongKm: 40 })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const row = screen.getByText('Aral').closest('li')!
|
||||
expect(within(row).getByText(/1\.2 km/)).toBeInTheDocument()
|
||||
expect(within(row).getByText(/40 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-009: a hit right at the start says so instead of "0 km along"', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', alongKm: 0.1 })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Aral').closest('li')!.textContent).not.toContain('0 km along')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-010: a chain gets its category icon, never its own logo', () => {
|
||||
// A corridor is mostly chains, and their marks turned a list of petrol stations into
|
||||
// an advertisement — while a brand with no logo on file fell back to a different
|
||||
// picture, so no two rows looked alike. The category's icon is also what the rail and
|
||||
// the map draw for the same stop.
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: 'Q565734' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const row = screen.getByText('Aral').closest('li')!
|
||||
expect(row.querySelector('img')).toBeNull()
|
||||
expect(row.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-012: adding a hit puts it on the day that was searched', () => {
|
||||
const onAddPoi = vi.fn()
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'node:9', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
// Day and position both: a fuel stop belongs where it is driven past, not at the end.
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-023: the position offered is the one worked out for that very hit', () => {
|
||||
const onAddPoi = vi.fn()
|
||||
const hit = poi({ osm_id: 'node:9', name: 'Aral', alongKm: 120 })
|
||||
const insertIndexFor = vi.fn(() => 3)
|
||||
const c = corridor({ insertIndexFor }, { results: [hit] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
expect(insertIndexFor).toHaveBeenCalledWith(hit)
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 3)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
|
||||
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25')
|
||||
expect(screen.getByRole('button', { name: /3/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-014: a partial answer says which stretches nobody looked at', () => {
|
||||
const c = corridor({}, { failedAreas: 2, results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// The difference between "no fuel here" and "nobody looked here".
|
||||
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
|
||||
const c = corridor({}, { error: true, failedAreas: 4 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('The place search is not answering right now.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-016: a drive longer than the budget admits the tail went unsearched', () => {
|
||||
const c = corridor({}, { capped: true })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
|
||||
const empty = corridor()
|
||||
const { unmount } = wrap(<RoadtripCorridorPanel corridor={empty} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.queryByLabelText('Filter by name')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
const found = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={found} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.getByLabelText('Filter by name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-019: typing in the filter reports the term rather than searching again', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Filter by name'), { target: { value: 'shell' } })
|
||||
expect(c.setNameFilter).toHaveBeenCalledWith('shell')
|
||||
// The filter must never trigger another round of Overpass requests.
|
||||
expect(c.search.search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-020: the list shows what is visible, and counts it against the whole', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
const c = corridor({ nameFilter: 'shell', visible: [hits[1]] }, { results: hits })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Shell')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Aral')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 of 2 on the way')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-021: filtering everything away says so instead of looking unsearched', () => {
|
||||
const c = corridor(
|
||||
{ nameFilter: 'esso', visible: [] },
|
||||
{ results: [poi({ osm_id: 'a', name: 'Aral' })] },
|
||||
)
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/Nothing on the way matches/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.queryByText('Add')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,555 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
|
||||
import { FS } from './typeScale'
|
||||
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
interface RoadtripCorridorPanelProps {
|
||||
corridor: RoadtripCorridor
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
|
||||
onAddPoi?: (
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
|
||||
dayId?: number | null,
|
||||
position?: number | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How each socket family is written on the plug.
|
||||
*
|
||||
* Not translated and not derived from the tag: these are proper nouns with an accepted
|
||||
* spelling, and "Type 2" as a lowercase tag key is not what anybody looks for on a
|
||||
* charger. Anything not listed falls through to the raw key rather than being hidden,
|
||||
* because an unknown socket is still worth seeing.
|
||||
*/
|
||||
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
|
||||
const KW_STEPS = [11, 22, 50, 150]
|
||||
|
||||
const SOCKET_LABEL: Record<string, string> = {
|
||||
type2: 'Type 2',
|
||||
type2_combo: 'CCS',
|
||||
type2_cable: 'Type 2 cable',
|
||||
ccs: 'CCS',
|
||||
chademo: 'CHAdeMO',
|
||||
type1: 'Type 1',
|
||||
type1_combo: 'CCS1',
|
||||
schuko: 'Schuko',
|
||||
tesla_supercharger: 'Supercharger',
|
||||
tesla_destination: 'Tesla Destination',
|
||||
}
|
||||
|
||||
/** Label and icon per category, from the one table every road-trip surface reads. */
|
||||
const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
|
||||
|
||||
/** The small capitalised word over a group of controls, matching the rail's own captions. */
|
||||
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
|
||||
|
||||
/** A card in this column: the same corner, hairline and surface the rail's cards use. */
|
||||
const CARD = 'rounded-2xl border border-edge-faint bg-surface-card'
|
||||
|
||||
/**
|
||||
* The tile in front of a result, in its category's own colour.
|
||||
*
|
||||
* Never the brand's logo. A corridor is mostly chains, and putting their marks here made
|
||||
* a list of petrol stations read as an advertisement — while the ones with no logo on
|
||||
* file fell back to a different picture entirely, so no two rows looked alike. The
|
||||
* category's icon in its own tint is the same thing the rail draws on its dashed line and
|
||||
* the map draws on the route, which is what makes a row, a pin and a stop recognisably
|
||||
* one place.
|
||||
*/
|
||||
function ResultBadge({ category }: { category: string }): React.ReactElement {
|
||||
const Icon = CATEGORY_META[category]?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<span
|
||||
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-[10px]"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`, the same colour
|
||||
// this kind of stop carries in the rail and on the map.
|
||||
style={{ background: `${color}1f`, color }}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.9} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
return (
|
||||
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
|
||||
<ResultBadge category={poi.category} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{/* Two facts, two elements. Both are translated phrases rather than short fixed
|
||||
values, so they stay text and the gap does the separating a middot used to —
|
||||
which also lets them wrap onto their own lines instead of breaking around a
|
||||
dot left stranded at the end of a line. */}
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
|
||||
<span>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
</span>
|
||||
{/* What the charger offers, where OSM says. Socket names are proper nouns and
|
||||
stay as they are; the numbers around them are what decides whether a car can
|
||||
use it at all. A station that says nothing shows nothing rather than a row
|
||||
of dashes, because "not stated" is not "no". */}
|
||||
{poi.charging?.sockets.length ? (
|
||||
<span className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
{poi.charging.sockets.slice(0, 3).map(s => (
|
||||
<span key={s.type} className="text-content-muted">
|
||||
{SOCKET_LABEL[s.type] ?? s.type}
|
||||
{s.kw ? ` ${s.kw} kW` : ''}
|
||||
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{poi.charging?.fee === false ? (
|
||||
<span className="text-success">{t('roadtrip.poi.free')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Always there, quiet until the row is under the pointer: a button that only
|
||||
exists on hover is one a keyboard user has to find by faith. */}
|
||||
{onAdd ? (() => {
|
||||
// One button, two meanings. Somewhere to sleep ends the day rather than
|
||||
// interrupting the drive, so the icon and the label say that before the dialog
|
||||
// opens instead of after.
|
||||
const night = poi.category === 'hotel'
|
||||
const label = night ? t('roadtrip.stay.nightAction') : t('roadtrip.poi.add')
|
||||
const Icon = night ? BedDouble : Plus
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
aria-label={label}
|
||||
className="grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-content-faint transition-colors hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text focus-visible:outline-none"
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})() : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
|
||||
function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
category: string
|
||||
pois: CorridorPoi[]
|
||||
dayId: number | null
|
||||
insertIndexFor: RoadtripCorridor['insertIndexFor']
|
||||
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see ResultBadge.
|
||||
style={{ background: color, color: '#fff' }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
|
||||
{meta ? t(meta.labelKey) : category}
|
||||
</span>
|
||||
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
|
||||
{t('roadtrip.poi.found', { count: pois.length })}
|
||||
</span>
|
||||
</header>
|
||||
<ul>
|
||||
{pois.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
// Added where it will be driven past, not at the end of the day.
|
||||
onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "What is on the way" — the right column while road trip mode is on.
|
||||
*
|
||||
* Searching is a button, not a side effect of panning: one run is several Overpass
|
||||
* requests and the mirrors are shared infrastructure. The scope is one day's drive, which
|
||||
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
|
||||
* the day whose route it was found along. Hits are drawn on the map at the same time, so
|
||||
* the list answers "which of these" and the map answers "which side of the road".
|
||||
*
|
||||
* Three cards down one column, the same shapes the rail opposite uses: what to look for,
|
||||
* how the search is getting on, and what it found. The day it searches sits in the
|
||||
* header rather than among the filters — it is the question's subject, not one of its
|
||||
* conditions.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const { search } = corridor
|
||||
|
||||
const dayOptions = routes.days.map(d => ({
|
||||
value: String(d.dayId),
|
||||
label: t('roadtrip.day', { number: d.dayNumber }),
|
||||
}))
|
||||
|
||||
const grouped = CORRIDOR_CATEGORY_KEYS
|
||||
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
|
||||
.filter(g => g.pois.length > 0)
|
||||
|
||||
// Something was found, the filter just hides it — a different state from "not searched
|
||||
// yet" and from "the drive really has none of these".
|
||||
/**
|
||||
* The socket families this search actually turned up, in the order the table lists
|
||||
* them. Offering all ten would put CHAdeMO in the dropdown for a corridor that has
|
||||
* three Type 2 posts on it, which is a filter that can only produce an empty list.
|
||||
*/
|
||||
const socketsFound = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
|
||||
// Compared explicitly: the default sort is by UTF-16 code unit, which is not the
|
||||
// order anybody reads a list of plug names in.
|
||||
return [...seen].sort((a, b) => a.localeCompare(b))
|
||||
}, [search.results])
|
||||
const hasCharging = socketsFound.length > 0
|
||||
|
||||
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
|
||||
|
||||
// The day has to have routed. Until it does, the corridor is built from the
|
||||
// straight line between the stops, so the boxes march across whatever lies
|
||||
// between them rather than along the roads actually driven — and when the
|
||||
// routing answer lands a second later the line changes, which clears the
|
||||
// search mid-flight and drops every result with no error and no explanation.
|
||||
// The routing runs one day at a time, about a second apart, so on a long trip
|
||||
// that window is wide open for the day somebody picks.
|
||||
const dayRouted = (corridor.day?.geometry.length ?? 0) > 1
|
||||
const canSearch = !search.loading
|
||||
&& !routes.loading
|
||||
&& dayRouted
|
||||
&& corridor.categories.length > 0
|
||||
&& (corridor.day?.stops.length ?? 0) > 1
|
||||
const progressPct = search.progress.total
|
||||
? Math.round((search.progress.done / search.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
const warnings: [string, string][] = []
|
||||
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
|
||||
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
// no fuel on this stretch" and "nobody looked at this stretch".
|
||||
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
|
||||
// The stretch was searched and the answer came back short. Without this line a filter
|
||||
// finding nothing looks like proof there is nothing.
|
||||
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
{/* Header — the panel's name, and the day the question is about. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
|
||||
<h2 className="font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
|
||||
{t('roadtrip.poi.title')}
|
||||
</h2>
|
||||
{dayOptions.length > 1 ? (
|
||||
<div className="ms-auto min-w-0">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* What to look for. */}
|
||||
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="h-px bg-edge-faint" aria-hidden />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.within')}</span>
|
||||
<div className="flex gap-1 rounded-xl bg-surface-tertiary p-1">
|
||||
{CORRIDOR_WIDTHS_KM.map(km => (
|
||||
<button
|
||||
key={km}
|
||||
type="button"
|
||||
aria-pressed={corridor.widthKm === km}
|
||||
onClick={() => corridor.setWidthKm(km)}
|
||||
style={{ fontSize: FS.control }}
|
||||
className={`flex h-[26px] flex-1 items-center justify-center rounded-lg tabular-nums transition-colors ${
|
||||
corridor.widthKm === km
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{formatDistance(km, distanceUnit)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* One word, so it can be read at the size a primary action deserves. The panel
|
||||
it sits in is headed "along the route" and every control above it narrows the
|
||||
same search; repeating that on the button only made it small. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={!canSearch}
|
||||
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading
|
||||
? <RotateCw size={15} className="animate-spin" aria-hidden />
|
||||
: <Search size={15} strokeWidth={2} aria-hidden />}
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
|
||||
: t('roadtrip.poi.search')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* How the run is getting on — a row of its own while it lasts, with the share
|
||||
done as a figure rather than only as a bar: a corridor search is many requests
|
||||
and "58%" answers "is this worth waiting for" that a bar only hints at. */}
|
||||
{search.loading ? (
|
||||
<div
|
||||
className="flex h-[32px] flex-shrink-0 items-center gap-2 rounded-xl border border-edge bg-surface-card pe-1.5 ps-3 text-content-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={progressPct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<RotateCw size={13} className="animate-spin" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium" style={{ fontSize: FS.control }}>
|
||||
{t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })}
|
||||
</span>
|
||||
<span
|
||||
className="ms-auto inline-flex h-[22px] shrink-0 items-center rounded-full bg-inverse px-2 font-geist font-semibold tabular-nums tracking-[0.08em] text-inverse-text"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{`${progressPct}%`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="flex flex-shrink-0 flex-col gap-1.5">
|
||||
{warnings.map(([key, text]) => (
|
||||
<p
|
||||
key={key}
|
||||
className={`flex items-start gap-1.5 ${key === 'failed' ? 'text-danger' : 'text-warning'}`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<AlertTriangle size={12} className="mt-px shrink-0" aria-hidden />
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* What was found. */}
|
||||
<div className={`flex min-h-0 flex-1 flex-col overflow-hidden ${CARD}`}>
|
||||
{/* Narrowing what was found. Deliberately not called "search": the button above
|
||||
asks the place search for new results, this only hides rows already in. It
|
||||
appears once there is something to narrow. */}
|
||||
{search.results.length > 0 ? (
|
||||
<div className="flex-shrink-0 border-b border-edge-faint p-3">
|
||||
<div className="relative">
|
||||
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
|
||||
<input
|
||||
type="text"
|
||||
value={corridor.nameFilter}
|
||||
onChange={e => corridor.setNameFilter(e.target.value)}
|
||||
placeholder={t('roadtrip.poi.filter')}
|
||||
aria-label={t('roadtrip.poi.filter')}
|
||||
style={{ fontSize: FS.time }}
|
||||
className="h-[32px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
{corridor.nameFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => corridor.setNameFilter('')}
|
||||
aria-label={t('common.clear')}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Around one point of the drive, which is how a break is actually planned: you
|
||||
decide roughly where to stop and then look at what is there, rather than
|
||||
reading seventy hits spread over seven hundred kilometres. The midpoint of
|
||||
a leg is offered alongside the stops, because a break on a five-hour drive
|
||||
belongs in the middle of it and not at either end.
|
||||
|
||||
The minWidth: 0 is what makes the name shorten instead of pushing the
|
||||
kilometre control off the panel: a nowrap label reports its whole width as
|
||||
its min-content contribution, so a flex item at its default min-width of
|
||||
auto refuses to go below it however long the place name is. */}
|
||||
{corridor.anchors.length > 1 && corridor.day ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<CustomSelect
|
||||
value={corridor.section ? `${corridor.section.kind}:${corridor.section.index}` : ''}
|
||||
onChange={value => {
|
||||
const raw = String(value)
|
||||
if (!raw) { corridor.setSection(null); return }
|
||||
const [kind, index] = raw.split(':')
|
||||
corridor.setSection({
|
||||
dayId: corridor.day!.dayId,
|
||||
kind: kind as 'stop' | 'leg',
|
||||
index: Number(index),
|
||||
})
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.poi.wholeDay') },
|
||||
...corridor.anchors.map(a => ({
|
||||
value: `${a.kind}:${a.index}`,
|
||||
label: a.kind === 'stop'
|
||||
? (corridor.day!.stops[a.index]?.name ?? t('roadtrip.poi.wholeDay'))
|
||||
: t('roadtrip.poi.midLeg', {
|
||||
from: corridor.day!.stops[a.index]?.name ?? '',
|
||||
to: corridor.day!.stops[a.index + 1]?.name ?? '',
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
menuFit="content"
|
||||
/>
|
||||
{corridor.section ? (
|
||||
<CustomSelect
|
||||
value={String(corridor.sectionKm)}
|
||||
onChange={value => corridor.setSectionKm(Number(value))}
|
||||
options={CORRIDOR_SECTION_KM.map(km => ({
|
||||
value: String(km),
|
||||
label: `± ${formatDistance(km, distanceUnit)}`,
|
||||
}))}
|
||||
style={{ width: 110, flexShrink: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Only where there is a charger to narrow. Two answers OSM actually carries
|
||||
often enough to filter on: which plug, and how fast. A station that states
|
||||
neither stays in the list, because roughly two thirds of them state no
|
||||
power at all and reading that silence as "too slow" would empty the map. */}
|
||||
{hasCharging ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<CustomSelect
|
||||
value={corridor.socketFilter}
|
||||
onChange={value => corridor.setSocketFilter(String(value))}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.poi.anySocket') },
|
||||
...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
<CustomSelect
|
||||
value={String(corridor.minKw)}
|
||||
onChange={value => corridor.setMinKw(Number(value))}
|
||||
options={[
|
||||
{ value: '0', label: t('roadtrip.poi.anyPower') },
|
||||
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
|
||||
{grouped.length === 0 ? (
|
||||
// The same empty state the rest of TREK uses, with the mascot acting out the
|
||||
// scene. `search` while it is running, `idle` before anyone has asked, and a
|
||||
// confused look when a filter left nothing standing — the picture says which
|
||||
// of the three it is before the sentence is read.
|
||||
<EmptyState
|
||||
scene={search.loading ? 'search' : 'idle'}
|
||||
mood={filteredToNothing ? 'confused' : undefined}
|
||||
size={88}
|
||||
compact
|
||||
surface="var(--bg-secondary)"
|
||||
title={
|
||||
search.loading
|
||||
? t('roadtrip.poi.searchingHint')
|
||||
: filteredToNothing
|
||||
// Four controls narrow this list and only one of them is the
|
||||
// name box. Blaming it regardless produced `Nothing on the
|
||||
// way matches ""` — empty quotes naming a filter the reader
|
||||
// never set — when a section, a plug type or a minimum power
|
||||
// was what emptied it.
|
||||
? corridor.nameFilter.trim()
|
||||
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
|
||||
: t('roadtrip.poi.noneMatchFilters')
|
||||
: t('roadtrip.poi.empty')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Shown whenever anything narrowed the list, not only the name
|
||||
box: a section or a plug filter hides hits just as much, and
|
||||
the reader is owed the same "x of y" either way. */}
|
||||
{corridor.visible.length !== search.results.length ? (
|
||||
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
|
||||
</p>
|
||||
) : null}
|
||||
{grouped.map(g => (
|
||||
<ResultGroup
|
||||
key={g.key}
|
||||
category={g.key}
|
||||
pois={g.pois}
|
||||
dayId={corridor.day?.dayId ?? null}
|
||||
insertIndexFor={corridor.insertIndexFor}
|
||||
onAddPoi={onAddPoi}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
|
||||
*
|
||||
* The one that matters is when a number is written. These fields had no local
|
||||
* state, so every keystroke was a settings PUT: typing "180" sent three, and
|
||||
* they raced. The value that decides whether a day is over budget could end up a
|
||||
* tenth of what was typed and only say so after the next reload.
|
||||
*/
|
||||
|
||||
function open(onSave?: (key: string, value: number) => void) {
|
||||
render(<RoadtripLimitsCard onSave={onSave} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
/** The three inputs, in the order the dialog lists them. */
|
||||
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_leg_minutes: 0,
|
||||
roadtrip_day_minutes: 0,
|
||||
roadtrip_range_km: 0,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripLimitsCard', () => {
|
||||
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '1' } })
|
||||
fireEvent.change(leg, { target: { value: '18' } })
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
|
||||
// Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180.
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(leg.value).toBe('180')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [, day] = inputs()
|
||||
fireEvent.change(day, { target: { value: '540' } })
|
||||
fireEvent.keyDown(day, { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
fireEvent.blur(inputs()[0])
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
|
||||
// `onSave` absent is how the caller says the reader may look but not change.
|
||||
open(undefined)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
// Nothing to assert but the absence of a crash: the commit path has no
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* The three numbers that decide when the rail speaks up about the driving.
|
||||
*
|
||||
* Behind a button rather than laid out in the column, because this is set once and then
|
||||
* read never: a permanent card costs three rows of a narrow sidebar for something most
|
||||
* travellers touch on the first day of planning and leave alone after. The button carries
|
||||
* the current answer, so what is set stays visible without the form being.
|
||||
*
|
||||
* All three are personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
|
||||
* They are plain per-user settings, which is why this needs no migration.
|
||||
*
|
||||
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
|
||||
* idea what car this is, and neither has TREK, so the range is a number the traveller
|
||||
* types. Furkot does exactly the same, for the same reason.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
|
||||
function parseLimit(raw: string): number {
|
||||
const n = Number(raw.replace(',', '.'))
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
icon: typeof Clock
|
||||
label: string
|
||||
suffix: string
|
||||
value: number | undefined
|
||||
placeholder: string
|
||||
onChange: (next: number) => void
|
||||
}): React.ReactElement {
|
||||
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
|
||||
// settings PUT per character — typing "180" produced three, carrying 1, 18 and
|
||||
// 180, unordered and concurrent over HTTP/2. Whichever the server committed
|
||||
// last won, so the limit that decides every over-budget warning could quietly
|
||||
// end up a tenth of what was typed, and only show it after the next reload.
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const shown = draft ?? (value ? String(value) : '')
|
||||
const commit = () => {
|
||||
if (draft === null) return
|
||||
const next = parseLimit(draft)
|
||||
setDraft(null)
|
||||
if (next !== (value ?? 0)) onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="w-7 text-caption text-content-faint">{suffix}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
}
|
||||
|
||||
// What is set, on the button itself, so the form does not have to be open to read it.
|
||||
// Each one keeps its own icon, because three numbers in a row say nothing about which
|
||||
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
|
||||
// an imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Full width and card-sized, the same weight as the search controls above it: this
|
||||
is one of three things this column does, not a footnote under the other two.
|
||||
The current answer rides on the button, so what is set stays visible without the
|
||||
form being open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the three badges below already carry a clock, a
|
||||
calendar and a pump, and a fourth mark in front of the title only ate the width
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-body font-semibold text-content">
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{badges.map(({ key, Icon, text }) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
|
||||
>
|
||||
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
|
||||
<Icon size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate text-content-faint" style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.limit.none')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={Fuel}
|
||||
label={t('roadtrip.limit.rangeLabel')}
|
||||
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Said out loud rather than left to be discovered: the day figure this is
|
||||
measured against is driving time only. It does not include how long anyone
|
||||
stands still, and it does not include the leg to the hotel, because the
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react'
|
||||
import { CalendarDays, Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
interface RoadtripModeSwitchProps {
|
||||
active: boolean
|
||||
onChange: (roadtrip: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the plan view's left rail between the day plan and the road trip reading of
|
||||
* the same trip. Only rendered while the road trip addon is on, so a trip that isn't
|
||||
* driven anywhere never grows a control it has no use for.
|
||||
*/
|
||||
export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwitchProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const options: [boolean, string, typeof Route][] = [
|
||||
[false, t('roadtrip.mode.days'), CalendarDays],
|
||||
[true, t('roadtrip.mode.roadtrip'), Route],
|
||||
]
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('roadtrip.mode.label')}
|
||||
className="mx-3.5 mb-0 mt-3 flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1"
|
||||
>
|
||||
{options.map(([value, label, Icon]) => {
|
||||
const selected = active === value
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(value)}
|
||||
// Sized off the day plan's own type, like the road trip rail below it: the
|
||||
// switcher sits above both, so it cannot be the largest thing in the column.
|
||||
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
|
||||
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
|
||||
selected
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} strokeWidth={1.8} aria-hidden />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
placeId: over.assignmentId * 10,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const leg = (over: Partial<RouteSegment> = {}): RouteSegment =>
|
||||
({ distance: 100000, duration: 3600, distanceText: '100 km', durationText: '1 h', mode: 'driving', ...over }) as RouteSegment
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
const stops = over.stops ?? [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
return {
|
||||
dayId: 1,
|
||||
dayNumber: 1,
|
||||
date: null,
|
||||
title: null,
|
||||
stops,
|
||||
legs: [leg()],
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
geometry: [],
|
||||
distance: 100000,
|
||||
duration: 3600,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [day()],
|
||||
lines: [],
|
||||
segments: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
totalDistance: 100000,
|
||||
totalDuration: 3600,
|
||||
totalStops: 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripSidebar', () => {
|
||||
/**
|
||||
* The value the summary head pairs with this label. The same words also name the chips
|
||||
* further down for screen readers, so the lookup has to hold on to the `dt` — otherwise
|
||||
* it matches "Distance" three times over.
|
||||
*/
|
||||
const total = (label: string): string => {
|
||||
// The summary is three columns; the caption and its number are siblings in one cell.
|
||||
// The same words also name the badges further down, so the lookup keeps to the cell
|
||||
// whose first child is the caption itself.
|
||||
const caption = screen.getAllByText(label).find(el => el.parentElement?.firstElementChild === el)!
|
||||
return caption.parentElement!.lastElementChild!.textContent!.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-001: leads with the totals for the whole drive', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
|
||||
|
||||
expect(total('Distance')).toBe('250 km')
|
||||
expect(total('Driving time')).toBe('2 h 30 min')
|
||||
expect(total('Stops')).toBe('5')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
|
||||
// Trip and day totals deliberately differ from the single leg, so the leg's own two
|
||||
// values are the only place '100 km' and '1 h' can come from.
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ distance: 250000, duration: 9000 })],
|
||||
totalDistance: 250000,
|
||||
totalDuration: 9000,
|
||||
})} />)
|
||||
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// One sentence, not two glued values: the middot is gone and so is the pair of chips.
|
||||
expect(screen.getByText('100 km in 1 h')).toBeInTheDocument()
|
||||
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-003: says a leg is still coming rather than showing a blank', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ legs: [undefined], distance: 0, duration: 0 })],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
})} />)
|
||||
|
||||
// An absent value says so rather than reading zero.
|
||||
expect(screen.queryByText(/100 km in/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-004: sets a pinned arrival apart from a computed one', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Ferry' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:30', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '10:30', departure: '10:30', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Both are plain text at the row's edge — no pill, no icon. What separates the time
|
||||
// somebody chose from the one the drive worked out is weight and ink. The reason is
|
||||
// named in a tooltip, which only enters the DOM on hover, so the visible difference
|
||||
// is what this pins.
|
||||
const pinned = screen.getByText('09:00')
|
||||
const computed = screen.getByText('10:30')
|
||||
expect(pinned.className).toContain('font-semibold')
|
||||
expect(computed.className).not.toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-005: flags a stop the drive cannot reach in time', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-006: shows how long a stop is planned to take', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Museum', dwellMinutes: 90 }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-007: selecting a stop reports the place and the assignment', () => {
|
||||
const onSelectStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onSelectStop={onSelectStop} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Berlin'))
|
||||
expect(onSelectStop).toHaveBeenCalledWith(20, 2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-008: marks the selected stop for assistive tech', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} selectedAssignmentId={2} />)
|
||||
|
||||
const selected = screen.getByText('Berlin').closest('button')!
|
||||
expect(selected).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
|
||||
const second = day({ dayId: 2, dayNumber: 2, distance: 50000, duration: 1800, stops: [
|
||||
stop({ assignmentId: 3, name: 'Dresden' }),
|
||||
stop({ assignmentId: 4, name: 'Prague' }),
|
||||
] })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day(), second], totalStops: 4 })} />)
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 3 })
|
||||
expect(headings).toHaveLength(2)
|
||||
expect(within(headings[1].parentElement!).getByText(/50 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-011: draws the midnight crossing where it happens', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: false, dayOffset: 1 },
|
||||
],
|
||||
warnings: [{ index: 1, code: 'overnight' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Said twice on purpose: once as the break in the chain, once on the arrival after
|
||||
// it, where "01:30" would otherwise read as tonight.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// The day marker rides on the arrival that crossed midnight, where "01:30" would
|
||||
// otherwise read as tonight.
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-012: a late arrival past midnight keeps both findings', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: true, dayOffset: 1 },
|
||||
],
|
||||
// Both belong to the same stop. Reading only the first one dropped the late flag.
|
||||
warnings: [
|
||||
{ index: 1, code: 'overnight' as const },
|
||||
{ index: 1, code: 'late' as const, minutes: 45 },
|
||||
],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-013: says the totals are still a partial sum while legs arrive', () => {
|
||||
const { unmount } = wrap(<RoadtripSidebar routes={routes({ loading: true })} />)
|
||||
expect(screen.getByText('Still working out the rest of the drive')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripSidebar routes={routes({ loading: false })} />)
|
||||
expect(screen.queryByText('Still working out the rest of the drive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-014: shows the name a day was given', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ title: 'Along the coast' })] })} />)
|
||||
|
||||
expect(screen.getByText('Along the coast')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-015: dates the day machine-readably, whatever the locale prints', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ date: '2026-08-31' })] })} />)
|
||||
|
||||
const stamp = container.querySelector('time')!
|
||||
expect(stamp).toHaveAttribute('datetime', '2026-08-31')
|
||||
expect(stamp.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-016: a leg a plugin routed carries its own mark and its note', () => {
|
||||
const charged = leg({ mode: 'plugin:charge', noteText: '25 min charge' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [charged] })] })} />)
|
||||
|
||||
// Free text, so it gets a line rather than being squeezed into a pill.
|
||||
expect(screen.getByText('25 min charge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a fuel stop breaks the drive instead of taking a number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
|
||||
|
||||
// The kind is what you look for on a drive; the running number is on the map pin.
|
||||
expect(screen.getByLabelText('Fuel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// How long the pause takes is what the row is for.
|
||||
expect(screen.getByText('15 min')).toBeInTheDocument()
|
||||
// The two places the trip is actually for keep counting one, two — the tank stop
|
||||
// between them is part of the drive, not a third destination. Scoped to the chain:
|
||||
// the summary above counts stops too, in the same digits.
|
||||
const chain = screen.getByRole('list')
|
||||
expect(within(chain).getByText('1')).toBeInTheDocument()
|
||||
expect(within(chain).getByText('2')).toBeInTheDocument()
|
||||
expect(within(chain).queryByText('3')).not.toBeInTheDocument()
|
||||
// Two drive bands, because the stop splits the leg it falls on. Scoped to the chain
|
||||
// again: the day's header badge phrases its own total the same way.
|
||||
expect(within(chain).getAllByText('100 km in 1 h')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-032: every stop offers a stay, whether it has one or not', () => {
|
||||
// The value has never been editable anywhere in TREK, so a stop without one needs a
|
||||
// way in before it can get one — the plus sits in the slot the number will occupy.
|
||||
const onEditStay = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onEditStay={onEditStay} />)
|
||||
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
const chain = screen.getByRole('list')
|
||||
const add = within(chain).getByText('+')
|
||||
|
||||
fireEvent.click(add)
|
||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
// A label, not an invitation that leads nowhere.
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-030: a day counts the places it visits, not the pauses on the way', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Ionity Horst', stopType: 'charging' }),
|
||||
stop({ assignmentId: 3, name: 'Rasthof Fläming', stopType: 'rest_area' }),
|
||||
stop({ assignmentId: 4, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-031: every kind the corridor finds is a pause, not a destination', () => {
|
||||
// Anything picked off "along the route" was come across on the way, so all six kinds
|
||||
// sit inside the leg with their own icon and none of them counts as a stop.
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Camping Seeblick', stopType: 'campsite' }),
|
||||
stop({ assignmentId: 3, name: 'Bäckerei Junge', stopType: 'restaurant' }),
|
||||
stop({ assignmentId: 4, name: 'Holstentor', stopType: 'sights' }),
|
||||
stop({ assignmentId: 5, name: 'Lübeck' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByLabelText('Campsite')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Food')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Sights')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-020: a stop kind nobody knows falls back to the running number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Etwas Neues', stopType: 'helipad' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
expect(within(screen.getByRole('list')).getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-021: without the right to edit days the chain cannot be dragged', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
expect(container.querySelector('li[draggable="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-022: dropping a stop on another reports where it should go', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
expect(rows).toHaveLength(3)
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[2], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
// The last stop dropped on the first: day, the assignment moved, and its new index.
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 3, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-023: dropping a stop back on itself changes nothing', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[0], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-024: the chain can be reordered from the keyboard too', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />)
|
||||
|
||||
// Dragging is a gesture; without this the chain is unreachable without a mouse.
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 2)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-025: the ends of a day cannot be pushed past themselves', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
fireEvent.keyDown(screen.getByText('Berlin').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-026: an arrow without Alt still belongs to the list, not to us', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowDown' })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-027: a day without a drive appears only while something is dragged', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Nothing in flight: an empty day is not worth a row, the rail is about the drive.
|
||||
expect(screen.queryByText(/drop one here/i)).not.toBeInTheDocument()
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
expect(screen.getByText(/drop one here/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-028: dropping on another day reports both days', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
const target = screen.getByText(/drop one here/i).closest('section')!
|
||||
fireEvent.dragOver(target)
|
||||
fireEvent.drop(target)
|
||||
|
||||
// From day 1, the second stop (assignment 2), onto day 9, at its end.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 9, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-029: a stop dropped on a row of another day lands at that row', () => {
|
||||
const second = day({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
stops: [stop({ assignmentId: 3, name: 'Dresden' }), stop({ assignmentId: 4, name: 'Prague' })],
|
||||
})
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [day(), second], totalStops: 4 })}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
fireEvent.dragOver(rows[3])
|
||||
fireEvent.drop(rows[3])
|
||||
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 1, 2, 1)
|
||||
// Crossing days is a different call; the same-day reorder must not also fire.
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
|
||||
const ferry = leg({ mode: 'ferry' })
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
|
||||
|
||||
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: the track badge is a viewer-free control, and it says when a day is shaped', () => {
|
||||
const onFollowTrack = vi.fn()
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const { rerender } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} viaCounts={{ 1: 4 }} />,
|
||||
)
|
||||
|
||||
const badge = screen.getByRole('button', { name: 'Track' })
|
||||
fireEvent.click(badge)
|
||||
expect(onFollowTrack).toHaveBeenCalledWith(1)
|
||||
// The rail draws no vias, so the tint is the only place a drive shaped by hand
|
||||
// differs from one the router picked on its own.
|
||||
expect(badge.className).toContain('bg-accent-subtle')
|
||||
|
||||
rerender(
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
|
||||
</TranslationProvider>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
it('FE-ROADTRIP-MODESWITCH-001: offers both readings and marks the active one', () => {
|
||||
wrap(<RoadtripModeSwitch active onChange={vi.fn()} />)
|
||||
|
||||
const tabs = screen.getAllByRole('tab')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
|
||||
expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODESWITCH-002: switching back to the day plan reports false', () => {
|
||||
const onChange = vi.fn()
|
||||
wrap(<RoadtripModeSwitch active onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('tab')[0])
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
|
||||
|
||||
/**
|
||||
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
|
||||
*
|
||||
* The value is `places.duration_minutes`, which the schedule has read for years
|
||||
* and nothing could write. Two things are worth pinning: that clearing sends a
|
||||
* zero rather than a null — the update statement folds a null into "leave it
|
||||
* alone", so a null could give a stop a stay but never take one away — and that
|
||||
* the dialog answers the question the number is chosen for, which is when the
|
||||
* drive leaves again.
|
||||
*/
|
||||
|
||||
const draft = (over: Partial<StayDraft> = {}): StayDraft => ({
|
||||
placeId: 9,
|
||||
name: 'Rasthof Dammer Berge',
|
||||
minutes: null,
|
||||
arrival: '12:00',
|
||||
...over,
|
||||
})
|
||||
|
||||
function open(over: Partial<StayDraft> = {}, onSave = vi.fn(), onClose = vi.fn()) {
|
||||
render(<RoadtripStayModal stop={draft(over)} onClose={onClose} onSave={onSave} />)
|
||||
return { onSave, onClose }
|
||||
}
|
||||
|
||||
const slider = () => screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
|
||||
})
|
||||
|
||||
describe('RoadtripStayModal', () => {
|
||||
it('FE-STAYMODAL-001: no stop means no dialog', () => {
|
||||
const { container } = render(<RoadtripStayModal stop={null} onClose={vi.fn()} onSave={vi.fn()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-002: it opens on the stop own length, not on the last one looked at', () => {
|
||||
open({ minutes: 45 })
|
||||
expect(slider().value).toBe('45')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-003: a stop with no stay opens at nothing rather than at a guess', () => {
|
||||
open({ minutes: null })
|
||||
expect(slider().value).toBe('0')
|
||||
expect(screen.getAllByText(/no stay|none/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-004: it answers the question the number is chosen for', () => {
|
||||
// The arrival is fixed by the drive; the departure is the only thing this
|
||||
// dialog moves, and seeing it move is the point of a slider over a box.
|
||||
open({ minutes: 30, arrival: '12:00' })
|
||||
|
||||
expect(screen.getByText('12:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:30')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(slider(), { target: { value: '90' } })
|
||||
expect(screen.getByText('13:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-005: a stop the drive has not timed shows no clock at all', () => {
|
||||
// Better than inventing one: a departure without an arrival is a number with
|
||||
// nothing behind it.
|
||||
open({ minutes: 30, arrival: null })
|
||||
expect(screen.queryByText(/\d\d:\d\d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-006: the clock follows the reader own format', () => {
|
||||
useSettingsStore.setState({ settings: { time_format: '12h' } as never })
|
||||
open({ minutes: 60, arrival: '13:00' })
|
||||
expect(screen.getByText(/1:00\s*PM/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/2:00\s*PM/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-007: the two buttons nudge, and stop at the ends', () => {
|
||||
open({ minutes: 0 })
|
||||
|
||||
// Nothing below zero: the minus is off rather than silently doing nothing.
|
||||
expect(screen.getByRole('button', { name: '-5' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '+5' }))
|
||||
expect(slider().value).toBe('5')
|
||||
fireEvent.click(screen.getByRole('button', { name: '-5' }))
|
||||
expect(slider().value).toBe('0')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-008: the presets are one tap, which is the point of them', () => {
|
||||
open({ minutes: 0 })
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
|
||||
expect(slider().value).toBe('60')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-009: saving reports the stop and the chosen length, then closes', async () => {
|
||||
const { onSave, onClose } = open({ minutes: 0 })
|
||||
|
||||
fireEvent.change(slider(), { target: { value: '45' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 45))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-010: clearing sends a zero, never a null', async () => {
|
||||
// The update statement folds a null into "leave it alone", so a null could
|
||||
// give a stop a stay and never take one away.
|
||||
const { onSave } = open({ minutes: 90 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /no stay|none/i }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 0))
|
||||
expect(onSave.mock.calls[0][1]).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-011: a save already running is not started twice', async () => {
|
||||
// Two clicks on Save wrote the same value twice and closed on the first
|
||||
// answer, which on a slow connection is easy to do.
|
||||
let release: () => void = () => {}
|
||||
const onSave = vi.fn(() => new Promise<void>(r => { release = r }))
|
||||
open({ minutes: 30 }, onSave as never)
|
||||
|
||||
const save = screen.getByRole('button', { name: /^save$/i })
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
release()
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-012: cancelling changes nothing', () => {
|
||||
const { onSave, onClose } = open({ minutes: 30 })
|
||||
fireEvent.change(slider(), { target: { value: '120' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The lengths a stop usually takes, so the common answer is one tap.
|
||||
*
|
||||
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
|
||||
* the slider is for.
|
||||
*/
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120]
|
||||
|
||||
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
|
||||
const MAX = 240
|
||||
/** The step the slider and the two buttons move in. */
|
||||
const STEP = 5
|
||||
|
||||
/** The stop being given a length. */
|
||||
export interface StayDraft {
|
||||
placeId: number
|
||||
name: string
|
||||
minutes: number | null
|
||||
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
||||
arrival?: string | null
|
||||
}
|
||||
|
||||
interface RoadtripStayModalProps {
|
||||
stop: StayDraft | null
|
||||
onClose: () => void
|
||||
/** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
|
||||
onSave: (placeId: number, minutes: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
|
||||
const SLIDER = [
|
||||
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
|
||||
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
|
||||
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
|
||||
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
|
||||
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* How long the traveller stays at one stop.
|
||||
*
|
||||
* A road-trip idea rather than a property of the place: it is what pushes every later
|
||||
* arrival back, so it belongs beside the drive times rather than in the place form, which
|
||||
* is why this opens from the rail and nowhere else.
|
||||
*
|
||||
* Built around the number instead of around a field. The chosen length is the largest
|
||||
* thing on screen and updates as the slider moves, and under it the dialog answers the
|
||||
* question the number is actually being chosen for: given when the drive arrives, when
|
||||
* does it leave again. Typing minutes into a box could never show that.
|
||||
*
|
||||
* The value is `places.duration_minutes`, which the schedule has read for years and
|
||||
* nothing has ever been able to write. Cleared as a zero rather than a null, because the
|
||||
* update statement folds a null into "leave it alone" — a null could give a stop a stay
|
||||
* but never take one away.
|
||||
*/
|
||||
export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [minutes, setMinutes] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reopened on a different stop, so it starts from that stop's own value rather than
|
||||
// from whatever the last one was left on.
|
||||
useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
|
||||
|
||||
// What the stay does to the day, worked out live: the arrival is fixed by the drive,
|
||||
// the departure is the only thing this dialog moves.
|
||||
const times = useMemo(() => {
|
||||
const arrival = parseClock(stop?.arrival)
|
||||
if (arrival === null) return null
|
||||
return {
|
||||
arrive: formatClockTime(formatClock(arrival), is12h),
|
||||
leave: formatClockTime(formatClock(arrival + minutes), is12h),
|
||||
}
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
if (!stop) return null
|
||||
|
||||
const commit = async (value: number) => {
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(stop.placeId, value)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} size="sm" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hourglass size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
|
||||
|
||||
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
|
||||
be nudged without hunting for the slider's thumb. */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(-STEP)}
|
||||
disabled={minutes === 0}
|
||||
aria-label={`-${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Minus size={15} aria-hidden />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
|
||||
>
|
||||
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(STEP)}
|
||||
disabled={minutes >= MAX}
|
||||
aria-label={`+${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Plus size={15} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX}
|
||||
step={STEP}
|
||||
value={minutes}
|
||||
onChange={e => setMinutes(Number(e.target.value))}
|
||||
aria-label={t('roadtrip.stay.custom')}
|
||||
className={SLIDER}
|
||||
/>
|
||||
|
||||
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
|
||||
decides it — so the arrow shows the one end this dialog does move. */}
|
||||
{times ? (
|
||||
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.arrive')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
|
||||
</span>
|
||||
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.leave')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESETS.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMinutes(value)}
|
||||
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
|
||||
minutes === value
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
{formatDurationShort(value * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{/* Removing is the same write as setting one, so it sits with the other actions
|
||||
rather than behind a confirmation. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !stop.minutes}
|
||||
onClick={() => void commit(0)}
|
||||
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.stay.none')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void commit(minutes)}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
|
||||
return {
|
||||
osm_id: 'node:9',
|
||||
name: 'Aral Autohof',
|
||||
lat: 53.1,
|
||||
lng: 10.2,
|
||||
category: 'fuel',
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
offRouteKm: 1.2,
|
||||
alongKm: 120,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
const draft = (over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft => ({
|
||||
poi: poi(),
|
||||
dayId: 4,
|
||||
position: 2,
|
||||
dayNumber: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const noop = {
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onMoreDetails: vi.fn(),
|
||||
}
|
||||
|
||||
describe('RoadtripStopPopup', () => {
|
||||
it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
|
||||
const { container } = wrap(<RoadtripStopPopup draft={null} {...noop} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ position: 2, dayNumber: 3 })} {...noop} />)
|
||||
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// Position 2 in the array is the third stop for a reader.
|
||||
expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
// Fuel suggests ten minutes; nobody plans an hour for it.
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
// Charging is half an hour, not the ten minutes a tank takes.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Every category the corridor can search for, with the name its button carries.
|
||||
*
|
||||
* The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
|
||||
* each hit with the category its own OSM tag matched. A key the popup does not know
|
||||
* preselects nothing, and the stop is saved as an ordinary place — which is exactly
|
||||
* how food and sights used to end up as numbered destinations in the middle of a drive.
|
||||
*/
|
||||
const CORRIDOR_KINDS: [string, string][] = [
|
||||
['fuel', 'Fuel'],
|
||||
['charging', 'Charging'],
|
||||
['rest_area', 'Rest area'],
|
||||
['campsite', 'Campsite'],
|
||||
['restaurant', 'Food'],
|
||||
['sights', 'Sights'],
|
||||
]
|
||||
|
||||
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
|
||||
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
|
||||
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
|
||||
for (const [, other] of CORRIDOR_KINDS) {
|
||||
if (other === label) continue
|
||||
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'museum', name: 'Albertinum' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft()} {...noop} duplicateName="Aral Autohof" />)
|
||||
expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
|
||||
const onMoreDetails = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onMoreDetails={onMoreDetails} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More details' }))
|
||||
expect(onMoreDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
|
||||
let release: () => void = () => {}
|
||||
const onSave = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
const add = screen.getByRole('button', { name: 'Add' })
|
||||
fireEvent.click(add)
|
||||
await waitFor(() => expect(add).toBeDisabled())
|
||||
fireEvent.click(add)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Settle it inside act, or React re-enables the button after the test has finished.
|
||||
await act(async () => { release() })
|
||||
expect(add).not.toBeDisabled()
|
||||
})
|
||||
// ── Overnight mode ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Somewhere to sleep is the one hit that ends the day rather than interrupting the
|
||||
// drive, so the dialog grows a second mode instead of offering a hotel a dwell of
|
||||
// thirty minutes.
|
||||
|
||||
const overnight = {
|
||||
days: [
|
||||
{ id: 4, number: 1, date: '2026-08-31' },
|
||||
{ id: 5, number: 2, date: '2026-09-01' },
|
||||
{ id: 6, number: 3, date: '2026-09-02' },
|
||||
],
|
||||
defaultEndDayId: 5,
|
||||
}
|
||||
const nightDraft = (category: string) => draft({
|
||||
poi: poi({ category, name: 'Hotel Adlon' }),
|
||||
overnight,
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-020: a hotel opens on the overnight mode', () => {
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Check out on')).toBeInTheDocument()
|
||||
// The two questions a pause asks are gone: a hotel has no kind and no dwell.
|
||||
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-021: a campsite opens on the pause mode but offers the night', () => {
|
||||
// It is a service stop today and stays one, but people do sleep at campsites, so the
|
||||
// choice is offered rather than decided.
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('campsite')} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Overnight' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-022: a petrol station is never offered a night', () => {
|
||||
// Nothing to choose between, so no switch at all.
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-023: booking a night reports the check-out day, defaulting to the next', () => {
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={onSaveNight} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '', checkOut: '' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-024: switching back to a pause saves a pause', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSave={onSave} onSaveNight={onSaveNight} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
expect(onSaveNight).not.toHaveBeenCalled()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-026: a scheme-less OSM website still leaves the app', () => {
|
||||
// OSM `website` tags are routinely written without a scheme. As a raw href
|
||||
// `www.hotel.de` is a RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de and the user lost the page they were on.
|
||||
wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: 'www.hotel-adlon.de' }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('href', 'https://www.hotel-adlon.de')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-027: a website that is not a link is not rendered as one', () => {
|
||||
// Anything that is not http(s) loses the link rather than being handed to
|
||||
// the browser. The CSP stops a javascript: navigation on a stock deployment,
|
||||
// but this is defended everywhere else in the app and belongs here too.
|
||||
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'tel:+49301234']) {
|
||||
const { unmount } = wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: bad }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: 'Website' }), bad).not.toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
|
||||
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
|
||||
// leads nowhere.
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The four kinds of stop, with the icon and colour they already carry in the corridor
|
||||
* search, and how long each one usually takes.
|
||||
*
|
||||
* Deliberately not TREK's place categories: those are the traveller's own editable list,
|
||||
* shared across the whole instance. Refuelling is not a taste, it is a fact about the
|
||||
* place, so it lives in `places.stop_type` and keeps the palette the search gave it.
|
||||
*
|
||||
* The colours are the road-trip palette from `poiCategories.ts` and are the same in both
|
||||
* themes on purpose — a brand-neutral fuel blue reads as fuel either way.
|
||||
*/
|
||||
// The kinds, their icons, their colours and how long each one usually takes all come
|
||||
// from the one table in stopKinds.ts.
|
||||
|
||||
/** How long to stand still, offered as the few answers anyone actually gives. */
|
||||
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
|
||||
|
||||
export interface RoadtripStopDraft {
|
||||
poi: CorridorPoi
|
||||
dayId: number
|
||||
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
|
||||
position: number
|
||||
dayNumber: number
|
||||
/**
|
||||
* Set only for a hit somebody could sleep at, and it is what turns the popup into two
|
||||
* modes rather than one. Absent, the dialog asks the two questions it always asked.
|
||||
*/
|
||||
overnight?: {
|
||||
/** The trip's days from this one on, in travel order — the check-out options. */
|
||||
days: { id: number; number: number; date: string | null }[]
|
||||
/** The day after this one, or this one when it is the last. */
|
||||
defaultEndDayId: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RoadtripStopPopupProps {
|
||||
draft: RoadtripStopDraft | null
|
||||
/** Names of stops already on this trip that came from the same OSM object. */
|
||||
duplicateName?: string | null
|
||||
onClose: () => void
|
||||
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
|
||||
/**
|
||||
* Books the hit as a night instead of a pause: the place, its day, and a row in
|
||||
* day_accommodations, in one go. Only ever called when the draft carries `overnight`.
|
||||
*/
|
||||
onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise<void> | void
|
||||
/** Hands over what has been picked here, so the full form opens on the same answer. */
|
||||
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding something found along the drive, without the full place form.
|
||||
*
|
||||
* The form is right for a place you are planning a day around; it is wrong for a petrol
|
||||
* station. Everything it asks for — category, price, photo, notes, files — is empty for a
|
||||
* refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
|
||||
* which neither column shows. This asks the two questions that matter (what kind of stop,
|
||||
* how long) and says where it will land, with a way out to the full form for the rest.
|
||||
*/
|
||||
export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t, language } = useTranslation()
|
||||
// Through the same guard every other external link in the app goes through.
|
||||
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
|
||||
// routinely written without a scheme — `www.hotel.de` as a raw href is a
|
||||
// RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
|
||||
// scheme for a bare host and drops anything that is not http(s).
|
||||
const websiteHref = safeExternalHref(draft?.poi.website)
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
|
||||
// A hotel is a night by default and a campsite a pause, which is what each already
|
||||
// means everywhere else — but both offer the other, because a campsite is somewhere
|
||||
// people sleep and a hotel is somewhere people stop for lunch.
|
||||
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
|
||||
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
|
||||
const [checkIn, setCheckIn] = useState('')
|
||||
const [checkOut, setCheckOut] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!draft) return null
|
||||
const overnight = draft.overnight
|
||||
const asNight = !!overnight && !!onSaveNight && night
|
||||
|
||||
const kind = STOP_KINDS.find(k => k.key === stopType)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (asNight) {
|
||||
await onSaveNight?.({
|
||||
endDayId: endDayId ?? overnight!.defaultEndDayId,
|
||||
checkIn,
|
||||
checkOut,
|
||||
})
|
||||
} else {
|
||||
await onSave({ stopType, dwellMinutes: dwell })
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
||||
// theme-lint-disable — the road-trip palette, the same one the map pin and the
|
||||
// result row use; a token here would make the three disagree.
|
||||
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
|
||||
>
|
||||
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
|
||||
<p className="mt-0.5 text-caption text-content-muted">
|
||||
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{duplicateName ? (
|
||||
// The full place form warns about duplicates; losing that warning would make
|
||||
// this the easiest way to add the same petrol station twice.
|
||||
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.stop.duplicate', { name: duplicateName })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Only where sleeping is on the table. A petrol station gets no choice, because
|
||||
there is nothing to choose between. */}
|
||||
{overnight && onSaveNight ? (
|
||||
<div className="flex rounded-lg border border-edge p-0.5">
|
||||
{[
|
||||
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
|
||||
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
|
||||
].map(({ on, label, Icon }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={night === on}
|
||||
onClick={() => setNight(on)}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
|
||||
night === on
|
||||
? 'bg-accent font-semibold text-accent-text'
|
||||
: 'text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{asNight ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stay.checkOutDay')}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<CustomSelect
|
||||
value={String(endDayId ?? overnight!.defaultEndDayId)}
|
||||
onChange={value => setEndDayId(Number(value))}
|
||||
options={overnight!.days.map(d => ({
|
||||
value: String(d.id),
|
||||
// Through the shared helper, like every other day date in
|
||||
// this feature. Interpolating the column gave this one
|
||||
// dropdown a bare ISO date while the rail beside it showed
|
||||
// the same day in the reader's own order.
|
||||
label: d.date
|
||||
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
|
||||
: t('roadtrip.stay.dayNumber', { number: d.number }),
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Both optional: leaving them empty is normal, and the server stores null.
|
||||
A hotel found on a map has no idea when its reception opens. */}
|
||||
<div className="flex gap-3">
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkIn}
|
||||
onChange={e => setCheckIn(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkOut')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkOut}
|
||||
onChange={e => setCheckOut(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/* What OSM knows about the house, which is the whole of what we offer: no
|
||||
price, no availability, no link into a booking portal. TREK links to maps
|
||||
and to the place's own site, never to a vendor. */}
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{websiteHref ? (
|
||||
<a
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t('places.formWebsite')}
|
||||
</a>
|
||||
) : null}
|
||||
{draft.poi.phone ? (
|
||||
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
|
||||
{draft.poi.phone}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.kind')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon }) => {
|
||||
const on = stopType === key
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
setStopType(on ? null : key)
|
||||
// Picking a kind is also picking how long it takes, until the user
|
||||
// says otherwise — a charge is not a fuel stop.
|
||||
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
|
||||
on
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{DWELL_CHOICES.map(minutes => (
|
||||
<button
|
||||
key={minutes}
|
||||
type="button"
|
||||
aria-pressed={dwell === minutes}
|
||||
onClick={() => setDwell(minutes)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
|
||||
dwell === minutes
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Hourglass size={11} aria-hidden />
|
||||
{formatDurationShort(minutes * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
|
||||
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('roadtrip.stop.moreDetails')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t('roadtrip.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripTrackModal from './RoadtripTrackModal'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const track = (over: Partial<TrackChoice> & { id: number; name: string }): TrackChoice => ({
|
||||
color: '#0ea5e9',
|
||||
points: [{ lat: 52, lng: 13 }, { lat: 52, lng: 14 }],
|
||||
lengthKm: 68,
|
||||
gapKm: 0.4,
|
||||
...over,
|
||||
})
|
||||
|
||||
function follow(over: Partial<FollowTrack> = {}): FollowTrack {
|
||||
return {
|
||||
dayId: 7,
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
tracks: [track({ id: 3, name: 'Atlantic Road' })],
|
||||
busy: false,
|
||||
round: 0,
|
||||
error: null,
|
||||
outcome: null,
|
||||
apply: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
viaCount: 0,
|
||||
available: true,
|
||||
current: null,
|
||||
namesByDay: {},
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripTrackModal', () => {
|
||||
it('FE-TRACKMODAL-001: closed is nothing at all, not a hidden dialog', () => {
|
||||
const { container } = wrap(<RoadtripTrackModal follow={follow({ dayId: null })} dayNumber={1} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [] })} dayNumber={2} />)
|
||||
expect(screen.getByText('No tracks in this trip')).toBeTruthy()
|
||||
expect(screen.getByText(/GPX or KML/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-003: a track close to the day says so rather than quoting a distance', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
expect(screen.getByText(/along this day/)).toBeTruthy()
|
||||
expect(screen.getByText(/68 km long/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-004: a track that runs elsewhere is offered with the distance to here', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [track({ id: 9, name: 'Elsewhere', gapKm: 240 })] })} dayNumber={1} />)
|
||||
expect(screen.getByText(/240 km off this day/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-005: nothing is applied until a track is picked', () => {
|
||||
const state = follow()
|
||||
wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Follow this track' })
|
||||
expect((action as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
// What it will do, before it does it: a track becomes a dozen waypoints on the day.
|
||||
expect(screen.getByText(/via points/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow this track' }))
|
||||
expect(state.apply).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-006: a picked track can be unpicked', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-007: a run says which round it is on rather than going blank', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, round: 4 })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/round 4/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 6, strayKm: 0.8, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/6 via points placed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-009: a run that ran out of vias admits the gap it left', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 12, strayKm: 2.4, capped: true } })} dayNumber={1} />)
|
||||
// The honest form: it did what it could, and the number says how much that was worth.
|
||||
expect(screen.getByRole('status').textContent).toMatch(/no closer than/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-010: a drive that already followed the track is not dressed up as work', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 0, strayKm: 0, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/already followed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ error: 'route' })} dayNumber={1} />)
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/routing service/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-012: dropping the day\'s vias is offered only once it has some', () => {
|
||||
const state = follow({ viaCount: 5 })
|
||||
const { rerender } = wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
const drop = screen.getByRole('button', { name: /Drop 5 via points/ })
|
||||
fireEvent.click(drop)
|
||||
expect(state.clear).toHaveBeenCalled()
|
||||
|
||||
rerender(<TranslationProvider><RoadtripTrackModal follow={follow({ viaCount: 0 })} dayNumber={1} /></TranslationProvider>)
|
||||
expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, viaCount: 2 })} dayNumber={1} />)
|
||||
expect((screen.getByRole('button', { name: /Atlantic Road/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: /Drop 2 via points/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { FS } from './typeScale'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* Picking the track a day drives along.
|
||||
*
|
||||
* The list is the whole dialog. Everything a traveller needs to tell two imported tracks
|
||||
* apart is on the row — the colour the map draws it in, how long it runs, and how far it
|
||||
* is from this particular day — and the last of those is why the list is ordered the way
|
||||
* it is: a trip carrying every track of a three-week tour still opens on the one beside
|
||||
* today's drive.
|
||||
*
|
||||
* Applying is slow and says so. It is a routing round trip per refinement, half a minute
|
||||
* on a long scenic route, and a dialog that went blank for that long would read as broken.
|
||||
*/
|
||||
|
||||
/** A track close enough that it is plainly about this day rather than another one. */
|
||||
const ON_THIS_DAY_KM = 2
|
||||
|
||||
function TrackRow({ track, disabled, onPick }: {
|
||||
track: TrackChoice
|
||||
disabled: boolean
|
||||
onPick: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const onDay = track.gapKm <= ON_THIS_DAY_KM
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onPick}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* The line's own colour, as a short stroke rather than a dot: it is a line on the
|
||||
map, and a dot beside a name reads as a place. */}
|
||||
<span
|
||||
className="h-1 w-6 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the colour the track carries, the same one the map draws.
|
||||
style={{ background: track.color || 'var(--text-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })}
|
||||
{' · '}
|
||||
{onDay
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripTrackModal({ follow, dayNumber }: {
|
||||
follow: FollowTrack
|
||||
/** The day's number, so the dialog says which drive it is about to reshape. */
|
||||
dayNumber: number
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [picked, setPicked] = useState<number | null>(null)
|
||||
|
||||
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow
|
||||
|
||||
// A track picked on one day means nothing on the next.
|
||||
useEffect(() => { setPicked(null) }, [dayId])
|
||||
|
||||
if (dayId === null) return null
|
||||
|
||||
const chosen = tracks.find(track => track.id === picked) ?? null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={follow.close}
|
||||
size="sm"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Spline size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.track.title', { number: dayNumber })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* What the day follows today, before anything is picked. Read back from the day
|
||||
rather than remembered from the run that applied it, which is the whole reason
|
||||
it is stored: it has to survive a reload. */}
|
||||
{current ? (
|
||||
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
|
||||
{t('roadtrip.track.current', { name: current.name })}
|
||||
</p>
|
||||
) : null}
|
||||
{tracks.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
|
||||
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
|
||||
{tracks.map(track => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
disabled={busy}
|
||||
onPick={() => setPicked(track.id === picked ? null : track.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done. The count is the honest part: a track
|
||||
becomes a dozen waypoints on the day, and a traveller who later wonders where
|
||||
they came from should have been told here. */}
|
||||
{chosen ? (
|
||||
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
|
||||
{t('roadtrip.track.explain')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
|
||||
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
|
||||
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{outcome && !busy && !error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
|
||||
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
|
||||
{outcome.vias === 0
|
||||
? t('roadtrip.track.already')
|
||||
: outcome.capped
|
||||
? t('roadtrip.track.capped', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})
|
||||
: t('roadtrip.track.done', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Offered whenever the day carries vias, whether this dialog put them there or
|
||||
a dragged handle did: "put it back the way the router had it" is the same
|
||||
wish either way. */}
|
||||
{viaCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void follow.clear() }}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} aria-hidden />
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
|
||||
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
|
||||
/**
|
||||
* FE-STOPKIND-001..009 — turning a place on the drive into a pause, and back.
|
||||
*
|
||||
* Opened from the stop's own number, because the number is what changes: a
|
||||
* service stop has none. The popover is a portal hung under that element, so
|
||||
* what is worth pinning is the part a portal makes easy to get wrong — that it
|
||||
* renders nothing without an anchor, that clicking outside and Escape both close
|
||||
* it, and that picking the kind a stop already has takes it back off rather than
|
||||
* setting it twice.
|
||||
*/
|
||||
|
||||
let anchor: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
anchor = document.createElement('button')
|
||||
document.body.appendChild(anchor)
|
||||
})
|
||||
afterEach(() => { anchor.remove() })
|
||||
|
||||
function open(over: { current?: string | null; onPick?: (k: string | null) => void; onClose?: () => void } = {}) {
|
||||
const onPick = over.onPick ?? vi.fn()
|
||||
const onClose = over.onClose ?? vi.fn()
|
||||
render(
|
||||
<StopKindPicker
|
||||
anchor={anchor}
|
||||
current={over.current ?? null}
|
||||
onPick={onPick as never}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { onPick, onClose }
|
||||
}
|
||||
|
||||
describe('StopKindPicker', () => {
|
||||
it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => {
|
||||
const onPick = vi.fn()
|
||||
render(<StopKindPicker anchor={null} current={null} onPick={onPick} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-002: every kind the rail draws is offered, as a disc rather than a word', () => {
|
||||
open()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
// One button per kind, plus none extra: six rows of text for six icons would
|
||||
// be a menu where a palette does.
|
||||
const buttons = dialog.querySelectorAll('button[aria-pressed]')
|
||||
expect(buttons).toHaveLength(STOP_KINDS.length)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-003: picking a kind reports it', () => {
|
||||
const { onPick } = open()
|
||||
const first = STOP_KINDS[0]
|
||||
fireEvent.click(screen.getAllByRole('button', { pressed: false })[0])
|
||||
expect(onPick).toHaveBeenCalledWith(first.key)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-004: picking the kind it already is takes it back off', () => {
|
||||
// The control is its own toggle. Without this, changing your mind about a
|
||||
// fuel stop means finding the separate undo below.
|
||||
const kind = STOP_KINDS[0].key
|
||||
const { onPick } = open({ current: kind })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { pressed: true }))
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-005: the current kind is the pressed one, and it is the only one', () => {
|
||||
open({ current: STOP_KINDS[1].key })
|
||||
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-006: the way back to a destination appears only once there is one', () => {
|
||||
const { onPick } = open({ current: STOP_KINDS[0].key })
|
||||
const back = screen.getByRole('button', { name: /destination/i })
|
||||
fireEvent.click(back)
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-007: on an ordinary place it would say "leave everything as it is", so it is absent', () => {
|
||||
open({ current: null })
|
||||
expect(screen.queryByRole('button', { name: /destination/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a click outside closes it, a click inside does not', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('dialog'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
// The anchor is not "outside" either: clicking the number again is what
|
||||
// toggles the picker, and closing here would fight that.
|
||||
fireEvent.mouseDown(anchor)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: Escape closes it, and so does scrolling the rail underneath', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Following the anchor would mean measuring on every frame for a menu that
|
||||
// is open for two seconds; closing is the cheaper honest answer.
|
||||
fireEvent.scroll(window)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Turning a place on the drive into a pause, and back.
|
||||
*
|
||||
* Opened from the stop's own number, because the number is exactly what changes: a
|
||||
* service stop has none. That makes the control its own preview — click the 3, pick the
|
||||
* pump, and the 3 becomes an orange disc while everything below renumbers itself.
|
||||
*
|
||||
* The kinds are the discs the rail already draws rather than a list of words. At this
|
||||
* size the colour and the shape are the label: a traveller who has seen one petrol stop
|
||||
* on the map knows the orange pump before reading anything, and six rows of text for six
|
||||
* icons would be a menu where a palette does.
|
||||
*
|
||||
* Only for stops on a drive. A place with no coordinates never reaches the rail, and a
|
||||
* hotel is not offered here at all, because sleeping somewhere is not a pause in the
|
||||
* driving — that path goes through the accommodation it books.
|
||||
*/
|
||||
|
||||
const DISC = 'grid place-items-center rounded-full transition-transform'
|
||||
|
||||
export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
/** The element the popover hangs under, usually the stop's number. */
|
||||
anchor: HTMLElement | null
|
||||
current: string | null
|
||||
onPick: (kind: RoadtripStopType | null) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the number. The number sits hard against the left
|
||||
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
|
||||
// the map. Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={t('roadtrip.stop.kind')}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
|
||||
const on = current === key
|
||||
return (
|
||||
<Tooltip key={key} label={t(labelKey)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
aria-label={t(labelKey)}
|
||||
onClick={() => onPick(on ? null : key)}
|
||||
className="group grid h-11 w-11 place-items-center rounded-xl transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span
|
||||
className={`${DISC} h-7 w-7 group-hover:scale-110 ${on ? 'ring-2 ring-offset-2 ring-offset-surface-card' : ''}`}
|
||||
// theme-lint-disable — the road-signage palette from `roadtripModel`, the
|
||||
// same one the rail disc and the map pin use, so the choice looks like
|
||||
// what it will become.
|
||||
style={{ background: color, color: '#fff', ...(on ? { boxShadow: `0 0 0 2px ${color}` } : {}) }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={14} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Only once there is something to undo. On an ordinary place it would be a button
|
||||
that says "leave everything as it is". */}
|
||||
{current ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(null)}
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<MapPin size={14} className="shrink-0 text-content-faint" aria-hidden />
|
||||
{t('roadtrip.stop.backToDestination')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ACCESS_SPUR_MIN_M, ACCESS_LABEL_MIN_M, spurFor, spurWorthLabelling } from './accessSpur'
|
||||
import type { SnappedWaypoint } from '../../types'
|
||||
|
||||
const snap = (meters: number): SnappedWaypoint => ({
|
||||
asked: [53.5, 10.0],
|
||||
at: [53.51, 10.01],
|
||||
meters,
|
||||
})
|
||||
|
||||
describe('access spur', () => {
|
||||
it('FE-ACCESSSPUR-001: a gap worth showing becomes the line from the place to the road', () => {
|
||||
const s = snap(400)
|
||||
expect(spurFor(s)).toEqual([s.asked, s.at])
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-002: map precision is not a gap', () => {
|
||||
// A place pinned off an aerial photo lands a few metres from the centreline it
|
||||
// belongs to all the time. Drawing those would put a dash on nearly every stop.
|
||||
expect(spurFor(snap(0))).toBeNull()
|
||||
expect(spurFor(snap(ACCESS_SPUR_MIN_M - 1))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-003: the threshold itself counts as a gap', () => {
|
||||
expect(spurFor(snap(ACCESS_SPUR_MIN_M))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-004: nothing to draw when the router said nothing', () => {
|
||||
expect(spurFor(undefined)).toBeNull()
|
||||
expect(spurFor(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-005: a NaN distance is not a gap either', () => {
|
||||
// Recomputed distances can come out NaN from a malformed coordinate, and NaN >= n is
|
||||
// false — asserted rather than assumed, because the guard reads as a plain comparison.
|
||||
expect(spurFor(snap(Number.NaN))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-006: only a walk long enough to change the plan gets a number', () => {
|
||||
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M)).toBe(true)
|
||||
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M - 1)).toBe(false)
|
||||
expect(spurWorthLabelling(null)).toBe(false)
|
||||
expect(spurWorthLabelling(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-007: the line is drawn but left unlabelled in between', () => {
|
||||
// The band where the map says "there is a gap" and the rail stays quiet.
|
||||
const between = (ACCESS_SPUR_MIN_M + ACCESS_LABEL_MIN_M) / 2
|
||||
expect(spurFor(snap(between))).not.toBeNull()
|
||||
expect(spurWorthLabelling(between)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { SnappedWaypoint } from '../../types'
|
||||
|
||||
/**
|
||||
* The gap between a place and the road the drive actually reaches it from.
|
||||
*
|
||||
* A router snaps every waypoint to the nearest road before it starts, and OSRM does it
|
||||
* with no distance limit. A place set back from the road is therefore driven to from
|
||||
* somewhere else, and the line drawn for that drive begins at the somewhere else without
|
||||
* saying so — which is what a viewpoint above a valley, a farmhouse down a track or a
|
||||
* marina at the end of a pier all look like today: a route that stops short for no
|
||||
* visible reason, or worse, one that appears to cut across open ground.
|
||||
*
|
||||
* Drawn as a dashed spur rather than hidden or silently absorbed, which is the same
|
||||
* answer Google gives: the drive ends at the road, the rest is not driving, and the
|
||||
* picture should say which is which.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Below this, the gap is map precision rather than missing road.
|
||||
*
|
||||
* Thirty metres is roughly the width of a dual carriageway plus its verge, and a place
|
||||
* pinned from an aerial photo lands that far from the centreline it belongs to all the
|
||||
* time. Drawing those would put a dash on almost every stop and teach people to ignore
|
||||
* the mark entirely.
|
||||
*/
|
||||
export const ACCESS_SPUR_MIN_M = 30
|
||||
|
||||
/**
|
||||
* Above this, the spur is worth a number as well as a line.
|
||||
*
|
||||
* A quarter of a kilometre is where "the car park is over there" turns into something
|
||||
* that changes the plan: a walk with luggage, a locked gate, a track a hire car should
|
||||
* not be on. Below it the dash alone says enough.
|
||||
*/
|
||||
export const ACCESS_LABEL_MIN_M = 250
|
||||
|
||||
/** The two-point line from a place to the road it was routed from, or null if there is none worth drawing. */
|
||||
export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null {
|
||||
if (!snapped) return null
|
||||
if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null
|
||||
return [snapped.asked, snapped.at]
|
||||
}
|
||||
|
||||
/** Whether this gap is far enough that the rail should print the distance beside it. */
|
||||
export function spurWorthLabelling(meters: number | null | undefined): boolean {
|
||||
return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* How the offered routes are drawn, after Apple Maps.
|
||||
*
|
||||
* All of them blue, not one blue and the rest grey: they are all the same road network
|
||||
* and all equally drivable, so the difference between them is emphasis, not category. The
|
||||
* chosen one is the full-strength blue the route already uses; the others are a pale blue
|
||||
* that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
|
||||
* line on it disappears, which is exactly what happened when these were grey.
|
||||
*
|
||||
* Fixed values rather than theme tokens: they have to match the blue the route itself is
|
||||
* drawn in, which is fixed too, and a token that shifted with the colour scheme would
|
||||
* break the pairing.
|
||||
*/
|
||||
|
||||
/** The route as chosen — same blue as the drawn trip route. */
|
||||
export const ALT_PRIMARY = '#0a84ff'
|
||||
/** The other offers: unmistakably blue, unmistakably secondary. */
|
||||
export const ALT_SECONDARY = '#7eb8f0'
|
||||
/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
|
||||
export const ALT_CASING = '#ffffff'
|
||||
|
||||
/**
|
||||
* The label colours, again after Apple: the chosen route's time is a filled blue pill,
|
||||
* the alternatives' are near-black. Both carry white text.
|
||||
*/
|
||||
export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
|
||||
export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
|
||||
export const ALT_LABEL_TEXT = '#ffffff'
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAlternativeOverlays } from './alternativeOverlays'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* FE-ALTOVL-001..010 — turning the router's answers into something drawable.
|
||||
*
|
||||
* Two decisions live here and neither is cosmetic: which of the offered roads is
|
||||
* drawn as the one you are on, and where each label hangs. A label anchored on a
|
||||
* stretch two routes share appears pinned to the wrong road, which is the whole
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
return Array.from({ length: n }, (_, i) => [lat, i / 10] as [number, number])
|
||||
}
|
||||
|
||||
function alt(over: Partial<RouteAlternative & { current?: boolean }> = {}): RouteAlternative & { current?: boolean } {
|
||||
return {
|
||||
coordinates: line(20, 0),
|
||||
duration: 3600,
|
||||
distance: 100_000,
|
||||
...over,
|
||||
} as RouteAlternative & { current?: boolean }
|
||||
}
|
||||
|
||||
describe('buildAlternativeOverlays', () => {
|
||||
it('FE-ALTOVL-001: nothing to choose between is not a choice', () => {
|
||||
// One answer drawn as an "alternative" would just double the route already
|
||||
// on the map.
|
||||
expect(buildAlternativeOverlays(undefined, LABELS)).toEqual([])
|
||||
expect(buildAlternativeOverlays([], LABELS)).toEqual([])
|
||||
expect(buildAlternativeOverlays([alt()], LABELS)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-002: the quickest is the blue one when no road is being driven yet', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[1].color).toBe(ALT_PRIMARY)
|
||||
expect(out[1].labelBg).toBe(ALT_LABEL_PRIMARY_BG)
|
||||
expect(out[0].color).toBe(ALT_SECONDARY)
|
||||
expect(out[0].labelBg).toBe(ALT_LABEL_SECONDARY_BG)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-003: the road already being driven takes the blue, even when it is slower', () => {
|
||||
// Blue means "the road you are on". Handing it to the quickest offer instead
|
||||
// would recolour the drive the traveller deliberately bent.
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 7200, current: true, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].color).toBe(ALT_PRIMARY)
|
||||
expect(out[0].note).toBe('Current')
|
||||
expect(out[1].color).toBe(ALT_SECONDARY)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-004: what a road IS beats what it is not', () => {
|
||||
// A way offered because the motorway was left out says so, rather than being
|
||||
// labelled by how much slower it is — being off the motorway is the reason
|
||||
// somebody would take it.
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 3600, coordinates: line(20, 0) }),
|
||||
alt({ duration: 5400, avoids: 'motorway', coordinates: line(20, 1) }),
|
||||
alt({ duration: 5400, avoids: 'toll', coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
)
|
||||
expect(out.map(o => o.note)).toEqual(['Fastest', 'No motorway', 'No tolls'])
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-005: a plain slower offer carries no note at all', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 3600, coordinates: line(20, 0) }), alt({ duration: 5400, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[1].note).toBe('')
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-006: how much slower is measured against the quickest, and never negative', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].slowerThanQuickest).toBe(1800)
|
||||
expect(out[1].slowerThanQuickest).toBe(0)
|
||||
expect(out.every(o => o.slowerThanQuickest >= 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-007: a label hangs where its own road is unlike every other', () => {
|
||||
// Two roads that share both ends and part in the middle. The midpoint of the
|
||||
// shared stretch would put both labels on the same line; the point furthest
|
||||
// from the other route puts each on the stretch only it uses.
|
||||
const shared = (lat: number): [number, number][] => [
|
||||
[0, 0], [0, 1], [lat, 2], [lat, 3], [0, 4], [0, 5],
|
||||
]
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ coordinates: shared(0.5) }), alt({ coordinates: shared(-0.5) })],
|
||||
LABELS,
|
||||
)
|
||||
// Each label sits on its own detour, not on the common ends.
|
||||
expect(Math.abs(out[0].at.lat)).toBeCloseTo(0.5, 5)
|
||||
expect(Math.abs(out[1].at.lat)).toBeCloseTo(0.5, 5)
|
||||
expect(out[0].at.lat).not.toBe(out[1].at.lat)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-008: a route with no coordinates gets a label anchor rather than crashing', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ coordinates: [] }), alt({ coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[0].at).toEqual({ lat: 0, lng: 0 })
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-009: the drive time is carried through as text and as seconds', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 3600, distance: 42_000 }), alt({ duration: 5400, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].duration).toBe(3600)
|
||||
expect(out[0].distance).toBe(42_000)
|
||||
// The label is the readable form of the same number.
|
||||
expect(out[0].label).toBeTruthy()
|
||||
expect(out[0].index).toBe(0)
|
||||
expect(out[1].index).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-010: only one road is ever the blue one', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 3600, coordinates: line(20, 0) }),
|
||||
alt({ duration: 3600, coordinates: line(20, 1) }),
|
||||
alt({ duration: 3600, coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
)
|
||||
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
|
||||
export interface AlternativeOverlay {
|
||||
index: number
|
||||
coordinates: [number, number][]
|
||||
color: string
|
||||
/** The drive time, the way Apple Maps puts it on the road itself. */
|
||||
label: string
|
||||
/** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
|
||||
note: string
|
||||
/** How long this way takes and how far it is, for the list beside the map. */
|
||||
duration: number
|
||||
distance: number
|
||||
/** Seconds more than the quickest offer; negative never happens by construction. */
|
||||
slowerThanQuickest: number
|
||||
labelBg: string
|
||||
/** Where to hang the label — a point on this route and on no other. */
|
||||
at: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance between two points, in degrees, with longitude scaled by latitude.
|
||||
*
|
||||
* Only ever compared against other values from this same function, so degrees are fine
|
||||
* and a haversine would cost more for an answer nobody reads.
|
||||
*/
|
||||
function roughDist(a: [number, number], b: [number, number]): number {
|
||||
const dLat = a[0] - b[0]
|
||||
const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
|
||||
return dLat * dLat + dLng * dLng
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of `line` that lies furthest from every one of `others`.
|
||||
*
|
||||
* This is where a route is unmistakably itself. Anchoring a label at the geometric
|
||||
* midpoint instead put it wherever that happened to fall — and on a pair of routes that
|
||||
* split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
|
||||
* to be pinned to the wrong road.
|
||||
*
|
||||
* Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
|
||||
* thousand-point lines in full costs milliseconds for a pixel of accuracy.
|
||||
*/
|
||||
function mostDistinctPoint(
|
||||
line: [number, number][],
|
||||
others: [number, number][][],
|
||||
): { lat: number; lng: number } | null {
|
||||
if (!line.length) return null
|
||||
if (!others.length) {
|
||||
const mid = line[Math.floor(line.length / 2)]
|
||||
return mid ? { lat: mid[0], lng: mid[1] } : null
|
||||
}
|
||||
const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
|
||||
let best: [number, number] | null = null
|
||||
let bestScore = -1
|
||||
for (let i = 0; i < line.length; i += sample(line)) {
|
||||
// How far this point is from the NEAREST other route: a point close to any of them
|
||||
// is not distinctive, however far it sits from the rest.
|
||||
let nearestOfAll = Infinity
|
||||
for (const other of others) {
|
||||
let nearest = Infinity
|
||||
const step = sample(other)
|
||||
for (let j = 0; j < other.length; j += step) {
|
||||
const d = roughDist(line[i], other[j])
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest < nearestOfAll) nearestOfAll = nearest
|
||||
}
|
||||
if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
|
||||
}
|
||||
return best ? { lat: best[0], lng: best[1] } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes as the map should draw them, built once so every renderer agrees.
|
||||
*
|
||||
* Each label hangs where its own route is furthest from all the others, so a label always
|
||||
* sits on a stretch only that route uses. Two routes that share their first and last
|
||||
* thirds still get their labels on the middle third, where they actually differ.
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
if (routes.length < 2) return []
|
||||
|
||||
// The quickest of what came back, which is what Apple calls out — not necessarily the
|
||||
// first entry, since the road currently driven is put at the top when there is one.
|
||||
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
|
||||
const anyCurrent = routes.some(r => r.current)
|
||||
|
||||
return routes.map((route, index) => {
|
||||
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
|
||||
const at = mostDistinctPoint(route.coordinates, others)
|
||||
// Blue is the road you are on: the one currently driven, or the router's own pick
|
||||
// when nothing has been bent. Everything else is the pale blue of an offer.
|
||||
const primary = route.current || (!anyCurrent && index === quickest)
|
||||
return {
|
||||
index,
|
||||
coordinates: route.coordinates,
|
||||
color: primary ? ALT_PRIMARY : ALT_SECONDARY,
|
||||
label: formatDurationShort(route.duration),
|
||||
// What this way IS beats what it is not: a road offered because the motorway was
|
||||
// left out of it says so, rather than being labelled by how much slower it is —
|
||||
// that is the reason somebody would take it.
|
||||
note: route.current
|
||||
? labels.current
|
||||
: route.avoids === 'motorway'
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
|
||||
at: at ?? { lat: 0, lng: 0 },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
haversineKm,
|
||||
projectOntoRoute,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor'
|
||||
|
||||
const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }
|
||||
const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }
|
||||
const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('measures a known distance', () => {
|
||||
// Berlin → Dresden is about 165 km as the crow flies.
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160)
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170)
|
||||
})
|
||||
|
||||
it('is zero for the same point', () => {
|
||||
expect(haversineKm(BERLIN, BERLIN)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('distanceToSegmentKm', () => {
|
||||
const a: LatLng = { lat: 52, lng: 13 }
|
||||
const b: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
it('measures perpendicular distance to the line', () => {
|
||||
const p: LatLng = { lat: 52.09, lng: 13.5 }
|
||||
// 0.09° of latitude is about 10 km, whatever the longitude does.
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9)
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11)
|
||||
})
|
||||
|
||||
it('measures to the end, not to the infinite line, for a point beyond it', () => {
|
||||
const beyond: LatLng = { lat: 52, lng: 16 }
|
||||
const d = distanceToSegmentKm(beyond, a, b)
|
||||
// Two degrees of longitude past the end at 52° north — roughly 137 km.
|
||||
expect(d).toBeGreaterThan(120)
|
||||
expect(d).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('falls back to point distance for a zero-length segment', () => {
|
||||
expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectOntoRoute', () => {
|
||||
const line = [BERLIN, DRESDEN, PRAGUE]
|
||||
|
||||
it('reports how far off the route a point is', () => {
|
||||
const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line)
|
||||
expect(onRoute!.offRouteKm).toBeLessThan(15)
|
||||
const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line)
|
||||
expect(wayOff!.offRouteKm).toBeGreaterThan(150)
|
||||
})
|
||||
|
||||
it('orders points by how far into the drive they come', () => {
|
||||
const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!
|
||||
const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!
|
||||
expect(early.alongKm).toBeLessThan(late.alongKm)
|
||||
})
|
||||
|
||||
it('tells two points on the same long straight apart', () => {
|
||||
// The petrol stations along one motorway segment must not all report the same
|
||||
// position in the drive, which is what taking the segment midpoint produced.
|
||||
const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }]
|
||||
const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!
|
||||
const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!
|
||||
expect(late.alongKm - early.alongKm).toBeGreaterThan(80)
|
||||
})
|
||||
|
||||
it('returns nothing for a line that is not one', () => {
|
||||
expect(projectOntoRoute(BERLIN, [])).toBeNull()
|
||||
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corridorTiles', () => {
|
||||
it('covers a short route with a single box', () => {
|
||||
const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5)
|
||||
expect(tiles).toHaveLength(1)
|
||||
expect(tiles[0].south).toBeLessThan(52.4)
|
||||
expect(tiles[0].north).toBeGreaterThan(52.52)
|
||||
})
|
||||
|
||||
it('splits a long route into boxes the server will not clamp', () => {
|
||||
const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10)
|
||||
expect(tiles.length).toBeGreaterThan(1)
|
||||
for (const t of tiles) {
|
||||
expect(t.north - t.south).toBeLessThanOrEqual(0.45)
|
||||
expect(t.east - t.west).toBeLessThanOrEqual(0.45)
|
||||
}
|
||||
})
|
||||
|
||||
it('pads longitude more the further north it goes', () => {
|
||||
const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]
|
||||
const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]
|
||||
expect(north.east - north.west).toBeGreaterThan(south.east - south.west)
|
||||
})
|
||||
|
||||
it('covers the middle of a long straight leg, not only its two ends', () => {
|
||||
// A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks
|
||||
// point to point, so without splitting the step everything between them goes
|
||||
// unsearched while the search still reports itself complete.
|
||||
const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }]
|
||||
const tiles = corridorTiles(straight, 10)
|
||||
|
||||
expect(tiles.length).toBeGreaterThan(4)
|
||||
// A box somewhere over the middle of the drive, which is what was missing.
|
||||
const midLat = 53.03
|
||||
const midLng = 11.7
|
||||
expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves no gap between consecutive boxes along a route', () => {
|
||||
const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10)
|
||||
for (let i = 1; i < tiles.length; i++) {
|
||||
const a = tiles[i - 1]
|
||||
const b = tiles[i]
|
||||
// Overlapping or touching in both axes: a hole here is a stretch nobody looks at.
|
||||
expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('has nothing to cover for an empty route', () => {
|
||||
expect(corridorTiles([], 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('simplifyLine', () => {
|
||||
it('keeps the ends and drops what sits on the line between them', () => {
|
||||
const straight = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]])
|
||||
})
|
||||
|
||||
it('keeps a point that actually bends the route', () => {
|
||||
const bent = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.5, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(bent, 1)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('leaves a two-point line alone', () => {
|
||||
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
|
||||
})
|
||||
})
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* Corridor geometry — "what lies along this drive", without asking Overpass to think
|
||||
* about a polyline.
|
||||
*
|
||||
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
|
||||
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
|
||||
* for plain bounding boxes that cover the route, then measure each candidate's
|
||||
* perpendicular distance to the line and throw away what is too far. The boxes are what
|
||||
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
|
||||
*
|
||||
* Everything here is pure: no React, no network, no store.
|
||||
*/
|
||||
|
||||
export interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Bbox {
|
||||
south: number
|
||||
west: number
|
||||
north: number
|
||||
east: number
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_KM = 6371
|
||||
const KM_PER_DEG_LAT = 111.32
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(a: LatLng, b: LatLng): number {
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const lat1 = toRad(a.lat)
|
||||
const lat2 = toRad(b.lat)
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from a point to a line segment, in kilometres.
|
||||
*
|
||||
* Works in a local equirectangular projection around the segment: longitudes shrink by
|
||||
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
|
||||
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
|
||||
* below the width the user picked.
|
||||
*/
|
||||
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
|
||||
return projectOnSegment(p, a, b).distanceKm
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
|
||||
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
|
||||
* the drive — taking the segment's midpoint instead makes every petrol station on a long
|
||||
* straight report the same position.
|
||||
*/
|
||||
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
|
||||
const DEGENERATE_SEGMENT_KM2 = 1e-12
|
||||
|
||||
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
|
||||
const latScale = KM_PER_DEG_LAT
|
||||
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
|
||||
const ax = a.lng * lngScale
|
||||
const ay = a.lat * latScale
|
||||
const bx = b.lng * lngScale
|
||||
const by = b.lat * latScale
|
||||
const px = p.lng * lngScale
|
||||
const py = p.lat * latScale
|
||||
const dx = bx - ax
|
||||
const dy = by - ay
|
||||
const lenSq = dx * dx + dy * dy
|
||||
// A segment whose two ends are the same point to within a millimetre has no
|
||||
// direction to project onto, so measure straight to it. Written as a range because
|
||||
// the ends came through a chain of floating point arithmetic and never land on 0.
|
||||
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
|
||||
// Clamped so a point beside the segment's end measures to the end, not to the
|
||||
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
|
||||
const cx = ax + t * dx
|
||||
const cy = ay + t * dy
|
||||
return { distanceKm: Math.hypot(px - cx, py - cy), t }
|
||||
}
|
||||
|
||||
export interface CorridorHit {
|
||||
/** Kilometres from the route line at its closest point. */
|
||||
offRouteKm: number
|
||||
/** Kilometres travelled along the route before reaching that closest point. */
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a point sits relative to the route: how far off it is, and how far into the
|
||||
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
|
||||
* instead of sorting by name.
|
||||
*/
|
||||
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
|
||||
if (line.length < 2) return null
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let bestAlong = 0
|
||||
let travelled = 0
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const a = line[i]
|
||||
const b = line[i + 1]
|
||||
const segment = haversineKm(a, b)
|
||||
const { distanceKm: d, t } = projectOnSegment(p, a, b)
|
||||
if (d < best) {
|
||||
best = d
|
||||
bestAlong = travelled + t * segment
|
||||
}
|
||||
travelled += segment
|
||||
}
|
||||
return { offRouteKm: best, alongKm: bestAlong }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
|
||||
*
|
||||
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
|
||||
* asked to scan a continent, which means a long drive has to be cut into boxes here
|
||||
* rather than sent as one. Boxes are grown along the route until one would exceed the
|
||||
* span, so a straight motorway needs far fewer requests than a mountain road.
|
||||
*/
|
||||
/**
|
||||
* Splits a step that is longer than a box into even pieces.
|
||||
*
|
||||
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
|
||||
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
|
||||
* nothing in between, so the middle of the drive would never be searched.
|
||||
*/
|
||||
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
|
||||
if (line.length < 2) return line
|
||||
const out: LatLng[] = [line[0]]
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const a = line[i - 1]
|
||||
const b = line[i]
|
||||
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
|
||||
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
out.push({
|
||||
lat: a.lat + ((b.lat - a.lat) * s) / steps,
|
||||
lng: a.lng + ((b.lng - a.lng) * s) / steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
|
||||
if (line.length === 0) return []
|
||||
const padLat = widthKm / KM_PER_DEG_LAT
|
||||
const tiles: Bbox[] = []
|
||||
let current: Bbox | null = null
|
||||
|
||||
const padded = (b: Bbox): Bbox => {
|
||||
// Longitude degrees shrink towards the poles, so the padding has to grow with
|
||||
// latitude or the corridor is narrower than asked for in the north.
|
||||
const midLat = (b.north + b.south) / 2
|
||||
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
|
||||
return {
|
||||
south: b.south - padLat,
|
||||
north: b.north + padLat,
|
||||
west: b.west - padLng,
|
||||
east: b.east + padLng,
|
||||
}
|
||||
}
|
||||
|
||||
// The point the open box last swallowed. When a box closes, the next one starts
|
||||
// from it rather than from `p` alone, so the two overlap by exactly one step of
|
||||
// the walk. Starting at `p` left the stretch between them covered only by the
|
||||
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
|
||||
// padding is widthKm, so at the offered widths a band of several kilometres of
|
||||
// route fell into no box at all. Nothing downstream noticed: the panel counts
|
||||
// tiles asked and tiles answered, so it reported a finished, uncapped,
|
||||
// error-free search of a drive it had never fully looked at. "Find fuel along
|
||||
// the route" is the whole feature.
|
||||
let previous: LatLng | null = null
|
||||
|
||||
for (const p of densify(line, maxSpanDeg / 2)) {
|
||||
if (!current) {
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
previous = p
|
||||
continue
|
||||
}
|
||||
const next: Bbox = {
|
||||
south: Math.min(current.south, p.lat),
|
||||
north: Math.max(current.north, p.lat),
|
||||
west: Math.min(current.west, p.lng),
|
||||
east: Math.max(current.east, p.lng),
|
||||
}
|
||||
const grown = padded(next)
|
||||
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
||||
tiles.push(padded(current))
|
||||
const from = previous ?? p
|
||||
current = {
|
||||
south: Math.min(from.lat, p.lat),
|
||||
north: Math.max(from.lat, p.lat),
|
||||
west: Math.min(from.lng, p.lng),
|
||||
east: Math.max(from.lng, p.lng),
|
||||
}
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
previous = p
|
||||
}
|
||||
if (current) tiles.push(padded(current))
|
||||
return tiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins a route down to the points that still describe it, so the corridor is built
|
||||
* from tens of coordinates rather than the thousands a routed geometry carries.
|
||||
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
|
||||
*/
|
||||
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
if (line.length < 3) return line.slice()
|
||||
let maxDist = 0
|
||||
let index = 0
|
||||
const first = line[0]
|
||||
const last = line[line.length - 1]
|
||||
for (let i = 1; i < line.length - 1; i++) {
|
||||
const d = distanceToSegmentKm(line[i], first, last)
|
||||
if (d > maxDist) {
|
||||
maxDist = d
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if (maxDist <= toleranceKm) return [first, last]
|
||||
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { haversineKm, type LatLng } from './corridor'
|
||||
import {
|
||||
parseTrack, lineLengthKm, thin, nearestIndex, orient, stopIndices, trackGapKm, planTrack,
|
||||
} from './followTrack'
|
||||
|
||||
/**
|
||||
* The refinement is the interesting part, and it is testable without a router: what it
|
||||
* needs is something that answers "the road I would drive through these waypoints", and
|
||||
* the straight line between them is exactly the answer that makes the loop work for its
|
||||
* living. A track that bulges away from that chord is the shape every scenic route has.
|
||||
*/
|
||||
|
||||
/** A track arching north away from the straight line between its ends. */
|
||||
function arc(points = 120, bulgeDeg = 0.1): LatLng[] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => ({
|
||||
lat: 52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
lng: 13 + i / points,
|
||||
}))
|
||||
}
|
||||
|
||||
const START: LatLng = { lat: 52, lng: 13 }
|
||||
const END: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
/** A router with no idea about roads: it drives straight from waypoint to waypoint. */
|
||||
function straightRouter(): { route: (w: LatLng[]) => Promise<LatLng[]>; calls: LatLng[][] } {
|
||||
const calls: LatLng[][] = []
|
||||
return {
|
||||
calls,
|
||||
route: async (waypoints: LatLng[]) => {
|
||||
calls.push(waypoints)
|
||||
return waypoints
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseTrack', () => {
|
||||
it('FE-FOLLOWTRACK-001: reads pairs and triples, and steps over what is neither', () => {
|
||||
const geometry = JSON.stringify([[52, 13], [52.1, 13.1, 340], ['x', 1], [1], null, [53, 14]])
|
||||
expect(parseTrack(geometry)).toEqual([
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.1, lng: 13.1 },
|
||||
{ lat: 53, lng: 14 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-002: a track that is not a track is nothing, never a throw', () => {
|
||||
// Somebody else's column, filled by an importer that has seen every malformed GPX
|
||||
// there is. A day that cannot follow a track is a day; an exception here is a page.
|
||||
expect(parseTrack('not json')).toEqual([])
|
||||
expect(parseTrack('{"type":"LineString"}')).toEqual([])
|
||||
expect(parseTrack(null)).toEqual([])
|
||||
expect(parseTrack(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinning and measuring', () => {
|
||||
it('FE-FOLLOWTRACK-003: thinning keeps both ends, whatever the stride', () => {
|
||||
const line = arc(1000)
|
||||
const thinned = thin(line, 400)
|
||||
expect(thinned.length).toBeLessThanOrEqual(401)
|
||||
expect(thinned[0]).toEqual(line[0])
|
||||
expect(thinned[thinned.length - 1]).toEqual(line[line.length - 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-004: a line shorter than the cap comes back untouched', () => {
|
||||
const line = arc(10)
|
||||
expect(thin(line, 400)).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-005: an arc is longer than the chord it spans', () => {
|
||||
expect(lineLengthKm(arc())).toBeGreaterThan(haversineKm(START, END))
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-006: the nearest point of a track is found by position, not by order', () => {
|
||||
const line = arc(20)
|
||||
expect(nearestIndex(line, line[7])).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orientation', () => {
|
||||
it('FE-FOLLOWTRACK-007: a track recorded the other way round is turned', () => {
|
||||
// Half of Norway's scenic routes are published southbound. Without this the vias come
|
||||
// out in reverse order and the day drives the loop backwards.
|
||||
const backwards = [...arc(20)].reverse()
|
||||
const turned = orient(backwards, [START, END])
|
||||
expect(turned[0].lng).toBeLessThan(turned[turned.length - 1].lng)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-008: a track already pointing the right way is left alone', () => {
|
||||
const line = arc(20)
|
||||
expect(orient(line, [START, END])).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-009: stops never walk backwards along the track', () => {
|
||||
// A track that doubles back has two nearest points for the same stop, and the wrong
|
||||
// one would put a via on the leg before it.
|
||||
const line = arc(40)
|
||||
const stops = [START, { lat: 52.05, lng: 13.5 }, END]
|
||||
const idx = stopIndices(line, stops)
|
||||
expect(idx[0]).toBeLessThanOrEqual(idx[1])
|
||||
expect(idx[1]).toBeLessThanOrEqual(idx[2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTrack', () => {
|
||||
it('FE-FOLLOWTRACK-010: a road that already follows the track needs no vias', async () => {
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route: async () => track })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.rounds).toBe(1)
|
||||
expect(plan.capped).toBe(false)
|
||||
// Still names the leg it runs along, so applying it clears what an earlier pass left.
|
||||
expect(plan.legs).toEqual([0])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-011: it refines until the road is inside the tolerance', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
expect(plan.capped).toBe(false)
|
||||
expect(plan.strayKm).toBeLessThanOrEqual(1)
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
// One route per round, and the waypoint list grows by exactly the via just added.
|
||||
expect(calls.length).toBe(plan.rounds)
|
||||
expect(calls[calls.length - 1].length).toBe(2 + plan.vias.length)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-012: every via lands on the track, in driving order', async () => {
|
||||
const { route } = straightRouter()
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
for (const via of plan.vias) {
|
||||
expect(track.some(p => haversineKm(p, via) < 0.001)).toBe(true)
|
||||
}
|
||||
const longitudes = plan.vias.map(v => v.lng)
|
||||
expect([...longitudes].sort((a, b) => a - b)).toEqual(longitudes)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-013: no via is laid down on top of a stop', async () => {
|
||||
// A via a hundred metres from a stop costs a waypoint to tell the router what the
|
||||
// stop already told it.
|
||||
const { route } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 0.2, maxVias: 8 } })
|
||||
for (const via of plan.vias) {
|
||||
expect(haversineKm(via, START)).toBeGreaterThanOrEqual(0.6)
|
||||
expect(haversineKm(via, END)).toBeGreaterThanOrEqual(0.6)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-014: a via hangs behind the stop whose leg it shapes', async () => {
|
||||
const { route } = straightRouter()
|
||||
// Three stops, and the bulge is entirely on the second leg.
|
||||
const straight: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 52, lng: 13 + i / 78 }))
|
||||
const bulge: LatLng[] = Array.from({ length: 81 }, (_, i) => ({
|
||||
lat: 52 + 0.1 * Math.sin((Math.PI * i) / 80),
|
||||
lng: 13.5 + i / 160,
|
||||
}))
|
||||
const middle: LatLng = { lat: 52, lng: 13.5 }
|
||||
const plan = await planTrack({
|
||||
track: [...straight, ...bulge],
|
||||
stops: [START, middle, END],
|
||||
route,
|
||||
options: { toleranceKm: 1 },
|
||||
})
|
||||
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
expect(plan.vias.every(v => v.after_order_index === 1)).toBe(true)
|
||||
expect(plan.legs).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-015: it gives up rather than spending the day on a road that will not fit', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route,
|
||||
options: { toleranceKm: 0.0001, maxVias: 3 },
|
||||
})
|
||||
|
||||
expect(plan.capped).toBe(true)
|
||||
expect(plan.vias).toHaveLength(3)
|
||||
expect(calls.length).toBe(4)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-016: a track the day only brushes past is not a route for it', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: [{ lat: 40, lng: 2 }, { lat: 40.001, lng: 2.001 }], stops: [START, END], route })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.legs).toEqual([])
|
||||
// Never asks the router about a track that has nothing to do with the day.
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-017: nothing to route through is not an error', async () => {
|
||||
const { route } = straightRouter()
|
||||
expect((await planTrack({ track: arc(), stops: [START], route })).vias).toEqual([])
|
||||
expect((await planTrack({ track: [], stops: [START, END], route })).vias).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-018: a router that answers with no road says so', async () => {
|
||||
await expect(planTrack({ track: arc(), stops: [START, END], route: async () => [] }))
|
||||
.rejects.toThrow(/road/)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-019: an aborted plan stops where it is', async () => {
|
||||
const controller = new AbortController()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: controller.signal,
|
||||
options: { toleranceKm: 0.001 },
|
||||
})
|
||||
controller.abort()
|
||||
// Ran to its own conclusion here; the point is that the signal is honoured at the top
|
||||
// of each round, which the aborted-before-start case proves.
|
||||
expect(plan.rounds).toBeGreaterThan(0)
|
||||
|
||||
const stopped = new AbortController()
|
||||
stopped.abort()
|
||||
const none = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: stopped.signal,
|
||||
})
|
||||
expect(none.rounds).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-020: how far a track runs from a day is what orders a list of them', async () => {
|
||||
const near = trackGapKm(arc(), [START, END])
|
||||
const far = trackGapKm(arc().map(p => ({ lat: p.lat + 3, lng: p.lng })), [START, END])
|
||||
expect(near).toBeLessThan(far)
|
||||
expect(trackGapKm([], [START])).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
})
|
||||
@@ -1,318 +0,0 @@
|
||||
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
|
||||
|
||||
/**
|
||||
* Making a day's drive follow an imported track.
|
||||
*
|
||||
* A GPX track is a shape, not a route: a list of positions with no idea which roads they
|
||||
* were recorded on. Drawing it beside the day is easy and useless — the distance, the
|
||||
* drive time and every warning still belong to the motorway the router picked, so the
|
||||
* plan says four hours while the road nobody is taking says seven. Norway publishes its
|
||||
* scenic routes this way, and so does every touring club worth importing from.
|
||||
*
|
||||
* What turns a shape into a route the day actually takes is a handful of vias placed on
|
||||
* it, because a via is the one thing a router treats as "go through here". Placing them
|
||||
* is the whole problem. Every point of the track would route exactly and take a hundred
|
||||
* requests; two would route in seconds and follow nothing.
|
||||
*
|
||||
* So this refines: route through what we have, find the point of the track the road
|
||||
* misses by the most, make that point a via, and go again. Each round roughly halves the
|
||||
* worst miss, because the new via lands in the middle of whatever the last one left
|
||||
* unfollowed — which is why a scenic route usually fits inside ten of them, and why the
|
||||
* loop stops when the road is close enough rather than after a fixed count.
|
||||
*
|
||||
* Everything here is pure apart from the injected router: no React, no store, no api.
|
||||
*/
|
||||
|
||||
/** Track points kept for measuring — enough to catch a hairpin, few enough to be fast. */
|
||||
const MEASURE_POINTS = 400
|
||||
|
||||
/** Routed points kept for measuring. Coarser: it is the thing being measured against. */
|
||||
const ROUTE_POINTS = 2000
|
||||
|
||||
/**
|
||||
* How close a via may sit to a stop, or to another via, before it is pointless.
|
||||
*
|
||||
* A via a hundred metres from a stop tells the router nothing it did not already know
|
||||
* from the stop, and two vias in the same lay-by cost a waypoint each to say one thing.
|
||||
*/
|
||||
const MIN_GAP_KM = 0.6
|
||||
|
||||
export interface FollowTrackOptions {
|
||||
/** How close the road must come to the track before the plan is done, in kilometres. */
|
||||
toleranceKm?: number
|
||||
/** The most vias the plan may lay down. */
|
||||
maxVias?: number
|
||||
/** The most routing round trips it may spend. */
|
||||
maxRounds?: number
|
||||
}
|
||||
|
||||
export interface TrackVia {
|
||||
/** The stop the via hangs behind, as an index into the day's stops. */
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface FollowTrackPlan {
|
||||
/** The vias to write, in driving order, each already on the leg it shapes. */
|
||||
vias: TrackVia[]
|
||||
/** The legs the track runs along — cleared first, so applying twice does not stack. */
|
||||
legs: number[]
|
||||
/** How far the road still strays from the track at its worst point, in kilometres. */
|
||||
strayKm: number
|
||||
/** Routing round trips spent. */
|
||||
rounds: number
|
||||
/** True when it ran out of vias or rounds before the road fitted the track. */
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: FollowTrackPlan = { vias: [], legs: [], strayKm: 0, rounds: 0, capped: false }
|
||||
|
||||
/**
|
||||
* The points of a stored track.
|
||||
*
|
||||
* `route_geometry` is the column the GPX and KML importers write, holding `[lat, lng]`
|
||||
* pairs or `[lat, lng, ele]` triples depending on whether every point carried an
|
||||
* elevation. Anything else in there is somebody else's data — a malformed track is worth
|
||||
* skipping, never worth throwing over.
|
||||
*/
|
||||
export function parseTrack(routeGeometry: string | null | undefined): LatLng[] {
|
||||
if (!routeGeometry) return []
|
||||
try {
|
||||
const raw: unknown = JSON.parse(routeGeometry)
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: LatLng[] = []
|
||||
for (const point of raw) {
|
||||
if (!Array.isArray(point) || point.length < 2) continue
|
||||
const lat = Number(point[0])
|
||||
const lng = Number(point[1])
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
out.push({ lat, lng })
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a polyline is, in kilometres. */
|
||||
export function lineLengthKm(line: LatLng[]): number {
|
||||
let km = 0
|
||||
for (let i = 1; i < line.length; i++) km += haversineKm(line[i - 1], line[i])
|
||||
return km
|
||||
}
|
||||
|
||||
/**
|
||||
* Every nth point, keeping both ends.
|
||||
*
|
||||
* Deliberately not the Douglas-Peucker in `corridor.ts`: that one is recursive and slices
|
||||
* the array at every step, which is fine for a routed leg and quadratic on a recorded
|
||||
* track of thirty thousand points. Taking a stride loses a little accuracy on a hairpin
|
||||
* and none of the shape, and the tolerance this feeds is measured in kilometres.
|
||||
*/
|
||||
export function thin(line: LatLng[], max: number): LatLng[] {
|
||||
if (line.length <= max) return line
|
||||
const stride = Math.ceil(line.length / max)
|
||||
const out: LatLng[] = []
|
||||
for (let i = 0; i < line.length; i += stride) out.push(line[i])
|
||||
const last = line[line.length - 1]
|
||||
if (out[out.length - 1] !== last) out.push(last)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Index of the point of `line` closest to `p`. */
|
||||
export function nearestIndex(line: LatLng[], p: LatLng): number {
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let at = 0
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const d = haversineKm(line[i], p)
|
||||
if (d < best) {
|
||||
best = d
|
||||
at = i
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
/** How far a point lies from a line, in kilometres. Infinite for a line of one point. */
|
||||
export function offLineKm(line: LatLng[], p: LatLng): number {
|
||||
return projectOntoRoute(p, line)?.offRouteKm ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* The track pointing the way the day is driven.
|
||||
*
|
||||
* A recorded track has a direction, and it is whichever way the person who recorded it
|
||||
* happened to drive. Half of Norway's scenic routes are published southbound. Reversed
|
||||
* where the day's last stop comes before its first, so everything downstream can treat a
|
||||
* higher index as "further along the drive".
|
||||
*/
|
||||
export function orient(track: LatLng[], stops: LatLng[]): LatLng[] {
|
||||
if (track.length < 2 || stops.length < 2) return track
|
||||
const start = nearestIndex(track, stops[0])
|
||||
const end = nearestIndex(track, stops[stops.length - 1])
|
||||
return end < start ? [...track].reverse() : track
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each stop sits on the track, as an index.
|
||||
*
|
||||
* Forced not to go backwards. A stop nowhere near the track still gets its nearest point,
|
||||
* and on a track that doubles back that point can land before the previous stop's —
|
||||
* which would put a via on the wrong leg and send the day round the loop twice.
|
||||
*/
|
||||
export function stopIndices(track: LatLng[], stops: LatLng[]): number[] {
|
||||
let floor = 0
|
||||
return stops.map(stop => {
|
||||
const at = Math.max(floor, nearestIndex(track, stop))
|
||||
floor = at
|
||||
return at
|
||||
})
|
||||
}
|
||||
|
||||
/** The leg an anchor belongs to: the last stop it comes after. */
|
||||
function legFor(anchor: number, stopIdx: number[]): number {
|
||||
let leg = 0
|
||||
for (let k = 0; k + 1 < stopIdx.length; k++) if (stopIdx[k] <= anchor) leg = k
|
||||
return leg
|
||||
}
|
||||
|
||||
/** The stops with the anchors threaded in between, in the order they are driven. */
|
||||
function waypointsFor(stops: LatLng[], anchors: number[], track: LatLng[], stopIdx: number[]): LatLng[] {
|
||||
const out: LatLng[] = []
|
||||
for (let k = 0; k < stops.length; k++) {
|
||||
out.push(stops[k])
|
||||
if (k + 1 >= stops.length) continue
|
||||
for (const anchor of anchors) if (legFor(anchor, stopIdx) === k) out.push(track[anchor])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the track the road misses by the most — among the ones worth fixing.
|
||||
*
|
||||
* Points already anchored are skipped because the router is going through them, and
|
||||
* points hard against a stop or an existing via are skipped because a via there would
|
||||
* buy nothing. What is left is the miss the next round can actually close.
|
||||
*/
|
||||
function worstMiss(
|
||||
track: LatLng[],
|
||||
road: LatLng[],
|
||||
span: { from: number; to: number },
|
||||
anchors: number[],
|
||||
stops: LatLng[],
|
||||
): { index: number; km: number } | null {
|
||||
const taken = new Set(anchors)
|
||||
let best: { index: number; km: number } | null = null
|
||||
for (let i = span.from; i <= span.to; i++) {
|
||||
if (taken.has(i)) continue
|
||||
const point = track[i]
|
||||
if (stops.some(s => haversineKm(s, point) < MIN_GAP_KM)) continue
|
||||
if (anchors.some(a => haversineKm(track[a], point) < MIN_GAP_KM)) continue
|
||||
const km = offLineKm(road, point)
|
||||
if (!Number.isFinite(km)) continue
|
||||
if (!best || km > best.km) best = { index: i, km }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface TrackPlanInput {
|
||||
/** The track's points, as stored. */
|
||||
track: LatLng[]
|
||||
/** The day's stops, in driving order. */
|
||||
stops: LatLng[]
|
||||
/** Routes through the given waypoints and answers with the road it drew. */
|
||||
route: (waypoints: LatLng[]) => Promise<LatLng[]>
|
||||
options?: FollowTrackOptions
|
||||
/** Called after each round, so a dialog can say how far it has got. */
|
||||
onRound?: (round: number, strayKm: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a day a track has anything to say about.
|
||||
*
|
||||
* A trip full of imported tracks needs an order to offer them in, and "how far this one
|
||||
* runs from that drive" is the answer — measured from the stops rather than from the
|
||||
* routed line, so it holds for a day that has not routed yet.
|
||||
*/
|
||||
export function trackGapKm(track: LatLng[], stops: LatLng[]): number {
|
||||
if (!track.length || !stops.length) return Number.POSITIVE_INFINITY
|
||||
const line = thin(track, MEASURE_POINTS)
|
||||
if (line.length < 2) return haversineKm(line[0], stops[0])
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
for (const stop of stops) best = Math.min(best, offLineKm(line, stop))
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Vias that pull a day's drive onto a track.
|
||||
*
|
||||
* Routes the whole day per round rather than one leg at a time: the day is a single
|
||||
* request to begin with, and asking per leg would multiply every round by the number of
|
||||
* legs against a host that answers one request a second.
|
||||
*/
|
||||
export async function planTrack({
|
||||
track,
|
||||
stops,
|
||||
route,
|
||||
options = {},
|
||||
onRound,
|
||||
signal,
|
||||
}: TrackPlanInput): Promise<FollowTrackPlan> {
|
||||
const toleranceKm = options.toleranceKm ?? 1
|
||||
const maxVias = options.maxVias ?? 12
|
||||
const maxRounds = options.maxRounds ?? 10
|
||||
if (track.length < 2 || stops.length < 2) return EMPTY_PLAN
|
||||
|
||||
const measured = orient(thin(track, MEASURE_POINTS), stops)
|
||||
const stopIdx = stopIndices(measured, stops)
|
||||
const span = { from: stopIdx[0], to: stopIdx[stopIdx.length - 1] }
|
||||
// A track the day only touches at one point is a track for some other day.
|
||||
if (span.to - span.from < 2) return EMPTY_PLAN
|
||||
|
||||
// The legs the track runs along. Read off the stops, not off the anchors, so applying
|
||||
// a track twice clears what the first pass laid down instead of stacking on it — and
|
||||
// so a track that already fits still says which legs it owns.
|
||||
const legs: number[] = []
|
||||
for (let k = 0; k + 1 < stops.length; k++) {
|
||||
if (stopIdx[k + 1] > span.from && stopIdx[k] < span.to) legs.push(k)
|
||||
}
|
||||
|
||||
const anchors: number[] = []
|
||||
let strayKm = 0
|
||||
let rounds = 0
|
||||
let capped = false
|
||||
|
||||
for (;;) {
|
||||
if (signal?.aborted) break
|
||||
const drawn = await route(waypointsFor(stops, anchors, measured, stopIdx))
|
||||
if (drawn.length < 2) throw new Error('The router answered without a road')
|
||||
rounds += 1
|
||||
|
||||
const miss = worstMiss(measured, thin(drawn, ROUTE_POINTS), span, anchors, stops)
|
||||
// No candidate left to fix is as finished as being inside the tolerance: everything
|
||||
// that remains sits against a stop or against a via that is already there.
|
||||
strayKm = miss?.km ?? 0
|
||||
onRound?.(rounds, strayKm)
|
||||
if (!miss || miss.km <= toleranceKm) break
|
||||
if (anchors.length >= maxVias || rounds >= maxRounds) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
anchors.push(miss.index)
|
||||
anchors.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return {
|
||||
vias: anchors.map(i => ({
|
||||
after_order_index: legFor(i, stopIdx),
|
||||
lat: measured[i].lat,
|
||||
lng: measured[i].lng,
|
||||
})),
|
||||
legs,
|
||||
strayKm,
|
||||
rounds,
|
||||
capped,
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder,
|
||||
isEmptyReanchoring,
|
||||
type AnchoredVia,
|
||||
} from './roadtripModel'
|
||||
|
||||
/**
|
||||
* A via lives at a position in the day's stop list, and that position is resolved fresh
|
||||
* every time the day is routed. These tests pin what happens to it when the list moves
|
||||
* under it — the arithmetic that decides whether a reshaped drive survives adding a
|
||||
* petrol station to it.
|
||||
*/
|
||||
const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
|
||||
({ id, after_order_index: after, lat, lng })
|
||||
|
||||
/** The anchors as a plain map, so a test reads as "which via ended up where". */
|
||||
const anchors = (moved: { id: number; after_order_index: number }[]) =>
|
||||
Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
|
||||
|
||||
describe('reanchorAfterInsert', () => {
|
||||
it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
|
||||
// Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
|
||||
// The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
|
||||
// position 1. The via is passed before the fuel stop, so it belongs to the first half.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
|
||||
// Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
|
||||
// first half back on itself and leave the long second half unshaped — which is the
|
||||
// route snapping back to the old road.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
|
||||
const early = via(1, 1)
|
||||
const late = via(2, 1)
|
||||
const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
|
||||
throw new Error('nothing is split when the new stop leads the day')
|
||||
})
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
|
||||
// Four stops, legs 0..2, appended as the fifth.
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-008: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterRemove', () => {
|
||||
it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
|
||||
// Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
|
||||
const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
|
||||
// Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
|
||||
expect(plan.remove).toEqual([2])
|
||||
expect(anchors(plan.vias)).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterReorder', () => {
|
||||
it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
|
||||
// Five stops, legs 0..3. Stop 1 moves to position 3.
|
||||
const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
|
||||
// Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
|
||||
// Moving the first stop kills the leg leaving it, and nothing later re-creates that
|
||||
// via's anchor — reporting it for deletion is the only honest answer.
|
||||
const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-016: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-017: swapping the only two stops keeps every via', () => {
|
||||
// A reorder removes nothing. Leg 0 is still leg 0 after the swap, merely
|
||||
// driven the other way round, so the anchors are already right. Routing this
|
||||
// through the removal half asked the server to delete every via of the day —
|
||||
// and two stops is exactly the shape "follow this track" produces, up to
|
||||
// nine vias on that one leg, gone to a single drag with no prompt and no
|
||||
// undo.
|
||||
const vias = [via(11, 0), via(12, 0), via(13, 0)]
|
||||
|
||||
for (const [from, to] of [[0, 1], [1, 0]] as const) {
|
||||
const plan = reanchorAfterReorder(vias, from, to, 2)
|
||||
expect(plan.remove, `${from}->${to}`).toEqual([])
|
||||
expect(isEmptyReanchoring(plan), `${from}->${to}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-019: a wholesale reorder follows the stop, not the number', () => {
|
||||
// The plan-mode drag hands a whole new ordering rather than one move, so the
|
||||
// anchors are mapped by the stop they were pinned behind. Stops A,B,C,D with
|
||||
// a via on the C->D leg (anchor 2): move A to the end and C is now at index
|
||||
// 1, so the via belongs on leg 1.
|
||||
const plan = reanchorByStopOrder([via(1, 2)], [10, 20, 30, 40], [20, 30, 40, 10])
|
||||
expect(plan.vias).toEqual([{ id: 1, after_order_index: 1 }])
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-020: a via whose stop became the last one has no leg left', () => {
|
||||
// Nothing follows the final stop, so there is no drive for the via to bend.
|
||||
const plan = reanchorByStopOrder([via(1, 0)], [10, 20, 30], [20, 30, 10])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-021: a stop that is gone takes its vias with it', () => {
|
||||
const plan = reanchorByStopOrder([via(1, 1)], [10, 20, 30], [10, 30])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-022: an ordering that did not actually change asks for nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorByStopOrder([via(1, 0)], [10, 20, 30], [10, 20, 30]))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-018: removing a stop from a two-stop day still clears the leg', () => {
|
||||
// The other half of the same rule, and the reason the short-circuit exists:
|
||||
// take a stop away from a two-stop day and there is no drive left, so a via
|
||||
// has nowhere to sit.
|
||||
const plan = reanchorAfterRemove([via(11, 0), via(12, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([11, 12])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,547 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeSchedule,
|
||||
formatClock,
|
||||
formatDurationShort,
|
||||
insertIndexForAlong,
|
||||
deriveDriveWarnings,
|
||||
legIndexForAlong,
|
||||
refuelsRange,
|
||||
sectionAnchors,
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
} from './roadtripModel'
|
||||
|
||||
describe('formatDurationShort', () => {
|
||||
it('prints minutes below an hour', () => {
|
||||
expect(formatDurationShort(45 * 60)).toBe('45 min')
|
||||
expect(formatDurationShort(0)).toBe('0 min')
|
||||
})
|
||||
|
||||
it('drops the zero minutes on a whole hour', () => {
|
||||
expect(formatDurationShort(3600)).toBe('1 h')
|
||||
expect(formatDurationShort(2 * 3600)).toBe('2 h')
|
||||
})
|
||||
|
||||
it('prints hours and minutes together', () => {
|
||||
expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min')
|
||||
})
|
||||
|
||||
it('carries instead of printing sixty minutes', () => {
|
||||
// 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h.
|
||||
expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h')
|
||||
})
|
||||
|
||||
it('treats nonsense as nothing rather than throwing', () => {
|
||||
expect(formatDurationShort(Number.NaN)).toBe('0 min')
|
||||
expect(formatDurationShort(-90)).toBe('0 min')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClock', () => {
|
||||
it('reads wall-clock times', () => {
|
||||
expect(parseClock('09:45')).toBe(9 * 60 + 45)
|
||||
expect(parseClock('9:05')).toBe(9 * 60 + 5)
|
||||
expect(parseClock('00:00')).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores anything trailing, as stored times sometimes carry seconds', () => {
|
||||
expect(parseClock('14:30:00')).toBe(14 * 60 + 30)
|
||||
})
|
||||
|
||||
it('rejects what is not a time', () => {
|
||||
expect(parseClock(null)).toBeNull()
|
||||
expect(parseClock('')).toBeNull()
|
||||
expect(parseClock('later')).toBeNull()
|
||||
expect(parseClock('25:00')).toBeNull()
|
||||
expect(parseClock('12:75')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatClock', () => {
|
||||
it('pads both halves', () => {
|
||||
expect(formatClock(9 * 60 + 5)).toBe('09:05')
|
||||
expect(formatClock(0)).toBe('00:00')
|
||||
})
|
||||
|
||||
it('wraps past midnight instead of printing a 25th hour', () => {
|
||||
expect(formatClock(25 * 60)).toBe('01:00')
|
||||
expect(formatClock(-30)).toBe('23:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeSchedule', () => {
|
||||
const hours = (h: number): number => h * 3600
|
||||
|
||||
it('walks arrival and departure forward from the first pinned time', () => {
|
||||
// The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: null, dwellMinutes: 120 },
|
||||
{ anchor: null, dwellMinutes: 45 },
|
||||
],
|
||||
[hours(1.75), hours(1) + 10 * 60],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true })
|
||||
expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false })
|
||||
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
|
||||
})
|
||||
|
||||
it('leaves stops before the first pinned time blank rather than inventing one', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
{ anchor: '10:00', dwellMinutes: 30 },
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('restarts the cascade at a pinned stop instead of pushing it', () => {
|
||||
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
|
||||
// after it counts from 11:00, not from the drive.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '11:00', dwellMinutes: 60 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[hours(1), hours(1)],
|
||||
)
|
||||
expect(entries[1].arrival).toBe('11:00')
|
||||
expect(entries[2].arrival).toBe('13:00')
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => {
|
||||
// Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be
|
||||
// placed on the day the cascade had reached so far — and that only advances
|
||||
// once a computed arrival crosses midnight, which is read before this stop's
|
||||
// arrival is known. So the stop that does the crossing had its own pin put a
|
||||
// whole day early: the rail drew a warning-coloured "+24 h" on a plan that
|
||||
// was exactly on time, and dropped the overnight marker with it.
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6)],
|
||||
)
|
||||
|
||||
expect(warnings.filter(w => w.code === 'late')).toEqual([])
|
||||
expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true })
|
||||
// The day still turned over, so the rail keeps its carry badge.
|
||||
expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => {
|
||||
// The other half: picking the nearest occurrence must not swallow a genuine
|
||||
// delay. Leaving at 21:00 and driving five hours arrives at 02:00, half an
|
||||
// hour after the 01:30 ferry, and that is what it says.
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '21:00', dwellMinutes: 0 },
|
||||
{ anchor: '01:30', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(5)],
|
||||
)
|
||||
expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 })
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => {
|
||||
// Nothing to be late against, but the day carried so far still applies —
|
||||
// otherwise the stop prints under a day-1 stop as though it happened first.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6), undefined],
|
||||
)
|
||||
expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 })
|
||||
})
|
||||
|
||||
it('reports a stop the drive cannot reach in time', () => {
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(3)],
|
||||
)
|
||||
expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }])
|
||||
})
|
||||
|
||||
it('does not cry about a minute of rounding', () => {
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(1) + 30],
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('breaks the chain at a leg that never routed', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 30 },
|
||||
{ anchor: null, dwellMinutes: 30 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[undefined, hours(1)],
|
||||
)
|
||||
expect(entries[0].arrival).toBe('08:00')
|
||||
expect(entries[1].arrival).toBeNull()
|
||||
expect(entries[2].arrival).toBeNull()
|
||||
})
|
||||
|
||||
it('flags the day rolling past midnight and keeps counting', () => {
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '22:00', dwellMinutes: 0 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[hours(4)],
|
||||
)
|
||||
expect(entries[1].arrival).toBe('02:00')
|
||||
expect(entries[1].dayOffset).toBe(1)
|
||||
expect(warnings).toEqual([{ index: 1, code: 'overnight' }])
|
||||
})
|
||||
|
||||
it('handles an empty chain', () => {
|
||||
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] })
|
||||
})
|
||||
|
||||
it('treats a missing dwell as no time spent', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: null },
|
||||
{ anchor: null, dwellMinutes: null },
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0].departure).toBe('08:00')
|
||||
expect(entries[1].arrival).toBe('09:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitIntoRuns', () => {
|
||||
const drive = (): string => 'driving'
|
||||
|
||||
it('asks for a whole day travelled one way in a single run', () => {
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive)
|
||||
expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }])
|
||||
})
|
||||
|
||||
it('splits where the travel mode changes, and repeats the stop on both sides', () => {
|
||||
// Drive to b, walk to c, drive on to d: the walk is its own request, and b and c
|
||||
// each belong to two runs because they are the ends of neighbouring legs.
|
||||
const modes = ['driving', 'walking', 'driving']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b'], mode: 'driving' },
|
||||
{ stops: ['b', 'c'], mode: 'walking' },
|
||||
{ stops: ['c', 'd'], mode: 'driving' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps consecutive legs of the same mode together across a change and back', () => {
|
||||
const modes = ['driving', 'driving', 'walking', 'walking']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b', 'c'], mode: 'driving' },
|
||||
{ stops: ['c', 'd', 'e'], mode: 'walking' },
|
||||
])
|
||||
})
|
||||
|
||||
it('has nothing to ask for when there is no leg', () => {
|
||||
expect(splitIntoRuns([], drive)).toEqual([])
|
||||
expect(splitIntoRuns(['a'], drive)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sumLegSeconds', () => {
|
||||
it('adds only the legs that routed', () => {
|
||||
expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
|
||||
expect(sumLegSeconds([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertIndexForAlong', () => {
|
||||
// Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
|
||||
const stops = [0, 50, 290]
|
||||
|
||||
it('puts a hit between the two stops it falls between', () => {
|
||||
expect(insertIndexForAlong(stops, 120)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 20)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands before the stop the day starts from', () => {
|
||||
expect(insertIndexForAlong(stops, 0)).toBe(1)
|
||||
// A corridor is wider than the road, so a hit can project just behind the start.
|
||||
expect(insertIndexForAlong(stops, -3)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands after the stop the day ends at', () => {
|
||||
expect(insertIndexForAlong(stops, 290)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 400)).toBe(2)
|
||||
})
|
||||
|
||||
it('puts a hit exactly on a stop after it, not before', () => {
|
||||
expect(insertIndexForAlong(stops, 50)).toBe(2)
|
||||
})
|
||||
|
||||
it('has nowhere to insert on a day that is not a drive', () => {
|
||||
expect(insertIndexForAlong([], 10)).toBe(0)
|
||||
expect(insertIndexForAlong([0], 10)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legIndexForAlong', () => {
|
||||
// Against the legs' own lengths rather than by projecting the stops as well: leg
|
||||
// distances come from the router and are exact, while two stops close together project
|
||||
// onto each other's stretch and would put a charging halt the wrong side of a town.
|
||||
const legs = [10_000, 25_000, 40_000]
|
||||
|
||||
it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => {
|
||||
expect(legIndexForAlong(legs, 0)).toBe(0)
|
||||
expect(legIndexForAlong(legs, 9_999)).toBe(0)
|
||||
expect(legIndexForAlong(legs, 10_000)).toBe(1)
|
||||
expect(legIndexForAlong(legs, 24_999)).toBe(1)
|
||||
expect(legIndexForAlong(legs, 25_000)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => {
|
||||
// A rounding edge, not a missing leg: the projection and the router measure the same
|
||||
// line two different ways and disagree by metres at the very end.
|
||||
expect(legIndexForAlong(legs, 40_000)).toBe(2)
|
||||
expect(legIndexForAlong(legs, 99_999)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => {
|
||||
expect(legIndexForAlong([], 100)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings', () => {
|
||||
// Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on
|
||||
// a stop has to agree with the sum of the drive bands above it, or it looks made up.
|
||||
it('FE-ROADTRIP-MODEL-069: the figure on a stop is what the bands above it add up to', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
// 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this
|
||||
// trip fills up.
|
||||
expect(out.warnings).toEqual([
|
||||
{ index: 2, code: 'range', sinceKm: 125 },
|
||||
{ index: 3, code: 'range', sinceKm: 370 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => {
|
||||
// Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly
|
||||
// what it is there for, so nothing is flagged on it; the budget still starts over,
|
||||
// and the next stretch is measured from there.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
|
||||
[false, false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }])
|
||||
// 245 and not 370: HEM filled the tank, so Berlin counts from there.
|
||||
})
|
||||
|
||||
const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }
|
||||
const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => {
|
||||
const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.day).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => {
|
||||
// On arrival, not on departure. Both findings are running totals that only become
|
||||
// true when you get there, and anchored to the departure the number reads as the
|
||||
// length of the NEXT leg, which is a different figure and the one that looked wrong.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(100, 90), leg(240, 200)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, legMinutes: 180 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(200, 180), leg(200, 180)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, dayMinutes: 360 },
|
||||
0,
|
||||
)
|
||||
expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => {
|
||||
// A warning is not a fill-up. Zeroing the budget at one made every figure after the
|
||||
// first wrong: the second stop would claim a fresh tank while the same one is still
|
||||
// in the car.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
|
||||
[false, false, false, false, false],
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([
|
||||
{ index: 2, code: 'range', sinceKm: 700 },
|
||||
{ index: 3, code: 'range', sinceKm: 1000 },
|
||||
{ index: 4, code: 'range', sinceKm: 1400 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => {
|
||||
// The same drive with a charger at stop 2. Nothing is flagged there, because reaching
|
||||
// it on an empty tank is what it is for; everything after counts from zero again, so
|
||||
// the three-warning run above collapses to one.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
|
||||
[false, false, true, false, false],
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => {
|
||||
const stops = [false, true, false]
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 500), leg(60, 500)],
|
||||
stops,
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
// The tank is filled at stop 1, so the second 500 km starts from zero and neither leg
|
||||
// trips the limit. Without the refuel it would be 1000 km on a 600 km range.
|
||||
expect(out.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => {
|
||||
expect(refuelsRange('fuel')).toBe(true)
|
||||
expect(refuelsRange('charging')).toBe(true)
|
||||
// A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a
|
||||
// charger in its car park.
|
||||
expect(refuelsRange('restaurant')).toBe(false)
|
||||
expect(refuelsRange('rest_area')).toBe(false)
|
||||
expect(refuelsRange('campsite')).toBe(false)
|
||||
expect(refuelsRange(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => {
|
||||
const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0)
|
||||
expect(first.carryKm).toBe(400)
|
||||
|
||||
const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm)
|
||||
expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[undefined, leg(60, 500)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.carryKm).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => {
|
||||
const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0)
|
||||
expect(out.carryKm).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionAnchors', () => {
|
||||
it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => {
|
||||
// The leg midpoints are the point of the feature: a break on a five-hour drive is
|
||||
// planned in the middle of the drive, not at either end of it.
|
||||
expect(sectionAnchors([0, 100, 250])).toEqual([
|
||||
{ kind: 'stop', index: 0, alongKm: 0 },
|
||||
{ kind: 'leg', index: 0, alongKm: 50 },
|
||||
{ kind: 'stop', index: 1, alongKm: 100 },
|
||||
{ kind: 'leg', index: 1, alongKm: 175 },
|
||||
{ kind: 'stop', index: 2, alongKm: 250 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => {
|
||||
expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }])
|
||||
expect(sectionAnchors([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — what counts as driving', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
const onFoot = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'walking' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => {
|
||||
// A day may legitimately mix modes — splitIntoRuns exists for exactly that —
|
||||
// and a two hour hike reported as a leg finding reads as a fault in a plan
|
||||
// that has none.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(120, 6)],
|
||||
[false, false, false],
|
||||
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => {
|
||||
// Nor does walking fill it: the budget is left exactly as the drive left it.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(120, 60), drive(30, 30)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
// 80 then 110 — the 60 walked kilometres never entered the budget.
|
||||
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }])
|
||||
expect(out.carryKm).toBe(110)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(180, 9)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: 90, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.day).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => {
|
||||
// Every leg was a drive before the field existed, so an absent mode counts.
|
||||
// Reading it the other way round would silently switch the warnings off.
|
||||
const out = deriveDriveWarnings(
|
||||
[{ duration: 120 * 60, distance: 200 * 1000 }],
|
||||
[false, false],
|
||||
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
|
||||
})
|
||||
})
|
||||
@@ -1,641 +0,0 @@
|
||||
/**
|
||||
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
|
||||
* function of the stops, the routed legs and the day's rules, so it can be unit tested
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
* Every kind the corridor search can find, and the list is meant to stay that way: what
|
||||
* you pick off "along the route" is by definition something you come across on the way,
|
||||
* so it belongs inside the leg it was found on rather than among the places the trip is
|
||||
* for. They are drawn on the dashed line with their own icon, carry no number, and are
|
||||
* left out of every stop count — four places with a charger, a rest stop and a bakery
|
||||
* between them is a four-stop day.
|
||||
*
|
||||
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
|
||||
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
|
||||
* a destination on one screen and a pause on the next.
|
||||
*/
|
||||
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
|
||||
|
||||
/**
|
||||
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
|
||||
*
|
||||
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
|
||||
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
|
||||
* printed in, a campsite is green. The last two are the colours the general place search
|
||||
* already gives food and sights, so a restaurant found along the route looks like the
|
||||
* restaurants found anywhere else.
|
||||
*
|
||||
* Deliberately literal and shared rather than tokenised: these mean the same thing in
|
||||
* both themes the way a motorway sign does, and one definition is what stops the rail,
|
||||
* the search result and the map pin from drifting into three different reds.
|
||||
*/
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
fuel: '#E8590C',
|
||||
charging: '#CA8A04',
|
||||
rest_area: '#3B82F6',
|
||||
campsite: '#16A34A',
|
||||
restaurant: '#EF4444',
|
||||
sights: '#EC4899',
|
||||
}
|
||||
|
||||
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
|
||||
export function serviceColor(stopType: string | null | undefined): string {
|
||||
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
|
||||
}
|
||||
|
||||
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
|
||||
export function isServiceStopType(stopType: string | null | undefined): boolean {
|
||||
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
|
||||
}
|
||||
|
||||
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
|
||||
export function formatDurationShort(seconds: number): string {
|
||||
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||||
const h = Math.floor(safe / 3600)
|
||||
const m = Math.round((safe % 3600) / 60)
|
||||
// 59.6 min must not print as "60 min"; carry it.
|
||||
if (m === 60) return `${h + 1} h`
|
||||
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
|
||||
export function parseClock(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 23 || min > 59) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
|
||||
export function formatClock(minutes: number): string {
|
||||
const day = 24 * 60
|
||||
const wrapped = ((minutes % day) + day) % day
|
||||
const h = Math.floor(wrapped / 60)
|
||||
const m = Math.round(wrapped % 60)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface ScheduleStop {
|
||||
/** Wall-clock time the user pinned on this stop, if any. */
|
||||
anchor: string | null
|
||||
/** Planned time spent here, in minutes. */
|
||||
dwellMinutes: number | null
|
||||
}
|
||||
|
||||
export interface ScheduleWarning {
|
||||
/**
|
||||
* Index of the stop the finding belongs to.
|
||||
*
|
||||
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
|
||||
* Both findings are a running total that only becomes true on arrival: "125 km since
|
||||
* the last fill-up" is what the tank reads when you get there, and the figure has to
|
||||
* agree with the one anybody adds up off the drive bands above it. Anchored to the
|
||||
* departure it read as the distance of the leg leaving that stop, which is a different
|
||||
* number entirely and the one that made the badge look wrong.
|
||||
*/
|
||||
index: number
|
||||
code: 'late' | 'overnight' | 'leg' | 'range'
|
||||
/** Minutes the computed arrival misses the anchor by (code 'late'). */
|
||||
minutes?: number
|
||||
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
|
||||
overMinutes?: number
|
||||
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
|
||||
sinceKm?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The two kinds of stop that put fuel or charge back.
|
||||
*
|
||||
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
|
||||
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
|
||||
* the service list here would quietly reset the range budget at every rest area and hide
|
||||
* the one finding this feature exists to produce.
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/** Whether stopping here starts the range budget over. */
|
||||
export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers a traveller can set. null means no limit, which is an explicit off
|
||||
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
|
||||
*/
|
||||
export interface DriveLimits {
|
||||
/** Longest single drive between two stops, in minutes. */
|
||||
legMinutes: number | null
|
||||
/** Longest total driving in one day, in minutes. */
|
||||
dayMinutes: number | null
|
||||
/** How far one tank or charge goes, in kilometres. */
|
||||
rangeKm: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A finding about the day as a whole.
|
||||
*
|
||||
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
|
||||
* read by every consumer that filters findings by stop, and one of them would eventually
|
||||
* draw the day's finding on the first stop.
|
||||
*/
|
||||
export interface DayWarning {
|
||||
code: 'dayDriving'
|
||||
/** Driving time of the day in minutes, the same number the badge beside it shows. */
|
||||
minutes: number
|
||||
limitMinutes: number
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
/** Computed arrival, or the anchor where the user pinned one. */
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** True when the time was pinned by the user rather than derived. */
|
||||
anchored: boolean
|
||||
/** Days past the first stop, so a chain running past midnight stays readable. */
|
||||
dayOffset: number
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
entries: ScheduleEntry[]
|
||||
warnings: ScheduleWarning[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the chain forward: arrival = previous departure + driving time, departure =
|
||||
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
|
||||
* it restarts the cascade from its own time, which is what makes a ferry, a museum
|
||||
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
|
||||
*
|
||||
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
|
||||
* `undefined` and breaks the chain rather than inventing a duration.
|
||||
*/
|
||||
/**
|
||||
* Where a stop's clock actually starts.
|
||||
*
|
||||
* A pinned time wins over whatever the drive before it worked out and restarts the
|
||||
* cascade from there. When it sits before the arrival the drive implies, the plan does
|
||||
* not fit: the caller is told by how much rather than the pinned time being moved.
|
||||
*/
|
||||
const DAY_MINUTES = 24 * 60
|
||||
|
||||
function resolveArrival(
|
||||
anchor: number | null,
|
||||
cursor: number | null,
|
||||
dayOffset: number,
|
||||
): { arrival: number | null; lateBy: number | null } {
|
||||
if (anchor === null) return { arrival: cursor, lateBy: null }
|
||||
// No drive has been timed yet, so there is nothing to be late against — but the
|
||||
// day carried so far still applies, or a pinned stop after an untimed leg
|
||||
// prints under a day-1 stop as though it happened first.
|
||||
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
|
||||
// The occurrence of that wall-clock time nearest the cursor, not the one on the
|
||||
// day the cascade has reached so far. `dayOffset` only advances once a computed
|
||||
// arrival crosses midnight, and it is read before this stop's arrival is known —
|
||||
// so when the stop that crosses midnight is the pinned one, its anchor was
|
||||
// placed a whole day early and the plan was reported as 24 hours late while
|
||||
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
|
||||
// hour leg, a 09:00 pin still picks the same day and is two hours late.
|
||||
const k = Math.round((cursor - anchor) / DAY_MINUTES)
|
||||
const anchorAt = anchor + k * DAY_MINUTES
|
||||
return {
|
||||
arrival: anchorAt,
|
||||
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
const entries: ScheduleEntry[] = []
|
||||
const warnings: ScheduleWarning[] = []
|
||||
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
|
||||
let cursor: number | null = null
|
||||
let dayOffset = 0
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]
|
||||
const anchor = parseClock(stop.anchor)
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
|
||||
|
||||
if (arrival === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = Math.floor(arrival / (24 * 60))
|
||||
if (offset > dayOffset) {
|
||||
dayOffset = offset
|
||||
warnings.push({ index: i, code: 'overnight' })
|
||||
}
|
||||
|
||||
const dwell = stop.dwellMinutes ?? 0
|
||||
const departure = arrival + dwell
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(departure),
|
||||
anchored: anchor !== null,
|
||||
dayOffset: offset,
|
||||
})
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
return { entries, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a day's stops into the stretches that can be asked for in one routing request:
|
||||
* consecutive stops whose leg shares a travel mode.
|
||||
*
|
||||
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
|
||||
* a day travelled one way is a single request rather than one per leg — the difference
|
||||
* between a road trip appearing at once and crawling in over ten seconds. A day that
|
||||
* mixes walking and driving splits at the change, because one request carries one mode.
|
||||
*/
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
const runs: { stops: T[]; mode: string }[] = []
|
||||
let current: T[] = []
|
||||
let currentMode: string | null = null
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const mode = modeOfLeg(stops[i], stops[i + 1])
|
||||
if (currentMode === null) {
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
} else if (mode === currentMode) {
|
||||
current.push(stops[i + 1])
|
||||
} else {
|
||||
runs.push({ stops: current, mode: currentMode })
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
}
|
||||
}
|
||||
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Total driving seconds of the legs that actually routed. */
|
||||
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a place found along the drive belongs in the day's chain.
|
||||
*
|
||||
* Both numbers are distances travelled along the same routed line, so the comparison is
|
||||
* "which stops has the car already passed when it reaches this one". A petrol station
|
||||
* 120 km into the day goes after every stop nearer than that and before the rest,
|
||||
* which is what makes adding one a single click rather than a drag afterwards.
|
||||
*
|
||||
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
|
||||
* slightly before the first stop or past the last, and neither "before where you set off"
|
||||
* nor "after where you arrive" is a place a fuel stop can go.
|
||||
*/
|
||||
/**
|
||||
* Which leg a point on the drive belongs to, given where each leg ends.
|
||||
*
|
||||
* Measured against the legs' own lengths rather than by projecting the stops as well: the
|
||||
* router's leg distances are exact by construction, while two stops close together
|
||||
* project onto each other's stretch and would put a charging halt on the wrong side of a
|
||||
* town. A point past the end lands in the last leg rather than nowhere, because that is
|
||||
* a rounding edge and not a missing leg.
|
||||
*/
|
||||
/**
|
||||
* The findings about the driving itself: too long at the wheel, too long in one day, and
|
||||
* the tank running out before anywhere to fill it.
|
||||
*
|
||||
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
|
||||
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
|
||||
* an hour of the day would simply never fire. All three limits are therefore durations and
|
||||
* kilometres, which exist as soon as a leg has routed.
|
||||
*
|
||||
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
|
||||
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
|
||||
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
|
||||
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
|
||||
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
|
||||
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
|
||||
* charging stop is put in.
|
||||
*
|
||||
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
|
||||
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
|
||||
*/
|
||||
export function deriveDriveWarnings(
|
||||
/**
|
||||
* `mode` decides whether a leg counts at all. A day may legitimately mix
|
||||
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
|
||||
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
|
||||
* Counting them reported a hike as "over the longest drive allowed" and let a
|
||||
* ferry crossing trip a range warning. Anything that is not a driving profile
|
||||
* is skipped; an absent mode counts, because every leg was a drive before the
|
||||
* field existed.
|
||||
*/
|
||||
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
|
||||
/** Whether the stop at each index refuels. One entry longer than `legs`. */
|
||||
refuelsAt: boolean[],
|
||||
limits: DriveLimits,
|
||||
/** Kilometres already on the tank when the day starts; null when that is unknown. */
|
||||
carryKm: number | null,
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
let budget = carryKm
|
||||
let totalSeconds = 0
|
||||
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
if (refuelsAt[i]) budget = 0
|
||||
const leg = legs[i]
|
||||
// The stop this leg arrives at. Both findings are about what is true on arrival.
|
||||
const at = i + 1
|
||||
// Not a drive: neither its minutes nor its kilometres belong in findings
|
||||
// about the driving. The budget is left exactly as it was — walking to a
|
||||
// viewpoint and back does not use fuel, and it does not fill the tank
|
||||
// either.
|
||||
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
|
||||
const seconds = leg?.duration
|
||||
if (typeof seconds === 'number') {
|
||||
totalSeconds += seconds
|
||||
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
|
||||
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
|
||||
}
|
||||
}
|
||||
const metres = leg?.distance
|
||||
if (typeof metres !== 'number') {
|
||||
budget = null
|
||||
} else if (budget !== null) {
|
||||
budget += metres / 1000
|
||||
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
|
||||
// the plan working, not a problem, and a warning there would sit on the one stop
|
||||
// that answers it. No reset either — a warning is not a fill-up, and the figure has
|
||||
// to keep counting until a fuel or charging stop actually puts one in.
|
||||
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
|
||||
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
|
||||
}
|
||||
}
|
||||
}
|
||||
// The last stop of the day counts too: filling up on arrival is what makes the next
|
||||
// morning start with a full tank.
|
||||
if (refuelsAt[legs.length]) budget = 0
|
||||
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
const day = limits.dayMinutes && minutes > limits.dayMinutes
|
||||
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
|
||||
: null
|
||||
|
||||
return { warnings, day, carryKm: budget }
|
||||
}
|
||||
|
||||
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
|
||||
if (!legEndMeters.length) return -1
|
||||
for (let i = 0; i < legEndMeters.length; i++) {
|
||||
if (alongMeters < legEndMeters[i]) return i
|
||||
}
|
||||
return legEndMeters.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The places on a day somebody would plan a break around.
|
||||
*
|
||||
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
|
||||
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
|
||||
* drive, not at either end of it, and offering only the stops would mean asking "what is
|
||||
* near Berlin" when the question is "what is near the halfway mark".
|
||||
*/
|
||||
export interface SectionAnchor {
|
||||
kind: 'stop' | 'leg'
|
||||
/** Index of the stop, or of the stop the leg leaves. */
|
||||
index: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
|
||||
const out: SectionAnchor[] = []
|
||||
for (let i = 0; i < stopsAlongKm.length; i++) {
|
||||
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
|
||||
if (i + 1 < stopsAlongKm.length) {
|
||||
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
|
||||
if (stopsAlongKm.length < 2) return stopsAlongKm.length
|
||||
let i = 0
|
||||
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
|
||||
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
|
||||
}
|
||||
|
||||
/* ── Via re-anchoring ────────────────────────────────────────────────────────
|
||||
*
|
||||
* A via records which stop it follows as a POSITION in the day's stop list, and that
|
||||
* position is resolved fresh every time the day is routed. So the moment the list
|
||||
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
|
||||
* anchor at or past the change means a different leg than it did when it was written.
|
||||
* The visible symptom is the route quietly snapping back to the road the user had
|
||||
* steered it away from, with the via handle still sitting on the map where they left it.
|
||||
*
|
||||
* Nothing about that is repairable after the fact: once the stops have moved there is no
|
||||
* record of which leg a via was drawn for. So the correction has to be computed against
|
||||
* the list as it stands and applied with the same change.
|
||||
*
|
||||
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
|
||||
* which half of a split leg a via belongs to — is asked of the caller, because only the
|
||||
* caller has the routed line to measure against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A via, reduced to what re-anchoring needs to know about it.
|
||||
*
|
||||
* The coordinates are part of it because the one decision that is not pure arithmetic —
|
||||
* which half of a split leg a via belongs to — is answered by measuring where it sits on
|
||||
* the road, and the caller is handed the whole via to measure.
|
||||
*/
|
||||
export interface AnchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Where one via ends up. */
|
||||
export interface ReanchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The corrected anchoring, and the vias that no longer have a leg at all.
|
||||
*
|
||||
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
|
||||
* write for no reason, and it keeps the diff readable when something goes wrong.
|
||||
*/
|
||||
export interface Reanchoring {
|
||||
vias: ReanchoredVia[]
|
||||
remove: number[]
|
||||
}
|
||||
|
||||
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
|
||||
|
||||
/** Only what moved, so an operation that changes nothing costs no request. */
|
||||
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const next = at(via.after_order_index)
|
||||
if (next === null) remove.push(via.id)
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was inserted at `position`, so everything from there on sits one place later.
|
||||
*
|
||||
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
|
||||
* before the new stop stays on the first half, one after it moves to the second. That is
|
||||
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
|
||||
* the reshaping — the via went to the first half by accident of arithmetic, the second
|
||||
* half got none, and the router took its own road again for the rest of the leg.
|
||||
*
|
||||
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
|
||||
* one: inserting at the very front or the very end splits nothing.
|
||||
*/
|
||||
export function reanchorAfterInsert(
|
||||
vias: AnchoredVia[],
|
||||
position: number,
|
||||
liesBeforeInsert: (via: AnchoredVia) => boolean,
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const split = position - 1
|
||||
const moved: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
const i = via.after_order_index
|
||||
let next = i
|
||||
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
|
||||
else if (i >= position) next = i + 1
|
||||
if (next !== i) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was taken out at `position`, so the legs either side of it become one.
|
||||
*
|
||||
* A via on either half keeps bending the same drive, so both end up on the merged leg —
|
||||
* dropping them would throw away a decision the user made about a road that still exists.
|
||||
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
|
||||
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
|
||||
* to sit. Those are reported for deletion rather than left pointing past the day.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the removal.
|
||||
*/
|
||||
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
// Two stops become one: the day has no drive left, so no via has a leg.
|
||||
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
|
||||
return collect(vias, i => {
|
||||
if (position === 0) return i === 0 ? null : i - 1
|
||||
if (position === stopCount - 1) return i === position - 1 ? null : i
|
||||
if (i === position) return position - 1
|
||||
return i > position ? i - 1 : i
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop moved from `from` to `to` inside its own day.
|
||||
*
|
||||
* Composed from the two operations it actually is — taken out, put back — because that
|
||||
* is the only reading under which the answer stays consistent with the other two. No
|
||||
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
|
||||
* themselves are about to be different and there is nothing meaningful to measure a via
|
||||
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
|
||||
* them where they were relative to the stop they were drawn after.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
|
||||
* it looks AFTER the stop has been taken out.
|
||||
*/
|
||||
/**
|
||||
* Re-pin a day's vias after its stops were reordered wholesale.
|
||||
*
|
||||
* The single-move helpers above take a from/to pair, which is what the rail
|
||||
* hands them. The plan-mode drag hands a whole new ordering instead, and any
|
||||
* permutation is possible — so this maps by the stop each anchor refers to
|
||||
* rather than by arithmetic on the index. A via anchored after a stop stays
|
||||
* anchored after that same stop, wherever it ended up.
|
||||
*
|
||||
* Both lists are in the rail's index space, i.e. only stops that can be routed.
|
||||
* A via whose stop is gone, or which lands on the last stop and so has no leg
|
||||
* left to bend, is reported for removal.
|
||||
*/
|
||||
export function reanchorByStopOrder(
|
||||
vias: AnchoredVia[],
|
||||
previousIds: number[],
|
||||
nextIds: number[],
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const stopId = previousIds[via.after_order_index]
|
||||
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
|
||||
// Gone, or now the last stop of the day: either way there is no leg for it.
|
||||
if (next === -1 || next >= nextIds.length - 1) {
|
||||
remove.push(via.id)
|
||||
continue
|
||||
}
|
||||
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
export function reanchorAfterReorder(
|
||||
vias: AnchoredVia[],
|
||||
from: number,
|
||||
to: number,
|
||||
stopCount: number,
|
||||
): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING
|
||||
// A reorder is modelled as remove-then-insert, and the removal half refuses to
|
||||
// treat a two-stop day as still having a drive: below three stops it reports
|
||||
// every via for deletion, which is right when a stop really goes away and
|
||||
// catastrophic here. Nothing is removed by a reorder — swapping the only two
|
||||
// stops leaves leg 0 exactly where it was, merely driven the other way round —
|
||||
// so the anchors are already correct and there is nothing to re-pin.
|
||||
//
|
||||
// Two stops is the shape "follow this track" produces: it lays up to nine vias
|
||||
// on the single leg between one pair, and one drag in the rail deleted all of
|
||||
// them, with no prompt and no undo.
|
||||
if (stopCount <= 2) return EMPTY_REANCHORING
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
|
||||
const dropped = new Set(afterRemove.remove)
|
||||
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
// The list the insert acts on is the one the removal left behind, so each via is
|
||||
// carried through at whatever index it holds by then.
|
||||
const shifted: AnchoredVia[] = vias
|
||||
.filter(v => !dropped.has(v.id))
|
||||
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
|
||||
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
|
||||
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
|
||||
const result: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
if (dropped.has(via.id)) continue
|
||||
const mid = movedTo.get(via.id) ?? via.after_order_index
|
||||
const end = finalIndex.get(via.id) ?? mid
|
||||
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
|
||||
}
|
||||
return { vias: result, remove: afterRemove.remove }
|
||||
}
|
||||
|
||||
/** Whether a re-anchoring has anything to write at all. */
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter, SERVICE_MARKER_SIZE, SERVICE_MARKER_SIZE_SELECTED } from './serviceMarker'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* FE-SVCMARK-001..007 — the disc a service stop gets on the map.
|
||||
*
|
||||
* Both renderers need the same string: one hands it to a Leaflet divIcon, the
|
||||
* other to an element it attaches itself. What is worth pinning is that an
|
||||
* ordinary place gets nothing at all — a petrol station is something the drive
|
||||
* passes through, and giving it a place-sized marker made a day look like it had
|
||||
* twice the stops it has — and that the anchor arithmetic includes the border,
|
||||
* or the pin sits a couple of pixels off the coordinate it marks.
|
||||
*/
|
||||
|
||||
describe('serviceMarkerHtml', () => {
|
||||
it('FE-SVCMARK-001: an ordinary place gets no service marker', () => {
|
||||
// Null is the signal to fall back to the place marker, so this is what keeps
|
||||
// a destination from being drawn as a fuel stop.
|
||||
expect(serviceMarkerHtml(null, false)).toBeNull()
|
||||
expect(serviceMarkerHtml(undefined, false)).toBeNull()
|
||||
expect(serviceMarkerHtml('', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-002: each kind of stop gets a disc in its own colour', () => {
|
||||
// All six, not only the four the corridor picker offers: a meal and a
|
||||
// viewpoint are pauses on the way too, and the map draws them the same way.
|
||||
for (const kind of ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']) {
|
||||
const html = serviceMarkerHtml(kind, false)
|
||||
expect(html, kind).toBeTruthy()
|
||||
expect(html, kind).toContain(serviceColor(kind))
|
||||
expect(html, kind).toContain('border-radius:50%')
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-003: an unknown service kind is still not drawn as one', () => {
|
||||
// isServiceStopType is the gate; a value the picker never offers must not
|
||||
// reach the renderer and produce a disc in an undefined colour.
|
||||
expect(serviceMarkerHtml('banana', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-004: selecting one grows it, the way a place marker confirms a click', () => {
|
||||
const plain = serviceMarkerHtml('fuel', false)!
|
||||
const selected = serviceMarkerHtml('fuel', true)!
|
||||
|
||||
expect(plain).toContain(`width:${SERVICE_MARKER_SIZE}px`)
|
||||
expect(selected).toContain(`width:${SERVICE_MARKER_SIZE_SELECTED}px`)
|
||||
expect(SERVICE_MARKER_SIZE_SELECTED).toBeGreaterThan(SERVICE_MARKER_SIZE)
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-005: selected carries the heavier ring, unselected the plain shadow', () => {
|
||||
expect(serviceMarkerHtml('fuel', true)!).toContain('0 0 0 3px')
|
||||
expect(serviceMarkerHtml('fuel', false)!).not.toContain('0 0 0 3px')
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-006: the icon is drawn white and inline, never a logo or a photo', () => {
|
||||
// A fuel stop used to come back from the place search carrying the
|
||||
// operator's logo, so the map showed a brand roundel where every other
|
||||
// road-trip surface showed a pump.
|
||||
const html = serviceMarkerHtml('fuel', false)!
|
||||
expect(html).toContain('<svg')
|
||||
expect(html).toContain('#fff')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('background-image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serviceMarkerOuter', () => {
|
||||
it('FE-SVCMARK-007: the box an anchor centres on includes the border', () => {
|
||||
// content-box plus a 2px border on each side. Measuring the inner size would
|
||||
// leave every service pin two pixels off the coordinate it marks.
|
||||
expect(serviceMarkerOuter(false)).toBe(SERVICE_MARKER_SIZE + 4)
|
||||
expect(serviceMarkerOuter(true)).toBe(SERVICE_MARKER_SIZE_SELECTED + 4)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { ParkingSquare } from 'lucide-react'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { isServiceStopType, serviceColor } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
|
||||
/**
|
||||
* Smaller than a place marker on purpose.
|
||||
*
|
||||
* A petrol station is not a destination — it is something the drive passes through — and
|
||||
* a marker the same size as the places the trip is for made a day look like it had twice
|
||||
* as many stops as it has. Selected grows a little, the same way a place marker does, so
|
||||
* clicking one still confirms itself.
|
||||
*/
|
||||
export const SERVICE_MARKER_SIZE = 24
|
||||
export const SERVICE_MARKER_SIZE_SELECTED = 30
|
||||
const BORDER = 2
|
||||
|
||||
/** The full box a service marker occupies, border included — what an anchor centres on. */
|
||||
export function serviceMarkerOuter(selected: boolean): number {
|
||||
return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker for a stop that interrupts the drive, or null for an ordinary place.
|
||||
*
|
||||
* One flat disc in the kind's own colour with a white icon — never the place's photo or
|
||||
* its brand logo. A fuel stop used to come back from the place search carrying the
|
||||
* operator's logo, so the map showed an Esso roundel where every other road-trip surface
|
||||
* showed a pump: unreadable at marker size, different for every brand, and nothing at all
|
||||
* for the ones with no logo on file.
|
||||
*
|
||||
* Built as markup rather than as a component because both renderers need a string: one
|
||||
* feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
|
||||
*/
|
||||
export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
|
||||
if (!isServiceStopType(stopType)) return null
|
||||
const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
|
||||
const Icon = STOP_KIND_BY_KEY[stopType ?? '']?.Icon ?? ParkingSquare
|
||||
const svg = renderIconMarkup(createElement(Icon, {
|
||||
size: Math.round(size * 0.56),
|
||||
color: '#fff',
|
||||
strokeWidth: 2.4,
|
||||
}))
|
||||
const ring = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 2px 6px rgba(0,0,0,0.25)'
|
||||
return `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${serviceColor(stopType)};
|
||||
border:${BORDER}px solid #fff;box-shadow:${ring};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;cursor:pointer;
|
||||
">${svg}</div>`
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { roadtripStopTypeSchema } from '@trek/shared'
|
||||
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
|
||||
import {
|
||||
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
|
||||
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
|
||||
} from './stopKinds'
|
||||
|
||||
/**
|
||||
* The table exists to stop nine lists from drifting apart, and these are the assertions
|
||||
* that make that true rather than intended. Each one pins the table against a list that
|
||||
* cannot import it: the Zod enum lives in `@trek/shared` and must not depend on the
|
||||
* client, and `SERVICE_STOP_TYPES` lives in the React-free model.
|
||||
*/
|
||||
describe('stop kinds', () => {
|
||||
it('FE-STOPKIND-001: covers exactly the kinds the shared enum allows', () => {
|
||||
const fromSchema = [...roadtripStopTypeSchema.options].sort()
|
||||
const fromTable = STOP_KINDS.map(k => k.key).sort()
|
||||
expect(fromTable).toEqual(fromSchema)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-002: agrees with the model about which kinds interrupt a drive', () => {
|
||||
expect([...SERVICE_KIND_KEYS].sort()).toEqual([...SERVICE_STOP_TYPES].sort())
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-003: every kind carries the colour the model gives it', () => {
|
||||
for (const kind of STOP_KINDS) expect(kind.color).toBe(SERVICE_COLORS[kind.key])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-004: a refuelling kind is a kind, so a range budget cannot reset on a typo', () => {
|
||||
for (const key of REFUELLING_STOP_TYPES) expect(STOP_KIND_BY_KEY[key]).toBeDefined()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-005: the corridor offers every category in a stable order', () => {
|
||||
// Sleeping sits next to sleeping: hotel follows campsite rather than landing wherever
|
||||
// the stop-kind table happens to end.
|
||||
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
|
||||
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
|
||||
// than interrupting the drive, so a hotel keeps its number in the chain and never
|
||||
// becomes a stop_type.
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
|
||||
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
|
||||
expect(isOvernightCategory('hotel')).toBe(true)
|
||||
expect(isOvernightCategory('campsite')).toBe(true)
|
||||
expect(isOvernightCategory('fuel')).toBe(false)
|
||||
expect(isOvernightCategory(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-010: every category that is a stop kind names the kind it creates', () => {
|
||||
for (const category of CORRIDOR_CATEGORIES) {
|
||||
if (category.stopKind === null) continue
|
||||
expect(category.stopKind).toBe(category.key)
|
||||
expect(STOP_KIND_BY_KEY[category.key]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => {
|
||||
// Spelled out rather than derived: the corridor calls a rest area rest_area and names
|
||||
// it under rest, a restaurant restaurant and names it under food. A derived label key
|
||||
// would silently ask for two translations that do not exist.
|
||||
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
|
||||
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
|
||||
for (const kind of STOP_KINDS) {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
|
||||
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-007: every kind offers a dwell the popup can pre-fill', () => {
|
||||
for (const kind of STOP_KINDS) {
|
||||
expect(kind.defaultMinutes).toBeGreaterThan(0)
|
||||
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, BedDouble, type LucideIcon } from 'lucide-react'
|
||||
import { SERVICE_COLORS, SERVICE_STOP_TYPES } from './roadtripModel'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* One row per kind of stop a drive can have, and the only place a new one is added.
|
||||
*
|
||||
* The six kinds used to be written out in nine separate lists: the enum in `@trek/shared`,
|
||||
* `SERVICE_STOP_TYPES` and `SERVICE_COLORS` here in the model, the corridor's category
|
||||
* keys, the map's `ROADTRIP_POI_CATEGORIES`, the popup's own `STOP_KINDS`, two lookups in
|
||||
* the rail, one in the map markers and one in the corridor panel. Every one of those
|
||||
* carried a comment telling the next person to keep the others in step, and it still came
|
||||
* apart: a rest area was a parking sign in the popup, on the map pill and in the panel,
|
||||
* and a coffee cup on the rail disc and the map marker, for the same stop. That is fixed
|
||||
* here in favour of the parking sign, because the colour is already documented as "the
|
||||
* blue every parking sign in Europe is printed in".
|
||||
*
|
||||
* The table lives beside the model rather than in it because an icon is a React component
|
||||
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
|
||||
* own. The colours and the service list stay there and are read from here.
|
||||
*
|
||||
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
|
||||
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
|
||||
* ends the day rather than interrupting the drive, so it would be a category without
|
||||
* being a service stop.
|
||||
*/
|
||||
export interface StopKind {
|
||||
key: RoadtripStopType
|
||||
/** Two of the six do not match their key: `rest_area` is named under `rest`, `restaurant` under `food`. */
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
/** What the popup offers before anyone changes it — how long this kind usually takes. */
|
||||
defaultMinutes: number
|
||||
/** Interrupts the drive: drawn on the dashed line, no number, left out of every stop count. */
|
||||
isService: boolean
|
||||
/** Offered as a category in the corridor search. */
|
||||
isCorridorCategory: boolean
|
||||
}
|
||||
|
||||
export const STOP_KINDS: StopKind[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, defaultMinutes: 60, isService: true, isCorridorCategory: true },
|
||||
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, defaultMinutes: 45, isService: true, isCorridorCategory: true },
|
||||
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
]
|
||||
|
||||
/** By key, for the lookups the rail, the markers and the panel used to keep themselves. */
|
||||
export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
|
||||
STOP_KINDS.map(k => [k.key, k]),
|
||||
)
|
||||
|
||||
/**
|
||||
* A category the corridor search offers, which is not the same question as a stop kind.
|
||||
*
|
||||
* Every stop kind is a category, but not every category is a stop kind: somewhere to
|
||||
* sleep is something the search can find, and staying there ends the day rather than
|
||||
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
|
||||
* in `day_accommodations`; it never becomes a `stop_type`.
|
||||
*/
|
||||
export interface CorridorCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
/** The kind a hit of this category becomes, or null when it becomes something else. */
|
||||
stopKind: RoadtripStopType | null
|
||||
}
|
||||
|
||||
/** Somewhere to sleep. Blue rather than the campsite's green: both are a night, but one
|
||||
* of them has a roof, and telling the two apart on a map matters more than grouping them.
|
||||
* theme-lint-disable — the road-signage palette, shared with the general place search so
|
||||
* a hotel found along a route looks like the hotels found anywhere else. */
|
||||
export const HOTEL_COLOR = '#2563EB'
|
||||
|
||||
/**
|
||||
* The categories, in the order the panel shows them.
|
||||
*
|
||||
* Written out rather than derived, because the order is a judgement: sleeping sits next
|
||||
* to sleeping, so `hotel` follows `campsite` instead of landing wherever the stop-kind
|
||||
* table happens to end.
|
||||
*/
|
||||
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
|
||||
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
]
|
||||
|
||||
export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object.fromEntries(
|
||||
CORRIDOR_CATEGORIES.map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
/** The keys the corridor search offers, in the order the panel shows them. */
|
||||
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
|
||||
|
||||
/** Categories whose hit becomes a night rather than a pause. */
|
||||
export function isOvernightCategory(category: string | null | undefined): boolean {
|
||||
// Campsite is both: it is a service stop today and stays one, but a night can be
|
||||
// booked there just as well, so the popup offers the choice rather than deciding.
|
||||
return category === 'hotel' || category === 'campsite'
|
||||
}
|
||||
|
||||
/**
|
||||
* A guard against the drift this file exists to end.
|
||||
*
|
||||
* `SERVICE_STOP_TYPES` is the model's own list and stays there, because the arithmetic
|
||||
* must not depend on anything that imports React. The two are checked against each other
|
||||
* in `stopKinds.test.ts` rather than derived, so a kind added to one and forgotten in the
|
||||
* other fails a test instead of quietly counting as a destination.
|
||||
*/
|
||||
export const SERVICE_KIND_KEYS = STOP_KINDS.filter(k => k.isService).map(k => k.key)
|
||||
|
||||
/** The kinds where a tank or a battery is filled, so a range budget starts over. */
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
export { SERVICE_STOP_TYPES }
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* The road trip's type scale, pinned to the day plan's.
|
||||
*
|
||||
* Both road trip panels sit beside the day plan and the tab switcher flips between
|
||||
* them, so a road trip whose names ran a size larger than the day plan's read as a
|
||||
* different app.
|
||||
* The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
|
||||
* (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
|
||||
* drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
|
||||
*
|
||||
* Written as `calc(px * var(--fs-scale-<tier>))` rather than as a tier utility because
|
||||
* the tiers land on 24/18/14/12px and this rail needs the steps between them; the
|
||||
* multiplier is what keeps the user's per-tier text-size setting reaching them. A value
|
||||
* and its unit always share one tier, so scaling one tier cannot break a pair apart.
|
||||
*/
|
||||
export const FS = {
|
||||
/** A total in the head. */
|
||||
total: 'calc(20px * var(--fs-scale-title, 1))',
|
||||
/** The unit hanging off one. */
|
||||
totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
|
||||
/** A day's name — the day plan's own size. */
|
||||
dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
/** A stop's name — the day plan's own size. */
|
||||
name: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
/** A clock reading, and a service stop's dwell. */
|
||||
time: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
/** A drive band, a date, a line of explanation. */
|
||||
meta: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
/** The number in a marker. */
|
||||
marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
|
||||
/** A badge, a caption over a total. */
|
||||
label: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
/** The word "Stay" — the smallest thing in the rail. */
|
||||
micro: 'calc(8px * var(--fs-scale-caption, 1))',
|
||||
/** The name of a panel — "Along the route". The largest text either panel carries. */
|
||||
panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
|
||||
/** A pill, a segment, a line of progress: anything you press but do not read as prose. */
|
||||
control: 'calc(10.5px * var(--fs-scale-body, 1))',
|
||||
} as const
|
||||
@@ -1,187 +0,0 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { LatLng } from './corridor'
|
||||
|
||||
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
|
||||
import { useCorridorPois } from './useCorridorPois'
|
||||
|
||||
/** A short drive west out of Hamburg — enough route to tile, few enough boxes to be quick. */
|
||||
const LINE: LatLng[] = [
|
||||
{ lat: 53.55, lng: 9.99 },
|
||||
{ lat: 53.5, lng: 9.8 },
|
||||
{ lat: 53.45, lng: 9.6 },
|
||||
]
|
||||
|
||||
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
|
||||
osm_id: id,
|
||||
name: id,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
|
||||
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
|
||||
|
||||
beforeEach(() => {
|
||||
pois.mockReset()
|
||||
pois.mockResolvedValue(answer())
|
||||
})
|
||||
|
||||
describe('useCorridorPois', () => {
|
||||
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(pois).toHaveBeenCalled()
|
||||
// The whole point of the batching: the kinds ride along in one query per box.
|
||||
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
|
||||
expect(result.current.progress.total).toBe(pois.mock.calls.length)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-002: keeps what is near the route and drops what is not', async () => {
|
||||
// One beside the line, one far north of it.
|
||||
pois.mockResolvedValue(answer(hit('near', 53.5, 9.81), hit('far', 54.6, 9.8)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 5))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.map(r => r.osm_id)).toEqual(['near'])
|
||||
expect(result.current.results[0].offRouteKm).toBeLessThan(5)
|
||||
expect(result.current.results[0].alongKm).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-003: orders hits by how far along the drive they are', async () => {
|
||||
pois.mockResolvedValue(answer(
|
||||
hit('late', 53.45, 9.61),
|
||||
hit('early', 53.55, 9.98),
|
||||
hit('middle', 53.5, 9.8),
|
||||
))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 8))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.map(r => r.osm_id)).toEqual(['early', 'middle', 'late'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-004: the same place found in two overlapping boxes is listed once', async () => {
|
||||
pois.mockResolvedValue(answer(hit('shared', 53.5, 9.81)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.filter(r => r.osm_id === 'shared')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-005: a box that times out is asked again before it counts as unsearched', async () => {
|
||||
let first = true
|
||||
pois.mockImplementation(async () => {
|
||||
if (first) { first = false; throw new Error('502') }
|
||||
return answer(hit('found', 53.5, 9.81))
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.failedAreas).toBe(0)
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(result.current.results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-006: a box that fails twice is reported rather than silently skipped', async () => {
|
||||
pois.mockRejectedValue(new Error('502'))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.failedAreas).toBeGreaterThan(0)
|
||||
// Nothing answered at all, so this is an outage and not a partial result.
|
||||
expect(result.current.error).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-007: a drive longer than the request budget says the tail went unsearched', async () => {
|
||||
const long: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 53.5 - i * 0.25, lng: 9.9 + i * 0.35 }))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(long, ['fuel'], 5))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.capped).toBe(true)
|
||||
expect(result.current.progress.total).toBeLessThanOrEqual(16)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-008: changing what is wanted drops the answers to the old question', async () => {
|
||||
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ categories }: { categories: string[] }) => useCorridorPois(LINE, categories, 10),
|
||||
{ initialProps: { categories: ['fuel'] } },
|
||||
)
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
|
||||
|
||||
rerender({ categories: ['campsite'] })
|
||||
await waitFor(() => expect(result.current.results).toEqual([]))
|
||||
expect(result.current.progress).toEqual({ done: 0, total: 0 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-009: a route too short to search asks for nothing', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois([LINE[0]], ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await act(async () => {})
|
||||
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-010: with nothing selected there is nothing to look for', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, [], 10))
|
||||
act(() => { result.current.search() })
|
||||
await act(async () => {})
|
||||
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-011: clearing stops the run and empties the list', async () => {
|
||||
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.failedAreas).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-012: leaving the view aborts the boxes still in flight', async () => {
|
||||
let seen: AbortSignal | undefined
|
||||
pois.mockImplementation((_c: string, _b: unknown, _l: string, signal: AbortSignal) => {
|
||||
seen = signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const { result, unmount } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(seen).toBeDefined())
|
||||
|
||||
unmount()
|
||||
expect(seen!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
/** A POI that is actually on the way, with the two numbers that make it one. */
|
||||
export interface CorridorPoi extends Poi {
|
||||
offRouteKm: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export interface CorridorSearch {
|
||||
results: CorridorPoi[]
|
||||
/** Areas answered so far, out of how many the route was cut into. */
|
||||
progress: { done: number; total: number }
|
||||
loading: boolean
|
||||
/** True when the route was longer than the request budget and the tail was left out. */
|
||||
capped: boolean
|
||||
/** Boxes the place search could not answer — a partial result is still shown. */
|
||||
failedAreas: number
|
||||
/**
|
||||
* Boxes where the server hit its own per-request ceiling and dropped the rest.
|
||||
* Distinct from `capped`, which is about stretches never asked for at all: here the
|
||||
* stretch was searched and the answer came back short. Without saying so, filtering
|
||||
* the results cannot tell "nothing of that name on the way" from "cut off before it".
|
||||
*/
|
||||
truncatedAreas: number
|
||||
error: boolean
|
||||
/** The thinned line every `alongKm` above is measured along. */
|
||||
spine: LatLng[]
|
||||
search: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
|
||||
* would otherwise be dozens of requests against a shared public mirror; past this the
|
||||
* search stops and says so rather than quietly covering half the route.
|
||||
*/
|
||||
const MAX_TILES = 16
|
||||
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
|
||||
const REQUEST_SPACING_MS = 250
|
||||
/** How many boxes are in flight at once. */
|
||||
const CONCURRENT_REQUESTS = 5
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal.aborted) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* "What is on the way" for a drawn route.
|
||||
*
|
||||
* Deliberately manual: the search runs when the user asks for it, never on pan or on
|
||||
* every route change, because each run is several Overpass requests and the mirrors are
|
||||
* shared. Results stay until the categories or the route change.
|
||||
*/
|
||||
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
|
||||
const { locale } = useTranslation()
|
||||
const [results, setResults] = useState<CorridorPoi[]>([])
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [capped, setCapped] = useState(false)
|
||||
const [failedAreas, setFailedAreas] = useState(0)
|
||||
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
||||
const [error, setError] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const runIdRef = useRef(0)
|
||||
|
||||
/**
|
||||
* The routed line, thinned. A corridor built from every vertex would make hundreds of
|
||||
* overlapping boxes out of what a handful describe just as well.
|
||||
*
|
||||
* Shared rather than local to the search, because every `alongKm` in the results is a
|
||||
* distance along THIS line. Anything comparing a hit against the day's own stops has to
|
||||
* project them onto the same one, or the two sets of numbers do not mean the same thing.
|
||||
*/
|
||||
const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setResults([])
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setLoading(false)
|
||||
setCapped(false)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
}, [])
|
||||
|
||||
// A changed route or category set makes the old answers wrong, not stale — drop them.
|
||||
const routeKey = line.length ? `${line.length}:${line[0].lat.toFixed(3)},${line[0].lng.toFixed(3)}` : ''
|
||||
const catKey = categories.join(',')
|
||||
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
|
||||
|
||||
const search = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
if (spine.length < 2 || categories.length === 0) return
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const runId = ++runIdRef.current
|
||||
|
||||
const allTiles = corridorTiles(spine, widthKm)
|
||||
const tiles = allTiles.slice(0, MAX_TILES)
|
||||
|
||||
setCapped(allTiles.length > tiles.length)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
setResults([])
|
||||
setLoading(true)
|
||||
setProgress({ done: 0, total: tiles.length })
|
||||
|
||||
// All the wanted kinds in one query per box. Asking per kind was the same answer for
|
||||
// four times the requests, and every extra request is another chance for a shared
|
||||
// mirror to time out — which is what made a search both slow and patchy.
|
||||
const wanted = categories.join(',')
|
||||
const jobs = [...tiles]
|
||||
|
||||
void (async () => {
|
||||
const seen = new Map<string, CorridorPoi>()
|
||||
let failures = 0
|
||||
let shortened = 0
|
||||
let done = 0
|
||||
let next = 0
|
||||
|
||||
// Several at a time. Following the real road rather than the line between stops
|
||||
// means more boxes to cover the same drive, and asking for them one after another
|
||||
// made a search take the better part of a minute. The server races several
|
||||
// Overpass mirrors per request and caches what comes back, so the boxes overlap
|
||||
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
|
||||
const collect = async (tile: Bbox): Promise<boolean> => {
|
||||
try {
|
||||
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
|
||||
// The server answers with at most a few hundred per box and says so. A hit
|
||||
// dropped there is invisible here, so the count has to reach the surface.
|
||||
if (data.truncated) setTruncatedAreas(++shortened)
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const publish = (): void => {
|
||||
setProgress({ done, total: jobs.length })
|
||||
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
|
||||
}
|
||||
|
||||
const retryable: Bbox[] = []
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < jobs.length) {
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
const job = jobs[next++]
|
||||
if (!(await collect(job))) retryable.push(job)
|
||||
done++
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
publish()
|
||||
await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENT_REQUESTS, jobs.length) }, worker))
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
|
||||
// One more pass over what did not answer. Overpass mirrors time out under load far
|
||||
// more often than they are truly empty, and reporting "4 stretches went unsearched"
|
||||
// when a second ask would have answered them is the wrong end of the trade.
|
||||
for (const job of retryable) {
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
if (await collect(job)) continue
|
||||
failures++
|
||||
setFailedAreas(failures)
|
||||
}
|
||||
publish()
|
||||
setError(failures === jobs.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [spine, categories, widthKm, locale])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
import type { TrackPlace } from './useFollowTrack'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useFollowTrack } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* The hook is the part that talks to two services and has to survive both of them going
|
||||
* wrong halfway. The refinement itself is `followTrack`'s business and tested there; what
|
||||
* matters here is what reaches the day and what reaches the traveller.
|
||||
*/
|
||||
|
||||
const START = { lat: 52, lng: 13 }
|
||||
const END = { lat: 52, lng: 14 }
|
||||
|
||||
/** A track arching north away from the straight line between the day's two stops. */
|
||||
function arc(points = 60, bulgeDeg = 0.1): [number, number][] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => [
|
||||
52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
13 + i / points,
|
||||
])
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
return {
|
||||
byDay: {},
|
||||
trackByDay: {},
|
||||
stale: false,
|
||||
editable: true,
|
||||
add: vi.fn(),
|
||||
addMany: vi.fn().mockResolvedValue(undefined),
|
||||
move: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
reanchor: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TRACK: TrackPlace = { id: 3, name: 'Atlantic Road', route_geometry: JSON.stringify(arc()), route_color: '#0ea5e9' }
|
||||
const PLAIN: TrackPlace = { id: 4, name: 'A hotel', route_geometry: null }
|
||||
const FAR: TrackPlace = { id: 5, name: 'Somewhere else', route_geometry: JSON.stringify(arc().map(([la, ln]) => [la + 4, ln])) }
|
||||
|
||||
/**
|
||||
* A router with no idea about roads: it drives straight from waypoint to waypoint.
|
||||
*
|
||||
* Which is exactly what makes the refinement work for its living — each via added bends
|
||||
* the answer a little closer to the arc, so the loop converges instead of running to its
|
||||
* cap the way a fixed answer would.
|
||||
*/
|
||||
function answersStraight(): void {
|
||||
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
|
||||
coordinates: waypoints.map(w => [w.lat, w.lng]),
|
||||
distance: 0, duration: 0, legs: [],
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
})
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.dayId).toBeNull()
|
||||
// Tens of thousands of points per track, for a dialog nobody opened.
|
||||
expect(result.current.tracks).toEqual([])
|
||||
// But it still knows there is something to open, without parsing a single point.
|
||||
expect(result.current.available).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-011: the track a day follows is read back from the day, not remembered', () => {
|
||||
// The whole point of storing it: a field that only ever held what this session did
|
||||
// would look right until somebody refreshed the page.
|
||||
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 3, stray_km: 0.8 } } });
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
|
||||
expect(result.current.namesByDay).toEqual({ 7: 'Atlantic Road' })
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.current).toEqual({ name: 'Atlantic Road', strayKm: 0.8 })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-012: a stored track whose place is gone names nothing', () => {
|
||||
// The row cascades with the place, so this is belt and braces — but a label that
|
||||
// outlived its line would be a day claiming to follow something invisible.
|
||||
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 999, stray_km: null } } })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.available).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-002: opening lists only the places that carry a line, nearest first', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [FAR, PLAIN, TRACK], routes([day()]), viasStub()))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
expect(result.current.tracks.map(t => t.id)).toEqual([3, 5])
|
||||
expect(result.current.tracks[0].name).toBe('Atlantic Road')
|
||||
expect(result.current.tracks[0].color).toBe('#0ea5e9')
|
||||
expect(result.current.tracks[0].lengthKm).toBeGreaterThan(60)
|
||||
expect(result.current.tracks[0].gapKm).toBeLessThan(result.current.tracks[1].gapKm)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-003: applying writes the plan onto the day, legs and all', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(vias.addMany).toHaveBeenCalledTimes(1)
|
||||
const [dayId, written, legs, track] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(dayId).toBe(7)
|
||||
expect(legs).toEqual([0])
|
||||
// The chain and the name it came from land in one write: a day claiming to follow a
|
||||
// road whose vias never arrived would be worse than a day claiming nothing.
|
||||
expect(track).toMatchObject({ place_id: 3 })
|
||||
expect(written.length).toBeGreaterThan(0)
|
||||
// Every via hangs behind the first stop, because the day has exactly one leg.
|
||||
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
|
||||
expect(result.current.outcome?.vias).toBe(written.length)
|
||||
expect(result.current.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-004: a track the drive already follows writes nothing but says so', async () => {
|
||||
// The router answers with the track itself, so there is nothing left to pull onto.
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: arc(), distance: 0, duration: 0, legs: [] })
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
// Still a write: the legs are cleared so a second track does not stack, and the name
|
||||
// is recorded even though the drive needed no bending to follow it.
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], { place_id: 3, stray_km: 0 })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('route')
|
||||
expect(result.current.busy).toBe(false)
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
// One retry, because a refinement is several calls against a rate-limited host.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-006: a save that fails says so instead of claiming the day changed', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub({ addMany: vi.fn().mockRejectedValue(new Error('boom')) })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('save')
|
||||
expect(result.current.outcome).toBeNull()
|
||||
expect(result.current.busy).toBe(false)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-007: clearing drops the vias of every leg of the day', async () => {
|
||||
const vias = viasStub({ byDay: { 7: [{ id: 1 }, { id: 2 }] as never } })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.viaCount).toBe(2)
|
||||
|
||||
await act(async () => { await result.current.clear() })
|
||||
// null, not nothing: dropping the points is also dropping the claim.
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-008: closing abandons a run rather than letting it land afterwards', async () => {
|
||||
let release: ((value: unknown) => void) | null = null
|
||||
calculateRouteWithLegs.mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
let running: Promise<void> | null = null
|
||||
act(() => { running = result.current.apply(3) })
|
||||
await waitFor(() => { expect(result.current.busy).toBe(true) })
|
||||
|
||||
act(() => { result.current.close() })
|
||||
await act(async () => {
|
||||
release?.({ coordinates: [[START.lat, START.lng], [END.lat, END.lng]], distance: 0, duration: 0, legs: [] })
|
||||
await running
|
||||
})
|
||||
|
||||
// The day is left exactly as it was: an abandoned plan is not half a chain of vias.
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
expect(result.current.dayId).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const lonely = day({ stops: [day().stops[0]] })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([lonely]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,255 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
|
||||
/**
|
||||
* The dialog state behind "this day follows that track".
|
||||
*
|
||||
* Kept out of the dialog because applying one is a long job — a round trip to the routing
|
||||
* host per refinement — and a component that unmounts halfway would leave the day with
|
||||
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
|
||||
* got while it runs.
|
||||
*/
|
||||
|
||||
/** A track of this trip, ready to be offered. */
|
||||
export interface TrackChoice {
|
||||
id: number
|
||||
name: string
|
||||
/** The colour the map already draws it in, so the list and the line agree. */
|
||||
color: string | null
|
||||
points: LatLng[]
|
||||
lengthKm: number
|
||||
/** How far the track runs from the open day at its closest — what orders the list. */
|
||||
gapKm: number
|
||||
}
|
||||
|
||||
/** What became of an applied track. */
|
||||
export interface FollowTrackOutcome {
|
||||
vias: number
|
||||
strayKm: number
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
export interface FollowTrack {
|
||||
/** The day the dialog is open for, or null when it is closed. */
|
||||
dayId: number | null
|
||||
open: (dayId: number) => void
|
||||
close: () => void
|
||||
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
|
||||
tracks: TrackChoice[]
|
||||
busy: boolean
|
||||
/** Which refinement round is running, for a dialog that has to say something. */
|
||||
round: number
|
||||
error: 'route' | 'save' | null
|
||||
outcome: FollowTrackOutcome | null
|
||||
apply: (trackId: number) => Promise<void>
|
||||
/** Drops every via of the open day, so it drives the way the router would. */
|
||||
clear: () => Promise<void>
|
||||
/** How many vias the open day carries — what makes clearing worth offering. */
|
||||
viaCount: number
|
||||
/**
|
||||
* The track the open day follows, if it follows one.
|
||||
*
|
||||
* Read back from the day rather than remembered from the run that applied it: the point
|
||||
* of storing it at all is that it survives a reload, and a field that only ever held
|
||||
* what this session did would look right until somebody refreshed.
|
||||
*/
|
||||
current: { name: string; strayKm: number | null } | null
|
||||
/** The name of the track each day follows, for the rail's badge. */
|
||||
namesByDay: Record<number, string>
|
||||
/**
|
||||
* Whether the trip holds a track at all.
|
||||
*
|
||||
* Read off the column rather than off `tracks`, which is empty until the dialog opens
|
||||
* and would therefore never let it be opened. It is what keeps the rail from offering
|
||||
* a control whose only possible answer is "no tracks in this trip".
|
||||
*/
|
||||
available: boolean
|
||||
}
|
||||
|
||||
/** A place is a track when it carries a line. */
|
||||
export interface TrackPlace {
|
||||
id: number
|
||||
name?: string | null
|
||||
route_geometry?: string | null
|
||||
route_color?: string | null
|
||||
}
|
||||
|
||||
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
|
||||
export const ROUND_PAUSE_MS = 400
|
||||
|
||||
/** How long to wait before the one retry a round gets. */
|
||||
export const RETRY_PAUSE_MS = 1500
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
|
||||
|
||||
export function useFollowTrack(
|
||||
tripId: number | string | null,
|
||||
places: TrackPlace[],
|
||||
routes: RoadtripRoutes,
|
||||
vias: RoadtripVias,
|
||||
): FollowTrack {
|
||||
const [dayId, setDayId] = useState<number | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [round, setRound] = useState(0)
|
||||
const [error, setError] = useState<'route' | 'save' | null>(null)
|
||||
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
*
|
||||
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
|
||||
* parsing them to render a rail nobody opened would cost more than the rail does. The
|
||||
* memo hangs off `dayId` as well as `places` for exactly that reason.
|
||||
*/
|
||||
const tracks = useMemo<TrackChoice[]>(() => {
|
||||
if (dayId === null) return []
|
||||
const found: TrackChoice[] = []
|
||||
for (const place of places) {
|
||||
const points = parseTrack(place.route_geometry)
|
||||
if (points.length < 2) continue
|
||||
found.push({
|
||||
id: place.id,
|
||||
name: place.name || '',
|
||||
color: place.route_color ?? null,
|
||||
points,
|
||||
lengthKm: lineLengthKm(points),
|
||||
gapKm: trackGapKm(points, stops),
|
||||
})
|
||||
}
|
||||
// Nearest first: a trip carrying every track of a three-week tour should still open
|
||||
// on the one that runs along today's drive.
|
||||
return found.sort((a, b) => a.gapKm - b.gapKm)
|
||||
}, [dayId, places, stops])
|
||||
|
||||
const open = useCallback((next: number) => {
|
||||
setDayId(next)
|
||||
setOutcome(null)
|
||||
setError(null)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
abort.current?.abort()
|
||||
abort.current = null
|
||||
setDayId(null)
|
||||
setBusy(false)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const apply = useCallback(async (trackId: number) => {
|
||||
const track = tracks.find(t => t.id === trackId)
|
||||
if (!track || dayId === null || stops.length < 2 || busy) return
|
||||
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setOutcome(null)
|
||||
setRound(0)
|
||||
|
||||
let plan: FollowTrackPlan
|
||||
let spent = 0
|
||||
try {
|
||||
plan = await planTrack({
|
||||
track: track.points,
|
||||
stops,
|
||||
signal: controller.signal,
|
||||
onRound: n => { setRound(n) },
|
||||
route: async waypoints => {
|
||||
// Paced before the call rather than after it, so a plan that fits on its first
|
||||
// round costs nothing in waiting — and given one second chance, because a
|
||||
// refinement is several calls in a row against a host that allows about one a
|
||||
// second, and losing the whole loop to a single 429 would restart it at nothing.
|
||||
if (spent > 0) await sleep(ROUND_PAUSE_MS)
|
||||
spent += 1
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(waypoints, {
|
||||
signal: controller.signal,
|
||||
profile: 'driving',
|
||||
tripId,
|
||||
dayId,
|
||||
})
|
||||
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
|
||||
} catch (err) {
|
||||
if (attempt >= 1 || controller.signal.aborted) throw err
|
||||
await sleep(RETRY_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError('route')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) { setBusy(false); return }
|
||||
|
||||
try {
|
||||
// The chain and the name it came from, in one write: a day claiming to follow a
|
||||
// road whose vias never landed would be worse than a day claiming nothing.
|
||||
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
|
||||
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [tracks, dayId, stops, busy, tripId, vias])
|
||||
|
||||
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
|
||||
|
||||
/**
|
||||
* The name behind each day's stored track id.
|
||||
*
|
||||
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
|
||||
* asks for this on every render.
|
||||
*/
|
||||
const namesByDay = useMemo(() => {
|
||||
const names: Record<number, string> = {}
|
||||
const byId = new Map(places.map(p => [p.id, p]))
|
||||
for (const [day, track] of Object.entries(vias.trackByDay)) {
|
||||
const place = byId.get(track.place_id)
|
||||
if (place?.name) names[Number(day)] = place.name
|
||||
}
|
||||
return names
|
||||
}, [places, vias.trackByDay])
|
||||
|
||||
const current = useMemo(() => {
|
||||
if (dayId === null) return null
|
||||
const track = vias.trackByDay[dayId]
|
||||
const name = namesByDay[dayId]
|
||||
return track && name ? { name, strayKm: track.stray_km } : null
|
||||
}, [dayId, vias.trackByDay, namesByDay])
|
||||
// A cheap look at the column, not a parse: this runs on every planner render, and the
|
||||
// question is only whether anything at all was ever imported.
|
||||
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
if (dayId === null || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Every leg of the day, so nothing is left behind on a leg the track never reached.
|
||||
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
|
||||
// null rather than nothing: dropping the points is also dropping the claim.
|
||||
await vias.addMany(dayId, [], legs, null)
|
||||
setOutcome({ vias: 0, strayKm: 0, capped: false })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [dayId, busy, stops.length, vias])
|
||||
|
||||
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
|
||||
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
|
||||
|
||||
import { useRoadtripCorridor } from './useRoadtripCorridor'
|
||||
|
||||
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
|
||||
return {
|
||||
lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
|
||||
phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 1, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
/** The search state the mocked hook hands back, results included. */
|
||||
function searchWith(results: CorridorPoi[]): CorridorSearch {
|
||||
return {
|
||||
results,
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
// The thinned line the day's geometry becomes; hits and stops are measured along it.
|
||||
spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCorridorPois.mockReset()
|
||||
})
|
||||
|
||||
describe('useRoadtripCorridor', () => {
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Aral Autohof' }),
|
||||
poi({ osm_id: 'b', name: 'Shell Nord' }),
|
||||
]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
|
||||
poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
|
||||
]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('Shell'))
|
||||
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter(' '))
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('aral'))
|
||||
expect(result.current.nameFilter).toBe('aral')
|
||||
|
||||
// A search begins: keeping the term would hide the new hits behind the old query.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
|
||||
// Hits arrive box by box and publish as they land. Clearing on every batch instead of
|
||||
// on the start of a search wiped out whatever had been typed in the meantime.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
useCorridorPois.mockReturnValue({
|
||||
...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
|
||||
loading: true,
|
||||
})
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('shell')
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
const before = result.current.visible
|
||||
rerender()
|
||||
|
||||
// Both map renderers rebuild every marker when this array's identity changes.
|
||||
expect(result.current.visible).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-020: narrowing to one point keeps only what is near it', () => {
|
||||
// A seven-hundred-kilometre day turns up hits along the whole of it. Picking the point
|
||||
// you mean to break at is a subtraction, because both figures are measured against
|
||||
// the same thinned line rather than being asked for separately.
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'near', name: 'Near', alongKm: 30 }),
|
||||
poi({ osm_id: 'far', name: 'Far', alongKm: 600 }),
|
||||
]))
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
act(() => { result.current.setSectionKm(50) })
|
||||
act(() => { result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) })
|
||||
|
||||
// Stop 0 sits at the start of the drive, so only the hit within 50 km survives.
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['near'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => {
|
||||
// The section names a day as well as an index, so switching days cannot silently
|
||||
// apply yesterday's break to today's drive.
|
||||
const hits = [poi({ osm_id: 'a', name: 'A', alongKm: 10 }), poi({ osm_id: 'b', name: 'B', alongKm: 600 })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
act(() => { result.current.setSection({ dayId: 99, kind: 'stop', index: 0 }) })
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
|
||||
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
|
||||
import type { SectionAnchor } from './roadtripModel'
|
||||
import { projectOntoRoute, type LatLng } from './corridor'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
// The categories come from the one table now, which is also the only place that knows
|
||||
// a hotel is a category without being a stop kind.
|
||||
export { CORRIDOR_CATEGORY_KEYS } from './stopKinds'
|
||||
|
||||
/** Corridor widths offered, in kilometres. */
|
||||
export const CORRIDOR_WIDTHS_KM = [2, 5, 10]
|
||||
|
||||
/** How far either side of a chosen point the list is narrowed to, in kilometres. */
|
||||
export const CORRIDOR_SECTION_KM = [25, 50, 100]
|
||||
|
||||
export interface RoadtripCorridor {
|
||||
/** Which day's drive is being searched. */
|
||||
dayId: string
|
||||
setDayId: (value: string) => void
|
||||
day: RoadtripDay | undefined
|
||||
categories: string[]
|
||||
toggleCategory: (key: string) => void
|
||||
widthKm: number
|
||||
setWidthKm: (km: number) => void
|
||||
search: CorridorSearch
|
||||
/** Narrows what was found by name or brand. Empty means everything. */
|
||||
nameFilter: string
|
||||
/** The stops and leg midpoints a break can be planned around. */
|
||||
anchors: SectionAnchor[]
|
||||
/** Which of them the list is narrowed to, or null for the whole day. */
|
||||
section: { dayId: number; kind: 'stop' | 'leg'; index: number } | null
|
||||
setSection: (value: { dayId: number; kind: 'stop' | 'leg'; index: number } | null) => void
|
||||
/** How far either side of it, in kilometres. */
|
||||
sectionKm: number
|
||||
setSectionKm: (value: number) => void
|
||||
/** Socket family the charging hits are narrowed to, or empty for any. */
|
||||
socketFilter: string
|
||||
setSocketFilter: (value: string) => void
|
||||
/** Minimum kW for charging hits, or 0 for any. */
|
||||
minKw: number
|
||||
setMinKw: (value: number) => void
|
||||
setNameFilter: (value: string) => void
|
||||
/**
|
||||
* What both the panel and the map show: the hits that match `nameFilter`.
|
||||
* Read this, never `search.results`, or the two drift apart.
|
||||
*/
|
||||
visible: CorridorPoi[]
|
||||
/**
|
||||
* Which position in the day's chain a hit belongs at, so adding one lands it in the
|
||||
* order it will actually be driven past rather than at the end of the day.
|
||||
*/
|
||||
insertIndexFor: (poi: CorridorPoi) => number
|
||||
/** How far along the drive each of the day's stops sits, in the same units as a hit. */
|
||||
stopsAlongKm: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor search as trip state rather than panel state.
|
||||
*
|
||||
* The map has to draw what the search found — a list of petrol stations is only half an
|
||||
* answer if you cannot see which one is on your side of the road — so the results have to
|
||||
* live above both the panel and the map rather than inside the panel.
|
||||
*/
|
||||
export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
|
||||
const [dayId, setDayId] = useState<string>('')
|
||||
const [categories, setCategories] = useState<string[]>(['fuel'])
|
||||
const [widthKm, setWidthKm] = useState<number>(5)
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
/**
|
||||
* Which point of the day the list is narrowed around, if any.
|
||||
*
|
||||
* Stored as the anchor's identity rather than as a distance, on purpose: `alongKm` is
|
||||
* measured against the thinned spine, whose length depends on the corridor width
|
||||
* (`useCorridorPois` thins with a tolerance derived from it). A kilometre figure kept
|
||||
* here would quietly shift when the width changes from 5 to 10; resolved through
|
||||
* `stopsAlongKm` it lands on the same stop again.
|
||||
*/
|
||||
const [section, setSection] = useState<{ dayId: number; kind: 'stop' | 'leg'; index: number } | null>(null)
|
||||
const [sectionKm, setSectionKm] = useState(50)
|
||||
/** A socket family, or empty for any. Only ever applied to charging hits. */
|
||||
const [socketFilter, setSocketFilter] = useState('')
|
||||
/** Minimum kW, or 0 for any. */
|
||||
const [minKw, setMinKw] = useState(0)
|
||||
|
||||
// Falls back to the first day with a drive, so the panel is useful before the user
|
||||
// has picked anything — and follows along when that day disappears.
|
||||
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
|
||||
|
||||
const line = useMemo<LatLng[]>(() => {
|
||||
if (!day) return []
|
||||
// The roads driven, not the straight lines between stops. A corridor built from the
|
||||
// stops searches beside a line the car never takes — between two cities that is open
|
||||
// country, which is why everything it found sat at the stops themselves.
|
||||
if (day.geometry.length > 1) return day.geometry.map(([lat, lng]) => ({ lat, lng }))
|
||||
// Until the day has routed there is nothing else to go on.
|
||||
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day])
|
||||
|
||||
const search = useCorridorPois(line, categories, widthKm)
|
||||
|
||||
const toggleCategory = useCallback((key: string) => {
|
||||
setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
|
||||
}, [])
|
||||
|
||||
// A filter kept across a new search would hide the fresh answer behind the old question.
|
||||
// Cleared when a search STARTS, not whenever results change: hits arrive box by box and
|
||||
// publish as they land, so resetting on every batch wiped out anything typed while the
|
||||
// search was still running.
|
||||
const searching = search.loading
|
||||
const wasSearching = useRef(false)
|
||||
useEffect(() => {
|
||||
if (searching && !wasSearching.current) setNameFilter('')
|
||||
wasSearching.current = searching
|
||||
}, [searching])
|
||||
|
||||
|
||||
/**
|
||||
* Where a hit belongs in the day's chain, as an index among its stops.
|
||||
*
|
||||
* The stops are projected onto the same thinned line the hits were measured against, so
|
||||
* both sides are distances along one drive and the comparison is simply "which stops has
|
||||
* the car passed by then". A stop the projection cannot place (nowhere near the routed
|
||||
* line) keeps its neighbours' order rather than jumping to the front.
|
||||
*/
|
||||
const stopsAlongKm = useMemo(() => {
|
||||
if (!day || search.spine.length < 2) return []
|
||||
let last = 0
|
||||
return day.stops.map(s => {
|
||||
const hit = projectOntoRoute({ lat: s.lat, lng: s.lng }, search.spine)
|
||||
if (hit) last = hit.alongKm
|
||||
return last
|
||||
})
|
||||
}, [day, search.spine])
|
||||
|
||||
/**
|
||||
* The stops and leg midpoints of the current day, and the distance of the chosen one.
|
||||
*
|
||||
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
|
||||
* is looked up fresh, so changing the corridor width does not move the point the list
|
||||
* is narrowed around.
|
||||
*/
|
||||
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
|
||||
const anchorKm = useMemo(() => {
|
||||
if (!section || !day || section.dayId !== day.dayId) return null
|
||||
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
|
||||
}, [section, day, anchors])
|
||||
|
||||
/**
|
||||
* Memoised on purpose, not filtered where it is drawn: both map renderers tear down and
|
||||
* rebuild every POI marker whenever the array's identity changes, so a `.filter()` in a
|
||||
* render body would make the pins flicker on each keystroke and on every unrelated
|
||||
* re-render.
|
||||
*
|
||||
* Brand counts as a name here because the server folds `operator` into `brand`, and
|
||||
* "Shell" is what someone types when the OSM name is "Shell Autohof Nord".
|
||||
*/
|
||||
const visible = useMemo(() => {
|
||||
const needle = nameFilter.trim().toLowerCase()
|
||||
// The same array when nothing narrows it, not a copy of it: the map redraws off this
|
||||
// reference, and handing it a fresh array on every render moves every pin.
|
||||
if (!needle && !socketFilter && !minKw && anchorKm === null) return search.results
|
||||
return search.results.filter(p => {
|
||||
// Around one point of the drive, when one has been picked. The two figures are
|
||||
// measured against the same thinned line, so this really is a subtraction and not
|
||||
// a second search.
|
||||
if (anchorKm !== null && Math.abs(p.alongKm - anchorKm) > sectionKm) return false
|
||||
if (needle && !(p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))) {
|
||||
return false
|
||||
}
|
||||
// The charging filters only ever hide charging hits. A rest area does not have a
|
||||
// socket and is not answering the question, so filtering the whole list by one
|
||||
// would empty it of everything the search also found.
|
||||
if (p.category !== 'charging') return true
|
||||
if (socketFilter && !p.charging?.sockets.some(s => s.type === socketFilter)) return false
|
||||
// A station that does not state its power is kept. Roughly two thirds of them do
|
||||
// not, and reading silence as "too slow" would throw away most of the map.
|
||||
if (minKw && p.charging?.sockets.some(s => s.kw != null) && !p.charging.sockets.some(s => (s.kw ?? 0) >= minKw)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [search.results, nameFilter, socketFilter, minKw, anchorKm, sectionKm])
|
||||
|
||||
const insertIndexFor = useCallback(
|
||||
(poi: CorridorPoi) => insertIndexForAlong(stopsAlongKm, poi.alongKm),
|
||||
[stopsAlongKm],
|
||||
)
|
||||
|
||||
return {
|
||||
dayId: day ? String(day.dayId) : '',
|
||||
setDayId,
|
||||
day,
|
||||
categories,
|
||||
toggleCategory,
|
||||
widthKm,
|
||||
setWidthKm,
|
||||
search,
|
||||
nameFilter,
|
||||
anchors,
|
||||
section,
|
||||
setSection,
|
||||
sectionKm,
|
||||
setSectionKm,
|
||||
socketFilter,
|
||||
setSocketFilter,
|
||||
minKw,
|
||||
setMinKw,
|
||||
setNameFilter,
|
||||
visible,
|
||||
insertIndexFor,
|
||||
stopsAlongKm,
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../types'
|
||||
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
// class declared down there would not exist yet when the hook does its `instanceof`.
|
||||
const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
||||
class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super('refused')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
|
||||
}
|
||||
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
|
||||
})
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
|
||||
|
||||
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
const HAMBURG: [number, number] = [53.5511, 9.9937]
|
||||
const LUENEBURG: [number, number] = [53.2464, 10.4115]
|
||||
const BERLIN: [number, number] = [52.52, 13.405]
|
||||
|
||||
interface StopSpec {
|
||||
id: number
|
||||
at: [number, number]
|
||||
time?: string | null
|
||||
dwell?: number | null
|
||||
legMode?: string | null
|
||||
incoming?: string | null
|
||||
stopType?: string | null
|
||||
noCoords?: boolean
|
||||
}
|
||||
|
||||
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
|
||||
return { id, day_number: number, date: null, title: null, ...extra } as unknown as Day
|
||||
}
|
||||
|
||||
function assignment(spec: StopSpec, order: number): Assignment {
|
||||
return {
|
||||
id: spec.id,
|
||||
place_id: spec.id * 10,
|
||||
order_index: order,
|
||||
assignment_time: spec.time ?? null,
|
||||
leg_transport_mode: spec.legMode ?? null,
|
||||
incoming_leg_transport_mode: spec.incoming ?? null,
|
||||
place: {
|
||||
id: spec.id * 10,
|
||||
name: `Stop ${spec.id}`,
|
||||
lat: spec.noCoords ? null : spec.at[0],
|
||||
lng: spec.noCoords ? null : spec.at[1],
|
||||
place_time: null,
|
||||
duration_minutes: spec.dwell ?? null,
|
||||
stop_type: spec.stopType ?? null,
|
||||
},
|
||||
} as unknown as Assignment
|
||||
}
|
||||
|
||||
const map = (dayId: number, stops: StopSpec[]): AssignmentsMap =>
|
||||
({ [String(dayId)]: stops.map(assignment) }) as unknown as AssignmentsMap
|
||||
|
||||
/** A router answer with one leg per consecutive pair, the way OSRM replies. */
|
||||
const routed = (legs: number, coordinates: [number, number][] = [HAMBURG, BERLIN]) => ({
|
||||
coordinates,
|
||||
distance: 100000,
|
||||
duration: 3600,
|
||||
legs: Array.from({ length: legs }, () => ({ distance: 100000 / legs, duration: 3600 / legs, text: '100 km' })),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(1))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useRoadtripRoutes', () => {
|
||||
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG },
|
||||
{ id: 2, at: LUENEBURG },
|
||||
{ id: 3, at: BERLIN },
|
||||
]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
const [waypoints, options] = calculateRouteWithLegs.mock.calls[0]
|
||||
expect(waypoints).toHaveLength(3)
|
||||
expect(options).toMatchObject({ profile: 'driving', tripId: 7, dayId: 1 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-002: a day whose legs are driven and cycled becomes two requests', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG, legMode: 'driving' },
|
||||
{ id: 2, at: LUENEBURG, legMode: 'cycling' },
|
||||
{ id: 3, at: BERLIN },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
const profiles = calculateRouteWithLegs.mock.calls.map(c => c[1].profile)
|
||||
expect(profiles).toEqual(['driving', 'cycling'])
|
||||
})
|
||||
|
||||
it("FE-ROADTRIP-ROUTES-003: the day's own default mode wins over the trip fallback", async () => {
|
||||
const days = [day(1, 1, { default_transport_mode: 'walking' } as Partial<Day>)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }]
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving'))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs.mock.calls[0][1].profile).toBe('walking')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-004: drops a stop without coordinates and a day left with one stop', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG, noCoords: true }]),
|
||||
...map(2, [{ id: 3, at: HAMBURG }, { id: 4, at: BERLIN }]),
|
||||
} as AssignmentsMap
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Day 1 lost its second stop and with it its reason to appear at all.
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([2])
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-013: a charger on the way is part of the drive, not a stop', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG },
|
||||
{ id: 2, at: [53, 11], stopType: 'charging' },
|
||||
{ id: 3, at: BERLIN },
|
||||
]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, [53, 11], BERLIN]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Three assignments, three markers on the map, two places the trip is for. The
|
||||
// charger still routes — it is only left out of the count the head shows.
|
||||
expect(result.current.days[0].stops).toHaveLength(3)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-005: sums the legs and hands the map one polyline per routed run', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(1, [HAMBURG, [53, 11], BERLIN]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.totalDistance).toBe(100000)
|
||||
expect(result.current.totalDuration).toBe(3600)
|
||||
expect(result.current.lines).toEqual([[HAMBURG, [53, 11], BERLIN]])
|
||||
// The corridor search reads this: the roads driven, not the line between stops.
|
||||
expect(result.current.days[0].geometry).toEqual([HAMBURG, [53, 11], BERLIN])
|
||||
expect(result.current.segments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-006: walks the clock forward from the first pinned time', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG, time: '09:00', dwell: 30 },
|
||||
{ id: 2, at: BERLIN, dwell: 60 },
|
||||
]
|
||||
// One hour of driving between them.
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(1))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const entries = result.current.days[0].schedule.entries
|
||||
expect(entries[0]).toMatchObject({ arrival: '09:00', departure: '09:30', anchored: true })
|
||||
expect(entries[1]).toMatchObject({ arrival: '10:30', departure: '11:30', anchored: false })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-007: retries a leg that did not route, then keeps it blank', async () => {
|
||||
vi.useFakeTimers()
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
// Two backoffs, then it gives up rather than inventing a duration.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(8000) })
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.days[0].legs).toEqual([undefined])
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
// A leg that never routed breaks the chain instead of guessing past it.
|
||||
expect(result.current.days[0].schedule.entries[1].arrival).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-008: a leg that answers on the second try still lands', async () => {
|
||||
vi.useFakeTimers()
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
calculateRouteWithLegs
|
||||
.mockRejectedValueOnce(new Error('429'))
|
||||
.mockResolvedValue(routed(1))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000) })
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.totalDistance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-009: renaming a place does not re-ask the router', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const first = map(1, stops)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
|
||||
{ initialProps: { assignments: first } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
|
||||
const renamed = JSON.parse(JSON.stringify(first)) as AssignmentsMap
|
||||
renamed['1'][0].place!.name = 'Somewhere else entirely'
|
||||
rerender({ assignments: renamed })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-010: a trip with nothing to drive asks for nothing', async () => {
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }])))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
expect(result.current.days).toEqual([])
|
||||
expect(result.current.totalStops).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-012: a stop whose id changes after saving keeps its legs and its clock', async () => {
|
||||
// The optimistic insert: a stop added mid-day carries a temporary negative id until
|
||||
// the server answers, then the real id replaces it without a coordinate moving. Since
|
||||
// `planKey` is geometry only, no refetch follows — so legs filed under the id would be
|
||||
// stranded, and a broken chain blanks every arrival after it.
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
const days = [day(1, 1)]
|
||||
const optimistic: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG, time: '09:00' },
|
||||
{ id: -1755000000, at: LUENEBURG },
|
||||
{ id: 2, at: BERLIN },
|
||||
]
|
||||
const first = map(1, optimistic)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
|
||||
{ initialProps: { assignments: first } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
||||
|
||||
const saved = JSON.parse(JSON.stringify(first)) as AssignmentsMap
|
||||
saved['1'][1].id = 4711
|
||||
rerender({ assignments: saved })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
||||
expect(result.current.days[0].schedule.entries[2].arrival).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-013: a rate-limited host is given the time it asked for', async () => {
|
||||
// The public OSRM instances answer 429 above roughly one request a second and say how
|
||||
// long to wait. Retrying sooner than that is just a second refusal, so the host's
|
||||
// Retry-After wins over the shorter backoff the hook would otherwise use.
|
||||
vi.useFakeTimers()
|
||||
calculateRouteWithLegs
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
|
||||
.mockResolvedValue(routed(1))
|
||||
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
|
||||
// The hook's own first backoff is 1500 ms; at that point it must still be waiting.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.days[0].legs[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-014: a via joins the routing request between the stops it follows', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
||||
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
||||
}))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Three waypoints for two stops: the via is threaded in where it belongs.
|
||||
const [waypoints] = calculateRouteWithLegs.mock.calls[0]
|
||||
expect(waypoints).toHaveLength(3)
|
||||
expect(waypoints[1]).toEqual({ lat: 53.2, lng: 10.4 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-015: the two legs a via creates are shown as the one drive they are', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
// The router answers per waypoint pair: two legs for one stop pair.
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
||||
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
||||
}))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// The rail shows one leg between two stops, carrying the whole drive.
|
||||
expect(result.current.days[0].legs).toHaveLength(1)
|
||||
expect(result.current.days[0].legs[0]).toMatchObject({ distance: 100000, duration: 3600 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-016: moving a via re-asks the router, renaming a place still does not', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }] }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, days, map(1, stops), 'driving', v),
|
||||
{ initialProps: { v: vias } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A via that moved is a different road, so the route has to be asked for again.
|
||||
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.9 }] } })
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
let seen: AbortSignal | undefined
|
||||
calculateRouteWithLegs.mockImplementation((_wp: unknown, opts: { signal: AbortSignal }) => {
|
||||
seen = opts.signal
|
||||
return new Promise(() => {}) // never settles
|
||||
})
|
||||
|
||||
const { unmount } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(seen).toBeDefined())
|
||||
expect(seen!.aborted).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(seen!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning,
|
||||
} from './roadtripModel'
|
||||
import { projectOntoRoute } from './corridor'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
|
||||
export interface RoadtripStop {
|
||||
assignmentId: number
|
||||
placeId: number
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
/** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */
|
||||
time: string | null
|
||||
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
|
||||
dwellMinutes: number | null
|
||||
/** Mode of the leg LEAVING this stop; null inherits the day default. */
|
||||
legMode: string | null
|
||||
incomingLegMode: string | null
|
||||
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
|
||||
stopType: string | null
|
||||
/**
|
||||
* How far the router had to go to find a road, in metres, once this stop has routed.
|
||||
*
|
||||
* null means either not routed yet or close enough that nobody would call it a gap.
|
||||
*/
|
||||
offRoadMeters?: number | null
|
||||
}
|
||||
|
||||
export interface RoadtripDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
stops: RoadtripStop[]
|
||||
/** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */
|
||||
legs: (RouteSegment | undefined)[]
|
||||
/** Arrival and departure per stop, walked forward from the day's first pinned time. */
|
||||
schedule: Schedule
|
||||
/**
|
||||
* Halts a routing plugin placed on each leg, indexed alongside `legs`.
|
||||
*
|
||||
* Read-only and never written back: a plugin halt that became a via would go out as a
|
||||
* waypoint on the next run, and the plugin would then optimise around its own charging
|
||||
* stop. Kept out of `RouteSegment` because that type is read by half the map.
|
||||
*/
|
||||
legVias: RouteVia[][]
|
||||
/**
|
||||
* The roads actually driven that day, as [lat, lng] — not the straight lines between
|
||||
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
|
||||
* Berlin the straight line runs across open country while the motorway swings north of
|
||||
* it, so a corridor built from the stops finds the wrong side of the map.
|
||||
*/
|
||||
geometry: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/**
|
||||
* Findings about the driving itself, per leg: too long behind the wheel, the tank
|
||||
* running out before the next fuel stop. Separate from `schedule.warnings`, which are
|
||||
* findings about a stop, because these belong to no stop and because `Schedule` is
|
||||
* pinned shape-for-shape by its own tests.
|
||||
*/
|
||||
driveWarnings: ScheduleWarning[]
|
||||
/** More driving in this day than the traveller allows, or null. */
|
||||
dayWarning: DayWarning | null
|
||||
}
|
||||
|
||||
/** A place, the road it is driven to from, and how far apart the two are. */
|
||||
export interface AccessSpur {
|
||||
line: [[number, number], [number, number]]
|
||||
meters: number
|
||||
/** So the rail and the map can point at the same one. */
|
||||
stopKey: string
|
||||
}
|
||||
|
||||
/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
|
||||
export interface QuietDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** Usually none or one — two would have made it a drive. */
|
||||
stops: RoadtripStop[]
|
||||
}
|
||||
|
||||
export interface RoadtripRoutes {
|
||||
days: RoadtripDay[]
|
||||
/**
|
||||
* Days with fewer than two stops, in trip order.
|
||||
*
|
||||
* They carry no drive, so they are not part of `days` and nothing is routed for them —
|
||||
* but a stop has to be able to move onto one, and a day that is not on screen cannot be
|
||||
* dropped on. Listed separately rather than folded into `days` so the totals and the
|
||||
* routing keep meaning exactly what they meant before.
|
||||
*/
|
||||
quietDays: QuietDay[]
|
||||
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
|
||||
lines: [number, number][][]
|
||||
/**
|
||||
* The gap between a place and the road it was routed from, as a two-point line.
|
||||
*
|
||||
* Drawn dashed, the way Google draws the walk from the kerb to the door, so a stop well
|
||||
* off the road reads as "the drive ends here and the rest is not driving" instead of
|
||||
* looking like a route that cuts across open country.
|
||||
*/
|
||||
accessLines: AccessSpur[]
|
||||
/** Every plugin halt of the trip, flat, which is the shape the map draws. */
|
||||
vias: RouteVia[]
|
||||
/** The routed legs themselves, so the map can label each connector. */
|
||||
segments: RouteSegment[]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
totalStops: number
|
||||
/** True while legs are still coming in — the numbers below are a partial sum until then. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
|
||||
* request per second; a road trip asks for every leg of every day, so without spacing
|
||||
* the first handful answer and the rest come back 429.
|
||||
*/
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a leg that failed (usually a rate limit) is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal.aborted) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* A leg that routed: the numbers for the rail plus the geometry the map draws.
|
||||
*
|
||||
* The two optional fields are filled by their own features and are declared here rather
|
||||
* than added later, because this shape is written in one place and read in another and
|
||||
* three separate changes to the same two lines is three merge conflicts.
|
||||
*/
|
||||
interface RoutedLeg {
|
||||
seg: RouteSegment
|
||||
line: [number, number][]
|
||||
/**
|
||||
* Where the router actually put each end of this leg, and how far that is from the
|
||||
* place we asked for. OSRM snaps every waypoint to the nearest road with no distance
|
||||
* limit and reports both in every response; a place set well off the road otherwise
|
||||
* looks like it is on the route when the drive really starts hundreds of metres away.
|
||||
*/
|
||||
snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }
|
||||
/** Halts a routing plugin placed on this leg, in the order they are driven through. */
|
||||
vias: RouteVia[]
|
||||
}
|
||||
|
||||
/** A waypoint as the router resolved it, beside the coordinate that was asked for. */
|
||||
export interface SnappedPoint {
|
||||
lat: number
|
||||
lng: number
|
||||
/** Straight-line metres from the requested coordinate to the road it snapped to. */
|
||||
offRoadMeters: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
|
||||
* these, and so are the keys the routed legs are filed under, so the two can never drift
|
||||
* apart.
|
||||
*
|
||||
* Deliberately not the assignment id. A stop added mid-day is written optimistically with
|
||||
* a temporary negative id (`assignmentsSlice`) and swapped for the real one once the
|
||||
* server answers, without its coordinates changing — so `planKey` stays identical, the
|
||||
* effect does not run again, and a leg filed under the id would sit under a dead one
|
||||
* forever. A missing leg breaks the schedule's chain (`computeSchedule` gives up its
|
||||
* cursor), which would silently blank every arrival time after the new stop.
|
||||
*/
|
||||
const stopKey = (s: RoadtripStop): string =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
|
||||
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
return {
|
||||
assignmentId: a.id,
|
||||
placeId: a.place_id,
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
time: a.assignment_time ?? p.place_time ?? null,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
||||
stopType: p.stop_type ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance and driving time for every leg of every day of the trip.
|
||||
*
|
||||
* The day plan sidebar routes one day at a time because that is all it shows; a road
|
||||
* trip is the whole chain, which is exactly what #435 asks for.
|
||||
*
|
||||
* A day is one request, not one per leg: the router already returns a leg for every
|
||||
* consecutive pair of the waypoints it is handed. Requests go out one after another
|
||||
* with a gap, because the public routing hosts TREK ships with allow about one per
|
||||
* second — but only the ones that actually reach the network are paced, so returning
|
||||
* to this view costs nothing.
|
||||
*/
|
||||
export function useRoadtripRoutes(
|
||||
tripId: number | string | null,
|
||||
days: Day[],
|
||||
assignments: AssignmentsMap,
|
||||
/** Mode for legs that neither the stop nor the day pins down. */
|
||||
fallbackProfile: string = 'driving',
|
||||
/**
|
||||
* Points the drive is made to pass through, per day (#1797). They join the routing
|
||||
* request between the stops they follow, so the router draws the road the traveller
|
||||
* chose rather than the one it prefers.
|
||||
*/
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
): RoadtripRoutes {
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [legsByDay, setLegsByDay] = useState<Record<number, Record<string, RoutedLeg>>>({})
|
||||
/**
|
||||
* Where the router put each stop, by day and stop key.
|
||||
*
|
||||
* Kept beside the legs rather than inside them because it belongs to a stop and a leg
|
||||
* has two of them: the arrival end of one leg is the departure end of the next, and
|
||||
* filing it twice would draw the spur twice.
|
||||
*/
|
||||
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
const limits = useMemo<DriveLimits>(
|
||||
() => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
|
||||
[legMinutes, dayMinutes, rangeKm],
|
||||
)
|
||||
|
||||
const plan = useMemo<RoadtripDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => {
|
||||
const stops = (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(asStop)
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
return {
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops,
|
||||
legs: [],
|
||||
legVias: [],
|
||||
driveWarnings: [],
|
||||
dayWarning: null,
|
||||
schedule: { entries: [], warnings: [] },
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
}
|
||||
})
|
||||
.filter(d => d.stops.length > 1)
|
||||
}, [days, assignments])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => ({
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(asStop)
|
||||
.filter((s): s is RoadtripStop => s !== null),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
const viaKey = useMemo(
|
||||
() => Object.entries(viasByDay)
|
||||
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
|
||||
// Only the order has to be stable — this is a cache key, not a list anybody reads
|
||||
// — but it has to be stable on purpose rather than by default.
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(';'),
|
||||
[viasByDay],
|
||||
)
|
||||
|
||||
const planKey = useMemo(
|
||||
() => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
|
||||
[plan, viaKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort()
|
||||
if (!plan.length) {
|
||||
setLegsByDay({})
|
||||
setSnapByDay({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setLoading(true)
|
||||
|
||||
const dayDefault = (dayId: number): string =>
|
||||
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
|
||||
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const day of plan) {
|
||||
const dayLegs: Record<string, RoutedLeg> = {}
|
||||
collected[day.dayId] = dayLegs
|
||||
const dfMode = dayDefault(day.dayId)
|
||||
|
||||
const runs = splitIntoRuns(day.stops, (from, to) =>
|
||||
resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
|
||||
dfMode,
|
||||
))
|
||||
|
||||
const dayVias = viasByDay[day.dayId] ?? []
|
||||
|
||||
for (const { stops: run, mode } of runs) {
|
||||
tasks.push(async () => {
|
||||
// Waypoints are the stops with this day's vias threaded in between them, so the
|
||||
// router draws the road the traveller picked. `stopAt` remembers which waypoint
|
||||
// each stop became, because the answer has a leg per waypoint PAIR and the rail
|
||||
// wants one leg per stop pair.
|
||||
const waypoints: { lat: number; lng: number }[] = []
|
||||
const stopAt: number[] = []
|
||||
run.forEach((stop, i) => {
|
||||
stopAt.push(waypoints.length)
|
||||
waypoints.push({ lat: stop.lat, lng: stop.lng })
|
||||
if (i === run.length - 1) return
|
||||
const dayIndex = day.stops.indexOf(stop)
|
||||
dayVias
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
||||
})
|
||||
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
)
|
||||
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
||||
// vias threaded in between are skipped: a via is a shape handle, not a
|
||||
// destination, and a dashed spur hanging off one reads as a fault.
|
||||
if (r.snapped) {
|
||||
const daySnaps = (collectedSnaps[day.dayId] ??= {})
|
||||
run.forEach((stop, i) => {
|
||||
const s = r.snapped?.[stopAt[i]]
|
||||
if (s) daySnaps[stopKey(stop)] = s
|
||||
})
|
||||
}
|
||||
// Where a plugin's halts sit along this run, measured once. Only the ones
|
||||
// that say something get projected — a bare coordinate has nothing to show
|
||||
// in the rail, and projection is not free.
|
||||
const spine = r.coordinates.map(([la, ln]) => ({ lat: la, lng: ln }))
|
||||
const viaAlong = (r.vias ?? [])
|
||||
.filter(v => v.label || v.dwellSeconds != null)
|
||||
.map(v => ({ via: v, alongMeters: (projectOntoRoute({ lat: v.lat, lng: v.lng }, spine)?.alongKm ?? 0) * 1000 }))
|
||||
|
||||
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
|
||||
// with a via between it comes back as two legs, and the rail shows one.
|
||||
// A running metre count comes with it, so each halt lands on the leg it is
|
||||
// actually driven on rather than on the nearest stop, which two towns close
|
||||
// together would get wrong.
|
||||
const legEndMeters: number[] = []
|
||||
const legKeys: string[] = []
|
||||
let travelled = 0
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
if (!from || !to) continue
|
||||
const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
|
||||
if (!parts.length) continue
|
||||
const merged = parts.length === 1 ? parts[0] : {
|
||||
...parts[0],
|
||||
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
||||
}
|
||||
travelled += merged.distance ?? 0
|
||||
legEndMeters.push(travelled)
|
||||
const key = legKey(from, to)
|
||||
legKeys.push(key)
|
||||
dayLegs[key] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [], vias: [] }
|
||||
}
|
||||
for (const { via, alongMeters } of viaAlong) {
|
||||
const idx = legIndexForAlong(legEndMeters, alongMeters)
|
||||
if (idx >= 0) dayLegs[legKeys[idx]].vias.push(via)
|
||||
}
|
||||
return
|
||||
} catch (err) {
|
||||
// Almost always a rate limit on the shared routing host. Back off and try
|
||||
// again; a run that still won't route (island hop, dead router) simply stays
|
||||
// blank, and the totals say so by being partial rather than wrong.
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay === undefined) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
const asked = err instanceof RoutingRefusedError && err.isRateLimit ? err.retryAfterMs : null
|
||||
await sleep(Math.max(delay, asked ?? 0), controller.signal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// One at a time, spaced out. The day sidebar can afford a small pool because it
|
||||
// routes a single day; a road trip is every leg of every day at once, and the
|
||||
// public routing hosts answer that with 429 after the first handful. Results are
|
||||
// published as they land so the rail fills in instead of sitting empty.
|
||||
void (async () => {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (controller.signal.aborted) return
|
||||
const startedAt = performance.now()
|
||||
await tasks[i]()
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
||||
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey stands in for `plan`: same geometry, same legs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, routeProfile, distanceUnit, tripId])
|
||||
|
||||
return useMemo(() => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
const accessLines: RoadtripRoutes['accessLines'] = []
|
||||
// A loop rather than a map, because the range budget outlives a single day: a tank
|
||||
// does not empty overnight, so what is left at the end of one day is what the next
|
||||
// one starts with. `plan` is already in day order.
|
||||
const out: RoadtripDay[] = []
|
||||
let carryKm: number | null = 0
|
||||
for (const day of plan) {
|
||||
const dayLegs = legsByDay[day.dayId] ?? {}
|
||||
const daySnaps = snapByDay[day.dayId] ?? {}
|
||||
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
|
||||
for (const leg of routed) {
|
||||
if (!leg) continue
|
||||
if (leg.line.length > 1) lines.push(leg.line)
|
||||
segments.push(leg.seg)
|
||||
}
|
||||
// The run polylines end to end: leg 0 of a run carries its whole geometry, the
|
||||
// rest carry none, so concatenating what is there gives the day as driven.
|
||||
const geometry = routed.flatMap(l => l?.line ?? [])
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
|
||||
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
|
||||
const schedule = computeSchedule(
|
||||
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
legs.map(l => l?.duration),
|
||||
)
|
||||
const legVias = routed.map(l => l?.vias ?? [])
|
||||
const stops = day.stops.map(s => {
|
||||
const snap = daySnaps[stopKey(s)]
|
||||
const line = spurFor(snap)
|
||||
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
|
||||
return { ...s, offRoadMeters: line ? snap.meters : null }
|
||||
})
|
||||
const drive = deriveDriveWarnings(
|
||||
legs,
|
||||
// One longer than the legs: the last stop of the day counts too, because filling
|
||||
// up on arrival is what makes the next morning start full.
|
||||
stops.map(s => refuelsRange(s.stopType)),
|
||||
limits,
|
||||
carryKm,
|
||||
)
|
||||
carryKm = drive.carryKm
|
||||
out.push({
|
||||
...day, stops, legs, legVias, schedule, geometry, distance, duration,
|
||||
driveWarnings: drive.warnings,
|
||||
dayWarning: drive.day,
|
||||
})
|
||||
}
|
||||
return {
|
||||
days: out,
|
||||
lines,
|
||||
segments,
|
||||
accessLines,
|
||||
vias: out.flatMap(d => d.legVias.flat()),
|
||||
totalDistance: out.reduce((s, d) => s + d.distance, 0),
|
||||
totalDuration: out.reduce((s, d) => s + d.duration, 0),
|
||||
// Service stops are not stops in this sense — a charger on the way is part of the
|
||||
// drive, and counting it here would make the head disagree with the day cards.
|
||||
totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
quietDays,
|
||||
loading,
|
||||
}
|
||||
}, [plan, legsByDay, snapByDay, loading, quietDays, limits])
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { roadtripApi } = vi.hoisted(() => ({
|
||||
roadtripApi: {
|
||||
listVias: vi.fn(),
|
||||
addVia: vi.fn(),
|
||||
moveVia: vi.fn(),
|
||||
removeVia: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../api/client', () => ({ roadtripApi }))
|
||||
|
||||
import { useRoadtripVias } from './useRoadtripVias'
|
||||
|
||||
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
|
||||
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
|
||||
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.removeVia.mockReset().mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('useRoadtripVias', () => {
|
||||
it('FE-ROADTRIP-VIAS-001: asks once for the whole trip, not once per day', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
await waitFor(() => expect(Object.keys(result.current.byDay)).toHaveLength(2))
|
||||
expect(roadtripApi.listVias).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.byDay[1].map(v => v.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-002: with road trip mode off nothing is fetched', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, false))
|
||||
|
||||
await waitFor(() => expect(result.current.byDay).toEqual({}))
|
||||
expect(roadtripApi.listVias).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-003: an addon that is switched off means no vias, not an error', async () => {
|
||||
// Shaped like the rejection axios actually produces, so the branch that
|
||||
// reads the status is the one under test.
|
||||
roadtripApi.listVias.mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
|
||||
expect(result.current.byDay).toEqual({})
|
||||
expect(result.current.stale).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-007: a read that fails for another reason keeps the vias it has', async () => {
|
||||
// This read runs after every write, and the via list feeds the routing key.
|
||||
// Emptying on a 502, a dropped connection or a tab that just went offline
|
||||
// re-routed the whole trip along the roads the traveller had steered away
|
||||
// from — with no toast, nothing retrying, and no way to tell it apart from
|
||||
// having deleted them.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
|
||||
roadtripApi.listVias.mockRejectedValue({ response: { status: 502 } })
|
||||
await act(async () => { await result.current.move(1, 1, 54, 11) })
|
||||
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
expect(result.current.stale).toBe(true)
|
||||
|
||||
// And a read that works again clears the doubt.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
await act(async () => { await result.current.move(1, 1, 54, 11) })
|
||||
expect(result.current.stale).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-004: adding one re-reads the list rather than guessing its id', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledTimes(1))
|
||||
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 5, day_id: 3 })] })
|
||||
await act(async () => { await result.current.add(3, 1, 52, 11) })
|
||||
|
||||
expect(roadtripApi.addVia).toHaveBeenCalledWith(7, 3, { after_order_index: 1, lat: 52, lng: 11 })
|
||||
// The server assigns id and sequence; patching the list by hand would be a second
|
||||
// source of truth for the sake of one round trip.
|
||||
await waitFor(() => expect(result.current.byDay[3]?.[0].id).toBe(5))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-005: moving and removing go to the day they belong to', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
|
||||
|
||||
await act(async () => { await result.current.move(2, 9, 51, 12) })
|
||||
expect(roadtripApi.moveVia).toHaveBeenCalledWith(7, 2, 9, { lat: 51, lng: 12 })
|
||||
|
||||
await act(async () => { await result.current.remove(2, 9) })
|
||||
expect(roadtripApi.removeVia).toHaveBeenCalledWith(7, 2, 9)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-006: without a trip there is nothing to write to', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(null, true))
|
||||
|
||||
await act(async () => { await result.current.add(1, 0, 53, 10) })
|
||||
|
||||
expect(roadtripApi.addVia).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { roadtripApi } from '../../api/client'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
|
||||
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Via points are DELIBERATELY online-only, which the repo asks to be said out
|
||||
* loud rather than left to be discovered.
|
||||
*
|
||||
* Everything else on this screen goes through a repo and the mutation queue:
|
||||
* optimistic Dexie write, temporary negative id, idempotent replay on
|
||||
* reconnect. A via cannot follow that shape as it stands, because its anchor is
|
||||
* a POSITION rather than an id — `after_order_index` counts stops as the routing
|
||||
* request builds them — so a queued write replayed after somebody else has
|
||||
* added or removed a stop would re-pin the drive onto a leg nobody chose, and it
|
||||
* would do so silently, hours later. The correct offline story needs the anchor
|
||||
* to be an assignment id first, which is a change to the wire contract and to
|
||||
* the migration, not to this hook.
|
||||
*
|
||||
* Until then the honest behaviour is to say so: `editable` is false with no
|
||||
* network, so the map stops offering a drag it cannot keep, instead of failing
|
||||
* one write at a time and losing the edit.
|
||||
*/
|
||||
export interface RoadtripVias {
|
||||
/** Every via of the trip, keyed by day. */
|
||||
byDay: Record<number, RoadtripVia[]>
|
||||
/**
|
||||
* Which imported track each day was fitted to, keyed by day.
|
||||
*
|
||||
* The vias are what make the day follow it; this is what lets the day say so. Empty for
|
||||
* a day shaped by hand, and gone by itself when the track is deleted — the row cascades
|
||||
* with the place, so nothing here can name a line that no longer exists.
|
||||
*/
|
||||
trackByDay: Record<number, RoadtripDayTrack>
|
||||
/**
|
||||
* True when the last read failed for a reason other than "there is nothing
|
||||
* here", so what is drawn may be older than what the server holds.
|
||||
*
|
||||
* The read runs after every write, and the via list feeds the routing key, so
|
||||
* emptying on a transient failure re-routed the whole trip along the roads the
|
||||
* traveller had steered away from — indistinguishable from having deleted
|
||||
* them. The list is kept and the staleness is said instead.
|
||||
*/
|
||||
stale: boolean
|
||||
/**
|
||||
* Whether shaping the drive is possible right now.
|
||||
*
|
||||
* False with no network: the domain is online-only (see the note above this
|
||||
* interface), so the surfaces disable the gesture rather than accepting one
|
||||
* and dropping it.
|
||||
*/
|
||||
editable: boolean
|
||||
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise<void>
|
||||
/**
|
||||
* Lay a chain of vias on one day, optionally clearing the legs it fills first.
|
||||
*
|
||||
* One request and one reload for the whole chain. `add` per point would trigger a full
|
||||
* trip re-route between each one, spaced by the routing host's rate limit, so a
|
||||
* twenty-anchor track would spend half a minute drawing routes nobody asked to see.
|
||||
*/
|
||||
addMany: (
|
||||
dayId: number,
|
||||
vias: { after_order_index: number; lat: number; lng: number }[],
|
||||
replaceLegs?: number[],
|
||||
/** Absent leaves the day's track alone, null clears it, an object records a new one. */
|
||||
track?: { place_id: number; stray_km?: number | null } | null,
|
||||
) => Promise<void>
|
||||
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
|
||||
remove: (dayId: number, id: number) => Promise<void>
|
||||
/**
|
||||
* Correct a day's anchors after its stops changed shape.
|
||||
*
|
||||
* Awaited by the caller before it lets the day re-route: the routing effect resolves
|
||||
* `after_order_index` against whatever the stop list looks like at that moment, so a
|
||||
* re-anchoring that lands afterwards is a second, visibly wrong route in between.
|
||||
*/
|
||||
reanchor: (dayId: number, plan: Reanchoring) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY: Record<number, RoadtripVia[]> = {}
|
||||
const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
|
||||
|
||||
/**
|
||||
* The points this trip's drives are routed through.
|
||||
*
|
||||
* Loaded once for the whole trip rather than per day: a road trip routes every day at
|
||||
* once, and one request for the lot beats one per day against a server that has to open
|
||||
* the same table each time.
|
||||
*
|
||||
* Writes are optimistic in the sense that the list is refreshed from the answer, not
|
||||
* patched by hand — a via has a server-assigned id and sequence, and guessing them would
|
||||
* be a second source of truth for the sake of one round trip.
|
||||
*/
|
||||
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
|
||||
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
|
||||
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
|
||||
/**
|
||||
* The list on screen may be older than the server's.
|
||||
*
|
||||
* Set when a read fails for a reason that is not "there is nothing here", so
|
||||
* the rail can say the shaping it is drawing might be out of date rather than
|
||||
* quietly showing a trip that has lost its detours.
|
||||
*/
|
||||
const [stale, setStale] = useState(false)
|
||||
// Read through the app's single source of truth for connectivity, which also
|
||||
// covers the offline switch a user can set with the network still up.
|
||||
const { offline } = useNetworkMode()
|
||||
|
||||
const group = useCallback((vias: RoadtripVia[]) => {
|
||||
const next: Record<number, RoadtripVia[]> = {}
|
||||
for (const v of vias) (next[v.day_id] ??= []).push(v)
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!tripId || !active) { setByDay(EMPTY); setTrackByDay(EMPTY_TRACKS); return }
|
||||
try {
|
||||
const { vias, tracks } = await roadtripApi.listVias(tripId)
|
||||
setByDay(group(vias))
|
||||
const byId: Record<number, RoadtripDayTrack> = {}
|
||||
for (const track of tracks ?? []) byId[track.day_id] = track
|
||||
setTrackByDay(byId)
|
||||
setStale(false)
|
||||
} catch (err) {
|
||||
// An instance with the addon off answers 404 here, and a caller without
|
||||
// the permission 403. Neither is worth reporting: it just means there are
|
||||
// no vias to draw, and emptying is the truthful answer.
|
||||
//
|
||||
// Anything else is not. This read runs after every write, so a 502 from a
|
||||
// proxy, a dropped connection or a tab that has just gone offline used to
|
||||
// empty the map — and because the via list feeds the routing key, the whole
|
||||
// trip was then re-routed along the roads the traveller had steered away
|
||||
// from. Nothing said so, nothing retried, and it looked exactly like
|
||||
// having deleted them. The last known list is kept instead.
|
||||
const status = (err as { response?: { status?: number } } | null)?.response?.status
|
||||
if (status === 404 || status === 403) {
|
||||
setByDay(EMPTY)
|
||||
setTrackByDay(EMPTY_TRACKS)
|
||||
setStale(false)
|
||||
return
|
||||
}
|
||||
setStale(true)
|
||||
}
|
||||
}, [tripId, active, group])
|
||||
|
||||
useEffect(() => { void reload() }, [reload])
|
||||
|
||||
const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const addMany = useCallback(async (
|
||||
dayId: number,
|
||||
vias: { after_order_index: number; lat: number; lng: number }[],
|
||||
replaceLegs?: number[],
|
||||
track?: { place_id: number; stray_km?: number | null } | null,
|
||||
) => {
|
||||
if (!tripId) return
|
||||
// An empty chain with nothing to clear is not a write. It is a real call though, from
|
||||
// a track that thinned down to nothing on a very short day, and from a track that the
|
||||
// drive already followed — which still has a name to record.
|
||||
if (!vias.length && !replaceLegs?.length && track === undefined) return
|
||||
await roadtripApi.addVias(tripId, dayId, {
|
||||
vias,
|
||||
replace_legs: replaceLegs,
|
||||
...(track === undefined ? {} : { track }),
|
||||
})
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const reanchor = useCallback(async (dayId: number, plan: Reanchoring) => {
|
||||
if (!tripId || isEmptyReanchoring(plan)) return
|
||||
await roadtripApi.reanchorVias(tripId, dayId, plan)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const remove = useCallback(async (dayId: number, id: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.removeVia(tripId, dayId, id)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
return { byDay, trackByDay, stale, editable: !offline, add, addMany, move, remove, reanchor }
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { calculateAlternatives, calculateRoute } = vi.hoisted(() => ({
|
||||
calculateAlternatives: vi.fn(),
|
||||
calculateRoute: vi.fn(),
|
||||
}))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateAlternatives, calculateRoute }))
|
||||
|
||||
import { useRouteAlternatives } from './useRouteAlternatives'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* FE-ALTHOOK-001..009 — asking for other ways of driving one leg.
|
||||
*
|
||||
* The case that carries the feature: when the leg already carries vias, the road
|
||||
* actually being driven is NOT among what the router offers for the two bare
|
||||
* endpoints, because it was asked a different question. Without fetching it
|
||||
* separately, opening the picker on a leg you have already reshaped shows three
|
||||
* roads, none of which is the one you are on, and no way back to it.
|
||||
*/
|
||||
|
||||
const from = { lat: 53.55, lng: 9.99 } as RoadtripStop
|
||||
const to = { lat: 52.52, lng: 13.4 } as RoadtripStop
|
||||
const via = (lat: number, lng: number): RoadtripVia =>
|
||||
({ id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat, lng })
|
||||
|
||||
const route = (over: Record<string, unknown> = {}) => ({
|
||||
coordinates: [[53.55, 9.99], [52.52, 13.4]],
|
||||
distance: 290_000,
|
||||
duration: 10_800,
|
||||
divergence: { lat: 53, lng: 11 },
|
||||
...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
calculateAlternatives.mockReset().mockResolvedValue([route(), route({ distance: 310_000, duration: 11_400 })])
|
||||
calculateRoute.mockReset().mockResolvedValue(route({ distance: 340_000, duration: 12_600 }))
|
||||
})
|
||||
|
||||
describe('useRouteAlternatives', () => {
|
||||
it('FE-ALTHOOK-001: nothing is open until somebody asks', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
expect(result.current.open).toBeNull()
|
||||
expect(calculateAlternatives).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-002: asking opens on a loading state, then fills it', async () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
expect(result.current.open).toMatchObject({ dayId: 4, index: 1, loading: true, routes: [] })
|
||||
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
// The router's own first answer is the direct one, which is what "no detour
|
||||
// at all" means when it is chosen.
|
||||
expect(result.current.open?.routes[0].direct).toBe(true)
|
||||
expect(result.current.open?.routes[1].direct).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-003: a bare leg is not asked for the road it is already driving', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
expect(calculateRoute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-004: a bent leg puts the road being driven at the top', async () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.routes[0]).toMatchObject({ current: true, distance: 340_000 })
|
||||
expect(result.current.open?.routes).toHaveLength(3)
|
||||
// Routed through the via, not between the bare ends.
|
||||
expect(calculateRoute).toHaveBeenCalledWith(
|
||||
[{ lat: 53.55, lng: 9.99 }, { lat: 53, lng: 11.5 }, { lat: 52.52, lng: 13.4 }],
|
||||
'driving',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-005: a via that barely moves the route is one road, not two', async () => {
|
||||
// Two lines are the same road if their ends and their length agree closely
|
||||
// enough. Listing it twice offers a choice that is not one.
|
||||
calculateRoute.mockResolvedValue(route({ distance: 290_020, duration: 10_810 }))
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11)]) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
expect(result.current.open?.routes.some(r => r.current)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-006: the driven road failing costs that entry, not the dialog', async () => {
|
||||
calculateRoute.mockRejectedValue(new Error('osrm down'))
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.error).toBe(false)
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-007: a router that will not answer says so instead of showing nothing', async () => {
|
||||
calculateAlternatives.mockRejectedValue(new Error('osrm down'))
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open).toMatchObject({ error: true, routes: [] })
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-008: only driving, walking and cycling reach the router', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
for (const [asked, sent] of [['walking', 'walking'], ['cycling', 'cycling'], ['transit', 'driving'], ['plugin:ferry', 'driving']]) {
|
||||
calculateAlternatives.mockClear()
|
||||
act(() => { result.current.ask(4, 1, from, to, asked, []) })
|
||||
expect(calculateAlternatives.mock.calls[0][2], asked).toBe(sent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-009: asking again abandons the answer still in flight', async () => {
|
||||
// Two clicks in a row must not let the first answer land on top of the
|
||||
// second: the picker would then describe a leg nobody is looking at.
|
||||
const signals: AbortSignal[] = []
|
||||
calculateAlternatives.mockImplementation((_a: unknown, _b: unknown, _p: unknown, opts: { signal: AbortSignal }) => {
|
||||
signals.push(opts.signal)
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
act(() => { result.current.ask(4, 2, from, to, 'driving', []) })
|
||||
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
expect(signals[1].aborted).toBe(false)
|
||||
expect(result.current.open?.index).toBe(2)
|
||||
|
||||
act(() => { result.current.close() })
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
expect(result.current.open).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/** One offered way of driving a leg, with what it means for this trip. */
|
||||
export interface OfferedRoute extends RouteAlternative {
|
||||
/** True for the road currently driven — the one the vias on this leg produce. */
|
||||
current?: boolean
|
||||
/** True for the router's own preference with no vias at all. */
|
||||
direct?: boolean
|
||||
}
|
||||
|
||||
/** Which leg is being reconsidered, and what was offered for it. */
|
||||
export interface LegAlternatives {
|
||||
dayId: number
|
||||
/** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
|
||||
index: number
|
||||
routes: OfferedRoute[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export interface RouteAlternativesState {
|
||||
open: LegAlternatives | null
|
||||
/** Asks the router for the ways of driving this one leg. */
|
||||
ask: (dayId: number, index: number, from: RoadtripStop, to: RoadtripStop, profile: string, vias: RoadtripVia[]) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** Two lines are the same road if their ends and their length agree closely enough. */
|
||||
function sameRoute(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 50 && Math.abs(a.duration - b.duration) < 30
|
||||
}
|
||||
|
||||
/**
|
||||
* Other ways of driving one leg.
|
||||
*
|
||||
* One leg at a time, on demand, and never for the whole trip: OSRM answers with
|
||||
* alternatives only between exactly two coordinates, so asking for a day would mean
|
||||
* breaking up the single request `splitIntoRuns` bundles it into — the optimisation that
|
||||
* makes a road trip appear at once rather than trickle in.
|
||||
*
|
||||
* When the leg already carries vias, the road actually being driven is NOT among what the
|
||||
* router offers for the two bare endpoints — it was asked a different question. So it is
|
||||
* fetched separately and put at the top, marked as the current one. Without that, opening
|
||||
* the picker on a leg you have already reshaped shows you three roads, none of which is
|
||||
* the one you are on, and no way back to it.
|
||||
*/
|
||||
export function useRouteAlternatives(): RouteAlternativesState {
|
||||
const [open, setOpen] = useState<LegAlternatives | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const close = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setOpen(null)
|
||||
}, [])
|
||||
|
||||
const ask = useCallback((
|
||||
dayId: number,
|
||||
index: number,
|
||||
from: RoadtripStop,
|
||||
to: RoadtripStop,
|
||||
profile: string,
|
||||
vias: RoadtripVia[],
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setOpen({ dayId, index, routes: [], loading: true, error: false })
|
||||
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const ends = [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }]
|
||||
|
||||
// The router's offers for the bare leg, plus — only when the leg is already bent —
|
||||
// the road that bend produces, so both are on the table together.
|
||||
const offers = calculateAlternatives(ends[0], ends[1], osrmProfile, { signal: controller.signal })
|
||||
const current = vias.length
|
||||
? calculateRoute(
|
||||
[ends[0], ...vias.map(v => ({ lat: v.lat, lng: v.lng })), ends[1]],
|
||||
osrmProfile,
|
||||
{ signal: controller.signal },
|
||||
).then(r => ({
|
||||
coordinates: r.coordinates,
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
current: true,
|
||||
} as OfferedRoute)).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
void Promise.all([offers, current])
|
||||
.then(([alternatives, driven]) => {
|
||||
if (controller.signal.aborted) return
|
||||
const routes: OfferedRoute[] = alternatives.map((r, i) => ({ ...r, direct: i === 0 }))
|
||||
// Only prepend the driven road when it really is a different one; with a via that
|
||||
// barely moves the route, the two are the same road and one entry is honest.
|
||||
if (driven && !routes.some(r => sameRoute(r, driven))) routes.unshift(driven)
|
||||
setOpen({ dayId, index, routes, loading: false, error: false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
// A router that will not answer is not worth a dialog; the leg keeps the route it
|
||||
// already has and the panel says so.
|
||||
setOpen({ dayId, index, routes: [], loading: false, error: true })
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { open, ask, close }
|
||||
}
|
||||
@@ -104,47 +104,6 @@ describe('CustomSelect', () => {
|
||||
expect(Number.parseInt(list.style.maxHeight, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// A select squeezed into a narrow flex row still has to offer readable options: the
|
||||
// trigger shortens to share its row with the control beside it, the menu does not.
|
||||
it('FE-COMP-SELECT-011: the menu is as wide as its trigger by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
|
||||
expect(panel.style.width).not.toBe('max-content');
|
||||
expect(panel.style.maxWidth).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-SELECT-012: menuFit content grows the menu to its options, bounded twice', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
|
||||
expect(panel.style.width).toBe('max-content');
|
||||
// Never narrower than the trigger, never wider than what the window still has.
|
||||
expect(panel.style.minWidth).not.toBe('');
|
||||
expect(Number.parseInt(panel.style.maxWidth, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SELECT-013: a content-fitted menu stops at the edge of its panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div data-testid="panel">
|
||||
<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />
|
||||
</div>,
|
||||
);
|
||||
// jsdom measures everything as zero, so the panel says how wide it is.
|
||||
const panel = screen.getByTestId('panel');
|
||||
panel.getBoundingClientRect = () => ({ left: 0, right: 180, width: 180, top: 0, bottom: 32, height: 32, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
const menu = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
|
||||
expect(menu.style.maxWidth).toBe('180px');
|
||||
});
|
||||
|
||||
it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
|
||||
|
||||
@@ -24,33 +24,6 @@ interface CustomSelectProps {
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
disabled?: boolean
|
||||
/**
|
||||
* How wide the open menu is.
|
||||
*
|
||||
* 'anchor' (the default) keeps it exactly as wide as the trigger, which is what a
|
||||
* select normally wants. 'content' lets it grow to its longest option instead, for the
|
||||
* cases where the trigger is a narrow flex item and the options are place names: there
|
||||
* the trigger has to shorten to share its row, and a menu that shortened with it would
|
||||
* offer a list of identical prefixes.
|
||||
*/
|
||||
menuFit?: 'anchor' | 'content'
|
||||
}
|
||||
|
||||
/** How wide a content-fitted menu may get before it stops reading as a menu. */
|
||||
const MENU_MAX_WIDTH = 420
|
||||
|
||||
/**
|
||||
* How much room a content-fitted menu has, measured from the trigger's left edge.
|
||||
*
|
||||
* Bounded by the trigger's own container first: a select sits inside a panel, and a menu
|
||||
* that grew past that panel's edge would hang over whatever is beside it — here, the map.
|
||||
* The window is the second bound, for a container that reaches the edge of the screen.
|
||||
*/
|
||||
function menuRoom(anchor: HTMLElement | null, left: number): number {
|
||||
const toWindowEdge = typeof window === 'undefined' ? MENU_MAX_WIDTH : window.innerWidth - left - 8
|
||||
const box = anchor?.parentElement?.getBoundingClientRect()
|
||||
const toPanelEdge = box && box.width > 0 ? box.right - left : Number.POSITIVE_INFINITY
|
||||
return Math.min(MENU_MAX_WIDTH, toWindowEdge, toPanelEdge)
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
@@ -62,7 +35,6 @@ export default function CustomSelect({
|
||||
style = {},
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
menuFit = 'anchor',
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -159,20 +131,9 @@ export default function CustomSelect({
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
...(anchored
|
||||
? {
|
||||
...(anchored.flipped ? { bottom: anchored.bottom } : { top: anchored.top }),
|
||||
left: anchored.left,
|
||||
// Never narrower than the trigger, and never past the right-hand edge of
|
||||
// the window. The cap is read at render time rather than measured, and
|
||||
// that is enough: every reason the anchor moved already re-renders this.
|
||||
...(menuFit === 'content'
|
||||
? {
|
||||
minWidth: anchored.width,
|
||||
width: 'max-content',
|
||||
maxWidth: Math.max(anchored.width, menuRoom(ref.current, anchored.left)),
|
||||
}
|
||||
: { width: anchored.width }),
|
||||
}
|
||||
? anchored.flipped
|
||||
? { bottom: anchored.bottom, left: anchored.left, width: anchored.width }
|
||||
: { top: anchored.top, left: anchored.left, width: anchored.width }
|
||||
: { top: 0, left: 0, width: 200 }),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function EmptyState({
|
||||
size = 104,
|
||||
surface = 'var(--bg-card)',
|
||||
layout = 'stack',
|
||||
compact = false,
|
||||
className = '',
|
||||
action,
|
||||
}: {
|
||||
@@ -32,14 +31,6 @@ export default function EmptyState({
|
||||
size?: number
|
||||
surface?: string
|
||||
layout?: 'stack' | 'row'
|
||||
/**
|
||||
* Quieter type for a state inside a narrow column rather than on a page.
|
||||
*
|
||||
* The sidebar's empty state sits under its own controls, so a line at the page size
|
||||
* competes with them for the eye; at the caption tier it reads as what it is, a note
|
||||
* about why the list below is blank.
|
||||
*/
|
||||
compact?: boolean
|
||||
className?: string
|
||||
/** Optional call to action under the title, for states that have an obvious next step. */
|
||||
action?: ReactNode
|
||||
@@ -53,7 +44,7 @@ export default function EmptyState({
|
||||
style={{ '--m-ink': 'var(--text-primary)', '--m-bg': surface } as CSSProperties}
|
||||
>
|
||||
<MDancingTrek scene={scene} mood={mood} size={size} />
|
||||
<p className={compact ? 'text-caption text-content-muted' : 'text-[15px] font-semibold text-content-secondary'}>{title}</p>
|
||||
<p className="text-[15px] font-semibold text-content-secondary">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -68,14 +68,6 @@ export interface SyncMeta {
|
||||
tilesBbox: [number, number, number, number] | null;
|
||||
/** Non-photo files available offline for this trip after the last sync. */
|
||||
filesCachedCount: number;
|
||||
/**
|
||||
* The rounded bbox the cached area places were fetched for. Compared rather
|
||||
* than a timestamp: adding a place inside the area the trip already covers
|
||||
* should not re-download it, and moving the trip should.
|
||||
*
|
||||
* Optional so a row written before this landed still reads.
|
||||
*/
|
||||
areaPlacesKey?: string;
|
||||
}
|
||||
|
||||
export interface BlobCacheEntry {
|
||||
@@ -94,33 +86,6 @@ export interface BlobCacheEntry {
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A place from the TREK Places index, cached for the area of one trip.
|
||||
*
|
||||
* NOT a trip place — those are in `places` and belong to the user. These are
|
||||
* candidates: the shops, restaurants and stations that happen to be near where
|
||||
* the trip goes, taken once so search still answers on a plane. They are
|
||||
* disposable and are re-fetched whenever the trip's area changes.
|
||||
*
|
||||
* The key is the GERS id, which is stable across index rebuilds; `tripId` is
|
||||
* indexed so the whole set can be dropped with the trip.
|
||||
*/
|
||||
export interface CachedAreaPlace {
|
||||
/** GERS id, without the `gers:` prefix the server puts on `osm_id`. */
|
||||
gers: string;
|
||||
tripId: number;
|
||||
name: string;
|
||||
/** Lowercased, diacritics folded — what an offline search actually matches on. */
|
||||
searchName: string;
|
||||
address: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
category: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** An uploaded booking-import source file, kept so the review flow can attach it to the
|
||||
* created bookings even after a page reload during the (background) parse. Keyed by job. */
|
||||
export interface ImportSourceFile {
|
||||
@@ -176,7 +141,6 @@ class TrekOfflineDb extends Dexie {
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
importFiles!: Table<ImportSourceFile, [string, string]>;
|
||||
areaPlaces!: Table<CachedAreaPlace, string>;
|
||||
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
@@ -217,13 +181,6 @@ class TrekOfflineDb extends Dexie {
|
||||
this.version(4).stores({
|
||||
importFiles: '[jobId+fileName], jobId, createdAt',
|
||||
});
|
||||
|
||||
// v5: places from the TREK Places index for the trip's area, so search
|
||||
// answers offline. `searchName` is indexed because that is what an offline
|
||||
// query filters on; `tripId` so the set drops with its trip.
|
||||
this.version(5).stores({
|
||||
areaPlaces: 'gers, tripId, searchName',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +404,6 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
offlineDb.areaPlaces,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
@@ -463,11 +419,6 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||
// The cached places around this trip's area go with it. They are searched
|
||||
// across every trip, so leaving them behind kept a switched-off trip
|
||||
// answering offline searches — and nothing else ever deleted them, so they
|
||||
// accumulated for the life of the install.
|
||||
await offlineDb.areaPlaces.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
);
|
||||
// Remove the trip row itself outside the transaction since it's a separate table
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// FE-HOOK-LOCBIAS-001 to FE-HOOK-LOCBIAS-008
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import { boxFromCoords, pointFromBox, radiusKm, useLocationBias } from './useLocationBias';
|
||||
|
||||
// Coordinates of real places, because the whole point of the hint is distance
|
||||
// and a made-up grid would not show when a box stops being useful.
|
||||
const KYOTO_A = { id: 1, lat: 35.0116, lng: 135.7681 }; // Kyoto Bahnhof
|
||||
const KYOTO_B = { id: 2, lat: 34.9949, lng: 135.7850 }; // Kiyomizu-dera, ~2 km
|
||||
const TOKIO = { id: 3, lat: 35.6762, lng: 139.6503 }; // ~370 km entfernt
|
||||
|
||||
function seed(state: Record<string, unknown>) {
|
||||
useTripStore.setState({ places: [], assignments: {}, selectedDayId: null, ...state } as never);
|
||||
}
|
||||
|
||||
afterEach(() => seed({}));
|
||||
|
||||
describe('boxFromCoords', () => {
|
||||
it('ignoriert Eintraege ohne brauchbare Koordinaten', () => {
|
||||
const box = boxFromCoords([KYOTO_A, { lat: null, lng: null }, { lat: 'x', lng: 'y' }]);
|
||||
expect(box).toEqual({ low: { lat: KYOTO_A.lat, lng: KYOTO_A.lng }, high: { lat: KYOTO_A.lat, lng: KYOTO_A.lng } });
|
||||
});
|
||||
|
||||
it('gibt nichts zurueck, wenn keine Koordinate brauchbar ist', () => {
|
||||
expect(boxFromCoords([{ lat: null, lng: null }])).toBeUndefined();
|
||||
expect(boxFromCoords([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointFromBox', () => {
|
||||
it('legt den Punkt in die Mitte und deckt die Box mit dem Radius ab', () => {
|
||||
const punkt = pointFromBox(boxFromCoords([KYOTO_A, KYOTO_B]));
|
||||
expect(punkt!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6);
|
||||
expect(punkt!.lng).toBeCloseTo((KYOTO_A.lng + KYOTO_B.lng) / 2, 6);
|
||||
// gut 2 km Diagonale, also rund 1 km Radius — angehoben auf die Untergrenze
|
||||
expect(punkt!.radius).toBe(5000);
|
||||
});
|
||||
|
||||
it('haelt eine Untergrenze ein, damit ein einzelner Ort die Nachbarschaft meint', () => {
|
||||
// Ein Punkt ohne Ausdehnung wuerde sonst mit Radius 0 auf die Tuerschwelle zeigen.
|
||||
expect(pointFromBox(boxFromCoords([KYOTO_A]))!.radius).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLocationBias', () => {
|
||||
it('gibt ohne Orte keinen Hinweis', () => {
|
||||
seed({});
|
||||
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
|
||||
});
|
||||
|
||||
it('nimmt die Orte des geoeffneten Tages, nicht die der ganzen Reise', () => {
|
||||
// Die Reise reicht von Kyoto bis Tokio; der offene Tag spielt in Kyoto.
|
||||
seed({
|
||||
places: [KYOTO_A, KYOTO_B, TOKIO],
|
||||
assignments: { '7': [{ place_id: 1 }, { place_id: 2 }] },
|
||||
selectedDayId: 7,
|
||||
});
|
||||
const { box } = renderHook(() => useLocationBias()).result.current;
|
||||
expect(box).toBeDefined();
|
||||
// Tokio darf nicht in der Box liegen, sonst zeigt der Hinweis ins Leere.
|
||||
expect(box!.high.lng).toBeLessThan(TOKIO.lng);
|
||||
expect(radiusKm(box!)).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('faellt auf die Reise zurueck, solange sie in eine Region passt', () => {
|
||||
seed({ places: [KYOTO_A, KYOTO_B] });
|
||||
const { box, point } = renderHook(() => useLocationBias()).result.current;
|
||||
expect(box).toBeDefined();
|
||||
expect(point!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6);
|
||||
});
|
||||
|
||||
it('verwirft einen Hinweis, der ueber eine ganze Rundreise spannt', () => {
|
||||
// Gemessen: eine Box ueber Kyoto UND Tokio kostet mehr Treffer auf Platz 1
|
||||
// als sie einbringt. Kein Hinweis ist dann die bessere Antwort.
|
||||
seed({ places: [KYOTO_A, TOKIO] });
|
||||
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
|
||||
});
|
||||
|
||||
it('verwirft die zu weite Reise-Box auch dann, wenn ein Tag gewaehlt aber leer ist', () => {
|
||||
seed({ places: [KYOTO_A, TOKIO], assignments: { '7': [] }, selectedDayId: 7 });
|
||||
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
|
||||
/**
|
||||
* Where the user is planning right now, as a hint for the place search.
|
||||
*
|
||||
* Without it the search cannot tell which of a thousand identically named
|
||||
* places is meant: "Hase-dera" returns the temple in Nara rather than the one
|
||||
* in Kamakura, and a common single word is refused by the index as too
|
||||
* expensive to answer, which drops the search back to Nominatim alone.
|
||||
*
|
||||
* The hint has to be NARROW, and that is the part that is easy to get wrong.
|
||||
* Measured over 126 places of a real trip through Japan, searched against a
|
||||
* live instance:
|
||||
*
|
||||
* no hint at all 54.8% found, 38.1% at rank 1
|
||||
* box around the WHOLE trip 57.1% found, 17.5% at rank 1
|
||||
* box around the nearest few places 70.6% found, 34.9% at rank 1
|
||||
*
|
||||
* A box spanning a whole round trip has its centre somewhere between the
|
||||
* cities, points at nothing, and costs more rank-1 hits than it gains. It is
|
||||
* worse than no hint. So the day being planned comes first, and a hint that
|
||||
* ends up wider than a metropolitan area is dropped rather than sent.
|
||||
*/
|
||||
|
||||
export interface LocationBiasBox {
|
||||
low: { lat: number; lng: number }
|
||||
high: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
export interface LocationBiasPoint {
|
||||
lat: number
|
||||
lng: number
|
||||
radius?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Widest hint still worth sending, as a radius. Roughly a metropolitan area:
|
||||
* far enough to cover a city and its surroundings, narrow enough that its
|
||||
* centre still means something. The old value here was 500km of diagonal,
|
||||
* which is how the whole-trip box slipped through.
|
||||
*/
|
||||
const MAX_RADIUS_KM = 60
|
||||
|
||||
const KM_PER_DEGREE = 111
|
||||
|
||||
interface Coord { lat?: number | string | null; lng?: number | string | null }
|
||||
|
||||
export function boxFromCoords(coords: Coord[]): LocationBiasBox | undefined {
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of coords) {
|
||||
// Number(null) is 0, and 0/0 is a real coordinate in the Gulf of Guinea.
|
||||
// A place without coordinates would otherwise stretch the box across half
|
||||
// the planet and the hint would be dropped for being too wide.
|
||||
if (p.lat == null || p.lng == null || p.lat === '' || p.lng === '') continue
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}
|
||||
|
||||
/** Half the diagonal of a box, in kilometres — how far the hint reaches. */
|
||||
export function radiusKm(box: LocationBiasBox): number {
|
||||
const lat = (box.low.lat + box.high.lat) / 2
|
||||
const dLat = box.high.lat - box.low.lat
|
||||
const dLng = box.high.lng - box.low.lng
|
||||
return Math.sqrt(
|
||||
(dLat * KM_PER_DEGREE) ** 2 + (dLng * KM_PER_DEGREE * Math.cos(lat * (Math.PI / 180))) ** 2,
|
||||
) / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The centre of a box, with the radius that covers it.
|
||||
*
|
||||
* Autocomplete takes a box, search takes a point — same hint, two shapes,
|
||||
* because the two upstream APIs were built years apart. The radius has a floor
|
||||
* so a day whose places sit on one street still biases toward the
|
||||
* neighbourhood rather than the doorstep.
|
||||
*/
|
||||
export function pointFromBox(box: LocationBiasBox | undefined): LocationBiasPoint | undefined {
|
||||
if (!box) return undefined
|
||||
return {
|
||||
lat: (box.low.lat + box.high.lat) / 2,
|
||||
lng: (box.low.lng + box.high.lng) / 2,
|
||||
// Google caps a Text Search bias circle at 50 km and rejects a wider one
|
||||
// outright, so the box for a trip spread over more than that is clamped
|
||||
// rather than sent and refused. A bias is a hint about where to look; the
|
||||
// hint is no worse for being drawn at the largest radius the API accepts.
|
||||
radius: Math.min(50_000, Math.round(Math.max(radiusKm(box), 5) * 1000)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hint for the place search, in both shapes the two endpoints want.
|
||||
*
|
||||
* Preference order, narrowest first:
|
||||
* 1. the places of the day currently open — this is the area being planned
|
||||
* 2. the trip's places, but only if they still fit inside one region
|
||||
* Anything wider is dropped: no hint beats a hint pointing at open country.
|
||||
*/
|
||||
export function useLocationBias(): { box?: LocationBiasBox; point?: LocationBiasPoint } {
|
||||
const places = useTripStore((s) => s.places)
|
||||
const assignments = useTripStore((s) => s.assignments)
|
||||
const selectedDayId = useTripStore((s) => s.selectedDayId)
|
||||
|
||||
return useMemo(() => {
|
||||
const alle = places || []
|
||||
|
||||
// The day being planned. Places reach a day through assignments, so the
|
||||
// ids come from there and the coordinates from the place pool.
|
||||
let quelle: Coord[] = []
|
||||
if (selectedDayId != null) {
|
||||
const desTages = new Set(
|
||||
(assignments?.[String(selectedDayId)] || []).map((a) => a.place_id),
|
||||
)
|
||||
if (desTages.size > 0) quelle = alle.filter((p) => desTages.has(Number(p.id)))
|
||||
}
|
||||
if (quelle.length === 0) quelle = alle
|
||||
|
||||
const box = boxFromCoords(quelle)
|
||||
if (!box || radiusKm(box) > MAX_RADIUS_KM) return {}
|
||||
return { box, point: pointFromBox(box) }
|
||||
}, [places, assignments, selectedDayId])
|
||||
}
|
||||
@@ -21,9 +21,9 @@ const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
|
||||
bicycle: 'cycling',
|
||||
}
|
||||
|
||||
// Beyond this straight-line distance a single leg of a car/taxi/bike (or even coach)
|
||||
// booking is almost always a data quirk or an inter-continental hop the road router
|
||||
// can't resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
|
||||
// almost always a data quirk or an inter-continental hop the road router can't
|
||||
// resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
// Shared with the day-route builder, which draws the same conclusion about a
|
||||
// booking endpoint that landed next to a local stop (#2133).
|
||||
const MAX_ROUTE_KM = MAX_DRIVE_KM
|
||||
@@ -60,14 +60,9 @@ export function useTransportRoutes(reservations: Reservation[]): Map<number, [nu
|
||||
if (!profile) continue
|
||||
const wps = orderedWaypoints(r)
|
||||
if (wps.length < 2) continue
|
||||
// Per leg, not over the whole booking: a road trip with stops is long by design
|
||||
// and its total would trip a limit meant to catch a single impossible hop. It is
|
||||
// one leg spanning an ocean that the road router cannot resolve, not the sum (#1797).
|
||||
let tooFar = false
|
||||
for (let i = 0; i < wps.length - 1; i++) {
|
||||
if (haversineKm(wps[i], wps[i + 1]) > MAX_ROUTE_KM) { tooFar = true; break }
|
||||
}
|
||||
if (tooFar) continue
|
||||
let dist = 0
|
||||
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
|
||||
if (dist > MAX_ROUTE_KM) continue
|
||||
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
|
||||
if (attempted.get(r.id) === key) continue
|
||||
attempted.set(r.id, key)
|
||||
|
||||
@@ -42,13 +42,6 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.journey-feed-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Road trip rail: hide the scrollbar of the day list. The totals card is pinned
|
||||
above the list and therefore outside its scroll box, so a visible scrollbar
|
||||
would take width from the day cards alone and leave the two stacks a few
|
||||
pixels out of line — the one thing a column of nested cards cannot hide. */
|
||||
.roadtrip-rail-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.roadtrip-rail-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Journey video: let the player fill the box the lightbox gives it.
|
||||
*
|
||||
* Plyr sizes its wrapper from the video's intrinsic aspect ratio via a
|
||||
|
||||
@@ -25,12 +25,11 @@ interface MAdminSettingsSectionProps {
|
||||
export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
|
||||
const {
|
||||
toast,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled,
|
||||
placesPhotosEnabled, setPlacesPhotosEnabledState,
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
@@ -345,26 +344,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
title={t('admin.placeShadow.title')}
|
||||
hint={t('admin.placeShadow.subtitle')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={placeShadowEnabled}
|
||||
ariaLabel={t('admin.placeShadow.title')}
|
||||
onChange={async (next) => {
|
||||
setPlaceShadowEnabledState(next)
|
||||
setPlaceShadowEnabled(next)
|
||||
try {
|
||||
await adminApi.updatePlaceShadow(next)
|
||||
} catch {
|
||||
setPlaceShadowEnabledState(!next)
|
||||
setPlaceShadowEnabled(!next)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo weather info */}
|
||||
|
||||
@@ -84,7 +84,6 @@ export default function ImpListStep({ planner, onBack, onDone }: ImpListStepProp
|
||||
|
||||
<div className="mt-3 font-geist text-[0.71875rem] leading-[1.45] text-m-muted">
|
||||
{t(provider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
{provider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
|
||||
</div>
|
||||
|
||||
<input
|
||||
|
||||
@@ -16,7 +16,6 @@ import PlTimeFields from './PlTimeFields'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import type { Place } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { useLocationBias } from '../../../../hooks/useLocationBias'
|
||||
|
||||
export interface MPlaceEditSheetProps {
|
||||
planner: TripPlanner
|
||||
@@ -147,10 +146,25 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPlaceForm, editingPlace, prefillCoords, editingAssignmentId])
|
||||
|
||||
// The area being planned, as a hint for search and autocomplete. Same helper
|
||||
// as the desktop dialog: the day currently open first, the whole trip only
|
||||
// while it still fits inside one region.
|
||||
const { box: locationBias } = useLocationBias()
|
||||
// Trip-centre bias for search/autocomplete, skipped past ~500 km diagonal.
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter(p => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt(((maxLat - minLat) * 111) ** 2 + ((maxLng - minLng) * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
|
||||
const handleChange = (field: keyof PlaceFormData, value: string) => {
|
||||
// Typed by hand, so the next pick must leave it alone.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../../../utils/apiError'
|
||||
import { pointFromBox } from '../../../../hooks/useLocationBias'
|
||||
import { FIELD_CLS } from './PlSheetChrome'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
@@ -72,10 +70,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// One Google billing session per search (see utils/placesSession).
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
// What produced the list on screen, for the shadow log. Refs, because a pick
|
||||
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -92,7 +86,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
abortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query: input, source: result.source || 'unknown' }
|
||||
setSuggestions(result.suggestions || [])
|
||||
} catch (err: unknown) {
|
||||
// Superseded request — axios rejects an aborted call with CanceledError.
|
||||
@@ -117,32 +110,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
}, [query, fetchSuggestions])
|
||||
|
||||
/**
|
||||
* `pick` is present only when the place came out of a ranked list, so a
|
||||
* coordinate paste or a resolved Google URL contributes no row.
|
||||
*/
|
||||
const applyPlace = (place: MapsPlace, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
const applyPlace = (place: MapsPlace) => {
|
||||
onPick(placeToPick(place))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
const lat = Number(place.lat)
|
||||
const lng = Number(place.lng)
|
||||
if (meta && Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: String(place.name ?? ''),
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: (place.google_place_id as string) || (place.osm_id as string) || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
setResults([])
|
||||
setSuggestions([])
|
||||
setQuery('')
|
||||
@@ -178,10 +147,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
return
|
||||
}
|
||||
}
|
||||
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
|
||||
// braucht ihn genauso, nur als Punkt statt als Kasten.
|
||||
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias))
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
const result = await mapsApi.search(trimmed, language)
|
||||
setResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -192,9 +158,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = suggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = suggestions.length
|
||||
setSuggestions([])
|
||||
const previousQuery = query
|
||||
setQuery('')
|
||||
@@ -213,11 +176,11 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
if (!place) {
|
||||
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias))
|
||||
const search = await mapsApi.search(fullQuery, language)
|
||||
place = (search.places?.[0] as MapsPlace | undefined) ?? null
|
||||
}
|
||||
if (place) {
|
||||
applyPlace(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
applyPlace(place)
|
||||
} else {
|
||||
setQuery(previousQuery)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -284,7 +247,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => applyPlace(result, { mode: 'search', rank: idx, count: results.length })}
|
||||
onClick={() => applyPlace(result)}
|
||||
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
|
||||
>
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
|
||||
@@ -517,65 +517,6 @@ describe('AdminPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-023b: Transit provider select in Settings tab (#1699)', () => {
|
||||
it('choosing Google calls PUT /api/admin/transit-provider', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/admin/transit-provider', () =>
|
||||
HttpResponse.json({ provider: 'transitous', googleKeySource: 'instance' })),
|
||||
http.put('/api/admin/transit-provider', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' });
|
||||
})
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const select = await screen.findByRole('combobox', { name: /transit provider/i });
|
||||
await waitFor(() => expect(select).toHaveValue('transitous'));
|
||||
fireEvent.change(select, { target: { value: 'google' } });
|
||||
|
||||
await waitFor(() => expect(capturedBody).toEqual({ provider: 'google' }));
|
||||
expect(select).toHaveValue('google');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-023c: Transit provider key warnings (#1699)', () => {
|
||||
async function openSettingsWith(googleKeySource: string | null) {
|
||||
server.use(
|
||||
http.get('/api/admin/transit-provider', () =>
|
||||
HttpResponse.json({ provider: 'google', googleKeySource })),
|
||||
);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
await screen.findByRole('combobox', { name: /transit provider/i });
|
||||
}
|
||||
|
||||
it('warns that Google is selected but no key is configured', async () => {
|
||||
await openSettingsWith(null);
|
||||
expect(await screen.findByText(/no google api key is configured/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns that only the admin\'s own key is set, so others fall back', async () => {
|
||||
await openSettingsWith('user-row');
|
||||
expect(await screen.findByText(/only your own google key is set/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when an instance-wide key resolves', async () => {
|
||||
await openSettingsWith('instance');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('combobox', { name: /transit provider/i })).toHaveValue('google'));
|
||||
expect(screen.queryByText(/no google api key is configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/only your own google key is set/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-024: JWT rotation modal opens from Danger Zone', () => {
|
||||
it('clicking Rotate in Danger Zone opens the JWT rotation confirmation modal', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
@@ -1091,7 +1032,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for the API Keys section to appear
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Type in the maps key field (type="password" by default)
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
@@ -1122,7 +1063,7 @@ describe('AdminPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// The Unsplash key is the second 'Enter key...' input (after Maps).
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
@@ -1155,15 +1096,15 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for the API Keys section
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Type a key value to enable the Test button
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
fireEvent.change(keyInputs[0], { target: { value: 'test-maps-key' } });
|
||||
|
||||
// The maps key is the only one with a Test button now: weather left the card
|
||||
// when it stopped needing a key at all.
|
||||
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^test$/i }));
|
||||
// Click the validate (Test) button for maps key — first "Test" button in the card
|
||||
const testBtns = within(apiKeysCard!).getAllByRole('button', { name: /^test$/i });
|
||||
fireEvent.click(testBtns[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// After validation, valid indicator appears (admin.keyValid = 'Connected')
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
@@ -67,13 +66,6 @@ const ExpenseModal = lazyWithRetry(() =>
|
||||
import('../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
|
||||
)
|
||||
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
|
||||
const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
|
||||
const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel'))
|
||||
const RoadtripLimitsCard = lazyWithRetry(() => import('../components/Roadtrip/RoadtripLimitsCard'))
|
||||
const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup'))
|
||||
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
|
||||
const RoadtripTrackModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripTrackModal'))
|
||||
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
|
||||
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
|
||||
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
|
||||
// — about 200 kB of packed zone geometry that every trip used to load.
|
||||
@@ -251,8 +243,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth,
|
||||
@@ -262,15 +252,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
|
||||
@@ -287,7 +268,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
@@ -318,9 +299,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
||||
// The corridor search draws into the same map channel and answers the same question for
|
||||
// a drive, so the explore pill stands down while road trip mode is on.
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
@@ -394,15 +373,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tripId={tripId}
|
||||
places={mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripRoutes.lines : route}
|
||||
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
route={route}
|
||||
routeVias={routeVias}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
// The route toggle belongs to one day, so the map needs that day to
|
||||
// know which automated transports may ride it (#2019).
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
routeSegments={roadtripActive ? roadtripRoutes.segments : routeSegments}
|
||||
routeSegments={routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
@@ -423,47 +401,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={roadtripActive
|
||||
/* `visible`, not `search.results`: the map is the picture of this very
|
||||
list, and filtering the list while seventy pins stay on the map no
|
||||
longer answers "which of these". */
|
||||
? roadtripCorridor.visible
|
||||
: poi.pois}
|
||||
onPoiClick={handlePoiClick}
|
||||
// Only while road trip mode is on: outside it there is no drive to drop onto.
|
||||
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
|
||||
// Clicking the drawn route puts a via there; only in road trip mode, where
|
||||
// the route is the thing being worked on.
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={alternativeFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
onHighlightAlternative={setHighlightedAlternative}
|
||||
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
|
||||
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{/* Over the map rather than in a dialog: the answer to "which of these" is the
|
||||
roads drawn behind it, so covering them to ask would hide the point. */}
|
||||
{routeAlternatives.open && (
|
||||
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
|
||||
<LazyPanel id="roadtrip-alternatives">
|
||||
<RoadtripAlternativesBar
|
||||
open={routeAlternatives.open}
|
||||
overlays={alternativeOverlays}
|
||||
onChoose={chooseRouteAlternative}
|
||||
onClose={routeAlternatives.close}
|
||||
onHighlight={setHighlightedAlternative}
|
||||
/>
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
@@ -525,27 +468,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftHidden ? 0 : 1,
|
||||
}}>
|
||||
{enabledAddons.roadtrip && (
|
||||
<RoadtripModeSwitch active={roadtripMode} onChange={(v) => { if (v !== roadtripMode) toggleRoadtripMode() }} />
|
||||
)}
|
||||
{enabledAddons.roadtrip && roadtripMode ? (
|
||||
<LazyPanel id="roadtrip-rail">
|
||||
<RoadtripSidebar
|
||||
routes={roadtripRoutes}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
|
||||
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
|
||||
viaCounts={roadtripViaCounts}
|
||||
trackNames={followTrack.namesByDay}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
tripId={tripId}
|
||||
@@ -597,7 +519,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
|
||||
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
|
||||
/>
|
||||
)}
|
||||
{!leftHidden && !narrowPanels && (
|
||||
<div
|
||||
role="presentation"
|
||||
@@ -648,23 +569,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
{roadtripActive ? (
|
||||
<LazyPanel id="roadtrip-corridor">
|
||||
{/* No Add button for someone who may not add: openAddPlaceFromPoi
|
||||
returns silently without the permission, which reads as a broken
|
||||
button rather than a missing one. */}
|
||||
<RoadtripCorridorPanel
|
||||
corridor={roadtripCorridor}
|
||||
routes={roadtripRoutes}
|
||||
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
|
||||
/>
|
||||
{/* Under the search, because the limits are read while looking at
|
||||
what the drive is doing rather than set up front. */}
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
|
||||
</div>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
@@ -684,7 +588,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
days={days}
|
||||
isMobile={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -936,7 +839,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill surface="trip-tab" className="w-full h-full" />
|
||||
@@ -944,37 +846,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The small way in for something found along the drive. Mounted only while a
|
||||
draft exists, so the chunk stays unloaded for anyone not using road trip mode. */}
|
||||
{/* How long a stop takes. Its own gate, not the corridor draft's: a stay is set on
|
||||
any stop of the trip, not only on something just found along the route. */}
|
||||
{/* Which track a day drives along. Mounted only while it is open, so the chunk and
|
||||
the parsing of every imported line stay out of an ordinary planner session. */}
|
||||
{followTrack.dayId !== null && (
|
||||
<LazyPanel id="roadtrip-track">
|
||||
<RoadtripTrackModal
|
||||
follow={followTrack}
|
||||
dayNumber={roadtripRoutes.days.find(d => d.dayId === followTrack.dayId)?.dayNumber ?? 0}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stayDraft && (
|
||||
<LazyPanel id="roadtrip-stay">
|
||||
<RoadtripStayModal stop={stayDraft} onClose={() => setStayDraft(null)} onSave={setRoadtripStay} />
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stopDraft && (
|
||||
<LazyPanel id="roadtrip-stop">
|
||||
<RoadtripStopPopup
|
||||
draft={stopDraft}
|
||||
duplicateName={stopDraftDuplicate}
|
||||
onClose={() => setStopDraft(null)}
|
||||
onSave={saveStopDraft}
|
||||
onSaveNight={saveStopDraftAsNight}
|
||||
onMoreDetails={stopDraftToForm}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
|
||||
<TripFormModal
|
||||
isOpen={showTripForm}
|
||||
|
||||
@@ -141,39 +141,6 @@ function baseState(): HookState {
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
tripAccommodations: [],
|
||||
setTripAccommodations: vi.fn(),
|
||||
// Road trip mode off, which is the state every assertion below reads: the page keeps
|
||||
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
|
||||
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
|
||||
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
|
||||
roadtripMode: false,
|
||||
toggleRoadtripMode: vi.fn(),
|
||||
roadtripActive: false,
|
||||
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
|
||||
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
|
||||
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
setStopDraft: vi.fn(),
|
||||
saveStopDraft: vi.fn(async () => undefined),
|
||||
stopDraftToForm: vi.fn(),
|
||||
stopDraftDuplicate: null,
|
||||
reorderRoadtripStop: vi.fn(async () => undefined),
|
||||
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
|
||||
addRoadtripVia: vi.fn(async () => undefined),
|
||||
moveRoadtripVia: vi.fn(async () => undefined),
|
||||
removeRoadtripVia: vi.fn(async () => undefined),
|
||||
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
|
||||
askRouteAlternatives: vi.fn(async () => undefined),
|
||||
chooseRouteAlternative: vi.fn(async () => undefined),
|
||||
alternativeOverlays: [],
|
||||
alternativeFocusPoints: [],
|
||||
stayDraft: null,
|
||||
setStayDraft: vi.fn(),
|
||||
setRoadtripStay: vi.fn(async () => undefined),
|
||||
highlightedAlternative: null,
|
||||
setHighlightedAlternative: vi.fn(),
|
||||
moveRoadtripStopToDay: vi.fn(async () => undefined),
|
||||
dropPoiOnRoute: vi.fn(async () => undefined),
|
||||
allowedFileTypes: 'pdf',
|
||||
tripMembers: [],
|
||||
setTripMembers: vi.fn(),
|
||||
|
||||
@@ -374,15 +374,11 @@ describe('AdminSettingsTab', () => {
|
||||
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
|
||||
it('FE-ADMSET-031: shows the Open-Meteo info block', () => {
|
||||
renderTab();
|
||||
|
||||
const keys = card('API Keys');
|
||||
expect(within(keys).getByRole('img', { name: 'TREK Places API' })).toBeInTheDocument();
|
||||
expect(within(keys).getByText('Recommended default')).toBeInTheDocument();
|
||||
// Weather needs no key, so it left a card about keys. The panel itself lives
|
||||
// on in the mobile section, where FE-MOB-ASET-019 asserts it.
|
||||
expect(screen.queryByText('Weather Data')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Weather Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('16-day forecast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-032: the API keys save button calls handleSaveApiKeys', () => {
|
||||
@@ -493,51 +489,4 @@ describe('AdminSettingsTab', () => {
|
||||
|
||||
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
describe('FE-ADMSET-041: the index switch', () => {
|
||||
// The one control on this card about egress rather than about spending:
|
||||
// with the index on, every search and every keystroke in the place box
|
||||
// leaves the instance. The flag was read by six call paths and written by
|
||||
// nothing, so before this there was no way to turn it off short of an
|
||||
// INSERT against app_settings by hand.
|
||||
const switchFor = () =>
|
||||
screen.getByRole('button', { name: /trek place index/i });
|
||||
|
||||
it('turns the index off and tells the server', async () => {
|
||||
let sent: unknown = null;
|
||||
server.use(
|
||||
http.put('/api/admin/trek-places', async ({ request }) => {
|
||||
sent = await request.json();
|
||||
return HttpResponse.json({ enabled: false });
|
||||
}),
|
||||
);
|
||||
const admin = renderTab({ trekPlacesEnabled: true });
|
||||
|
||||
expect(switchFor()).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(switchFor());
|
||||
|
||||
expect(admin.setTrekPlacesEnabledState).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => expect(sent).toEqual({ enabled: false }));
|
||||
});
|
||||
|
||||
it('rolls back when the save fails, instead of showing a state the server never took', async () => {
|
||||
server.use(http.put('/api/admin/trek-places', () => HttpResponse.error()));
|
||||
const admin = renderTab({ trekPlacesEnabled: true });
|
||||
|
||||
fireEvent.click(switchFor());
|
||||
|
||||
await waitFor(() => expect(admin.setTrekPlacesEnabledState).toHaveBeenLastCalledWith(true));
|
||||
});
|
||||
|
||||
it('says what answers instead, but only while it is off', () => {
|
||||
renderTab({ trekPlacesEnabled: false });
|
||||
expect(screen.getByText(/place search uses openstreetmap/i)).toBeInTheDocument();
|
||||
expect(switchFor()).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('stays quiet about the fallback while the index is on', () => {
|
||||
renderTab({ trekPlacesEnabled: true });
|
||||
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user