diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 33382d8..30f428b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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 | 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 { sessionStorage.setItem('seen', '1'); setShowLanding(false) }} /> + } + if (valhallaStatus === 'starting') { return (
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index af62aa3..fa1c5b0 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -2,21 +2,22 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --bg: #0f111a; - --panel-bg: #161b27; - --panel-border: #1e2535; - --surface: #1c2235; - --surface-hover: #232a3e; - --text: #dde3f0; - --text-muted: #7a8499; + --bg: #080b14; + --panel-bg: rgba(10, 13, 22, 0.84); + --panel-border: rgba(255,255,255,0.065); + --surface: rgba(255,255,255,0.055); + --surface-hover: rgba(255,255,255,0.09); + --text: #e2e8f8; + --text-muted: #6b7a9a; --accent: #3b82f6; --accent-hover: #2563eb; --danger: #f87171; --success: #4ade80; --warning: #fbbf24; --info: #60a5fa; - --radius: 8px; - --radius-sm: 5px; + --radius: 10px; + --radius-sm: 6px; + --radius-lg: 16px; --transition-fast: 0.15s ease; --transition-med: 0.25s ease; font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; @@ -33,15 +34,14 @@ html, body, #root { /* ─── Layout ────────────────────────────────────────────────────────── */ .app-layout { - display: flex; + position: relative; width: 100%; height: 100%; } .map-wrapper { - flex: 1; - position: relative; - overflow: hidden; + position: absolute; + inset: 0; } .map-container { @@ -49,37 +49,70 @@ html, body, #root { height: 100%; } -/* ─── Control panel ─────────────────────────────────────────────────── */ +/* ─── Control panel — floating glass card ───────────────────────────── */ .control-panel { - width: 320px; - flex-shrink: 0; + position: absolute; + left: 16px; + top: 16px; + bottom: 16px; + width: 300px; + z-index: 200; background: var(--panel-bg); - border-right: 1px solid var(--panel-border); + backdrop-filter: blur(28px) saturate(160%); + -webkit-backdrop-filter: blur(28px) saturate(160%); + border: 1px solid var(--panel-border); + border-radius: var(--radius-lg); + box-shadow: + 0 32px 80px rgba(0,0,0,0.65), + 0 8px 24px rgba(0,0,0,0.4), + inset 0 1px 0 rgba(255,255,255,0.07); display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; } -.control-panel::-webkit-scrollbar { width: 4px; } +.control-panel::-webkit-scrollbar { width: 3px; } .control-panel::-webkit-scrollbar-track { background: transparent; } -.control-panel::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 2px; } +.control-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; } .panel-header { - padding: 16px 16px 12px; + padding: 16px 16px 14px; border-bottom: 1px solid var(--panel-border); display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; } .panel-title { - font-size: 16px; - font-weight: 700; - color: var(--text); - letter-spacing: -0.3px; + font-size: 14px; + font-weight: 800; + letter-spacing: 0.12em; + background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 60%, #34d399 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } +.panel-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-share { + background: none; + border: none; + font-size: 13px; + cursor: pointer; + opacity: 0.5; + transition: opacity var(--transition-fast); + padding: 0 2px; + line-height: 1; +} +.btn-share:hover { opacity: 1; } + .panel-section { padding: 14px 16px; border-bottom: 1px solid var(--panel-border); @@ -95,17 +128,15 @@ html, body, #root { } .section-label { - font-size: 11px; - font-weight: 600; + font-size: 10px; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.08em; color: var(--text-muted); } /* ─── Search ────────────────────────────────────────────────────────── */ -.search-wrap { - position: relative; -} +.search-wrap { position: relative; } .input-text { width: 100%; @@ -116,30 +147,29 @@ html, body, #root { padding: 8px 10px; font-size: 13px; outline: none; - transition: border-color 0.15s; + transition: border-color 0.15s, box-shadow 0.15s; } .input-text:focus { - border-color: var(--accent); + border-color: rgba(59,130,246,0.5); + box-shadow: 0 0 0 3px rgba(59,130,246,0.08); } -.input-text::placeholder { - color: var(--text-muted); -} +.input-text::placeholder { color: var(--text-muted); } .search-dropdown { position: absolute; top: calc(100% + 4px); - left: 0; - right: 0; - background: var(--surface); + left: 0; right: 0; + background: rgba(12,16,28,0.96); + backdrop-filter: blur(16px); border: 1px solid var(--panel-border); border-radius: var(--radius-sm); list-style: none; z-index: 1000; max-height: 220px; overflow-y: auto; - box-shadow: 0 8px 24px rgba(0,0,0,0.5); + box-shadow: 0 12px 32px rgba(0,0,0,0.6); } .search-dropdown li { @@ -151,23 +181,26 @@ html, body, #root { border-bottom: 1px solid var(--panel-border); transition: background 0.1s; } - .search-dropdown li:last-child { border-bottom: none; } .search-dropdown li:hover { background: var(--surface-hover); } .coord-row { display: flex; align-items: center; - justify-content: space-between; + gap: 6px; background: var(--surface); border-radius: var(--radius-sm); padding: 6px 10px; } .coord-text { - font-size: 12px; + flex: 1; + font-size: 11px; color: var(--text-muted); - font-family: monospace; + font-family: 'SF Mono', 'Fira Code', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .btn-clear { @@ -175,70 +208,69 @@ html, body, #root { border: none; color: var(--text-muted); cursor: pointer; - font-size: 14px; + font-size: 13px; padding: 0 2px; line-height: 1; transition: color 0.15s; + flex-shrink: 0; } - .btn-clear:hover { color: var(--danger); } -.hint { - font-size: 11px; - color: var(--text-muted); - font-style: italic; -} +.hint { font-size: 11px; color: var(--text-muted); font-style: italic; } /* ─── Mode selector ─────────────────────────────────────────────────── */ -.mode-selector { - display: flex; - gap: 6px; -} +.mode-selector { display: flex; gap: 6px; } .btn-mode { flex: 1; display: flex; flex-direction: column; align-items: center; - gap: 4px; - padding: 8px 4px; + gap: 5px; + padding: 10px 4px; background: var(--surface); border: 1px solid var(--panel-border); - border-radius: var(--radius-sm); + border-radius: var(--radius); color: var(--text-muted); cursor: pointer; - transition: all 0.15s; + transition: all 0.2s; } - .btn-mode:hover { background: var(--surface-hover); color: var(--text); } -.btn-mode.active { background: rgba(59,130,246,0.15); border-color: var(--accent); color: var(--accent); } -.btn-mode.active[data-mode="auto"] { background: rgba(251,191,36,.14); border-color: #fbbf24; color: #fbbf24; } -.btn-mode.active[data-mode="bicycle"] { background: rgba(34,211,238,.14); border-color: #22d3ee; color: #22d3ee; } -.btn-mode.active[data-mode="pedestrian"] { background: rgba(74,222,128,.14); border-color: #4ade80; color: #4ade80; } +.btn-mode.active { + background: rgba(59,130,246,0.12); + border-color: rgba(59,130,246,0.4); + color: #60a5fa; + box-shadow: 0 0 16px rgba(59,130,246,0.12); +} +.btn-mode.active[data-mode="auto"] { + background: linear-gradient(135deg, rgba(251,191,36,.15), rgba(249,115,22,.08)); + border-color: rgba(251,191,36,0.45); + color: #fbbf24; + box-shadow: 0 0 16px rgba(251,191,36,0.12); +} +.btn-mode.active[data-mode="bicycle"] { + background: linear-gradient(135deg, rgba(34,211,238,.12), rgba(99,102,241,.08)); + border-color: rgba(34,211,238,0.4); + color: #22d3ee; + box-shadow: 0 0 16px rgba(34,211,238,0.12); +} +.btn-mode.active[data-mode="pedestrian"] { + background: linear-gradient(135deg, rgba(74,222,128,.12), rgba(16,185,129,.08)); + border-color: rgba(74,222,128,0.4); + color: #4ade80; + box-shadow: 0 0 16px rgba(74,222,128,0.12); +} -.mode-icon { font-size: 18px; } -.mode-label { font-size: 11px; font-weight: 600; } +.mode-icon { font-size: 20px; } +.mode-label { font-size: 10px; font-weight: 700; letter-spacing: 0.04em; } /* ─── Time ranges ───────────────────────────────────────────────────── */ -.time-ranges { - display: flex; - flex-direction: column; - gap: 6px; -} +.time-ranges { display: flex; flex-direction: column; gap: 6px; } -.time-range-row { - display: flex; - align-items: center; - gap: 8px; -} +.time-range-row { display: flex; align-items: center; gap: 8px; } -.range-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} +.range-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 6px currentColor; } .range-input { width: 60px; @@ -250,37 +282,26 @@ html, body, #root { font-size: 13px; text-align: center; outline: none; + transition: border-color 0.15s; } +.range-input:focus { border-color: rgba(59,130,246,0.5); } -.range-input:focus { border-color: var(--accent); } - -.range-preview { - flex: 1; - font-size: 12px; - color: var(--text-muted); -} +.range-preview { flex: 1; font-size: 12px; color: var(--text-muted); } .btn-icon { background: none; border: 1px solid transparent; color: var(--text-muted); cursor: pointer; - width: 24px; - height: 24px; + width: 24px; height: 24px; border-radius: var(--radius-sm); font-size: 16px; line-height: 1; - display: flex; - align-items: center; - justify-content: center; + display: flex; align-items: center; justify-content: center; transition: all 0.15s; + flex-shrink: 0; } - -.btn-icon:hover:not(:disabled) { - border-color: var(--danger); - color: var(--danger); -} - +.btn-icon:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); } .btn-icon:disabled { opacity: 0.3; cursor: not-allowed; } .btn-add { @@ -295,28 +316,32 @@ html, body, #root { transition: all 0.15s; width: 100%; } - -.btn-add:hover { - border-color: var(--accent); - color: var(--accent); -} +.btn-add:hover { border-color: rgba(59,130,246,0.5); color: var(--accent); } /* ─── Buttons ───────────────────────────────────────────────────────── */ .btn-primary { width: 100%; - padding: 10px; - background: var(--accent); + padding: 11px; + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); border: none; border-radius: var(--radius); color: #fff; font-size: 13px; - font-weight: 600; + font-weight: 700; + letter-spacing: 0.02em; cursor: pointer; - transition: background 0.15s, opacity 0.15s; + transition: all 0.2s; + box-shadow: 0 0 24px rgba(99,102,241,0.25), 0 4px 12px rgba(0,0,0,0.3); } - -.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } -.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } +.btn-primary:hover:not(:disabled) { + box-shadow: 0 0 36px rgba(99,102,241,0.45), 0 4px 16px rgba(0,0,0,0.4); + transform: translateY(-1px); +} +.btn-primary:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 0 16px rgba(99,102,241,0.25); +} +.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; } .btn-secondary { flex: 1; @@ -330,13 +355,9 @@ html, body, #root { cursor: pointer; transition: all 0.15s; } +.btn-secondary:hover { background: var(--surface-hover); border-color: rgba(59,130,246,0.3); } -.btn-secondary:hover { background: var(--surface-hover); border-color: var(--accent); } - -.export-row { - display: flex; - gap: 8px; -} +.export-row { display: flex; gap: 8px; } .btn-link { background: none; @@ -348,7 +369,6 @@ html, body, #root { text-align: left; transition: color 0.15s; } - .btn-link:hover { color: var(--text); } .key-ok { color: var(--success); } @@ -357,8 +377,8 @@ html, body, #root { .error-msg { font-size: 12px; color: var(--danger); - background: rgba(248, 113, 113, 0.1); - border: 1px solid rgba(248, 113, 113, 0.25); + background: rgba(248,113,113,0.08); + border: 1px solid rgba(248,113,113,0.2); border-radius: var(--radius-sm); padding: 8px 10px; line-height: 1.4; @@ -367,17 +387,29 @@ html, body, #root { /* ─── Legend ────────────────────────────────────────────────────────── */ .legend { position: absolute; - bottom: 32px; - left: 16px; - background: rgba(15, 17, 26, 0.88); - backdrop-filter: blur(8px); - border: 1px solid rgba(255,255,255,0.08); + bottom: 16px; + left: 332px; + background: rgba(8, 11, 20, 0.86); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--panel-border); border-radius: var(--radius); padding: 10px 14px; display: flex; flex-direction: column; - gap: 6px; + gap: 5px; pointer-events: all; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + min-width: 180px; +} + +.legend-title { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-bottom: 3px; } .legend-item { @@ -387,29 +419,34 @@ html, body, #root { cursor: pointer; transition: opacity var(--transition-fast); border-radius: var(--radius-sm); - padding: 1px 2px; + padding: 2px 2px; } .legend-item:hover { opacity: 0.8; } -.legend-item.hidden { opacity: 0.35; } +.legend-item.hidden { opacity: 0.3; } .legend-swatch { - width: 14px; - height: 14px; + width: 12px; height: 12px; border-radius: 3px; - opacity: 0.85; flex-shrink: 0; } .legend-label { font-size: 12px; - font-weight: 600; + font-weight: 700; color: var(--text); + min-width: 36px; +} + +.legend-area { + flex: 1; + font-size: 10px; + color: var(--text-muted); + text-align: right; } /* ─── Splash screen ─────────────────────────────────────────────────── */ .splash { - width: 100%; - height: 100%; + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; @@ -420,122 +457,219 @@ html, body, #root { display: flex; flex-direction: column; align-items: center; - gap: 16px; + gap: 20px; color: var(--text-muted); font-size: 14px; } -.splash-spinner { - width: 32px; - height: 32px; - border: 3px solid var(--panel-border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { to { transform: rotate(360deg); } } - -/* Splash amélioré - anneaux concentriques */ .splash-rings { - width: 72px; - height: 72px; + width: 80px; height: 80px; position: relative; margin: 0 auto; } .splash-ring { border-radius: 50%; - border: 2px solid var(--accent); + border: 1.5px solid rgba(99,102,241,0.6); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - animation: splash-radiate 2.2s ease-out infinite; + animation: splash-radiate 2.4s ease-out infinite; opacity: 0; } .splash-ring:nth-child(1) { animation-delay: 0s; } -.splash-ring:nth-child(2) { animation-delay: 0.55s; } -.splash-ring:nth-child(3) { animation-delay: 1.1s; } +.splash-ring:nth-child(2) { animation-delay: 0.6s; } +.splash-ring:nth-child(3) { animation-delay: 1.2s; } @keyframes splash-radiate { - 0% { width: 10px; height: 10px; opacity: 0.9; } + 0% { width: 10px; height: 10px; opacity: 1; } 80% { opacity: 0.2; } - 100% { width: 72px; height: 72px; opacity: 0; } + 100% { width: 80px; height: 80px; opacity: 0; } } .splash-error .splash-content { color: var(--danger); } - -.splash-hint { - font-size: 12px; - color: var(--text-muted); -} +.splash-hint { font-size: 12px; color: var(--text-muted); margin-top: -8px; } /* ─── MapLibre overrides ────────────────────────────────────────────── */ .maplibregl-ctrl-group { - background: rgba(15, 17, 26, 0.9) !important; - border: 1px solid var(--panel-border) !important; + background: rgba(8,11,20,0.88) !important; + backdrop-filter: blur(12px) !important; + border: 1px solid rgba(255,255,255,0.07) !important; + border-radius: 8px !important; + box-shadow: 0 4px 16px rgba(0,0,0,0.4) !important; } - .maplibregl-ctrl-group button { background: transparent !important; -} - -.maplibregl-ctrl-group button:hover { - background: var(--surface) !important; -} - -.maplibregl-ctrl-attrib { - background: rgba(15, 17, 26, 0.7) !important; color: var(--text-muted) !important; } - +.maplibregl-ctrl-group button:hover { background: var(--surface) !important; } +.maplibregl-ctrl-attrib { + background: rgba(8,11,20,0.7) !important; + color: var(--text-muted) !important; + border-radius: 6px !important; + font-size: 10px !important; +} .maplibregl-ctrl-attrib a { color: var(--text-muted) !important; } +/* ─── Basemap switcher ──────────────────────────────────────────────── */ +.basemap-switcher { + position: absolute; + bottom: 52px; + right: 10px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 2px; + background: rgba(8,11,20,0.88); + backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; + padding: 3px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.basemap-btn { + width: 28px; height: 28px; + border: none; + border-radius: 6px; + background: none; + font-size: 14px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: background 0.15s; + opacity: 0.5; +} +.basemap-btn:hover { background: var(--surface-hover); opacity: 0.9; } +.basemap-btn.active { background: var(--surface-hover); opacity: 1; } + /* ─── Status dot ────────────────────────────────────────────────────── */ -.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } -.status-dot--ready { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,.6); } +.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.status-dot--ready { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,.7); } .status-dot--starting { background: #fbbf24; animation: pulse-dot 1.2s ease-in-out infinite; } -.status-dot--error { background: #f87171; } -@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } +.status-dot--error { background: #f87171; box-shadow: 0 0 8px rgba(248,113,113,.5); } +@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } /* ─── Toast system ──────────────────────────────────────────────────── */ -.toast-container { position: absolute; bottom: 48px; right: 16px; z-index: 2000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } -.toast { padding: 10px 14px; border-radius: var(--radius); font-size: 12px; font-weight: 500; backdrop-filter: blur(8px); border: 1px solid; animation: toast-enter .25s ease forwards; pointer-events: all; display: flex; align-items: center; gap: 8px; min-width: 180px; } -.toast--success { background: rgba(74,222,128,.12); border-color: rgba(74,222,128,.3); color: #4ade80; } -.toast--error { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.3); color: #f87171; } -.toast--info { background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.3); color: #60a5fa; } +.toast-container { + position: absolute; bottom: 20px; right: 16px; z-index: 2000; + display: flex; flex-direction: column; gap: 8px; + pointer-events: none; +} +.toast { + padding: 10px 14px; + border-radius: var(--radius); + font-size: 12px; font-weight: 500; + backdrop-filter: blur(16px); + border: 1px solid; + animation: toast-enter .25s ease forwards; + pointer-events: all; + display: flex; align-items: center; gap: 8px; + min-width: 160px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} +.toast--success { background: rgba(74,222,128,.1); border-color: rgba(74,222,128,.25); color: #4ade80; } +.toast--error { background: rgba(248,113,113,.1); border-color: rgba(248,113,113,.25); color: #f87171; } +.toast--info { background: rgba(59,130,246,.1); border-color: rgba(59,130,246,.25); color: #60a5fa; } .toast-msg { flex: 1; } -.toast-close { background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6; font-size: 14px; line-height: 1; padding: 0; } +.toast-close { background: none; border: none; color: inherit; cursor: pointer; opacity: 0.5; font-size: 14px; line-height: 1; padding: 0; } .toast-close:hover { opacity: 1; } -@keyframes toast-enter { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } +@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: 3px; background: linear-gradient(90deg, transparent, var(--accent), transparent); background-size: 200% 100%; animation: loading-sweep 1.2s ease-in-out infinite; } +.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; +} @keyframes loading-sweep { 0% { background-position: -100% 0; } 100% { background-position: 200% 0; } } /* ─── Map context menu ──────────────────────────────────────────────── */ -.map-context-menu { position: absolute; z-index: 1500; background: var(--panel-bg); border: 1px solid var(--panel-border); border-radius: var(--radius-sm); box-shadow: 0 8px 24px rgba(0,0,0,.5); min-width: 210px; overflow: hidden; } -.map-context-menu button { display: block; width: 100%; padding: 9px 14px; background: none; border: none; color: var(--text); font-size: 12px; text-align: left; cursor: pointer; transition: background .1s; } +.map-context-menu { + position: absolute; z-index: 1500; + background: rgba(10,13,22,0.94); + backdrop-filter: blur(16px); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0,0,0,.6); + min-width: 210px; overflow: hidden; +} +.map-context-menu button { + display: block; width: 100%; + padding: 9px 14px; + background: none; border: none; + color: var(--text); font-size: 12px; + text-align: left; cursor: pointer; + transition: background .1s; +} .map-context-menu button:hover { background: var(--surface-hover); } /* ─── Isochrone hover popup ─────────────────────────────────────────── */ -.iso-popup .maplibregl-popup-content { background: rgba(15,17,26,.92); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-sm); color: var(--text); font-size: 12px; font-weight: 600; padding: 5px 10px; box-shadow: 0 4px 16px rgba(0,0,0,.5); } +.iso-popup .maplibregl-popup-content { + background: rgba(8,11,20,.92); + backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,.1); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; font-weight: 600; + padding: 5px 10px; + box-shadow: 0 4px 16px rgba(0,0,0,.5); +} .iso-popup .maplibregl-popup-tip { display: none; } /* ─── Isochrone map labels ──────────────────────────────────────────── */ -.iso-map-label { background: rgba(0,0,0,.58); backdrop-filter: blur(4px); border-radius: 4px; color: #fff; font-size: 10px; font-weight: 700; padding: 2px 6px; pointer-events: none; white-space: nowrap; letter-spacing: .3px; font-family: 'Inter', system-ui, sans-serif; } +.iso-map-label { + background: rgba(0,0,0,.6); + backdrop-filter: blur(4px); + border-radius: 4px; + color: #fff; + font-size: 10px; font-weight: 700; + padding: 2px 6px; + pointer-events: none; + white-space: nowrap; + letter-spacing: .3px; + font-family: 'Inter', system-ui, sans-serif; +} /* ─── Preset chips ──────────────────────────────────────────────────── */ -.preset-chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } +.preset-chips { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } .preset-chips::-webkit-scrollbar { display: none; } -.preset-chip { flex-shrink: 0; padding: 3px 8px; background: var(--surface); border: 1px solid var(--panel-border); border-radius: 100px; color: var(--text-muted); font-size: 11px; cursor: pointer; white-space: nowrap; transition: all var(--transition-fast); } -.preset-chip:hover { border-color: var(--accent); color: var(--accent); } +.preset-chip { + flex-shrink: 0; + padding: 3px 9px; + background: var(--surface); + border: 1px solid var(--panel-border); + border-radius: 100px; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + white-space: nowrap; + transition: all var(--transition-fast); +} +.preset-chip:hover { border-color: rgba(99,102,241,0.4); color: #a78bfa; } /* ─── History ───────────────────────────────────────────────────────── */ -.history-toggle { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; padding: 0; width: 100%; text-align: left; display: flex; align-items: center; justify-content: space-between; } +.history-toggle { + background: none; border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: .08em; + padding: 0; width: 100%; + text-align: left; + display: flex; align-items: center; justify-content: space-between; +} .history-toggle:hover { color: var(--text); } .history-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } -.history-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--surface); border: 1px solid var(--panel-border); border-radius: var(--radius-sm); cursor: pointer; text-align: left; width: 100%; transition: background .1s; } -.history-item:hover { background: var(--surface-hover); border-color: var(--accent); } +.history-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; + background: var(--surface); + border: 1px solid var(--panel-border); + border-radius: var(--radius-sm); + cursor: pointer; text-align: left; width: 100%; + transition: background .1s, border-color .1s; +} +.history-item:hover { background: var(--surface-hover); border-color: rgba(99,102,241,0.3); } .history-icon { font-size: 14px; flex-shrink: 0; } .history-label { flex: 1; font-size: 11px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-meta { font-size: 10px; color: var(--text-muted); flex-shrink: 0; } @@ -543,22 +677,194 @@ html, body, #root { .collapsible-body.open { max-height: 500px; opacity: 1; } /* ─── Coord copy / auto-recalc ──────────────────────────────────────── */ -.btn-copy { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 14px; padding: 0 3px; transition: color var(--transition-fast); line-height: 1; } +.btn-copy { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 14px; padding: 0 3px; transition: color var(--transition-fast); line-height: 1; flex-shrink: 0; } .btn-copy:hover { color: var(--accent); } .auto-recalc-toggle { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none; } -.auto-recalc-toggle input { accent-color: var(--accent); } +.auto-recalc-toggle input { accent-color: #6366f1; } /* ─── Error card ────────────────────────────────────────────────────── */ -.error-card { display: flex; align-items: flex-start; gap: 10px; background: rgba(248,113,113,.08); border: 1px solid rgba(248,113,113,.2); border-radius: var(--radius); padding: 10px 12px; } -.error-icon { font-size: 16px; color: var(--danger); flex-shrink: 0; margin-top: 1px; } +.error-card { + display: flex; align-items: flex-start; gap: 10px; + background: rgba(248,113,113,.07); + border: 1px solid rgba(248,113,113,.18); + border-radius: var(--radius); padding: 10px 12px; +} +.error-icon { font-size: 15px; color: var(--danger); flex-shrink: 0; margin-top: 1px; } .error-body { flex: 1; } .error-title { font-size: 12px; font-weight: 600; color: var(--danger); } .error-detail { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.4; word-break: break-word; } -.btn-retry { background: none; border: 1px solid rgba(248,113,113,.3); border-radius: var(--radius-sm); color: var(--danger); font-size: 11px; padding: 4px 8px; cursor: pointer; flex-shrink: 0; transition: all var(--transition-fast); white-space: nowrap; } -.btn-retry:hover { background: rgba(248,113,113,.1); } +.btn-retry { + background: none; + border: 1px solid rgba(248,113,113,.25); + border-radius: var(--radius-sm); + color: var(--danger); font-size: 11px; + padding: 4px 8px; cursor: pointer; + flex-shrink: 0; transition: all var(--transition-fast); white-space: nowrap; +} +.btn-retry:hover { background: rgba(248,113,113,.08); } /* ─── Legend eye toggle ─────────────────────────────────────────────── */ -.legend-eye { background: none; border: none; color: var(--text-muted); font-size: 11px; cursor: pointer; margin-left: auto; padding: 0 2px; line-height: 1; opacity: 0; transition: opacity var(--transition-fast); } -.legend:hover .legend-eye { opacity: 0.5; } +.legend-eye { + background: none; border: none; color: var(--text-muted); + font-size: 11px; cursor: pointer; margin-left: 4px; + padding: 0 2px; line-height: 1; opacity: 0; + transition: opacity var(--transition-fast); + flex-shrink: 0; +} +.legend:hover .legend-eye { opacity: 0.4; } .legend-item:hover .legend-eye { opacity: 1; } .legend-item.hidden .legend-eye { opacity: 1; color: var(--danger); } + +/* ─── Landing page ──────────────────────────────────────────────────── */ +.landing { + position: fixed; inset: 0; z-index: 9999; + background: #060810; + display: flex; align-items: center; justify-content: center; + overflow: hidden; + transition: opacity 0.42s ease, transform 0.42s ease; +} +.landing--out { + opacity: 0; + transform: scale(1.04); + pointer-events: none; +} + +/* Animated blobs */ +.landing-blob { + position: absolute; border-radius: 50%; + filter: blur(90px); opacity: 0.13; + animation: blob-drift linear infinite alternate; + pointer-events: none; +} +.landing-blob-1 { + width: 600px; height: 600px; + background: radial-gradient(circle, #3b82f6, transparent); + top: -15%; left: -15%; + animation-duration: 16s; +} +.landing-blob-2 { + width: 500px; height: 500px; + background: radial-gradient(circle, #8b5cf6, transparent); + bottom: -15%; right: -15%; + animation-duration: 20s; + animation-delay: -7s; +} +.landing-blob-3 { + width: 360px; height: 360px; + background: radial-gradient(circle, #06b6d4, transparent); + bottom: 10%; left: 15%; + animation-duration: 12s; + animation-delay: -3s; +} +@keyframes blob-drift { + from { transform: translate(0, 0) scale(1); } + to { transform: translate(50px, 40px) scale(1.12); } +} + +/* Expanding rings */ +.landing-rings { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + pointer-events: none; +} +.landing-ring { + position: absolute; + border-radius: 50%; + border: 1px solid rgba(99,102,241,0.35); + animation: ring-expand 5s ease-out infinite; +} +.landing-ring:nth-child(1) { animation-delay: 0s; } +.landing-ring:nth-child(2) { animation-delay: 1.25s; } +.landing-ring:nth-child(3) { animation-delay: 2.5s; } +.landing-ring:nth-child(4) { animation-delay: 3.75s; } +@keyframes ring-expand { + 0% { width: 40px; height: 40px; opacity: 0.9; border-color: rgba(99,102,241,0.7); } + 60% { opacity: 0.3; } + 100% { width: 700px; height: 700px; opacity: 0; border-color: rgba(99,102,241,0.05); } +} + +/* Landing content */ +.landing-content { + position: relative; z-index: 1; + text-align: center; + display: flex; flex-direction: column; align-items: center; + gap: 20px; +} + +.landing-badge { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(107,114,154,0.8); + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 100px; + padding: 5px 14px; +} + +.landing-title { + font-size: clamp(52px, 10vw, 100px); + font-weight: 900; + letter-spacing: 0.12em; + line-height: 1; + background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 45%, #34d399 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: title-shimmer 4s ease-in-out infinite alternate; +} +@keyframes title-shimmer { + from { background-position: 0% 50%; } + to { background-position: 100% 50%; } +} + +.landing-sub { + font-size: 15px; + color: rgba(180,190,220,0.55); + max-width: 380px; + line-height: 1.65; +} + +.landing-modes { + display: flex; gap: 12px; +} +.landing-mode-chip { + padding: 6px 14px; + border-radius: 100px; + font-size: 12px; font-weight: 600; + border: 1px solid; +} +.landing-mode-auto { background: rgba(251,191,36,.1); border-color: rgba(251,191,36,.25); color: #fbbf24; } +.landing-mode-bike { background: rgba(34,211,238,.1); border-color: rgba(34,211,238,.25); color: #22d3ee; } +.landing-mode-walk { background: rgba(74,222,128,.1); border-color: rgba(74,222,128,.25); color: #4ade80; } + +.landing-cta { + margin-top: 4px; + padding: 15px 44px; + background: linear-gradient(135deg, #3b82f6, #6366f1 50%, #8b5cf6); + background-size: 200% 100%; + border: none; + border-radius: 100px; + color: #fff; + font-size: 15px; font-weight: 700; + letter-spacing: 0.03em; + cursor: pointer; + box-shadow: + 0 0 40px rgba(99,102,241,0.45), + 0 8px 32px rgba(0,0,0,0.5); + transition: all 0.25s; + animation: cta-pulse 3s ease-in-out infinite; +} +.landing-cta:hover { + transform: translateY(-3px) scale(1.04); + box-shadow: + 0 0 60px rgba(99,102,241,0.65), + 0 12px 40px rgba(0,0,0,0.6); + background-position: 100% 0; +} +@keyframes cta-pulse { + 0%, 100% { box-shadow: 0 0 40px rgba(99,102,241,0.4), 0 8px 32px rgba(0,0,0,0.5); } + 50% { box-shadow: 0 0 60px rgba(99,102,241,0.6), 0 8px 32px rgba(0,0,0,0.5); } +} diff --git a/src/renderer/src/components/ControlPanel.tsx b/src/renderer/src/components/ControlPanel.tsx index 8ac3fde..db754ba 100644 --- a/src/renderer/src/components/ControlPanel.tsx +++ b/src/renderer/src/components/ControlPanel.tsx @@ -88,8 +88,20 @@ export function ControlPanel({ mapRef }: ControlPanelProps): React.JSX.Element { return (