diff --git a/.gitignore b/.gitignore index 9a3dd33..ae10fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ out *.log* .env *.local +dist-web diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..18e33cd --- /dev/null +++ b/AGENT.md @@ -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` | 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`. 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) diff --git a/README.md b/README.md index 2f7c1c2..e2055a3 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,204 @@ -# 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 +![isochrone demo](https://raw.githubusercontent.com/DaKerboul/isochrone-app/master/resources/screenshot.png) -- [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) +--- -## 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` (~8–12 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 diff --git a/package.json b/package.json index 879d7d6..d38daab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proxy.cjs b/proxy.cjs new file mode 100644 index 0000000..f375886 --- /dev/null +++ b/proxy.cjs @@ -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}`) +}) diff --git a/src/main/index.ts b/src/main/index.ts index cff1256..8ea8609 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + await new Promise((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 { + // 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 { + 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 | 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((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() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a153669..b74f774 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + valhallaAbort: () => Promise + getValhallaStatus: () => Promise<'ready' | 'starting' | 'error'> + onValhallaStatus: (cb: (status: 'ready' | 'starting' | 'error') => void) => void + } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d18524..9ceefbf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('valhalla-fetch', url, body), + valhallaAbort: (): Promise => + 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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 87c29da..76f5181 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,18 +1,91 @@ -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(null) + const valhallaStatus = useAppStore((s) => s.valhallaStatus) + const setValhallaStatus = useAppStore((s) => s.setValhallaStatus) + const autoRecalculate = useAppStore((s) => s.autoRecalculate) + const autoTimerRef = useRef | null>(null) + + // Probe Valhalla status at startup + useEffect(() => { + const base = (import.meta.env.VITE_VALHALLA_URL as string | undefined) ?? 'http://127.0.0.1:8002' + fetch(`${base}/status`, { signal: AbortSignal.timeout(3000) }) + .then((r) => setValhallaStatus(r.ok ? 'ready' : 'error')) + .catch(() => setValhallaStatus('error')) + }, []) // 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 ( +
+
+
+
+
+
+
+

Démarrage du moteur de routage...

+
+
+ ) + } + + if (valhallaStatus === 'error') { + return ( +
+
+

Valhalla n'a pas pu démarrer.

+

Vérifiez que Docker est actif dans WSL Ubuntu.

+
+
+ ) + } return (
+ +
) diff --git a/src/renderer/src/api/ors.ts b/src/renderer/src/api/ors.ts index 23a9d4d..5968845 100644 --- a/src/renderer/src/api/ors.ts +++ b/src/renderer/src/api/ors.ts @@ -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 = { + 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'], +} + +export function getIsochroneColors(mode: TransportMode): string[] { + return ISOCHRONE_COLORS[mode] +} -// Valhalla costing profiles const COSTING_MAP: Record = { 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 { - // 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(`Erreur réseau: ${err}`) + } + + console.log('[Valhalla] Response status:', response.status) + if (!response.ok) { - const text = await response.text() + const errText = await response.text().catch(() => '') let msg = `Erreur Valhalla ${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[] = reversed.map((f, i) => ({ - ...(f as Feature), - 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[] = reversed.map((f, i) => { + const geom = f.geometry as Polygon | MultiPolygon + return { + ...(f as Feature), + 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 } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 20157ab..af62aa3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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); } diff --git a/src/renderer/src/components/ControlPanel.tsx b/src/renderer/src/components/ControlPanel.tsx index 22b2299..29d96c3 100644 --- a/src/renderer/src/components/ControlPanel.tsx +++ b/src/renderer/src/components/ControlPanel.tsx @@ -1,7 +1,7 @@ -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' @@ -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([]) const [showDropdown, setShowDropdown] = useState(false) - const [showSettings, setShowSettings] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) const searchTimer = useRef | 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,26 +52,44 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element { } const handleCalculate = async (): Promise => { - if (!point) { - setError('Cliquez sur la carte pour définir un point de départ.') - return - } + if (!point) { setError('Cliquez sur la carte pour définir un point de départ.'); 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: `Calculé en ${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' ? 'Moteur prêt' : valhallaStatus === 'starting' ? 'Démarrage...' : 'Erreur moteur' + return (
{point ? (
- - 📍 {point[1].toFixed(5)}, {point[0].toFixed(5)} - + 📍 {point[1].toFixed(5)}, {point[0].toFixed(5)} + >⎘ +
) : (

ou cliquez directement sur la carte

@@ -138,6 +138,7 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element { {MODES.map((m) => ( - {error &&

⚠️ {error}

} + + {error && ( +
+ +
+

Erreur de calcul

+

{error}

+
+ +
+ )} {/* Export */} @@ -167,34 +185,32 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
- - + +
)} - {/* Settings */} -
- - {showSettings && ( - setValhallaUrl(e.target.value)} - /> - )} -
+ {/* History */} + {history.length > 0 && ( +
+ +
+
+ {history.map((h) => ( + + ))} +
+
+
+ )} ) } diff --git a/src/renderer/src/components/Legend.tsx b/src/renderer/src/components/Legend.tsx index 760ac8b..273ab70 100644 --- a/src/renderer/src/components/Legend.tsx +++ b/src/renderer/src/components/Legend.tsx @@ -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 (
{sorted.map((t, i) => ( -
- +
toggleLayer(i)} + title={hiddenLayers.has(i) ? 'Afficher' : 'Masquer'} + > + {formatDuration(t)} +
))}
diff --git a/src/renderer/src/components/Map.tsx b/src/renderer/src/components/Map.tsx index 327bbe9..9c69a73 100644 --- a/src/renderer/src/components/Map.tsx +++ b/src/renderer/src/components/Map.tsx @@ -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: '© CARTO © OSM' + } + }, + 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 } +type ContextMenu = { lng: number; lat: number; x: number; y: number } | null + export function MapView({ mapRef }: MapViewProps): React.JSX.Element { const containerRef = useRef(null) - const { point, setPoint, isochrones } = useAppStore() + const { point, setPoint, isochrones, hiddenLayers, timeRanges } = useAppStore() + const [contextMenu, setContextMenu] = useState(null) - // Keep refs in sync for use inside map event handlers / load callback const isochronesRef = useRef(null) const pointRef = useRef<[number, number] | null>(null) + const hoveredIdRef = useRef(null) + const markersRef = useRef([]) + const popupRef = useRef(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(`${label}`).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
+ // 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 ( +
+
+ {contextMenu && ( +
+ +
+ )} +
+ ) } 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 +): 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 } ) } diff --git a/src/renderer/src/components/MapOverlay.tsx b/src/renderer/src/components/MapOverlay.tsx new file mode 100644 index 0000000..a8e38a3 --- /dev/null +++ b/src/renderer/src/components/MapOverlay.tsx @@ -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 ( +
+
+
+ ) +} diff --git a/src/renderer/src/components/TimeRangeEditor.tsx b/src/renderer/src/components/TimeRangeEditor.tsx index 95a08a7..dfe55d5 100644 --- a/src/renderer/src/components/TimeRangeEditor.tsx +++ b/src/renderer/src/components/TimeRangeEditor.tsx @@ -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 (
+
+ {PRESETS.map((p) => ( + + ))} +
+ {sorted.map((t, i) => (
- + update(i, parseFloat(e.target.value) || 0.5)} /> h — {formatDuration(t)} - +
))} - {sorted.length < 5 && Math.max(...sorted) < 36000 && ( - + + {sorted.length < 8 && Math.max(...sorted) < 36000 && ( + )}
) diff --git a/src/renderer/src/components/Toast.tsx b/src/renderer/src/components/Toast.tsx new file mode 100644 index 0000000..8d40faa --- /dev/null +++ b/src/renderer/src/components/Toast.tsx @@ -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 ( +
+ {toasts.map((t) => ( + + ))} +
+ ) +} + +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 ( +
+ {message} + +
+ ) +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 11f02fe..7093a60 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_VALHALLA_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index 38527cb..8f08902 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -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) => void + removeToast: (id: string) => void + + history: HistoryEntry[] + addToHistory: (e: Omit) => void + restoreHistory: (e: HistoryEntry) => void + + hiddenLayers: Set + 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((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() + }), + + hiddenLayers: new Set(), + 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 }) })) diff --git a/vite.web.config.ts b/vite.web.config.ts new file mode 100644 index 0000000..87441b5 --- /dev/null +++ b/vite.web.config.ts @@ -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 + } +})