Files
blog/src/pages/heatmap-preview.astro
T
wait4xandGLM-5.3 d6dfe39db3 feat(heatmap): 热力图六案 v2 预览上线(含贪吃蛇)
按用户反馈重做:F 贪吃蛇 / G 心跳呼吸 / H 雨滴涟漪 / I 3D 地形 / J 周冠军柱 / K 显微镜。

验证: 构建 exit 0;Playwright 逐案实测动效均生效(蛇 371 格爬完吃到 51 天)

Spec: none (预览页随选型迭代)

Co-authored-by: GLM-5.3
2026-11-04 20:47:26 +08:00

517 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* 热力图动效方案预览 v2(Phase 5j)——用户看过 v1 五案后觉得不够,要求「类似贪吃蛇」的
* 方案并再深入设计 4–6 个。本页给 6 个更深入的方案(F–K),v1 的 A–E 已下线。
*
* 设计红线(六案都守):灰阶骨架、accent 只做高亮不做数据编码(数据仍由 ink 四档承载)、
* prefers-reduced-motion 一律静态、零第三方依赖、离开视口即停(省电)。
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro';
import { loadHeatmap, type HeatDay } from '../lib/heatmap';
const { weeks, total } = await loadHeatmap();
const days: HeatDay[] = weeks.flat();
const buildTime = new Date().toISOString().slice(0, 10);
/* ── 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数)── */
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
let current = 0;
if (lastActive) {
const start = new Date(`${lastActive}T00:00:00Z`);
for (let i = 0; ; i++) {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() - i);
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++;
else break;
}
}
let longest = 0;
let run = 0;
for (const d of days) {
run = d.count > 0 ? run + 1 : 0;
if (run > longest) longest = run;
}
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
/* ── 月度聚合(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="热力图动效方案 v2 · wait4x" description="六个更深入的热力图动效方案">
<h1 class="pv-title">热力图:六个更深入的方案</h1>
<p class="pv-note">
同一份真实数据(近一年 <strong>{stats.total}</strong> 次贡献 · 当前连续
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天 · 单日最高 {stats.best} 次),
数据更新于 {buildTime}。
<br />
上一版的五个方案已按你的反馈下线;这一版六个都往「有生命、有叙事」的方向推。
<strong>回字母告诉我选哪个</strong>(也可以说「F 但慢一点」「F + J 组合」)。
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#hf" class="pv-chip">F · 贪吃蛇</a>
<a href="#hg" class="pv-chip">G · 心跳呼吸</a>
<a href="#hh" class="pv-chip">H · 雨滴涟漪</a>
<a href="#hi" class="pv-chip">I · 3D 地形</a>
<a href="#hj" class="pv-chip">J · 周冠军柱</a>
<a href="#hk" class="pv-chip">K · 显微镜</a>
</nav>
<!-- ═══════════ F · 贪吃蛇 ═══════════ -->
<section id="hf" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F</span>
<h2>贪吃蛇</h2>
<p class="pv-pros">一条蛇沿着贡献格蛇形爬过:吃到有贡献的格子就把它们点亮并"长"一点,空格直接穿过。
<b>优点</b>最有生命感、天然表达"这一年被一点点吃掉";<b>代价</b>是持续动画(离开视口即暂停)。</p>
</header>
<div class="hm-wrap" data-snake>
<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>
</section>
<!-- ═══════════ H · 雨滴涟漪 ═══════════ -->
<section id="hh" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">H</span>
<h2>雨滴涟漪</h2>
<p class="pv-pros">从"最后一次贡献"那一格出发,涟漪一圈圈向外扩散,扫过的格子闪一下再落定。
<b>优点</b>叙事清晰(现在 → 过去)、只播一次;<b>代价</b>纯装饰。</p>
</header>
<div class="hm-wrap" data-ripple>
<HeatGrid weeks={weeks} variant="ripple" />
</div>
<p class="hm-read mono"><button class="hm-btn" type="button" data-ripple-replay>再看一次涟漪</button> · 起点 {lastActive}</p>
</section>
<!-- ═══════════ I · 3D 地形 ═══════════ -->
<section id="hi" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">I</span>
<h2>3D 地形</h2>
<p class="pv-pros">把贡献量当"海拔":忙的日子凸起来,整块网格随鼠标轻轻倾斜,像俯瞰一年的地貌。
<b>优点</b>一眼看出密集区、视觉独特;<b>代价</b>倾斜会略降可读性。</p>
</header>
<div class="hm-3d" data-terrain>
<div class="hm-3d-inner">
<HeatGrid weeks={weeks} variant="terrain" />
</div>
</div>
</section>
<!-- ═══════════ J · 周冠军柱 ═══════════ -->
<section id="hj" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">J</span>
<h2>周冠军柱</h2>
<p class="pv-pros">网格下方按月长出一排柱子,进视口时逐根升起;指针移到柱子上,对应那些格子同时亮起。
<b>优点</b>把"哪几个月最忙"讲清楚、信息量大;<b>代价</b>多一层图表,页面变高。</p>
</header>
<div class="hm-wrap" data-bars>
<HeatGrid weeks={weeks} variant="bars" />
</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>
<!-- ═══════════ 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>
<script>
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const raw = document.getElementById('hm-data')?.textContent ?? '{"cells":[],"weeks":0}';
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(
(entries) => {
for (const e of entries) (e.isIntersecting ? onIn : onOut)?.();
},
{ threshold: 0.15 },
);
io.observe(el);
return io;
};
/* ══════ F · 贪吃蛇 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const readEl = document.querySelector<HTMLElement>('[data-snake-read]');
// 蛇形路径:按列走,奇数列自下而上(与 GitHub 贡献蛇同构)
const path: number[] = [];
for (let c = 0; c < DATA.weeks; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse();
for (const r of rows) path.push(c * 7 + r);
}
let step = 0;
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 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) {
clearInterval(timer);
timer = 0;
}
},
);
document.querySelector('[data-snake-replay]')?.addEventListener('click', () => {
if (timer) clearInterval(timer);
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();
});
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');
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>
<style>
.pv-title { font-size: var(--text-h1); color: var(--ink-1); margin-top: var(--space-5); }
.pv-note { font-size: 14px; color: var(--ink-3); line-height: 1.75; max-width: var(--measure); margin-block: var(--space-2) var(--space-4); }
.pv-switch {
position: sticky; top: 0; z-index: 3; display: flex; flex-wrap: wrap; gap: var(--space-2);
padding: var(--space-2) 0; background: color-mix(in oklab, var(--bg) 92%, transparent);
backdrop-filter: blur(6px); border-bottom: 1px solid var(--line);
}
.pv-chip {
font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px;
transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
}
.pv-chip:hover { border-color: var(--accent); color: var(--ink-1); }
.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-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
.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 b { color: var(--ink-2); }
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
.hm-wrap { overflow-x: auto; padding: 6px 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; }
.hm-btn {
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: 3px 10px; cursor: pointer;
}
.hm-btn:hover { border-color: var(--accent); }
/* ── F 蛇:头 + 吃过 ── */
:global(.snake-head) {
outline: 2px solid var(--accent);
outline-offset: 1px;
z-index: 1;
position: relative;
}
:global(.snake-passed) {
background: color-mix(in oklab, var(--accent) 16%, transparent);
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);
}
/* ── 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 {
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) {
:global(.hg-breath) { animation: none; opacity: 1; }
.play :global(.hg-c) { animation: none; }
.hm-3d-inner { transform: none !important; transition: none; }
.grow .hm-bar-fill { transition: none; }
}
</style>