/* ============================================================ ROOY · 03b 滑行中 · 自由模式 ─────────────────────────────────────────────────────────── 需求:地图全屏 + 当前滑行的轻反馈,ROOY 偷懒中不主动出声, 用户上拉 sheet 才看到详细。 结构:basemap → peek sheet(170px:scope + 3 stat + ROOY 一行) → 上拉 expanded(550px:速度曲线 / 能力 / 左右脚 / 累计)。 stop button 浮在 peek 顶 14px 上方,tap 进 03d 暂停态;结束走 03d 的两步确认(继续 / 结束 pill),不再长按直结束。 italic anchor:ROOY 反馈内联数字(0.08,标识"重心切换偏快")。 accent:ROOY 绿(peek 内 RooyLine dot 一次)。 Anti-pattern:peek↔expanded 之间**不**画 hairline 分隔——hairline 只用于列表 cell 之间,区块切换靠 spacing。 ============================================================ */ function SkiScreenB({ onNav, defaultExpanded = false, paused = false }) { const [expanded, setExpanded] = React.useState(defaultExpanded); const sheetH = expanded ? 550 : 170; return (
{/* basemap — 全屏铺底;trail 曲线与高度标记不变。底色是冷调 雪面浅灰蓝(one-off,basemap 不属于 surface token 体系), 与 ink 冷灰 / data 蓝 / rooy 青同一色温. */}
{Array.from({ length: 19 }).map((_, i) => { const y = 18 + i * 30; const amp = 24 + (i % 4) * 9; return ( ); })} {Array.from({ length: 5 }).map((_, i) => { const y = 65 + i * 120; return ( ); })} 2400 M 2200 M 2000 M 1800 M 1600 M Furi 缆车站
{paused ? ( /* 03d · 暂停态 · 居中 pill pair(继续 + 结束)。pill 高 56 = 03b 圆形 stop 同直径,视觉重量一致。移植自 zhutty ski-b。 */
) : ( /* 03b · 滑行中 · 右下 56pt 圆形 stop。tap → 暂停 push 03d。 固定在 peek 高度上沿 14px,展开时 sheet 升上来会盖住它(用户 故意要的效果——展开就是在看详细数据,不需要再看 stop 入口)。 */ )} {/* Sheet —— 圆角对称 B.sheetL(ski 常驻 panel 档)*/}
{/* drag handle —— 紧凑行 (03c 同款) */}
onNav && onNav(t)}/>
); } /* SheetBody —— peek (caption + 3 stat + ROOY) → divider → expanded 4 段。 peek 顺序按"数据 → 教练"读:caption 告诉你这是哪趟、数字摆出来、 ROOY 在下面给一句话 verdict。展开后再细看四块。 */ function SheetBody({ expanded }) { return ( <> {/* ── PEEK · 1. trip scope — "label ⇄ trip name" space-between, 让 "刚刚那趟" 视觉上读作 scope label,不像一句小标题. */}
刚刚那趟 Furi 蓝道
{/* ── PEEK · 2. 3 stat — flex 1 等宽 + 列间 vertical hairline. 跟 03c peek 一致, 让"刚刚那趟"的三个核心数据像 dashboard 一样分列读. */}
{/* ── PEEK · 3. ROOY 反馈 — dot inline 跟文字中部对齐 ── */}

右弯入弯快了 0.08s,比昨天稳。

{/* ── EXPANDED · 3 段,平铺无卡 ───────────────── 顺序:速度曲线 → 左右脚 → 今日累计。 peek↔expanded 不用 hairline,第一段 first padding-top 做气口. */}
{/* 左右脚 —— bar + 50% 中线 reference 让"偏左/偏右"秒读, 比纯数字更直观。fill 弱化到 ink3 (原 ink2 太重),中线 ink2 作为强 reference 锚 50%. "偏左 4" 作为 sub 是 ROOY 主动 verdict,取代了平淡的 "重心分配". */}
52 48
{/* "今日累计" 是另一个时间尺度——前 3 段都是本趟,这段是当日。 时间尺度切换靠加大 spacing 表达(gap=40)。 4 项用 2x2 grid,让每项有呼吸——一行 4 项太挤. */}
); } /* Section —— 平铺段,靠 spacing + caption 自立;不用白卡。 caption 是 T.eyebrow + ink3,副 sub 是 T.footnote + ink4。 first:peek↔expanded 不用 hairline 时,第一段顶气口拉大成 32。 gap:标记时间尺度切换的段(本趟 → 今日),顶气口拉大成 40. */ function Section({ label, sub, first, last, gap, children }) { const top = first ? 32 : gap ? 40 : 22; return (
{label} {sub && {sub}}
{children}
); } /* StatCol —— peek 的 3 列数字。label footnote 普通中文小字(不再 uppercase),自然左对齐让三个 stat 跟 ski (03) 的 Stat 一致。 */ function StatCol({ label, value, unit }) { return (

{label}

); } /* DayStat —— 今日累计 inline 项:label 灰小 + 数字 size 18 + unit 灰小。 靠 parent flex 的 space-between 分散,无需 dot 分隔符. */ function DayStat({ l, v, u }) { return ( {l} {v} {u && {u}} ); } /* ── 速度曲线 sparkline —— 单色 data 折线 + dot-grid 底。 数据按"弯"分组:8 段 turns,每段是一个弯的 speed samples。弯之 间用 dashed vertical line 标分界——让 sub "8 弯" 字面可见, 而不只是"一根曲线"。不高亮最快弯,所有弯平权. */ function SpeedSparkline() { const turns = [ [18, 28, 36, 30, 20, 14, 12], [22, 36, 50, 55, 46, 30, 20, 16, 18], [28, 46, 62, 68, 58, 40, 28, 22, 30], [38, 48, 50, 40, 22, 14, 12], [26, 46, 64, 72, 60, 40, 28, 24, 30], [36, 42, 45, 36, 22, 16, 14], [28, 44, 58, 60, 50, 32, 22, 18], [24, 28, 32, 26, 22, 20], ]; const speeds = turns.flat(); // 边界点索引(每个弯起始位置,含尾边界)→ 内部分割线落在 // (boundary - 0.5) 处,画在两个弯之间. const boundaries = turns.reduce( (acc, t) => [...acc, acc[acc.length - 1] + t.length], [0], ).slice(1, -1); // 去掉首尾,只留 7 条内部分隔 const min = Math.min(...speeds), max = Math.max(...speeds); const range = max - min || 1; const w = 326, h = 80, padTop = 14, padBot = 10, padX = 6; const yRange = h - padTop - padBot; const xRange = w - padX * 2; const xAt = (i) => padX + (i / (speeds.length - 1)) * xRange; const pts = speeds.map((v, i) => ({ x: xAt(i), y: padTop + (1 - (v - min) / range) * yRange, })); const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' '); const last = pts[pts.length - 1]; return ( {/* turn dividers —— 实线 vertical 标 8 段分界。 点阵 (discrete 圆点) + dashed (discrete 短段) 视觉碎片 会互相干扰;改实线,形态语言跟点阵区分开,眼睛能立刻 分清"参考线 vs 底纹"。stroke 极弱 (0.18) 不抢曲线. */} {boundaries.map((b, k) => { const x = xAt(b - 0.5); return ( ); })} ); } Object.assign(window, { SkiScreenB });