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:
@@ -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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,9 +88,21 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element {
|
||||
return (
|
||||
<aside className="control-panel">
|
||||
<div className="panel-header">
|
||||
<h1 className="panel-title">🗺️ Isochrone Map</h1>
|
||||
<h1 className="panel-title">ISOCHRONE</h1>
|
||||
<div className="panel-header-right">
|
||||
{isochrones && (
|
||||
<button
|
||||
className="btn-share"
|
||||
title="Copy shareable link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
addToast({ message: 'Link copied!', type: 'success', duration: 2000 })
|
||||
}}
|
||||
>🔗</button>
|
||||
)}
|
||||
<span className={`status-dot status-dot--${valhallaStatus}`} title={statusLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<section className="panel-section">
|
||||
|
||||
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,32 @@
|
||||
import type { Feature } from 'geojson'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
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 {
|
||||
const { isochrones, timeRanges, mode, hiddenLayers, toggleLayer } = useAppStore()
|
||||
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 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 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' : ''}`}
|
||||
@@ -19,11 +54,13 @@ export function Legend(): React.JSX.Element | null {
|
||||
>
|
||||
<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]
|
||||
|
||||
@@ -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