diff --git a/client/src/components/Map/HazardLayers.tsx b/client/src/components/Map/HazardLayers.tsx new file mode 100644 index 000000000..9ccaa050b --- /dev/null +++ b/client/src/components/Map/HazardLayers.tsx @@ -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 => 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 })} + />)} +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index ff69bcc27..3f68a6bae 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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 && } {poiMarkers} {/* Charging stops / rest areas a plugin route places on the drawn day route. Host-vetted data (server-normalized), rendered as plain tone dots. */} diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index d9a5024ec..f6084e340 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -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 ?
+ {hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <> + + + {t('roadtrip.hazards.current')} + + + + {new Date(hazards.feed!.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + + {hazards.feed!.sources.map(source => + + {source.source} + {source.status !== 'ok' && } + + )} + + } +
: 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. - }> - }> - + <>}> + }> + - + {status} ) } - return + return <>{status} } diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 6394bbc3c..4266841f4 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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 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(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(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>(new Map()) // Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin). diff --git a/client/src/components/Map/hazardPopup.test.ts b/client/src/components/Map/hazardPopup.test.ts new file mode 100644 index 000000000..f89da7e2a --- /dev/null +++ b/client/src/components/Map/hazardPopup.test.ts @@ -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: '', description: '', + 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') +}) diff --git a/client/src/components/Map/hazardPopup.ts b/client/src/components/Map/hazardPopup.ts new file mode 100644 index 000000000..cdb3adc86 --- /dev/null +++ b/client/src/components/Map/hazardPopup.ts @@ -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 +} diff --git a/client/src/components/Map/useHazardLayerGL.ts b/client/src/components/Map/useHazardLayerGL.ts new file mode 100644 index 000000000..123c363e7 --- /dev/null +++ b/client/src/components/Map/useHazardLayerGL.ts @@ -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]) +} diff --git a/client/src/components/Map/useRoadtripHazards.ts b/client/src/components/Map/useRoadtripHazards.ts new file mode 100644 index 000000000..12453f826 --- /dev/null +++ b/client/src/components/Map/useRoadtripHazards.ts @@ -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(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 } +} diff --git a/client/src/components/Roadtrip/DayWindowFields.tsx b/client/src/components/Roadtrip/DayWindowFields.tsx index 286b4cb60..5947a003c 100644 --- a/client/src/components/Roadtrip/DayWindowFields.tsx +++ b/client/src/components/Roadtrip/DayWindowFields.tsx @@ -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 ( -
+
{t('roadtrip.window.title')} {(['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 ) })} -

- {t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')} -

-
+ {invalid + ? + : {t('roadtrip.window.hint')}} +
{t('roadtrip.window.endAt')} {t('roadtrip.window.endAt')}
@@ -61,7 +62,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave ))}
-

{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}

+ {t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}
) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index ad13f964a..bba178623 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -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 ( -
+
{title} {children} - {note ?

{note}

: null} + {note ? {note} : null}
) } @@ -429,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi {t('roadtrip.limit.title')} }> -
+
{/* 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. */}
-
+
-
+
onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)} /> + + onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} /> + +

{children}

+ +} diff --git a/client/src/repo/roadtripHazardsRepo.ts b/client/src/repo/roadtripHazardsRepo.ts new file mode 100644 index 000000000..e7e72e88d --- /dev/null +++ b/client/src/repo/roadtripHazardsRepo.ts @@ -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) + }, +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.controller.ts b/server/src/nest/roadtrip/roadtrip-hazards.controller.ts new file mode 100644 index 000000000..183b29b87 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.controller.ts @@ -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(); } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts new file mode 100644 index 000000000..58a5c8fc2 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts @@ -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()); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.service.ts b/server/src/nest/roadtrip/roadtrip-hazards.service.ts new file mode 100644 index 000000000..af2ff8ed5 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.service.ts @@ -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(fetched, 8_000_000)); +} + +@Injectable() +export class RoadtripHazardsService { + private cached?: RoadtripHazards; + private pending?: Promise; + + async read(): Promise { + 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 { + 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 }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 9230bd8c9..2b59b5634 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -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, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 2dfa445da..4bf48ef2e 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/roadtrip-hazards.test.ts b/server/tests/unit/nest/roadtrip-hazards.test.ts new file mode 100644 index 000000000..2d4870226 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-hazards.test.ts @@ -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'); + }); +}); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 8b64fd77d..de10a55b4 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index a94de3e61..4fd4e4006 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 701530054..d7041ef80 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 903d93d74..3708ddc67 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 41d022e0d..e8e4fc0e1 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 932b9ae95..af0880800 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 3772784b6..655df2f3b 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index de5453aa4..9d2b03a36 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index e2e01f6fc..090224592 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index a66e62918..1ac1dcc1c 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index ceaef212c..4f649d4b8 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index e6a79d273..929b9ec39 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index f4f5b7076..a9e503766 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index b9eb42f17..c6f45e9f0 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 4252fa9f3..222f5e478 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index cd94814f4..360893fa0 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 6b7416725..6c4d52ab6 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index f9be3bf6c..0c14156d7 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index f6c8bc165..4680bd4c8 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 29b47c8a7..389edcdab 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 37ca59b95..2f1cd15d3 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 1bd196767..1fa2c4ebc 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -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': diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index b1179999c..fe92a5810 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -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': diff --git a/shared/src/index.ts b/shared/src/index.ts index 4bb5fae25..e926e450d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -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'; diff --git a/shared/src/roadtrip/hazards.schema.spec.ts b/shared/src/roadtrip/hazards.schema.spec.ts new file mode 100644 index 000000000..63257fd98 --- /dev/null +++ b/shared/src/roadtrip/hazards.schema.spec.ts @@ -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); +}); diff --git a/shared/src/roadtrip/hazards.schema.ts b/shared/src/roadtrip/hazards.schema.ts new file mode 100644 index 000000000..adfea3837 --- /dev/null +++ b/shared/src/roadtrip/hazards.schema.ts @@ -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; +export type RoadtripHazards = z.infer; diff --git a/shared/src/roadtrip/preferences.schema.ts b/shared/src/roadtrip/preferences.schema.ts index e1c0726ec..cfad5b4e7 100644 --- a/shared/src/roadtrip/preferences.schema.ts +++ b/shared/src/roadtrip/preferences.schema.ts @@ -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) diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 5efdaff88..f5574b83c 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -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) diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 302fb2d2e..78b37679b 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -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)