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:
@@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user