Compare commits

...

6 Commits

Author SHA1 Message Date
20f5c1a361 feat: Valhalla status poll loop with IPC/fetch dual-mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:17:50 +02:00
332acfe815 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>
2026-03-31 14:35:23 +02:00
cf6e4c6479 docs: rename screenshot to bust CDN cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:14:54 +02:00
9463eaa8ad docs: update screenshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:13:09 +02:00
7b9b2cda5a docs: add screenshot and badges to README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:11:14 +02:00
0c9faeb6f3 feat: web SPA build target + UX overhaul + public deployment setup
- Replace Electron IPC with native fetch + AbortController (VITE_VALHALLA_URL)
- Add vite.web.config.ts for standalone SPA build (npm run build:web)
- Per-mode color palettes (green/cyan/amber), RDP polygon simplification
- Interactive legend, hover popup, right-click context menu, map labels
- Toast notifications, history (last 5), auto-recalculate toggle
- Status dot, error card with retry, timing feedback, copy coordinates
- Zustand: toasts, history, hiddenLayers, autoRecalculate
- Add README.md and AGENT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:03:34 +02:00
22 changed files with 1419 additions and 215 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ out
*.log*
.env
*.local
dist-web

200
AGENT.md Normal file
View File

@@ -0,0 +1,200 @@
# AGENT.md — Isochrone App
Architecture and development guide for AI agents working on this codebase.
---
## What this app does
Renders isochrone maps (reachable-area polygons by travel time) on top of a dark MapLibre map.
The user clicks a point, picks a transport mode and time ranges, and the app calls a local Valhalla instance to compute the polygons.
---
## Build targets
| Target | Command | Output | Entry |
|--------|---------|--------|-------|
| Electron desktop | `npm run dev` / `npm run build` | `out/` | `src/main/index.ts` |
| Web SPA | `npm run build:web` | `dist-web/` | `src/renderer/index.html` |
**The renderer (`src/renderer/`) is shared between both targets.** The only difference is how Valhalla is called:
- Electron: via IPC (`window.api.valhallaFetch`) → main process proxies with Node.js `http`
- Web: via native `fetch()` directly to `VITE_VALHALLA_URL`
The adapter lives entirely in `src/renderer/src/api/ors.ts`.
---
## Project layout
```
src/
main/
index.ts # Electron main process
# - starts/stops Valhalla Docker container (WSL)
# - IPC handlers: valhalla-fetch, valhalla-abort, valhalla-status
# - Node.js http proxy (bypasses Chromium for localhost)
preload/
index.ts # contextBridge: exposes window.api to renderer
index.d.ts # TypeScript types for window.api
renderer/
index.html # SPA entry point
src/
main.tsx # React root
App.tsx # Root component: Valhalla probe, auto-recalculate, splash/error
env.d.ts # ImportMetaEnv: VITE_VALHALLA_URL
api/
ors.ts # Valhalla fetch, color palettes, polygon simplification (RDP)
store/
useAppStore.ts # Zustand store (all global state)
components/
Map.tsx # MapLibre map, isochrone layers, hover/click/contextmenu
ControlPanel.tsx # Left panel UI: mode, geocoder, time ranges, history, error
TimeRangeEditor.tsx # Add/remove time ranges, preset chips
Legend.tsx # Interactive layer legend (toggle visibility)
Toast.tsx # Toast notification system
MapOverlay.tsx # Loading bar overlay
Versions.tsx # Electron version display (unused in web build)
utils/
geocoder.ts # Nominatim search (fetch, no API key)
export.ts # GeoJSON / PNG export
assets/
main.css # All app styles (dark theme, components)
base.css # CSS reset / root variables
vite.web.config.ts # Vite config for standalone SPA build (no Electron)
electron.vite.config.ts # electron-vite config (main + preload + renderer)
```
---
## State (Zustand — `useAppStore.ts`)
| Field | Type | Description |
|-------|------|-------------|
| `point` | `[number, number] \| null` | Selected map point [lng, lat] |
| `mode` | `TransportMode` | `'auto' \| 'bicycle' \| 'pedestrian'` |
| `timeRanges` | `number[]` | Array of seconds (e.g. [900, 1800, 3600]) |
| `isochrones` | `FeatureCollection \| null` | Current computed isochrones |
| `loading` | `boolean` | Fetch in progress |
| `error` | `string \| null` | Last error message |
| `valhallaStatus` | `'starting' \| 'ready' \| 'error'` | Routing engine status |
| `toasts` | `Toast[]` | Active toast notifications |
| `history` | `HistoryEntry[]` | Last 5 calculations (max) |
| `hiddenLayers` | `Set<number>` | isoIndex values of hidden layers |
| `autoRecalculate` | `boolean` | Auto-recompute on param change |
---
## Data flow
```
User click on map
→ Map.tsx sets point in store
→ ControlPanel.tsx calls fetchIsochrones()
→ ors.ts: builds Valhalla request body
→ Electron: window.api.valhallaFetch() → IPC → Node http.request → Valhalla
Web: fetch(VITE_VALHALLA_URL + '/isochrone')
→ response: raw GeoJSON FeatureCollection
→ ors.ts: reverses features (outermost first), stamps isoColor/isoOpacity/isoIndex/isoLabel, RDP-simplifies polygons
→ store.setIsochrones(data)
→ Map.tsx useEffect([isochrones]): updates GeoJSON source, animates opacity, places label markers
→ Legend.tsx: renders color swatches from getIsochroneColors(mode)
```
---
## Valhalla integration
### Request format
```json
POST /isochrone
{
"locations": [{ "lon": 2.3488, "lat": 48.8534 }],
"costing": "auto",
"contours": [{ "time": 15 }, { "time": 30 }, { "time": 60 }],
"polygons": true,
"denoise": 0.5,
"generalize": 150
}
```
### Response
GeoJSON `FeatureCollection`. Each feature has `properties.contour` (time in minutes).
### Limits (configured in `valhalla.json`)
```json
"service_limits": {
"isochrone": {
"max_contours": 8,
"max_time_contour": 600
}
}
```
### Color palettes (per transport mode)
Defined in `ors.ts``ISOCHRONE_COLORS: Record<TransportMode, string[]>`. Export `getIsochroneColors(mode)` used by Map, Legend, TimeRangeEditor.
---
## Map layers (MapLibre)
| Layer ID | Type | Source | Description |
|----------|------|--------|-------------|
| `iso-fill` | fill | `isochrones` GeoJSON | Isochrone polygons, opacity from `isoOpacity` feature property |
| `iso-line` | line | `isochrones` GeoJSON | Isochrone outlines |
| `point-dot` | circle | `point` GeoJSON | Departure point marker |
Feature-state `hovered: true` on `iso-fill` raises opacity to 0.6. Labels are DOM `Marker` elements with class `iso-map-label`.
Layer visibility is controlled via `map.setFilter(LAYER_FILL, ['in', ['get','isoIndex'], ['literal', visibleIndices]])` driven by `store.hiddenLayers`.
---
## CSS conventions
All styles in `src/renderer/src/assets/main.css`. CSS variables defined on `:root`:
| Variable | Value | Usage |
|----------|-------|-------|
| `--bg` | `#0f111a` | App background |
| `--panel-bg` | `#161926` | Panel background |
| `--accent` | `#6366f1` | Primary accent (indigo) |
| `--danger` | `#f87171` | Error states |
| `--text` | `#e2e8f0` | Primary text |
| `--text-muted` | `#64748b` | Secondary text |
| `--radius` | `8px` | Standard border radius |
---
## Adding a new transport mode
1. Add key to `TransportMode` union in `useAppStore.ts`
2. Add entry to `COSTING_MAP` in `ors.ts` (Valhalla costing string)
3. Add color palette to `ISOCHRONE_COLORS` in `ors.ts`
4. Add button in `ControlPanel.tsx` mode selector with `data-mode` attribute
5. Add `.btn-mode.active[data-mode="..."]` style in `main.css`
---
## Environment
- Node.js 20+
- Valhalla: `ghcr.io/gis-ops/docker-valhalla/valhalla:latest`
- Tiles built from Geofabrik `.osm.pbf` extracts
- In Electron mode: Docker runs in WSL2 Ubuntu, port-forwarded to `127.0.0.1:8002`
- In web mode: `VITE_VALHALLA_URL` env var (injected at build time by Vite)
---
## Known constraints
- `VITE_VALHALLA_URL` is **build-time**, not runtime — rebuild needed to change the Valhalla URL
- Electron app assumes WSL2 Ubuntu with Docker at `/home/kerboul/valhalla/`
- CartoCDN tiles require internet access (map background only — routing is offline)
- `Versions.tsx` uses `window.electron` — renders nothing in web build (unused component)

212
README.md
View File

@@ -1,34 +1,212 @@
# isochrone-app
# Isochrone App
An Electron application with React and TypeScript
> Offline isochrone map explorer — Electron desktop app + deployable web SPA, powered by a self-hosted [Valhalla](https://github.com/valhalla/valhalla) routing engine.
## Recommended IDE Setup
[![Electron](https://img.shields.io/badge/Electron-39-47848F?logo=electron&logoColor=white)](https://www.electronjs.org/)
[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![MapLibre GL](https://img.shields.io/badge/MapLibre_GL-5-396CB2?logo=maplibre&logoColor=white)](https://maplibre.org/)
[![Valhalla](https://img.shields.io/badge/Routing-Valhalla-FF6B35?logo=openstreetmap&logoColor=white)](https://github.com/valhalla/valhalla)
[![Self-hosted](https://img.shields.io/badge/self--hosted-100%25_offline-22c55e)](https://github.com/DaKerboul/isochrone-app)
[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
![isochrone demo](https://raw.githubusercontent.com/DaKerboul/isochrone-app/master/resources/preview.png)
## Project Setup
---
## What is this?
An isochrone map shows you **all the places you can reach within a given time** from a starting point. This app lets you:
- Click anywhere on the map to set a departure point
- Choose a transport mode (🚗 car / 🚲 bike / 🚶 walking)
- Configure up to 8 time ranges (e.g. 15min, 30min, 1h, 2h, 4h, 8h...)
- See the reachable areas rendered as colored polygons on a dark map
Everything runs **100% offline and self-hosted** — no external routing API, no data leaves your infrastructure.
---
## Features
- **Offline-first** — Valhalla routing engine runs locally (Docker / WSL / Proxmox CT)
- **Dark map** — CartoCDN dark tiles
- **Per-mode color palettes** — green (walking) / cyan (bike) / amber (car)
- **Interactive legend** — click to toggle individual isochrone layers
- **Hover popup** — hover an isochrone to see its duration
- **Right-click context menu** — set departure point from map
- **Geocoder** — search for a place by name (Nominatim/OpenStreetMap)
- **Export** — save as GeoJSON or PNG
- **History** — last 5 searches, one-click restore
- **Auto-recalculate** — toggle to recompute automatically on param changes
- **Toast notifications** — timing feedback, copy coordinates
- **Abort in-flight requests** — start a new calculation without waiting
- **Dual build target** — Electron desktop app + standalone web SPA (`npm run build:web`)
---
## Architecture
```
┌─────────────────────────────────────┐
│ Electron Main │
│ - Manages Valhalla Docker container│
│ - Proxies HTTP via Node.js │
│ - IPC bridge (preload) │
└────────────────┬────────────────────┘
│ IPC (Electron) / fetch (Web)
┌────────────────▼────────────────────┐
│ React Renderer (SPA) │
│ MapLibre GL │ Zustand store │
│ ControlPanel │ TimeRangeEditor │
│ Legend │ Toast / MapOverlay │
└────────────────┬────────────────────┘
│ HTTP POST /isochrone
┌────────────────▼────────────────────┐
│ Valhalla Routing Engine │
│ ghcr.io/gis-ops/docker-valhalla │
│ OSM tiles built from Geofabrik PBF │
└─────────────────────────────────────┘
```
### Key files
| Path | Role |
|------|------|
| `src/main/index.ts` | Electron main: Docker lifecycle, IPC handlers, HTTP proxy |
| `src/preload/index.ts` | Electron preload: exposes `window.api` to renderer |
| `src/renderer/src/api/ors.ts` | Valhalla fetch logic, color palettes, polygon simplification |
| `src/renderer/src/store/useAppStore.ts` | Zustand global state |
| `src/renderer/src/components/Map.tsx` | MapLibre map, layers, hover, context menu, labels |
| `src/renderer/src/components/ControlPanel.tsx` | Left panel: mode, geocoder, time ranges, history |
| `src/renderer/src/components/TimeRangeEditor.tsx` | Time range chips + presets |
| `src/renderer/src/components/Legend.tsx` | Interactive layer legend |
| `vite.web.config.ts` | Vite config for standalone web SPA build |
---
## Stack
- **Frontend** — React 19, TypeScript, Vite
- **Map** — MapLibre GL v5
- **State** — Zustand v5
- **Desktop shell** — Electron 39
- **Routing engine** — Valhalla (self-hosted via Docker)
- **Map tiles** — CartoCDN dark (raster)
- **Geocoding** — Nominatim (OpenStreetMap)
---
## Getting started
### Prerequisites
- Node.js 20+
- Docker (for running Valhalla locally)
- WSL2 with Ubuntu (if on Windows)
### Install
```bash
$ npm install
npm install
```
### Development
### Build Valhalla tiles
Download an OSM extract from [Geofabrik](https://download.geofabrik.de/) and place it in a `valhalla/` directory, then:
```bash
$ npm run dev
docker run --rm \
-v ./valhalla:/custom_files \
-e build_tar=True \
-e serve_tiles=False \
-e concurrency=8 \
ghcr.io/gis-ops/docker-valhalla/valhalla:latest \
build_tiles
```
### Build
This produces `valhalla/valhalla_tiles.tar` (~812 GB for France).
### Run Valhalla service
```bash
# For windows
$ npm run build:win
# For macOS
$ npm run build:mac
# For Linux
$ npm run build:linux
docker run -d \
--name valhalla-service \
-p 8002:8002 \
-v ./valhalla:/custom_files \
--entrypoint /usr/local/bin/valhalla_service \
ghcr.io/gis-ops/docker-valhalla/valhalla:latest \
/custom_files/valhalla.json 4
```
### Run the Electron app
```bash
npm run dev
```
### Build standalone web SPA
```bash
VITE_VALHALLA_URL=https://your-valhalla-instance.example.com npm run build:web
# Output in dist-web/
```
Set `VITE_VALHALLA_URL` to your public Valhalla endpoint. The SPA calls it directly from the browser (CORS must be enabled on the Valhalla side).
---
## Configuration
### Valhalla limits
Edit `valhalla/valhalla.json` to increase default limits:
```json
"service_limits": {
"isochrone": {
"max_contours": 8,
"max_time_contour": 600
}
}
```
### Environment variables (web SPA)
| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_VALHALLA_URL` | `http://127.0.0.1:8002` | Base URL of Valhalla service |
---
## Self-hosted deployment
This app is designed to run self-hosted. The recommended setup:
```
Browser → isochrone.example.com (static SPA via CDN / Coolify / Nginx)
↓ fetch
valhalla.example.com (Traefik → Valhalla Docker on a Proxmox CT)
```
Traefik config example (restrict CORS + rate limit):
```yaml
middlewares:
cors-isochrone:
headers:
accessControlAllowOriginList: ["https://isochrone.example.com"]
accessControlAllowMethods: [GET, POST, OPTIONS]
accessControlAllowHeaders: [Content-Type]
valhalla-ratelimit:
rateLimit:
average: 20
burst: 10
period: 1m
```
---
## License
MIT

View File

@@ -18,7 +18,8 @@
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
"build:linux": "electron-vite build && electron-builder --linux",
"build:web": "vite build --config vite.web.config.ts"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",

40
proxy.cjs Normal file
View File

@@ -0,0 +1,40 @@
const http = require('http')
const WSL_HOST = '172.30.239.252'
const WSL_PORT = 8002
const LOCAL_PORT = 9002
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const proxyReq = http.request(
{ hostname: WSL_HOST, port: WSL_PORT, path: req.url, method: req.method, headers: { 'Content-Type': 'application/json' } },
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers)
proxyRes.pipe(res)
}
)
proxyReq.on('error', (e) => {
console.error('Proxy error:', e.message)
res.writeHead(502)
res.end(JSON.stringify({ error: e.message }))
})
if (chunks.length) proxyReq.write(Buffer.concat(chunks))
proxyReq.end()
})
})
server.listen(LOCAL_PORT, '127.0.0.1', () => {
console.log(`Proxy listening on http://127.0.0.1:${LOCAL_PORT} -> ${WSL_HOST}:${WSL_PORT}`)
})

BIN
resources/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

BIN
resources/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

View File

@@ -1,10 +1,160 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import http from 'node:http'
import { execFile, ChildProcess } from 'node:child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
const VALHALLA_URL = 'http://127.0.0.1:8002'
const CONTAINER_NAME = 'valhalla-service'
const WSL_EXE = 'C:\\Windows\\System32\\wsl.exe'
let valhallaProcess: ChildProcess | null = null
let valhallaReady = false
let mainWindowRef: BrowserWindow | null = null
function wsl(...args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const child = execFile(WSL_EXE, ['-d', 'Ubuntu', '--', ...args], (err, stdout, stderr) => {
const code = typeof err?.code === 'number' ? err.code : 0
resolve({ stdout: stdout || '', stderr: stderr || '', code })
})
child.stdout?.on('data', () => {})
child.stderr?.on('data', () => {})
})
}
async function pollValhalla(timeoutMs = 180000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(`${VALHALLA_URL}/status`, { timeout: 2000 }, (res) => {
res.resume()
if (res.statusCode === 200) resolve()
else reject(new Error(`HTTP ${res.statusCode}`))
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')) })
})
return true
} catch {
await new Promise((r) => setTimeout(r, 1000))
}
}
return false
}
async function startValhalla(): Promise<void> {
// If already running, just poll and declare ready
const { stdout: running } = await wsl('docker', 'inspect', '-f', '{{.State.Running}}', CONTAINER_NAME)
if (running.trim() === 'true') {
console.log('[Valhalla] Container already running, polling...')
const ready = await pollValhalla(30000)
valhallaReady = ready
mainWindowRef?.webContents.send('valhalla-status', ready ? 'ready' : 'error')
return
}
console.log('[Valhalla] Starting container...')
// Remove stale container if exists
await wsl('docker', 'rm', '-f', CONTAINER_NAME)
const { code } = await new Promise<{ code: number }>((resolve) => {
const child = execFile(
WSL_EXE,
[
'-d', 'Ubuntu', '--',
'docker', 'run', '-d',
'--name', CONTAINER_NAME,
'--restart=unless-stopped',
'-p', '8002:8002',
'-v', '/home/kerboul/valhalla:/custom_files',
'--entrypoint', '/usr/local/bin/valhalla_service',
'ghcr.io/gis-ops/docker-valhalla/valhalla:latest',
'/custom_files/valhalla.json', '4'
],
(err) => resolve({ code: typeof err?.code === 'number' ? err.code : 0 })
)
child.stdout?.on('data', (d) => console.log('[Valhalla] docker run:', d.toString().trim()))
child.stderr?.on('data', (d) => console.error('[Valhalla] docker run err:', d.toString().trim()))
})
if (code !== 0) {
console.error('[Valhalla] Failed to start container')
mainWindowRef?.webContents.send('valhalla-status', 'error')
return
}
console.log('[Valhalla] Container started, waiting for service...')
const ready = await pollValhalla(180000)
valhallaReady = ready
if (ready) {
console.log('[Valhalla] Ready ✓')
mainWindowRef?.webContents.send('valhalla-status', 'ready')
} else {
console.error('[Valhalla] Timed out waiting for service')
mainWindowRef?.webContents.send('valhalla-status', 'error')
}
}
async function stopValhalla(): Promise<void> {
if (valhallaProcess) {
valhallaProcess.kill()
valhallaProcess = null
}
await wsl('docker', 'stop', CONTAINER_NAME).catch(() => {})
await wsl('docker', 'rm', CONTAINER_NAME).catch(() => {})
valhallaReady = false
}
// AbortController for in-flight isochrone requests
let currentIsoRequest: ReturnType<typeof http.request> | null = null
ipcMain.handle('valhalla-abort', () => {
if (currentIsoRequest) {
currentIsoRequest.destroy()
currentIsoRequest = null
}
})
// IPC: isochrone fetch — proxied through Node.js native http (bypasses Chromium)
ipcMain.handle('valhalla-fetch', async (_event, _urlStr: string, body: string) => {
// Cancel any in-flight request
if (currentIsoRequest) {
currentIsoRequest.destroy()
currentIsoRequest = null
}
return new Promise<string>((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port: 8002,
path: '/isochrone',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
timeout: 120000
},
(res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
currentIsoRequest = null
resolve(JSON.stringify({ statusCode: res.statusCode, body: data }))
})
}
)
currentIsoRequest = req
req.on('timeout', () => { req.destroy(); currentIsoRequest = null; reject('Connection timeout') })
req.on('error', (err) => { currentIsoRequest = null; reject(err.message) })
req.write(body)
req.end()
})
})
ipcMain.handle('valhalla-status', () => valhallaReady ? 'ready' : 'starting')
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1400,
height: 860,
@@ -15,12 +165,17 @@ function createWindow(): void {
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
sandbox: false,
webSecurity: false
}
})
mainWindowRef = mainWindow
mainWindow.on('ready-to-show', () => {
mainWindow.show()
// Start Valhalla after window is shown
startValhalla().catch((e) => console.error('[Valhalla] start error:', e))
})
mainWindow.webContents.setWindowOpenHandler((details) => {
@@ -40,6 +195,11 @@ function createWindow(): void {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.commandLine.appendSwitch('disable-features', 'BlockInsecurePrivateNetworkRequests')
// Force Chromium to use DoH, bypassing Tailscale DNS capture
app.commandLine.appendSwitch('dns-over-https-mode', 'secure')
app.commandLine.appendSwitch('dns-over-https-servers', 'https://1.1.1.1/dns-query,https://8.8.8.8/dns-query')
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
@@ -63,9 +223,11 @@ app.whenReady().then(() => {
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('before-quit', () => {
stopValhalla().catch(() => {})
})
// Quit when all windows are closed, except on macOS.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()

View File

@@ -3,6 +3,11 @@ import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
api: {
valhallaFetch: (url: string, body: string) => Promise<string>
valhallaAbort: () => Promise<void>
getValhallaStatus: () => Promise<'ready' | 'starting' | 'error'>
onValhallaStatus: (cb: (status: 'ready' | 'starting' | 'error') => void) => void
}
}
}

View File

@@ -1,12 +1,18 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
const api = {
valhallaFetch: (url: string, body: string): Promise<string> =>
ipcRenderer.invoke('valhalla-fetch', url, body),
valhallaAbort: (): Promise<void> =>
ipcRenderer.invoke('valhalla-abort'),
getValhallaStatus: (): Promise<'ready' | 'starting' | 'error'> =>
ipcRenderer.invoke('valhalla-status'),
onValhallaStatus: (cb: (status: 'ready' | 'starting' | 'error') => void): void => {
ipcRenderer.on('valhalla-status', (_e, status) => cb(status))
}
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
@@ -15,8 +21,8 @@ if (process.contextIsolated) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
// @ts-ignore
window.electron = electronAPI
// @ts-ignore (define in dts)
// @ts-ignore
window.api = api
}

View File

@@ -1,18 +1,107 @@
import { useRef } from 'react'
import { useEffect, useRef } from 'react'
import type maplibregl from 'maplibre-gl'
import { MapView } from './components/Map'
import { ControlPanel } from './components/ControlPanel'
import { Legend } from './components/Legend'
import { Toast } from './components/Toast'
import { MapOverlay } from './components/MapOverlay'
import { useAppStore } from './store/useAppStore'
import { fetchIsochrones, cancelFetch } from './api/ors'
function App(): React.JSX.Element {
const mapRef = useRef<maplibregl.Map | null>(null)
const valhallaStatus = useAppStore((s) => s.valhallaStatus)
const setValhallaStatus = useAppStore((s) => s.setValhallaStatus)
const autoRecalculate = useAppStore((s) => s.autoRecalculate)
const autoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Get Valhalla status — IPC in Electron, fetch poll in web
useEffect(() => {
if (window.api) {
window.api.getValhallaStatus().then((s) => setValhallaStatus(s))
window.api.onValhallaStatus((s) => setValhallaStatus(s))
return
}
const base = (import.meta.env.VITE_VALHALLA_URL as string | undefined) ?? 'http://127.0.0.1:8002'
const deadline = Date.now() + 180000
let cancelled = false
const poll = async (): Promise<void> => {
while (!cancelled && Date.now() < deadline) {
try {
const r = await fetch(`${base}/status`, { signal: AbortSignal.timeout(2000) })
if (r.ok) { setValhallaStatus('ready'); return }
} catch { /* not ready yet */ }
await new Promise((r) => setTimeout(r, 1000))
}
if (!cancelled) setValhallaStatus('error')
}
poll()
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-calculate once Valhalla is ready
useEffect(() => {
if (valhallaStatus !== 'ready') return
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState()
if (!point) return
setLoading(true)
fetchIsochrones(point, mode, timeRanges)
.then((data) => setIsochrones(data))
.catch((e) => setError((e as Error).message))
.finally(() => setLoading(false))
}, [valhallaStatus]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-recalculate on param changes
useEffect(() => {
if (!autoRecalculate || valhallaStatus !== 'ready') return
if (autoTimerRef.current) clearTimeout(autoTimerRef.current)
autoTimerRef.current = setTimeout(() => {
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState()
if (!point) return
cancelFetch()
setLoading(true)
fetchIsochrones(point, mode, timeRanges)
.then((data) => setIsochrones(data))
.catch((e) => setError((e as Error).message))
.finally(() => setLoading(false))
}, 800)
return () => { if (autoTimerRef.current) clearTimeout(autoTimerRef.current) }
}) // eslint-disable-line react-hooks/exhaustive-deps
if (valhallaStatus === 'starting') {
return (
<div className="splash">
<div className="splash-content">
<div className="splash-rings">
<div className="splash-ring" />
<div className="splash-ring" />
<div className="splash-ring" />
</div>
<p>Starting routing engine...</p>
</div>
</div>
)
}
if (valhallaStatus === 'error') {
return (
<div className="splash splash-error">
<div className="splash-content">
<p>Valhalla could not start.</p>
<p className="splash-hint">Check that Docker is running in WSL Ubuntu.</p>
</div>
</div>
)
}
return (
<div className="app-layout">
<ControlPanel mapRef={mapRef} />
<div className="map-wrapper">
<MapView mapRef={mapRef} />
<MapOverlay />
<Legend />
<Toast />
</div>
</div>
)

View File

@@ -1,9 +1,16 @@
import type { FeatureCollection, Feature, Polygon, MultiPolygon } from 'geojson'
import type { TransportMode } from '../store/useAppStore'
export const ISOCHRONE_COLORS = ['#4ade80', '#fbbf24', '#f87171', '#c084fc', '#60a5fa']
const ISOCHRONE_COLORS: Record<TransportMode, string[]> = {
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[] {
return ISOCHRONE_COLORS[mode]
}
// Valhalla costing profiles
const COSTING_MAP: Record<TransportMode, string> = {
auto: 'auto',
bicycle: 'bicycle',
@@ -18,36 +25,103 @@ export function formatDuration(seconds: number): string {
return `${m}min`
}
// Ramer-Douglas-Peucker simplification
function perpendicularDist(p: number[], a: number[], b: number[]): number {
const dx = b[0] - a[0], dy = b[1] - a[1]
if (dx === 0 && dy === 0) {
return Math.hypot(p[0] - a[0], p[1] - a[1])
}
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy))
}
function rdp(pts: number[][], tolerance: number): number[][] {
if (pts.length <= 2) return pts
let maxDist = 0, maxIdx = 0
for (let i = 1; i < pts.length - 1; i++) {
const d = perpendicularDist(pts[i], pts[0], pts[pts.length - 1])
if (d > maxDist) { maxDist = d; maxIdx = i }
}
if (maxDist > tolerance) {
const left = rdp(pts.slice(0, maxIdx + 1), tolerance)
const right = rdp(pts.slice(maxIdx), tolerance)
return [...left.slice(0, -1), ...right]
}
return [pts[0], pts[pts.length - 1]]
}
function simplifyRing(ring: number[][], tolerance: number): number[][] {
const simplified = rdp(ring, tolerance)
// ensure ring is closed
if (simplified.length > 0 && (simplified[0][0] !== simplified[simplified.length-1][0] || simplified[0][1] !== simplified[simplified.length-1][1])) {
simplified.push(simplified[0])
}
return simplified.length >= 4 ? simplified : ring
}
function simplifyGeometry(geom: Polygon | MultiPolygon, tolerance: number): Polygon | MultiPolygon {
if (geom.type === 'Polygon') {
return { ...geom, coordinates: geom.coordinates.map(ring => simplifyRing(ring, tolerance)) }
}
return {
...geom,
coordinates: geom.coordinates.map(poly => poly.map(ring => simplifyRing(ring, tolerance)))
}
}
const VALHALLA_BASE = (import.meta.env.VITE_VALHALLA_URL as string | undefined) ?? 'http://127.0.0.1:8002'
let abortController: AbortController | null = null
export function cancelFetch(): void {
abortController?.abort()
abortController = null
}
export async function fetchIsochrones(
point: [number, number],
mode: TransportMode,
ranges: number[], // in seconds
valhallaUrl: string
ranges: number[]
): Promise<FeatureCollection> {
// Valhalla expects minutes, sorted ascending
const sorted = [...ranges].sort((a, b) => a - b)
const contours = sorted.map((s) => ({ time: s / 60 }))
const url = `${valhallaUrl.replace(/\/$/, '')}/isochrone`
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locations: [{ lon: point[0], lat: point[1] }],
costing: COSTING_MAP[mode],
contours,
polygons: true,
denoise: 0.5,
generalize: 150
})
const body = JSON.stringify({
locations: [{ lon: point[0], lat: point[1] }],
costing: COSTING_MAP[mode],
contours,
polygons: true,
denoise: 0.5,
generalize: 150
})
abortController?.abort()
abortController = new AbortController()
console.log('[Valhalla] POST', `${VALHALLA_BASE}/isochrone`)
let response: Response
try {
response = await fetch(`${VALHALLA_BASE}/isochrone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: abortController.signal
})
} catch (err) {
if ((err as Error).name === 'AbortError') throw err
console.error('[Valhalla] fetch error:', err)
throw new Error(`Network error: ${err}`)
}
console.log('[Valhalla] Response status:', response.status)
if (!response.ok) {
const text = await response.text()
let msg = `Erreur Valhalla ${response.status}`
const errText = await response.text().catch(() => '')
let msg = `Valhalla error ${response.status}`
try {
const parsed = JSON.parse(text)
msg = parsed.error ?? parsed.error_code ? `Valhalla ${parsed.error_code}: ${parsed.error}` : msg
const parsed = JSON.parse(errText)
if (parsed.error) msg = `Valhalla ${parsed.error_code}: ${parsed.error}`
} catch {
// ignore
}
@@ -55,18 +129,24 @@ export async function fetchIsochrones(
}
const geojson: FeatureCollection = await response.json()
const colors = getIsochroneColors(mode)
// 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?.contour as number | undefined) ?? sorted[i] / 60) * 60)
const features: Feature<Polygon | MultiPolygon>[] = reversed.map((f, i) => {
const geom = f.geometry as Polygon | MultiPolygon
return {
...(f as Feature<Polygon | MultiPolygon>),
id: i,
geometry: simplifyGeometry(geom, 0.001),
properties: {
...f.properties,
isoColor: colors[i % colors.length],
isoOpacity: Math.min(0.18 + i * 0.04, 0.55),
isoIndex: i,
isoLabel: formatDuration(((f.properties?.contour as number | undefined) ?? sorted[i] / 60) * 60)
}
}
}))
})
return { type: 'FeatureCollection', features } as FeatureCollection
}

View File

@@ -13,8 +13,12 @@
--accent-hover: #2563eb;
--danger: #f87171;
--success: #4ade80;
--warning: #fbbf24;
--info: #60a5fa;
--radius: 8px;
--radius-sm: 5px;
--transition-fast: 0.15s ease;
--transition-med: 0.25s ease;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
color: var(--text);
@@ -64,6 +68,9 @@ html, body, #root {
.panel-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--panel-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
@@ -205,11 +212,10 @@ html, body, #root {
.btn-mode:hover { background: var(--surface-hover); color: var(--text); }
.btn-mode.active {
background: rgba(59, 130, 246, 0.15);
border-color: var(--accent);
color: var(--accent);
}
.btn-mode.active { background: rgba(59,130,246,0.15); border-color: var(--accent); color: var(--accent); }
.btn-mode.active[data-mode="auto"] { background: rgba(251,191,36,.14); border-color: #fbbf24; color: #fbbf24; }
.btn-mode.active[data-mode="bicycle"] { background: rgba(34,211,238,.14); border-color: #22d3ee; color: #22d3ee; }
.btn-mode.active[data-mode="pedestrian"] { background: rgba(74,222,128,.14); border-color: #4ade80; color: #4ade80; }
.mode-icon { font-size: 18px; }
.mode-label { font-size: 11px; font-weight: 600; }
@@ -371,14 +377,20 @@ html, body, #root {
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
pointer-events: all;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: opacity var(--transition-fast);
border-radius: var(--radius-sm);
padding: 1px 2px;
}
.legend-item:hover { opacity: 0.8; }
.legend-item.hidden { opacity: 0.35; }
.legend-swatch {
width: 14px;
@@ -394,6 +406,68 @@ html, body, #root {
color: var(--text);
}
/* ─── Splash screen ─────────────────────────────────────────────────── */
.splash {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--text-muted);
font-size: 14px;
}
.splash-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--panel-border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Splash amélioré - anneaux concentriques */
.splash-rings {
width: 72px;
height: 72px;
position: relative;
margin: 0 auto;
}
.splash-ring {
border-radius: 50%;
border: 2px solid var(--accent);
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: splash-radiate 2.2s ease-out infinite;
opacity: 0;
}
.splash-ring:nth-child(1) { animation-delay: 0s; }
.splash-ring:nth-child(2) { animation-delay: 0.55s; }
.splash-ring:nth-child(3) { animation-delay: 1.1s; }
@keyframes splash-radiate {
0% { width: 10px; height: 10px; opacity: 0.9; }
80% { opacity: 0.2; }
100% { width: 72px; height: 72px; opacity: 0; }
}
.splash-error .splash-content { color: var(--danger); }
.splash-hint {
font-size: 12px;
color: var(--text-muted);
}
/* ─── MapLibre overrides ────────────────────────────────────────────── */
.maplibregl-ctrl-group {
background: rgba(15, 17, 26, 0.9) !important;
@@ -414,3 +488,77 @@ html, body, #root {
}
.maplibregl-ctrl-attrib a { color: var(--text-muted) !important; }
/* ─── Status dot ────────────────────────────────────────────────────── */
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-dot--ready { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,.6); }
.status-dot--starting { background: #fbbf24; animation: pulse-dot 1.2s ease-in-out infinite; }
.status-dot--error { background: #f87171; }
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
/* ─── Toast system ──────────────────────────────────────────────────── */
.toast-container { position: absolute; bottom: 48px; right: 16px; z-index: 2000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { padding: 10px 14px; border-radius: var(--radius); font-size: 12px; font-weight: 500; backdrop-filter: blur(8px); border: 1px solid; animation: toast-enter .25s ease forwards; pointer-events: all; display: flex; align-items: center; gap: 8px; min-width: 180px; }
.toast--success { background: rgba(74,222,128,.12); border-color: rgba(74,222,128,.3); color: #4ade80; }
.toast--error { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.3); color: #f87171; }
.toast--info { background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.3); color: #60a5fa; }
.toast-msg { flex: 1; }
.toast-close { background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6; font-size: 14px; line-height: 1; padding: 0; }
.toast-close:hover { opacity: 1; }
@keyframes toast-enter { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
/* ─── Map loading bar ───────────────────────────────────────────────── */
.map-loading-overlay { position: absolute; top: 0; left: 0; right: 0; z-index: 500; pointer-events: none; }
.map-loading-bar { height: 3px; background: linear-gradient(90deg, transparent, var(--accent), transparent); background-size: 200% 100%; animation: loading-sweep 1.2s ease-in-out infinite; }
@keyframes loading-sweep { 0% { background-position: -100% 0; } 100% { background-position: 200% 0; } }
/* ─── Map context menu ──────────────────────────────────────────────── */
.map-context-menu { position: absolute; z-index: 1500; background: var(--panel-bg); border: 1px solid var(--panel-border); border-radius: var(--radius-sm); box-shadow: 0 8px 24px rgba(0,0,0,.5); min-width: 210px; overflow: hidden; }
.map-context-menu button { display: block; width: 100%; padding: 9px 14px; background: none; border: none; color: var(--text); font-size: 12px; text-align: left; cursor: pointer; transition: background .1s; }
.map-context-menu button:hover { background: var(--surface-hover); }
/* ─── Isochrone hover popup ─────────────────────────────────────────── */
.iso-popup .maplibregl-popup-content { background: rgba(15,17,26,.92); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-sm); color: var(--text); font-size: 12px; font-weight: 600; padding: 5px 10px; box-shadow: 0 4px 16px rgba(0,0,0,.5); }
.iso-popup .maplibregl-popup-tip { display: none; }
/* ─── Isochrone map labels ──────────────────────────────────────────── */
.iso-map-label { background: rgba(0,0,0,.58); backdrop-filter: blur(4px); border-radius: 4px; color: #fff; font-size: 10px; font-weight: 700; padding: 2px 6px; pointer-events: none; white-space: nowrap; letter-spacing: .3px; font-family: 'Inter', system-ui, sans-serif; }
/* ─── Preset chips ──────────────────────────────────────────────────── */
.preset-chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; }
.preset-chips::-webkit-scrollbar { display: none; }
.preset-chip { flex-shrink: 0; padding: 3px 8px; background: var(--surface); border: 1px solid var(--panel-border); border-radius: 100px; color: var(--text-muted); font-size: 11px; cursor: pointer; white-space: nowrap; transition: all var(--transition-fast); }
.preset-chip:hover { border-color: var(--accent); color: var(--accent); }
/* ─── History ───────────────────────────────────────────────────────── */
.history-toggle { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; padding: 0; width: 100%; text-align: left; display: flex; align-items: center; justify-content: space-between; }
.history-toggle:hover { color: var(--text); }
.history-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.history-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--surface); border: 1px solid var(--panel-border); border-radius: var(--radius-sm); cursor: pointer; text-align: left; width: 100%; transition: background .1s; }
.history-item:hover { background: var(--surface-hover); border-color: var(--accent); }
.history-icon { font-size: 14px; flex-shrink: 0; }
.history-label { flex: 1; font-size: 11px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-meta { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
.collapsible-body { max-height: 0; overflow: hidden; transition: max-height var(--transition-med), opacity var(--transition-med); opacity: 0; }
.collapsible-body.open { max-height: 500px; opacity: 1; }
/* ─── Coord copy / auto-recalc ──────────────────────────────────────── */
.btn-copy { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 14px; padding: 0 3px; transition: color var(--transition-fast); line-height: 1; }
.btn-copy:hover { color: var(--accent); }
.auto-recalc-toggle { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none; }
.auto-recalc-toggle input { accent-color: var(--accent); }
/* ─── Error card ────────────────────────────────────────────────────── */
.error-card { display: flex; align-items: flex-start; gap: 10px; background: rgba(248,113,113,.08); border: 1px solid rgba(248,113,113,.2); border-radius: var(--radius); padding: 10px 12px; }
.error-icon { font-size: 16px; color: var(--danger); flex-shrink: 0; margin-top: 1px; }
.error-body { flex: 1; }
.error-title { font-size: 12px; font-weight: 600; color: var(--danger); }
.error-detail { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.4; word-break: break-word; }
.btn-retry { background: none; border: 1px solid rgba(248,113,113,.3); border-radius: var(--radius-sm); color: var(--danger); font-size: 11px; padding: 4px 8px; cursor: pointer; flex-shrink: 0; transition: all var(--transition-fast); white-space: nowrap; }
.btn-retry:hover { background: rgba(248,113,113,.1); }
/* ─── Legend eye toggle ─────────────────────────────────────────────── */
.legend-eye { background: none; border: none; color: var(--text-muted); font-size: 11px; cursor: pointer; margin-left: auto; padding: 0 2px; line-height: 1; opacity: 0; transition: opacity var(--transition-fast); }
.legend:hover .legend-eye { opacity: 0.5; }
.legend-item:hover .legend-eye { opacity: 1; }
.legend-item.hidden .legend-eye { opacity: 1; color: var(--danger); }

View File

@@ -1,15 +1,15 @@
import { useRef, useState } from 'react'
import { useRef, useState, useEffect } from 'react'
import type maplibregl from 'maplibre-gl'
import { useAppStore, type TransportMode } from '../store/useAppStore'
import { fetchIsochrones } from '../api/ors'
import { fetchIsochrones, cancelFetch } from '../api/ors'
import { searchPlace, type NominatimResult } from '../utils/geocoder'
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 {
@@ -18,35 +18,23 @@ interface ControlPanelProps {
export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
const {
point,
mode,
timeRanges,
isochrones,
loading,
error,
valhallaUrl,
setPoint,
setMode,
setIsochrones,
setLoading,
setError,
setValhallaUrl
point, mode, timeRanges, isochrones, loading, error,
valhallaStatus, history,
setPoint, setMode, setIsochrones, setLoading, setError,
addToast, addToHistory, restoreHistory,
autoRecalculate, setAutoRecalculate
} = useAppStore()
const [query, setQuery] = useState('')
const [results, setResults] = useState<NominatimResult[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleQueryChange = (q: string): void => {
setQuery(q)
if (searchTimer.current) clearTimeout(searchTimer.current)
if (q.length < 2) {
setResults([])
setShowDropdown(false)
return
}
if (q.length < 2) { setResults([]); setShowDropdown(false); return }
searchTimer.current = setTimeout(async () => {
const r = await searchPlace(q)
setResults(r)
@@ -64,36 +52,54 @@ 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)
const t0 = Date.now()
try {
const data = await fetchIsochrones(point, mode, timeRanges, valhallaUrl)
const data = await fetchIsochrones(point, mode, timeRanges)
setIsochrones(data)
const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
addToast({ message: `Computed in ${elapsed}s`, type: 'success', duration: 3000 })
addToHistory({ point, mode, timeRanges, isochrones: data, timestamp: Date.now(), label: query || undefined })
} catch (e) {
setError((e as Error).message)
const msg = (e as Error).message
setError(msg)
addToast({ message: msg, type: 'error', duration: 5000 })
} finally {
setLoading(false)
}
}
// Keyboard shortcut: Enter to calculate
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && !(e.target instanceof HTMLInputElement) && !loading) {
handleCalculate()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [point, mode, timeRanges, loading]) // eslint-disable-line react-hooks/exhaustive-deps
const statusLabel = valhallaStatus === 'ready' ? 'Engine ready' : valhallaStatus === 'starting' ? 'Starting...' : 'Engine error'
return (
<aside className="control-panel">
<div className="panel-header">
<h1 className="panel-title">🗺 Isochrone Map</h1>
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
</div>
{/* 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)}
@@ -102,42 +108,37 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
{showDropdown && (
<ul className="search-dropdown">
{results.map((r) => (
<li key={r.place_id} onMouseDown={() => selectResult(r)}>
{r.display_name}
</li>
<li key={r.place_id} onMouseDown={() => selectResult(r)}>{r.display_name}</li>
))}
</ul>
)}
</div>
{point ? (
<div className="coord-row">
<span className="coord-text">
📍 {point[1].toFixed(5)}, {point[0].toFixed(5)}
</span>
<span className="coord-text">📍 {point[1].toFixed(5)}, {point[0].toFixed(5)}</span>
<button
className="btn-clear"
title="Effacer le point"
className="btn-copy"
title="Copy coordinates"
onClick={() => {
setPoint(null)
setIsochrones(null)
setQuery('')
navigator.clipboard.writeText(`${point[1].toFixed(6)}, ${point[0].toFixed(6)}`)
addToast({ message: 'Coordinates copied', type: 'info', duration: 2000 })
}}
>
</button>
></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
key={m.value}
data-mode={m.value}
className={`btn-mode${mode === m.value ? ' active' : ''}`}
onClick={() => setMode(m.value)}
>
@@ -150,51 +151,67 @@ 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>
{error && <p className="error-msg"> {error}</p>}
<label className="auto-recalc-toggle">
<input
type="checkbox"
checked={autoRecalculate}
onChange={(e) => setAutoRecalculate(e.target.checked)}
/>
<span>Auto-recalculate</span>
</label>
{error && (
<div className="error-card">
<span className="error-icon"></span>
<div className="error-body">
<p className="error-title">Computation error</p>
<p className="error-detail">{error}</p>
</div>
<button className="btn-retry" onClick={handleCalculate}></button>
</div>
)}
</section>
{/* Export */}
{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>
<button className="btn-secondary" onClick={() => mapRef.current && exportPng(mapRef.current)}>📸 PNG</button>
<button className="btn-secondary" onClick={() => exportGeoJSON(isochrones)}>📄 GeoJSON</button>
</div>
</section>
)}
{/* Settings */}
<section className="panel-section api-section">
<button className="btn-link" onClick={() => setShowSettings(!showSettings)}>
{showSettings ? '▲' : '▼'} Serveur Valhalla
</button>
{showSettings && (
<input
type="text"
className="input-text"
placeholder="https://routing.kerboul.me"
value={valhallaUrl}
onChange={(e) => setValhallaUrl(e.target.value)}
/>
)}
</section>
{/* History */}
{history.length > 0 && (
<section className="panel-section">
<button className="history-toggle" onClick={() => setHistoryOpen((v) => !v)}>
<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: '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} range{h.timeRanges.length > 1 ? 's' : ''}</span>
</button>
))}
</div>
</div>
</section>
)}
</aside>
)
}

View File

@@ -1,21 +1,27 @@
import { useAppStore } from '../store/useAppStore'
import { formatDuration, ISOCHRONE_COLORS } from '../api/ors'
import { formatDuration, getIsochroneColors } from '../api/ors'
export function Legend(): React.JSX.Element | null {
const { isochrones, timeRanges } = useAppStore()
const { isochrones, timeRanges, mode, hiddenLayers, toggleLayer } = useAppStore()
if (!isochrones || isochrones.features.length === 0) return null
const sorted = [...timeRanges].sort((a, b) => a - b)
const colors = getIsochroneColors(mode)
return (
<div className="legend">
{sorted.map((t, i) => (
<div key={t} className="legend-item">
<span
className="legend-swatch"
style={{ background: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length] }}
/>
<div
key={t}
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
onClick={() => toggleLayer(i)}
title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
>
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
<span className="legend-label">{formatDuration(t)}</span>
<button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}>
{hiddenLayers.has(i) ? '🚫' : '👁'}
</button>
</div>
))}
</div>

View File

@@ -1,10 +1,21 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useAppStore } from '../store/useAppStore'
import type { FeatureCollection } from 'geojson'
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
const MAP_STYLE: maplibregl.StyleSpecification = {
version: 8,
sources: {
carto: {
type: 'raster',
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
tileSize: 256,
attribution: '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
}
},
layers: [{ id: 'carto-dark', type: 'raster', source: 'carto' }]
}
const SRC_ISO = 'isochrones'
const SRC_POINT = 'point'
const LAYER_FILL = 'iso-fill'
@@ -15,13 +26,19 @@ interface MapViewProps {
mapRef: React.MutableRefObject<maplibregl.Map | null>
}
type ContextMenu = { lng: number; lat: number; x: number; y: number } | null
export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const { point, setPoint, isochrones } = useAppStore()
const { point, setPoint, isochrones, hiddenLayers, timeRanges } = useAppStore()
const [contextMenu, setContextMenu] = useState<ContextMenu>(null)
// Keep refs in sync for use inside map event handlers / load callback
const isochronesRef = useRef<FeatureCollection | null>(null)
const pointRef = useRef<[number, number] | null>(null)
const hoveredIdRef = useRef<number | null>(null)
const markersRef = useRef<maplibregl.Marker[]>([])
const popupRef = useRef<maplibregl.Popup | null>(null)
isochronesRef.current = isochrones
pointRef.current = point
@@ -32,33 +49,49 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
container: containerRef.current,
style: MAP_STYLE,
center: [2.3522, 48.8566],
zoom: 5,
canvasContextAttributes: { preserveDrawingBuffer: true } // needed for PNG export
zoom: 6,
canvasContextAttributes: { preserveDrawingBuffer: true }
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(new maplibregl.ScaleControl(), 'bottom-right')
map.getCanvas().style.cursor = 'crosshair'
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
className: 'iso-popup',
maxWidth: 'none',
offset: 8
})
popupRef.current = popup
map.on('load', () => {
const empty: FeatureCollection = { type: 'FeatureCollection', features: [] }
// Isochrone layers
map.addSource(SRC_ISO, { type: 'geojson', data: empty })
map.addSource(SRC_ISO, { type: 'geojson', data: empty, generateId: false })
map.addLayer({
id: LAYER_FILL,
type: 'fill',
source: SRC_ISO,
paint: { 'fill-color': ['get', 'isoColor'], 'fill-opacity': 0.25 }
paint: {
'fill-color': ['get', 'isoColor'],
'fill-opacity': ['case', ['boolean', ['feature-state', 'hovered'], false], 0.6, ['get', 'isoOpacity']],
'fill-opacity-transition': { duration: 500, delay: 0 }
}
})
map.addLayer({
id: LAYER_LINE,
type: 'line',
source: SRC_ISO,
paint: { 'line-color': ['get', 'isoColor'], 'line-width': 2, 'line-opacity': 0.9 }
paint: {
'line-color': ['get', 'isoColor'],
'line-width': 2,
'line-opacity': 0.9,
'line-opacity-transition': { duration: 500, delay: 0 }
}
})
// Departure point dot
map.addSource(SRC_POINT, { type: 'geojson', data: empty })
map.addLayer({
id: LAYER_POINT,
@@ -72,17 +105,54 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
}
})
// Apply data that may have been set before load fired
if (isochronesRef.current) applyIsochrones(map, isochronesRef.current)
if (pointRef.current) applyPoint(map, pointRef.current)
})
// Cursor intelligence
map.on('mouseenter', LAYER_FILL, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' })
map.on('click', (e) => {
setPoint([e.lngLat.lng, e.lngLat.lat])
// Hover popup + highlight
map.on('mousemove', LAYER_FILL, (e) => {
if (!e.features?.length) return
const f = e.features[0]
const fid = f.id as number
if (hoveredIdRef.current !== null && hoveredIdRef.current !== fid) {
map.setFeatureState({ source: SRC_ISO, id: hoveredIdRef.current }, { hovered: false })
}
hoveredIdRef.current = fid
map.setFeatureState({ source: SRC_ISO, id: fid }, { hovered: true })
const label = f.properties?.isoLabel as string
popup.setLngLat(e.lngLat).setHTML(`<span>${label}</span>`).addTo(map)
})
map.on('mouseleave', LAYER_FILL, () => {
if (hoveredIdRef.current !== null) {
map.setFeatureState({ source: SRC_ISO, id: hoveredIdRef.current }, { hovered: false })
}
hoveredIdRef.current = null
popup.remove()
})
// Context menu
map.on('contextmenu', (e) => {
e.preventDefault()
setContextMenu({ lng: e.lngLat.lng, lat: e.lngLat.lat, x: e.point.x, y: e.point.y })
})
map.on('click', (e) => {
setContextMenu(null)
setPoint([e.lngLat.lng, e.lngLat.lat])
})
// Apply data that may have been set before load fired
if (isochronesRef.current) applyIsochrones(map, isochronesRef.current, markersRef)
if (pointRef.current) applyPoint(map, pointRef.current)
})
mapRef.current = map
return () => {
markersRef.current.forEach((m) => m.remove())
markersRef.current = []
popup.remove()
map.remove()
mapRef.current = null
}
@@ -95,14 +165,57 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
applyPoint(map, point)
}, [point]) // eslint-disable-line react-hooks/exhaustive-deps
// Sync isochrones to map
// Sync isochrones to map — with fade-in
useEffect(() => {
const map = mapRef.current
if (!map?.isStyleLoaded()) return
applyIsochrones(map, isochrones)
// Set opacity to 0 immediately, then animate in on next frame
map.setPaintProperty(LAYER_FILL, 'fill-opacity', 0)
map.setPaintProperty(LAYER_LINE, 'line-opacity', 0)
applyIsochrones(map, isochrones, markersRef)
requestAnimationFrame(() => {
if (!mapRef.current) return
mapRef.current.setPaintProperty(LAYER_FILL, 'fill-opacity', [
'case', ['boolean', ['feature-state', 'hovered'], false], 0.6, ['get', 'isoOpacity']
])
mapRef.current.setPaintProperty(LAYER_LINE, 'line-opacity', 0.9)
})
}, [isochrones]) // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={containerRef} className="map-container" />
// Sync hidden layers filter
useEffect(() => {
const map = mapRef.current
if (!map?.isStyleLoaded()) return
const total = timeRanges.length
const visibleIndices = Array.from({ length: total }, (_, i) => i).filter((i) => !hiddenLayers.has(i))
const filter: maplibregl.FilterSpecification = visibleIndices.length > 0
? ['in', ['get', 'isoIndex'], ['literal', visibleIndices]]
: ['==', 1, 0]
map.setFilter(LAYER_FILL, filter)
map.setFilter(LAYER_LINE, filter)
}, [hiddenLayers, timeRanges]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={containerRef} className="map-container" />
{contextMenu && (
<div
className="map-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button onClick={() => {
setPoint([contextMenu.lng, contextMenu.lat])
setContextMenu(null)
}}>
📍 Set as starting point
</button>
</div>
)}
</div>
)
}
function applyPoint(map: maplibregl.Map, point: [number, number] | null): void {
@@ -110,22 +223,54 @@ function applyPoint(map: maplibregl.Map, point: [number, number] | null): void {
if (!src) return
src.setData(
point
? {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: point }, properties: {} }
]
}
? { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: { type: 'Point', coordinates: point }, properties: {} }] }
: { type: 'FeatureCollection', features: [] }
)
}
function applyIsochrones(map: maplibregl.Map, data: FeatureCollection | null): void {
function computeCentroid(coords: number[][]): [number, number] {
let x = 0, y = 0
const n = coords.length
for (const c of coords) { x += c[0]; y += c[1] }
return [x / n, y / n]
}
function applyIsochrones(
map: maplibregl.Map,
data: FeatureCollection | null,
markersRef: React.MutableRefObject<maplibregl.Marker[]>
): void {
const src = map.getSource(SRC_ISO) as maplibregl.GeoJSONSource | undefined
if (!src) return
// Clear old markers
markersRef.current.forEach((m) => m.remove())
markersRef.current = []
src.setData(data ?? { type: 'FeatureCollection', features: [] })
if (data && data.features.length > 0) {
// Add isochrone labels as DOM markers
data.features.forEach((f) => {
const label = f.properties?.isoLabel as string | undefined
if (!label) return
let centroid: [number, number] | null = null
if (f.geometry.type === 'Polygon' && f.geometry.coordinates[0]?.length) {
centroid = computeCentroid(f.geometry.coordinates[0])
} else if (f.geometry.type === 'MultiPolygon' && f.geometry.coordinates[0]?.[0]?.length) {
centroid = computeCentroid(f.geometry.coordinates[0][0])
}
if (!centroid) return
const el = document.createElement('div')
el.className = 'iso-map-label'
el.textContent = label
const marker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat(centroid)
.addTo(map)
markersRef.current.push(marker)
})
// Fit bounds
const coords = data.features.flatMap((f) => {
const g = f.geometry
if (g.type === 'Polygon') return g.coordinates[0]
@@ -135,10 +280,7 @@ function applyIsochrones(map: maplibregl.Map, data: FeatureCollection | null): v
const lngs = coords.map((c) => c[0])
const lats = coords.map((c) => c[1])
map.fitBounds(
[
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)]
],
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
{ padding: 60, duration: 800 }
)
}

View File

@@ -0,0 +1,11 @@
import { useAppStore } from '../store/useAppStore'
export function MapOverlay(): React.JSX.Element | null {
const loading = useAppStore((s) => s.loading)
if (!loading) return null
return (
<div className="map-loading-overlay">
<div className="map-loading-bar" />
</div>
)
}

View File

@@ -1,12 +1,20 @@
import { useAppStore } from '../store/useAppStore'
import { formatDuration, ISOCHRONE_COLORS } from '../api/ors'
import { formatDuration, getIsochroneColors } from '../api/ors'
const PRESETS = [
{ label: '15-30-60', ranges: [900, 1800, 3600] },
{ label: '1h-2h-4h-8h', ranges: [3600, 7200, 14400, 28800] },
{ label: 'Road trip', ranges: [14400, 21600, 28800, 36000] },
{ label: '30m-1h-2h', ranges: [1800, 3600, 7200] },
]
export function TimeRangeEditor(): React.JSX.Element {
const { timeRanges, setTimeRanges } = useAppStore()
const { timeRanges, mode, setTimeRanges } = useAppStore()
const colors = getIsochroneColors(mode)
const sorted = [...timeRanges].sort((a, b) => a - b)
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 sec = Math.max(1800, Math.round(hours * 2) / 2 * 3600)
const next = sorted.map((v, i) => (i === index ? sec : v)).sort((a, b) => a - b)
setTimeRanges(next)
}
@@ -17,7 +25,7 @@ export function TimeRangeEditor(): React.JSX.Element {
}
const add = (): void => {
if (sorted.length >= 5) return
if (sorted.length >= 8) return
const next = Math.min(36000, Math.max(...sorted) + 3600)
if (next === Math.max(...sorted)) return
setTimeRanges([...sorted, next])
@@ -25,12 +33,17 @@ export function TimeRangeEditor(): React.JSX.Element {
return (
<div className="time-ranges">
<div className="preset-chips">
{PRESETS.map((p) => (
<button key={p.label} className="preset-chip" onClick={() => setTimeRanges(p.ranges)}>
{p.label}
</button>
))}
</div>
{sorted.map((t, i) => (
<div key={i} className="time-range-row">
<span
className="range-dot"
style={{ background: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length] }}
/>
<span className="range-dot" style={{ background: colors[i % colors.length] }} />
<input
type="number"
className="range-input"
@@ -41,20 +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 < 5 && Math.max(...sorted) < 36000 && (
<button className="btn-add" onClick={add}>
+ Ajouter une durée
</button>
{sorted.length < 8 && Math.max(...sorted) < 36000 && (
<button className="btn-add" onClick={add}>+ Add a duration</button>
)}
</div>
)

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { useAppStore } from '../store/useAppStore'
export function Toast(): React.JSX.Element {
const toasts = useAppStore((s) => s.toasts)
const removeToast = useAppStore((s) => s.removeToast)
return (
<div className="toast-container">
{toasts.map((t) => (
<ToastItem key={t.id} id={t.id} message={t.message} type={t.type} duration={t.duration} onRemove={removeToast} />
))}
</div>
)
}
function ToastItem({
id, message, type, duration = 4000, onRemove
}: {
id: string; message: string; type: 'success'|'error'|'info'; duration?: number; onRemove: (id: string) => void
}): React.JSX.Element {
useEffect(() => {
const timer = setTimeout(() => onRemove(id), duration)
return () => clearTimeout(timer)
}, [id, duration, onRemove])
return (
<div className={`toast toast--${type}`}>
<span className="toast-msg">{message}</span>
<button className="toast-close" onClick={() => onRemove(id)}>×</button>
</div>
)
}

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_VALHALLA_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -2,43 +2,100 @@ import { create } from 'zustand'
import type { FeatureCollection } from 'geojson'
export type TransportMode = 'auto' | 'bicycle' | 'pedestrian'
export type ValhallaStatus = 'starting' | 'ready' | 'error'
export type Toast = {
id: string
message: string
type: 'success' | 'error' | 'info'
duration?: number
}
export type HistoryEntry = {
id: string
point: [number, number]
mode: TransportMode
timeRanges: number[]
isochrones: FeatureCollection
timestamp: number
label?: string
}
interface AppState {
point: [number, number] | null
mode: TransportMode
timeRanges: number[] // seconds
timeRanges: number[]
isochrones: FeatureCollection | null
loading: boolean
error: string | null
valhallaUrl: string
valhallaStatus: ValhallaStatus
toasts: Toast[]
addToast: (t: Omit<Toast, 'id'>) => void
removeToast: (id: string) => void
history: HistoryEntry[]
addToHistory: (e: Omit<HistoryEntry, 'id'>) => void
restoreHistory: (e: HistoryEntry) => void
hiddenLayers: Set<number>
toggleLayer: (index: number) => void
autoRecalculate: boolean
setAutoRecalculate: (v: boolean) => void
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
setValhallaUrl: (url: string) => void
setValhallaStatus: (s: ValhallaStatus) => void
}
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,
point: [2.3522, 48.8566],
mode: 'auto',
timeRanges: [3600, 7200, 14400], // 1h, 2h, 4h
timeRanges: [900, 1800, 3600],
isochrones: null,
loading: false,
error: null,
valhallaUrl: localStorage.getItem(URL_KEY) ?? DEFAULT_URL,
valhallaStatus: 'starting',
toasts: [],
addToast: (t) => set((s) => ({
toasts: [...s.toasts, { ...t, id: Math.random().toString(36).slice(2) }]
})),
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
history: [],
addToHistory: (e) => set((s) => ({
history: [{ ...e, id: Math.random().toString(36).slice(2) }, ...s.history].slice(0, 5)
})),
restoreHistory: (e) => set({
point: e.point,
mode: e.mode,
timeRanges: e.timeRanges,
isochrones: e.isochrones,
hiddenLayers: new Set<number>()
}),
hiddenLayers: new Set<number>(),
toggleLayer: (index) => set((s) => {
const next = new Set(s.hiddenLayers)
if (next.has(index)) next.delete(index)
else next.add(index)
return { hiddenLayers: next }
}),
autoRecalculate: false,
setAutoRecalculate: (autoRecalculate) => set({ autoRecalculate }),
setPoint: (point) => set({ point }),
setMode: (mode) => set({ mode }),
setTimeRanges: (timeRanges) => set({ timeRanges }),
setIsochrones: (isochrones) => set({ isochrones }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setValhallaUrl: (url) => {
localStorage.setItem(URL_KEY, url)
set({ valhallaUrl: url })
}
setValhallaStatus: (valhallaStatus) => set({ valhallaStatus })
}))

15
vite.web.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { resolve } from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'src/renderer',
plugins: [react()],
resolve: {
alias: { '@renderer': resolve('src/renderer/src') }
},
build: {
outDir: '../../dist-web',
emptyOutDir: true
}
})