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:
@@ -574,14 +574,43 @@ html, body, #root {
|
||||
@keyframes toast-enter { from { opacity: 0; transform: translateX(12px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
/* ─── Map loading bar ───────────────────────────────────────────────── */
|
||||
.map-loading-overlay { position: absolute; top: 0; left: 0; right: 0; z-index: 500; pointer-events: none; }
|
||||
.map-loading-bar {
|
||||
height: 2px;
|
||||
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;
|
||||
.map-loading-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
@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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user