feat(heatmap): 热力图方案 v2——六案重做,含「贪吃蛇」(5j 预览迭代)
用户看过 v1 五案后反馈「不是很满意」,并要求「类似贪吃蛇般的方案」,故重做六案:
F 贪吃蛇 / G 心跳呼吸 / H 雨滴涟漪 / I 3D 地形 / J 周冠军柱 / K 显微镜。
**贪吃蛇(F)的关键设计判断**:真实数据 371 格里只有 51 天有贡献(总 306 次),
若照 GitHub 的做法"只吃有贡献的格子",蛇有 86% 的行程毫无反馈、看起来像卡住。
改为「爬过即留淡色轨迹 + 有贡献处强调色加重」,并把节奏 55ms→30ms(371 格约 11 秒
爬完一年),末尾给出「走过 371 格,吃到 51 天」的收束统计。
其余五案:
- G 心跳呼吸:按当日贡献量给每格不同的呼吸时长(3.4s→1.2s),纯 CSS 合成层动画
- H 雨滴涟漪:以「最后一次贡献」为圆心按距离错峰扩散,扫过闪一下;可重播
- I 3D 地形:贡献量当海拔(translateZ 0–14px),指针倾斜整块网格
- J 周冠军柱:月度总计柱状图,进视口逐根升起,悬停柱子联动高亮该月所有格子
- K 显微镜:指针处浮起圆镜,放大 5×5 邻域(用数据档位绘制)+ 日期与次数
六案共同约束:accent 只做高亮不参与数据编码(数据仍由 ink 四档承载)、
reduced-motion 全部静态、离开视口即停(IntersectionObserver)、零第三方依赖。
验证: 构建 52 页 exit 0;content-lint 52 页 0/0/0;Playwright 逐案实测——
F 蛇身逐格推进至 371/371、吃到 51 天、读数实时更新;
G 52 格动画 hmBreath 时长生效(2.6/3.35/3.06s…);
H wrap.play 且 animationName=hmWave;
I .hg-raise 有 translateZ 且容器 matrix3d 倾斜;
J 柱子 grow 后高 14px、13 个月、悬停联动 28 格;
K 圆镜 25 个迷你格 + 日期读数 + 焦点格 1;
390 零横向溢出;reduced-motion 下 breath none / 蛇静态全亮 / 地形无倾斜
Spec: 2026-11-03_blog-5j-content-refine (热力图预览 v2)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
1a486a20d2
commit
d9721cdbd8
1 file changed
+390
-274
+390
-274
@@ -1,37 +1,30 @@
|
|||||||
---
|
---
|
||||||
/**
|
/**
|
||||||
* 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。
|
* 热力图动效方案预览 v2(Phase 5j)——用户看过 v1 五案后觉得不够,要求「类似贪吃蛇」的
|
||||||
|
* 方案并再深入设计 4–6 个。本页给 6 个更深入的方案(F–K),v1 的 A–E 已下线。
|
||||||
*
|
*
|
||||||
* 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮;
|
* 设计红线(六案都守):灰阶骨架、accent 只做高亮不做数据编码(数据仍由 ink 四档承载)、
|
||||||
* 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案
|
* prefers-reduced-motion 一律静态、零第三方依赖、离开视口即停(省电)。
|
||||||
* 落进 Heatmap.astro,然后删掉本路由。
|
|
||||||
*
|
|
||||||
* 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion
|
|
||||||
* 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。
|
|
||||||
*/
|
*/
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import HeatGrid from '../components/geek/HeatGrid.astro';
|
import HeatGrid from '../components/geek/HeatGrid.astro';
|
||||||
import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap';
|
import { loadHeatmap, type HeatDay } from '../lib/heatmap';
|
||||||
|
|
||||||
const { weeks, total } = await loadHeatmap();
|
const { weeks, total } = await loadHeatmap();
|
||||||
const days: HeatDay[] = weeks.flat();
|
const days: HeatDay[] = weeks.flat();
|
||||||
const buildTime = new Date().toISOString().slice(0, 10);
|
const buildTime = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
/* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */
|
/* ── 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数)── */
|
||||||
const countOf = new Map(days.map((d) => [d.date, d.count]));
|
const countOf = new Map(days.map((d) => [d.date, d.count]));
|
||||||
const today = new Date();
|
|
||||||
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
|
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
// 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径),
|
|
||||||
// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。
|
|
||||||
let current = 0;
|
|
||||||
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
|
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
|
||||||
|
let current = 0;
|
||||||
if (lastActive) {
|
if (lastActive) {
|
||||||
const start = new Date(`${lastActive}T00:00:00Z`);
|
const start = new Date(`${lastActive}T00:00:00Z`);
|
||||||
for (let i = 0; ; i++) {
|
for (let i = 0; ; i++) {
|
||||||
const d = new Date(start);
|
const d = new Date(start);
|
||||||
d.setUTCDate(start.getUTCDate() - i);
|
d.setUTCDate(start.getUTCDate() - i);
|
||||||
const c = countOf.get(isoOf(d)) ?? 0;
|
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++;
|
||||||
if (c > 0) current++;
|
|
||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,260 +35,356 @@ for (const d of days) {
|
|||||||
if (run > longest) longest = run;
|
if (run > longest) longest = run;
|
||||||
}
|
}
|
||||||
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
|
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
|
||||||
const activeDays = days.filter((d) => d.count > 0).length;
|
|
||||||
|
|
||||||
const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays };
|
/* ── 月度聚合(J 方案用)── */
|
||||||
|
const byMonth = new Map<string, number>();
|
||||||
|
for (const d of days) {
|
||||||
|
const key = d.date.slice(0, 7);
|
||||||
|
byMonth.set(key, (byMonth.get(key) ?? 0) + d.count);
|
||||||
|
}
|
||||||
|
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const maxMonth = Math.max(...months.map(([, v]) => v));
|
||||||
|
|
||||||
|
/* ── 最后活跃日的坐标(H 涟漪终点起点)── */
|
||||||
|
const lastActiveIdx = days.findLastIndex((d) => d.date === lastActive);
|
||||||
|
const lastActiveCol = Math.floor(lastActiveIdx / 7);
|
||||||
|
const lastActiveRow = lastActiveIdx % 7;
|
||||||
|
|
||||||
|
const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '' };
|
||||||
|
const dataForClient = { cells: days.map((d) => d.count), weeks: weeks.length };
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="热力图动效方案 · wait4x" description="五个贡献热力图动效方案的并陈预览">
|
<BaseLayout title="热力图动效方案 v2 · wait4x" description="六个更深入的热力图动效方案">
|
||||||
<h1 class="pv-title">热力图:五个动效方案</h1>
|
<h1 class="pv-title">热力图:六个更深入的方案</h1>
|
||||||
<p class="pv-note">
|
<p class="pv-note">
|
||||||
同一份真实数据(近一年 <strong>{total}</strong> 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续
|
同一份真实数据(近一年 <strong>{stats.total}</strong> 次贡献 · 当前连续
|
||||||
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。
|
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天 · 单日最高 {stats.best} 次),
|
||||||
|
数据更新于 {buildTime}。
|
||||||
<br />
|
<br />
|
||||||
<strong>看完告诉我选哪个</strong>(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。
|
上一版的五个方案已按你的反馈下线;这一版六个都往「有生命、有叙事」的方向推。
|
||||||
五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。
|
<strong>回字母告诉我选哪个</strong>(也可以说「F 但慢一点」「F + J 组合」)。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<nav class="pv-switch" aria-label="方案跳转">
|
<nav class="pv-switch" aria-label="方案跳转">
|
||||||
<a href="#ha" class="pv-chip">A · 入场波纹</a>
|
<a href="#hf" class="pv-chip">F · 贪吃蛇</a>
|
||||||
<a href="#hb" class="pv-chip">B · 光标光晕</a>
|
<a href="#hg" class="pv-chip">G · 心跳呼吸</a>
|
||||||
<a href="#hc" class="pv-chip">C · 年度回放</a>
|
<a href="#hh" class="pv-chip">H · 雨滴涟漪</a>
|
||||||
<a href="#hd" class="pv-chip">D · 连续电流</a>
|
<a href="#hi" class="pv-chip">I · 3D 地形</a>
|
||||||
<a href="#he" class="pv-chip">E · 悬停周详情</a>
|
<a href="#hj" class="pv-chip">J · 周冠军柱</a>
|
||||||
|
<a href="#hk" class="pv-chip">K · 显微镜</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ═══════════ A · 入场波纹 ═══════════ -->
|
<!-- ═══════════ F · 贪吃蛇 ═══════════ -->
|
||||||
<section id="ha" class="pv-sec">
|
<section id="hf" class="pv-sec">
|
||||||
<header class="pv-head">
|
<header class="pv-head">
|
||||||
<span class="pv-tag mono">A</span>
|
<span class="pv-tag mono">F</span>
|
||||||
<h2>入场波纹</h2>
|
<h2>贪吃蛇</h2>
|
||||||
<p class="pv-pros">滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。
|
<p class="pv-pros">一条蛇沿着贡献格蛇形爬过:吃到有贡献的格子就把它们点亮并"长"一点,空格直接穿过。
|
||||||
<b>优点</b>有生命感、不打扰、只播一次;<b>代价</b>纯装饰,不提供额外信息。</p>
|
<b>优点</b>最有生命感、天然表达"这一年被一点点吃掉";<b>代价</b>是持续动画(离开视口即暂停)。</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="hm-wrap" data-cascade>
|
<div class="hm-wrap" data-snake>
|
||||||
<HeatGrid weeks={weeks} variant="cascade" step={7} />
|
<HeatGrid weeks={weeks} variant="snake" />
|
||||||
|
</div>
|
||||||
|
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · <button class="hm-btn" type="button" data-snake-replay>重新开始</button></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ G · 心跳呼吸 ═══════════ -->
|
||||||
|
<section id="hg" class="pv-sec">
|
||||||
|
<header class="pv-head">
|
||||||
|
<span class="pv-tag mono">G</span>
|
||||||
|
<h2>心跳呼吸</h2>
|
||||||
|
<p class="pv-pros">每格按当日贡献量呼吸:越忙的日子跳得越快、越亮,静下来时整片缓慢起伏。
|
||||||
|
<b>优点</b>像一张"活的"心电图,无需交互;<b>代价</b>持续动画(纯 CSS,合成层,很省)。</p>
|
||||||
|
</header>
|
||||||
|
<div class="hm-wrap" data-breath>
|
||||||
|
<HeatGrid weeks={weeks} variant="breath" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══════════ B · 光标光晕 ═══════════ -->
|
<!-- ═══════════ H · 雨滴涟漪 ═══════════ -->
|
||||||
<section id="hb" class="pv-sec">
|
<section id="hh" class="pv-sec">
|
||||||
<header class="pv-head">
|
<header class="pv-head">
|
||||||
<span class="pv-tag mono">B</span>
|
<span class="pv-tag mono">H</span>
|
||||||
<h2>光标光晕</h2>
|
<h2>雨滴涟漪</h2>
|
||||||
<p class="pv-pros">鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。
|
<p class="pv-pros">从"最后一次贡献"那一格出发,涟漪一圈圈向外扩散,扫过的格子闪一下再落定。
|
||||||
<b>优点</b>能精确读数、零轮播;<b>代价</b>触屏设备没有指针,需退化为点按读数。</p>
|
<b>优点</b>叙事清晰(现在 → 过去)、只播一次;<b>代价</b>纯装饰。</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="hm-wrap hm-spot" data-spot>
|
<div class="hm-wrap" data-ripple>
|
||||||
<HeatGrid weeks={weeks} variant="spotlight" />
|
<HeatGrid weeks={weeks} variant="ripple" />
|
||||||
<span class="hm-glow" aria-hidden="true"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hm-read mono" data-spot-read>把指针移到格子上 →</p>
|
<p class="hm-read mono"><button class="hm-btn" type="button" data-ripple-replay>再看一次涟漪</button> · 起点 {lastActive}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══════════ C · 年度回放 ═══════════ -->
|
<!-- ═══════════ I · 3D 地形 ═══════════ -->
|
||||||
<section id="hc" class="pv-sec">
|
<section id="hi" class="pv-sec">
|
||||||
<header class="pv-head">
|
<header class="pv-head">
|
||||||
<span class="pv-tag mono">C</span>
|
<span class="pv-tag mono">I</span>
|
||||||
<h2>年度回放</h2>
|
<h2>3D 地形</h2>
|
||||||
<p class="pv-pros">拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。
|
<p class="pv-pros">把贡献量当"海拔":忙的日子凸起来,整块网格随鼠标轻轻倾斜,像俯瞰一年的地貌。
|
||||||
<b>优点</b>最有叙事感、能直观感受节奏;<b>代价</b>多一层交互,需要一点耐心。</p>
|
<b>优点</b>一眼看出密集区、视觉独特;<b>代价</b>倾斜会略降可读性。</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="hm-wrap" data-scrub>
|
<div class="hm-3d" data-terrain>
|
||||||
<HeatGrid weeks={weeks} variant="scrub" />
|
<div class="hm-3d-inner">
|
||||||
</div>
|
<HeatGrid weeks={weeks} variant="terrain" />
|
||||||
<div class="hm-ctrl">
|
|
||||||
<button class="hm-btn" type="button" data-scrub-play>▶ 播放</button>
|
|
||||||
<input class="hm-range" type="range" min="0" max={weeks.length} value={weeks.length} data-scrub-range aria-label="回放进度" />
|
|
||||||
<span class="hm-read mono" data-scrub-read>累计 {total} 次 · 第 {weeks.length} 周</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ═══════════ D · 连续电流 ═══════════ -->
|
|
||||||
<section id="hd" class="pv-sec">
|
|
||||||
<header class="pv-head">
|
|
||||||
<span class="pv-tag mono">D</span>
|
|
||||||
<h2>连续电流</h2>
|
|
||||||
<p class="pv-pros">当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。
|
|
||||||
<b>优点</b>把"坚持"这件事讲成故事;<b>代价</b>只覆盖最近一小段,全年的信息仍靠静态格子。</p>
|
|
||||||
</header>
|
|
||||||
<div class="hm-streak">
|
|
||||||
<div class="hm-streak-num">
|
|
||||||
<strong>{stats.current}</strong>
|
|
||||||
<span class="mono">连续天数{lastActive ? `(截至 ${lastActive})` : ''}</span>
|
|
||||||
<span class="hm-streak-sub mono">最长 {stats.longest} 天 · 单日最高 {stats.best} 次</span>
|
|
||||||
</div>
|
|
||||||
<div class="hm-wrap" data-streak>
|
|
||||||
<HeatGrid weeks={weeks} variant="streak" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══════════ E · 悬停周详情 ═══════════ -->
|
<!-- ═══════════ J · 周冠军柱 ═══════════ -->
|
||||||
<section id="he" class="pv-sec">
|
<section id="hj" class="pv-sec">
|
||||||
<header class="pv-head">
|
<header class="pv-head">
|
||||||
<span class="pv-tag mono">E</span>
|
<span class="pv-tag mono">J</span>
|
||||||
<h2>悬停周详情</h2>
|
<h2>周冠军柱</h2>
|
||||||
<p class="pv-pros">指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。
|
<p class="pv-pros">网格下方按月长出一排柱子,进视口时逐根升起;指针移到柱子上,对应那些格子同时亮起。
|
||||||
<b>优点</b>信息密度最高、动效克制;<b>代价</b>仍是鼠标优先,触屏靠点按。</p>
|
<b>优点</b>把"哪几个月最忙"讲清楚、信息量大;<b>代价</b>多一层图表,页面变高。</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="hm-inspector">
|
<div class="hm-wrap" data-bars>
|
||||||
<div class="hm-wrap" data-insp>
|
<HeatGrid weeks={weeks} variant="bars" />
|
||||||
<HeatGrid weeks={weeks} variant="inspector" />
|
|
||||||
</div>
|
|
||||||
<aside class="hm-week" data-insp-panel>
|
|
||||||
<p class="mono">把指针移到任意一周</p>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul class="hm-bars" data-bars-chart>
|
||||||
|
{months.map(([m, v]) => (
|
||||||
|
<li>
|
||||||
|
<button class="hm-bar-btn" type="button" data-month={m} aria-label={`${m} 共 ${v} 次`}>
|
||||||
|
<span class="hm-bar-fill" style={`--h:${Math.round((v / maxMonth) * 100)}%`} />
|
||||||
|
</button>
|
||||||
|
<span class="hm-bar-label mono">{m.slice(5)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。</p>
|
<!-- ═══════════ K · 显微镜 ═══════════ -->
|
||||||
|
<section id="hk" class="pv-sec">
|
||||||
|
<header class="pv-head">
|
||||||
|
<span class="pv-tag mono">K</span>
|
||||||
|
<h2>显微镜</h2>
|
||||||
|
<p class="pv-pros">指针所在处浮起一枚圆镜,把周围 5×5 的格子放大显示,并标出当天日期与次数。
|
||||||
|
<b>优点</b>小格子也读得清、趣味强;<b>代价</b>触屏需点按。</p>
|
||||||
|
</header>
|
||||||
|
<div class="hm-wrap hm-lens-wrap" data-lens>
|
||||||
|
<HeatGrid weeks={weeks} variant="lens" />
|
||||||
|
<span class="hm-lens" data-lens-glass hidden></span>
|
||||||
|
</div>
|
||||||
|
<p class="hm-read mono" data-lens-read>把指针移到任意格子上</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下六案全部静态。</p>
|
||||||
|
|
||||||
|
<script is:inline type="application/json" id="hm-data" set:html={JSON.stringify(dataForClient)} />
|
||||||
|
<span hidden data-ripple-origin={`${lastActiveCol},${lastActiveRow}`}></span>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ── A · 入场波纹:进视口一次性播放(不进视口不播,省电)──
|
|
||||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
const cascades = document.querySelectorAll<HTMLElement>('[data-cascade]');
|
const raw = document.getElementById('hm-data')?.textContent ?? '{"cells":[],"weeks":0}';
|
||||||
if (!reduce && cascades.length) {
|
const DATA = JSON.parse(raw) as { cells: number[]; weeks: number };
|
||||||
|
const cellsAll = () => [...document.querySelectorAll<HTMLElement>('.hg-c')];
|
||||||
|
|
||||||
|
/** 离开视口即停:省电,也避免滚动时 CPU 抖动 */
|
||||||
|
const whenVisible = (el: Element, onIn: () => void, onOut?: () => void) => {
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const e of entries) {
|
for (const e of entries) (e.isIntersecting ? onIn : onOut)?.();
|
||||||
if (!e.isIntersecting) continue;
|
|
||||||
e.target.classList.add('play');
|
|
||||||
io.unobserve(e.target);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ rootMargin: '0px 0px -10% 0px' },
|
{ threshold: 0.15 },
|
||||||
);
|
);
|
||||||
cascades.forEach((el) => io.observe(el));
|
io.observe(el);
|
||||||
}
|
return io;
|
||||||
|
};
|
||||||
|
|
||||||
// ── B · 光标光晕:只更新两个 CSS 变量,不跑 rAF 循环 ──
|
/* ══════ F · 贪吃蛇 ══════ */
|
||||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-spot]')) {
|
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
|
||||||
const glow = wrap.querySelector<HTMLElement>('.hm-glow');
|
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
||||||
const read = document.querySelector<HTMLElement>('[data-spot-read]');
|
const readEl = document.querySelector<HTMLElement>('[data-snake-read]');
|
||||||
const move = (x: number, y: number) => {
|
// 蛇形路径:按列走,奇数列自下而上(与 GitHub 贡献蛇同构)
|
||||||
const r = wrap.getBoundingClientRect();
|
const path: number[] = [];
|
||||||
if (!glow) return;
|
for (let c = 0; c < DATA.weeks; c++) {
|
||||||
glow.style.setProperty('--x', `${x - r.left}px`);
|
const rows = [0, 1, 2, 3, 4, 5, 6];
|
||||||
glow.style.setProperty('--y', `${y - r.top}px`);
|
if (c % 2 === 1) rows.reverse();
|
||||||
glow.style.opacity = '1';
|
for (const r of rows) path.push(c * 7 + r);
|
||||||
};
|
|
||||||
wrap.addEventListener('pointermove', (e) => move(e.clientX, e.clientY));
|
|
||||||
wrap.addEventListener('pointerleave', () => {
|
|
||||||
if (glow) glow.style.opacity = '0';
|
|
||||||
});
|
|
||||||
// 读数:事件委托到格子(点按在触屏也work)
|
|
||||||
const show = (cell: HTMLElement) => {
|
|
||||||
if (!read) return;
|
|
||||||
read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
|
|
||||||
};
|
|
||||||
wrap.addEventListener('pointerover', (e) => {
|
|
||||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
|
||||||
if (t) show(t);
|
|
||||||
});
|
|
||||||
wrap.addEventListener('click', (e) => {
|
|
||||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
|
||||||
if (t) show(t);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
let step = 0;
|
||||||
// ── C · 年度回放:滑块控制显示到第几周 + 累计数 ──
|
|
||||||
const counts = Array.from(document.querySelectorAll<HTMLElement>('[data-scrub] .hg-c')).reduce<
|
|
||||||
Record<number, number>
|
|
||||||
>((acc, cell) => {
|
|
||||||
const c = Number(cell.dataset.c ?? 0);
|
|
||||||
acc[c] = (acc[c] ?? 0) + Number(cell.dataset.count ?? 0);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const weeksTotal = Object.keys(counts).length;
|
|
||||||
const cum = (upto: number) => {
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < upto; i++) sum += counts[i] ?? 0;
|
|
||||||
return sum;
|
|
||||||
};
|
|
||||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-scrub]')) {
|
|
||||||
const range = document.querySelector<HTMLInputElement>('[data-scrub-range]');
|
|
||||||
const read = document.querySelector<HTMLElement>('[data-scrub-read]');
|
|
||||||
const play = document.querySelector<HTMLButtonElement>('[data-scrub-play]');
|
|
||||||
const apply = (v: number) => {
|
|
||||||
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
|
||||||
const visible = Number(cell.dataset.c ?? 0) < v;
|
|
||||||
cell.style.opacity = visible ? '' : '0.06';
|
|
||||||
}
|
|
||||||
if (read) read.textContent = `累计 ${cum(v)} 次 · 第 ${v} 周`;
|
|
||||||
};
|
|
||||||
range?.addEventListener('input', () => apply(Number(range.value)));
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
play?.addEventListener('click', () => {
|
let eaten = 0;
|
||||||
|
const reset = () => {
|
||||||
|
step = 0;
|
||||||
|
eaten = 0;
|
||||||
|
cells.forEach((c) => c.classList.remove('snake-head', 'snake-eaten', 'snake-passed'));
|
||||||
|
if (readEl) readEl.textContent = '准备出发…';
|
||||||
|
};
|
||||||
|
const tick = () => {
|
||||||
|
const idx = path[step];
|
||||||
|
cells.forEach((c) => c.classList.remove('snake-head'));
|
||||||
|
const cell = cells[idx];
|
||||||
|
if (!cell) return;
|
||||||
|
cell.classList.add('snake-head');
|
||||||
|
// 爬过即留下淡色轨迹:真实数据里 371 格只有 ~51 天有贡献,
|
||||||
|
// 若只在有贡献的格子给反馈,蛇大半程看起来是"空跑"。
|
||||||
|
cell.classList.add('snake-passed');
|
||||||
|
const count = Number(cell.dataset.count ?? 0);
|
||||||
|
if (count > 0) {
|
||||||
|
cell.classList.add('snake-eaten');
|
||||||
|
eaten++;
|
||||||
|
if (readEl) readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
|
||||||
|
}
|
||||||
|
step++;
|
||||||
|
if (step >= path.length) {
|
||||||
|
if (readEl) readEl.textContent = `整年爬完:走过 ${path.length} 格,吃到 ${eaten} 天`;
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
reset();
|
||||||
|
if (reduce) {
|
||||||
|
cells.forEach((c) => {
|
||||||
|
c.classList.add('snake-passed');
|
||||||
|
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
|
||||||
|
});
|
||||||
|
if (readEl) readEl.textContent = '已按减少动态效果静态呈现';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timer) return;
|
||||||
|
timer = window.setInterval(tick, 30);
|
||||||
|
};
|
||||||
|
whenVisible(
|
||||||
|
wrap,
|
||||||
|
() => {
|
||||||
|
if (!timer) start();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = 0;
|
timer = 0;
|
||||||
play.textContent = '▶ 播放';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
play.textContent = '⏸ 暂停';
|
},
|
||||||
if (!range) return;
|
);
|
||||||
range.value = '0';
|
document.querySelector('[data-snake-replay]')?.addEventListener('click', () => {
|
||||||
apply(0);
|
if (timer) clearInterval(timer);
|
||||||
timer = window.setInterval(() => {
|
|
||||||
if (!range) return;
|
|
||||||
const next = Math.min(weeksTotal, Number(range.value) + 2);
|
|
||||||
range.value = String(next);
|
|
||||||
apply(next);
|
|
||||||
if (next >= weeksTotal) {
|
|
||||||
clearInterval(timer);
|
|
||||||
timer = 0;
|
timer = 0;
|
||||||
play.textContent = '▶ 播放';
|
start();
|
||||||
}
|
|
||||||
}, 60);
|
|
||||||
});
|
});
|
||||||
apply(Number(range?.value ?? weeksTotal));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── D · 连续电流:最近连续段逐格流光(延迟按位置递增)──
|
/* ══════ G · 心跳呼吸(纯 CSS 驱动,只按数据写变量)══════ */
|
||||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-streak]')) {
|
for (const wrap of document.querySelectorAll<HTMLElement>('[data-breath]')) {
|
||||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')].filter((c) => Number(c.dataset.count ?? 0) > 0);
|
const max = Math.max(...DATA.cells, 1);
|
||||||
const tail = cells.slice(-12);
|
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
||||||
tail.forEach((c, i) => {
|
const n = Number(cell.dataset.count ?? 0);
|
||||||
c.style.setProperty('--flow', `${i * 90}ms`);
|
if (n <= 0) continue;
|
||||||
c.classList.add('hg-flow');
|
// 越忙跳得越快(时长 3.4s → 1.2s),幅度略随强度增大
|
||||||
});
|
const dur = 3.4 - (n / max) * 2.2;
|
||||||
wrap.classList.toggle('no-flow', reduce);
|
cell.style.setProperty('--breath', `${dur.toFixed(2)}s`);
|
||||||
|
cell.classList.add('hg-breath');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── E · 悬停周详情:抬列 + 面板读数 ──
|
/* ══════ H · 雨滴涟漪 ══════ */
|
||||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-insp]')) {
|
for (const wrap of document.querySelectorAll<HTMLElement>('[data-ripple]')) {
|
||||||
const panel = document.querySelector<HTMLElement>('[data-insp-panel]');
|
const origin = document.querySelector('[data-ripple-origin]')?.getAttribute('data-ripple-origin') ?? '0,0';
|
||||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
const [oc, or] = origin.split(',').map(Number);
|
||||||
const byWeek = new Map<number, HTMLElement[]>();
|
const play = () => {
|
||||||
for (const c of cells) {
|
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
||||||
const w = Number(c.dataset.c ?? 0);
|
const c = Number(cell.dataset.c ?? 0);
|
||||||
(byWeek.get(w) ?? byWeek.set(w, []).get(w)!).push(c);
|
const r = Number(cell.dataset.r ?? 0);
|
||||||
}
|
// 以"最后一次贡献"为圆心,按列/行距离给错峰延迟
|
||||||
const showWeek = (w: number) => {
|
const dist = Math.hypot(c - oc, (r - or) * 1.6);
|
||||||
wrap.querySelectorAll('.hg-c.lift').forEach((c) => c.classList.remove('lift'));
|
cell.style.setProperty('--wave', `${Math.round(dist * 26)}ms`);
|
||||||
const group = byWeek.get(w);
|
|
||||||
if (!group) return;
|
|
||||||
group.forEach((c) => c.classList.add('lift'));
|
|
||||||
const totalWeek = group.reduce((s, c) => s + Number(c.dataset.count ?? 0), 0);
|
|
||||||
const best = group.reduce((a, b) => (Number(b.dataset.count ?? 0) > Number(a.dataset.count ?? 0) ? b : a));
|
|
||||||
const first = group[0]?.dataset.date ?? '';
|
|
||||||
const last = group[group.length - 1]?.dataset.date ?? '';
|
|
||||||
if (panel) {
|
|
||||||
panel.innerHTML =
|
|
||||||
`<p class="mono">${first} → ${last}</p>` +
|
|
||||||
`<p class="hm-week-total"><strong>${totalWeek}</strong> 次</p>` +
|
|
||||||
`<p class="mono hm-week-best">最好的一天 ${best.dataset.date} · ${best.dataset.count} 次</p>`;
|
|
||||||
}
|
}
|
||||||
|
wrap.classList.remove('play');
|
||||||
|
void wrap.offsetWidth; // 强制回流,让动画重播
|
||||||
|
wrap.classList.add('play');
|
||||||
};
|
};
|
||||||
wrap.addEventListener('pointerover', (e) => {
|
whenVisible(wrap, () => {
|
||||||
|
if (!reduce) play();
|
||||||
|
});
|
||||||
|
document.querySelector('[data-ripple-replay]')?.addEventListener('click', () => {
|
||||||
|
if (!reduce) play();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════ I · 3D 地形 ══════ */
|
||||||
|
for (const wrap of document.querySelectorAll<HTMLElement>('[data-terrain]')) {
|
||||||
|
const max = Math.max(...DATA.cells, 1);
|
||||||
|
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
||||||
|
const n = Number(cell.dataset.count ?? 0);
|
||||||
|
cell.style.setProperty('--z', `${((n / max) * 14).toFixed(1)}px`);
|
||||||
|
if (n > 0) cell.classList.add('hg-raise');
|
||||||
|
}
|
||||||
|
if (reduce) continue;
|
||||||
|
wrap.addEventListener('pointermove', (e) => {
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
const dx = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const dy = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
wrap.style.setProperty('--rx', `${(-dy * 10).toFixed(2)}deg`);
|
||||||
|
wrap.style.setProperty('--ry', `${(dx * 12).toFixed(2)}deg`);
|
||||||
|
});
|
||||||
|
wrap.addEventListener('pointerleave', () => {
|
||||||
|
wrap.style.setProperty('--rx', '0deg');
|
||||||
|
wrap.style.setProperty('--ry', '0deg');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════ J · 周冠军柱 ══════ */
|
||||||
|
for (const chart of document.querySelectorAll<HTMLElement>('[data-bars-chart]')) {
|
||||||
|
whenVisible(chart, () => chart.classList.add('grow'));
|
||||||
|
const grid = document.querySelector<HTMLElement>('[data-bars]');
|
||||||
|
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) {
|
||||||
|
const m = btn.dataset.month ?? '';
|
||||||
|
btn.addEventListener('pointerenter', () => {
|
||||||
|
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => {
|
||||||
|
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
btn.addEventListener('pointerleave', () => {
|
||||||
|
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month'));
|
||||||
|
});
|
||||||
|
btn.addEventListener('focus', () => btn.dispatchEvent(new Event('pointerenter')));
|
||||||
|
btn.addEventListener('blur', () => btn.dispatchEvent(new Event('pointerleave')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════ K · 显微镜 ══════ */
|
||||||
|
for (const wrap of document.querySelectorAll<HTMLElement>('[data-lens]')) {
|
||||||
|
const glass = wrap.querySelector<HTMLElement>('[data-lens-glass]');
|
||||||
|
const read = document.querySelector<HTMLElement>('[data-lens-read]');
|
||||||
|
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
||||||
|
const show = (cell: HTMLElement) => {
|
||||||
|
const c = Number(cell.dataset.c ?? 0);
|
||||||
|
const r = Number(cell.dataset.r ?? 0);
|
||||||
|
if (!glass) return;
|
||||||
|
glass.hidden = false;
|
||||||
|
// 5×5 邻域放大:用 CSS 变量把每格的"档位"喂给镜内的 25 个小格
|
||||||
|
let mini = '';
|
||||||
|
for (let dr = -2; dr <= 2; dr++) {
|
||||||
|
for (let dc = -2; dc <= 2; dc++) {
|
||||||
|
const idx = (c + dc) * 7 + (r + dr);
|
||||||
|
const n = data(idx);
|
||||||
|
mini += `<i style="--v:${Math.min(n, 8)}"></i>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glass.innerHTML = `<span class="hm-lens-grid">${mini}</span><span class="hm-lens-tip mono">${cell.dataset.date} · ${cell.dataset.count} 次</span>`;
|
||||||
|
const box = wrap.getBoundingClientRect();
|
||||||
|
const cb = cell.getBoundingClientRect();
|
||||||
|
glass.style.setProperty('--lx', `${cb.left - box.left + cb.width / 2}px`);
|
||||||
|
glass.style.setProperty('--ly', `${cb.top - box.top + cb.height / 2}px`);
|
||||||
|
if (read) read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
|
||||||
|
cells.forEach((x) => x.classList.remove('hg-focus'));
|
||||||
|
cell.classList.add('hg-focus');
|
||||||
|
void r;
|
||||||
|
};
|
||||||
|
const data = (idx: number) => (idx >= 0 && idx < DATA.cells.length ? DATA.cells[idx] : 0);
|
||||||
|
wrap.addEventListener('pointermove', (e) => {
|
||||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
if (t) show(t);
|
||||||
|
});
|
||||||
|
wrap.addEventListener('pointerleave', () => {
|
||||||
|
if (glass) glass.hidden = true;
|
||||||
|
cells.forEach((x) => x.classList.remove('hg-focus'));
|
||||||
});
|
});
|
||||||
wrap.addEventListener('click', (e) => {
|
wrap.addEventListener('click', (e) => {
|
||||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
if (t) show(t);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -321,80 +410,107 @@ const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: be
|
|||||||
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
|
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
|
||||||
.pv-pros b { color: var(--ink-2); }
|
.pv-pros b { color: var(--ink-2); }
|
||||||
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
|
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
|
||||||
|
.hm-wrap { overflow-x: auto; padding: 6px 0; }
|
||||||
.hm-wrap { overflow-x: auto; padding: 4px 0; }
|
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
/* ── A:入场波纹 ── */
|
|
||||||
/* 注意:网格是子组件渲染的,页面级 scoped 样式够不到它内部元素,必须 :global() */
|
|
||||||
[data-cascade] :global(.hg-c) { opacity: 0.18; }
|
|
||||||
[data-cascade].play :global(.hg-c) {
|
|
||||||
animation: hmRise 420ms cubic-bezier(0.22, 0.8, 0.3, 1) var(--d) forwards;
|
|
||||||
}
|
|
||||||
@keyframes hmRise {
|
|
||||||
from { opacity: 0.18; transform: translateY(3px) scale(0.86); }
|
|
||||||
to { opacity: 1; transform: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── B:光标光晕 ── */
|
|
||||||
.hm-spot { position: relative; isolation: isolate; }
|
|
||||||
.hm-glow {
|
|
||||||
position: absolute; inset: 0; pointer-events: none; opacity: 0;
|
|
||||||
transition: opacity 200ms var(--ease);
|
|
||||||
background: radial-gradient(90px 90px at var(--x, 50%) var(--y, 50%),
|
|
||||||
color-mix(in oklab, var(--accent) 26%, transparent), transparent 70%);
|
|
||||||
mix-blend-mode: multiply;
|
|
||||||
}
|
|
||||||
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
|
|
||||||
|
|
||||||
/* ── C:年度回放 ── */
|
|
||||||
.hm-ctrl { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); flex-wrap: wrap; }
|
|
||||||
.hm-btn {
|
.hm-btn {
|
||||||
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
|
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
|
||||||
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer;
|
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.hm-btn:hover { border-color: var(--accent); }
|
.hm-btn:hover { border-color: var(--accent); }
|
||||||
.hm-range { flex: 1; min-width: 160px; accent-color: var(--accent); }
|
|
||||||
|
|
||||||
/* ── D:连续电流 ── */
|
/* ── F 蛇:头 + 吃过 ── */
|
||||||
.hm-streak { display: flex; flex-direction: column; gap: var(--space-3); }
|
:global(.snake-head) {
|
||||||
.hm-streak-num { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
|
outline: 2px solid var(--accent);
|
||||||
.hm-streak-num strong { font-size: 34px; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
|
outline-offset: 1px;
|
||||||
.hm-streak-num span { font-size: 12.5px; color: var(--ink-3); }
|
z-index: 1;
|
||||||
.hm-streak-sub { color: var(--ink-4) !important; }
|
position: relative;
|
||||||
:global(.hg-flow) { position: relative; }
|
|
||||||
:global(.hg-flow)::after {
|
|
||||||
content: ''; position: absolute; inset: 0; border-radius: 2px;
|
|
||||||
background: var(--accent); opacity: 0;
|
|
||||||
animation: hmFlow 1.6s ease-in-out var(--flow, 0ms) infinite;
|
|
||||||
}
|
}
|
||||||
@keyframes hmFlow {
|
:global(.snake-passed) {
|
||||||
0%, 100% { opacity: 0; }
|
background: color-mix(in oklab, var(--accent) 16%, transparent);
|
||||||
45% { opacity: 0.55; }
|
transition: background 220ms var(--ease);
|
||||||
|
}
|
||||||
|
:global(.snake-eaten) {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
|
||||||
|
transform: scale(1.12);
|
||||||
|
transition: background 180ms var(--ease), transform 180ms var(--ease);
|
||||||
}
|
}
|
||||||
.no-flow :global(.hg-flow)::after { animation: none; }
|
|
||||||
|
|
||||||
/* ── E:悬停周详情 ── */
|
/* ── G 呼吸 ── */
|
||||||
.hm-inspector { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; }
|
:global(.hg-breath) { animation: hmBreath var(--breath, 2.6s) ease-in-out infinite; }
|
||||||
.hm-inspector .hm-wrap { flex: 1 1 560px; min-width: 0; }
|
@keyframes hmBreath {
|
||||||
:global(.hg-c.lift) {
|
0%, 100% { opacity: 0.55; transform: scale(0.94); }
|
||||||
transform: translateY(-2px);
|
50% { opacity: 1; transform: scale(1.06); }
|
||||||
outline: 1px solid color-mix(in oklab, var(--accent) 45%, transparent);
|
|
||||||
transition: transform 140ms var(--ease);
|
|
||||||
}
|
}
|
||||||
.hm-week {
|
|
||||||
flex: 0 0 190px; border: 1px solid var(--line); border-radius: 8px;
|
/* ── H 涟漪 ── */
|
||||||
padding: var(--space-3); background: var(--bg-soft); font-size: 12.5px; color: var(--ink-3);
|
.play :global(.hg-c) { animation: hmWave 700ms ease-out var(--wave, 0ms) both; }
|
||||||
|
@keyframes hmWave {
|
||||||
|
0% { transform: scale(0.7); filter: brightness(1.6); }
|
||||||
|
60% { transform: scale(1.12); }
|
||||||
|
100% { transform: none; filter: none; }
|
||||||
}
|
}
|
||||||
.hm-week p { margin: 0 0 4px; }
|
|
||||||
.hm-week-total strong { font-size: 22px; color: var(--ink-1); font-variant-numeric: tabular-nums; }
|
/* ── I 3D 地形 ── */
|
||||||
.hm-week-best { color: var(--ink-4); }
|
.hm-3d { perspective: 900px; padding: var(--space-3) 0; overflow-x: auto; }
|
||||||
|
.hm-3d-inner {
|
||||||
|
transform: rotateX(var(--rx, 6deg)) rotateY(var(--ry, 0deg));
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 160ms var(--ease);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
:global(.hg-raise) {
|
||||||
|
transform: translateZ(var(--z, 0px));
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── J 周冠军柱 ── */
|
||||||
|
.hm-bars {
|
||||||
|
display: flex; align-items: flex-end; gap: 6px; list-style: none;
|
||||||
|
padding: 0; margin: var(--space-3) 0 0; height: 92px;
|
||||||
|
}
|
||||||
|
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; }
|
||||||
|
.hm-bar-btn {
|
||||||
|
width: 100%; height: 100%; display: flex; align-items: flex-end;
|
||||||
|
background: none; border: 0; padding: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.hm-bar-fill {
|
||||||
|
display: block; width: 100%; height: 0;
|
||||||
|
background: var(--ink-4); border-radius: 2px 2px 0 0;
|
||||||
|
transition: height 520ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
|
||||||
|
}
|
||||||
|
.grow .hm-bar-fill { height: var(--h, 0%); }
|
||||||
|
.hm-bar-btn:hover .hm-bar-fill, .hm-bar-btn:focus-visible .hm-bar-fill { background: var(--accent); }
|
||||||
|
.hm-bar-label { font-size: 10.5px; color: var(--ink-4); }
|
||||||
|
:global(.hg-month) { background: var(--accent) !important; }
|
||||||
|
|
||||||
|
/* ── K 显微镜 ── */
|
||||||
|
.hm-lens-wrap { position: relative; }
|
||||||
|
.hm-lens {
|
||||||
|
position: absolute; left: var(--lx, 0); top: var(--ly, 0); translate: -50% -50%;
|
||||||
|
width: 132px; height: 132px; border-radius: 50%;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line));
|
||||||
|
background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
pointer-events: none; z-index: 4;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.hm-lens-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(5, 14px); grid-template-rows: repeat(5, 14px); gap: 3px;
|
||||||
|
}
|
||||||
|
.hm-lens-grid i {
|
||||||
|
width: 14px; height: 14px; border-radius: 2px;
|
||||||
|
background: color-mix(in oklab, var(--ink-1) calc(var(--v) * 11%), transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 80%, transparent);
|
||||||
|
}
|
||||||
|
.hm-lens-tip { font-size: 10px; color: var(--ink-3); }
|
||||||
|
:global(.hg-focus) { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
[data-cascade].play :global(.hg-c) { animation: none; opacity: 1; }
|
:global(.hg-breath) { animation: none; opacity: 1; }
|
||||||
:global(.hg-flow)::after { animation: none; }
|
.play :global(.hg-c) { animation: none; }
|
||||||
.hm-glow { display: none; }
|
.hm-3d-inner { transform: none !important; transition: none; }
|
||||||
}
|
.grow .hm-bar-fill { transition: none; }
|
||||||
@media (max-width: 620px) {
|
|
||||||
.hm-week { flex-basis: 100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user