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 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
@@ -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 */}
|
||||||
|
|||||||
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 { 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,22 +34,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '© <a href="https://carto.com/">CARTO</a> © <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: '© Esri' },
|
||||||
tileSize: 256,
|
|
||||||
attribution: '© <a href="https://carto.com/">CARTO</a> © <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]
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user