Files
blog/src/components/geek/hm/Tide.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

221 lines
7.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 预览方案「潮汐」:一道 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>