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*
|
*.log*
|
||||||
.env
|
.env
|
||||||
*.local
|
*.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
|
### Install
|
||||||
|
|
||||||
```bash
|
```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
|
```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
|
```bash
|
||||||
# For windows
|
docker run -d \
|
||||||
$ npm run build:win
|
--name valhalla-service \
|
||||||
|
-p 8002:8002 \
|
||||||
# For macOS
|
-v ./valhalla:/custom_files \
|
||||||
$ npm run build:mac
|
--entrypoint /usr/local/bin/valhalla_service \
|
||||||
|
ghcr.io/gis-ops/docker-valhalla/valhalla:latest \
|
||||||
# For Linux
|
/custom_files/valhalla.json 4
|
||||||
$ npm run build:linux
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"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",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "npm run build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"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": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@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 { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
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 {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 860,
|
height: 860,
|
||||||
@@ -15,12 +165,17 @@ function createWindow(): void {
|
|||||||
...(process.platform === 'linux' ? { icon } : {}),
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false
|
sandbox: false,
|
||||||
|
webSecurity: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindowRef = mainWindow
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
|
// Start Valhalla after window is shown
|
||||||
|
startValhalla().catch((e) => console.error('[Valhalla] start error:', e))
|
||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
@@ -40,6 +195,11 @@ function createWindow(): void {
|
|||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
// initialization and is ready to create browser windows.
|
// initialization and is ready to create browser windows.
|
||||||
// Some APIs can only be used after this event occurs.
|
// 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(() => {
|
app.whenReady().then(() => {
|
||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
@@ -63,9 +223,11 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
app.on('before-quit', () => {
|
||||||
// for applications and their menu bar to stay active until the user quits
|
stopValhalla().catch(() => {})
|
||||||
// explicitly with Cmd + Q.
|
})
|
||||||
|
|
||||||
|
// Quit when all windows are closed, except on macOS.
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
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'
|
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) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
@@ -15,8 +21,8 @@ if (process.contextIsolated) {
|
|||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore
|
||||||
window.electron = electronAPI
|
window.electron = electronAPI
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore
|
||||||
window.api = api
|
window.api = api
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,144 @@
|
|||||||
import { useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type maplibregl from 'maplibre-gl'
|
import type maplibregl from 'maplibre-gl'
|
||||||
import { MapView } from './components/Map'
|
import { MapView } from './components/Map'
|
||||||
import { ControlPanel } from './components/ControlPanel'
|
import { ControlPanel } from './components/ControlPanel'
|
||||||
import { Legend } from './components/Legend'
|
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 {
|
function App(): React.JSX.Element {
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
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 (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<ControlPanel mapRef={mapRef} />
|
<ControlPanel mapRef={mapRef} />
|
||||||
<div className="map-wrapper">
|
<div className="map-wrapper">
|
||||||
<MapView mapRef={mapRef} />
|
<MapView mapRef={mapRef} />
|
||||||
|
<MapOverlay />
|
||||||
<Legend />
|
<Legend />
|
||||||
|
<Toast />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { FeatureCollection, Feature, Polygon, MultiPolygon } from 'geojson'
|
import type { FeatureCollection, Feature, Polygon, MultiPolygon } from 'geojson'
|
||||||
import type { TransportMode } from '../store/useAppStore'
|
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> = {
|
const COSTING_MAP: Record<TransportMode, string> = {
|
||||||
auto: 'auto',
|
auto: 'auto',
|
||||||
bicycle: 'bicycle',
|
bicycle: 'bicycle',
|
||||||
@@ -18,21 +25,68 @@ export function formatDuration(seconds: number): string {
|
|||||||
return `${m}min`
|
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(
|
export async function fetchIsochrones(
|
||||||
point: [number, number],
|
point: [number, number],
|
||||||
mode: TransportMode,
|
mode: TransportMode,
|
||||||
ranges: number[], // in seconds
|
ranges: number[]
|
||||||
valhallaUrl: string
|
|
||||||
): Promise<FeatureCollection> {
|
): Promise<FeatureCollection> {
|
||||||
// Valhalla expects minutes, sorted ascending
|
|
||||||
const sorted = [...ranges].sort((a, b) => a - b)
|
const sorted = [...ranges].sort((a, b) => a - b)
|
||||||
const contours = sorted.map((s) => ({ time: s / 60 }))
|
const contours = sorted.map((s) => ({ time: s / 60 }))
|
||||||
|
|
||||||
const url = `${valhallaUrl.replace(/\/$/, '')}/isochrone`
|
const body = JSON.stringify({
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
locations: [{ lon: point[0], lat: point[1] }],
|
locations: [{ lon: point[0], lat: point[1] }],
|
||||||
costing: COSTING_MAP[mode],
|
costing: COSTING_MAP[mode],
|
||||||
contours,
|
contours,
|
||||||
@@ -40,14 +94,34 @@ export async function fetchIsochrones(
|
|||||||
denoise: 0.5,
|
denoise: 0.5,
|
||||||
generalize: 150
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text()
|
const errText = await response.text().catch(() => '')
|
||||||
let msg = `Erreur Valhalla ${response.status}`
|
let msg = `Valhalla error ${response.status}`
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text)
|
const parsed = JSON.parse(errText)
|
||||||
msg = parsed.error ?? parsed.error_code ? `Valhalla ${parsed.error_code}: ${parsed.error}` : msg
|
if (parsed.error) msg = `Valhalla ${parsed.error_code}: ${parsed.error}`
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -55,18 +129,24 @@ export async function fetchIsochrones(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const geojson: FeatureCollection = await response.json()
|
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 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>),
|
...(f as Feature<Polygon | MultiPolygon>),
|
||||||
|
id: i,
|
||||||
|
geometry: simplifyGeometry(geom, 0.001),
|
||||||
properties: {
|
properties: {
|
||||||
...f.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,
|
isoIndex: i,
|
||||||
isoLabel: formatDuration(((f.properties?.contour as number | undefined) ?? sorted[i] / 60) * 60)
|
isoLabel: formatDuration(((f.properties?.contour as number | undefined) ?? sorted[i] / 60) * 60)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { type: 'FeatureCollection', features } as FeatureCollection
|
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 type maplibregl from 'maplibre-gl'
|
||||||
import { useAppStore, type TransportMode } from '../store/useAppStore'
|
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 { searchPlace, type NominatimResult } from '../utils/geocoder'
|
||||||
import { exportPng, exportGeoJSON } from '../utils/export'
|
import { exportPng, exportGeoJSON } from '../utils/export'
|
||||||
import { TimeRangeEditor } from './TimeRangeEditor'
|
import { TimeRangeEditor } from './TimeRangeEditor'
|
||||||
|
|
||||||
const MODES: { value: TransportMode; label: string; icon: string }[] = [
|
const MODES: { value: TransportMode; label: string; icon: string }[] = [
|
||||||
{ value: 'auto', label: 'Voiture', icon: '🚗' },
|
{ value: 'auto', label: 'Car', icon: '🚗' },
|
||||||
{ value: 'bicycle', label: 'Vélo', icon: '🚴' },
|
{ value: 'bicycle', label: 'Bike', icon: '🚴' },
|
||||||
{ value: 'pedestrian', label: 'Piéton', icon: '🚶' }
|
{ value: 'pedestrian', label: 'Walk', icon: '🚶' }
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ControlPanelProps {
|
interface ControlPanelProps {
|
||||||
@@ -18,35 +18,23 @@ interface ControlPanelProps {
|
|||||||
|
|
||||||
export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
point,
|
point, mode, timeRanges, isochrones, loading, error,
|
||||||
mode,
|
valhallaStatus, history,
|
||||||
timeRanges,
|
setPoint, setMode, setIsochrones, setLoading, setError,
|
||||||
isochrones,
|
addToast, addToHistory, restoreHistory,
|
||||||
loading,
|
autoRecalculate, setAutoRecalculate
|
||||||
error,
|
|
||||||
valhallaUrl,
|
|
||||||
setPoint,
|
|
||||||
setMode,
|
|
||||||
setIsochrones,
|
|
||||||
setLoading,
|
|
||||||
setError,
|
|
||||||
setValhallaUrl
|
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [results, setResults] = useState<NominatimResult[]>([])
|
const [results, setResults] = useState<NominatimResult[]>([])
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const handleQueryChange = (q: string): void => {
|
const handleQueryChange = (q: string): void => {
|
||||||
setQuery(q)
|
setQuery(q)
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||||
if (q.length < 2) {
|
if (q.length < 2) { setResults([]); setShowDropdown(false); return }
|
||||||
setResults([])
|
|
||||||
setShowDropdown(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
const r = await searchPlace(q)
|
const r = await searchPlace(q)
|
||||||
setResults(r)
|
setResults(r)
|
||||||
@@ -64,36 +52,66 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCalculate = async (): Promise<void> => {
|
const handleCalculate = async (): Promise<void> => {
|
||||||
if (!point) {
|
if (!point) { setError('Click on the map to set a starting point.'); return }
|
||||||
setError('Cliquez sur la carte pour définir un point de départ.')
|
cancelFetch()
|
||||||
return
|
|
||||||
}
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
const t0 = Date.now()
|
||||||
try {
|
try {
|
||||||
const data = await fetchIsochrones(point, mode, timeRanges, valhallaUrl)
|
const data = await fetchIsochrones(point, mode, timeRanges)
|
||||||
setIsochrones(data)
|
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) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
const msg = (e as Error).message
|
||||||
|
setError(msg)
|
||||||
|
addToast({ message: msg, type: 'error', duration: 5000 })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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 (
|
return (
|
||||||
<aside className="control-panel">
|
<aside className="control-panel">
|
||||||
<div className="panel-header">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<label className="section-label">Point de départ</label>
|
<label className="section-label">Starting point</label>
|
||||||
<div className="search-wrap">
|
<div className="search-wrap">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="Rechercher un lieu..."
|
placeholder="Search for a place..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => handleQueryChange(e.target.value)}
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
||||||
@@ -102,42 +120,37 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
|||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
<ul className="search-dropdown">
|
<ul className="search-dropdown">
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<li key={r.place_id} onMouseDown={() => selectResult(r)}>
|
<li key={r.place_id} onMouseDown={() => selectResult(r)}>{r.display_name}</li>
|
||||||
{r.display_name}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{point ? (
|
{point ? (
|
||||||
<div className="coord-row">
|
<div className="coord-row">
|
||||||
<span className="coord-text">
|
<span className="coord-text">📍 {point[1].toFixed(5)}, {point[0].toFixed(5)}</span>
|
||||||
📍 {point[1].toFixed(5)}, {point[0].toFixed(5)}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
className="btn-clear"
|
className="btn-copy"
|
||||||
title="Effacer le point"
|
title="Copy coordinates"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPoint(null)
|
navigator.clipboard.writeText(`${point[1].toFixed(6)}, ${point[0].toFixed(6)}`)
|
||||||
setIsochrones(null)
|
addToast({ message: 'Coordinates copied', type: 'info', duration: 2000 })
|
||||||
setQuery('')
|
|
||||||
}}
|
}}
|
||||||
>
|
>⎘</button>
|
||||||
✕
|
<button className="btn-clear" title="Clear point" onClick={() => { setPoint(null); setIsochrones(null); setQuery('') }}>✕</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="hint">ou cliquez directement sur la carte</p>
|
<p className="hint">or click directly on the map</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Transport mode */}
|
{/* Transport mode */}
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<label className="section-label">Mode de transport</label>
|
<label className="section-label">Transport mode</label>
|
||||||
<div className="mode-selector">
|
<div className="mode-selector">
|
||||||
{MODES.map((m) => (
|
{MODES.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
|
data-mode={m.value}
|
||||||
className={`btn-mode${mode === m.value ? ' active' : ''}`}
|
className={`btn-mode${mode === m.value ? ' active' : ''}`}
|
||||||
onClick={() => setMode(m.value)}
|
onClick={() => setMode(m.value)}
|
||||||
>
|
>
|
||||||
@@ -150,51 +163,80 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Time ranges */}
|
{/* Time ranges */}
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<label className="section-label">Durées de trajet (heures)</label>
|
<label className="section-label">Travel times (hours)</label>
|
||||||
<TimeRangeEditor />
|
<TimeRangeEditor />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Calculate */}
|
{/* Calculate */}
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<button className="btn-primary" onClick={handleCalculate} disabled={loading}>
|
<button className="btn-primary" onClick={handleCalculate} disabled={loading}>
|
||||||
{loading ? '⏳ Calcul en cours...' : '⚡ Calculer les isochrones'}
|
{loading ? '⏳ Computing...' : '⚡ Compute isochrones'}
|
||||||
</button>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
{isochrones && (
|
{isochrones && (
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<label className="section-label">Export</label>
|
<label className="section-label">Export</label>
|
||||||
|
|
||||||
<div className="export-row">
|
<div className="export-row">
|
||||||
<button
|
<button className="btn-secondary" onClick={() => mapRef.current && exportPng(mapRef.current)}>📸 PNG</button>
|
||||||
className="btn-secondary"
|
<button className="btn-secondary" onClick={() => exportGeoJSON(isochrones)}>📄 GeoJSON</button>
|
||||||
onClick={() => mapRef.current && exportPng(mapRef.current)}
|
|
||||||
>
|
|
||||||
📸 PNG
|
|
||||||
</button>
|
|
||||||
<button className="btn-secondary" onClick={() => exportGeoJSON(isochrones)}>
|
|
||||||
📄 GeoJSON
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* History */}
|
||||||
<section className="panel-section api-section">
|
{history.length > 0 && (
|
||||||
<button className="btn-link" onClick={() => setShowSettings(!showSettings)}>
|
<section className="panel-section">
|
||||||
{showSettings ? '▲' : '▼'} Serveur Valhalla
|
<button className="history-toggle" onClick={() => setHistoryOpen((v) => !v)}>
|
||||||
|
<span>History</span>
|
||||||
|
<span>{historyOpen ? '▾' : '▸'}</span>
|
||||||
</button>
|
</button>
|
||||||
{showSettings && (
|
<div className={`collapsible-body${historyOpen ? ' open' : ''}`}>
|
||||||
<input
|
<div className="history-list">
|
||||||
type="text"
|
{history.map((h) => (
|
||||||
className="input-text"
|
<button key={h.id} className="history-item" onClick={() => { restoreHistory(h); addToast({ message: 'Calculation restored', type: 'info', duration: 2000 }) }}>
|
||||||
placeholder="https://routing.kerboul.me"
|
<span className="history-icon">{MODES.find((m) => m.value === h.mode)?.icon}</span>
|
||||||
value={valhallaUrl}
|
<span className="history-label">{h.label ?? `${h.point[1].toFixed(3)}, ${h.point[0].toFixed(3)}`}</span>
|
||||||
onChange={(e) => setValhallaUrl(e.target.value)}
|
<span className="history-meta">{h.timeRanges.length} range{h.timeRanges.length > 1 ? 's' : ''}</span>
|
||||||
/>
|
</button>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</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>
|
</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 { 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 {
|
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
|
if (!isochrones || isochrones.features.length === 0) return null
|
||||||
|
|
||||||
const sorted = [...timeRanges].sort((a, b) => a - b)
|
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 (
|
return (
|
||||||
<div className="legend">
|
<div className="legend">
|
||||||
{sorted.map((t, i) => (
|
<div className="legend-title">Reachable zones</div>
|
||||||
<div key={t} className="legend-item">
|
{sorted.map((t, i) => {
|
||||||
<span
|
const area = areaByIndex.get(i)
|
||||||
className="legend-swatch"
|
return (
|
||||||
style={{ background: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length] }}
|
<div
|
||||||
/>
|
key={t}
|
||||||
|
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
|
||||||
|
onClick={() => toggleLayer(i)}
|
||||||
|
title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
|
||||||
|
>
|
||||||
|
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
|
||||||
<span className="legend-label">{formatDuration(t)}</span>
|
<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>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import maplibregl from 'maplibre-gl'
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { useAppStore } from '../store/useAppStore'
|
import { useAppStore } from '../store/useAppStore'
|
||||||
import type { FeatureCollection } from 'geojson'
|
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_ISO = 'isochrones'
|
||||||
const SRC_POINT = 'point'
|
const SRC_POINT = 'point'
|
||||||
const LAYER_FILL = 'iso-fill'
|
const LAYER_FILL = 'iso-fill'
|
||||||
@@ -15,13 +48,19 @@ interface MapViewProps {
|
|||||||
mapRef: React.MutableRefObject<maplibregl.Map | null>
|
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 {
|
export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
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 isochronesRef = useRef<FeatureCollection | null>(null)
|
||||||
const pointRef = useRef<[number, number] | 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
|
isochronesRef.current = isochrones
|
||||||
pointRef.current = point
|
pointRef.current = point
|
||||||
|
|
||||||
@@ -32,33 +71,49 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
|||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: MAP_STYLE,
|
style: MAP_STYLE,
|
||||||
center: [2.3522, 48.8566],
|
center: [2.3522, 48.8566],
|
||||||
zoom: 5,
|
zoom: 6,
|
||||||
canvasContextAttributes: { preserveDrawingBuffer: true } // needed for PNG export
|
canvasContextAttributes: { preserveDrawingBuffer: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
map.addControl(new maplibregl.ScaleControl(), 'bottom-right')
|
map.addControl(new maplibregl.ScaleControl(), 'bottom-right')
|
||||||
map.getCanvas().style.cursor = 'crosshair'
|
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', () => {
|
map.on('load', () => {
|
||||||
const empty: FeatureCollection = { type: 'FeatureCollection', features: [] }
|
const empty: FeatureCollection = { type: 'FeatureCollection', features: [] }
|
||||||
|
|
||||||
// Isochrone layers
|
map.addSource(SRC_ISO, { type: 'geojson', data: empty, generateId: false })
|
||||||
map.addSource(SRC_ISO, { type: 'geojson', data: empty })
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: LAYER_FILL,
|
id: LAYER_FILL,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: SRC_ISO,
|
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({
|
map.addLayer({
|
||||||
id: LAYER_LINE,
|
id: LAYER_LINE,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: SRC_ISO,
|
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.addSource(SRC_POINT, { type: 'geojson', data: empty })
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: LAYER_POINT,
|
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
|
map.on('mouseenter', LAYER_FILL, () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
if (isochronesRef.current) applyIsochrones(map, isochronesRef.current)
|
map.on('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' })
|
||||||
if (pointRef.current) applyPoint(map, pointRef.current)
|
|
||||||
|
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) => {
|
map.on('click', (e) => {
|
||||||
|
setContextMenu(null)
|
||||||
setPoint([e.lngLat.lng, e.lngLat.lat])
|
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
|
mapRef.current = map
|
||||||
return () => {
|
return () => {
|
||||||
|
markersRef.current.forEach((m) => m.remove())
|
||||||
|
markersRef.current = []
|
||||||
|
popup.remove()
|
||||||
map.remove()
|
map.remove()
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
}
|
}
|
||||||
@@ -95,14 +181,78 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
|||||||
applyPoint(map, point)
|
applyPoint(map, point)
|
||||||
}, [point]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [point]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Sync isochrones to map
|
// Sync isochrones to map — with fade-in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map?.isStyleLoaded()) return
|
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
|
}, [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 {
|
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
|
if (!src) return
|
||||||
src.setData(
|
src.setData(
|
||||||
point
|
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: [] }
|
: { 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
|
const src = map.getSource(SRC_ISO) as maplibregl.GeoJSONSource | undefined
|
||||||
if (!src) return
|
if (!src) return
|
||||||
|
|
||||||
|
markersRef.current.forEach((m) => m.remove())
|
||||||
|
markersRef.current = []
|
||||||
|
|
||||||
src.setData(data ?? { type: 'FeatureCollection', features: [] })
|
src.setData(data ?? { type: 'FeatureCollection', features: [] })
|
||||||
|
|
||||||
if (data && data.features.length > 0) {
|
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 coords = data.features.flatMap((f) => {
|
||||||
const g = f.geometry
|
const g = f.geometry
|
||||||
if (g.type === 'Polygon') return g.coordinates[0]
|
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 lngs = coords.map((c) => c[0])
|
||||||
const lats = coords.map((c) => c[1])
|
const lats = coords.map((c) => c[1])
|
||||||
map.fitBounds(
|
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 }
|
{ 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 { 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 {
|
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 sorted = [...timeRanges].sort((a, b) => a - b)
|
||||||
|
|
||||||
const update = (index: number, hours: number): void => {
|
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)
|
const next = sorted.map((v, i) => (i === index ? sec : v)).sort((a, b) => a - b)
|
||||||
setTimeRanges(next)
|
setTimeRanges(next)
|
||||||
}
|
}
|
||||||
@@ -17,44 +25,41 @@ export function TimeRangeEditor(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const add = (): void => {
|
const add = (): void => {
|
||||||
if (sorted.length >= 5) return
|
if (sorted.length >= 8) return
|
||||||
const next = Math.min(36000, Math.max(...sorted) + 3600)
|
const next = Math.min(28800, Math.max(...sorted) + 3600)
|
||||||
if (next === Math.max(...sorted)) return
|
if (next === Math.max(...sorted)) return
|
||||||
setTimeRanges([...sorted, next])
|
setTimeRanges([...sorted, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="time-ranges">
|
<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) => (
|
{sorted.map((t, i) => (
|
||||||
<div key={i} className="time-range-row">
|
<div key={i} className="time-range-row">
|
||||||
<span
|
<span className="range-dot" style={{ background: colors[i % colors.length] }} />
|
||||||
className="range-dot"
|
|
||||||
style={{ background: ISOCHRONE_COLORS[i % ISOCHRONE_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="range-input"
|
className="range-input"
|
||||||
min={0.5}
|
min={0.5}
|
||||||
max={10}
|
max={8}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
value={t / 3600}
|
value={t / 3600}
|
||||||
onChange={(e) => update(i, parseFloat(e.target.value) || 0.5)}
|
onChange={(e) => update(i, parseFloat(e.target.value) || 0.5)}
|
||||||
/>
|
/>
|
||||||
<span className="range-preview">h — {formatDuration(t)}</span>
|
<span className="range-preview">h — {formatDuration(t)}</span>
|
||||||
<button
|
<button className="btn-icon" onClick={() => remove(i)} disabled={sorted.length <= 1} title="Remove">×</button>
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
disabled={sorted.length <= 1}
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sorted.length < 5 && Math.max(...sorted) < 36000 && (
|
|
||||||
<button className="btn-add" onClick={add}>
|
{sorted.length < 8 && Math.max(...sorted) < 28800 && (
|
||||||
+ Ajouter une durée
|
<button className="btn-add" onClick={add}>+ Add a duration</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
/// <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'
|
import type { FeatureCollection } from 'geojson'
|
||||||
|
|
||||||
export type TransportMode = 'auto' | 'bicycle' | 'pedestrian'
|
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 {
|
interface AppState {
|
||||||
point: [number, number] | null
|
point: [number, number] | null
|
||||||
mode: TransportMode
|
mode: TransportMode
|
||||||
timeRanges: number[] // seconds
|
timeRanges: number[]
|
||||||
isochrones: FeatureCollection | null
|
isochrones: FeatureCollection | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
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
|
setPoint: (point: [number, number] | null) => void
|
||||||
setMode: (mode: TransportMode) => void
|
setMode: (mode: TransportMode) => void
|
||||||
setTimeRanges: (ranges: number[]) => void
|
setTimeRanges: (ranges: number[]) => void
|
||||||
setIsochrones: (iso: FeatureCollection | null) => void
|
setIsochrones: (iso: FeatureCollection | null) => void
|
||||||
setLoading: (v: boolean) => void
|
setLoading: (v: boolean) => void
|
||||||
setError: (e: string | null) => 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) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
point: null,
|
point: [2.3522, 48.8566],
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
timeRanges: [3600, 7200, 14400], // 1h, 2h, 4h
|
timeRanges: [900, 1800, 3600],
|
||||||
isochrones: null,
|
isochrones: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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 }),
|
setPoint: (point) => set({ point }),
|
||||||
setMode: (mode) => set({ mode }),
|
setMode: (mode) => set({ mode }),
|
||||||
setTimeRanges: (timeRanges) => set({ timeRanges }),
|
setTimeRanges: (timeRanges) => set({ timeRanges }),
|
||||||
setIsochrones: (isochrones) => set({ isochrones }),
|
setIsochrones: (isochrones) => set({ isochrones }),
|
||||||
setLoading: (loading) => set({ loading }),
|
setLoading: (loading) => set({ loading }),
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
setValhallaUrl: (url) => {
|
setValhallaStatus: (valhallaStatus) => set({ valhallaStatus })
|
||||||
localStorage.setItem(URL_KEY, url)
|
|
||||||
set({ valhallaUrl: url })
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
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