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