feat(maps): pass through what OSM knows about a charging station

The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.

The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.

Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.

The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
This commit is contained in:
Maurice
2026-09-04 23:25:38 +02:00
committed by Maurice
parent fc74991a15
commit 061259b3d9
31 changed files with 374 additions and 33 deletions
@@ -58,5 +58,17 @@ export interface Poi {
phone: string | null
opening_hours: string | null
cuisine: string | null
/**
* What a charging station offers, when it is one and OSM says so.
*
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
* own answer here and must not be read as a no.
*/
charging?: {
sockets: { type: string; count: number | null; kw: number | null }[]
capacity: number | null
fee: boolean | null
} | null
source: 'openstreetmap'
}
@@ -91,6 +91,10 @@ function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<Roadtrip
widthKm: 5,
setWidthKm: vi.fn(),
nameFilter: '',
socketFilter: '',
setSocketFilter: vi.fn(),
minKw: 0,
setMinKw: vi.fn(),
setNameFilter: vi.fn(),
// Same as the hook with an empty filter: everything found is on show.
visible: searchState.results,
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useMemo } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
} from 'lucide-react'
@@ -27,6 +27,30 @@ interface RoadtripCorridorPanelProps {
) => void
}
/**
* How each socket family is written on the plug.
*
* Not translated and not derived from the tag: these are proper nouns with an accepted
* spelling, and "Type 2" as a lowercase tag key is not what anybody looks for on a
* charger. Anything not listed falls through to the raw key rather than being hidden,
* because an unknown socket is still worth seeing.
*/
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
const KW_STEPS = [11, 22, 50, 150]
const SOCKET_LABEL: Record<string, string> = {
type2: 'Type 2',
type2_combo: 'CCS',
type2_cable: 'Type 2 cable',
ccs: 'CCS',
chademo: 'CHAdeMO',
type1: 'Type 1',
type1_combo: 'CCS1',
schuko: 'Schuko',
tesla_supercharger: 'Supercharger',
tesla_destination: 'Tesla Destination',
}
/** Label and icon per category, from the one table every road-trip surface reads. */
const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
@@ -85,6 +109,24 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* What the charger offers, where OSM says. Socket names are proper nouns and
stay as they are; the numbers around them are what decides whether a car can
use it at all. A station that says nothing shows nothing rather than a row
of dashes, because "not stated" is not "no". */}
{poi.charging?.sockets.length ? (
<span className="flex flex-wrap items-baseline gap-x-1.5">
{poi.charging.sockets.slice(0, 3).map(s => (
<span key={s.type} className="text-content-muted">
{SOCKET_LABEL[s.type] ?? s.type}
{s.kw ? ` ${s.kw} kW` : ''}
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
</span>
))}
</span>
) : null}
{poi.charging?.fee === false ? (
<span className="text-success">{t('roadtrip.poi.free')}</span>
) : null}
</div>
</div>
{/* Always there, quiet until the row is under the pointer: a button that only
@@ -186,6 +228,18 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
// Something was found, the filter just hides it — a different state from "not searched
// yet" and from "the drive really has none of these".
/**
* The socket families this search actually turned up, in the order the table lists
* them. Offering all ten would put CHAdeMO in the dropdown for a corridor that has
* three Type 2 posts on it, which is a filter that can only produce an empty list.
*/
const socketsFound = useMemo(() => {
const seen = new Set<string>()
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
return [...seen].sort()
}, [search.results])
const hasCharging = socketsFound.length > 0
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
const canSearch = !search.loading && corridor.categories.length > 0 && (corridor.day?.stops.length ?? 0) > 1
@@ -258,16 +312,18 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
</div>
</div>
{/* One word, so it can be read at the size a primary action deserves. The panel
it sits in is headed "along the route" and every control above it narrows the
same search; repeating that on the button only made it small. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
style={{ fontSize: FS.control }}
className="flex h-[34px] w-full items-center justify-center gap-1.5 rounded-xl bg-accent font-semibold text-accent-text transition-opacity disabled:opacity-50"
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={13} className="animate-spin" aria-hidden />
: <Search size={13} strokeWidth={2} aria-hidden />}
? <RotateCw size={15} className="animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} aria-hidden />}
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
@@ -342,6 +398,35 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
</button>
) : null}
</div>
{/* Only where there is a charger to narrow. Two answers OSM actually carries
often enough to filter on: which plug, and how fast. A station that states
neither stays in the list, because roughly two thirds of them state no
power at all and reading that silence as "too slow" would empty the map. */}
{hasCharging ? (
<div className="mt-2 flex gap-2">
<CustomSelect
value={corridor.socketFilter}
onChange={value => corridor.setSocketFilter(String(value))}
options={[
{ value: '', label: t('roadtrip.poi.anySocket') },
...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })),
]}
style={{ flex: 1 }}
size="sm"
/>
<CustomSelect
value={String(corridor.minKw)}
onChange={value => corridor.setMinKw(Number(value))}
options={[
{ value: '0', label: t('roadtrip.poi.anyPower') },
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
]}
style={{ flex: 1 }}
size="sm"
/>
</div>
) : null}
</div>
) : null}
@@ -23,6 +23,12 @@ export interface RoadtripCorridor {
search: CorridorSearch
/** Narrows what was found by name or brand. Empty means everything. */
nameFilter: string
/** Socket family the charging hits are narrowed to, or empty for any. */
socketFilter: string
setSocketFilter: (value: string) => void
/** Minimum kW for charging hits, or 0 for any. */
minKw: number
setMinKw: (value: number) => void
setNameFilter: (value: string) => void
/**
* What both the panel and the map show: the hits that match `nameFilter`.
@@ -50,6 +56,10 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
const [categories, setCategories] = useState<string[]>(['fuel'])
const [widthKm, setWidthKm] = useState<number>(5)
const [nameFilter, setNameFilter] = useState('')
/** A socket family, or empty for any. Only ever applied to charging hits. */
const [socketFilter, setSocketFilter] = useState('')
/** Minimum kW, or 0 for any. */
const [minKw, setMinKw] = useState(0)
// Falls back to the first day with a drive, so the panel is useful before the user
// has picked anything — and follows along when that day disappears.
@@ -93,10 +103,26 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
*/
const visible = useMemo(() => {
const needle = nameFilter.trim().toLowerCase()
if (!needle) return search.results
return search.results.filter(p =>
p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))
}, [search.results, nameFilter])
// The same array when nothing narrows it, not a copy of it: the map redraws off this
// reference, and handing it a fresh array on every render moves every pin.
if (!needle && !socketFilter && !minKw) return search.results
return search.results.filter(p => {
if (needle && !(p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))) {
return false
}
// The charging filters only ever hide charging hits. A rest area does not have a
// socket and is not answering the question, so filtering the whole list by one
// would empty it of everything the search also found.
if (p.category !== 'charging') return true
if (socketFilter && !p.charging?.sockets.some(s => s.type === socketFilter)) return false
// A station that does not state its power is kept. Roughly two thirds of them do
// not, and reading silence as "too slow" would throw away most of the map.
if (minKw && p.charging?.sockets.some(s => s.kw != null) && !p.charging.sockets.some(s => (s.kw ?? 0) >= minKw)) {
return false
}
return true
})
}, [search.results, nameFilter, socketFilter, minKw])
/**
* Where a hit belongs in the day's chain, as an index among its stops.
@@ -131,6 +157,10 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
setWidthKm,
search,
nameFilter,
socketFilter,
setSocketFilter,
minKw,
setMinKw,
setNameFilter,
visible,
insertIndexFor,