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:
1 parent
dac34edd9a
commit
97a56f3c89
7 files changed
+1290
-1
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user