feat(heatmap): 蛇家族四变体 + 补齐后台表单(5j 预览 v3)

用户选定 F+J 方向,要求蛇能**循环**(学 GitHub README 蛇的节奏),并再看几个新变体。

四个蛇变体:
- F1 循环贪吃蛇:蛇形爬一年,吃到有贡献的日子点亮;到端点停一拍、清空、重新开始,
  无限循环,身体保持 6 节(README 蛇手感)
- F2 生长蛇:每吃到一天长一节(3 → 26 上限),吃完循环重置——"积累"的隐喻最强
- F3 双蛇对开:两条蛇从最早/最新一头相向而吃,在年中交汇
- FJ 蛇 + 月柱联动:蛇爬进某个月,对应柱子同步升起并高亮;爬到年末十二根全立,
  然后回到起点、柱子清零、再来一轮(用户点名要的组合)
- J 单独保留月度柱状图作参考

实现要点:共用一套蛇引擎(蛇形路径 = 按列推进、奇数列反向;轨迹队列控制身长;
到端点判定"本轮结束"→ 900ms 后 reset 并重入);IntersectionObserver 离开视口即停;
「减少动态效果」下直接呈终态(点亮所有有贡献的格子 + 柱子立起)。

**后台补齐(回答用户"改动同步到后台了吗"——此前确实漏了两处)**:
- 开源页新增「叙述(推荐改这里)」story 字段(双语 markdown,链接内联),
  旧 blurb 标注 legacy
- 新增「瞬间 / 书架 标题下的说明」文件条目:文字(markdown)+ 字号(11–16)
  + 颜色(ink 灰阶四级)+ 字体(12 族下拉),两页独立

验证: 构建 52 页 exit 0;config-check 231 字段 / 73 hint 全过;
      Playwright 实测——F1 身长 6 且 loop 计数 1→2、第 1 轮吃到 51 天;
      F2 身长 3→26、吃掉 38 天;F3 同时 2 个蛇头;FJ 柱子随爬行递增点亮(3→5→7→10);
      J 柱子升起并悬停联动

Spec: 2026-11-03_blog-5j-content-refine (预览 v3)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-04 21:27:12 +08:00
1 parent d9721cdbd8
commit 34a78dacb9
2 files changed
+318 -341

No files matched your search

+77 -2
View File
@@ -143,6 +143,73 @@ collections:
- name: site_pages - name: site_pages
label: 页面文案 label: 页面文案
files: files:
- name: pages
label: 瞬间 / 书架 标题下的说明
file: content/site/pages.yaml
format: yaml
hint: 这两页大标题下面那一行字,文字与样式都在这里改
fields:
- label: 瞬间页
name: moments
widget: object
required: false
fields:
- label: 说明文字
name: note
widget: object
required: false
hint: 支持 markdown 与排版类(字体/颜色)
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 样式
name: style
widget: object
required: false
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
fields:
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
- label: 颜色
name: color
widget: select
required: false
hint: 只开放灰阶层级,避免正文被染色
options: [ink-1, ink-2, ink-3, ink-4]
- label: 字体
name: font
widget: select
required: false
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
- label: 书架页
name: books
widget: object
required: false
fields:
- label: 说明文字
name: note
widget: object
required: false
hint: 支持 markdown 与排版类(字体/颜色)
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 样式
name: style
widget: object
required: false
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
fields:
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
- label: 颜色
name: color
widget: select
required: false
options: [ink-1, ink-2, ink-3, ink-4]
- label: 字体
name: font
widget: select
required: false
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
- name: home - name: home
label: 主页设置 label: 主页设置
file: content/site/home.yaml file: content/site/home.yaml
@@ -544,11 +611,19 @@ collections:
fields: fields:
- { label: 中文, name: zh, widget: string } - { label: 中文, name: zh, widget: string }
- { label: English, name: en, widget: string } - { label: English, name: en, widget: string }
- label: 叙述(链接前) - label: 叙述(推荐改这里)
name: story
widget: object
required: false
hint: '整段叙述写成一个字段即可(markdown + 排版类),链接直接写在句子里:[本站源码](https://git.wait4x.online/wait4x/blog) —— 这样就不用再分"链接前/链接后"。留空时才会退回下面的旧字段拼接'
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 叙述(旧:链接前)
name: blurb name: blurb
widget: object widget: object
required: false required: false
hint: 富文本(markdown + 排版类),渲染在仓库链接之前 hint: 'legacy:仅当上面的「叙述」为空时才使用(历史字段,为把仓库链接夹在中间而拆成两半)'
fields: fields:
- { label: 中文, name: zh, widget: string, required: false } - { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false } - { label: 英文, name: en, widget: string, required: false }
+241 -339
View File
@@ -1,10 +1,16 @@
--- ---
/** /**
* 热力图动效方案预览 v2(Phase 5j)——用户看过 v1 五案后觉得不够,要求「类似贪吃蛇」的 * 热力图动效方案 v3(Phase 5j)——用户选定方向「F+J」,但要求贪吃蛇能**循环**,
* 方案并再深入设计 4–6 个。本页给 6 个更深入的方案(F–K),v1 的 A–E 已下线。 * 并希望再看几个新变体。故本页给四个蛇家族方案 + J 柱状图参考:
* *
* 设计红线(六案都守):灰阶骨架、accent 只做高亮不做数据编码(数据仍由 ink 四档承载)、 * F1 循环贪吃蛇(GitHub README 蛇的循环逻辑)
* prefers-reduced-motion 一律静态、零第三方依赖、离开视口即停(省电)。 * F2 生长蛇(吃得越多身体越长)
* F3 双蛇对开(两头同时吃,交汇于年中)
* FJ 蛇 + 月度柱联动(用户要的 F+J 组合)
* J 月度柱状图(单独保留作参考)
*
* 设计红线:accent 只做高亮不参与数据编码(数据密度仍由 ink 四档承载)、
* prefers-reduced-motion 静态化、离开视口即停、零第三方依赖。
*/ */
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';
@@ -14,7 +20,7 @@ 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);
/* ── 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数)── */ /* 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数) */
const countOf = new Map(days.map((d) => [d.date, d.count])); const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10); const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? ''; const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
@@ -34,9 +40,8 @@ for (const d of days) {
run = d.count > 0 ? run + 1 : 0; run = d.count > 0 ? run + 1 : 0;
if (run > longest) longest = run; if (run > longest) longest = run;
} }
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
/* ── 月度聚合(J 方案用)── */ /* 月度聚合(J / FJ 用) */
const byMonth = new Map<string, number>(); const byMonth = new Map<string, number>();
for (const d of days) { for (const d of days) {
const key = d.date.slice(0, 7); const key = d.date.slice(0, 7);
@@ -44,102 +49,103 @@ for (const d of days) {
} }
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)); const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
const maxMonth = Math.max(...months.map(([, v]) => v)); const maxMonth = Math.max(...months.map(([, v]) => v));
const activeDays = days.filter((d) => d.count > 0).length;
/* ── 最后活跃日的坐标(H 涟漪终点起点)── */ const stats = { total, current, longest, activeDays };
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="热力图动效方案 v2 · wait4x" description="六个更深入的热力图动效方案"> <BaseLayout title="热力图动效方案 v3 · wait4x" description="贪吃蛇的四个循环变体 + 月度柱状图">
<h1 class="pv-title">热力图:六个更深入的方案</h1> <h1 class="pv-title">热力图:蛇的四个变体</h1>
<p class="pv-note"> <p class="pv-note">
同一份真实数据(近一年 <strong>{stats.total}</strong> 次贡献 · 当前连续 按你的选择做成 <strong>F + J</strong> 方向,并让蛇<strong>循环</strong>爬(学 GitHub README 蛇的
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天 · 单日最高 {stats.best} 次), 节奏:吃完一轮 → 停顿 → 清空 → 再来)。真实数据:近一年 <strong>{stats.total}</strong> 次贡献 ·
数据更新于 {buildTime}。 {stats.activeDays} 天有贡献 · 当前连续 {stats.current} 天 · 数据更新于 {buildTime}。
<br /> <br />
上一版的五个方案已按你的反馈下线;这一版六个都往「有生命、有叙事」的方向推。 <strong>回字母告诉我选哪个</strong>(F1 / F2 / F3 / FJ / J),也可以说「FJ 但循环慢一点」。
<strong>回字母告诉我选哪个</strong>(也可以说「F 但慢一点」「F + J 组合」)。
</p> </p>
<nav class="pv-switch" aria-label="方案跳转"> <nav class="pv-switch" aria-label="方案跳转">
<a href="#hf" class="pv-chip">F · 贪吃蛇</a> <a href="#f1" class="pv-chip">F1 · 循环贪吃蛇</a>
<a href="#hg" class="pv-chip">G · 心跳呼吸</a> <a href="#f2" class="pv-chip">F2 · 生长蛇</a>
<a href="#hh" class="pv-chip">H · 雨滴涟漪</a> <a href="#f3" class="pv-chip">F3 · 双蛇对开</a>
<a href="#hi" class="pv-chip">I · 3D 地形</a> <a href="#fj" class="pv-chip">FJ · 蛇 + 柱联动</a>
<a href="#hj" class="pv-chip">J · 周冠军柱</a> <a href="#j" class="pv-chip">J · 月度柱</a>
<a href="#hk" class="pv-chip">K · 显微镜</a>
</nav> </nav>
<!-- ═══════════ F · 贪吃蛇 ═══════════ --> <!-- ══════ F1 · 循环贪吃蛇 ══════ -->
<section id="hf" class="pv-sec"> <section id="f1" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">F</span> <span class="pv-tag mono">F1</span>
<h2>贪吃蛇</h2> <h2>循环贪吃蛇</h2>
<p class="pv-pros">一条蛇沿着贡献格蛇形爬过:吃到有贡献的格子就把它们点亮并"长"一点,空格直接穿过。 <p class="pv-pros">蛇沿格子蛇形爬一年,吃到有贡献的日子就点亮;爬到尽头停一拍、清空、重新开始,
<b>优点</b>最有生命感、天然表达"这一年被一点点吃掉";<b>代价</b>是持续动画(离开视口即暂停)。</p> <b>无限循环</b>。身体保持 6 格长度(GitHub README 蛇手感)。</p>
</header> </header>
<div class="hm-wrap" data-snake> <div class="hm-wrap" data-snake data-body={6}>
<HeatGrid weeks={weeks} variant="snake" /> <HeatGrid weeks={weeks} variant="f1" />
</div> </div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · <button class="hm-btn" type="button" data-snake-replay>重新开始</button></p> <p class="hm-read mono"><span data-snake-read>准备出发…</span> · 第 <span data-snake-loop>1</span> 轮 ·
<button class="hm-btn" type="button" data-snake-toggle>暂停</button></p>
</section> </section>
<!-- ═══════════ G · 心跳呼吸 ═══════════ --> <!-- ══════ F2 · 生长蛇 ══════ -->
<section id="hg" class="pv-sec"> <section id="f2" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">G</span> <span class="pv-tag mono">F2</span>
<h2>心跳呼吸</h2> <h2>生长蛇</h2>
<p class="pv-pros">每格按当日贡献量呼吸:越忙的日子跳得越快、越亮,静下来时整片缓慢起伏。 <p class="pv-pros">每吃到一天就长一节(最长 26 节),前半程是细蛇、后半程拖成长尾,吃完循环重置。
<b>优点</b>像一张"活的"心电图,无需交互;<b>代价</b>持续动画(纯 CSS,合成层,很省)。</p> <b>优点</b>"积累"的隐喻最强;<b>代价</b>尾长时略挡格子。</p>
</header> </header>
<div class="hm-wrap" data-breath> <div class="hm-wrap" data-snake data-body={3} data-grow={26}>
<HeatGrid weeks={weeks} variant="breath" /> <HeatGrid weeks={weeks} variant="f2" />
</div> </div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 蛇长 <span data-snake-len>3</span> 节</p>
</section> </section>
<!-- ═══════════ H · 雨滴涟漪 ═══════════ --> <!-- ══════ F3 · 双蛇对开 ══════ -->
<section id="hh" class="pv-sec"> <section id="f3" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">H</span> <span class="pv-tag mono">F3</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" data-ripple> <div class="hm-wrap" data-snake data-body={5} data-dual="1">
<HeatGrid weeks={weeks} variant="ripple" /> <HeatGrid weeks={weeks} variant="f3" />
</div> </div>
<p class="hm-read mono"><button class="hm-btn" type="button" data-ripple-replay>再看一次涟漪</button> · 起点 {lastActive}</p> <p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section> </section>
<!-- ═══════════ I · 3D 地形 ═══════════ --> <!-- ══════ FJ · 蛇 + 月柱联动 ══════ -->
<section id="hi" class="pv-sec"> <section id="fj" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">I</span> <span class="pv-tag mono">FJ</span>
<h2>3D 地形</h2> <h2>蛇 + 月度柱联动 <span class="pv-rec">你的选择方向</span></h2>
<p class="pv-pros">把贡献量当"海拔":忙的日子凸起来,整块网格随鼠标轻轻倾斜,像俯瞰一年的地貌。 <p class="pv-pros">蛇每爬进一个月,对应的柱子就同步升起并高亮;爬到年末时十二根柱子全部立起,
<b>优点</b>一眼看出密集区、视觉独特;<b>代价</b>倾斜会略降可读性。</p> 然后蛇回到起点、柱子清零、再来一轮。<b>优点</b>格子与柱状图互相解释,信息与动感兼得。</p>
</header> </header>
<div class="hm-3d" data-terrain> <div class="hm-wrap" data-snake data-body={6} data-bars-link="1">
<div class="hm-3d-inner"> <HeatGrid weeks={weeks} variant="fj" />
<HeatGrid weeks={weeks} variant="terrain" />
</div>
</div> </div>
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
<li>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section> </section>
<!-- ═══════════ J · 周冠军柱 ═══════════ --> <!-- ══════ J · 月度柱(单独参考)══════ -->
<section id="hj" class="pv-sec"> <section id="j" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">J</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>可单独用,也可像 FJ 那样与蛇联动。</b></p>
</header> </header>
<div class="hm-wrap" data-bars> <div class="hm-wrap" data-bars>
<HeatGrid weeks={weeks} variant="bars" /> <HeatGrid weeks={weeks} variant="j" />
</div> </div>
<ul class="hm-bars" data-bars-chart> <ul class="hm-bars" data-bars-chart>
{months.map(([m, v]) => ( {months.map(([m, v]) => (
@@ -153,240 +159,192 @@ const dataForClient = { cells: days.map((d) => d.count), weeks: weeks.length };
</ul> </ul>
</section> </section>
<!-- ═══════════ K · 显微镜 ═══════════ --> <p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下全部静态;离开视口自动暂停。</p>
<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> <span hidden data-months-map={JSON.stringify(months.map(([m]) => m))} data-weeks={weeks.length}></span>
<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>
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const raw = document.getElementById('hm-data')?.textContent ?? '{"cells":[],"weeks":0}'; const weeksN = Number(document.querySelector<HTMLElement>('[data-weeks]')?.dataset.weeks ?? 53);
const DATA = JSON.parse(raw) as { cells: number[]; weeks: number }; const monthKeys: string[] = JSON.parse(
const cellsAll = () => [...document.querySelectorAll<HTMLElement>('.hg-c')]; document.querySelector<HTMLElement>('[data-months-map]')?.dataset.monthsMap ?? '[]',
/** 离开视口即停:省电,也避免滚动时 CPU 抖动 */
const whenVisible = (el: Element, onIn: () => void, onOut?: () => void) => {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) (e.isIntersecting ? onIn : onOut)?.();
},
{ threshold: 0.15 },
); );
io.observe(el);
return io;
};
/* ══════ F · 贪吃蛇 ══════ */ /** 蛇形路径:按列推进,奇数列自下而上(与 GitHub README 蛇同构) */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) { const buildPath = () => {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')]; const p: number[] = [];
const readEl = document.querySelector<HTMLElement>('[data-snake-read]'); for (let c = 0; c < weeksN; c++) {
// 蛇形路径:按列走,奇数列自下而上(与 GitHub 贡献蛇同构)
const path: number[] = [];
for (let c = 0; c < DATA.weeks; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6]; const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse(); if (c % 2 === 1) rows.reverse();
for (const r of rows) path.push(c * 7 + r); for (const r of rows) p.push(c * 7 + r);
} }
let step = 0; return p;
let timer = 0;
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 PATH = buildPath();
const idx = path[step];
cells.forEach((c) => c.classList.remove('snake-head')); for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const readEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-read]');
const loopEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-loop]');
const lenEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-len]');
const toggle = wrap.parentElement?.querySelector<HTMLButtonElement>('[data-snake-toggle]');
const barsEl = wrap.parentElement?.querySelector<HTMLElement>('[data-months]');
const bars = barsEl ? [...barsEl.querySelectorAll<HTMLElement>('[data-month-idx]')] : [];
const baseBody = Number(wrap.dataset.body ?? 6);
const growTo = Number(wrap.dataset.grow ?? 0);
const dual = wrap.dataset.dual === '1';
const barsLink = wrap.dataset.barsLink === '1';
let body = baseBody;
let eaten = 0;
let loopNo = 1;
let timer = 0;
let paused = false;
// 双蛇:两条独立游标,反向推进
const cursors = dual ? [0, PATH.length - 1] : [0];
const dirs = dual ? [1, -1] : [1];
const trails: number[][] = cursors.map(() => []);
const paint = () => {
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
trails.forEach((t) => t.forEach((i) => cells[i]?.classList.add('snake-body')));
cursors.forEach((cur, si) => {
const cell = cells[PATH[cur]];
if (cell) cell.classList.add('snake-head');
const e = trails[si];
if (e) e[e.length - 1] = PATH[cur];
void cur;
});
};
const reset = (full: boolean) => {
eaten = 0;
body = baseBody;
cursors.forEach((_, i) => (cursors[i] = i === 0 ? 0 : PATH.length - 1));
trails.forEach((t) => (t.length = 0));
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
if (full) bars.forEach((b) => b.classList.remove('on'));
if (lenEl) lenEl.textContent = String(body);
if (loopEl) loopEl.textContent = String(loopNo);
};
const step = () => {
let moved = false;
cursors.forEach((cur, si) => {
const idx = PATH[cur];
if (idx === undefined) return;
moved = true;
const cell = cells[idx]; const cell = cells[idx];
if (!cell) return; if (!cell) return;
cell.classList.add('snake-head'); // 轨迹队列:保持 body 长度
// 爬过即留下淡色轨迹:真实数据里 371 格只有 ~51 天有贡献, const trail = trails[si];
// 若只在有贡献的格子给反馈,蛇大半程看起来是"空跑"。 trail.push(idx);
cell.classList.add('snake-passed'); if (trail.length > body) {
const dropped = trail.shift();
cells[dropped ?? -1]?.classList.remove('snake-body');
}
cells.forEach((c) => c.classList.remove('snake-head'));
cursors.forEach((c2) => cells[PATH[c2]]?.classList.add('snake-head'));
cells[idx].classList.add('snake-body');
const count = Number(cell.dataset.count ?? 0); const count = Number(cell.dataset.count ?? 0);
if (count > 0) { if (count > 0) {
cell.classList.add('snake-eaten'); cell.classList.add('snake-eaten');
eaten++; eaten++;
if (readEl) readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`; if (growTo) body = Math.min(growTo, body + 1);
if (readEl)
readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
} }
step++; // 月份联动:柱子按当前列所属月份点亮
if (step >= path.length) { if (barsLink && bars.length) {
if (readEl) readEl.textContent = `整年爬完:走过 ${path.length} 格,吃到 ${eaten} 天`; const col = Math.floor(idx / 7);
clearInterval(timer); const date = cell.dataset.date ?? '';
timer = 0; const mi = monthKeys.indexOf(date.slice(0, 7));
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
} }
}; if (lenEl) lenEl.textContent = String(body);
const start = () => { // 推进游标
reset(); const next = cur + dirs[si];
if (reduce) { cursors[si] = next;
cells.forEach((c) => { if (next < 0 || next >= PATH.length) cursors[si] = -1 as number;
c.classList.add('snake-passed');
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
}); });
if (readEl) readEl.textContent = '已按减少动态效果静态呈现'; // 全部游标到端点 → 一轮结束
return; if (!moved || cursors.every((c) => c < 0)) {
}
if (timer) return;
timer = window.setInterval(tick, 30);
};
whenVisible(
wrap,
() => {
if (!timer) start();
},
() => {
if (timer) {
clearInterval(timer); clearInterval(timer);
timer = 0; timer = 0;
if (readEl) readEl.textContent = `第 ${loopNo} 轮完成:吃到 ${eaten} 天`;
loopNo++;
setTimeout(() => {
reset(true);
if (!paused) start();
}, 900);
} }
}, void paint;
); };
document.querySelector('[data-snake-replay]')?.addEventListener('click', () => {
const start = () => {
if (timer || paused) return;
reset(false);
timer = window.setInterval(step, 26);
};
const stop = () => {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = 0; timer = 0;
start();
});
}
/* ══════ G · 心跳呼吸(纯 CSS 驱动,只按数据写变量)══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-breath]')) {
const max = Math.max(...DATA.cells, 1);
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const n = Number(cell.dataset.count ?? 0);
if (n <= 0) continue;
// 越忙跳得越快(时长 3.4s → 1.2s),幅度略随强度增大
const dur = 3.4 - (n / max) * 2.2;
cell.style.setProperty('--breath', `${dur.toFixed(2)}s`);
cell.classList.add('hg-breath');
}
}
/* ══════ H · 雨滴涟漪 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-ripple]')) {
const origin = document.querySelector('[data-ripple-origin]')?.getAttribute('data-ripple-origin') ?? '0,0';
const [oc, or] = origin.split(',').map(Number);
const play = () => {
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const c = Number(cell.dataset.c ?? 0);
const r = Number(cell.dataset.r ?? 0);
// 以"最后一次贡献"为圆心,按列/行距离给错峰延迟
const dist = Math.hypot(c - oc, (r - or) * 1.6);
cell.style.setProperty('--wave', `${Math.round(dist * 26)}ms`);
}
wrap.classList.remove('play');
void wrap.offsetWidth; // 强制回流,让动画重播
wrap.classList.add('play');
}; };
whenVisible(wrap, () => {
if (!reduce) play(); toggle?.addEventListener('click', () => {
paused = !paused;
toggle.textContent = paused ? '继续' : '暂停';
if (paused) stop();
else start();
}); });
document.querySelector('[data-ripple-replay]')?.addEventListener('click', () => {
if (!reduce) play(); if (reduce) {
// 减少动态:直接展示终态(所有有贡献的格子点亮 + 柱子立起)
cells.forEach((c) => {
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
}); });
bars.forEach((b) => b.classList.add('on'));
if (readEl) readEl.textContent = '已按"减少动态效果"静态呈现';
} else {
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) start();
else stop();
}
},
{ threshold: 0.15 },
).observe(wrap);
}
} }
/* ══════ I · 3D 地形 ══════ */ /* J:单独的柱状图(进视口升起 + hover 联动) */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-terrain]')) { const chart = document.querySelector<HTMLElement>('[data-bars-chart]');
const max = Math.max(...DATA.cells, 1); if (chart) {
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) { new IntersectionObserver(
const n = Number(cell.dataset.count ?? 0); (entries) => {
cell.style.setProperty('--z', `${((n / max) * 14).toFixed(1)}px`); for (const e of entries) if (e.isIntersecting) e.target.classList.add('grow');
if (n > 0) cell.classList.add('hg-raise'); },
} { threshold: 0.2 },
if (reduce) continue; ).observe(chart);
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]'); const grid = document.querySelector<HTMLElement>('[data-bars]');
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) { for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) {
const m = btn.dataset.month ?? ''; const m = btn.dataset.month ?? '';
btn.addEventListener('pointerenter', () => { const on = () =>
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => { grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) =>
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m)); c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m)),
}); );
}); const off = () => grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month'));
btn.addEventListener('pointerleave', () => { btn.addEventListener('pointerenter', on);
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month')); btn.addEventListener('pointerleave', off);
}); btn.addEventListener('focus', on);
btn.addEventListener('focus', () => btn.dispatchEvent(new Event('pointerenter'))); btn.addEventListener('blur', off);
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');
if (t) show(t);
});
wrap.addEventListener('pointerleave', () => {
if (glass) glass.hidden = true;
cells.forEach((x) => x.classList.remove('hg-focus'));
});
wrap.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) show(t);
});
}
</script> </script>
<style> <style>
@@ -406,6 +364,7 @@ const dataForClient = { cells: days.map((d) => d.count), weeks: weeks.length };
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; } .pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); } .pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; } .pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
.pv-rec { font-size: 11px; color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line)); border-radius: 4px; padding: 0 5px; margin-left: 6px; font-family: var(--font-mono); }
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; } .pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
.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); }
@@ -418,99 +377,42 @@ const dataForClient = { cells: days.map((d) => d.count), weeks: weeks.length };
} }
.hm-btn:hover { border-color: var(--accent); } .hm-btn:hover { border-color: var(--accent); }
/* ── F 蛇:头 + 吃过 ── */ /* 蛇:头 / 身 / 吃过 */
:global(.snake-head) { :global(.snake-head) {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
z-index: 1; z-index: 2;
position: relative; position: relative;
} }
:global(.snake-passed) { :global(.snake-body) {
background: color-mix(in oklab, var(--accent) 16%, transparent); background: color-mix(in oklab, var(--accent) 32%, transparent) !important;
transition: background 220ms var(--ease);
} }
:global(.snake-eaten) { :global(.snake-eaten) {
background: var(--accent) !important; background: var(--accent) !important;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent); box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
transform: scale(1.12); transition: background 180ms var(--ease);
transition: background 180ms var(--ease), transform 180ms var(--ease);
} }
/* ── G 呼吸 ── */ /* 月度柱 */
:global(.hg-breath) { animation: hmBreath var(--breath, 2.6s) ease-in-out infinite; }
@keyframes hmBreath {
0%, 100% { opacity: 0.55; transform: scale(0.94); }
50% { opacity: 1; transform: scale(1.06); }
}
/* ── H 涟漪 ── */
.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; }
}
/* ── I 3D 地形 ── */
.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 { .hm-bars {
display: flex; align-items: flex-end; gap: 6px; list-style: none; display: flex; align-items: flex-end; gap: 6px; list-style: none;
padding: 0; margin: var(--space-3) 0 0; height: 92px; padding: 0; margin: var(--space-2) 0 0; height: 84px;
} }
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; } .hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; }
.hm-bar-btn { .hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; background: none; border: 0; padding: 0; cursor: pointer; }
width: 100%; height: 100%; display: flex; align-items: flex-end;
background: none; border: 0; padding: 0; cursor: pointer;
}
.hm-bar-fill { .hm-bar-fill {
display: block; width: 100%; height: 0; display: block; width: 100%; height: 0;
background: var(--ink-4); border-radius: 2px 2px 0 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); transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
} }
.grow .hm-bar-fill { height: var(--h, 0%); } .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); } /* FJ:蛇爬到该月时柱子升起(由 JS 加 .on) */
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); }
.hm-bar-btn:hover .hm-bar-fill { background: var(--accent); }
.hm-bar-label { font-size: 10.5px; color: var(--ink-4); } .hm-bar-label { font-size: 10.5px; color: var(--ink-4); }
:global(.hg-month) { background: var(--accent) !important; } :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) {
:global(.hg-breath) { animation: none; opacity: 1; } .hm-bar-fill { transition: none; }
.play :global(.hg-c) { animation: none; }
.hm-3d-inner { transform: none !important; transition: none; }
.grow .hm-bar-fill { transition: none; }
} }
</style> </style>