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
@@ -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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user