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']
// Valhalla costing profiles
const COSTING_MAP: Record<TransportMode, string> = {
auto: 'auto',
bicycle: 'bicycle',
pedestrian: 'pedestrian'
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60)
@@ -14,33 +21,33 @@ export function formatDuration(seconds: number): string {
export async function fetchIsochrones(
point: [number, number],
mode: TransportMode,
ranges: number[],
apiKey: string
ranges: number[], // in seconds
valhallaUrl: string
): Promise<FeatureCollection> {
// ORS expects largest range first
const sorted = [...ranges].sort((a, b) => b - a)
// Valhalla expects minutes, sorted ascending
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',
headers: {
Authorization: apiKey,
'Content-Type': 'application/json',
Accept: 'application/json, application/geo+json'
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locations: [point],
range: sorted,
range_type: 'time',
smoothing: 10
locations: [{ lon: point[0], lat: point[1] }],
costing: COSTING_MAP[mode],
contours,
polygons: true,
denoise: 0.5,
generalize: 150
})
})
if (!response.ok) {
const text = await response.text()
let msg = `Erreur ORS ${response.status}`
let msg = `Erreur Valhalla ${response.status}`
try {
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 {
// ignore
}
@@ -49,14 +56,15 @@ export async function fetchIsochrones(
const geojson: FeatureCollection = await response.json()
// ORS returns features largest-first; we assign colors in that order
const features: Feature<Polygon | MultiPolygon>[] = geojson.features.map((f, i) => ({
// Valhalla returns features smallest-first; reverse for layering (big polygon below)
const reversed = [...geojson.features].reverse()
const features: Feature<Polygon | MultiPolygon>[] = reversed.map((f, i) => ({
...(f as Feature<Polygon | MultiPolygon>),
properties: {
...f.properties,
isoColor: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length],
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'
const MODES: { value: TransportMode; label: string; icon: string }[] = [
{ value: 'driving-car', label: 'Voiture', icon: '🚗' },
{ value: 'cycling-regular', label: 'Vélo', icon: '🚴' },
{ value: 'foot-walking', label: 'Piéton', icon: '🚶' }
{ value: 'auto', label: 'Voiture', icon: '🚗' },
{ value: 'bicycle', label: 'Vélo', icon: '🚴' },
{ value: 'pedestrian', label: 'Piéton', icon: '🚶' }
]
interface ControlPanelProps {
@@ -24,19 +24,19 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
isochrones,
loading,
error,
orsApiKey,
valhallaUrl,
setPoint,
setMode,
setIsochrones,
setLoading,
setError,
setOrsApiKey
setValhallaUrl
} = useAppStore()
const [query, setQuery] = useState('')
const [results, setResults] = useState<NominatimResult[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [showApiKey, setShowApiKey] = useState(!orsApiKey)
const [showSettings, setShowSettings] = useState(false)
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
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.')
return
}
if (!orsApiKey.trim()) {
setError('Clé API ORS manquante.')
setShowApiKey(true)
return
}
setError(null)
setLoading(true)
try {
const data = await fetchIsochrones(point, mode, timeRanges, orsApiKey)
const data = await fetchIsochrones(point, mode, timeRanges, valhallaUrl)
setIsochrones(data)
} catch (e) {
setError((e as Error).message)
@@ -155,7 +150,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{/* Time ranges */}
<section className="panel-section">
<label className="section-label">Durées de trajet</label>
<label className="section-label">Durées de trajet (heures)</label>
<TimeRangeEditor />
</section>
@@ -185,20 +180,18 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
</section>
)}
{/* API Key */}
{/* Settings */}
<section className="panel-section api-section">
<button className="btn-link" onClick={() => setShowApiKey(!showApiKey)}>
{showApiKey ? '▲' : '▼'} Clé API ORS
{orsApiKey && <span className="key-ok"> </span>}
<button className="btn-link" onClick={() => setShowSettings(!showSettings)}>
{showSettings ? '▲' : '▼'} Serveur Valhalla
</button>
{showApiKey && (
{showSettings && (
<input
type="password"
type="text"
className="input-text"
placeholder="Clé API OpenRouteService..."
value={orsApiKey}
onChange={(e) => setOrsApiKey(e.target.value)}
autoComplete="off"
placeholder="https://routing.kerboul.me"
value={valhallaUrl}
onChange={(e) => setValhallaUrl(e.target.value)}
/>
)}
</section>

View File

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

View File

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