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>
This commit is contained in:
2026-03-31 15:38:58 +02:00
parent 20f5c1a361
commit d68f954a7d
7 changed files with 721 additions and 246 deletions

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">

File diff suppressed because it is too large Load Diff

View File

@@ -88,9 +88,21 @@ 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>
<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} /> <span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
</div> </div>
</div>
{/* Search */} {/* Search */}
<section className="panel-section"> <section className="panel-section">

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,32 @@
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 {
let area = 0
const n = coords.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += coords[i][0] * coords[j][1]
area -= coords[j][0] * coords[i][1]
}
area = Math.abs(area) / 2
const midLat = (coords.reduce((s, c) => s + c[1], 0) / coords.length) * (Math.PI / 180)
return area * 111.32 * 111.32 * Math.cos(midLat)
}
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,9 +34,18 @@ 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>
{sorted.map((t, i) => {
const area = areaByIndex.get(i)
return (
<div <div
key={t} key={t}
className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`} className={`legend-item${hiddenLayers.has(i) ? ' hidden' : ''}`}
@@ -19,11 +54,13 @@ export function Legend(): React.JSX.Element | null {
> >
<span className="legend-swatch" style={{ background: colors[i % colors.length] }} /> <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) }}> <button className="legend-eye" onClick={(e) => { e.stopPropagation(); toggleLayer(i) }}>
{hiddenLayers.has(i) ? '🚫' : '👁'} {hiddenLayers.has(i) ? '🚫' : '👁'}
</button> </button>
</div> </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

@@ -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 }),