Compare commits
7 Commits
20f5c1a361
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bfbe5c6050 | |||
| 7319184f74 | |||
| 63eb6b921d | |||
| 05f7cb35cc | |||
| 70b4da8e2c | |||
| 071e4f91ac | |||
| d68f954a7d |
@@ -12,6 +12,7 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "wsl.exe -d Ubuntu -u root -- bash -c \"docker ps --filter name=valhalla-service --filter status=running -q | grep -q . && echo '[valhalla] already running' || (docker start valhalla-service 2>/dev/null && echo '[valhalla] started') || echo '[valhalla] WARNING: could not start valhalla-service'\"",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type maplibregl from 'maplibre-gl'
|
||||
import { MapView } from './components/Map'
|
||||
import { ControlPanel } from './components/ControlPanel'
|
||||
import { Legend } from './components/Legend'
|
||||
import { Toast } from './components/Toast'
|
||||
import { MapOverlay } from './components/MapOverlay'
|
||||
import { LandingPage } from './components/LandingPage'
|
||||
import { useAppStore } from './store/useAppStore'
|
||||
import { fetchIsochrones, cancelFetch } from './api/ors'
|
||||
import type { TransportMode } from './store/useAppStore'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
@@ -14,6 +16,37 @@ function App(): React.JSX.Element {
|
||||
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(() => {
|
||||
@@ -42,10 +75,10 @@ function App(): React.JSX.Element {
|
||||
// Auto-calculate once Valhalla is ready
|
||||
useEffect(() => {
|
||||
if (valhallaStatus !== 'ready') return
|
||||
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!point) return
|
||||
const { point: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!p) return
|
||||
setLoading(true)
|
||||
fetchIsochrones(point, mode, timeRanges)
|
||||
fetchIsochrones(p, m, tr)
|
||||
.then((data) => setIsochrones(data))
|
||||
.catch((e) => setError((e as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -56,11 +89,11 @@ function App(): React.JSX.Element {
|
||||
if (!autoRecalculate || valhallaStatus !== 'ready') return
|
||||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current)
|
||||
autoTimerRef.current = setTimeout(() => {
|
||||
const { point, mode, timeRanges, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!point) return
|
||||
const { point: p, mode: m, timeRanges: tr, setIsochrones, setLoading, setError } = useAppStore.getState()
|
||||
if (!p) return
|
||||
cancelFetch()
|
||||
setLoading(true)
|
||||
fetchIsochrones(point, mode, timeRanges)
|
||||
fetchIsochrones(p, m, tr)
|
||||
.then((data) => setIsochrones(data))
|
||||
.catch((e) => setError((e as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -68,6 +101,10 @@ function App(): React.JSX.Element {
|
||||
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">
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function fetchIsochrones(
|
||||
try {
|
||||
response = await fetch(`${VALHALLA_BASE}/isochrone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'Connection': 'close' },
|
||||
body,
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,8 +88,20 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
||||
return (
|
||||
<aside className="control-panel">
|
||||
<div className="panel-header">
|
||||
<h1 className="panel-title">🗺️ Isochrone Map</h1>
|
||||
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
|
||||
<h1 className="panel-title">ISOCHRONE</h1>
|
||||
<div className="panel-header-right">
|
||||
{isochrones && (
|
||||
<button
|
||||
className="btn-share"
|
||||
title="Copy shareable link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
addToast({ message: 'Link copied!', type: 'success', duration: 2000 })
|
||||
}}
|
||||
>🔗</button>
|
||||
)}
|
||||
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -212,6 +224,19 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="panel-footer">
|
||||
<a
|
||||
href="https://github.com/DaKerboul/isochrone-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="github-link"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836a9.59 9.59 0 0 1 2.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/renderer/src/components/LandingPage.tsx
Normal file
43
src/renderer/src/components/LandingPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props { onStart: () => void }
|
||||
|
||||
export function LandingPage({ onStart }: Props): React.JSX.Element {
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
|
||||
const handleStart = (): void => {
|
||||
setLeaving(true)
|
||||
setTimeout(onStart, 420)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`landing${leaving ? ' landing--out' : ''}`}>
|
||||
<div className="landing-blob landing-blob-1" />
|
||||
<div className="landing-blob landing-blob-2" />
|
||||
<div className="landing-blob landing-blob-3" />
|
||||
|
||||
<div className="landing-rings">
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
<div className="landing-ring" />
|
||||
</div>
|
||||
|
||||
<div className="landing-content">
|
||||
<div className="landing-badge">Powered by Valhalla · OpenStreetMap</div>
|
||||
<h1 className="landing-title">ISOCHRONE</h1>
|
||||
<p className="landing-sub">
|
||||
Visualize how far you can travel from any point in France — by car, bike, or on foot.
|
||||
</p>
|
||||
<div className="landing-modes">
|
||||
<span className="landing-mode-chip landing-mode-auto">🚗 Drive</span>
|
||||
<span className="landing-mode-chip landing-mode-bike">🚴 Bike</span>
|
||||
<span className="landing-mode-chip landing-mode-walk">🚶 Walk</span>
|
||||
</div>
|
||||
<button className="landing-cta" onClick={handleStart}>
|
||||
Start Exploring →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Feature } from 'geojson'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import { formatDuration, getIsochroneColors } from '../api/ors'
|
||||
|
||||
function ringAreaKm2(coords: number[][]): number {
|
||||
const R = 6371
|
||||
let area = 0
|
||||
const n = coords.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const lon1 = coords[i][0] * Math.PI / 180
|
||||
const lat1 = coords[i][1] * Math.PI / 180
|
||||
const lon2 = coords[j][0] * Math.PI / 180
|
||||
const lat2 = coords[j][1] * Math.PI / 180
|
||||
area += (lon2 - lon1) * (2 + Math.sin(lat1) + Math.sin(lat2))
|
||||
}
|
||||
return Math.abs(area) * R * R / 2
|
||||
}
|
||||
|
||||
function featureAreaKm2(f: Feature): number {
|
||||
const g = f.geometry
|
||||
if (g.type === 'Polygon') return ringAreaKm2(g.coordinates[0])
|
||||
if (g.type === 'MultiPolygon') return g.coordinates.reduce((s, p) => s + ringAreaKm2(p[0]), 0)
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatArea(km2: number): string {
|
||||
if (km2 >= 1000) return `${(km2 / 1000).toFixed(1)}k km²`
|
||||
return `${Math.round(km2).toLocaleString()} km²`
|
||||
}
|
||||
|
||||
export function Legend(): React.JSX.Element | null {
|
||||
const { isochrones, timeRanges, mode, hiddenLayers, toggleLayer } = useAppStore()
|
||||
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 colors = getIsochroneColors(mode)
|
||||
|
||||
const areaByIndex = new Map<number, number>()
|
||||
isochrones.features.forEach((f) => {
|
||||
const idx = f.properties?.isoIndex as number | undefined
|
||||
if (idx !== undefined) areaByIndex.set(idx, featureAreaKm2(f))
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="legend">
|
||||
{sorted.map((t, i) => (
|
||||
<div
|
||||
key={t}
|
||||
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
|
||||
onClick={() => toggleLayer(i)}
|
||||
title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
|
||||
>
|
||||
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
|
||||
<span className="legend-label">{formatDuration(t)}</span>
|
||||
<button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}>
|
||||
{hiddenLayers.has(i) ? '🚫' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="legend-title">Reachable zones</div>
|
||||
{sorted.map((t, i) => {
|
||||
const area = areaByIndex.get(i)
|
||||
return (
|
||||
<div
|
||||
key={t}
|
||||
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
|
||||
onClick={() => toggleLayer(i)}
|
||||
title={hiddenLayers.has(i) ? 'Show' : 'Hide'}
|
||||
>
|
||||
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} />
|
||||
<span className="legend-label">{formatDuration(t)}</span>
|
||||
{area !== undefined && <span className="legend-area">{formatArea(area)}</span>}
|
||||
<button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}>
|
||||
{hiddenLayers.has(i) ? '🚫' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,40 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
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 = '© <a href="https://carto.com/">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||
|
||||
const MAP_STYLE: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
carto: {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://carto.com/">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||
}
|
||||
'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: '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_POINT = 'point'
|
||||
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 {
|
||||
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 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('mouseleave', LAYER_FILL, () => { map.getCanvas().style.cursor = 'crosshair' })
|
||||
|
||||
// Hover popup + highlight
|
||||
map.on('mousemove', LAYER_FILL, (e) => {
|
||||
if (!e.features?.length) return
|
||||
const f = e.features[0]
|
||||
const fid = f.id as number
|
||||
|
||||
if (hoveredIdRef.current !== null && hoveredIdRef.current !== fid) {
|
||||
map.setFeatureState({ source: SRC_ISO, id: hoveredIdRef.current }, { hovered: false })
|
||||
}
|
||||
hoveredIdRef.current = fid
|
||||
map.setFeatureState({ source: SRC_ISO, id: fid }, { hovered: true })
|
||||
|
||||
const label = f.properties?.isoLabel as string
|
||||
popup.setLngLat(e.lngLat).setHTML(`<span>${label}</span>`).addTo(map)
|
||||
})
|
||||
@@ -133,7 +151,6 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
||||
popup.remove()
|
||||
})
|
||||
|
||||
// Context menu
|
||||
map.on('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ lng: e.lngLat.lng, lat: e.lngLat.lat, x: e.point.x, y: e.point.y })
|
||||
@@ -143,7 +160,6 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
||||
setPoint([e.lngLat.lng, e.lngLat.lat])
|
||||
})
|
||||
|
||||
// Apply data that may have been set before load fired
|
||||
if (isochronesRef.current) applyIsochrones(map, isochronesRef.current, markersRef)
|
||||
if (pointRef.current) applyPoint(map, pointRef.current)
|
||||
})
|
||||
@@ -169,13 +185,9 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
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_LINE, 'line-opacity', 0)
|
||||
|
||||
applyIsochrones(map, isochrones, markersRef)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!mapRef.current) return
|
||||
mapRef.current.setPaintProperty(LAYER_FILL, 'fill-opacity', [
|
||||
@@ -198,9 +210,34 @@ export function MapView({ mapRef }: MapViewProps): React.JSX.Element {
|
||||
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"
|
||||
@@ -243,14 +280,12 @@ function applyIsochrones(
|
||||
const src = map.getSource(SRC_ISO) as maplibregl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
|
||||
// Clear old markers
|
||||
markersRef.current.forEach((m) => m.remove())
|
||||
markersRef.current = []
|
||||
|
||||
src.setData(data ?? { type: 'FeatureCollection', features: [] })
|
||||
|
||||
if (data && data.features.length > 0) {
|
||||
// Add isochrone labels as DOM markers
|
||||
data.features.forEach((f) => {
|
||||
const label = f.properties?.isoLabel as string | undefined
|
||||
if (!label) return
|
||||
@@ -270,7 +305,6 @@ function applyIsochrones(
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
// Fit bounds
|
||||
const coords = data.features.flatMap((f) => {
|
||||
const g = f.geometry
|
||||
if (g.type === 'Polygon') return g.coordinates[0]
|
||||
|
||||
@@ -1,11 +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)
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function TimeRangeEditor(): React.JSX.Element {
|
||||
const sorted = [...timeRanges].sort((a, b) => a - b)
|
||||
|
||||
const update = (index: number, hours: number): void => {
|
||||
const sec = Math.max(1800, Math.round(hours * 2) / 2 * 3600)
|
||||
const sec = Math.min(28800, Math.max(1800, Math.round(hours * 2) / 2 * 3600))
|
||||
const next = sorted.map((v, i) => (i === index ? sec : v)).sort((a, b) => a - b)
|
||||
setTimeRanges(next)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function TimeRangeEditor(): React.JSX.Element {
|
||||
|
||||
const add = (): void => {
|
||||
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
|
||||
setTimeRanges([...sorted, next])
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function TimeRangeEditor(): React.JSX.Element {
|
||||
type="number"
|
||||
className="range-input"
|
||||
min={0.5}
|
||||
max={10}
|
||||
max={8}
|
||||
step={0.5}
|
||||
value={t / 3600}
|
||||
onChange={(e) => update(i, parseFloat(e.target.value) || 0.5)}
|
||||
@@ -58,7 +58,7 @@ export function TimeRangeEditor(): React.JSX.Element {
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,9 @@ interface AppState {
|
||||
autoRecalculate: boolean
|
||||
setAutoRecalculate: (v: boolean) => void
|
||||
|
||||
basemap: 'dark' | 'light' | 'satellite'
|
||||
setBasemap: (b: 'dark' | 'light' | 'satellite') => void
|
||||
|
||||
setPoint: (point: [number, number] | null) => void
|
||||
setMode: (mode: TransportMode) => void
|
||||
setTimeRanges: (ranges: number[]) => void
|
||||
@@ -91,6 +94,9 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
autoRecalculate: false,
|
||||
setAutoRecalculate: (autoRecalculate) => set({ autoRecalculate }),
|
||||
|
||||
basemap: 'dark',
|
||||
setBasemap: (basemap) => set({ basemap }),
|
||||
|
||||
setPoint: (point) => set({ point }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
setTimeRanges: (timeRanges) => set({ timeRanges }),
|
||||
|
||||
Reference in New Issue
Block a user