Files
blog/src/pages/heatmap-preview.astro
T
wait4xandGLM-5.3 6b2e4d8666 feat(opensource): 热力图五方案动效预览上线
用户要求「热力图可以有更多的动效,而不是静态的一张图」,先上线五案供选型:
A 入场波纹 / B 光标光晕 / C 年度回放 / D 连续电流 / E 悬停周详情。
均守灰阶红线、reduced-motion 静态化、零第三方依赖。

验证: 构建 exit 0;content-lint 52 页 0/0/0;Playwright 逐案实测动效均生效
      (A hmRise 0.42s、B 光晕读数、C 第 10 周回放、D 12 格 hmFlow、E 整列 lift)

Spec: none (预览页需先上线供用户选择)

Co-authored-by: GLM-5.3
2026-11-04 20:09:54 +08:00

401 lines
18 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.
---
/**
* 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。
*
* 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮;
* 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案
* 落进 Heatmap.astro,然后删掉本路由。
*
* 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion
* 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro';
import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap';
const { weeks, total } = await loadHeatmap();
const days: HeatDay[] = weeks.flat();
const buildTime = new Date().toISOString().slice(0, 10);
/* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */
const countOf = new Map(days.map((d) => [d.date, d.count]));
const today = new Date();
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
// 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径),
// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。
let current = 0;
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
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);
const c = countOf.get(isoOf(d)) ?? 0;
if (c > 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]);
const activeDays = days.filter((d) => d.count > 0).length;
const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays };
---
<BaseLayout title="热力图动效方案 · wait4x" description="五个贡献热力图动效方案的并陈预览">
<h1 class="pv-title">热力图:五个动效方案</h1>
<p class="pv-note">
同一份真实数据(近一年 <strong>{total}</strong> 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。
<br />
<strong>看完告诉我选哪个</strong>(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。
五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#ha" class="pv-chip">A · 入场波纹</a>
<a href="#hb" class="pv-chip">B · 光标光晕</a>
<a href="#hc" class="pv-chip">C · 年度回放</a>
<a href="#hd" class="pv-chip">D · 连续电流</a>
<a href="#he" class="pv-chip">E · 悬停周详情</a>
</nav>
<!-- ═══════════ A · 入场波纹 ═══════════ -->
<section id="ha" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">A</span>
<h2>入场波纹</h2>
<p class="pv-pros">滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。
<b>优点</b>有生命感、不打扰、只播一次;<b>代价</b>纯装饰,不提供额外信息。</p>
</header>
<div class="hm-wrap" data-cascade>
<HeatGrid weeks={weeks} variant="cascade" step={7} />
</div>
</section>
<!-- ═══════════ B · 光标光晕 ═══════════ -->
<section id="hb" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">B</span>
<h2>光标光晕</h2>
<p class="pv-pros">鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。
<b>优点</b>能精确读数、零轮播;<b>代价</b>触屏设备没有指针,需退化为点按读数。</p>
</header>
<div class="hm-wrap hm-spot" data-spot>
<HeatGrid weeks={weeks} variant="spotlight" />
<span class="hm-glow" aria-hidden="true"></span>
</div>
<p class="hm-read mono" data-spot-read>把指针移到格子上 →</p>
</section>
<!-- ═══════════ C · 年度回放 ═══════════ -->
<section id="hc" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">C</span>
<h2>年度回放</h2>
<p class="pv-pros">拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。
<b>优点</b>最有叙事感、能直观感受节奏;<b>代价</b>多一层交互,需要一点耐心。</p>
</header>
<div class="hm-wrap" data-scrub>
<HeatGrid weeks={weeks} variant="scrub" />
</div>
<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>
</section>
<!-- ═══════════ E · 悬停周详情 ═══════════ -->
<section id="he" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">E</span>
<h2>悬停周详情</h2>
<p class="pv-pros">指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。
<b>优点</b>信息密度最高、动效克制;<b>代价</b>仍是鼠标优先,触屏靠点按。</p>
</header>
<div class="hm-inspector">
<div class="hm-wrap" data-insp>
<HeatGrid weeks={weeks} variant="inspector" />
</div>
<aside class="hm-week" data-insp-panel>
<p class="mono">把指针移到任意一周</p>
</aside>
</div>
</section>
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。</p>
</BaseLayout>
<script>
// ── A · 入场波纹:进视口一次性播放(不进视口不播,省电)──
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const cascades = document.querySelectorAll<HTMLElement>('[data-cascade]');
if (!reduce && cascades.length) {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add('play');
io.unobserve(e.target);
}
},
{ rootMargin: '0px 0px -10% 0px' },
);
cascades.forEach((el) => io.observe(el));
}
// ── B · 光标光晕:只更新两个 CSS 变量,不跑 rAF 循环 ──
for (const wrap of document.querySelectorAll<HTMLElement>('[data-spot]')) {
const glow = wrap.querySelector<HTMLElement>('.hm-glow');
const read = document.querySelector<HTMLElement>('[data-spot-read]');
const move = (x: number, y: number) => {
const r = wrap.getBoundingClientRect();
if (!glow) return;
glow.style.setProperty('--x', `${x - r.left}px`);
glow.style.setProperty('--y', `${y - r.top}px`);
glow.style.opacity = '1';
};
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);
});
}
// ── 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;
play?.addEventListener('click', () => {
if (timer) {
clearInterval(timer);
timer = 0;
play.textContent = '▶ 播放';
return;
}
play.textContent = '⏸ 暂停';
if (!range) return;
range.value = '0';
apply(0);
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;
play.textContent = '▶ 播放';
}
}, 60);
});
apply(Number(range?.value ?? weeksTotal));
}
// ── D · 连续电流:最近连续段逐格流光(延迟按位置递增)──
for (const wrap of document.querySelectorAll<HTMLElement>('[data-streak]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')].filter((c) => Number(c.dataset.count ?? 0) > 0);
const tail = cells.slice(-12);
tail.forEach((c, i) => {
c.style.setProperty('--flow', `${i * 90}ms`);
c.classList.add('hg-flow');
});
wrap.classList.toggle('no-flow', reduce);
}
// ── E · 悬停周详情:抬列 + 面板读数 ──
for (const wrap of document.querySelectorAll<HTMLElement>('[data-insp]')) {
const panel = document.querySelector<HTMLElement>('[data-insp-panel]');
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const byWeek = new Map<number, HTMLElement[]>();
for (const c of cells) {
const w = Number(c.dataset.c ?? 0);
(byWeek.get(w) ?? byWeek.set(w, []).get(w)!).push(c);
}
const showWeek = (w: number) => {
wrap.querySelectorAll('.hg-c.lift').forEach((c) => c.classList.remove('lift'));
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.addEventListener('pointerover', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) showWeek(Number(t.dataset.c ?? 0));
});
wrap.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) showWeek(Number(t.dataset.c ?? 0));
});
}
</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: 4px 0; }
/* ── 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 {
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;
}
.hm-btn:hover { border-color: var(--accent); }
.hm-range { flex: 1; min-width: 160px; accent-color: var(--accent); }
/* ── D:连续电流 ── */
.hm-streak { display: flex; flex-direction: column; gap: var(--space-3); }
.hm-streak-num { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
.hm-streak-num strong { font-size: 34px; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
.hm-streak-num span { font-size: 12.5px; color: var(--ink-3); }
.hm-streak-sub { color: var(--ink-4) !important; }
: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 {
0%, 100% { opacity: 0; }
45% { opacity: 0.55; }
}
.no-flow :global(.hg-flow)::after { animation: none; }
/* ── E:悬停周详情 ── */
.hm-inspector { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; }
.hm-inspector .hm-wrap { flex: 1 1 560px; min-width: 0; }
:global(.hg-c.lift) {
transform: translateY(-2px);
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;
padding: var(--space-3); background: var(--bg-soft); font-size: 12.5px; color: var(--ink-3);
}
.hm-week p { margin: 0 0 4px; }
.hm-week-total strong { font-size: 22px; color: var(--ink-1); font-variant-numeric: tabular-nums; }
.hm-week-best { color: var(--ink-4); }
@media (prefers-reduced-motion: reduce) {
[data-cascade].play :global(.hg-c) { animation: none; opacity: 1; }
:global(.hg-flow)::after { animation: none; }
.hm-glow { display: none; }
}
@media (max-width: 620px) {
.hm-week { flex-basis: 100%; }
}
</style>