feat: estimated progress bar + Valhalla 2 threads

- Progress bar animates 0→88% (exponential ease, time-range-aware)
- Snaps to 100% green ✓ on completion
- Floating % badge follows the bar
- Restarted valhalla-service with 2 threads for faster large isochrones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:50:44 +02:00
parent 70b4da8e2c
commit 05f7cb35cc
2 changed files with 94 additions and 9 deletions

View File

@@ -574,14 +574,43 @@ html, body, #root {
@keyframes toast-enter { from { opacity: 0; transform: translateX(12px); } to { opacity: 1; transform: translateX(0); } } @keyframes toast-enter { from { opacity: 0; transform: translateX(12px); } to { opacity: 1; transform: translateX(0); } }
/* ─── Map loading bar ───────────────────────────────────────────────── */ /* ─── Map loading bar ───────────────────────────────────────────────── */
.map-loading-overlay { position: absolute; top: 0; left: 0; right: 0; z-index: 500; pointer-events: none; } .map-loading-overlay {
.map-loading-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 500;
height: 2px; pointer-events: none;
background: linear-gradient(90deg, transparent 0%, #6366f1 40%, #a78bfa 60%, transparent 100%);
background-size: 200% 100%;
animation: loading-sweep 1.2s ease-in-out infinite;
} }
@keyframes loading-sweep { 0% { background-position: -100% 0; } 100% { background-position: 200% 0; } } .map-loading-track {
height: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.map-loading-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #6366f1, #a78bfa);
transition: width 0.12s ease-out;
box-shadow: 0 0 10px rgba(99,102,241,0.7);
}
.map-loading-fill--done {
transition: width 0.18s ease-out;
background: linear-gradient(90deg, #4ade80, #34d399);
box-shadow: 0 0 10px rgba(74,222,128,0.7);
}
.map-loading-badge {
position: absolute;
top: 7px;
transform: translateX(-50%);
background: rgba(10,13,22,0.88);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 100px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
letter-spacing: 0.04em;
transition: left 0.12s ease-out, color 0.2s;
white-space: nowrap;
}
.map-loading-badge--done { color: #4ade80 !important; border-color: rgba(74,222,128,0.25); }
/* ─── Map context menu ──────────────────────────────────────────────── */ /* ─── Map context menu ──────────────────────────────────────────────── */
.map-context-menu { .map-context-menu {

View File

@@ -1,11 +1,67 @@
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '../store/useAppStore' import { useAppStore } from '../store/useAppStore'
// Estimate how long a calculation will take based on max range (seconds)
function estimateDuration(maxRangeSec: number): number {
// Base 1.5s + ~2s per hour of isochrone range
return 1500 + (maxRangeSec / 3600) * 2200
}
export function MapOverlay(): React.JSX.Element | null { export function MapOverlay(): React.JSX.Element | null {
const loading = useAppStore((s) => s.loading) const loading = useAppStore((s) => s.loading)
if (!loading) return null const timeRanges = useAppStore((s) => s.timeRanges)
const [progress, setProgress] = useState(0)
const [visible, setVisible] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startRef = useRef<number>(0)
const estimatedRef = useRef<number>(3000)
useEffect(() => {
if (loading) {
const maxRange = Math.max(...timeRanges)
estimatedRef.current = estimateDuration(maxRange)
startRef.current = Date.now()
setProgress(0)
setVisible(true)
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current
const ratio = elapsed / estimatedRef.current
// Ease to 88%: asymptotic curve that never quite reaches it
const pct = 88 * (1 - Math.exp(-3 * ratio))
setProgress(Math.min(pct, 88))
}, 80)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
// Snap to 100% then fade out
setProgress(100)
const t = setTimeout(() => {
setVisible(false)
setProgress(0)
}, 500)
return () => clearTimeout(t)
}
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [loading]) // eslint-disable-line react-hooks/exhaustive-deps
if (!visible) return null
const pct = Math.round(progress)
return ( return (
<div className="map-loading-overlay"> <div className="map-loading-overlay">
<div className="map-loading-bar" /> <div className="map-loading-track">
<div
className={`map-loading-fill${progress >= 100 ? ' map-loading-fill--done' : ''}`}
style={{ width: `${progress}%` }}
/>
</div>
<div
className={`map-loading-badge${progress >= 100 ? ' map-loading-badge--done' : ''}`}
style={{ left: `clamp(32px, ${progress}%, calc(100% - 40px))` }}
>
{pct < 100 ? `${pct}%` : '✓'}
</div>
</div> </div>
) )
} }