feat(heatmap): v5 五案预览页上线——墨染/潮汐/星轨/织锦/萤火并陈供选型

五个候选方案在现役 P1 贪吃蛇之上重做动效语言(更安静文气的方向),
共用 loadHeatmap 真实数据与同份组件契约(offset 定位 / reduced-motion
静态兜底 / IO 离屏即停 / ink 档位独占数据密度 / accent 只给运动元素)。
多队友并行产出:@worker-one 墨染+潮汐,三个子代理星轨/织锦/萤火;
经 glm-5.3-flash 视觉复核一轮(织锦纱线可见度提档、梭子加描边、萤火
色向转暖)后上线 /heatmap-preview/ 供用户点选。

- 五组件自包含(模板+引擎+样式),选定后落进正式开源页并删本预览路由
- content-lint 一次性白名单 /heatmap-preview/(无 en 版配对豁免)

验证: pnpm astro build exit 0;本地 18926 快照零控制台错误、六 section 帧差确认全案在动

Spec: none (预览页需先上线供用户选择,选定后落地时正式立项收尾)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-05 11:01:20 +08:00
1 parent dac34edd9a
commit 97a56f3c89
7 files changed
+1290 -1

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
---
// 热力图 v5 预览方案「墨染」:一年贡献按时间顺序洇墨——
// 只走有贡献的格子,每到访一格便有一圈软光晕从格心洇开;
// 扫完一年后一道横向墨光掠过整幅,随后无缝进入下一轮。
// 本文件是独立预览件:只新增、不改既有、零依赖零请求。
import { loadHeatmap, levelOf } from '../../../lib/heatmap';
interface Props {
title: string;
concept: string;
}
const { title, concept } = Astro.props;
// 数据获取失败(无 token / 无缓存)时 weeks 为空 → 网格空壳,引擎自然不启动
let weeks: import('../../../lib/heatmap').HeatDay[][] = [];
try { ({ weeks } = await loadHeatmap()); } catch {}
---
<section class="hm-v" id="hm-ink">
<h3 class="hm-v-title">{title}</h3>
<p class="hm-v-concept">{concept}</p>
<div class="hm-v-stage" data-hm-ink>
<div class="hm-grid">
{weeks.map((w) =>
w.map((d) => (
<span
class:list={['hm-c', 'hm-l' + levelOf(d.count)]}
data-count={d.count}
data-date={d.date}
title={`${d.date} · ${d.count}`}
aria-hidden="true"
/>
)),
)}
</div>
</div>
</section>
<script>
// ── 引擎契约(与 Tide 同守)──
// 1) reduced-motion → 完全不启动:静态网格本身就是完整的可读形态
// 2) IntersectionObserver(0.15) 离视口停 rAF,回来续跑(时间轴平移而非追赶)
// 3) 密度只由 ink 档位表达;accent 只出现在运动元素(光晕 / 墨光带)上
// 4) 所有瞬时高亮都衰减回落,禁止永久染色
const ANCHOR = '.hm-v-stage[data-hm-ink]';
const PACE_MS = 45; // 每格到访间隔:整年约 200+ 个贡献日 → 主扫约 9-10s
const CELL = 10; // 与 CSS 里的格径保持一致(定位用,禁止手算节距)
interface Visit {
el: HTMLElement;
left: number;
top: number;
level: number; // 光晕直径随档位放大:l1≈2 格径 → l4≈4 格径
}
const boot = (stage: HTMLElement) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const cells = Array.from(stage.querySelectorAll<HTMLElement>('.hm-c'));
if (cells.length === 0) return;
// 只走有贡献的格子,按日期升序——"时间顺序"是叙事核心,不是视觉排序
const visits: Visit[] = cells
.filter((el) => Number(el.dataset.count ?? 0) > 0)
.map((el) => ({
el,
// offsetLeft/OffsetTop 的 offsetParent 就是 stage(position:relative),
// 因此这两个值即"相对浮层坐标系"的位置,不需要知道 gap/列宽
left: el.offsetLeft,
top: el.offsetTop,
level: Number(/hm-l(\d)/.exec(el.className)?.[1] ?? 0),
}))
.sort((a, b) => (a.el.dataset.date ?? '').localeCompare(b.el.dataset.date ?? ''));
if (visits.length === 0) return;
const stageW = stage.clientWidth;
// 光晕直径:档位越高洇得越大(l1≈20px,l4≈40px)——用运动幅度回应数据密度,
// 而不是改格子底色(底色永远只表达档位本身)
const glowSize = (level: number) => CELL * (1.35 + 0.65 * level);
const spawnGlow = (v: Visit) => {
const d = glowSize(v.level);
const g = document.createElement('div');
g.className = 'hm-glow';
g.style.width = `${d}px`;
g.style.height = `${d}px`;
g.style.left = `${v.left + CELL / 2 - d / 2}px`;
g.style.top = `${v.top + CELL / 2 - d / 2}px`;
stage.appendChild(g);
// WAAPI 驱动光晕(浏览器合成线程跑,JS 不逐帧参与);结束自清,DOM 不累积
g.animate(
[
{ transform: 'scale(0.25)', opacity: 0.9 },
{ transform: 'scale(1)', opacity: 0 },
],
{ duration: 650, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' },
).onfinish = () => g.remove();
// 到访格自身瞬时加深一档,随后回落(去掉类时 transition 生效)
v.el.classList.add('hm-hot');
setTimeout(() => v.el.classList.remove('hm-hot'), 180);
};
const band = document.createElement('div');
band.className = 'hm-band';
// 单次"墨光":低透明度 accent 渐变带从左掠到右,~1.2s
// bandAnim 交给 IO 闸门:离视口时连墨光一起暂停,回来续播
const runBand = () =>
new Promise<void>((done) => {
band.style.left = '0px';
stage.appendChild(band);
bandAnim = band.animate(
[
{ transform: 'translateX(-90px)', opacity: 0 },
{ opacity: 0.9, offset: 0.25 },
{ transform: `translateX(${stageW + 40}px)`, opacity: 0 },
],
{ duration: 1200, easing: 'ease-in-out' },
);
bandAnim.onfinish = () => {
band.remove();
bandAnim = null;
done();
};
});
// 状态机:sweep(逐格洇墨)→ band(整幅墨光)→ rest(一口气歇息)→ 循环。
// 时间基准 t0 在暂停/恢复时整体平移,保证续跑无跳变、离屏不追赶。
type Phase = 'sweep' | 'band' | 'rest';
let phase: Phase = 'sweep';
let t0 = performance.now();
let fired = 0;
let raf = 0;
let bandAnim: Animation | null = null;
const tick = (now: number) => {
if (phase === 'sweep') {
// 插值出"此刻应到访到第几格",一帧内补齐落后的步(rAF 掉帧也不丢事件)
const due = Math.min(Math.floor((now - t0) / PACE_MS), visits.length - 1);
while (fired <= due) spawnGlow(visits[fired++]);
if (fired >= visits.length) {
phase = 'band';
void runBand().then(() => {
phase = 'rest';
t0 = performance.now();
});
}
} else if (phase === 'rest') {
// 墨光结束后的静息:网格完全回到静态档位底色,再无缝开始下一轮
if (now - t0 > 600) {
phase = 'sweep';
t0 = now;
fired = 0;
}
}
raf = requestAnimationFrame(tick);
};
// 可见性闸门:离视口即停(连 WAAPI 一起暂停),回来平移 t0 续跑
let visible = true;
let pausedAt = 0;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting === visible) continue;
visible = e.isIntersecting;
if (visible) {
t0 += performance.now() - pausedAt;
raf = requestAnimationFrame(tick);
bandAnim?.play();
} else {
pausedAt = performance.now();
cancelAnimationFrame(raf);
bandAnim?.pause();
}
}
},
{ threshold: 0.15 },
);
io.observe(stage);
raf = requestAnimationFrame(tick);
};
// 锚定自己的 stage(页面可能同时渲染多个预览方案/多实例);
// forEach 而非 for...of:NodeList 迭代性依赖 dom.iterable lib,forEach 零配置可用
document.querySelectorAll<HTMLElement>(ANCHOR).forEach((stage) => boot(stage));
</script>
<style>
.hm-v-stage { position: relative; }
.hm-v-title {
font-family: var(--font-mono);
font-size: 14px;
color: var(--ink-3);
}
.hm-v-concept {
font-size: 12.5px;
color: var(--ink-4);
}
.hm-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 10px);
grid-auto-columns: 10px;
gap: 2px;
width: max-content;
}
.hm-c {
width: 10px;
height: 10px;
border-radius: 2px;
/* 回落用:hm-hot 撤掉时按这条过渡退回档位底色 */
transition: filter 0.45s var(--ease, ease);
}
.hm-l0 { border: 1px solid var(--line); }
.hm-l1 { background: var(--ink-4); }
.hm-l2 { background: var(--ink-3); }
.hm-l3 { background: var(--ink-2); }
.hm-l4 { background: var(--ink-1); }
/* 到访格的瞬时加深:只提亮度不改底色,衰减回落靠上面的 transition */
.hm-c.hm-hot { filter: brightness(1.45); }
/* 洇墨光晕:accent 只出现在这类运动元素上;直径由 JS 按档位写入 */
.hm-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
background: radial-gradient(circle, color-mix(in oklab, var(--accent) 26%, transparent) 0%, transparent 70%);
will-change: transform, opacity;
}
/* 整幅墨光带:横向掠过的低透明度 accent 渐变 */
.hm-band {
position: absolute;
top: 0;
bottom: 0;
width: 80px;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--accent) 13%, transparent), transparent);
will-change: transform, opacity;
}
</style>