/* ============================================================ ROOY · 08b 雪季汇总 · 雪场地图 hero(season-c) ─────────────────────────────────────────────────────────── 08(season-b)的重设计变体:hero 从「12 天」纯排版换成 「本季去过的雪场分布地图 + 雪场数」。并排挂在 08 旁边对比, 落选由用户删(见画布工作流纪律)。 为什么这么设计: 1. hero 锚点 = 本季雪场数(italic data 蓝),地图与「数量」 天然成对;「12 天」不丢,下沉进 stat row 第一行。 2. 地图 = **自适应取景**:按已打卡雪场经纬度算外接框, frameOf() 自动缩放。跨国 → 框到多国(世界带);只在一个 雪区 → MINW/MINH 兜底收紧成地区。本季数据全在欧亚 (阿尔卑斯 / 日本 / 中国),预览就自动框到欧亚带。 3. **屏幕空间聚类**:低缩放时相邻 pin 合成一个「区域气泡」 (阿尔卑斯 ×3 / 日本 ×2 / 中国 ×3),预览干净;放大才拆开。 4. **交互分层(迁就真机手势)**: - 内嵌 hero 不可拖动(拖动会跟页面上下滚动打架)。点 pin = 选中 → 浮层「最常去」读出更新(纯点按)。点地图/「展开」= 地图框就地向下加高(对齐 09b),不跳全屏。 - 全屏 SeasonMapSheet(本屏不再触发,保留供 EN 版 hero 入口) 承载 pan(拖)/ 双指捏合 / 双击放大 / 点 pin 弹读数,放大到 单一雪区时 basemap 交叉淡入等高线地形。 accent budget:data 蓝 1(标题数字 + 地图 pin,全屏唯一主色)。 gold 仅 心流 stat + ROOY 见证(成长语义,沿用 08)。rooy 仅 ROOY 指数最高分。地图 basemap 走中性冷调,不铺第二个 accent。 hero 大字数已撤——地图即 hero,数量退成标题句子;italic 只剩 最常去次数一处小锚。 编辑性 uppercase inline label(SEASON / RESORTS)沿用 08 的杂志 语言——本屏专属例外。 ============================================================ */ /* ── 本季数据 ───────────────────────────────────────────── 经纬度真实(用于投影 + 自适应取景 + 聚类都吃这份坐标)。 area = 聚类/读出用的区域名;hi = 最常去(读出默认高亮)。 */ /* CN 08b hero = 世界底图态(minW 150,对齐 09b / EN 08b):即使本季数据 集中在阿尔卑斯,预览也铺开世界大陆带,pin 聚成区域气泡。地形等高线层 不再用于 hero——只服务于全屏 SeasonMapSheet 放大到单一雪区时淡入。 */ const SEASON_C_RESORTS = [ { id: 'zermatt', name: 'Zermatt', area: '阿尔卑斯', country: '瑞士', lon: 7.75, lat: 46.02, visits: 7, hours: 38, hi: true }, { id: 'verbier', name: 'Verbier', area: '阿尔卑斯', country: '瑞士', lon: 7.23, lat: 46.10, visits: 3, hours: 15 }, { id: 'saasfee', name: 'Saas-Fee', area: '阿尔卑斯', country: '瑞士', lon: 7.93, lat: 46.11, visits: 2, hours: 9 }, { id: 'chamonix', name: 'Chamonix', area: '阿尔卑斯', country: '法国', lon: 6.87, lat: 45.92, visits: 3, hours: 14 }, { id: 'courmayeur', name: 'Courmayeur', area: '阿尔卑斯', country: '意大利', lon: 6.97, lat: 45.79, visits: 2, hours: 10 }, { id: 'grindel', name: 'Grindelwald', area: '阿尔卑斯', country: '瑞士', lon: 8.03, lat: 46.62, visits: 1, hours: 6 }, ]; const SEASON_C_AREA = '阿尔卑斯'; // 本季主区(单区态) /* ── 世界底图 ───────────────────────────────────────────── 等距圆柱投影:lon[-180,180]→x[0,1000],lat[85,-85]→y[0,460]。 大陆是刻意低多边形的抽象轮廓(不做精确国界,与 app 非写实 SVG-line 语言一致),只求「一眼是世界地图」。 */ const WORLD_W = 1000, WORLD_H = 460; const LONLAT = (lon, lat) => [ (lon + 180) / 360 * WORLD_W, (85 - lat) / 170 * WORLD_H ]; const LANDPATH = (pts) => pts.map((p, i) => { const [x, y] = LONLAT(p[0], p[1]); return (i ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1); }).join(' ') + ' Z'; const CONTINENTS = [ /* 欧亚 */ [[-9.5,36.8],[-9,43],[-1.5,43.5],[1.5,51],[4,53.5],[8,57.5],[11,58],[13,55.5],[19,54.5],[21,56],[24,60],[28,61],[30,59.5],[38,64.5],[41,66.5],[54,68.5],[60,70],[69,73],[90,75],[113,74],[140,73],[160,70],[170,68.5],[180,66],[180,62],[163,61],[160,58],[155,52],[145,49],[142,46],[135,43],[130,42.5],[128,38],[122,40],[121,37],[120,34],[122,31],[121,28],[117,24],[110,21],[108,16],[106,10],[104,9],[100,6],[100,13],[97,16],[94,16],[90,22],[88,21.5],[80,13],[77,8],[73,16],[70,22],[67,24],[61,25],[57,25],[56,27],[50,30],[48,30],[48,25],[43,29],[40,32],[35,31],[34,28],[35,36],[30,36.5],[27,40.5],[23,40],[19,42],[13,45],[8,44],[3.5,43.3],[-2,36.5]], /* 非洲 */ [[-16.5,14.5],[-17.5,21],[-16,26],[-9.5,30.5],[0,35],[10,37],[11,34],[20,32.5],[25,32],[32,31.5],[34,28],[37,22],[39,15],[43,11.5],[48,12.5],[51,11.5],[45,5],[42,-2],[40,-10],[39,-16],[35,-22],[32,-26],[27,-33.5],[20,-34.8],[18,-34],[15,-27],[13,-17],[9,-2],[9.5,4],[3,6],[-4,5],[-8,4.5],[-13,8.5]], /* 北美 */ [[-168,65.5],[-166,68.5],[-156,71],[-140,70],[-128,70.5],[-110,69],[-95,71.5],[-82,73],[-78,68],[-80,62],[-71,62],[-64,60],[-56,53],[-55,49],[-66,44],[-70,41],[-74,39],[-81,31],[-80.5,25],[-83,29],[-90,29],[-97,26],[-97,22],[-105,20],[-106,23],[-110,24],[-113,29],[-114,31],[-117,33],[-121,35],[-124,40],[-124,46],[-123,48.5],[-132,53],[-135,58],[-140,60],[-150,60],[-158,58],[-165,60]], /* 南美 */ [[-78,8.5],[-72,11.5],[-64,10.5],[-60,5],[-51,4],[-50,0],[-44,-2],[-35,-5.5],[-35,-8],[-38,-13],[-39,-18],[-42,-23],[-48,-25],[-54,-34],[-57,-35],[-62,-40],[-65,-45],[-69,-51],[-73,-52],[-74,-45],[-73,-38],[-71,-30],[-70,-23],[-70,-18],[-76,-14],[-79,-8],[-81,-6],[-80.5,-2],[-79,2],[-77,5]], /* 澳洲 */ [[113,-22],[114,-26],[116,-31],[115,-34],[119,-34],[123,-34],[129,-32],[131,-31],[134,-33],[138,-35],[140,-38],[145,-38.5],[147,-38],[150,-37],[151,-33],[153,-28],[153,-25],[149,-21],[146,-19],[143,-14],[141,-12],[137,-12],[135,-14],[130,-12],[126,-14],[122,-18],[118,-20]], /* 日本 */ [[130,31],[131,31.5],[133,33],[135,34],[137,35],[138.5,37],[141,39],[141.5,41],[140,42],[141,45],[143,44.5],[145,44],[144,43],[142,41],[140.5,38],[139,35],[137,34.5],[135.5,33.5],[132,31.5]], ]; /* ── 自适应取景 ─────────────────────────────────────────── 算 pin 外接框 → pad → 兜底最小尺寸 → 套目标宽高比。 pin 铺得开 → 框大(多国/世界带);挤在一处 → MINW/MINH 兜底 收紧成地区。这就是「跨国显世界、单区显地形」的驱动逻辑。 */ const frameOf = (resorts, aspect, { pad = 0.35, minW = 120, minH = 70 } = {}) => { const xs = resorts.map(r => LONLAT(r.lon, r.lat)[0]); const ys = resorts.map(r => LONLAT(r.lon, r.lat)[1]); const minx = Math.min(...xs), maxx = Math.max(...xs); const miny = Math.min(...ys), maxy = Math.max(...ys); const cx = (minx + maxx) / 2, cy = (miny + maxy) / 2; let w = Math.max((maxx - minx) * (1 + pad * 2), minW); let h = Math.max((maxy - miny) * (1 + pad * 2), minH); if (w / h < aspect) w = h * aspect; else h = w / aspect; return { x: cx - w / 2, y: cy - h / 2, w, h }; }; /* ── 屏幕空间聚类 ───────────────────────────────────────── 把当前 viewBox 下投影距离 < radiusPx 的 pin 合成一个气泡。 低缩放 → 阿尔卑斯/日本/中国各聚成 1 个;放大 → 拆开。 */ const clusterPins = (resorts, view, pxW, radiusPx = 30) => { const scale = view.w / pxW; // world units / px const rWorld = radiusPx * scale; const proj = resorts.map(r => { const [x, y] = LONLAT(r.lon, r.lat); return { r, x, y }; }); const used = new Array(proj.length).fill(false); const clusters = []; for (let i = 0; i < proj.length; i++) { if (used[i]) continue; const members = [proj[i].r]; used[i] = true; for (let j = i + 1; j < proj.length; j++) { if (used[j]) continue; const dx = proj[i].x - proj[j].x, dy = proj[i].y - proj[j].y; if (Math.hypot(dx, dy) < rWorld) { members.push(proj[j].r); used[j] = true; } } // 气泡锚点 = 成员按访问量加权重心 let wsum = 0, ax = 0, ay = 0; members.forEach(m => { const [mx, my] = LONLAT(m.lon, m.lat); ax += mx * m.visits; ay += my * m.visits; wsum += m.visits; }); const visits = members.reduce((s, m) => s + m.visits, 0); const areas = [...new Set(members.map(m => m.area))]; clusters.push({ x: ax / wsum, y: ay / wsum, members, visits, label: members.length === 1 ? members[0].name : (areas.length === 1 ? areas[0] : '多雪区'), hi: members.some(m => m.hi), id: members.map(m => m.id).join('+'), }); } return clusters; }; /* ── pin 尺寸 / 颜色 = 天数编码 ────────────────────────── visits(天数)越多 → 圆点越大、蓝越深。hover(active) 在此基础上整体 放大。单点与聚类共用同一比例尺(聚类 visits = 成员天数和,自然更大): 阿尔卑斯气泡(18) > Zermatt(7) > Grindelwald(1)。pin 块与 MapHoverCard 共用本函数,保证引线 ring 与画布光环一致。 */ const V_MIN = 1, V_MAX = 20; const pinMetrics = (c, { px, markerScale, active = false }) => { const t = Math.min(1, Math.max(0, (c.visits - V_MIN) / (V_MAX - V_MIN))); const rBase = (4.5 + t * 3.5) * px * markerScale; // 4.5..8 pt 数据驱动基础半径 const rDot = rBase * (active ? 1.28 : 1); // hover 整体 ×1.28 const ring = rDot + (active ? 7.5 : 6) * px * markerScale; const dotFill = `color-mix(in srgb, var(--r-data) ${(65 + t * 35).toFixed(0)}%, white)`; return { rDot, ring, dotFill }; }; /* ── 等高线地形层(单雪区态)────────────────────────────── 围绕 focus 中心生成一组嵌套抖动等高线,暗示山体 massif。 放大到单一雪区时交叉淡入,复用 app 的地形语言。 */ const TerrainLayer = ({ cx, cy, spanW, opacity }) => { if (opacity <= 0.01) return null; const rings = 7; const paths = []; for (let i = 0; i < rings; i++) { const rr = spanW * (0.08 + i * 0.052); const wob = spanW * 0.012 * (i % 2 ? 1 : -1); paths.push( `M ${(cx - rr).toFixed(1)} ${cy.toFixed(1)} Q ${(cx - rr * 0.5).toFixed(1)} ${(cy - rr + wob).toFixed(1)} ${cx.toFixed(1)} ${(cy - rr * 0.86).toFixed(1)} T ${(cx + rr).toFixed(1)} ${cy.toFixed(1)} Q ${(cx + rr * 0.5).toFixed(1)} ${(cy + rr - wob).toFixed(1)} ${cx.toFixed(1)} ${(cy + rr * 0.9).toFixed(1)} T ${(cx - rr).toFixed(1)} ${cy.toFixed(1)} Z` ); } return ( {paths.map((d, i) => ( ))} ); }; /* ── pin 悬浮卡片(hover 显示 地点 + 雪场 + 天数)────────────── 指针悬停 pin 即浮出小卡片:地点(multi = 代表雪场 + 计数)作 eyebrow、 雪场名作标题(data 蓝)、天数右贴(ink)。主色锚点对齐底部「最常去」 读出浮层——名字 = 蓝、天数 = ink,hover→tap 是同构图放大,不跳色。 折叠 / 展开 / 全屏三态都弹(无门控)。抗缩放(同 marker,按 px 换算); pointerEvents 关闭,既不挡相邻 pin 也不闪烁;近顶边翻到 pin 下方, 水平夹在 viewBox 内防裁切。入场 120ms fade-in。 */ function MapHoverCard({ c, px, view, markerScale = 1 }) { const m = c.members, multi = m.length > 1; const resort = m.find(x => x.hi) || m[0]; const place = multi ? `${resort.name} 等 ${m.length} 个` : `${resort.area} · ${resort.country}`; const name = multi ? c.label : resort.name; const days = c.visits; const PAD = 11, GAP = 5, CW = 158, CH = 54; const { ring } = pinMetrics(c, { px, markerScale, active: true }); // hovered pin 必为 active → 取其 active 光环半径,引线贴外缘 const w = CW * px, h = CH * px; let x = c.x - w / 2; // 水平默认居中于 pin const lo = view.x + 2 * px, hiEdge = view.x + view.w - w - 2 * px; if (x < lo) x = lo; else if (x > hiEdge) x = hiEdge; // 夹在 viewBox 内防裁切 const aboveY = c.y - ring - GAP * px - h; const flip = aboveY < view.y + 2 * px; // 近顶边 → 翻到 pin 下方 const y = flip ? (c.y + ring + GAP * px) : aboveY; const tx = Math.min(Math.max(c.x, x + 10 * px), x + w - 10 * px); const eyeY = y + 18 * px, rowY = y + 40 * px; // eyebrow / 名字+天数 共用基线 // 细引线(Apple Maps POI callout 风):卡片朝 pin 那侧的边 → pin 光环外缘。 // 替代实心尾翼,更轻;pin 不在卡片正下方时自然成斜线,指向真实位置。 const lineFrom = flip ? { x: tx, y: y } : { x: tx, y: y + h }; const lineTo = flip ? { x: c.x, y: c.y + ring } : { x: c.x, y: c.y - ring }; const [on, setOn] = React.useState(false); // 入场 fade-in(hover 弹出时播) React.useEffect(() => { const id = requestAnimationFrame(() => setOn(true)); return () => cancelAnimationFrame(id); }, []); return ( {/* 细引线(先画,压在卡片底下;无阴影,避免细线糊掉)*/} {/* 卡片本体 — 阴影走 Shadow.lift(浅地图底图上更脱离,仍 < 选中态 modal)*/} {/* 地点 eyebrow(multi:代表雪场 + 计数,让气泡知道里面谁最重要)*/} {place} {/* 雪场名(data 蓝,对齐底部读出浮层「名字 = 主色」)*/} {name} {/* 天数(右贴,ink;字族同 N.hero,预览态压小、不抢主色)*/} {days} ); } /* ── 地图画布 ───────────────────────────────────────────── 底图(大陆 + 经纬网 + 可选地形)+ 聚类 pin。预览与全屏共用。 pxW 用于聚类换算(屏幕像素半径)。 */ function SeasonMapCanvas({ view, resorts, pxW, selectedId, onPin, terrainOpacity = 0, markerScale = 1 }) { const clusters = clusterPins(resorts, view, pxW); const px = view.w / pxW; // world units per screen px(marker 抗缩放) const vb = `${view.x} ${view.y} ${view.w} ${view.h}`; const [hoverId, setHoverId] = React.useState(null); // 悬停的 pin → 浮出 地点+雪场+天数 卡片 // 经纬网:每 20° 一条,落在 view 内的才画 const grid = []; for (let lon = -180; lon <= 180; lon += 20) { const x = LONLAT(lon, 0)[0]; if (x >= view.x - 1 && x <= view.x + view.w + 1) grid.push(['v', x]); } for (let lat = -80; lat <= 80; lat += 20) { const y = LONLAT(0, lat)[1]; if (y >= view.y - 1 && y <= view.y + view.h + 1) grid.push(['h', y]); } const terrainFocus = clusters.reduce((a, b) => (b.visits > a.visits ? b : a), clusters[0]); const hovered = hoverId ? clusters.find(c => c.id === hoverId) : null; return ( {/* 经纬网 */} {grid.map(([o, v], i) => o === 'v' ? : )} {/* 大陆(世界态,随地形淡入而淡出)*/} {CONTINENTS.map((c, i) => ( ))} {/* 地形层(单雪区态)*/} {terrainFocus && ( )} {/* pin / 气泡 — 尺寸 & 颜色 = 天数编码(visits 越多越大越深);hover 在此 基础上整体放大 + 光环加深 + 描边淡入。r / opacity 走 CSS 过渡(180ms, 地图框同款贝塞尔)。最常去不再用单独描边——由「最大最深」自然表达。 */} {clusters.map((c) => { const multi = c.members.length > 1; const active = c.id === hoverId; // 仅 hover 驱动 const { rDot, ring, dotFill } = pinMetrics(c, { px, markerScale, active }); const ease = 'cubic-bezier(0.32,0.72,0,1)'; const tR = { transition: `r 180ms ${ease}` }; const tRO = { transition: `r 180ms ${ease}, opacity 180ms ease` }; return ( { e.stopPropagation(); onPin && onPin(c); }} onMouseEnter={() => setHoverId(c.id)} onMouseLeave={() => setHoverId(h => (h === c.id ? null : h))}> {/* 命中区(≥44pt 感)*/} {/* 光晕:fill 固定 28%,opacity 在 默认↔active 过渡 */} {/* 圆点:天数编码(大↑ 深↑)*/} {/* 多点计数 */} {multi && ( {c.members.length} )} ); })} {/* 悬浮卡片(指针悬停 pin 时浮出:地点 + 雪场 + 天数)*/} {hovered && } ); } const FAM_NUM_C = '"SF Pro Display", -apple-system, "Helvetica Neue", system-ui, sans-serif'; /* ── 全屏交互地图 ───────────────────────────────────────── pan(拖)+ 双指捏合 + 双击放大 + 点 pin 弹读数。打开 auto-frame, 放大到单雪区交叉淡入地形。这层才承载连续手势,不与页面滚动冲突。 */ function SeasonMapSheet({ resorts, onClose, initialSelected, title, subtitle, visitsUnit = '天', terrainLabel = '地形' }) { const wrapRef = React.useRef(null); const [box, setBox] = React.useState({ w: 390, h: 600 }); const [view, setView] = React.useState(() => frameOf(resorts, 390 / 600, { pad: 0.7, minW: 160 })); const [selected, setSelected] = React.useState(initialSelected || null); const pointers = React.useRef(new Map()); const gesture = React.useRef(null); // { moved } 判 tap vs pan const pinchRef = React.useRef(null); // 量测容器像素尺寸(画布缩放下也准),重设初始 frame 匹配真实宽高比 React.useEffect(() => { const el = wrapRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const scale = rect.width / el.offsetWidth || 1; const w = rect.width / scale, h = rect.height / scale; setBox({ w, h }); setView(frameOf(resorts, w / h, { pad: 0.7, minW: 160 })); }, []); const TERRAIN_ON = 70, TERRAIN_OFF = 150; // world-units 跨度阈值 const terrainOpacity = Math.max(0, Math.min(1, (TERRAIN_OFF - view.w) / (TERRAIN_OFF - TERRAIN_ON))); const pxScale = () => { const el = wrapRef.current; if (!el) return 1; const rect = el.getBoundingClientRect(); return (rect.width / el.offsetWidth || 1); }; const clampView = (v) => { const w = Math.max(28, Math.min(WORLD_W, v.w)); const h = w * (box.h / box.w); const x = Math.max(-w * 0.15, Math.min(WORLD_W - w * 0.85, v.x)); const y = Math.max(-h * 0.4, Math.min(WORLD_H - h * 0.6, v.y)); return { x, y, w, h }; }; const zoomAt = (factor, sx, sy) => { setView(v => { const nw = v.w * factor; const fx = v.x + (sx / box.w) * v.w; // 缩放锚点(世界坐标) const fy = v.y + (sy / box.h) * v.h; const nx = fx - (sx / box.w) * nw; const ny = fy - (sy / box.h) * (nw * box.h / box.w); return clampView({ x: nx, y: ny, w: nw, h: nw * box.h / box.w }); }); }; const onPointerDown = (e) => { e.currentTarget.setPointerCapture?.(e.pointerId); pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); gesture.current = { moved: 0, sx: e.clientX, sy: e.clientY }; if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()]; pinchRef.current = { d: Math.hypot(a.x - b.x, a.y - b.y), w: view.w }; } }; const onPointerMove = (e) => { if (!pointers.current.has(e.pointerId)) return; const prev = pointers.current.get(e.pointerId); pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const sc = pxScale(); if (pointers.current.size >= 2 && pinchRef.current) { const [a, b] = [...pointers.current.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y); const mid = wrapRef.current.getBoundingClientRect(); const sx = ((a.x + b.x) / 2 - mid.left) / sc, sy = ((a.y + b.y) / 2 - mid.top) / sc; setView(_ => clampView((() => { const factor = pinchRef.current.d / Math.max(1, d); const nw = pinchRef.current.w * factor; const fx = view.x + (sx / box.w) * view.w, fy = view.y + (sy / box.h) * view.h; return { x: fx - (sx / box.w) * nw, y: fy - (sy / box.h) * (nw * box.h / box.w), w: nw, h: nw * box.h / box.w }; })())); gesture.current.moved += 20; return; } const dxPx = (e.clientX - prev.x) / sc, dyPx = (e.clientY - prev.y) / sc; gesture.current.moved += Math.abs(dxPx) + Math.abs(dyPx); setView(v => clampView({ ...v, x: v.x - dxPx * (v.w / box.w), y: v.y - dyPx * (v.h / box.h) })); }; const onPointerUp = (e) => { pointers.current.delete(e.pointerId); if (pointers.current.size < 2) pinchRef.current = null; }; const lastTap = React.useRef(0); const onCanvasClick = (e) => { if (gesture.current && gesture.current.moved > 8) return; // 是 pan 不是 tap const rect = wrapRef.current.getBoundingClientRect(); const sc = rect.width / wrapRef.current.offsetWidth || 1; const sx = (e.clientX - rect.left) / sc, sy = (e.clientY - rect.top) / sc; const now = e.timeStamp || 0; if (now - lastTap.current < 300) { zoomAt(0.5, sx, sy); lastTap.current = 0; return; } lastTap.current = now; setSelected(null); }; const onPin = (c) => { if (c.members.length > 1 && view.w > TERRAIN_OFF * 0.9) { // 气泡:放大进去 zoomAt(0.42, (c.x - view.x) / view.w * box.w, (c.y - view.y) / view.h * box.h); setSelected(c.members.find(m => m.hi)?.id || c.members[0].id); } else { setSelected(c.members[0].id); } }; const selResort = resorts.find(r => r.id === selected); const selXY = selResort ? LONLAT(selResort.lon, selResort.lat) : null; const selScreen = selXY ? { left: (selXY[0] - view.x) / view.w * box.w, top: (selXY[1] - view.y) / view.h * box.h, } : null; return (

{title || '雪场分布'}

{subtitle || `本季 ${resorts.length} 个 · ${[...new Set(resorts.map(r => r.area))].length} 区`}

{/* 地图区 */}
{/* 选中读数气泡 */} {selResort && selScreen && (
{selResort.name} {selResort.country}
{selResort.visits} {visitsUnit} · {selResort.hours}h
)} {/* 缩放控件(右下)*/}
{['+', '−'].map((s, i) => ( ))}
{/* 地形态标签 */} {terrainOpacity > 0.5 && selResort && (
{selResort.area} · {terrainLabel}
)}
); } /* ── 08b 屏 ───────────────────────────────────────────────── */ function SeasonScreenC({ onNav }) { const [selectedId, setSelectedId] = React.useState('zermatt'); // 读出默认 = 最常去 const [expanded, setExpanded] = React.useState(true); // 默认展开(地图即 hero,沉浸);收起键折叠回 300 const areas = [...new Set(SEASON_C_RESORTS.map(r => r.area))]; const sel = SEASON_C_RESORTS.find(r => r.id === selectedId) || SEASON_C_RESORTS[0]; // 地图 hero:full-bleed 世界底图(对齐 09b / EN 08b,minW 150)。 // 展开时地图框向下加高(顶部间距不变),按新宽高比重算取景;biasY 把 // pin 顶到上部,底部留给「最常去」读出浮层 + 渐变托底。 const HERO_W = 390; const mapH = expanded ? 600 : 300; // 展开大幅加高(不顶满,留呼吸) const heroView = (() => { const v = frameOf(SEASON_C_RESORTS, HERO_W / mapH, { pad: 0.35, minW: 150 }); return { ...v, y: v.y + v.h * 0.20 }; })(); return (
onNav && onNav('history')} title="雪季汇总" right={( )} />
{/* ── HERO · 地图为主,无大字数 ────────────────── */}

SEASON · 2025

本季去过 {SEASON_C_RESORTS.length} 个雪场

都在{SEASON_C_AREA}

{/* ── 地图 hero(full-bleed 世界底图;展开 = 框就地加高,对齐 09b)── 世界大陆底图(无 terrainOpacity),markerScale 1.3 同 09b。点地图 / 「展开」= 地图向下加高(顶部间距不变)。底部渐变托底,「最常去」 读出浮在渐变上(折叠/展开共用)。展开按钮贴地图框右上角 absolute。 */}
{ if (!expanded) setExpanded(true); }} style={{ position: 'relative', height: mapH, overflow: 'hidden', cursor: expanded ? 'default' : 'pointer', background: 'oklch(96.8% 0.007 240)', borderTop: `0.5px solid ${R.hair}`, transition: 'height 340ms cubic-bezier(0.32, 0.72, 0, 1)', }}> { if (!expanded) setExpanded(true); setSelectedId(c.members.find(m => m.hi)?.id || c.members[0].id); }} /> {/* 底部渐变托底(折叠/展开都保留)*/}
{/* 「最常去」读出浮层(点地图 pin 更新;折叠/展开都浮在底部渐变上) 左名右数据两栏:名用 data(含「最常去」小标),次数用 ink italic。 同 08 字族,不双重强调。 */}
{sel.name} {sel.hi && 最常去}

{sel.area} · {sel.country}

{sel.visits}

{sel.hours} h

{/* 展开/收起按钮:absolute 贴地图框右上角(地图框 relative → 始终钉在 地图顶角,不随页滚)。折叠态点整个地图也展开。 */}
{/* ── STAT ROWS(时长类已并入「滑行模式」)── */}
{/* 段间距修正:BStatRow 尾行自带 14px 底 padding,spacer 收窄到 26,令「列表底 → 下一段标题」视觉间距 ≈ 40,与齐平段落一致。 */}
{/* ── 滑行模式(时长 × 占比合一:56 h = 自由 40 + 上课 16;置于雪况之上)── */} 滑行模式
{/* ── 雪况 ── */} 雪况
{/* ── 高光时刻 ── */} 高光时刻
{/* 段间距修正:BRecordRow 尾行自带 12px 底 padding,spacer 收窄到 28。 */}
ROOY 见证
{[ { n: '首破 40°', d: '4 月 30 日', kind: 'trophy', hi: true }, { n: '生涯 100 km', d: '4 月 22 日', kind: 'flag' }, { n: '刃滑 I', d: '4 月 18 日', kind: 'medal' }, { n: '平行式', d: '4 月 10 日', kind: 'flag' }, ].map((m, i, arr) => (
{m.kind === 'trophy' && } {m.kind === 'flag' && } {m.kind === 'medal' && }

{m.n}

{m.d}

))}
); } /* CModeSplit — 08c 专用:把「滑行时长 / 上课时长」并进滑行模式分布。 分布条置顶,小时数(左自由 ink / 右跟 ROOY 上课 rooy)置于条下、 百分比作副标,总时长进 section meta。跟 ROOY 上课沿用 rooy 语义色。 */ function CModeSplit({ total, free, lesson, freeLabel = '自由滑行', lessonLabel = '跟 ROOY 上课', unit = 'h' }) { const freePct = Math.round(free / total * 100); const lessonPct = 100 - freePct; return (
{free} {unit}

{freeLabel} · {freePct}%

{lesson} {unit}

{lessonLabel} · {lessonPct}%

); } /* CSnowMix — 08c 局部:雪况堆叠条更细(8px)。legend 2×2,每项紧凑自成一组 [色块][标签][%] 左对齐、标签不拉伸——% 紧贴标签而非顶到格右沿,避免同行 两项「% ↔ 下一项色块」首尾相连。配色走集中常量 SNOW。 */ function CSnowMix({ items }) { return (
{items.map((it, i) => (
))}
{items.map((it, i) => (
{it.label} {it.pct}%
))}
); } Object.assign(window, { SeasonScreenC, SeasonMapSheet, SeasonMapCanvas, CModeSplit, CSnowMix, frameOf, LONLAT, WORLD_W, WORLD_H });