/* ============================================================
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 站 + 出发点定位 */}
{/* 静音 scrim — 出发前地图是"未激活"底图:把地形推远、让
卡片内容前置,也与 03b/03c 滑行中的全强度地图形成状态
区分。定位点 + 缆车站画在 scrim 之上保持清晰。 */}
{/* info 层 — 定位点与缆车站,浮在 scrim 上 */}
{/* ── Header — 传感器按钮浮在地图上加 lift ── */}
5 月 3 日 · 周五 · 瑞士阿尔卑斯
Zermatt
{/* ── 天气卡 — 悬浮白卡。含 76pt 大字,垂直 padding 20 比内容卡多一档呼吸 ── */}
{/* 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 });