add roadtrip warning areas and tidy driving settings

This commit is contained in:
Maurice
2026-09-12 11:23:09 +02:00
parent ec281e5974
commit af45b0ca74
47 changed files with 652 additions and 45 deletions
@@ -0,0 +1,15 @@
import { GeoJSON } from 'react-leaflet'
import type { RoadtripHazard } from '@trek/shared'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip', maxWidth: 320 })}
/>)}</>
}
+3
View File
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
import MarkerClusterGroup from 'react-leaflet-cluster' import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers' import RoadtripViaMarkers from './RoadtripViaMarkers'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays' import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -666,6 +667,7 @@ export const MapView = memo(function MapView({
tripId, tripId,
routeVias = [], routeVias = [],
dayBoundaryControls, dayBoundaryControls,
hazards,
accessLines = [], accessLines = [],
onPoiDropOnRoute, onPoiDropOnRoute,
onRouteClick, onRouteClick,
@@ -1153,6 +1155,7 @@ export const MapView = memo(function MapView({
roadRoutes={transportRoutes} roadRoutes={transportRoutes}
/> />
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois> <ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route. {/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */} Host-vetted data (server-normalized), rendered as plain tone dots. */}
+33 -5
View File
@@ -1,8 +1,12 @@
import { Suspense } from 'react' import { Suspense } from 'react'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView' import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary' import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy' import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
// Auto-selects the map renderer based on user settings. Keeps the existing // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
@@ -14,6 +18,30 @@ import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
// them online (see the GL tile rules in vite.config.js), not prefetched. // them online (see the GL tile rules in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const status = hazards.enabled ? <div role="status" className="absolute bottom-10 left-1/2 z-[500] flex max-w-[calc(100%-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2 text-caption text-content shadow-md">
{hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <>
<span className="flex items-center gap-2 whitespace-nowrap font-medium">
<AlertTriangle size={14} className="text-content-muted" aria-hidden />
{t('roadtrip.hazards.current')}
</span>
<span className="flex items-center gap-1 whitespace-nowrap tabular-nums text-content-muted">
<Clock size={12} aria-hidden />
{new Date(hazards.feed!.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
<span className="flex items-center gap-1.5">
{hazards.feed!.sources.map(source => <Tooltip key={source.source} placement="top"
label={source.status === 'ok' ? source.source : `${source.source}: ${t(`roadtrip.hazards.${source.status}`)}`}>
<span tabIndex={0} className={`inline-flex items-center gap-1 rounded-md bg-surface-secondary px-2 py-1 font-medium ${source.status === 'ok' ? 'text-content-secondary' : 'text-warning'}`}>
{source.source}
{source.status !== 'ok' && <Info size={12} aria-label={t(`roadtrip.hazards.${source.status}`)} />}
</span>
</Tooltip>)}
</span>
</>}
</div> : null
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token) const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set, // Fall back to Leaflet when Mapbox is selected but no token is set,
@@ -33,12 +61,12 @@ export function MapViewAuto(props: any) {
// to Leaflet keeps a usable map instead of an error card. // to Leaflet keeps a usable map instead of an error card.
// resetKeys: with two engine chunks, a failure under one provider must not // resetKeys: with two engine chunks, a failure under one provider must not
// keep showing Leaflet after the user switches to the other. // keep showing Leaflet after the user switches to the other.
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}> <><ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...props} />}> <Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...props} glProvider={glProvider} /> <MapViewGL {...mapProps} glProvider={glProvider} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>{status}</>
) )
} }
return <MapView {...props} /> return <><MapView {...mapProps} />{status}</>
} }
+6
View File
@@ -28,6 +28,8 @@ import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover' import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag' import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip' import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
@@ -187,6 +189,7 @@ interface Props {
* motorway are the shape of the day, and merging them into one dot hides it. * motorway are the shape of the day, and merging them into one dot hides it.
*/ */
clusterLoosely?: boolean clusterLoosely?: boolean
hazards?: RoadtripHazard[]
/** Via points to draw as draggable handles, keyed by day (#1797). */ /** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]> roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -631,6 +634,7 @@ export function MapViewGL({
fitKey = 0, fitKey = 0,
focusPoints, focusPoints,
clusterLoosely = false, clusterLoosely = false,
hazards,
dayOrderMap = NO_DAY_ORDER, dayOrderMap = NO_DAY_ORDER,
leftWidth = 0, leftWidth = 0,
rightWidth = 0, rightWidth = 0,
@@ -706,6 +710,8 @@ export function MapViewGL({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null) const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map()) const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin). // Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
@@ -0,0 +1,12 @@
import { expect, it } from 'vitest'
import { hazardPopup } from './hazardPopup'
it('renders provider text without HTML and identifies point-only notices', () => {
const popup = hazardPopup({ id: 'test', source: 'DWD', title: '<img src=x>', description: '<script>bad()</script>',
updatedAt: '2026-09-12T08:00:00Z', validUntil: null, url: 'https://www.dwd.de/',
geometry: { type: 'Point', coordinates: [10, 50] },
}, 'Not a closure', 'Location only')
expect(popup.querySelector('img,script')).toBeNull()
expect(popup.textContent).toContain('Location only Not a closure')
expect(popup.querySelector('a')?.rel).toBe('noopener noreferrer')
})
+27
View File
@@ -0,0 +1,27 @@
import type { RoadtripHazard } from '@trek/shared'
import type { Feature, Geometry } from 'geojson'
export function hazardFeature(hazard: RoadtripHazard): Feature {
return { type: 'Feature', properties: { id: hazard.id }, geometry: hazard.geometry as Geometry }
}
export function hazardPopup(hazard: RoadtripHazard, note: string, pointNote: string): HTMLDivElement {
const box = document.createElement('div')
box.className = 'flex max-w-xs flex-col gap-2 text-caption text-content'
const title = document.createElement('strong')
title.textContent = hazard.title
const details = document.createElement('p')
details.textContent = hazard.description
const timestamp = document.createElement('p')
timestamp.textContent = `${hazard.source}: ${new Date(hazard.updatedAt).toLocaleString()}`
const caution = document.createElement('p')
caution.textContent = hazard.geometry.type === 'Point' ? `${pointNote} ${note}` : note
const source = document.createElement('a')
source.textContent = hazard.source
source.href = hazard.url
source.target = '_blank'
source.rel = 'noopener noreferrer'
source.className = 'text-accent-on underline'
box.append(title, details, timestamp, caution, source)
return box
}
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import type { Map, Popup, MapMouseEvent } from 'mapbox-gl'
import type { RoadtripHazard } from '@trek/shared'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useTranslation } from '../../i18n/TranslationContext'
export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: RoadtripHazard[] | undefined, popup: () => Popup) {
const { t } = useTranslation()
useEffect(() => {
if (!map || !ready || !hazards?.length) return
const source = 'roadtrip-hazards'
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
let detail: Popup | undefined
const draw = () => {
if (!map.isStyleLoaded() || map.getSource(source)) return
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
map.addLayer({ id: layers[1], type: 'line', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': color, 'line-width': 2 } })
map.addLayer({ id: layers[2], type: 'circle', source, filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-color': color, 'circle-radius': 7 } })
}
const click = (event: MapMouseEvent) => {
if (!map.getLayer(layers[0])) return
const hit = map.queryRenderedFeatures(event.point, { layers })[0]
const hazard = hazards.find(item => item.id === hit?.properties?.id)
if (!hazard) return
detail?.remove()
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
}
draw()
map.on('style.load', draw)
map.on('click', click)
return () => {
detail?.remove()
map.off('style.load', draw)
map.off('click', click)
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
if (map.getSource(source)) map.removeSource(source)
}
}, [map, ready, hazards, popup, t])
}
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import type { RoadtripHazards } from '@trek/shared'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { roadtripHazardsRepo } from '../../repo/roadtripHazardsRepo'
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
export function useRoadtripHazards(tripId: number | undefined, roadtrip: boolean) {
const enabled = useRoadtripSettings(s => s.roadtrip_show_hazards === true, tripId) && roadtrip && !!tripId
const [feed, setFeed] = useState<RoadtripHazards | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable' | 'offline'>('loading')
useEffect(() => {
if (!enabled || !tripId) return
let controller: AbortController
const load = () => {
controller?.abort()
controller = new AbortController()
const signal = controller.signal
setFeed(null)
if (isEffectivelyOffline()) { setStatus('offline'); return }
setStatus('loading')
roadtripHazardsRepo.read(tripId, signal).then(saved => {
if (!signal.aborted) { setFeed(saved); setStatus('ready') }
}).catch(() => { if (!signal.aborted) setStatus('unavailable') })
}
load()
const timer = window.setInterval(load, 600000)
const unsubscribe = onNetworkModeChange(load)
window.addEventListener('online', load)
return () => { controller.abort(); window.clearInterval(timer); unsubscribe(); window.removeEventListener('online', load) }
}, [enabled, tripId])
return { enabled, feed: enabled ? feed : null, status }
}
@@ -3,6 +3,7 @@ import { Sunrise, Sunset } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext' import { useTranslation } from '../../i18n/TranslationContext'
import { dayWindow } from './dayWindow' import { dayWindow } from './dayWindow'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import SettingsHint from './SettingsHint'
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: { export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
start?: string start?: string
@@ -25,7 +26,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
} }
} }
return ( return (
<fieldset className="flex flex-col gap-3"> <fieldset className="flex min-w-0 flex-col gap-3">
<legend className="sr-only">{t('roadtrip.window.title')}</legend> <legend className="sr-only">{t('roadtrip.window.title')}</legend>
{(['start', 'end'] as const).map(field => { {(['start', 'end'] as const).map(field => {
const Icon = field === 'start' ? Sunrise : Sunset const Icon = field === 'start' ? Sunrise : Sunset
@@ -45,10 +46,10 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
</div> </div>
) )
})} })}
<p id="roadtrip-window-hint" role={invalid ? 'alert' : undefined} className={`text-caption ${invalid ? 'text-danger' : 'text-content-faint'}`}> {invalid
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')} ? <p id="roadtrip-window-hint" role="alert" className="text-caption text-danger">{t('roadtrip.window.invalid')}</p>
</p> : <SettingsHint id="roadtrip-window-hint">{t('roadtrip.window.hint')}</SettingsHint>}
<fieldset className="flex flex-col gap-2 border-t border-edge-faint pt-3"> <fieldset className="flex min-w-0 flex-col gap-2 border-t border-edge-faint pt-3">
<legend className="sr-only">{t('roadtrip.window.endAt')}</legend> <legend className="sr-only">{t('roadtrip.window.endAt')}</legend>
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span> <span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -61,7 +62,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
</label> </label>
))} ))}
</div> </div>
<p id="roadtrip-end-mode-hint" className="text-caption text-content-faint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</p> <SettingsHint id="roadtrip-end-mode-hint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</SettingsHint>
</fieldset> </fieldset>
</fieldset> </fieldset>
) )
@@ -16,6 +16,7 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
import type { DistanceUnit, RouteAvoidClass } from '../../types' import type { DistanceUnit, RouteAvoidClass } from '../../types'
import { FS } from './typeScale' import { FS } from './typeScale'
import DayWindowFields from './DayWindowFields' import DayWindowFields from './DayWindowFields'
import SettingsHint from './SettingsHint'
import { dayWindow } from './dayWindow' import { dayWindow } from './dayWindow'
/** /**
@@ -193,10 +194,10 @@ function Panel({ icon: Icon, title, note, children }: {
children: React.ReactNode children: React.ReactNode
}): React.ReactElement { }): React.ReactElement {
return ( return (
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5"> <section className="flex min-w-0 flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
<SectionTitle icon={Icon}>{title}</SectionTitle> <SectionTitle icon={Icon}>{title}</SectionTitle>
{children} {children}
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null} {note ? <SettingsHint>{note}</SettingsHint> : null}
</section> </section>
) )
} }
@@ -429,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
{t('roadtrip.limit.title')} {t('roadtrip.limit.title')}
</span> </span>
}> }>
<div className="flex flex-col gap-4"> <div className="flex min-w-0 flex-col gap-4">
{/* Two columns, kept. One column reads more tidily but turns the dialog into {/* Two columns, kept. One column reads more tidily but turns the dialog into
something that has to be scrolled, and a setting you cannot see while you something that has to be scrolled, and a setting you cannot see while you
change another is worse than a seam down the middle. change another is worse than a seam down the middle.
@@ -439,7 +440,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
ends on the same right edge, and the panels are stacked to one rhythm. The ends on the same right edge, and the panels are stacked to one rhythm. The
left column holds trip settings, the right holds the car and route appearance. */} left column holds trip settings, the right holds the car and route appearance. */}
<div className="grid items-start gap-4 sm:grid-cols-2"> <div className="grid items-start gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-4"> <div className="flex min-w-0 flex-col gap-4">
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}> <Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
<LimitRow <LimitRow
icon={Clock} icon={Clock}
@@ -484,7 +485,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
</Panel> </Panel>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex min-w-0 flex-col gap-4">
<Panel <Panel
icon={Car} icon={Car}
title={t('roadtrip.limit.sectionVehicle')} title={t('roadtrip.limit.sectionVehicle')}
@@ -641,6 +642,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)} onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
/> />
</Panel> </Panel>
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
</Panel>
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}> <Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')} <AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
on={settings.roadtrip_service_stops_in_days !== false} on={settings.roadtrip_service_stops_in_days !== false}
@@ -0,0 +1,7 @@
import { Tooltip } from '../shared/Tooltip'
export default function SettingsHint({ children, id }: { children: string; id?: string }) {
return <Tooltip label={children} placement="top">
<p id={id} tabIndex={0} className="min-w-0 truncate text-caption leading-snug text-content-faint">{children}</p>
</Tooltip>
}
+9
View File
@@ -0,0 +1,9 @@
import { roadtripHazardsSchema } from '@trek/shared'
import { apiClient } from '../api/client'
export const roadtripHazardsRepo = {
async read(tripId: number, signal: AbortSignal) {
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal })
return roadtripHazardsSchema.parse(fetched.data)
},
}
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
@Controller('api/trips/:tripId/roadtrip/hazards')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
export class RoadtripHazardsController {
constructor(private readonly hazards: RoadtripHazardsService) {}
@Get()
read() { return this.hazards.read(); }
}
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
@McpController()
export class RoadtripHazardsMcp {
constructor(private readonly hazards: RoadtripHazardsService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
@Tool({
name: 'get_roadtrip_hazards',
description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP),
})
async read({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
return ok(await this.hazards.read());
}
}
@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { hazardGeometrySchema, roadtripHazardSchema, type RoadtripHazard, type RoadtripHazards } from '@trek/shared';
import { z } from 'zod';
import { readCappedJson } from '../../utils/cappedFetch';
const collection = z.object({
type: z.literal('FeatureCollection'), features: z.array(z.object({
id: z.union([z.string(), z.number()]).optional(), geometry: z.unknown(), properties: z.record(z.string(), z.unknown()),
})).max(1000), numberMatched: z.union([z.number(), z.string()]).optional(),
});
const dwdProperties = z.object({
HEADLINE: z.string(), DESCRIPTION: z.string().nullish(), SENT: z.string(), EXPIRES: z.string(),
});
const gdacsProperties = z.object({
eventtype: z.string().regex(/^[A-Z]{2}$/), eventid: z.number().int().positive(), episodeid: z.number().int().nonnegative(),
name: z.string(), description: z.string(), datemodified: z.string(), iscurrent: z.union([z.string(), z.boolean()]),
});
const utc = (date: string) => new Date(/(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : `${date}Z`).toISOString();
async function fetchCollection(url: string) {
const fetched = await fetch(url, { signal: AbortSignal.timeout(15000), redirect: 'error' });
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Hazard source unavailable'); }
return collection.parse(await readCappedJson<unknown>(fetched, 8_000_000));
}
@Injectable()
export class RoadtripHazardsService {
private cached?: RoadtripHazards;
private pending?: Promise<RoadtripHazards>;
async read(): Promise<RoadtripHazards> {
if (this.cached && Date.now() - Date.parse(this.cached.fetchedAt) < 600000) return this.cached;
if (this.pending) return this.pending;
this.pending = this.load();
try { this.cached = await this.pending; return this.cached; }
finally { this.pending = undefined; }
}
private async load(): Promise<RoadtripHazards> {
const loaded = await Promise.allSettled([this.dwd(), this.gdacs()]);
return {
fetchedAt: new Date().toISOString(),
hazards: loaded.flatMap(entry => entry.status === 'fulfilled' ? entry.value.hazards : []),
sources: loaded.map((entry, index) => ({ source: index === 0 ? 'DWD' : 'GDACS', status: entry.status === 'fulfilled' ? entry.value.status : 'unavailable' })),
};
}
private async dwd() {
const feed = await fetchCollection('https://maps.dwd.de/geoserver/dwd/ows?service=WFS&version=2.0.0&request=GetFeature&typeNames=dwd:Warnungen_Gemeinden_vereinigt&outputFormat=application/json&srsName=CRS:84&count=500');
const hazards: RoadtripHazard[] = [];
let partial = Number(feed.numberMatched) > feed.features.length;
for (const [index, feature] of feed.features.entries()) {
try {
const props = dwdProperties.parse(feature.properties);
if (Date.parse(props.EXPIRES) <= Date.now()) continue;
hazards.push(roadtripHazardSchema.parse({
id: `dwd-${feature.id ?? index}`, source: 'DWD', title: props.HEADLINE, description: props.DESCRIPTION ?? '',
updatedAt: utc(props.SENT), validUntil: utc(props.EXPIRES),
url: 'https://www.dwd.de/DE/wetter/warnungen_gemeinden/warnWetter_node.html', geometry: feature.geometry,
}));
} catch { partial = true; }
}
return { hazards, status: partial ? 'partial' as const : 'ok' as const };
}
private async gdacs() {
const feed = await fetchCollection('https://www.gdacs.org/gdacsapi/api/events/geteventlist/events4app');
const hazards: RoadtripHazard[] = [];
let partial = feed.features.length >= 100;
const current = feed.features.filter(feature => feature.properties.iscurrent === 'true' || feature.properties.iscurrent === true);
for (const feature of current) {
try {
const props = gdacsProperties.parse(feature.properties);
hazards.push(roadtripHazardSchema.parse({
id: `gdacs-${props.eventtype}-${props.eventid}-${props.episodeid}`, source: 'GDACS',
title: props.name, description: props.description, updatedAt: utc(props.datemodified), validUntil: null,
url: `https://www.gdacs.org/report.aspx?eventtype=${props.eventtype}&eventid=${props.eventid}&episodeid=${props.episodeid}`,
geometry: feature.geometry,
}));
} catch { partial = true; }
}
const areas = hazards.filter(hazard => /^gdacs-(FL|WF|TC)-/.test(hazard.id));
if (areas.length > 12) partial = true;
await Promise.all(areas.slice(0, 12).map(async hazard => {
const [, eventtype, eventid, episodeid] = hazard.id.split('-');
try {
const shapes = await fetchCollection(`https://www.gdacs.org/gdacsapi/api/polygons/getgeometry?eventtype=${eventtype}&eventid=${eventid}&episodeid=${episodeid}`);
const polygons = shapes.features.filter(feature => feature.properties.Class === 'Poly_Affected').flatMap(feature => {
const parsed = hazardGeometrySchema.safeParse(feature.geometry);
if (!parsed.success) { partial = true; return []; }
return parsed.data.type === 'Polygon' ? [parsed.data.coordinates] : parsed.data.type === 'MultiPolygon' ? parsed.data.coordinates : [];
});
if (polygons.length) hazard.geometry = hazardGeometrySchema.parse({ type: 'MultiPolygon', coordinates: polygons });
} catch { partial = true; }
}));
return { hazards, status: partial ? 'partial' as const : 'ok' as const };
}
}
+6 -1
View File
@@ -19,6 +19,9 @@ import { RoadtripController } from './roadtrip.controller';
import { RoadtripMcp } from './roadtrip.mcp'; import { RoadtripMcp } from './roadtrip.mcp';
import { RoadtripService } from './roadtrip.service'; import { RoadtripService } from './roadtrip.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */ /** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({ @Module({
@@ -34,8 +37,10 @@ import { Module } from '@nestjs/common';
PluginsRuntimeModule, PluginsRuntimeModule,
MapsModule, MapsModule,
], ],
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController], controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
providers: [ providers: [
RoadtripHazardsService,
RoadtripHazardsMcp,
RoadtripService, RoadtripService,
RoadtripMcp, RoadtripMcp,
DayBoundariesService, DayBoundariesService,
+12
View File
@@ -17,6 +17,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module'; import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service';
import { seedUser, sessionCookie } from './harness'; import { seedUser, sessionCookie } from './harness';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
@@ -130,6 +131,17 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
}); });
const cookie = () => sessionCookie(1); const cookie = () => sessionCookie(1);
it('gates live hazards by addon, authentication and trip access', async () => {
const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] });
try {
await request(server).get('/api/trips/5/roadtrip/hazards').expect(401);
await request(server).get('/api/trips/6/roadtrip/hazards').set('Cookie', cookie()).expect(404);
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(200);
setAddon(false);
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(404);
expect(read).toHaveBeenCalledTimes(1);
} finally { read.mockRestore(); }
});
describe('shared trip driving preferences', () => { describe('shared trip driving preferences', () => {
it('shares values with members and isolates other trips', async () => { it('shares values with members and isolates other trips', async () => {
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RoadtripHazardsService } from '../../../src/nest/roadtrip/roadtrip-hazards.service';
const polygon = { type: 'Polygon', coordinates: [[[10, 50], [11, 50], [11, 51], [10, 50]]] };
const feed = (features: unknown[]) => new Response(JSON.stringify({ type: 'FeatureCollection', features }));
afterEach(() => vi.unstubAllGlobals());
describe('roadtrip hazard sources', () => {
it('isolates failed providers and caches concurrent reads', async () => {
const fetcher = vi.fn(async (url: string) => {
if (url.includes('dwd.de')) throw new Error('down');
return feed([]);
});
vi.stubGlobal('fetch', fetcher);
const service = new RoadtripHazardsService();
const [first, second] = await Promise.all([service.read(), service.read()]);
expect(first).toEqual(second);
expect(first.sources).toEqual([{ source: 'DWD', status: 'unavailable' }, { source: 'GDACS', status: 'ok' }]);
await service.read();
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('drops expired warnings and marks malformed records as incomplete', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => feed(url.includes('dwd.de') ? [
{ geometry: polygon, properties: { HEADLINE: 'Rain', SENT: '2026-01-01T00:00:00Z', EXPIRES: '2099-01-01T00:00:00Z' } },
{ geometry: polygon, properties: { HEADLINE: 'Old', SENT: '2020-01-01T00:00:00Z', EXPIRES: '2020-01-02T00:00:00Z' } },
{ geometry: null, properties: {} },
] : [])));
const saved = await new RoadtripHazardsService().read();
expect(saved.hazards).toHaveLength(1);
expect(saved.hazards[0].title).toBe('Rain');
expect(saved.sources[0].status).toBe('partial');
});
it('uses affected polygons rather than the global event footprint', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('dwd.de')) return feed([]);
if (url.includes('getgeometry')) return feed([
{ geometry: polygon, properties: { Class: 'Poly_Affected' } },
{ geometry: polygon, properties: { Class: 'Poly_Global' } },
]);
return feed([{ geometry: { type: 'Point', coordinates: [10, 50] }, properties: {
eventtype: 'FL', eventid: 1, episodeid: 2, name: 'Flood', description: 'Flood report',
datemodified: '2026-09-12T08:00:00', iscurrent: 'true',
} }]);
}));
const saved = await new RoadtripHazardsService().read();
expect(saved.hazards[0].geometry).toEqual({ type: 'MultiPolygon', coordinates: [polygon.coordinates] });
expect(saved.hazards[0].updatedAt).toBe('2026-09-12T08:00:00.000Z');
});
});
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'عرض مناطق الخطر',
'roadtrip.hazards.current': 'التحذيرات الحالية',
'roadtrip.hazards.note': 'التحذيرات لا تعني إغلاق الطرق. يبقى المسار دون تغيير.',
'roadtrip.hazards.point': 'الموقع فقط؛ المنطقة المتأثرة غير معروفة.',
'roadtrip.hazards.loading': 'جارٍ تحميل التحذيرات…',
'roadtrip.hazards.unavailable': 'غير متاح',
'roadtrip.hazards.offline': 'دون اتصال: التحذيرات الحالية غير متاحة',
'roadtrip.hazards.partial': 'تغطية محدودة',
'roadtrip.stops.section': 'توقفات الخدمة', 'roadtrip.stops.section': 'توقفات الخدمة',
'roadtrip.stops.inDays': 'إظهارها في الأيام أيضًا', 'roadtrip.stops.inDays': 'إظهارها في الأيام أيضًا',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Mostrar áreas de risco',
'roadtrip.hazards.current': 'Alertas atuais',
'roadtrip.hazards.note': 'Alertas não são interdições de vias. A rota não muda.',
'roadtrip.hazards.point': 'Apenas localização; área afetada desconhecida.',
'roadtrip.hazards.loading': 'Carregando alertas…',
'roadtrip.hazards.unavailable': 'Indisponível',
'roadtrip.hazards.offline': 'Offline: alertas indisponíveis',
'roadtrip.hazards.partial': 'Cobertura limitada',
'roadtrip.stops.section': 'Paradas de serviço', 'roadtrip.stops.section': 'Paradas de serviço',
'roadtrip.stops.inDays': 'Mostrar também em Dias', 'roadtrip.stops.inDays': 'Mostrar também em Dias',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Mostra zones de perill',
'roadtrip.hazards.current': 'Avisos actuals',
'roadtrip.hazards.note': 'Els avisos no són talls de carretera. La ruta no canvia.',
'roadtrip.hazards.point': 'Només ubicació; àrea afectada desconeguda.',
'roadtrip.hazards.loading': 'S’estan carregant els avisos…',
'roadtrip.hazards.unavailable': 'No disponible',
'roadtrip.hazards.offline': 'Sense connexió: avisos no disponibles',
'roadtrip.hazards.partial': 'Cobertura limitada',
'roadtrip.stops.section': 'Parades de servei', 'roadtrip.stops.section': 'Parades de servei',
'roadtrip.stops.inDays': 'Mostra també a Dies', 'roadtrip.stops.inDays': 'Mostra també a Dies',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Zobrazit nebezpečné oblasti',
'roadtrip.hazards.current': 'Aktuální výstrahy',
'roadtrip.hazards.note': 'Výstrahy nejsou uzavírky silnic. Trasa se nemění.',
'roadtrip.hazards.point': 'Pouze místo; zasažená oblast není známa.',
'roadtrip.hazards.loading': 'Načítání výstrah…',
'roadtrip.hazards.unavailable': 'Nedostupné',
'roadtrip.hazards.offline': 'Offline: aktuální výstrahy nejsou dostupné',
'roadtrip.hazards.partial': 'Omezené pokrytí',
'roadtrip.stops.section': 'Servisní zastávky', 'roadtrip.stops.section': 'Servisní zastávky',
'roadtrip.stops.inDays': 'Zobrazit také ve Dnech', 'roadtrip.stops.inDays': 'Zobrazit také ve Dnech',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+18 -14
View File
@@ -1,10 +1,17 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Gefahrengebiete anzeigen',
'roadtrip.hazards.current': 'Aktuelle Warnungen',
'roadtrip.hazards.note': 'Warnungen sind keine Straßensperrungen. Die Route bleibt unverändert.',
'roadtrip.hazards.point': 'Nur Ereignisort, betroffene Fläche unbekannt.',
'roadtrip.hazards.loading': 'Warnungen werden geladen…',
'roadtrip.hazards.unavailable': 'Nicht verfügbar',
'roadtrip.hazards.offline': 'Offline: aktuelle Warnungen nicht verfügbar',
'roadtrip.hazards.partial': 'Eingeschränkte Abdeckung',
'roadtrip.stops.section': 'Service Stops', 'roadtrip.stops.section': 'Service Stops',
'roadtrip.stops.inDays': 'Auch in Days anzeigen', 'roadtrip.stops.inDays': 'Auch in Days anzeigen',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint': 'Aus: Alle Service Stops bleiben nur im Roadtrip.',
'Gilt für alle Service Stops dieser Reise. Ausgeschaltet bleiben sie ausschließlich im Roadtrip-Plan; beim Einschalten erscheinen sie wieder in Days.',
'roadtrip.window.dragHint': 'roadtrip.window.dragHint':
'Online kannst du ein Tagesende entlang der Route oder auf einen Ort ziehen; ein Rechtsklick stellt das automatische Ende wieder her.', 'Online kannst du ein Tagesende entlang der Route oder auf einen Ort ziehen; ein Rechtsklick stellt das automatische Ende wieder her.',
'roadtrip.window.resetBoundaries': 'Automatische Tagesenden wiederherstellen', 'roadtrip.window.resetBoundaries': 'Automatische Tagesenden wiederherstellen',
@@ -14,16 +21,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.endAt': 'Tagesende planen', 'roadtrip.window.endAt': 'Tagesende planen',
'roadtrip.window.onRoute': 'Auf der Strecke', 'roadtrip.window.onRoute': 'Auf der Strecke',
'roadtrip.window.atStop': 'Am letzten Ort', 'roadtrip.window.atStop': 'Am letzten Ort',
'roadtrip.window.routeHint': 'Zur Endzeit an der erreichten Stelle auf der Strecke pausieren.', 'roadtrip.window.routeHint': 'Zur Endzeit auf der Strecke pausieren.',
'roadtrip.window.stopHint': 'Am letzten Ort bleiben, wenn der nächste vor Tagesende nicht mehr erreichbar ist.', 'roadtrip.window.stopHint': 'Stoppen, wenn der nächste Ort zu spät erreicht würde.',
'roadtrip.window.legTooLong': 'roadtrip.window.legTooLong':
'Eine Fahrt ist länger als das tägliche Zeitfenster. Ergänze einen Ort unterwegs oder wähle die Pause auf der Strecke.', 'Eine Fahrt ist länger als das tägliche Zeitfenster. Ergänze einen Ort unterwegs oder wähle die Pause auf der Strecke.',
'roadtrip.window.pointLabel': 'Tagesende von Tag {day} um {time} Uhr', 'roadtrip.window.pointLabel': 'Tagesende von Tag {day} um {time} Uhr',
'roadtrip.window.title': 'Tägliche Reisezeiten', 'roadtrip.window.title': 'Tägliche Reisezeiten',
'roadtrip.window.start': 'Tagesbeginn', 'roadtrip.window.start': 'Tagesbeginn',
'roadtrip.window.end': 'Tagesende', 'roadtrip.window.end': 'Tagesende',
'roadtrip.window.hint': 'roadtrip.window.hint': 'Beide Zeiten aktivieren. Manuelle Zeiten gehen vor.',
'Beide Zeiten setzen, um tägliche Pausen zu planen. Ein leeres Feld schaltet dies aus. Manuelle Zeiten haben Vorrang. Tage werden automatisch verbunden.',
'roadtrip.window.invalid': 'Das Tagesende muss nach dem Tagesbeginn liegen.', 'roadtrip.window.invalid': 'Das Tagesende muss nach dem Tagesbeginn liegen.',
'roadtrip.window.stop': 'Tagesende', 'roadtrip.window.stop': 'Tagesende',
'roadtrip.window.resume': 'Weiterfahrt', 'roadtrip.window.resume': 'Weiterfahrt',
@@ -75,8 +81,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll', 'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll',
'roadtrip.limit.rangeEmpty': 'Keine Reichweite', 'roadtrip.limit.rangeEmpty': 'Keine Reichweite',
'roadtrip.limit.rangeEmptyHint': 'Trag eine Reichweite ein oder fülle die Felder aus, dann rechnet TREK sie aus.', 'roadtrip.limit.rangeEmptyHint': 'Trag eine Reichweite ein oder fülle die Felder aus, dann rechnet TREK sie aus.',
'roadtrip.limit.specHint': 'Ausgefüllt bestimmen diese Werte die Reichweite oben.', 'roadtrip.limit.specHint': 'Fahrzeugdaten bestimmen die Reichweite.',
'roadtrip.limit.vehicleHint': 'Sag, was du fährst, dann kannst du statt der Reichweite die Fahrzeugdaten angeben.', 'roadtrip.limit.vehicleHint': 'Fahrzeug wählen und Reichweite berechnen.',
'roadtrip.limit.minutes': 'Min', 'roadtrip.limit.minutes': 'Min',
'roadtrip.limit.off': 'aus', 'roadtrip.limit.off': 'aus',
'roadtrip.limit.blockNote': 'Ein Block = {distance}', 'roadtrip.limit.blockNote': 'Ein Block = {distance}',
@@ -85,7 +91,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.echoDry': '{count} Abschnitte ohne erreichbaren Stopp.', 'roadtrip.limit.echoDry': '{count} Abschnitte ohne erreichbaren Stopp.',
'roadtrip.limit.echoEmpty': 'Öffne einen Roadtrip, um zu sehen, was das bewirkt.', 'roadtrip.limit.echoEmpty': 'Öffne einen Roadtrip, um zu sehen, was das bewirkt.',
'roadtrip.limit.specToggle': 'Aus den Fahrzeugdaten rechnen', 'roadtrip.limit.specToggle': 'Aus den Fahrzeugdaten rechnen',
'roadtrip.limit.hint': 'Zählt nur Fahrzeit. Standzeiten und Übernachtungen sind nicht enthalten.', 'roadtrip.limit.hint': 'Nur Fahrzeit, ohne Aufenthalte und Nachtetappen.',
'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt', 'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt',
'roadtrip.limit.dayOver': '{time} drüber', 'roadtrip.limit.dayOver': '{time} drüber',
'roadtrip.limit.range': '{distance} seit dem letzten Tanken', 'roadtrip.limit.range': '{distance} seit dem letzten Tanken',
@@ -93,15 +99,13 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.section': 'Routenlinie', 'roadtrip.line.section': 'Routenlinie',
'roadtrip.line.connect': 'Tage verbinden', 'roadtrip.line.connect': 'Tage verbinden',
'roadtrip.line.dayColors': 'Farbe je Tag', 'roadtrip.line.dayColors': 'Farbe je Tag',
'roadtrip.line.hint': 'Routet auch die Strecke zwischen zwei Tagen und zählt sie zum Ankunftstag.', 'roadtrip.line.hint': 'Verbindungsfahrten zählen zum Ankunftstag.',
'roadtrip.avoid.section': 'Möglichst meiden', 'roadtrip.avoid.section': 'Möglichst meiden',
'roadtrip.avoid.toll': 'Mautstraßen', 'roadtrip.avoid.toll': 'Mautstraßen',
'roadtrip.avoid.motorway': 'Autobahnen', 'roadtrip.avoid.motorway': 'Autobahnen',
'roadtrip.avoid.ferry': 'Fähren', 'roadtrip.avoid.ferry': 'Fähren',
'roadtrip.avoid.hint': 'roadtrip.avoid.hint': 'Meidet nach Möglichkeit und markiert Ausnahmen.',
'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.', 'roadtrip.avoid.unavailable': 'Der Routing-Anbieter unterstützt kein Meiden.',
'roadtrip.avoid.unavailable':
'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.',
'roadtrip.avoid.badge': '{count} gemieden', 'roadtrip.avoid.badge': '{count} gemieden',
'roadtrip.avoid.missed': '{classes} unvermeidbar', 'roadtrip.avoid.missed': '{classes} unvermeidbar',
'roadtrip.avoid.missedHint': 'roadtrip.avoid.missedHint':
+18 -14
View File
@@ -1,10 +1,17 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Show hazard areas',
'roadtrip.hazards.current': 'Current warnings',
'roadtrip.hazards.note': 'Warnings are not road closures. Routes stay unchanged.',
'roadtrip.hazards.point': 'Location only; affected area unknown.',
'roadtrip.hazards.loading': 'Loading warnings…',
'roadtrip.hazards.unavailable': 'Unavailable',
'roadtrip.hazards.offline': 'Offline: live warnings unavailable',
'roadtrip.hazards.partial': 'Limited coverage',
'roadtrip.stops.section': 'Service stops', 'roadtrip.stops.section': 'Service stops',
'roadtrip.stops.inDays': 'Show in Days too', 'roadtrip.stops.inDays': 'Show in Days too',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint': 'Off keeps all service stops in Roadtrip only.',
'Applies to all service stops in this trip. When off, they stay exclusively in the Roadtrip plan. Turn on to show them in Days again.',
'roadtrip.window.dragHint': 'roadtrip.window.dragHint':
'While online, drag a day ending along the route or onto a place; right-click it to restore the automatic ending.', 'While online, drag a day ending along the route or onto a place; right-click it to restore the automatic ending.',
'roadtrip.window.resetBoundaries': 'Restore automatic day endings', 'roadtrip.window.resetBoundaries': 'Restore automatic day endings',
@@ -14,16 +21,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.endAt': 'End the day', 'roadtrip.window.endAt': 'End the day',
'roadtrip.window.onRoute': 'Along the route', 'roadtrip.window.onRoute': 'Along the route',
'roadtrip.window.atStop': 'At the last place', 'roadtrip.window.atStop': 'At the last place',
'roadtrip.window.routeHint': 'Pause along the route when the end time is reached.', 'roadtrip.window.routeHint': 'Pause on the route at the end time.',
'roadtrip.window.stopHint': 'Stay at the last place if the next one cannot be reached before the end time.', 'roadtrip.window.stopHint': 'Stop before the next place would exceed the end time.',
'roadtrip.window.legTooLong': 'roadtrip.window.legTooLong':
'A drive exceeds the daily travel window. Add a place along the way or choose to pause along the route.', 'A drive exceeds the daily travel window. Add a place along the way or choose to pause along the route.',
'roadtrip.window.pointLabel': 'End of day {day} at {time}', 'roadtrip.window.pointLabel': 'End of day {day} at {time}',
'roadtrip.window.title': 'Daily travel times', 'roadtrip.window.title': 'Daily travel times',
'roadtrip.window.start': 'Day start time', 'roadtrip.window.start': 'Day start time',
'roadtrip.window.end': 'Day end time', 'roadtrip.window.end': 'Day end time',
'roadtrip.window.hint': 'roadtrip.window.hint': 'Set both times to enable. Manual times take priority.',
'Set both times to plan daily breaks. Clear either to turn this off. Manual times take priority. Days are connected automatically.',
'roadtrip.window.invalid': 'The end time must be later than the start time.', 'roadtrip.window.invalid': 'The end time must be later than the start time.',
'roadtrip.window.stop': 'End of day', 'roadtrip.window.stop': 'End of day',
'roadtrip.window.resume': 'Continue journey', 'roadtrip.window.resume': 'Continue journey',
@@ -74,8 +80,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.fullNote': 'A stop fills right up', 'roadtrip.limit.fullNote': 'A stop fills right up',
'roadtrip.limit.rangeEmpty': 'No range set', 'roadtrip.limit.rangeEmpty': 'No range set',
'roadtrip.limit.rangeEmptyHint': 'Type a range, or fill in the figures below and TREK works it out.', 'roadtrip.limit.rangeEmptyHint': 'Type a range, or fill in the figures below and TREK works it out.',
'roadtrip.limit.specHint': 'Filled in, these decide the range above.', 'roadtrip.limit.specHint': 'Vehicle details determine the range.',
'roadtrip.limit.vehicleHint': 'Name the kind of car and you can state its figures instead of a range.', 'roadtrip.limit.vehicleHint': 'Choose a vehicle to calculate its range.',
'roadtrip.limit.minutes': 'min', 'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'off', 'roadtrip.limit.off': 'off',
'roadtrip.limit.blockNote': 'One block = {distance}', 'roadtrip.limit.blockNote': 'One block = {distance}',
@@ -84,7 +90,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.echoDry': '{count} stretches with no reachable stop.', 'roadtrip.limit.echoDry': '{count} stretches with no reachable stop.',
'roadtrip.limit.echoEmpty': 'Open a road trip to see what these do to it.', 'roadtrip.limit.echoEmpty': 'Open a road trip to see what these do to it.',
'roadtrip.limit.specToggle': 'Work it out from the car', 'roadtrip.limit.specToggle': 'Work it out from the car',
'roadtrip.limit.hint': 'Counts driving time only. Time spent at stops and overnight legs are not included.', 'roadtrip.limit.hint': 'Driving only, excluding stays and overnight legs.',
'roadtrip.limit.legOver': '{time} over your longest drive', 'roadtrip.limit.legOver': '{time} over your longest drive',
'roadtrip.limit.dayOver': '{time} over', 'roadtrip.limit.dayOver': '{time} over',
'roadtrip.limit.range': '{distance} since the last fill-up', 'roadtrip.limit.range': '{distance} since the last fill-up',
@@ -92,15 +98,13 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.section': 'Route line', 'roadtrip.line.section': 'Route line',
'roadtrip.line.connect': 'Connect the days', 'roadtrip.line.connect': 'Connect the days',
'roadtrip.line.dayColors': 'A colour per day', 'roadtrip.line.dayColors': 'A colour per day',
'roadtrip.line.hint': 'Also routes the gap between two days, and counts it on the day it arrives.', 'roadtrip.line.hint': 'Connecting drives count toward the arrival day.',
'roadtrip.avoid.section': 'Avoid where possible', 'roadtrip.avoid.section': 'Avoid where possible',
'roadtrip.avoid.toll': 'Toll roads', 'roadtrip.avoid.toll': 'Toll roads',
'roadtrip.avoid.motorway': 'Motorways', 'roadtrip.avoid.motorway': 'Motorways',
'roadtrip.avoid.ferry': 'Ferries', 'roadtrip.avoid.ferry': 'Ferries',
'roadtrip.avoid.hint': 'roadtrip.avoid.hint': 'Avoids where possible; exceptions are flagged.',
'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.', 'roadtrip.avoid.unavailable': 'Your routing provider does not support avoidance.',
'roadtrip.avoid.unavailable':
'Not available: this instance routes through its own engine, which cannot leave a road class out.',
'roadtrip.avoid.badge': '{count} avoided', 'roadtrip.avoid.badge': '{count} avoided',
'roadtrip.avoid.missed': '{classes} unavoidable', 'roadtrip.avoid.missed': '{classes} unavoidable',
'roadtrip.avoid.missedHint': 'roadtrip.avoid.missedHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Mostrar zonas de peligro',
'roadtrip.hazards.current': 'Alertas actuales',
'roadtrip.hazards.note': 'Las alertas no son cierres de carreteras. La ruta no cambia.',
'roadtrip.hazards.point': 'Solo ubicación; área afectada desconocida.',
'roadtrip.hazards.loading': 'Cargando alertas…',
'roadtrip.hazards.unavailable': 'No disponible',
'roadtrip.hazards.offline': 'Sin conexión: alertas no disponibles',
'roadtrip.hazards.partial': 'Cobertura limitada',
'roadtrip.stops.section': 'Paradas de servicio', 'roadtrip.stops.section': 'Paradas de servicio',
'roadtrip.stops.inDays': 'Mostrar también en Días', 'roadtrip.stops.inDays': 'Mostrar también en Días',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Afficher les zones à risque',
'roadtrip.hazards.current': 'Alertes actuelles',
'roadtrip.hazards.note': 'Les alertes ne sont pas des fermetures de routes. Le trajet reste inchangé.',
'roadtrip.hazards.point': 'Lieu uniquement, zone touchée inconnue.',
'roadtrip.hazards.loading': 'Chargement des alertes…',
'roadtrip.hazards.unavailable': 'Indisponible',
'roadtrip.hazards.offline': 'Hors ligne : alertes indisponibles',
'roadtrip.hazards.partial': 'Couverture limitée',
'roadtrip.stops.section': 'Arrêts de service', 'roadtrip.stops.section': 'Arrêts de service',
'roadtrip.stops.inDays': 'Afficher aussi dans Jours', 'roadtrip.stops.inDays': 'Afficher aussi dans Jours',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Εμφάνιση επικίνδυνων περιοχών',
'roadtrip.hazards.current': 'Τρέχουσες προειδοποιήσεις',
'roadtrip.hazards.note': 'Οι προειδοποιήσεις δεν σημαίνουν κλειστούς δρόμους. Η διαδρομή δεν αλλάζει.',
'roadtrip.hazards.point': 'Μόνο τοποθεσία· άγνωστη πληγείσα περιοχή.',
'roadtrip.hazards.loading': 'Φόρτωση προειδοποιήσεων…',
'roadtrip.hazards.unavailable': 'Μη διαθέσιμο',
'roadtrip.hazards.offline': 'Εκτός σύνδεσης: μη διαθέσιμες προειδοποιήσεις',
'roadtrip.hazards.partial': 'Περιορισμένη κάλυψη',
'roadtrip.stops.section': 'Στάσεις εξυπηρέτησης', 'roadtrip.stops.section': 'Στάσεις εξυπηρέτησης',
'roadtrip.stops.inDays': 'Εμφάνιση και στις Ημέρες', 'roadtrip.stops.inDays': 'Εμφάνιση και στις Ημέρες',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Veszélyes területek megjelenítése',
'roadtrip.hazards.current': 'Aktuális figyelmeztetések',
'roadtrip.hazards.note': 'A figyelmeztetések nem útlezárások. Az útvonal változatlan marad.',
'roadtrip.hazards.point': 'Csak helyszín; az érintett terület ismeretlen.',
'roadtrip.hazards.loading': 'Figyelmeztetések betöltése…',
'roadtrip.hazards.unavailable': 'Nem érhető el',
'roadtrip.hazards.offline': 'Offline: aktuális figyelmeztetések nem érhetők el',
'roadtrip.hazards.partial': 'Korlátozott lefedettség',
'roadtrip.stops.section': 'Szervizmegállók', 'roadtrip.stops.section': 'Szervizmegállók',
'roadtrip.stops.inDays': 'Megjelenítés a Napok nézetben is', 'roadtrip.stops.inDays': 'Megjelenítés a Napok nézetben is',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Tampilkan area bahaya',
'roadtrip.hazards.current': 'Peringatan terkini',
'roadtrip.hazards.note': 'Peringatan bukan penutupan jalan. Rute tidak berubah.',
'roadtrip.hazards.point': 'Hanya lokasi; area terdampak tidak diketahui.',
'roadtrip.hazards.loading': 'Memuat peringatan…',
'roadtrip.hazards.unavailable': 'Tidak tersedia',
'roadtrip.hazards.offline': 'Luring: peringatan terkini tidak tersedia',
'roadtrip.hazards.partial': 'Cakupan terbatas',
'roadtrip.stops.section': 'Perhentian layanan', 'roadtrip.stops.section': 'Perhentian layanan',
'roadtrip.stops.inDays': 'Tampilkan juga di Hari', 'roadtrip.stops.inDays': 'Tampilkan juga di Hari',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Mostra zone di pericolo',
'roadtrip.hazards.current': 'Avvisi attuali',
'roadtrip.hazards.note': 'Gli avvisi non indicano strade chiuse. Il percorso non cambia.',
'roadtrip.hazards.point': 'Solo posizione; area interessata sconosciuta.',
'roadtrip.hazards.loading': 'Caricamento avvisi…',
'roadtrip.hazards.unavailable': 'Non disponibile',
'roadtrip.hazards.offline': 'Offline: avvisi non disponibili',
'roadtrip.hazards.partial': 'Copertura limitata',
'roadtrip.stops.section': 'Soste di servizio', 'roadtrip.stops.section': 'Soste di servizio',
'roadtrip.stops.inDays': 'Mostra anche in Giorni', 'roadtrip.stops.inDays': 'Mostra anche in Giorni',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': '危険地域を表示',
'roadtrip.hazards.current': '現在の警報',
'roadtrip.hazards.note': '警報は通行止めを意味しません。ルートは変更されません。',
'roadtrip.hazards.point': '発生地点のみ。影響範囲は不明です。',
'roadtrip.hazards.loading': '警報を読み込み中…',
'roadtrip.hazards.unavailable': '利用できません',
'roadtrip.hazards.offline': 'オフライン:最新の警報は利用できません',
'roadtrip.hazards.partial': '対象範囲は限定的',
'roadtrip.stops.section': 'サービス施設での停車', 'roadtrip.stops.section': 'サービス施設での停車',
'roadtrip.stops.inDays': '日別にも表示', 'roadtrip.stops.inDays': '日別にも表示',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': '위험 지역 표시',
'roadtrip.hazards.current': '현재 경보',
'roadtrip.hazards.note': '경보는 도로 폐쇄를 의미하지 않습니다. 경로는 변경되지 않습니다.',
'roadtrip.hazards.point': '발생 위치만 표시하며 영향 지역은 알 수 없습니다.',
'roadtrip.hazards.loading': '경보 불러오는 중…',
'roadtrip.hazards.unavailable': '사용 불가',
'roadtrip.hazards.offline': '오프라인: 현재 경보를 사용할 수 없습니다',
'roadtrip.hazards.partial': '제한된 범위',
'roadtrip.stops.section': '편의시설 경유지', 'roadtrip.stops.section': '편의시설 경유지',
'roadtrip.stops.inDays': '일별 보기에도 표시', 'roadtrip.stops.inDays': '일별 보기에도 표시',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Gevarengebieden tonen',
'roadtrip.hazards.current': 'Actuele waarschuwingen',
'roadtrip.hazards.note': 'Waarschuwingen zijn geen wegafsluitingen. De route blijft ongewijzigd.',
'roadtrip.hazards.point': 'Alleen locatie; getroffen gebied onbekend.',
'roadtrip.hazards.loading': 'Waarschuwingen laden…',
'roadtrip.hazards.unavailable': 'Niet beschikbaar',
'roadtrip.hazards.offline': 'Offline: actuele waarschuwingen niet beschikbaar',
'roadtrip.hazards.partial': 'Beperkte dekking',
'roadtrip.stops.section': 'Servicestops', 'roadtrip.stops.section': 'Servicestops',
'roadtrip.stops.inDays': 'Ook in Dagen tonen', 'roadtrip.stops.inDays': 'Ook in Dagen tonen',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Pokaż obszary zagrożeń',
'roadtrip.hazards.current': 'Aktualne ostrzeżenia',
'roadtrip.hazards.note': 'Ostrzeżenia nie oznaczają zamknięcia dróg. Trasa pozostaje bez zmian.',
'roadtrip.hazards.point': 'Tylko lokalizacja; obszar zagrożenia nieznany.',
'roadtrip.hazards.loading': 'Ładowanie ostrzeżeń…',
'roadtrip.hazards.unavailable': 'Niedostępne',
'roadtrip.hazards.offline': 'Offline: aktualne ostrzeżenia niedostępne',
'roadtrip.hazards.partial': 'Ograniczony zasięg',
'roadtrip.stops.section': 'Postoje serwisowe', 'roadtrip.stops.section': 'Postoje serwisowe',
'roadtrip.stops.inDays': 'Pokaż także w Dniach', 'roadtrip.stops.inDays': 'Pokaż także w Dniach',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Показывать опасные зоны',
'roadtrip.hazards.current': 'Текущие предупреждения',
'roadtrip.hazards.note': 'Предупреждения не означают закрытие дорог. Маршрут не меняется.',
'roadtrip.hazards.point': 'Только место события; затронутая зона неизвестна.',
'roadtrip.hazards.loading': 'Загрузка предупреждений…',
'roadtrip.hazards.unavailable': 'Недоступно',
'roadtrip.hazards.offline': 'Нет сети: текущие предупреждения недоступны',
'roadtrip.hazards.partial': 'Ограниченный охват',
'roadtrip.stops.section': 'Сервисные остановки', 'roadtrip.stops.section': 'Сервисные остановки',
'roadtrip.stops.inDays': 'Показывать также в разделе «Дни»', 'roadtrip.stops.inDays': 'Показывать также в разделе «Дни»',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Visa riskområden',
'roadtrip.hazards.current': 'Aktuella varningar',
'roadtrip.hazards.note': 'Varningar är inte vägavstängningar. Rutten ändras inte.',
'roadtrip.hazards.point': 'Endast plats; berört område okänt.',
'roadtrip.hazards.loading': 'Läser in varningar…',
'roadtrip.hazards.unavailable': 'Inte tillgängligt',
'roadtrip.hazards.offline': 'Offline: aktuella varningar saknas',
'roadtrip.hazards.partial': 'Begränsad täckning',
'roadtrip.stops.section': 'Servicestopp', 'roadtrip.stops.section': 'Servicestopp',
'roadtrip.stops.inDays': 'Visa även i Dagar', 'roadtrip.stops.inDays': 'Visa även i Dagar',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Tehlike bölgelerini göster',
'roadtrip.hazards.current': 'Güncel uyarılar',
'roadtrip.hazards.note': 'Uyarılar yol kapatmaları değildir. Rota değişmez.',
'roadtrip.hazards.point': 'Yalnızca konum; etkilenen alan bilinmiyor.',
'roadtrip.hazards.loading': 'Uyarılar yükleniyor…',
'roadtrip.hazards.unavailable': 'Kullanılamıyor',
'roadtrip.hazards.offline': 'Çevrimdışı: güncel uyarılar kullanılamıyor',
'roadtrip.hazards.partial': 'Sınırlı kapsam',
'roadtrip.stops.section': 'Hizmet durakları', 'roadtrip.stops.section': 'Hizmet durakları',
'roadtrip.stops.inDays': 'Günler içinde de göster', 'roadtrip.stops.inDays': 'Günler içinde de göster',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Показувати небезпечні зони',
'roadtrip.hazards.current': 'Поточні попередження',
'roadtrip.hazards.note': 'Попередження не означають закриття доріг. Маршрут не змінюється.',
'roadtrip.hazards.point': 'Лише місце події; уражена зона невідома.',
'roadtrip.hazards.loading': 'Завантаження попереджень…',
'roadtrip.hazards.unavailable': 'Недоступно',
'roadtrip.hazards.offline': 'Офлайн: поточні попередження недоступні',
'roadtrip.hazards.partial': 'Обмежене охоплення',
'roadtrip.stops.section': 'Сервісні зупинки', 'roadtrip.stops.section': 'Сервісні зупинки',
'roadtrip.stops.inDays': 'Показувати також у розділі «Дні»', 'roadtrip.stops.inDays': 'Показувати також у розділі «Дні»',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': 'Hiển thị vùng nguy hiểm',
'roadtrip.hazards.current': 'Cảnh báo hiện tại',
'roadtrip.hazards.note': 'Cảnh báo không có nghĩa là đường bị đóng. Tuyến đường không thay đổi.',
'roadtrip.hazards.point': 'Chỉ có vị trí; chưa rõ vùng bị ảnh hưởng.',
'roadtrip.hazards.loading': 'Đang tải cảnh báo…',
'roadtrip.hazards.unavailable': 'Không khả dụng',
'roadtrip.hazards.offline': 'Ngoại tuyến: không có cảnh báo hiện tại',
'roadtrip.hazards.partial': 'Phạm vi hạn chế',
'roadtrip.stops.section': 'Điểm dừng dịch vụ', 'roadtrip.stops.section': 'Điểm dừng dịch vụ',
'roadtrip.stops.inDays': 'Hiển thị cả trong Ngày', 'roadtrip.stops.inDays': 'Hiển thị cả trong Ngày',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': '顯示危險區域',
'roadtrip.hazards.current': '目前警報',
'roadtrip.hazards.note': '警報不代表道路封閉。路線保持不變。',
'roadtrip.hazards.point': '僅有事件位置,受影響區域未知。',
'roadtrip.hazards.loading': '正在載入警報…',
'roadtrip.hazards.unavailable': '無法使用',
'roadtrip.hazards.offline': '離線:無法取得目前警報',
'roadtrip.hazards.partial': '涵蓋範圍有限',
'roadtrip.stops.section': '服務停靠點', 'roadtrip.stops.section': '服務停靠點',
'roadtrip.stops.inDays': '也顯示於每日行程', 'roadtrip.stops.inDays': '也顯示於每日行程',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+8
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const roadtrip: TranslationStrings = { const roadtrip: TranslationStrings = {
'roadtrip.hazards.show': '显示危险区域',
'roadtrip.hazards.current': '当前警报',
'roadtrip.hazards.note': '警报不代表道路封闭。路线保持不变。',
'roadtrip.hazards.point': '仅有事件位置,受影响区域未知。',
'roadtrip.hazards.loading': '正在加载警报…',
'roadtrip.hazards.unavailable': '不可用',
'roadtrip.hazards.offline': '离线:无法获取当前警报',
'roadtrip.hazards.partial': '覆盖有限',
'roadtrip.stops.section': '服务停靠点', 'roadtrip.stops.section': '服务停靠点',
'roadtrip.stops.inDays': '也显示于每日行程', 'roadtrip.stops.inDays': '也显示于每日行程',
'roadtrip.stops.daysHint': 'roadtrip.stops.daysHint':
+1
View File
@@ -77,4 +77,5 @@ export * from './plugin-permissions';
// Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes. // Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes.
export * from './plugins/plugins.schema'; export * from './plugins/plugins.schema';
export * from './roadtrip/preferences.schema'; export * from './roadtrip/preferences.schema';
export * from './roadtrip/hazards.schema';
export * from './roadtrip/planning.schema'; export * from './roadtrip/planning.schema';
@@ -0,0 +1,8 @@
import { hazardGeometrySchema } from './hazards.schema';
import { expect, it } from 'vitest';
it('rejects coordinates outside the map and incomplete polygon rings', () => {
expect(hazardGeometrySchema.safeParse({ type: 'Point', coordinates: [181, 50] }).success).toBe(false);
expect(hazardGeometrySchema.safeParse({ type: 'Polygon', coordinates: [[[10, 50]]] }).success).toBe(false);
});
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
const position = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]);
const ring = z.array(position).min(4).max(50000);
export const hazardGeometrySchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('Point'), coordinates: position }),
z.object({ type: z.literal('Polygon'), coordinates: z.array(ring).min(1).max(500) }),
z.object({ type: z.literal('MultiPolygon'), coordinates: z.array(z.array(ring).min(1).max(500)).max(500) }),
]);
export const roadtripHazardSchema = z.object({
id: z.string(),
source: z.enum(['DWD', 'GDACS']),
title: z.string().max(2000),
description: z.string().max(20000),
updatedAt: z.string().datetime(),
validUntil: z.string().datetime().nullable(),
url: z.string().url(),
geometry: hazardGeometrySchema,
});
export const roadtripHazardsSchema = z.object({
fetchedAt: z.string().datetime(),
hazards: z.array(roadtripHazardSchema).max(1000),
sources: z.array(z.object({ source: z.enum(['DWD', 'GDACS']), status: z.enum(['ok', 'partial', 'unavailable']) })),
});
export type RoadtripHazard = z.infer<typeof roadtripHazardSchema>;
export type RoadtripHazards = z.infer<typeof roadtripHazardsSchema>;
@@ -21,6 +21,7 @@ export const roadtripPreferencesSchema = z
roadtrip_connect_days: z.boolean().optional(), roadtrip_connect_days: z.boolean().optional(),
roadtrip_service_stops_in_days: z.boolean().optional(), roadtrip_service_stops_in_days: z.boolean().optional(),
roadtrip_day_colors: z.boolean().optional(), roadtrip_day_colors: z.boolean().optional(),
roadtrip_show_hazards: z.boolean().optional(),
roadtrip_avoid: z roadtrip_avoid: z
.string() .string()
.max(32) .max(32)
+2
View File
@@ -235,6 +235,8 @@ Resources provide read-only access via `trek://` URIs. The following resources r
--- ---
Roadtrip also exposes `get_roadtrip_hazards` (`tripId`, trips read scope). It returns current DWD/GDACS notices with geometry, source timestamps and availability. This does not reroute the trip. The shared `roadtrip_show_hazards` setting controls the online map overlay.
## Related ## Related
- [MCP-Tools-and-Resources](MCP-Tools-and-Resources) - [MCP-Tools-and-Resources](MCP-Tools-and-Resources)
+4
View File
@@ -91,6 +91,10 @@ When you first open a trip, a brief loading screen appears while the planner dat
| Weather forecasts | [Weather-Forecasts](Weather-Forecasts) | | Weather forecasts | [Weather-Forecasts](Weather-Forecasts) |
| Reservations and bookings | [Reservations-and-Bookings](Reservations-and-Bookings) | | Reservations and bookings | [Reservations-and-Bookings](Reservations-and-Bookings) |
Driving settings includes **Show hazard areas**, off by default and shared by the trip. In online Roadtrip mode, the map loads current DWD warnings for Germany and worldwide GDACS notices. Click an area or event point for its source report and update time. A point alone does not describe the affected area. Feeds refresh every ten minutes; unavailable or incomplete sources are labelled. These are current notices, not forecasts for travel dates or confirmed road closures, and they do not change routing. GDACS supplies a limited recent-event feed, with affected-area geometry loaded for up to twelve flood, wildfire or cyclone events. Coverage is not exhaustive.
> **AI / MCP:** Use `get_roadtrip_hazards` to read notices and source availability. Set `roadtrip_show_hazards` through `update_roadtrip_settings` to change the shared map setting.
## Related Pages ## Related Pages
- [Places-and-Search](Places-and-Search) - [Places-and-Search](Places-and-Search)