feat: English UI + more distinct isochrone colors

- Translate all UI strings to English (labels, toasts, hints, context menu)
- Replace monochromatic color palettes with visually distinct multi-hue palettes
  per transport mode (car: yellow→red→purple, bike: cyan→indigo→green,
  walk: green→lime alternating)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 14:35:23 +02:00
parent cf6e4c6479
commit 332acfe815
6 changed files with 33 additions and 32 deletions

View File

@@ -61,7 +61,7 @@ function App(): React.JSX.Element {
<div className="splash-ring" />
<div className="splash-ring" />
</div>
<p>Démarrage du moteur de routage...</p>
<p>Starting routing engine...</p>
</div>
</div>
)
@@ -71,8 +71,8 @@ function App(): React.JSX.Element {
return (
<div className="splash splash-error">
<div className="splash-content">
<p>Valhalla n'a pas pu démarrer.</p>
<p className="splash-hint">Vérifiez que Docker est actif dans WSL Ubuntu.</p>
<p>Valhalla could not start.</p>
<p className="splash-hint">Check that Docker is running in WSL Ubuntu.</p>
</div>
</div>
)

View File

@@ -2,9 +2,9 @@ import type { FeatureCollection, Feature, Polygon, MultiPolygon } from 'geojson'
import type { TransportMode } from '../store/useAppStore'
const ISOCHRONE_COLORS: Record<TransportMode, string[]> = {
pedestrian: ['#86efac', '#4ade80', '#22c55e', '#16a34a', '#15803d', '#166534', '#14532d', '#052e16'],
bicycle: ['#67e8f9', '#22d3ee', '#06b6d4', '#0891b2', '#0e7490', '#155e75', '#164e63', '#083344'],
auto: ['#fcd34d', '#fbbf24', '#f59e0b', '#d97706', '#b45309', '#92400e', '#78350f', '#451a03'],
pedestrian: ['#bbf7d0', '#4ade80', '#16a34a', '#166534', '#a3e635', '#65a30d', '#bef264', '#4d7c0f'],
bicycle: ['#a5f3fc', '#22d3ee', '#0891b2', '#155e75', '#818cf8', '#4338ca', '#6ee7b7', '#047857'],
auto: ['#fef08a', '#fbbf24', '#f97316', '#dc2626', '#e879f9', '#9333ea', '#fb7185', '#be123c'],
}
export function getIsochroneColors(mode: TransportMode): string[] {
@@ -111,14 +111,14 @@ export async function fetchIsochrones(
} catch (err) {
if ((err as Error).name === 'AbortError') throw err
console.error('[Valhalla] fetch error:', err)
throw new Error(`Erreur réseau: ${err}`)
throw new Error(`Network error: ${err}`)
}
console.log('[Valhalla] Response status:', response.status)
if (!response.ok) {
const errText = await response.text().catch(() => '')
let msg = `Erreur Valhalla ${response.status}`
let msg = `Valhalla error ${response.status}`
try {
const parsed = JSON.parse(errText)
if (parsed.error) msg = `Valhalla ${parsed.error_code}: ${parsed.error}`

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: 'auto', label: 'Voiture', icon: '🚗' },
{ value: 'bicycle', label: 'Vélo', icon: '🚴' },
{ value: 'pedestrian', label: 'Piéton', icon: '🚶' }
{ value: 'auto', label: 'Car', icon: '🚗' },
{ value: 'bicycle', label: 'Bike', icon: '🚴' },
{ value: 'pedestrian', label: 'Walk', icon: '🚶' }
]
interface ControlPanelProps {
@@ -52,7 +52,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
}
const handleCalculate = async (): Promise<void> => {
if (!point) { setError('Cliquez sur la carte pour définir un point de départ.'); return }
if (!point) { setError('Click on the map to set a starting point.'); return }
cancelFetch()
setError(null)
setLoading(true)
@@ -61,7 +61,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
const data = await fetchIsochrones(point, mode, timeRanges)
setIsochrones(data)
const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
addToast({ message: `Calculé en ${elapsed}s`, type: 'success', duration: 3000 })
addToast({ message: `Computed in ${elapsed}s`, type: 'success', duration: 3000 })
addToHistory({ point, mode, timeRanges, isochrones: data, timestamp: Date.now(), label: query || undefined })
} catch (e) {
const msg = (e as Error).message
@@ -83,7 +83,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
return () => document.removeEventListener('keydown', handler)
}, [point, mode, timeRanges, loading]) // eslint-disable-line react-hooks/exhaustive-deps
const statusLabel = valhallaStatus === 'ready' ? 'Moteur prêt' : valhallaStatus === 'starting' ? 'Démarrage...' : 'Erreur moteur'
const statusLabel = valhallaStatus === 'ready' ? 'Engine ready' : valhallaStatus === 'starting' ? 'Starting...' : 'Engine error'
return (
<aside className="control-panel">
@@ -94,12 +94,12 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{/* Search */}
<section className="panel-section">
<label className="section-label">Point de départ</label>
<label className="section-label">Starting point</label>
<div className="search-wrap">
<input
type="text"
className="input-text"
placeholder="Rechercher un lieu..."
placeholder="Search for a place..."
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onFocus={() => results.length > 0 && setShowDropdown(true)}
@@ -118,22 +118,22 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
<span className="coord-text">📍 {point[1].toFixed(5)}, {point[0].toFixed(5)}</span>
<button
className="btn-copy"
title="Copier les coordonnées"
title="Copy coordinates"
onClick={() => {
navigator.clipboard.writeText(`${point[1].toFixed(6)}, ${point[0].toFixed(6)}`)
addToast({ message: 'Coordonnées copiées', type: 'info', duration: 2000 })
addToast({ message: 'Coordinates copied', type: 'info', duration: 2000 })
}}
></button>
<button className="btn-clear" title="Effacer le point" onClick={() => { setPoint(null); setIsochrones(null); setQuery('') }}></button>
<button className="btn-clear" title="Clear point" onClick={() => { setPoint(null); setIsochrones(null); setQuery('') }}></button>
</div>
) : (
<p className="hint">ou cliquez directement sur la carte</p>
<p className="hint">or click directly on the map</p>
)}
</section>
{/* Transport mode */}
<section className="panel-section">
<label className="section-label">Mode de transport</label>
<label className="section-label">Transport mode</label>
<div className="mode-selector">
{MODES.map((m) => (
<button
@@ -151,14 +151,14 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{/* Time ranges */}
<section className="panel-section">
<label className="section-label">Durées de trajet (heures)</label>
<label className="section-label">Travel times (hours)</label>
<TimeRangeEditor />
</section>
{/* Calculate */}
<section className="panel-section">
<button className="btn-primary" onClick={handleCalculate} disabled={loading}>
{loading ? '⏳ Calcul en cours...' : '⚡ Calculer les isochrones'}
{loading ? '⏳ Computing...' : '⚡ Compute isochrones'}
</button>
<label className="auto-recalc-toggle">
<input
@@ -166,13 +166,13 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
checked={autoRecalculate}
onChange={(e) => setAutoRecalculate(e.target.checked)}
/>
<span>Recalcul automatique</span>
<span>Auto-recalculate</span>
</label>
{error && (
<div className="error-card">
<span className="error-icon"></span>
<div className="error-body">
<p className="error-title">Erreur de calcul</p>
<p className="error-title">Computation error</p>
<p className="error-detail">{error}</p>
</div>
<button className="btn-retry" onClick={handleCalculate}></button>
@@ -184,6 +184,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{isochrones && (
<section className="panel-section">
<label className="section-label">Export</label>
<div className="export-row">
<button className="btn-secondary" onClick={() => mapRef.current && exportPng(mapRef.current)}>📸 PNG</button>
<button className="btn-secondary" onClick={() => exportGeoJSON(isochrones)}>📄 GeoJSON</button>
@@ -195,16 +196,16 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{history.length > 0 && (
<section className="panel-section">
<button className="history-toggle" onClick={() => setHistoryOpen((v) => !v)}>
<span>Historique</span>
<span>History</span>
<span>{historyOpen ? '▾' : '▸'}</span>
</button>
<div className={`collapsible-body${historyOpen ? ' open' : ''}`}>
<div className="history-list">
{history.map((h) => (
<button key={h.id} className="history-item" onClick={() => { restoreHistory(h); addToast({ message: 'Calcul restauré', type: 'info', duration: 2000 }) }}>
<button key={h.id} className="history-item" onClick={() => { restoreHistory(h); addToast({ message: 'Calculation restored', type: 'info', duration: 2000 }) }}>
<span className="history-icon">{MODES.find((m) => m.value === h.mode)?.icon}</span>
<span className="history-label">{h.label ?? `${h.point[1].toFixed(3)}, ${h.point[0].toFixed(3)}`}</span>
<span className="history-meta">{h.timeRanges.length} zones</span>
<span className="history-meta">{h.timeRanges.length} range{h.timeRanges.length > 1 ? 's' : ''}</span>
</button>
))}
</div>

View File

@@ -15,7 +15,7 @@ export function Legend(): React.JSX.Element | null {
key={t}
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
onClick={() => toggleLayer(i)}
title={hiddenLayers.has(i) ? 'Afficher' : 'Masquer'}
title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
>
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
<span className="legend-label">{formatDuration(t)}</span>

View File

@@ -210,7 +210,7 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
setPoint([contextMenu.lng, contextMenu.lat])
setContextMenu(null)
}}>
📍 Définir comme point de départ
📍 Set as starting point
</button>
</div>
)}

View File

@@ -54,12 +54,12 @@ export function TimeRangeEditor(): React.JSX.Element {
onChange={(e) => update(i, parseFloat(e.target.value) || 0.5)}
/>
<span className="range-preview">h {formatDuration(t)}</span>
<button className="btn-icon" onClick={() => remove(i)} disabled={sorted.length <= 1} title="Supprimer">×</button>
<button className="btn-icon" onClick={() => remove(i)} disabled={sorted.length <= 1} title="Remove">×</button>
</div>
))}
{sorted.length < 8 && Math.max(...sorted) < 36000 && (
<button className="btn-add" onClick={add}>+ Ajouter une durée</button>
<button className="btn-add" onClick={add}>+ Add a duration</button>
)}
</div>
)