feat: migrate from ORS API to self-hosted Valhalla

- API calls rewritten for Valhalla isochrone endpoint
- Transport modes: auto/bicycle/pedestrian (Valhalla costing)
- Time ranges now up to 10h (no more 1h ORS limit)
- Defaults: 1h, 2h, 4h — input in hours with 0.5h steps
- Valhalla URL configurable in settings (default: routing.kerboul.me)
- No API key needed (self-hosted on CT 201 / Echelon)
This commit is contained in:
2026-03-30 23:15:06 +02:00
parent a41670a52f
commit fbd7bf5c70
4 changed files with 66 additions and 70 deletions

View File

@@ -3,6 +3,13 @@ import type { TransportMode } from '../store/useAppStore'
export const ISOCHRONE_COLORS = ['#4ade80', '#fbbf24', '#f87171', '#c084fc', '#60a5fa'] export const ISOCHRONE_COLORS = ['#4ade80', '#fbbf24', '#f87171', '#c084fc', '#60a5fa']
// Valhalla costing profiles
const COSTING_MAP: Record<TransportMode, string> = {
auto: 'auto',
bicycle: 'bicycle',
pedestrian: 'pedestrian'
}
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60) const m = Math.round((seconds % 3600) / 60)
@@ -14,33 +21,33 @@ export function formatDuration(seconds: number): string {
export async function fetchIsochrones( export async function fetchIsochrones(
point: [number, number], point: [number, number],
mode: TransportMode, mode: TransportMode,
ranges: number[], ranges: number[], // in seconds
apiKey: string valhallaUrl: string
): Promise<FeatureCollection> { ): Promise<FeatureCollection> {
// ORS expects largest range first // Valhalla expects minutes, sorted ascending
const sorted = [...ranges].sort((a, b) => b - a) const sorted = [...ranges].sort((a, b) => a - b)
const contours = sorted.map((s) => ({ time: s / 60 }))
const response = await fetch(`https://api.openrouteservice.org/v2/isochrones/${mode}`, { const url = `${valhallaUrl.replace(/\/$/, '')}/isochrone`
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
Authorization: apiKey,
'Content-Type': 'application/json',
Accept: 'application/json, application/geo+json'
},
body: JSON.stringify({ body: JSON.stringify({
locations: [point], locations: [{ lon: point[0], lat: point[1] }],
range: sorted, costing: COSTING_MAP[mode],
range_type: 'time', contours,
smoothing: 10 polygons: true,
denoise: 0.5,
generalize: 150
}) })
}) })
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text()
let msg = `Erreur ORS ${response.status}` let msg = `Erreur Valhalla ${response.status}`
try { try {
const parsed = JSON.parse(text) const parsed = JSON.parse(text)
msg = parsed.error?.message ?? parsed.message ?? msg msg = parsed.error ?? parsed.error_code ? `Valhalla ${parsed.error_code}: ${parsed.error}` : msg
} catch { } catch {
// ignore // ignore
} }
@@ -49,14 +56,15 @@ export async function fetchIsochrones(
const geojson: FeatureCollection = await response.json() const geojson: FeatureCollection = await response.json()
// ORS returns features largest-first; we assign colors in that order // Valhalla returns features smallest-first; reverse for layering (big polygon below)
const features: Feature<Polygon | MultiPolygon>[] = geojson.features.map((f, i) => ({ const reversed = [...geojson.features].reverse()
const features: Feature<Polygon | MultiPolygon>[] = reversed.map((f, i) => ({
...(f as Feature<Polygon | MultiPolygon>), ...(f as Feature<Polygon | MultiPolygon>),
properties: { properties: {
...f.properties, ...f.properties,
isoColor: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length], isoColor: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length],
isoIndex: i, isoIndex: i,
isoLabel: formatDuration((f.properties?.value as number) ?? sorted[i]) isoLabel: formatDuration(((f.properties?.contour as number | undefined) ?? sorted[i] / 60) * 60)
} }
})) }))

View File

@@ -7,9 +7,9 @@ import { exportPng, exportGeoJSON } from '../utils/export'
import { TimeRangeEditor } from './TimeRangeEditor' import { TimeRangeEditor } from './TimeRangeEditor'
const MODES: { value: TransportMode; label: string; icon: string }[] = [ const MODES: { value: TransportMode; label: string; icon: string }[] = [
{ value: 'driving-car', label: 'Voiture', icon: '🚗' }, { value: 'auto', label: 'Voiture', icon: '🚗' },
{ value: 'cycling-regular', label: 'Vélo', icon: '🚴' }, { value: 'bicycle', label: 'Vélo', icon: '🚴' },
{ value: 'foot-walking', label: 'Piéton', icon: '🚶' } { value: 'pedestrian', label: 'Piéton', icon: '🚶' }
] ]
interface ControlPanelProps { interface ControlPanelProps {
@@ -24,19 +24,19 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
isochrones, isochrones,
loading, loading,
error, error,
orsApiKey, valhallaUrl,
setPoint, setPoint,
setMode, setMode,
setIsochrones, setIsochrones,
setLoading, setLoading,
setError, setError,
setOrsApiKey setValhallaUrl
} = useAppStore() } = useAppStore()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<NominatimResult[]>([]) const [results, setResults] = useState<NominatimResult[]>([])
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
const [showApiKey, setShowApiKey] = useState(!orsApiKey) const [showSettings, setShowSettings] = useState(false)
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleQueryChange = (q: string): void => { const handleQueryChange = (q: string): void => {
@@ -68,15 +68,10 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
setError('Cliquez sur la carte pour définir un point de départ.') setError('Cliquez sur la carte pour définir un point de départ.')
return return
} }
if (!orsApiKey.trim()) {
setError('Clé API ORS manquante.')
setShowApiKey(true)
return
}
setError(null) setError(null)
setLoading(true) setLoading(true)
try { try {
const data = await fetchIsochrones(point, mode, timeRanges, orsApiKey) const data = await fetchIsochrones(point, mode, timeRanges, valhallaUrl)
setIsochrones(data) setIsochrones(data)
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message)
@@ -155,7 +150,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{/* Time ranges */} {/* Time ranges */}
<section className="panel-section"> <section className="panel-section">
<label className="section-label">Durées de trajet</label> <label className="section-label">Durées de trajet (heures)</label>
<TimeRangeEditor /> <TimeRangeEditor />
</section> </section>
@@ -185,20 +180,18 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
</section> </section>
)} )}
{/* API Key */} {/* Settings */}
<section className="panel-section api-section"> <section className="panel-section api-section">
<button className="btn-link" onClick={() => setShowApiKey(!showApiKey)}> <button className="btn-link" onClick={() => setShowSettings(!showSettings)}>
{showApiKey ? '▲' : '▼'} Clé API ORS {showSettings ? '▲' : '▼'} Serveur Valhalla
{orsApiKey && <span className="key-ok"> </span>}
</button> </button>
{showApiKey && ( {showSettings && (
<input <input
type="password" type="text"
className="input-text" className="input-text"
placeholder="Clé API OpenRouteService..." placeholder="https://routing.kerboul.me"
value={orsApiKey} value={valhallaUrl}
onChange={(e) => setOrsApiKey(e.target.value)} onChange={(e) => setValhallaUrl(e.target.value)}
autoComplete="off"
/> />
)} )}
</section> </section>

View File

@@ -5,10 +5,8 @@ export function TimeRangeEditor(): React.JSX.Element {
const { timeRanges, setTimeRanges } = useAppStore() const { timeRanges, setTimeRanges } = useAppStore()
const sorted = [...timeRanges].sort((a, b) => a - b) const sorted = [...timeRanges].sort((a, b) => a - b)
const ORS_MAX_SEC = 3600 const update = (index: number, hours: number): void => {
const sec = Math.max(1800, Math.round(hours * 2) / 2 * 3600) // pas de 0.5h, min 30min
const update = (index: number, minutes: number): void => {
const sec = Math.min(ORS_MAX_SEC, Math.max(60, Math.round(minutes) * 60))
const next = sorted.map((v, i) => (i === index ? sec : v)).sort((a, b) => a - b) const next = sorted.map((v, i) => (i === index ? sec : v)).sort((a, b) => a - b)
setTimeRanges(next) setTimeRanges(next)
} }
@@ -20,8 +18,8 @@ export function TimeRangeEditor(): React.JSX.Element {
const add = (): void => { const add = (): void => {
if (sorted.length >= 5) return if (sorted.length >= 5) return
const next = Math.min(ORS_MAX_SEC, Math.max(...sorted) + 600) const next = Math.min(36000, Math.max(...sorted) + 3600)
if (next === Math.max(...sorted)) return // already at max if (next === Math.max(...sorted)) return
setTimeRanges([...sorted, next]) setTimeRanges([...sorted, next])
} }
@@ -36,13 +34,13 @@ export function TimeRangeEditor(): React.JSX.Element {
<input <input
type="number" type="number"
className="range-input" className="range-input"
min={1} min={0.5}
max={60} max={10}
step={5} step={0.5}
value={Math.round(t / 60)} value={t / 3600}
onChange={(e) => update(i, parseInt(e.target.value) || 5)} onChange={(e) => update(i, parseFloat(e.target.value) || 0.5)}
/> />
<span className="range-preview">min {formatDuration(t)}</span> <span className="range-preview">h {formatDuration(t)}</span>
<button <button
className="btn-icon" className="btn-icon"
onClick={() => remove(i)} onClick={() => remove(i)}
@@ -53,12 +51,11 @@ export function TimeRangeEditor(): React.JSX.Element {
</button> </button>
</div> </div>
))} ))}
{sorted.length < 5 && Math.max(...sorted) < ORS_MAX_SEC && ( {sorted.length < 5 && Math.max(...sorted) < 36000 && (
<button className="btn-add" onClick={add}> <button className="btn-add" onClick={add}>
+ Ajouter une durée + Ajouter une durée
</button> </button>
)} )}
<p className="hint">Max 60 min limite API ORS gratuit</p>
</div> </div>
) )
} }

View File

@@ -1,46 +1,44 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { FeatureCollection } from 'geojson' import type { FeatureCollection } from 'geojson'
export type TransportMode = 'driving-car' | 'cycling-regular' | 'foot-walking' export type TransportMode = 'auto' | 'bicycle' | 'pedestrian'
interface AppState { interface AppState {
point: [number, number] | null point: [number, number] | null
mode: TransportMode mode: TransportMode
timeRanges: number[] timeRanges: number[] // seconds
isochrones: FeatureCollection | null isochrones: FeatureCollection | null
loading: boolean loading: boolean
error: string | null error: string | null
orsApiKey: string valhallaUrl: string
setPoint: (point: [number, number] | null) => void setPoint: (point: [number, number] | null) => void
setMode: (mode: TransportMode) => void setMode: (mode: TransportMode) => void
setTimeRanges: (ranges: number[]) => void setTimeRanges: (ranges: number[]) => void
setIsochrones: (iso: FeatureCollection | null) => void setIsochrones: (iso: FeatureCollection | null) => void
setLoading: (v: boolean) => void setLoading: (v: boolean) => void
setError: (e: string | null) => void setError: (e: string | null) => void
setOrsApiKey: (key: string) => void setValhallaUrl: (url: string) => void
} }
const STORAGE_KEY = 'ors_api_key' const URL_KEY = 'valhalla_url'
const DEFAULT_URL = (import.meta.env.VITE_VALHALLA_URL as string | undefined) ?? 'https://routing.kerboul.me'
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
point: null, point: null,
mode: 'driving-car', mode: 'auto',
timeRanges: [1200, 2400, 3600], // 20min, 40min, 1h timeRanges: [3600, 7200, 14400], // 1h, 2h, 4h
isochrones: null, isochrones: null,
loading: false, loading: false,
error: null, error: null,
orsApiKey: valhallaUrl: localStorage.getItem(URL_KEY) ?? DEFAULT_URL,
localStorage.getItem(STORAGE_KEY) ??
(import.meta.env.VITE_ORS_API_KEY as string | undefined) ??
'',
setPoint: (point) => set({ point }), setPoint: (point) => set({ point }),
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
setTimeRanges: (timeRanges) => set({ timeRanges }), setTimeRanges: (timeRanges) => set({ timeRanges }),
setIsochrones: (isochrones) => set({ isochrones }), setIsochrones: (isochrones) => set({ isochrones }),
setLoading: (loading) => set({ loading }), setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
setOrsApiKey: (key) => { setValhallaUrl: (url) => {
localStorage.setItem(STORAGE_KEY, key) localStorage.setItem(URL_KEY, url)
set({ orsApiKey: key }) set({ valhallaUrl: url })
} }
})) }))