add open charging data and polish roadtrip warnings

This commit is contained in:
Maurice
2026-09-12 13:25:15 +02:00
parent ff5fa55fad
commit f56e7a161e
52 changed files with 820 additions and 32 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] })
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 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
/>)}</>
}
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -64,6 +65,7 @@ vi.mock('react-leaflet', () => ({
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
<div
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
@@ -632,6 +634,23 @@ describe('MapView explore POIs', () => {
expect(onPoiClick).toHaveBeenCalledWith(poi)
})
it('keeps native POI clicks working after a pan and uses the latest callback', () => {
const first = vi.fn()
const latest = vi.fn()
const poi = buildPoi({ osm_id: 'node/7' })
const { rerender } = render(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
const marker = markersWithZ('500')[0]
expect(marker).toHaveAttribute('draggable', 'true')
fireEvent.mouseDown(marker)
fireEvent.mouseUp(marker)
fireEvent.click(marker)
expect(first).toHaveBeenCalledTimes(1)
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
fireEvent.click(markersWithZ('500')[0])
expect(latest).toHaveBeenCalledExactlyOnceWith(poi)
expect(first).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => {
render(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
const html = iconHtmlOf(markersWithZ('500')[0])
+8 -1
View File
@@ -685,9 +685,12 @@ export const MapView = memo(function MapView({
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
@@ -697,7 +700,11 @@ export const MapView = memo(function MapView({
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
if (!el) return
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name)
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
+1 -1
View File
@@ -21,7 +21,7 @@ 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">
const status = hazards.enabled ? <div role="status" style={props.hasInspector || props.hasDayDetail ? { top: 64 } : { bottom: 40 }} className="absolute 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 />
+1 -1
View File
@@ -710,7 +710,7 @@ 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])
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', 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())
+133
View File
@@ -0,0 +1,133 @@
.trek-hazard-content {
font-family: var(--font-system);
}
.trek-hazard-score {
padding: 7px 10px 5px;
border: 1px solid var(--border-faint);
border-radius: 10px;
background: var(--bg-hover);
}
.trek-hazard-score-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--text-secondary);
font-weight: 500;
}
.trek-hazard-score-heading strong {
font-size: calc(16px * var(--fs-scale-caption, 1));
font-weight: 650;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.trek-hazard-score-track {
position: relative;
display: flex;
gap: 3px;
height: 5px;
}
.trek-hazard-score-track > span:not(.trek-hazard-score-marker) {
flex: 1;
border-radius: 3px;
opacity: 0.75;
}
.trek-hazard-score-marker {
position: absolute;
top: 50%;
width: 10px;
height: 10px;
border: 2px solid var(--bg-card);
border-radius: 50%;
background: var(--text-primary);
box-sizing: border-box;
transform: translate(-50%, -50%);
box-shadow: var(--shadow-sm);
}
.trek-hazard-score-ticks {
display: flex;
justify-content: space-between;
margin-top: 4px;
color: var(--text-muted);
font-size: calc(10px * var(--fs-scale-caption, 1));
font-variant-numeric: tabular-nums;
}
.trek-hazard-popup .leaflet-popup-content {
margin: 14px 16px;
}
.trek-hazard-popup .mapboxgl-popup-content,
.trek-hazard-popup .maplibregl-popup-content {
padding: 14px 16px;
border: 1px solid var(--border-faint);
border-radius: 14px;
background: var(--bg-card);
color: var(--text-primary);
box-shadow: var(--shadow-popover);
}
.trek-hazard-popup a.trek-hazard-source {
color: var(--text-secondary);
text-decoration: none;
}
.trek-hazard-popup a.trek-hazard-source:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button,
.trek-hazard-popup .mapboxgl-popup-close-button,
.trek-hazard-popup .maplibregl-popup-close-button {
top: 10px;
right: 10px;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 0;
transition: background-color 150ms ease, color 150ms ease !important;
}
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button span {
display: none;
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::before,
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
content: '';
position: absolute;
top: 12px;
left: 6px;
width: 14px;
height: 1.5px;
border-radius: 1px;
background: currentColor;
transform: rotate(45deg);
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
transform: rotate(-45deg);
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button):hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: none;
}
.trek-hazard-popup :is(a, button):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
+73 -11
View File
@@ -1,3 +1,7 @@
import { createElement } from 'react'
import { AlertTriangle, ArrowUpRight } from 'lucide-react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import './hazardPopup.css'
import type { RoadtripHazard } from '@trek/shared'
import type { Feature, Geometry } from 'geojson'
@@ -7,21 +11,79 @@ export function hazardFeature(hazard: RoadtripHazard): Feature {
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
box.className = 'trek-hazard-content flex max-w-xs flex-col gap-3 text-content'
box.style.fontSize = 'calc(12px * var(--fs-scale-caption, 1))'
box.style.lineHeight = '1.45'
const title = document.createElement('div')
title.className = 'flex items-center gap-2.5 pr-7 font-semibold'
const symbol = document.createElement('span')
symbol.className = 'flex size-7 shrink-0 items-center justify-center rounded-lg bg-warning-soft text-warning'
symbol.innerHTML = renderIconMarkup(createElement(AlertTriangle, { size: 15, strokeWidth: 1.8 }))
const heading = document.createElement('span')
heading.textContent = hazard.title
title.append(symbol, heading)
box.append(title)
if (hazard.description.trim() && hazard.description.trim().toLocaleLowerCase() !== hazard.title.trim().toLocaleLowerCase()) {
const details = document.createElement('div')
details.className = 'text-content-secondary'
details.textContent = hazard.description
box.append(details)
}
const metadata = document.createElement('div')
metadata.className = 'flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-content-muted'
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)
source.className = 'trek-hazard-source inline-flex items-center gap-1 rounded-md bg-surface-hover px-1.5 py-0.5 font-medium'
const external = document.createElement('span')
external.innerHTML = renderIconMarkup(createElement(ArrowUpRight, { size: 12 }))
source.append(external)
const timestamp = document.createElement('time')
timestamp.dateTime = hazard.updatedAt
timestamp.textContent = new Date(hazard.updatedAt).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
metadata.append(source, timestamp)
const caution = document.createElement('div')
caution.className = 'border-t border-edge-faint pt-2.5 text-content-muted'
caution.textContent = hazard.geometry.type === 'Point' ? pointNote + ' ' + note : note
box.append(metadata)
if (hazard.source === 'GDACS' && hazard.alertScore != null) box.append(scoreBar(hazard.alertScore))
box.append(caution)
return box
}
function scoreBar(score: number): HTMLDivElement {
const panel = document.createElement('div')
panel.className = 'trek-hazard-score'
const heading = document.createElement('div')
heading.className = 'trek-hazard-score-heading'
const label = document.createElement('span')
label.textContent = 'GDACS Score'
const value = document.createElement('strong')
value.textContent = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(score)
value.style.color = score < 1 ? 'var(--success)' : score < 2 ? 'var(--warning)' : 'var(--danger)'
heading.append(label, value)
const track = document.createElement('div')
track.className = 'trek-hazard-score-track'
track.setAttribute('role', 'img')
track.setAttribute('aria-label', 'GDACS Score ' + value.textContent)
for (const color of ['success', 'warning', 'danger']) {
const segment = document.createElement('span')
segment.style.background = 'var(--' + color + ')'
track.append(segment)
}
const marker = document.createElement('span')
marker.className = 'trek-hazard-score-marker'
marker.style.left = Math.min(score / 3, 1) * 100 + '%'
track.append(marker)
const ticks = document.createElement('div')
ticks.className = 'trek-hazard-score-ticks'
for (const tick of ['0', '1', '2', '3+']) {
const number = document.createElement('span')
number.textContent = tick
ticks.append(number)
}
panel.append(heading, track, ticks)
return panel
}