diff --git a/src/renderer/src/api/ors.ts b/src/renderer/src/api/ors.ts index 2e254bd..23a9d4d 100644 --- a/src/renderer/src/api/ors.ts +++ b/src/renderer/src/api/ors.ts @@ -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 = { + 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 { - // 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[] = geojson.features.map((f, i) => ({ + // Valhalla returns features smallest-first; reverse for layering (big polygon below) + const reversed = [...geojson.features].reverse() + const features: Feature[] = reversed.map((f, i) => ({ ...(f as Feature), 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) } })) diff --git a/src/renderer/src/components/ControlPanel.tsx b/src/renderer/src/components/ControlPanel.tsx index 44cb418..22b2299 100644 --- a/src/renderer/src/components/ControlPanel.tsx +++ b/src/renderer/src/components/ControlPanel.tsx @@ -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([]) const [showDropdown, setShowDropdown] = useState(false) - const [showApiKey, setShowApiKey] = useState(!orsApiKey) + const [showSettings, setShowSettings] = useState(false) const searchTimer = useRef | 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 */}
- +
@@ -185,20 +180,18 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element { )} - {/* API Key */} + {/* Settings */}
- - {showApiKey && ( + {showSettings && ( setOrsApiKey(e.target.value)} - autoComplete="off" + placeholder="https://routing.kerboul.me" + value={valhallaUrl} + onChange={(e) => setValhallaUrl(e.target.value)} /> )}
diff --git a/src/renderer/src/components/TimeRangeEditor.tsx b/src/renderer/src/components/TimeRangeEditor.tsx index b728989..95a08a7 100644 --- a/src/renderer/src/components/TimeRangeEditor.tsx +++ b/src/renderer/src/components/TimeRangeEditor.tsx @@ -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 { 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)} /> - min — {formatDuration(t)} + h — {formatDuration(t)} ))} - {sorted.length < 5 && Math.max(...sorted) < ORS_MAX_SEC && ( + {sorted.length < 5 && Math.max(...sorted) < 36000 && ( )} -

Max 60 min — limite API ORS gratuit

) } diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index 1fda60d..38527cb 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -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((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 }) } }))