Compare commits
13 Commits
fbd7bf5c70
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bfbe5c6050 | |||
| 7319184f74 | |||
| 63eb6b921d | |||
| 05f7cb35cc | |||
| 70b4da8e2c | |||
| 071e4f91ac | |||
| d68f954a7d | |||
| 20f5c1a361 | |||
| 332acfe815 | |||
| cf6e4c6479 | |||
| 9463eaa8ad | |||
| 7b9b2cda5a | |||
| 0c9faeb6f3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ out
|
||||
*.log*
|
||||
.env
|
||||
*.local
|
||||
dist-web
|
||||
|
||||
200
AGENT.md
Normal file
200
AGENT.md
Normal 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
212
README.md
@@ -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
|
||||
[](https://www.electronjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://maplibre.org/)
|
||||
[](https://github.com/valhalla/valhalla)
|
||||
[](https://github.com/DaKerboul/isochrone-app)
|
||||
[](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)
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "wsl.exe -d Ubuntu -u root -- bash -c \"docker ps --filter name=valhalla-service --filter status=running -q | grep -q . && echo '[valhalla] already running' || (docker start valhalla-service 2>/dev/null && echo '[valhalla] started') || echo '[valhalla] WARNING: could not start valhalla-service'\"",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"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
40
proxy.cjs
Normal 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
BIN
resources/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 519 KiB |
BIN
resources/screenshot.png
Normal file
BIN
resources/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 519 KiB |
@@ -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()
|
||||
|
||||
7
src/preload/index.d.ts
vendored
7
src/preload/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,18 +1,144 @@
|
||||
import { useRef } from 'react'
|
||||
import { useEffect, useRef, useState } 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 { LandingPage } from './components/LandingPage'
|
||||
import { useAppStore } from './store/useAppStore'
|
||||
import { fetchIsochrones, cancelFetch } from './api/ors'
|
||||
import type { TransportMode } from './store/useAppStore'
|
||||
|
||||
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)
|
||||
const point = useAppStore((s) => s.point)
|
||||
const mode = useAppStore((s) => s.mode)
|
||||
const timeRanges = useAppStore((s) => s.timeRanges)
|
||||
|
||||
const hasHash = !!window.location.hash
|
||||
const [showLanding, setShowLanding] = useState(() => !sessionStorage.getItem('seen') && !hasHash)
|
||||
|
||||
// Restore from URL hash on mount
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) return
|
||||
try {
|
||||
const parts = hash.split('/')
|
||||
if (parts.length < 3) return
|
||||
const [lat, lng] = parts[0].split(',').map(Number)
|
||||
const modeStr = parts[1]
|
||||
const ranges = parts[2].split('-').map(Number)
|
||||
const { setPoint, setMode, setTimeRanges } = useAppStore.getState()
|
||||
if (!isNaN(lat) && !isNaN(lng)) setPoint([lng, lat])
|
||||
if (['auto', 'bicycle', 'pedestrian'].includes(modeStr)) setMode(modeStr as TransportMode)
|
||||
if (ranges.length > 0 && ranges.every((r) => !isNaN(r) && r > 0)) setTimeRanges(ranges)
|
||||
} catch { /* ignore malformed hash */ }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync state to URL hash
|
||||
useEffect(() => {
|
||||
if (!point) return
|
||||
const sorted = [...timeRanges].sort((a, b) => a - b)
|
||||
const hash = `#${point[1].toFixed(5)},${point[0].toFixed(5)}/${mode}/${sorted.join('-')}`
|
||||
window.history.replaceState(null, '', hash)
|
||||
}, [point, mode, timeRanges])
|
||||
|
||||
// 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: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!p) return
|
||||
setLoading(true)
|
||||
fetchIsochrones(p, m, tr)
|
||||
.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: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!p) return
|
||||
cancelFetch()
|
||||
setLoading(true)
|
||||
fetchIsochrones(p, m, tr)
|
||||
.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 (showLanding) {
|
||||
return <LandingPage onStart={() => { sessionStorage.setItem('seen', '1'); setShowLanding(false) }} />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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,21 +25,68 @@ 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({
|
||||
const body = JSON.stringify({
|
||||
locations: [{ lon: point[0], lat: point[1] }],
|
||||
costing: COSTING_MAP[mode],
|
||||
contours,
|
||||
@@ -40,14 +94,34 @@ export async function fetchIsochrones(
|
||||
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', 'Connection': 'close' },
|
||||
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) => ({
|
||||
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: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length],
|
||||
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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,66 @@ 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>
|
||||
<h1 className="panel-title">ISOCHRONE</h1>
|
||||
<div className="panel-header-right">
|
||||
{isochrones && (
|
||||
<button
|
||||
className="btn-share"
|
||||
title="Copy shareable link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
addToast({ message: 'Link copied!', type: 'success', duration: 2000 })
|
||||
}}
|
||||
>🔗</button>
|
||||
)}
|
||||
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
|
||||
</div>
|
||||
</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 +120,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 +163,80 @@ 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
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<section className="panel-section">
|
||||
<button className="history-toggle" onClick={() => setHistoryOpen((v) => !v)}>
|
||||
<span>History</span>
|
||||
<span>{historyOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{showSettings && (
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="https://routing.kerboul.me"
|
||||
value={valhallaUrl}
|
||||
onChange={(e) => setValhallaUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<div className="panel-footer">
|
||||
<a
|
||||
href="https://github.com/DaKerboul/isochrone-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="github-link"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836a9.59 9.59 0 0 1 2.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/renderer/src/components/LandingPage.tsx
Normal file
43
src/renderer/src/components/LandingPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props { onStart: () => void }
|
||||
|
||||
export function LandingPage({ onStart }: Props): React.JSX.Element {
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
|
||||
const handleStart = (): void => {
|
||||
setLeaving(true)
|
||||
setTimeout(onStart, 420)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`landing${leaving ? ' landing--out' : ''}`}>
|
||||
<div className="landing-blob landing-blob-1" />
|
||||
<div className="landing-blob landing-blob-2" />
|
||||
<div className="landing-blob landing-blob-3" />
|
||||
|
||||
<div className="landing-rings">
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
</div>
|
||||
|
||||
<div className="landing-content">
|
||||
<div className="landing-badge">Powered by Valhalla · OpenStreetMap</div>
|
||||
<h1 className="landing-title">ISOCHRONE</h1>
|
||||
<p className="landing-sub">
|
||||
Visualize how far you can travel from any point in France — by car, bike, or on foot.
|
||||
</p>
|
||||
<div className="landing-modes">
|
||||
<span className="landing-mode-chip landing-mode-auto">🚗 Drive</span>
|
||||
<span className="landing-mode-chip landing-mode-bike">🚴 Bike</span>
|
||||
<span className="landing-mode-chip landing-mode-walk">🚶 Walk</span>
|
||||
</div>
|
||||
<button className="landing-cta" onClick={handleStart}>
|
||||
Start Exploring →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,68 @@
|
||||
import type { Feature } from 'geojson'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import { formatDuration, ISOCHRONE_COLORS } from '../api/ors'
|
||||
import { formatDuration, getIsochroneColors } from '../api/ors'
|
||||
|
||||
function ringAreaKm2(coords: number[][]): number {
|
||||
const R = 6371
|
||||
let area = 0
|
||||
const n = coords.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const lon1 = coords[i][0] * Math.PI / 180
|
||||
const lat1 = coords[i][1] * Math.PI / 180
|
||||
const lon2 = coords[j][0] * Math.PI / 180
|
||||
const lat2 = coords[j][1] * Math.PI / 180
|
||||
area += (lon2 - lon1) * (2 + Math.sin(lat1) + Math.sin(lat2))
|
||||
}
|
||||
return Math.abs(area) * R * R / 2
|
||||
}
|
||||
|
||||
function featureAreaKm2(f: Feature): number {
|
||||
const g = f.geometry
|
||||
if (g.type === 'Polygon') return ringAreaKm2(g.coordinates[0])
|
||||
if (g.type === 'MultiPolygon') return g.coordinates.reduce((s, p) => s + ringAreaKm2(p[0]), 0)
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatArea(km2: number): string {
|
||||
if (km2 >= 1000) return `${(km2 / 1000).toFixed(1)}k km²`
|
||||
return `${Math.round(km2).toLocaleString()} km²`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const areaByIndex = new Map<number, number>()
|
||||
isochrones.features.forEach((f) => {
|
||||
const idx = f.properties?.isoIndex as number | undefined
|
||||
if (idx !== undefined) areaByIndex.set(idx, featureAreaKm2(f))
|
||||
})
|
||||
|
||||
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 className="legend-title">Reachable zones</div>
|
||||
{sorted.map((t, i) => {
|
||||
const area = areaByIndex.get(i)
|
||||
return (
|
||||
<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>
|
||||
{area !== undefined && <span className="legend-area">{formatArea(area)}</span>}
|
||||
<button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}>
|
||||
{hiddenLayers.has(i) ? '🚫' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
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 BASEMAP_TILES = {
|
||||
dark: [
|
||||
'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',
|
||||
],
|
||||
light: [
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
satellite: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
}
|
||||
|
||||
const ATTRIBUTION = '© <a href="https://carto.com/">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||
|
||||
const MAP_STYLE: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'bg-dark': { type: 'raster', tiles: BASEMAP_TILES.dark, tileSize: 256, attribution: ATTRIBUTION },
|
||||
'bg-light': { type: 'raster', tiles: BASEMAP_TILES.light, tileSize: 256, attribution: ATTRIBUTION },
|
||||
'bg-satellite': { type: 'raster', tiles: BASEMAP_TILES.satellite, tileSize: 256, attribution: '© Esri' },
|
||||
},
|
||||
layers: [
|
||||
{ id: 'bg-dark-layer', type: 'raster', source: 'bg-dark' },
|
||||
{ id: 'bg-light-layer', type: 'raster', source: 'bg-light', layout: { visibility: 'none' } },
|
||||
{ id: 'bg-satellite-layer', type: 'raster', source: 'bg-satellite', layout: { visibility: 'none' } },
|
||||
]
|
||||
}
|
||||
|
||||
const BG_LAYERS = { dark: 'bg-dark-layer', light: 'bg-light-layer', satellite: 'bg-satellite-layer' }
|
||||
|
||||
const SRC_ISO = 'isochrones'
|
||||
const SRC_POINT = 'point'
|
||||
const LAYER_FILL = 'iso-fill'
|
||||
@@ -15,13 +48,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, basemap, setBasemap } = 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 +71,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 +127,48 @@ 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)
|
||||
map.on('mouseenter', LAYER_FILL, () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' })
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
|
||||
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 +181,78 @@ 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)
|
||||
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
|
||||
|
||||
// Sync basemap
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map?.isStyleLoaded()) return
|
||||
Object.values(BG_LAYERS).forEach((id) => map.setLayoutProperty(id, 'visibility', 'none'))
|
||||
map.setLayoutProperty(BG_LAYERS[basemap], 'visibility', 'visible')
|
||||
}, [basemap]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const BASEMAP_ICONS = { dark: '🌙', light: '☀️', satellite: '🛰️' }
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<div ref={containerRef} className="map-container" />
|
||||
|
||||
{/* Basemap switcher */}
|
||||
<div className="basemap-switcher">
|
||||
{(Object.keys(BASEMAP_ICONS) as Array<keyof typeof BASEMAP_ICONS>).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
className={`basemap-btn${basemap === b ? ' active' : ''}`}
|
||||
onClick={() => setBasemap(b)}
|
||||
title={b.charAt(0).toUpperCase() + b.slice(1)}
|
||||
>
|
||||
{BASEMAP_ICONS[b]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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 +260,51 @@ 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
|
||||
|
||||
markersRef.current.forEach((m) => m.remove())
|
||||
markersRef.current = []
|
||||
|
||||
src.setData(data ?? { type: 'FeatureCollection', features: [] })
|
||||
|
||||
if (data && data.features.length > 0) {
|
||||
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)
|
||||
})
|
||||
|
||||
const coords = data.features.flatMap((f) => {
|
||||
const g = f.geometry
|
||||
if (g.type === 'Polygon') return g.coordinates[0]
|
||||
@@ -135,10 +314,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 }
|
||||
)
|
||||
}
|
||||
|
||||
67
src/renderer/src/components/MapOverlay.tsx
Normal file
67
src/renderer/src/components/MapOverlay.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
|
||||
// Estimate how long a calculation will take based on max range (seconds)
|
||||
function estimateDuration(maxRangeSec: number): number {
|
||||
// Base 1.5s + ~2s per hour of isochrone range
|
||||
return 1500 + (maxRangeSec / 3600) * 2200
|
||||
}
|
||||
|
||||
export function MapOverlay(): React.JSX.Element | null {
|
||||
const loading = useAppStore((s) => s.loading)
|
||||
const timeRanges = useAppStore((s) => s.timeRanges)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const startRef = useRef<number>(0)
|
||||
const estimatedRef = useRef<number>(3000)
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
const maxRange = Math.max(...timeRanges)
|
||||
estimatedRef.current = estimateDuration(maxRange)
|
||||
startRef.current = Date.now()
|
||||
setProgress(0)
|
||||
setVisible(true)
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startRef.current
|
||||
const ratio = elapsed / estimatedRef.current
|
||||
// Ease to 88%: asymptotic curve that never quite reaches it
|
||||
const pct = 88 * (1 - Math.exp(-3 * ratio))
|
||||
setProgress(Math.min(pct, 88))
|
||||
}, 80)
|
||||
} else {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
// Snap to 100% then fade out
|
||||
setProgress(100)
|
||||
const t = setTimeout(() => {
|
||||
setVisible(false)
|
||||
setProgress(0)
|
||||
}, 500)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const pct = Math.round(progress)
|
||||
|
||||
return (
|
||||
<div className="map-loading-overlay">
|
||||
<div className="map-loading-track">
|
||||
<div
|
||||
className={`map-loading-fill${progress >= 100 ? ' map-loading-fill--done' : ''}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`map-loading-badge${progress >= 100 ? ' map-loading-badge--done' : ''}`}
|
||||
style={{ left: `clamp(32px, ${progress}%, calc(100% - 40px))` }}
|
||||
>
|
||||
{pct < 100 ? `${pct}%` : '✓'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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.min(28800, 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,44 +25,41 @@ export function TimeRangeEditor(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const add = (): void => {
|
||||
if (sorted.length >= 5) return
|
||||
const next = Math.min(36000, Math.max(...sorted) + 3600)
|
||||
if (sorted.length >= 8) return
|
||||
const next = Math.min(28800, Math.max(...sorted) + 3600)
|
||||
if (next === Math.max(...sorted)) return
|
||||
setTimeRanges([...sorted, next])
|
||||
}
|
||||
|
||||
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"
|
||||
min={0.5}
|
||||
max={10}
|
||||
max={8}
|
||||
step={0.5}
|
||||
value={t / 3600}
|
||||
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) < 28800 && (
|
||||
<button className="btn-add" onClick={add}>+ Add a duration</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
33
src/renderer/src/components/Toast.tsx
Normal file
33
src/renderer/src/components/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
src/renderer/src/env.d.ts
vendored
8
src/renderer/src/env.d.ts
vendored
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_VALHALLA_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@@ -2,43 +2,106 @@ 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
|
||||
|
||||
basemap: 'dark' | 'light' | 'satellite'
|
||||
setBasemap: (b: 'dark' | 'light' | 'satellite') => 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 }),
|
||||
|
||||
basemap: 'dark',
|
||||
setBasemap: (basemap) => set({ basemap }),
|
||||
|
||||
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
15
vite.web.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user