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); } }
|
@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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user