/* ============================================================ ROOY · 02 准备出发 ─────────────────────────────────────────────────────────── 需求:当日开滑前的雪场上下文 + ROOY 一句建议 + 出发按钮。 地形图打底把用户放回雪场空间;天气收进悬浮卡。 结构:全屏 basemap(地形层 → 静音 scrim → 定位信息层)→ 日期 + Zermatt title + 传感器按钮 → 天气卡(−7° italic + SunGlyph / hairline / 3 列 CCond)→ ROOY 白卡滑行建议 → SlideToStart(pinned 拇指区)。 accent budget:data 蓝 1(地图定位点);rooy 3(传感器 pip + 滑行建议 pill + 白卡 "40° 立刃",原 gold 已改 rooy);gold 0。主导 = ink。 italic 锚 = 天气 −7°。 交互:滑动到尾段提交,进入 03b 自由模式(默认)。**不是** "长按 600ms"(早期方案,已弃用 —— 戴手套滑动比按住准)。 非显然的设计决定:地形层压一层 bg 60% 的静音 scrim——出发前 地图是"未激活"底图,与 03b/03c 滑行中的全强度地图形成状态区 分,也让卡片内容前置;定位点 + 缆车站画在 scrim 之上保持清晰 (这屏地图唯一要回答"我在哪")。内容白卡(天气 / 滑行建议) 对齐 01q 主屏白卡:Shadow.cardSoft + 左右 20;建议卡垂直 16, 天气卡因含 76pt 大字垂直放到 20;按钮与交互层(传感器钮 / SlideToStart)浮在地图上仍用 Shadow.lift。卡内 hairline 分隔 HANDOFF 允许的双模块例外。地图 上没画建议路线——练什么由白卡文字承担。basemap 色值与 ski-b.jsx 一致(冷调雪面浅灰蓝,one-off 不入 token)。 ============================================================ */ function PreSessionScreenC({ onNav }) { return (
{/* basemap — 全屏 844,等高线延伸到底;出发前无滑行轨迹, 只有缆车虚线 + Furi 站 + 出发点定位 */}
{Array.from({ length: 27 }).map((_, i) => { const y = 18 + i * 30; const amp = 24 + (i % 4) * 9; return ( ); })} {Array.from({ length: 7 }).map((_, i) => { const y = 65 + i * 120; return ( ); })} {/* 海拔标注 — 与 03b 同刻度(120px / 200M);被卡片盖住的 保留不删,露出的是下方 1400 M 一条 */} 2400 M 2000 M 1600 M 1400 M {/* 缆车虚线 — 从山脚站向上山方向 */} {/* 静音 scrim — 出发前地图是"未激活"底图:把地形推远、让 卡片内容前置,也与 03b/03c 滑行中的全强度地图形成状态 区分。定位点 + 缆车站画在 scrim 之上保持清晰。 */}
{/* info 层 — 定位点与缆车站,浮在 scrim 上 */} {/* Furi 缆车站 glyph + label */} Furi 缆车站 {/* 出发点定位 — 03b 同款 data 蓝三环,"你在缆车站" */}
{/* ── Header — 传感器按钮浮在地图上加 lift ── */}

5 月 3 日 · 周五 · 瑞士阿尔卑斯

Zermatt

{/* ── 天气卡 — 悬浮白卡。含 76pt 大字,垂直 padding 20 比内容卡多一档呼吸 ── */}
−7 °
{/* 28:白卡与天气卡拉开一档,构图重心下移,露出更多地图 */}
{/* ── 建议白卡 · 滑行建议(原黑卡;白底后 TopoLines 白描线不可见,移除)── */}
滑行建议

40° 立刃 变成本能

上次你连三趟造出 40° 立刃,但右弯入弯还有点犹豫。 今天 5 趟里凑 3 趟稳住,就成了。

{/* SlideToStart — pinned 拇指区 */}
onNav && onNav('ski')}/>
onNav && onNav(tab)}/>
); } /* ── SlideToStart · sports-flavored swipe affordance ────── Chunky white thumb on a 76-tall ink track. Drag past 85% to commit; otherwise elastic-snaps back. Hint text track-centered, chevron pinned to track-right. `label` defaults to 开始滑行 for fresh-session use; 02a (滑行结束) passes 继续滑行. */ function SlideToStart({ onStart, label = '开始滑行' }) { const [progress, setProgress] = React.useState(0); const [dragging, setDragging] = React.useState(false); const trackRef = React.useRef(null); const [maxTravel, setMaxTravel] = React.useState(0); const THUMB = 60; const PAD = 8; /* Auto-reset after commit so this layer's SlideToStart starts fresh next time the user returns (router pop-to-existing reuses the layer instance — without reset, thumb stays at progress=1 and the next swipe is dead). onStart goes through a latest-ref so an unstable callback identity from the parent (inline arrows re-create every render) doesn't re-trigger this effect after navigation — that caused a feedback loop where each onStart push re-rendered the parent, gave us a fresh onStart ref, re-scheduled, and pushed again. */ const onStartRef = React.useRef(onStart); React.useEffect(() => { onStartRef.current = onStart; }); React.useEffect(() => { if (progress < 1 || dragging) return; const tStart = setTimeout(() => onStartRef.current && onStartRef.current(), 280); const tReset = setTimeout(() => setProgress(0), 800); return () => { clearTimeout(tStart); clearTimeout(tReset); }; }, [progress, dragging]); React.useLayoutEffect(() => { if (!trackRef.current) return; const measure = () => { if (trackRef.current) { setMaxTravel(Math.max(0, trackRef.current.offsetWidth - 2 * PAD - THUMB)); } }; measure(); const ro = new ResizeObserver(measure); ro.observe(trackRef.current); return () => ro.disconnect(); }, []); React.useEffect(() => { if (!dragging) return; const onMove = (e) => { const el = trackRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const scale = rect.width / el.offsetWidth || 1; const elementX = (e.clientX - rect.left) / scale; const home = PAD + THUMB / 2; const end = el.offsetWidth - PAD - THUMB / 2; const p = end > home ? (elementX - home) / (end - home) : 0; setProgress(Math.max(0, Math.min(1, p))); }; const onUp = () => { setDragging(false); setProgress(p => p >= 0.85 ? 1 : 0); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); return () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; }, [dragging, onStart]); const onDown = (e) => { if (progress >= 1) return; e.preventDefault(); setDragging(true); }; return (
{label}
= 1 ? 'default' : (dragging ? 'grabbing' : 'grab'), transform: `translateX(${progress * maxTravel}px)`, transition: dragging ? 'none' : 'transform .36s cubic-bezier(.32,.72,0,1)', boxShadow: Shadow.thumb, touchAction: 'none', }}>
); } Object.assign(window, { PreSessionScreenC, SlideToStart });