mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
add roadtrip warning areas and tidy driving settings
This commit is contained in:
@@ -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 })}
|
||||
/>)}</>
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
@@ -666,6 +667,7 @@ export const MapView = memo(function MapView({
|
||||
tripId,
|
||||
routeVias = [],
|
||||
dayBoundaryControls,
|
||||
hazards,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -1153,6 +1155,7 @@ export const MapView = memo(function MapView({
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
{/* Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
Host-vetted data (server-normalized), rendered as plain tone dots. */}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AlertTriangle, Clock, Info } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
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
|
||||
// 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.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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 token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// 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.
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
|
||||
<Suspense fallback={<MapView {...props} />}>
|
||||
<MapViewGL {...props} glProvider={glProvider} />
|
||||
<><ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>{status}</>
|
||||
)
|
||||
}
|
||||
return <MapView {...props} />
|
||||
return <><MapView {...mapProps} />{status}</>
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
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 NightPauseTooltip from './NightPauseTooltip'
|
||||
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.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
hazards?: RoadtripHazard[]
|
||||
/** 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
|
||||
@@ -631,6 +634,7 @@ export function MapViewGL({
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
hazards,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -706,6 +710,8 @@ export function MapViewGL({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// 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')
|
||||
})
|
||||
@@ -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 { dayWindow } from './dayWindow'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import SettingsHint from './SettingsHint'
|
||||
|
||||
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
|
||||
start?: string
|
||||
@@ -25,7 +26,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
}
|
||||
}
|
||||
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>
|
||||
{(['start', 'end'] as const).map(field => {
|
||||
const Icon = field === 'start' ? Sunrise : Sunset
|
||||
@@ -45,10 +46,10 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p id="roadtrip-window-hint" role={invalid ? 'alert' : undefined} className={`text-caption ${invalid ? 'text-danger' : 'text-content-faint'}`}>
|
||||
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
|
||||
</p>
|
||||
<fieldset className="flex flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
{invalid
|
||||
? <p id="roadtrip-window-hint" role="alert" className="text-caption text-danger">{t('roadtrip.window.invalid')}</p>
|
||||
: <SettingsHint id="roadtrip-window-hint">{t('roadtrip.window.hint')}</SettingsHint>}
|
||||
<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>
|
||||
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -61,7 +62,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
import SettingsHint from './SettingsHint'
|
||||
import { dayWindow } from './dayWindow'
|
||||
|
||||
/**
|
||||
@@ -193,10 +194,10 @@ function Panel({ icon: Icon, title, note, children }: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
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>
|
||||
{children}
|
||||
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
|
||||
{note ? <SettingsHint>{note}</SettingsHint> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -429,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
{t('roadtrip.limit.title')}
|
||||
</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
|
||||
something that has to be scrolled, and a setting you cannot see while you
|
||||
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
|
||||
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="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')}>
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
@@ -484,7 +485,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel
|
||||
icon={Car}
|
||||
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)}
|
||||
/>
|
||||
</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')}>
|
||||
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
|
||||
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>
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import { RoadtripController } from './roadtrip.controller';
|
||||
import { RoadtripMcp } from './roadtrip.mcp';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
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. */
|
||||
@Module({
|
||||
@@ -34,8 +37,10 @@ import { Module } from '@nestjs/common';
|
||||
PluginsRuntimeModule,
|
||||
MapsModule,
|
||||
],
|
||||
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController],
|
||||
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [
|
||||
RoadtripHazardsService,
|
||||
RoadtripHazardsMcp,
|
||||
RoadtripService,
|
||||
RoadtripMcp,
|
||||
DayBoundariesService,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
|
||||
import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
@@ -130,6 +131,17 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'إظهارها في الأيام أيضًا',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Mostrar também em Dias',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Mostra també a Dies',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Zobrazit také ve Dnech',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Auch in Days anzeigen',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Gilt für alle Service Stops dieser Reise. Ausgeschaltet bleiben sie ausschließlich im Roadtrip-Plan; beim Einschalten erscheinen sie wieder in Days.',
|
||||
'roadtrip.stops.daysHint': 'Aus: Alle Service Stops bleiben nur im Roadtrip.',
|
||||
'roadtrip.window.dragHint':
|
||||
'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',
|
||||
@@ -14,16 +21,15 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.endAt': 'Tagesende planen',
|
||||
'roadtrip.window.onRoute': 'Auf der Strecke',
|
||||
'roadtrip.window.atStop': 'Am letzten Ort',
|
||||
'roadtrip.window.routeHint': 'Zur Endzeit an der erreichten Stelle auf der Strecke pausieren.',
|
||||
'roadtrip.window.stopHint': 'Am letzten Ort bleiben, wenn der nächste vor Tagesende nicht mehr erreichbar ist.',
|
||||
'roadtrip.window.routeHint': 'Zur Endzeit auf der Strecke pausieren.',
|
||||
'roadtrip.window.stopHint': 'Stoppen, wenn der nächste Ort zu spät erreicht würde.',
|
||||
'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.',
|
||||
'roadtrip.window.pointLabel': 'Tagesende von Tag {day} um {time} Uhr',
|
||||
'roadtrip.window.title': 'Tägliche Reisezeiten',
|
||||
'roadtrip.window.start': 'Tagesbeginn',
|
||||
'roadtrip.window.end': 'Tagesende',
|
||||
'roadtrip.window.hint':
|
||||
'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.hint': 'Beide Zeiten aktivieren. Manuelle Zeiten gehen vor.',
|
||||
'roadtrip.window.invalid': 'Das Tagesende muss nach dem Tagesbeginn liegen.',
|
||||
'roadtrip.window.stop': 'Tagesende',
|
||||
'roadtrip.window.resume': 'Weiterfahrt',
|
||||
@@ -75,8 +81,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll',
|
||||
'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.specHint': 'Ausgefüllt bestimmen diese Werte die Reichweite oben.',
|
||||
'roadtrip.limit.vehicleHint': 'Sag, was du fährst, dann kannst du statt der Reichweite die Fahrzeugdaten angeben.',
|
||||
'roadtrip.limit.specHint': 'Fahrzeugdaten bestimmen die Reichweite.',
|
||||
'roadtrip.limit.vehicleHint': 'Fahrzeug wählen und Reichweite berechnen.',
|
||||
'roadtrip.limit.minutes': 'Min',
|
||||
'roadtrip.limit.off': 'aus',
|
||||
'roadtrip.limit.blockNote': 'Ein Block = {distance}',
|
||||
@@ -85,7 +91,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.echoDry': '{count} Abschnitte ohne erreichbaren Stopp.',
|
||||
'roadtrip.limit.echoEmpty': 'Öffne einen Roadtrip, um zu sehen, was das bewirkt.',
|
||||
'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.dayOver': '{time} drüber',
|
||||
'roadtrip.limit.range': '{distance} seit dem letzten Tanken',
|
||||
@@ -93,15 +99,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.line.section': 'Routenlinie',
|
||||
'roadtrip.line.connect': 'Tage verbinden',
|
||||
'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.toll': 'Mautstraßen',
|
||||
'roadtrip.avoid.motorway': 'Autobahnen',
|
||||
'roadtrip.avoid.ferry': 'Fähren',
|
||||
'roadtrip.avoid.hint':
|
||||
'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':
|
||||
'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.',
|
||||
'roadtrip.avoid.hint': 'Meidet nach Möglichkeit und markiert Ausnahmen.',
|
||||
'roadtrip.avoid.unavailable': 'Der Routing-Anbieter unterstützt kein Meiden.',
|
||||
'roadtrip.avoid.badge': '{count} gemieden',
|
||||
'roadtrip.avoid.missed': '{classes} unvermeidbar',
|
||||
'roadtrip.avoid.missedHint':
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Show in Days too',
|
||||
'roadtrip.stops.daysHint':
|
||||
'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.stops.daysHint': 'Off keeps all service stops in Roadtrip only.',
|
||||
'roadtrip.window.dragHint':
|
||||
'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',
|
||||
@@ -14,16 +21,15 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.endAt': 'End the day',
|
||||
'roadtrip.window.onRoute': 'Along the route',
|
||||
'roadtrip.window.atStop': 'At the last place',
|
||||
'roadtrip.window.routeHint': 'Pause along the route when the end time is reached.',
|
||||
'roadtrip.window.stopHint': 'Stay at the last place if the next one cannot be reached before the end time.',
|
||||
'roadtrip.window.routeHint': 'Pause on the route at the end time.',
|
||||
'roadtrip.window.stopHint': 'Stop before the next place would exceed the end time.',
|
||||
'roadtrip.window.legTooLong':
|
||||
'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.title': 'Daily travel times',
|
||||
'roadtrip.window.start': 'Day start time',
|
||||
'roadtrip.window.end': 'Day end time',
|
||||
'roadtrip.window.hint':
|
||||
'Set both times to plan daily breaks. Clear either to turn this off. Manual times take priority. Days are connected automatically.',
|
||||
'roadtrip.window.hint': 'Set both times to enable. Manual times take priority.',
|
||||
'roadtrip.window.invalid': 'The end time must be later than the start time.',
|
||||
'roadtrip.window.stop': 'End of day',
|
||||
'roadtrip.window.resume': 'Continue journey',
|
||||
@@ -74,8 +80,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.fullNote': 'A stop fills right up',
|
||||
'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.specHint': 'Filled in, these decide the range above.',
|
||||
'roadtrip.limit.vehicleHint': 'Name the kind of car and you can state its figures instead of a range.',
|
||||
'roadtrip.limit.specHint': 'Vehicle details determine the range.',
|
||||
'roadtrip.limit.vehicleHint': 'Choose a vehicle to calculate its range.',
|
||||
'roadtrip.limit.minutes': 'min',
|
||||
'roadtrip.limit.off': 'off',
|
||||
'roadtrip.limit.blockNote': 'One block = {distance}',
|
||||
@@ -84,7 +90,7 @@ const roadtrip: TranslationStrings = {
|
||||
'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.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.dayOver': '{time} over',
|
||||
'roadtrip.limit.range': '{distance} since the last fill-up',
|
||||
@@ -92,15 +98,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.line.section': 'Route line',
|
||||
'roadtrip.line.connect': 'Connect the days',
|
||||
'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.toll': 'Toll roads',
|
||||
'roadtrip.avoid.motorway': 'Motorways',
|
||||
'roadtrip.avoid.ferry': 'Ferries',
|
||||
'roadtrip.avoid.hint':
|
||||
'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':
|
||||
'Not available: this instance routes through its own engine, which cannot leave a road class out.',
|
||||
'roadtrip.avoid.hint': 'Avoids where possible; exceptions are flagged.',
|
||||
'roadtrip.avoid.unavailable': 'Your routing provider does not support avoidance.',
|
||||
'roadtrip.avoid.badge': '{count} avoided',
|
||||
'roadtrip.avoid.missed': '{classes} unavoidable',
|
||||
'roadtrip.avoid.missedHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Mostrar también en Días',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Afficher aussi dans Jours',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Εμφάνιση και στις Ημέρες',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Megjelenítés a Napok nézetben is',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Tampilkan juga di Hari',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Mostra anche in Giorni',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': '日別にも表示',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': '일별 보기에도 표시',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Ook in Dagen tonen',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Pokaż także w Dniach',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Показывать также в разделе «Дни»',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Visa även i Dagar',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Günler içinde de göster',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Показувати також у розділі «Дні»',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': 'Hiển thị cả trong Ngày',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': '也顯示於每日行程',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
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.inDays': '也显示于每日行程',
|
||||
'roadtrip.stops.daysHint':
|
||||
|
||||
@@ -77,4 +77,5 @@ export * from './plugin-permissions';
|
||||
// Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes.
|
||||
export * from './plugins/plugins.schema';
|
||||
export * from './roadtrip/preferences.schema';
|
||||
export * from './roadtrip/hazards.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);
|
||||
});
|
||||
@@ -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_service_stops_in_days: z.boolean().optional(),
|
||||
roadtrip_day_colors: z.boolean().optional(),
|
||||
roadtrip_show_hazards: z.boolean().optional(),
|
||||
roadtrip_avoid: z
|
||||
.string()
|
||||
.max(32)
|
||||
|
||||
@@ -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
|
||||
|
||||
- [MCP-Tools-and-Resources](MCP-Tools-and-Resources)
|
||||
|
||||
@@ -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) |
|
||||
| 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
|
||||
|
||||
- [Places-and-Search](Places-and-Search)
|
||||
|
||||
Reference in New Issue
Block a user