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,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>
|
||||
Reference in new issue
Block a user