/* ============================================================
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 (
);
}
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 });