Compare commits

..

7 Commits

Author SHA1 Message Date
bfbe5c6050 feat: cap max isochrone range at 8h (28800s)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:16:51 +02:00
7319184f74 fix: add Connection: close to prevent keep-alive socket reuse after abort
When AbortController.abort() resets a TCP connection mid-request,
Chromium may reuse the same keep-alive socket for the next request.
Valhalla's HTTP server then returns ERR_EMPTY_RESPONSE. Forcing
Connection: close ensures each request uses a fresh TCP connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:55:15 +02:00
63eb6b921d feat: predev hook auto-starts local Valhalla (WSL) before npm run dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:51:21 +02:00
05f7cb35cc feat: estimated progress bar + Valhalla 2 threads
- Progress bar animates 0→88% (exponential ease, time-range-aware)
- Snaps to 100% green ✓ on completion
- Floating % badge follows the bar
- Restarted valhalla-service with 2 threads for faster large isochrones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:50:44 +02:00
70b4da8e2c fix: use spherical polygon formula for isochrone area (Turf.js method)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:43:49 +02:00
071e4f91ac feat: add GitHub link in panel footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:42:55 +02:00
d68f954a7d feat: landing page, floating glass panel, basemap switcher, area stats, share URL
- LandingPage with animated rings + blobs, gradient title, per-mode chips
- Control panel → floating glass card (backdrop-filter, inset 16px)
- Gradient compute button with glow + hover lift
- Mode buttons with per-mode gradient glow
- Basemap switcher (dark/light/satellite) on map
- Legend: km² area per isochrone zone
- URL hash encoding for shareable links (lat,lng/mode/ranges)
- Share 🔗 button in panel header
- Full CSS overhaul: darker bg, glass surfaces, gradient accents

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:38:58 +02:00
11 changed files with 852 additions and 255 deletions

View File

@@ -12,6 +12,7 @@
"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",

View File

@@ -1,12 +1,14 @@
import { useEffect, 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 { Toast } from './components/Toast'
import { MapOverlay } from './components/MapOverlay' import { MapOverlay } from './components/MapOverlay'
import { LandingPage } from './components/LandingPage'
import { useAppStore } from './store/useAppStore' import { useAppStore } from './store/useAppStore'
import { fetchIsochrones, cancelFetch } from './api/ors' 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)
@@ -14,6 +16,37 @@ function App(): React.JSX.Element {
const setValhallaStatus = useAppStore((s) => s.setValhallaStatus) const setValhallaStatus = useAppStore((s) => s.setValhallaStatus)
const autoRecalculate = useAppStore((s) => s.autoRecalculate) const autoRecalculate = useAppStore((s) => s.autoRecalculate)
const autoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 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 // Get Valhalla status — IPC in Electron, fetch poll in web
useEffect(() => { useEffect(() => {
@@ -42,10 +75,10 @@ function App(): React.JSX.Element {
// Auto-calculate once Valhalla is ready // Auto-calculate once Valhalla is ready
useEffect(() => { useEffect(() => {
if (valhallaStatus !== 'ready') return if (valhallaStatus !== 'ready') return
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState() const { point: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
if (!point) return if (!p) return
setLoading(true) setLoading(true)
fetchIsochrones(point, mode, timeRanges) fetchIsochrones(p, m, tr)
.then((data) => setIsochrones(data)) .then((data) => setIsochrones(data))
.catch((e) => setError((e as Error).message)) .catch((e) => setError((e as Error).message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -56,11 +89,11 @@ function App(): React.JSX.Element {
if (!autoRecalculate || valhallaStatus !== 'ready') return if (!autoRecalculate || valhallaStatus !== 'ready') return
if (autoTimerRef.current) clearTimeout(autoTimerRef.current) if (autoTimerRef.current) clearTimeout(autoTimerRef.current)
autoTimerRef.current = setTimeout(() => { autoTimerRef.current = setTimeout(() => {
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState() const { point: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
if (!point) return if (!p) return
cancelFetch() cancelFetch()
setLoading(true) setLoading(true)
fetchIsochrones(point, mode, timeRanges) fetchIsochrones(p, m, tr)
.then((data) => setIsochrones(data)) .then((data) => setIsochrones(data))
.catch((e) => setError((e as Error).message)) .catch((e) => setError((e as Error).message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -68,6 +101,10 @@ function App(): React.JSX.Element {
return () => { if (autoTimerRef.current) clearTimeout(autoTimerRef.current) } return () => { if (autoTimerRef.current) clearTimeout(autoTimerRef.current) }
}) // eslint-disable-line react-hooks/exhaustive-deps }) // eslint-disable-line react-hooks/exhaustive-deps
if (showLanding) {
return <LandingPage onStart={() => { sessionStorage.setItem('seen', '1'); setShowLanding(false) }} />
}
if (valhallaStatus === 'starting') { if (valhallaStatus === 'starting') {
return ( return (
<div className="splash"> <div className="splash">

View File

@@ -104,7 +104,7 @@ export async function fetchIsochrones(
try { try {
response = await fetch(`${VALHALLA_BASE}/isochrone`, { response = await fetch(`${VALHALLA_BASE}/isochrone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'Connection': 'close' },
body, body,
signal: abortController.signal signal: abortController.signal
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -88,8 +88,20 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
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>
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} /> <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 */}
@@ -212,6 +224,19 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
</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>
) )
} }

View 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>
)
}

View File

@@ -1,6 +1,34 @@
import type { Feature } from 'geojson'
import { useAppStore } from '../store/useAppStore' import { useAppStore } from '../store/useAppStore'
import { formatDuration, getIsochroneColors } 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, mode, hiddenLayers, toggleLayer } = useAppStore() const { isochrones, timeRanges, mode, hiddenLayers, toggleLayer } = useAppStore()
if (!isochrones || isochrones.features.length === 0) return null if (!isochrones || isochrones.features.length === 0) return null
@@ -8,22 +36,33 @@ export function Legend(): React.JSX.Element | null {
const sorted = [...timeRanges].sort((a, b) => a - b) const sorted = [...timeRanges].sort((a, b) => a - b)
const colors = getIsochroneColors(mode) 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 {sorted.map((t, i) => {
key={t} const area = areaByIndex.get(i)
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`} return (
onClick={() => toggleLayer(i)} <div
title={hiddenLayers.has(i) ? 'Show' : 'Hide'} key={t}
> className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} /> onClick={() => toggleLayer(i)}
<span className="legend-label">{formatDuration(t)}</span> title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
<button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}> >
{hiddenLayers.has(i) ? '🚫' : '👁'} <span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
</button> <span className="legend-label">{formatDuration(t)}</span>
</div> {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>
) )
} }

View File

@@ -4,18 +4,40 @@ 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 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 = '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
const MAP_STYLE: maplibregl.StyleSpecification = { const MAP_STYLE: maplibregl.StyleSpecification = {
version: 8, version: 8,
sources: { sources: {
carto: { 'bg-dark': { type: 'raster', tiles: BASEMAP_TILES.dark, tileSize: 256, attribution: ATTRIBUTION },
type: 'raster', 'bg-light': { type: 'raster', tiles: BASEMAP_TILES.light, tileSize: 256, attribution: ATTRIBUTION },
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'], 'bg-satellite': { type: 'raster', tiles: BASEMAP_TILES.satellite, tileSize: 256, attribution: '&copy; Esri' },
tileSize: 256,
attribution: '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
}
}, },
layers: [{ id: 'carto-dark', type: 'raster', source: 'carto' }] 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'
@@ -30,7 +52,7 @@ 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, hiddenLayers, timeRanges } = useAppStore() const { point, setPoint, isochrones, hiddenLayers, timeRanges, basemap, setBasemap } = useAppStore()
const [contextMenu, setContextMenu] = useState<ContextMenu>(null) const [contextMenu, setContextMenu] = useState<ContextMenu>(null)
const isochronesRef = useRef<FeatureCollection | null>(null) const isochronesRef = useRef<FeatureCollection | null>(null)
@@ -105,22 +127,18 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
} }
}) })
// Cursor intelligence
map.on('mouseenter', LAYER_FILL, () => { map.getCanvas().style.cursor = 'pointer' }) map.on('mouseenter', LAYER_FILL, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' }) map.on('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' })
// Hover popup + highlight
map.on('mousemove', LAYER_FILL, (e) => { map.on('mousemove', LAYER_FILL, (e) => {
if (!e.features?.length) return if (!e.features?.length) return
const f = e.features[0] const f = e.features[0]
const fid = f.id as number const fid = f.id as number
if (hoveredIdRef.current !== null && hoveredIdRef.current !== fid) { if (hoveredIdRef.current !== null && hoveredIdRef.current !== fid) {
map.setFeatureState({ source: SRC_ISO, id: hoveredIdRef.current }, { hovered: false }) map.setFeatureState({ source: SRC_ISO, id: hoveredIdRef.current }, { hovered: false })
} }
hoveredIdRef.current = fid hoveredIdRef.current = fid
map.setFeatureState({ source: SRC_ISO, id: fid }, { hovered: true }) map.setFeatureState({ source: SRC_ISO, id: fid }, { hovered: true })
const label = f.properties?.isoLabel as string const label = f.properties?.isoLabel as string
popup.setLngLat(e.lngLat).setHTML(`<span>${label}</span>`).addTo(map) popup.setLngLat(e.lngLat).setHTML(`<span>${label}</span>`).addTo(map)
}) })
@@ -133,7 +151,6 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
popup.remove() popup.remove()
}) })
// Context menu
map.on('contextmenu', (e) => { map.on('contextmenu', (e) => {
e.preventDefault() e.preventDefault()
setContextMenu({ lng: e.lngLat.lng, lat: e.lngLat.lat, x: e.point.x, y: e.point.y }) setContextMenu({ lng: e.lngLat.lng, lat: e.lngLat.lat, x: e.point.x, y: e.point.y })
@@ -143,7 +160,6 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
setPoint([e.lngLat.lng, e.lngLat.lat]) setPoint([e.lngLat.lng, e.lngLat.lat])
}) })
// Apply data that may have been set before load fired
if (isochronesRef.current) applyIsochrones(map, isochronesRef.current, markersRef) if (isochronesRef.current) applyIsochrones(map, isochronesRef.current, markersRef)
if (pointRef.current) applyPoint(map, pointRef.current) if (pointRef.current) applyPoint(map, pointRef.current)
}) })
@@ -169,13 +185,9 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map?.isStyleLoaded()) return if (!map?.isStyleLoaded()) return
// Set opacity to 0 immediately, then animate in on next frame
map.setPaintProperty(LAYER_FILL, 'fill-opacity', 0) map.setPaintProperty(LAYER_FILL, 'fill-opacity', 0)
map.setPaintProperty(LAYER_LINE, 'line-opacity', 0) map.setPaintProperty(LAYER_LINE, 'line-opacity', 0)
applyIsochrones(map, isochrones, markersRef) applyIsochrones(map, isochrones, markersRef)
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!mapRef.current) return if (!mapRef.current) return
mapRef.current.setPaintProperty(LAYER_FILL, 'fill-opacity', [ mapRef.current.setPaintProperty(LAYER_FILL, 'fill-opacity', [
@@ -198,9 +210,34 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
map.setFilter(LAYER_LINE, filter) map.setFilter(LAYER_LINE, filter)
}, [hiddenLayers, timeRanges]) // eslint-disable-line react-hooks/exhaustive-deps }, [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 ( return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}> <div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={containerRef} className="map-container" /> <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 && ( {contextMenu && (
<div <div
className="map-context-menu" className="map-context-menu"
@@ -243,14 +280,12 @@ function applyIsochrones(
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
// Clear old markers
markersRef.current.forEach((m) => m.remove()) markersRef.current.forEach((m) => m.remove())
markersRef.current = [] 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) {
// Add isochrone labels as DOM markers
data.features.forEach((f) => { data.features.forEach((f) => {
const label = f.properties?.isoLabel as string | undefined const label = f.properties?.isoLabel as string | undefined
if (!label) return if (!label) return
@@ -270,7 +305,6 @@ function applyIsochrones(
markersRef.current.push(marker) markersRef.current.push(marker)
}) })
// Fit bounds
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]

View File

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

View File

@@ -14,7 +14,7 @@ export function TimeRangeEditor(): React.JSX.Element {
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) 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)
} }
@@ -26,7 +26,7 @@ export function TimeRangeEditor(): React.JSX.Element {
const add = (): void => { const add = (): void => {
if (sorted.length >= 8) 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])
} }
@@ -48,7 +48,7 @@ export function TimeRangeEditor(): React.JSX.Element {
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)}
@@ -58,7 +58,7 @@ export function TimeRangeEditor(): React.JSX.Element {
</div> </div>
))} ))}
{sorted.length < 8 && Math.max(...sorted) < 36000 && ( {sorted.length < 8 && Math.max(...sorted) < 28800 && (
<button className="btn-add" onClick={add}>+ Add a duration</button> <button className="btn-add" onClick={add}>+ Add a duration</button>
)} )}
</div> </div>

View File

@@ -44,6 +44,9 @@ interface AppState {
autoRecalculate: boolean autoRecalculate: boolean
setAutoRecalculate: (v: boolean) => void 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
@@ -91,6 +94,9 @@ export const useAppStore = create<AppState>((set) => ({
autoRecalculate: false, autoRecalculate: false,
setAutoRecalculate: (autoRecalculate) => set({ autoRecalculate }), 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 }),