Files
blog/src/components/geek/hm/InkBloom.astro
T
wait4xandGLM-5.3 97a56f3c89 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
2026-11-05 11:01:20 +08:00

243 lines
8.6 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.
---
// 热力图 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>