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

+5 -1
View File
@@ -14,7 +14,11 @@ if (MODE === 'skip') {
} }
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障) const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对 const SKIP_FILE = new Set([
'/404.html', // Astro 404 页不参与配对
// 一次性预览路由(预览-选型-落地模式):只有 zh 版,配对检查豁免;选定后随路由一起删
'/heatmap-preview/index.html',
]);
+238
View File
@@ -0,0 +1,238 @@
---
// 热力图动效预览方案「星轨」:格子即星——档位只决定微光强弱(越密越亮),
// 一颗彗星(accent 亮核 + 渐变长尾)沿水平正弦轨迹横掠全年网格,
// 途经的活跃星短暂闪烁后回落档位原色,掠完全图歇息片刻再折返——尾随行进方向甩。
// 本文件是独立预览件:只新增、不改既有、零依赖零请求。
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-comet">
<h3 class="hm-v-title">{title}</h3>
<p class="hm-v-concept">{concept}</p>
<div class="hm-v-stage" data-hm-comet>
<!-- 彗星浮层随模板渲染而非 JS 建节点:scoped 样式天然命中(运行时创建的元素
拿不到 Astro 作用域类);默认 display:none,引擎启动才现身——
reduced-motion / 无 JS 下零残留 -->
<div class="hm-comet" aria-hidden="true">
<span class="hm-comet-tail"></span>
<span class="hm-comet-core"></span>
</div>
<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/InkBloom 同守)──
// 1) reduced-motion → 完全不启动:静态星图(档位微光差)本身即完整可读形态
// 2) IntersectionObserver(0.15) 离视口停 rAF,回来平移时间轴续跑(不追赶进度)
// 3) 彗星坐标一律取格子 offsetLeft/offsetTop(offsetParent 即 stage,浮层同坐标系);
// 行程边界也由首尾格实测得出——禁手算节距,格子尺寸/gap 改版引擎零回改
// 4) accent 只给彗星;星星闪烁是 brightness 脉冲不换色,档位底色语义不动
// 5) 闪烁走 WAAPI 一次性时间线且 fill 默认 none —— 结束态即原样,机制上保证回落
const ANCHOR = '.hm-v-stage[data-hm-comet]';
const SWEEP_MS = 5000; // 一端到另一端一次横掠
const REST_MS = 2500; // 两趟之间的歇息(彗星淡出)
const SINE_MS = 1800; // 正弦起伏周期
const AMPLITUDE = 15; // 正弦幅度 ≈ 1.5 格高(格 10px)
const NEAR_PX = 6; // 闪烁判定水平邻域:半节距(10px 格 + 2px 缝)
const FLICK_MS = 450; // 星星闪烁衰减时长
interface Cell {
el: HTMLElement;
left: number; // offsetLeft,相对 stage(浮层同坐标系)
top: number;
hot: boolean; // 邻域去重旗:一次掠过只闪一下
anim?: Animation;
}
const boot = (stage: HTMLElement) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const nodes = Array.from(stage.querySelectorAll<HTMLElement>('.hm-c'));
const comet = stage.querySelector<HTMLElement>('.hm-comet');
if (!nodes.length || !comet) return;
const cells: Cell[] = nodes.map((el) => ({ el, left: el.offsetLeft, top: el.offsetTop, hot: false }));
const xs = cells.map((c) => c.left);
const ys = cells.map((c) => c.top);
const x0 = Math.min(...xs);
const x1 = Math.max(...xs);
const yMid = (Math.min(...ys) + Math.max(...ys)) / 2;
// 引擎接管即现身(display 一次性写定,此后每帧只写 transform)
comet.style.display = 'block';
let dir = 1; // +1 左→右,-1 折返右→左;尾甩在行进反方向
let phase: 'sweep' | 'rest' = 'sweep';
let t0 = performance.now();
let raf = 0;
const flick = (c: Cell) => {
// 只闪有贡献的星(data-count>0):空格没有「被点亮」的叙事资格
if (Number(c.el.dataset.count ?? 0) <= 0) return;
// 前 1/5 冲上峰值(亮度 1.8 + 放大 1.15),余下 ~450ms 衰减归位
c.anim?.cancel();
c.anim = c.el.animate(
[
{ filter: 'brightness(1)', transform: 'scale(1)' },
{ filter: 'brightness(1.8)', transform: 'scale(1.15)', offset: 0.2 },
{ filter: 'brightness(1)', transform: 'scale(1)' },
],
{ duration: FLICK_MS, easing: 'ease-out' },
);
};
const tick = (now: number) => {
if (phase === 'sweep') {
const p = Math.min((now - t0) / SWEEP_MS, 1);
const x = dir > 0 ? x0 + (x1 - x0) * p : x1 - (x1 - x0) * p;
const y = yMid + Math.sin(((now - t0) / SINE_MS) * Math.PI * 2) * AMPLITUDE;
// 每帧唯一样式写入:容器位移,方向翻转合并在 scaleX(核是圆点翻转无感,尾随掉头)
comet.style.transform = `translate(${x}px, ${y}px) scaleX(${dir})`;
// 闪烁按「彗核当前列」判定而非按行——正弦只管彗星自身起伏,
// 不按行过滤,否则边行的星永远轮不到闪
for (const c of cells) {
const near = Math.abs(c.left - x) <= NEAR_PX;
if (near && !c.hot) {
c.hot = true;
flick(c);
} else if (!near && c.hot) {
c.hot = false;
}
}
if (p >= 1) {
phase = 'rest';
t0 = now;
comet.classList.add('hm-comet-off');
}
} else if (now - t0 > REST_MS) {
phase = 'sweep';
t0 = now;
dir = -dir; // 折返反向:尾随行进方向翻转的规则,只有方向真的会反转才有意义
// 先摆到新出发点再淡入,避免「从旧终点滑回来」的假动作
comet.style.transform = `translate(${dir > 0 ? x0 : x1}px, ${yMid}px) scaleX(${dir})`;
comet.classList.remove('hm-comet-off');
}
raf = requestAnimationFrame(tick);
};
// 可见性闸门:离视口即停 rAF;回来平移 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);
} else {
pausedAt = performance.now();
cancelAnimationFrame(raf);
}
}
},
{ threshold: 0.15 },
);
io.observe(stage);
// 首帧前先摆到出发点,避免 (0,0) 角落闪现一帧
comet.style.transform = `translate(${x0}px, ${yMid}px) scaleX(1)`;
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;
}
/* 档位底色(全站热力图同一灰阶语义)+ 星光微光差:越密越亮。
微光用 opacity 直接挂 hm-l 类(纯 CSS,引擎与 reduced-motion 都不参与),
静态星图因此天然分层可读 */
.hm-l0 { border: 1px solid var(--line); opacity: 0.35; }
.hm-l1 { background: var(--ink-4); opacity: 0.55; }
.hm-l2 { background: var(--ink-3); opacity: 0.7; }
.hm-l3 { background: var(--ink-2); opacity: 0.85; }
.hm-l4 { background: var(--ink-1); opacity: 1; }
/* 彗星浮层:translate 原点=彗核圆心,尾甩行进反方向(容器 scaleX 翻转掉头);
JS 每帧只写容器 transform,核/尾自身从不被动 */
.hm-comet {
display: none;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: 3;
will-change: transform;
transition: opacity 320ms var(--ease, ease); /* 歇息期的淡出/淡入 */
}
.hm-comet-off { opacity: 0; }
.hm-comet-core {
position: absolute;
left: -3px; /* 6px 核圆心对齐 translate 原点 */
top: -3px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 30%, transparent),
0 0 10px color-mix(in oklab, var(--accent) 60%, transparent);
}
.hm-comet-tail {
position: absolute;
left: -90px; /* 尾根贴核、向行进反方向延伸 90px */
top: -1px;
width: 90px;
height: 2px;
border-radius: 1px;
background: linear-gradient(to right, transparent, color-mix(in oklab, var(--accent) 55%, transparent));
}
</style>
+213
View File
@@ -0,0 +1,213 @@
---
// 热力图动效预览 · 方案「萤火」:5 只萤火虫在网格包围盒内拟周期游走,
// 路过活跃格时短暂点亮(brightness + 微光晕)后指数衰减回落。
// 密度语义始终由 ink 档位承载,accent 只出现在运动元素与瞬时高亮上
import { loadHeatmap, levelOf } from '../../../lib/heatmap';
interface Props {
title: string;
concept: string;
}
const { title, concept } = Astro.props;
let weeks: import('../../../lib/heatmap').HeatDay[][] = [];
try {
({ weeks } = await loadHeatmap());
} catch {
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
}
---
<section class="hm-v" id="hm-fire">
<h3 class="hm-v-title">{title}</h3>
<p class="hm-v-concept">{concept}</p>
<div class="hm-v-stage" data-hm-fire>
<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>
{[0, 1, 2, 3, 4].map(() => <i class="hm-fly" aria-hidden="true" />)}
</div>
</section>
<script>
// 萤火引擎:虫体位置 O(5)/帧;点亮判定只扫预收集的活跃格,且仅在进入/离开时改样式类
(() => {
// 减少动态效果:引擎整体不启动,静态灰阶网格即完整可读形态
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const stage = document.querySelector<HTMLElement>('[data-hm-fire]');
if (!stage) return;
const cells = [...stage.querySelectorAll<HTMLElement>('.hm-c')];
const flies = [...stage.querySelectorAll<HTMLElement>('.hm-fly')];
const grid = stage.querySelector<HTMLElement>('.hm-grid');
if (!cells.length || !flies.length || !grid) return;
// 坐标一次读齐(offsetLeft/Top 均相对 .hm-v-stage,stage 为 offsetParent):
// 网格是构建期定宽的 max-content,运行期无重排,此后不再触碰 layout
const midX = grid.offsetLeft + grid.offsetWidth / 2;
const midY = grid.offsetTop + grid.offsetHeight / 2;
const halfW = grid.offsetWidth / 2 - 6; // 内缩 6px:虫体与光晕不出包围盒
const halfH = grid.offsetHeight / 2 - 6;
interface Active {
el: HTMLElement;
x: number;
y: number;
until: number; // 点亮保持截止时刻(去抖用)
lit: boolean;
}
const active: Active[] = [];
for (const c of cells) {
if (Number(c.dataset.count ?? 0) > 0) {
active.push({
el: c,
x: c.offsetLeft + c.offsetWidth / 2,
y: c.offsetTop + c.offsetHeight / 2,
until: -1,
lit: false,
});
}
}
// 每虫独立的频率/相位:每轴双余弦叠加(幅度和 ≤0.94,到界即余弦自然折返,
// 无硬反弹跳变);频率互不成整数比 → 轨迹拟周期永不重置,契合"持续氛围"
const FLY = flies.map((_, i) => ({
fx: [0.00013 * (1 + i * 0.31), 0.00047 * (1 - i * 0.07)],
fy: [0.00011 * (1 + i * 0.19), 0.00053 * (1 + i * 0.11)],
px: [i * 2.4, i * 2.4 + 1.7],
py: [i * 2.4 + 3.1, i * 2.4 + 4.9],
ax: [0.62, 0.32],
ay: [0.58, 0.36],
bw: 0.0009 * (1 + i * 0.21), // 呼吸:逐虫独立周期(7s 起步)
bp: i * 1.9,
}));
const HOLD = 150; // 去抖:离开后短暂保持点亮,避免 6px 判定圈边缘类名高频翻转
const R2 = 36; // 6px 半径的平方——判定用平方距离免开方
let raf = 0;
const frame = (t: number) => {
raf = requestAnimationFrame(frame);
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < flies.length; i++) {
const f = FLY[i];
const dx =
f.ax[0] * Math.cos(t * f.fx[0] + f.px[0]) + f.ax[1] * Math.cos(t * f.fx[1] + f.px[1]);
const dy =
f.ay[0] * Math.cos(t * f.fy[0] + f.py[0]) + f.ay[1] * Math.cos(t * f.fy[1] + f.py[1]);
xs.push(midX + halfW * dx);
ys.push(midY + halfH * dy);
const el = flies[i];
el.style.transform = `translate(${xs[i] - 2}px, ${ys[i] - 2}px)`; // 4px 虫体:-2 对齐中心
el.style.opacity = String(0.775 + 0.225 * Math.sin(t * f.bw + f.bp)); // 呼吸区间 0.55~1
}
for (const c of active) {
let hit = false;
for (let i = 0; i < xs.length; i++) {
const ddx = c.x - xs[i];
const ddy = c.y - ys[i];
if (ddx * ddx + ddy * ddy <= R2) {
hit = true;
break;
}
}
if (hit) {
c.until = t + HOLD;
if (!c.lit) {
c.lit = true;
c.el.classList.add('hm-lit'); // 高亮是单值状态:重复进入不叠加、无爆闪
}
} else if (c.lit && c.until < t) {
c.lit = false;
c.el.classList.remove('hm-lit'); // 回落交给 CSS 800ms 指数衰减,终点是原 ink 底色
}
}
};
// 离视口停 rAF、回来续跑;rAF 时间戳连续,相位无缝衔接
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting && !raf) raf = requestAnimationFrame(frame);
else if (!e.isIntersecting && raf) {
cancelAnimationFrame(raf);
raf = 0;
}
}
},
{ threshold: 0.15 },
).observe(stage);
})();
</script>
<style>
.hm-v-stage {
position: relative;
}
.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;
/* 点亮走 120ms 快通道(.hm-lit 覆写时长),离开回落 800ms easeOutExpo≈指数衰减;
终点永远是原 ink 底色——高亮只借道 filter/box-shadow,不改 background */
transition:
filter 800ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 800ms cubic-bezier(0.16, 1, 0.3, 1);
}
.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); }
.hm-lit {
filter: brightness(1.7);
box-shadow: 0 0 6px color-mix(in oklab, var(--accent) 45%, transparent);
transition-duration: 120ms;
}
.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);
}
/* 虫体浮层:位置由引擎 transform 驱动;初始 opacity:0 防 JS 未跑时滞在左上角 */
.hm-fly {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in oklab, #ffd27d 68%, var(--accent));
box-shadow: 0 0 5px 1px color-mix(in oklab, #ffd27d 68%, var(--accent));
opacity: 0;
pointer-events: none;
z-index: 2;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.hm-fly { display: none; }
.hm-c { transition: none; }
}
</style>
+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>
+325
View File
@@ -0,0 +1,325 @@
---
// 热力图动效候选「织锦」(Loom):一枚梭子逐周穿行,途经的活跃格成结,织完整幅呼吸一次再重来。
// 数据与现役 Heatmap 同源(src/lib/heatmap.ts);reduced-motion 下引擎不启动,静态网格即成品。
import { loadHeatmap, levelOf } from '../../../lib/heatmap';
interface Props {
title: string;
concept: string;
}
const { title, concept } = Astro.props;
let weeks: import('../../../lib/heatmap').HeatDay[][] = [];
try {
({ weeks } = await loadHeatmap());
} catch {
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
}
// 纱线渐变尾:分 6 段递减透明度(单个 polyline 无法做沿路径的透明度渐变)
const YARN_SEGS = 6;
---
<section class="hm-v" id="hm-loom">
<h3 class="hm-v-title">{title}</h3>
<p class="hm-v-concept">{concept}</p>
<div class="hm-v-stage" data-hm-loom>
<div class="hm-grid">
{weeks.map((week) =>
week.map((day) => (
<span
class:list={['hm-c', `hm-l${levelOf(day.count)}`]}
data-count={day.count}
data-date={day.date}
title={`${day.date} · ${day.count}`}
aria-hidden="true"
/>
))
)}
</div>
<!-- 浮层(梭子 + 纱线):默认透明,引擎启动后才显示;无 JS / reduced-motion 时保持静态可读。
纱线段写在模板里而非 JS 创建——Astro scoped 样式只作用于构建期元素,JS 造的节点拿不到样式。 -->
<svg class="hm-yarn" aria-hidden="true">
{Array.from({ length: YARN_SEGS }).map(() => <polyline class="seg" points="" />)}
</svg>
<span class="hm-shuttle" aria-hidden="true" />
</div>
</section>
<script>
// 织锦引擎:梭子按列(周)S 形穿行,被经过的活跃格弹一次结,梭尾拖一段渐隐纱线;
// 全幅织完歇 ~3s,网格呼吸一次(scale 1.008 → 1),然后从最左列重来。
// 坐标纪律:一律取 cell.offsetLeft/offsetTop——.hm-grid 是 static,offsetParent 恰好就是
// .hm-v-stage,所以这两个值本身就是相对 stage 的坐标;手算节距会在改格子尺寸时静默错位。
type Pt = { x: number; y: number; el: HTMLElement; row: number };
const COL_MS = 120; // 一列(一周)穿行时长
const REST_MS = 3000; // 织完之后的静默
const BREATH_MS = 900; // 与 .hm-grid.is-breathe 的 animation-duration 对齐
const TRAIL_MAX = 96; // 纱线取样上限:够 ~1.5s 行程,再长就成了整幅描边
const startLoom = (stage: HTMLElement) => {
// 减少动态效果:引擎完全不启动(静态网格 + 原生日计数 tooltip 已是完整成品)
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (stage.dataset.hmLoomOn === '1') return; // 客户端路由/HMR 二次执行时别双开 rAF
const grid = stage.querySelector<HTMLElement>('.hm-grid');
const shuttle = stage.querySelector<HTMLElement>('.hm-shuttle');
const segs = [...stage.querySelectorAll<SVGPolylineElement>('.hm-yarn .seg')];
if (!grid || !shuttle || !segs.length) return;
const cells = [...grid.querySelectorAll<HTMLElement>('.hm-c')];
const PER_COL = 7; // 一周 7 天(lib 保证)
if (cells.length < PER_COL * 2) return;
stage.dataset.hmLoomOn = '1';
// 路径点 = 逐列 S 形穿行:偶数列自上而下、奇数列自下而上,于是列尾恰好是下一列列首的行,
// 过列边界就自然衔接(不需要额外的横向段,也不会出现折返跳变)。
const waypoints: Pt[] = [];
const cols = Math.ceil(cells.length / PER_COL);
for (let c = 0; c < cols; c++) {
const rows = [...Array(PER_COL).keys()];
if (c % 2 === 1) rows.reverse();
for (const r of rows) {
const el = cells[c * PER_COL + r];
if (!el) continue;
waypoints.push({
x: el.offsetLeft + el.offsetWidth / 2,
y: el.offsetTop + el.offsetHeight / 2,
el,
row: r,
});
}
}
if (waypoints.length < 2) return;
const lastIdx = waypoints.length - 1;
const SPEED = lastIdx / (cols * COL_MS); // waypoint / ms:整幅恰好 cols × COL_MS 织完
const HALF_SHUTTLE_W = shuttle.offsetWidth / 2;
const HALF_SHUTTLE_H = shuttle.offsetHeight / 2;
const trail: { x: number; y: number }[] = [];
let raf = 0;
let last = 0;
let phase: 'weave' | 'rest' | 'breathe' = 'weave';
let elapsed = 0;
let progress = 0;
let reached = -1;
const place = (x: number, y: number) => {
shuttle.style.transform = `translate(${(x - HALF_SHUTTLE_W).toFixed(1)}px, ${(y - HALF_SHUTTLE_H).toFixed(1)}px)`;
};
const posAt = (p: number) => {
const i = Math.min(lastIdx - 1, Math.floor(p));
const t = p - i;
const a = waypoints[i];
const b = waypoints[Math.min(lastIdx, i + 1)];
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
};
const paintYarn = () => {
const per = Math.ceil(TRAIL_MAX / segs.length);
for (let k = 0; k < segs.length; k++) {
// trail[0] 是最新点,所以第 k 段(CSS 里 k 越大越亮)要取靠近数组头的切片
const from = (segs.length - 1 - k) * per;
const pts = trail.slice(from, from + per + 1); // +1 让相邻段接上,纱线才不断
segs[k].setAttribute('points', pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '));
}
};
// 成结:只给运动/瞬时高亮(accent 光环 + 微弹),格子底色始终是 ink 档位语义
const knot = (p: Pt) => {
if (Number(p.el.dataset.count ?? 0) <= 0) return; // 空格不成结——密度只由 ink 档位表达
if (p.el.classList.contains('is-knot')) return;
p.el.style.animationDelay = `${p.row * 10}ms`; // 按行错峰,才有"一梭织进去"的手感
p.el.classList.add('is-knot');
};
grid.addEventListener('animationend', (e) => {
const el = e.target as HTMLElement;
if (el.classList.contains('is-knot')) el.classList.remove('is-knot');
});
const frame = (now: number) => {
raf = requestAnimationFrame(frame);
// 从后台标签页切回来时 dt 会很大:夹住,否则一帧就跨完整幅、看起来像跳帧
const dt = Math.min(now - last || 16, 64);
last = now;
if (phase === 'weave') {
progress = Math.min(lastIdx, progress + dt * SPEED);
const pos = posAt(progress);
place(pos.x, pos.y);
if (!trail.length || Math.hypot(pos.x - trail[0].x, pos.y - trail[0].y) >= 1.2) {
trail.unshift(pos);
if (trail.length > TRAIL_MAX) trail.pop();
}
paintYarn();
const at = progress >= lastIdx ? lastIdx : Math.floor(progress);
while (reached < at) {
reached++;
knot(waypoints[reached]);
}
if (progress >= lastIdx) {
phase = 'rest';
elapsed = 0;
stage.classList.remove('is-live'); // 梭子与纱线一起淡出(过渡交给 CSS)
}
return;
}
if (phase === 'rest') {
elapsed += dt;
if (elapsed >= REST_MS) {
phase = 'breathe';
elapsed = 0;
grid.classList.add('is-breathe');
}
return;
}
elapsed += dt;
if (elapsed >= BREATH_MS) {
grid.classList.remove('is-breathe');
phase = 'weave';
elapsed = 0;
progress = 0;
reached = -1;
trail.length = 0;
paintYarn();
stage.classList.add('is-live');
}
};
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting && !raf) {
stage.classList.add('is-live');
last = performance.now();
raf = requestAnimationFrame(frame);
} else if (!e.isIntersecting && raf) {
cancelAnimationFrame(raf);
raf = 0;
}
}
},
{ threshold: 0.15 },
).observe(stage);
};
document.querySelectorAll<HTMLElement>('[data-hm-loom]').forEach(startLoom);
</script>
<style>
.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);
margin: 4px 0 var(--space-3);
}
/* 浮层定位基准:格子是 static 网格的子项,offsetParent 因此是 stage(引擎据此取坐标) */
.hm-v-stage {
position: relative;
}
.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;
}
/* 灰阶四档即数据密度(亮暗两套 token 自动跟随);accent 不参与底色 */
.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); }
/* ---- 织锦动效层(默认不可见,引擎加 .is-live 后才出现) ---- */
.hm-shuttle {
position: absolute;
left: 0;
top: 0;
width: 8px;
height: 3px;
border-radius: 2px;
background: var(--accent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink-1) 55%, transparent), 0 0 7px color-mix(in oklab, var(--accent) 55%, transparent);
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 240ms var(--ease);
}
.hm-v-stage.is-live .hm-shuttle {
opacity: 1;
}
.hm-yarn {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: visible;
opacity: 0;
pointer-events: none;
z-index: 2;
transition: opacity 240ms var(--ease);
}
.hm-v-stage.is-live .hm-yarn {
opacity: 1;
}
.hm-yarn .seg {
fill: none;
stroke: var(--accent);
stroke-width: 1;
stroke-linecap: round;
stroke-linejoin: round;
}
/* 段序 = 旧 → 新(引擎切片顺序与之对应):末梢渐隐成"纱" */
.hm-yarn .seg:nth-child(1) { stroke-opacity: 0.1; }
.hm-yarn .seg:nth-child(2) { stroke-opacity: 0.2; }
.hm-yarn .seg:nth-child(3) { stroke-opacity: 0.32; }
.hm-yarn .seg:nth-child(4) { stroke-opacity: 0.46; }
.hm-yarn .seg:nth-child(5) { stroke-opacity: 0.28; }
.hm-yarn .seg:nth-child(6) { stroke-opacity: 0.35; }
/* 成结:微弹一次即回原状——animation-fill-mode both 保证延迟期间不提前染色 */
.hm-c.is-knot {
animation: hm-knot 350ms var(--ease) both;
}
@keyframes hm-knot {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--accent) 0%, transparent);
}
32% {
transform: scale(1.25);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 38%, transparent);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--accent) 0%, transparent);
}
}
/* 全图呼吸:作用在网格上(transform 不动布局,浮层坐标也不受影响) */
.hm-grid.is-breathe {
animation: hm-breath 900ms var(--ease) both;
}
@keyframes hm-breath {
0% { transform: scale(1); }
40% { transform: scale(1.008); }
100% { transform: scale(1); }
}
</style>
+220
View File
@@ -0,0 +1,220 @@
---
// 热力图 v5 预览方案「潮汐」:一道 135° 斜向光带周期性从左下扫向右上,
// 带锋经过的格子短暂提亮并微微上浮,带过即按列位错峰回落——
// 像潮线退去时在沙滩上留下的一层层水痕。
// 本文件是独立预览件:只新增、不改既有、零依赖零请求。
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-tide">
<h3 class="hm-v-title">{title}</h3>
<p class="hm-v-concept">{concept}</p>
<div class="hm-v-stage" data-hm-tide>
<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>
// ── 引擎契约(与 InkBloom 同守)──
// 1) reduced-motion → 完全不启动:静态网格本身就是完整的可读形态
// 2) IntersectionObserver(0.15) 离视口停 rAF,回来续跑(时间轴平移而非追赶)
// 3) 每帧只写光带 transform 一行;格子只在进出阈值时改类(无逐格逐帧样式写入)
// 4) 提亮必须回落;回落按列位 stagger 出"潮线层叠"
const ANCHOR = '.hm-v-stage[data-hm-tide]';
const SWEEP_MS = 7000; // 一次完整扫掠
const REST_MS = 1500; // 两潮之间的歇息
const NEAR_PX = 40; // 带锋与格子的判定阈值(水平距离近似)
interface Cell {
el: HTMLElement;
left: number; // offsetLeft 的 offsetParent 是 stage(position:relative) → 即浮层坐标系
lit: boolean;
timer: number;
}
const boot = (stage: HTMLElement) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const nodes = Array.from(stage.querySelectorAll<HTMLElement>('.hm-c'));
if (nodes.length === 0) return;
const stageW = stage.clientWidth;
// 列位(0..1)用来给回落排序:右边的格子晚一点退,读出"潮线"的层次
const cells: Cell[] = nodes.map((el) => ({
el,
left: el.offsetLeft,
lit: false,
timer: 0,
}));
const cx = stageW / 2;
// 光带:宽 60px、135° 方向(带轴为"\"),运动方向 (X,-X) 恰好垂直穿过它——
// 这样 translate 扫过时才是一道"面"在推进,而不是沿着自身滑行
const band = document.createElement('div');
band.className = 'hm-tide-band';
stage.appendChild(band);
let bandOn = false;
const showBand = (on: boolean) => {
if (on === bandOn) return;
bandOn = on;
// 透明度交给 WAAPI(淡入淡出一次性),rAF 里只碰 transform
band.animate([{ opacity: on ? 0 : 1 }, { opacity: on ? 1 : 0 }], {
duration: on ? 500 : 700,
easing: 'ease-out',
fill: 'forwards',
});
};
// 扫掠行程:从左带完全出画,到右带完全出画
const x0 = -(cx + 70);
const x1 = stageW - cx + 70;
let phase: 'sweep' | 'rest' = 'sweep';
let t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
if (phase === 'sweep') {
const p = Math.min((now - t0) / SWEEP_MS, 1);
const X = x0 + (x1 - x0) * p;
// 每帧唯一的样式写入:光带位移(合成器友好)
band.style.transform = `translate(${X}px, ${-X}px) rotate(135deg)`;
// 带锋的水平位置:格子只在进出 |left - front| ≤ 阈值 时改类
const front = cx + X;
for (const c of cells) {
const near = Math.abs(c.left - front) <= NEAR_PX;
if (near && !c.lit) {
c.lit = true;
window.clearTimeout(c.timer);
c.el.classList.add('hm-lit');
} else if (!near && c.lit) {
c.lit = false;
// 回落按列位 stagger:越靠右退得越晚 → 月与月之间呈潮线层叠
window.clearTimeout(c.timer);
c.timer = window.setTimeout(
() => c.el.classList.remove('hm-lit'),
100 + (c.left / stageW) * 500,
);
}
}
if (p >= 1) {
phase = 'rest';
t0 = now;
showBand(false);
}
} else if (now - t0 > REST_MS) {
phase = 'sweep';
t0 = now;
// 先归位再淡入:否则新一带会在旧位置(右侧)现形后跳回左下
band.style.transform = `translate(${x0}px, ${-x0}px) rotate(135deg)`;
showBand(true);
}
raf = requestAnimationFrame(tick);
};
// 可见性闸门:离视口即停,回来平移 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);
} else {
pausedAt = performance.now();
cancelAnimationFrame(raf);
}
}
},
{ threshold: 0.15 },
);
io.observe(stage);
// 首带:先摆到起点位置再淡入,避免中心位置闪现一帧
band.style.transform = `translate(${x0}px, ${-x0}px) rotate(135deg)`;
showBand(true);
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;
/* 提亮/上浮与回落共用一条 ~500ms 过渡;translate 是视觉位移,不破 grid 布局 */
transition: filter 0.5s var(--ease, ease), transform 0.5s 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); }
/* 带锋经过:提亮 + 1px 上浮;类撤掉时按上面的 transition 回落到档位底色 */
.hm-c.hm-lit {
filter: brightness(1.6);
transform: translateY(-1px);
}
/* 潮汐光带:accent 低透明度渐变(5-10%),运动方向与带轴垂直;
定位锚在 stage 中心(left:50% + 负 margin / top:-35% + height:170%),
JS 只追加 translate(X,-X) rotate(135deg) */
.hm-tide-band {
position: absolute;
left: 50%;
top: -35%;
width: 60px;
height: 170%;
margin-left: -30px;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--accent) 9%, transparent) 50%, transparent);
will-change: transform;
opacity: 0;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
---
// 热力图 v5 预览页(一次性路由):贪吃蛇之上的五个更"美观向"方案并陈,
// 供用户点选;选定后落地正式开源页并删除本路由与 lint 白名单(预览-选型-落地模式)。
// 五个方案组件由多队友并行产出(契约见各组件头注),全部共用 loadHeatmap 真实数据。
import BaseLayout from '../layouts/BaseLayout.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import InkBloom from '../components/geek/hm/InkBloom.astro';
import Tide from '../components/geek/hm/Tide.astro';
import Comet from '../components/geek/hm/Comet.astro';
import Loom from '../components/geek/hm/Loom.astro';
import Fireflies from '../components/geek/hm/Fireflies.astro';
import { t } from '../i18n/ui';
---
<BaseLayout title="热力图方案预览 · wait4x">
<h1 class="page-title">热力图 v5 · 候选方案</h1>
<p class="preview-note">
五个候选均在现役「智能寻食蛇」之上重做动效语言——更安静、更文艺。数据同源(真实贡献),
reduced-motion 下全部退化为静态网格。选定后我会把它落进开源页并删除本预览页。
</p>
<section class="ref">
<h2 class="ref-title">现役参考 · P1 智能寻食蛇</h2>
<Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
}} />
</section>
<InkBloom title="方案一 · 墨染" concept="一年的贡献按时间顺序洇墨——光晕落在哪格,哪格就深一分,扫完一年再让墨光整体掠过一次。" />
<Tide title="方案二 · 潮汐" concept="一道斜向光带周期性漫过贡献滩涂,带锋经过处格子微升微亮,像潮水一层层退去。" />
<Comet title="方案三 · 星轨" concept="格子即星,亮度即贡献;一颗彗星沿正弦轨迹横掠全年,被扫过的星各自闪一下。" />
<Loom title="方案四 · 织锦" concept="一枚梭子逐周穿行织过整年,活跃的格子依次成结;织完整幅轻轻呼吸一次。" />
<Fireflies title="方案五 · 萤火" concept="五只萤火虫在贡献的原野上游荡,落在哪格哪格亮起,离开后慢慢熄回原色。" />
</BaseLayout>
<style>
.page-title { font-size: var(--text-h1); color: var(--ink-1); }
.preview-note { font-size: 13px; color: var(--ink-3); margin: var(--space-3) 0 var(--space-6); }
.ref { margin-bottom: var(--space-6); }
.ref-title {
font-family: var(--font-mono); font-size: 14px; color: var(--ink-3);
letter-spacing: 0.08em; margin-bottom: var(--space-3);
}
/* 五案纵向排开留出比较空间;组件自身的标题/网格样式各自 scoped */
:global(section.hm-v) { margin: var(--space-6) 0; }
</style>