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

+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>