Merge branch 'spec/2026-11-05_blog-5m-heatmap-fireflies'
Phase 5m 交付:热力图 v5 选型闭环——用户在五案预览页选定「萤火」 (虫数 4),引擎落进正式开源页:贪吃蛇与月柱点亮联动退役(月柱转 静态定高),reduced-motion/无 JS 兜底不变;预览路由、五个候选组件、 lint 两处白名单全部退役,E2E 补萤火断言(61/61)。 Spec: 2026-11-05_blog-5m-heatmap-fireflies
This commit is contained in:
commit
cf331ccf3d
14 files changed
+321
-1411
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
# blog-5m:热力图定稿萤火(4 只)落地正式开源页 - 完成总结
|
||||||
|
|
||||||
|
## 交付结论
|
||||||
|
- 结果:完成
|
||||||
|
- 完成时间:2026-11-05 11:21
|
||||||
|
|
||||||
|
## 假设回顾
|
||||||
|
### 已验证假设
|
||||||
|
- 月柱在无「点亮联动」后仍有信息价值(静态高度=哪个月最忙),保留不删
|
||||||
|
- 萤火引擎的 offsetLeft/offsetTop 定位对官方组件的 11px/10px 双档网格同样成立(布局实测,零手算节距)
|
||||||
|
|
||||||
|
### 仍未完全验证的假设
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 交付范围
|
||||||
|
### 已交付
|
||||||
|
- task_0001 萤火落地与预览退役
|
||||||
|
- task_0002 生产核验(manual: 部署后人工核验)
|
||||||
|
|
||||||
|
### 未交付
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 偏差说明
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 简化决策
|
||||||
|
### 保持简单的关键选择
|
||||||
|
- 最简可行:Heatmap.astro 三处手术(浮层标记换 4 只 hm-fly、script 换萤火引擎、style 换 hm-fly/hm-lit+月柱静态)+ 删三类一次性资产 + E2E 一条断言
|
||||||
|
- 暂不引入: 虫数可配置化
|
||||||
|
- 不做的抽象: 「动效引擎注册表」(只有一种引擎,抽象无收益)
|
||||||
|
- 范围级别:package
|
||||||
|
- 变更对象:开源页热力图组件与一次性预览资产
|
||||||
|
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
|
||||||
|
- 集成检查:E2E(含萤火新断言)+ content-lint 0/0/0 + grep 断言产物(无 heatmap-preview、无 hm-head、4 只 hm-fly)
|
||||||
|
- 全项目检查:生产部署后人工核验 /opensource/ 萤火在跑、/heatmap-preview/ 404;**升级条件**——仅当改动触及 BaseLayout 或构建链时才升级全站回归,本包不涉及
|
||||||
|
- 升级触发:开源页独立区块,升级为「该区块人工核验」即可
|
||||||
|
|
||||||
|
### 本轮明确不做的内容
|
||||||
|
- 萤火参数再调(数量/速度/半径——用户未提,不为未发生的需求加工)
|
||||||
|
|
||||||
|
## 变更边界
|
||||||
|
### 本轮主要改动模块
|
||||||
|
- 见 tasks.md 的 boundary 记录
|
||||||
|
|
||||||
|
### 明确未触碰的区域
|
||||||
|
- 萤火参数再调(数量/速度/半径——用户未提,不为未发生的需求加工)
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
### 备注
|
||||||
|
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
- 级别:package
|
||||||
|
- 机器验证率:1/1;manual 任务 1 项不计入分母
|
||||||
|
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||||
|
|
||||||
|
## 哲学生效证据
|
||||||
|
### 行为成效回填
|
||||||
|
- Development Record 行为门禁通过
|
||||||
|
|
||||||
|
### 一致性门禁回顾
|
||||||
|
- 验收面(checklist 或成功标准)已通过
|
||||||
|
|
||||||
|
## Git 记录
|
||||||
|
- 日期时间:2026-11-05 11:21
|
||||||
|
- 范围:blog-5m:热力图定稿萤火(4 只)落地正式开源页
|
||||||
|
- 功能:task_0001 萤火落地与预览退役;task_0002 生产核验(manual: 部署后人工核验)
|
||||||
|
- 操作:提交 / 推送
|
||||||
|
- 成效:Development Record 行为门禁通过
|
||||||
|
- 提交:pending local commit
|
||||||
|
- 提交区间:44cd62e9a2a5fc0f400de2862a3a07c5686bf25e..44cd62e9a2a5fc0f400de2862a3a07c5686bf25e
|
||||||
|
- 推送:not-run (push stage follows commit)
|
||||||
|
|
||||||
|
## AGENTS.md 增量提议
|
||||||
|
```diff
|
||||||
|
热力图 v5 选型闭环:用户在五案预览页选定萤火并指定虫数 4——引擎落进正式开源页(贪吃蛇与月柱点亮联动退役,月柱转静态),预览路由/五候选组件/lint 白名单全部退役,E2E 补萤火断言(61/61)。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 被拒绝的扩展提议
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 门禁证据
|
||||||
|
- check_spec_package.py exit 0
|
||||||
|
|
||||||
|
## 遗留事项
|
||||||
|
- 无;问题处置见下方结构化区块
|
||||||
|
|
||||||
|
## 问题处置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"issues": [],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# task_0001 萤火落地与预览退役:构建 + 产物断言 + lint + E2E
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
export PATH="$HOME/.local/node/bin:$PATH"
|
||||||
|
pnpm astro build >/dev/null 2>&1
|
||||||
|
[ ! -f src/pages/heatmap-preview.astro ]
|
||||||
|
[ ! -d src/components/geek/hm ]
|
||||||
|
! grep -q heatmap-preview scripts/build-perf/content-lint.mjs
|
||||||
|
! grep -q hm-head src/components/geek/Heatmap.astro
|
||||||
|
[ "$(grep -o 'class="hm-fly"' dist/opensource/index.html | wc -l)" -eq 4 ]
|
||||||
|
node scripts/build-perf/content-lint.mjs dist 2>&1 | grep -q "通过"
|
||||||
|
nohup python3 scripts/build-perf/gz-server.py 18925 "$PWD/dist" >/tmp/gz-5m.log 2>&1 &
|
||||||
|
SRV=$!
|
||||||
|
sleep 2
|
||||||
|
trap 'kill $SRV 2>/dev/null || true' EXIT
|
||||||
|
E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: 61/61 PASS"
|
||||||
|
echo "verify_t1 OK"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5m-heatmap-fireflies/evidence/verify_t1.sh", "exit": 0, "duration_s": 44.525, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "e364e39e9d06d1dcfe55ea481995d132f94854ed078cb251c9fa4fa9a6af1202", "at": "2026-11-05T03:21:15+00:00"}
|
||||||
|
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5m-heatmap-fireflies/evidence/verify_t1.sh", "exit": 0, "duration_s": 43.513, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "e364e39e9d06d1dcfe55ea481995d132f94854ed078cb251c9fa4fa9a6af1202", "at": "2026-11-05T03:21:58+00:00"}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<!-- spec-format: 2 -->
|
||||||
|
# blog-5m:热力图定稿萤火(4 只)落地正式开源页
|
||||||
|
|
||||||
|
- **slug**: 2026-11-05_blog-5m-heatmap-fireflies
|
||||||
|
- **日期**: 2026-11-05
|
||||||
|
- **状态**: 进行中
|
||||||
|
- Git integration branch:`spec/2026-11-05_blog-5m-heatmap-fireflies`
|
||||||
|
- 证据协议:evidence: runs
|
||||||
|
- **提案**: 用户在 /heatmap-preview/ 五案中选定方案五「萤火」并要求虫数 5→4。把萤火引擎落进正式 Heatmap.astro(换下 P1 贪吃蛇),删预览路由/五候选组件/lint 白名单,E2E 补萤火断言。
|
||||||
|
|
||||||
|
## 1. 问题定义
|
||||||
|
- **项目目标**: 开源页热力图动效从贪吃蛇切换为萤火(4 只,暖色游走、落格点亮),保持数据语义(ink 档位=密度)与无障碍兜底(reduced-motion 静态、无 JS 可读)不变;一次性预览资产全部退役。
|
||||||
|
- **目标用户**: 读者(更安静文气的动效);站长(选定方案的落地闭环)
|
||||||
|
- **核心价值**: 选型闭环——预览页五案并陈→用户点选→落地删预览的工作流第三次跑通,站点只留定稿资产
|
||||||
|
|
||||||
|
## 2. 假设与待确认
|
||||||
|
|
||||||
|
### 2.1 已确认事实
|
||||||
|
- 萤火引擎已在预览页跑通(/heatmap-preview/ 方案五),视觉复核判词无硬伤、色向已转暖
|
||||||
|
- 官方 Heatmap.astro 现役结构:streak 行 + 蛇引擎(hm-head/hm-trail/eaten + 月柱 .on 联动)+ 月柱 + 图例 + 横滚锚定
|
||||||
|
- E2E 无蛇头断言(仅注释),补萤火断言无旧断言要改
|
||||||
|
|
||||||
|
### 2.2 关键假设
|
||||||
|
- 月柱在无「点亮联动」后仍有信息价值(静态高度=哪个月最忙),保留不删
|
||||||
|
- 萤火引擎的 offsetLeft/offsetTop 定位对官方组件的 11px/10px 双档网格同样成立(布局实测,零手算节距)
|
||||||
|
|
||||||
|
### 2.3 待确认问题
|
||||||
|
- [x] 虫数 4(用户明确:5→4)
|
||||||
|
- [x] 贪吃蛇整体退役(用户选新方案即换下,git 历史留档)
|
||||||
|
|
||||||
|
### 2.4 可选解释与取舍
|
||||||
|
- 五个候选组件目录整体删除而非保留:git 历史即档案,工作树不留死代码
|
||||||
|
- 月柱保留静态形态:删掉会丢「月度粗粒度」信息,联动点亮属蛇的语义,随蛇退役
|
||||||
|
|
||||||
|
### 2.5 已证伪路径
|
||||||
|
- 无(本包是纯落地,无新设计探索)
|
||||||
|
|
||||||
|
## 3. 功能范围
|
||||||
|
|
||||||
|
### 3.1 核心功能(MVP)
|
||||||
|
- [x] T1 萤火落地:Heatmap.astro 换引擎(4 虫、data-hm-fire 锚、hm-lit 样式、月柱转静态)、删预览路由+五候选组件+lint 白名单两处、E2E 补萤火断言
|
||||||
|
|
||||||
|
### 3.2 扩展功能
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 3.3 不在范围内
|
||||||
|
- 萤火参数再调(数量/速度/半径——用户未提,不为未发生的需求加工)
|
||||||
|
|
||||||
|
## 4. 最小实现路径
|
||||||
|
- 最简可行:Heatmap.astro 三处手术(浮层标记换 4 只 hm-fly、script 换萤火引擎、style 换 hm-fly/hm-lit+月柱静态)+ 删三类一次性资产 + E2E 一条断言
|
||||||
|
- 暂不引入: 虫数可配置化
|
||||||
|
- 不做的抽象: 「动效引擎注册表」(只有一种引擎,抽象无收益)
|
||||||
|
|
||||||
|
### 5.1 验证策略
|
||||||
|
- 范围级别:package
|
||||||
|
- 变更对象:开源页热力图组件与一次性预览资产
|
||||||
|
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
|
||||||
|
- 集成检查:E2E(含萤火新断言)+ content-lint 0/0/0 + grep 断言产物(无 heatmap-preview、无 hm-head、4 只 hm-fly)
|
||||||
|
- 全项目检查:生产部署后人工核验 /opensource/ 萤火在跑、/heatmap-preview/ 404;**升级条件**——仅当改动触及 BaseLayout 或构建链时才升级全站回归,本包不涉及
|
||||||
|
- 升级触发:开源页独立区块,升级为「该区块人工核验」即可
|
||||||
|
|
||||||
|
## 6. 成功标准与验证方式
|
||||||
|
- [x] 正式开源页萤火 4 只在跑、预览资产全退役 -> verify: task_0001
|
||||||
|
- [x] 生产 /opensource/ 人工核验 + /heatmap-preview/ 404 -> verify: task_0002(manual: 部署后人工核验)
|
||||||
|
|
||||||
|
## 7. 关联与风险
|
||||||
|
- 前序:v4 路径优化(P1 蛇)、v5 五案预览(44cd62e)
|
||||||
|
- 风险:萤火引擎在官方组件的 11px 移动档网格上包围盒内缩 6px 后可用幅度变小——若视觉过挤,回退方案是内缩降为 3px(本包不预设,部署后看)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Phase 5m:热力图萤火落地 - 任务拆解
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
- E2E 口径:`pnpm astro build → gz-server.py 18925 <dist 绝对路径> → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`
|
||||||
|
|
||||||
|
## 阶段一:实现
|
||||||
|
|
||||||
|
- [x] task_0001 萤火落地与预览退役
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: src/components/geek/Heatmap.astro(换引擎)、tests/e2e.mjs(断言)、删除 src/pages/heatmap-preview.astro、删除 src/components/geek/hm/、scripts/build-perf/content-lint.mjs(白名单两处退役)
|
||||||
|
- verify: `bash .spec/specs/2026-11-05_blog-5m-heatmap-fireflies/evidence/verify_t1.sh`
|
||||||
|
|
||||||
|
- [x] task_0002 生产核验(manual: 部署后人工核验)
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: 无代码改动;生产 /opensource/ 萤火 4 只在跑 + /heatmap-preview/ 404
|
||||||
|
- verify: manual: 部署后人工核验
|
||||||
@@ -14,13 +14,7 @@ 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,另有部署链保障)
|
||||||
// 已知不存在的 en 孪生目标(见 targetExists 内注释)
|
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
|
||||||
const KNOWN_MISSING_TWIN = new Set(['/en/heatmap-preview/']);
|
|
||||||
const SKIP_FILE = new Set([
|
|
||||||
'/404.html', // Astro 404 页不参与配对
|
|
||||||
// 一次性预览路由(预览-选型-落地模式):只有 zh 版,配对检查豁免;选定后随路由一起删
|
|
||||||
'/heatmap-preview/index.html',
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -45,9 +39,6 @@ function decodePath(p) {
|
|||||||
/** /x/ → dist/x/index.html;/a.svg → dist/a.svg;找不到即 null */
|
/** /x/ → dist/x/index.html;/a.svg → dist/a.svg;找不到即 null */
|
||||||
function targetExists(url) {
|
function targetExists(url) {
|
||||||
if (SKIP_PREFIX.some((s) => url.startsWith(s))) return true;
|
if (SKIP_PREFIX.some((s) => url.startsWith(s))) return true;
|
||||||
// 一次性预览路由的 en 孪生:语言切换器会给预览页生成 /en/… 链接,而预览只有 zh 版
|
|
||||||
// (页面本体已在 SKIP_FILE 豁免配对检查;此处豁免切换器链接,选定后随路由一起删)
|
|
||||||
if (KNOWN_MISSING_TWIN.has(url.split('#')[0].split('?')[0])) return true;
|
|
||||||
const p = decodePath(url.split('#')[0].split('?')[0]);
|
const p = decodePath(url.split('#')[0].split('?')[0]);
|
||||||
if (!p || p === '/') return existsSync(join(DIST, 'index.html'));
|
if (!p || p === '/') return existsSync(join(DIST, 'index.html'));
|
||||||
if (p.endsWith('/')) return existsSync(join(DIST, p, 'index.html'));
|
if (p.endsWith('/')) return existsSync(join(DIST, p, 'index.html'));
|
||||||
|
|||||||
+116
-116
@@ -51,9 +51,8 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
|
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
|
||||||
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
|
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="hm-stage" data-snake>
|
<div class="hm-stage" data-hm-fire>
|
||||||
<span class="hm-head" aria-hidden="true"></span>
|
{[0, 1, 2, 3].map(() => <i class="hm-fly" aria-hidden="true" />)}
|
||||||
<span class="hm-trail" aria-hidden="true"></span>
|
|
||||||
<div class="hm-scroll" data-anchor-latest>
|
<div class="hm-scroll" data-anchor-latest>
|
||||||
<div class="hm-grid" role="img" aria-label={summary}>
|
<div class="hm-grid" role="img" aria-label={summary}>
|
||||||
{weeks.map((week) =>
|
{weeks.map((week) =>
|
||||||
@@ -78,7 +77,6 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<noscript><style>.hm-bar-fill{height:var(--h,0%)!important}</style></noscript>
|
|
||||||
<p class="hm-legend">
|
<p class="hm-legend">
|
||||||
<span aria-hidden="true">{labels.less}</span>
|
<span aria-hidden="true">{labels.less}</span>
|
||||||
{[0, 1, 2, 3, 4].map((l) => (
|
{[0, 1, 2, 3, 4].map((l) => (
|
||||||
@@ -94,117 +92,108 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// P1 · 智能寻食蛇(Phase 5j 用户选定):只按「最近的有贡献格子」前进,子格插值滑行,
|
// 5m 定稿 · 萤火(v5 预览页方案五,用户选定,虫数 4):虫体拟周期游走、落格点亮。
|
||||||
// 循环播放(离开视口即停;减少动态效果下完全静态——不启动引擎)。
|
// 坐标一次读齐(offsetLeft/Top 相对 stage),此后零 layout 触碰;点亮判定只扫
|
||||||
// 坐标系:格子按列优先排(idx → 列=floor(idx/7)、行=idx%7);此前 x/y 写反导致
|
// 预收集的活跃格,且仅进入/离开时改类名——O(4)/帧。
|
||||||
// 蛇头走出下边界(2026-11-04 用户实测),修正后必须断言头坐标始终在网格包围盒内。
|
|
||||||
(() => {
|
(() => {
|
||||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
// 减少动态效果:引擎整体不启动,静态灰阶网格即完整可读形态
|
||||||
const PITCH = 13; // 10px 格 + 3px 缝
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
const stage = document.querySelector('.hm-stage');
|
const stage = document.querySelector<HTMLElement>('[data-hm-fire]');
|
||||||
if (!stage || reduce) return;
|
if (!stage) return;
|
||||||
const cells = [...stage.querySelectorAll<HTMLElement>('.hm-c')];
|
const cells = [...stage.querySelectorAll<HTMLElement>('.hm-c')];
|
||||||
if (!cells.length) return;
|
const flies = [...stage.querySelectorAll<HTMLElement>('.hm-fly')];
|
||||||
const head = stage.querySelector<HTMLElement>('.hm-head');
|
const grid = stage.querySelector<HTMLElement>('.hm-grid');
|
||||||
const trailEl = stage.querySelector<HTMLElement>('.hm-trail');
|
if (!cells.length || !flies.length || !grid) return;
|
||||||
const bars = [...document.querySelectorAll<HTMLElement>('.hm-bar-fill')];
|
|
||||||
const posOf = (idx: number) => ({ x: Math.floor(idx / 7) * PITCH, y: (idx % 7) * PITCH });
|
|
||||||
|
|
||||||
/** 最近邻路径:只走有贡献的格子(真实数据 371 格仅 ~51 天活跃,
|
const midX = grid.offsetLeft + grid.offsetWidth / 2;
|
||||||
全网扫描会让蛇 86% 行程空转——这是 P1 与 P2/P3 的本质区别) */
|
const midY = grid.offsetTop + grid.offsetHeight / 2;
|
||||||
const path = (() => {
|
const halfW = grid.offsetWidth / 2 - 6; // 内缩 6px:虫体与光晕不出包围盒
|
||||||
const pool = cells
|
const halfH = grid.offsetHeight / 2 - 6;
|
||||||
.map((c, i) => ({ i, n: Number(c.dataset.count ?? 0) }))
|
|
||||||
.filter((c) => c.n > 0)
|
|
||||||
.map((c) => c.i);
|
|
||||||
if (!pool.length) return [];
|
|
||||||
const order: number[] = [];
|
|
||||||
let cur = pool[0];
|
|
||||||
pool.splice(0, 1);
|
|
||||||
order.push(cur);
|
|
||||||
while (pool.length) {
|
|
||||||
let best = 0;
|
|
||||||
let bestD = Infinity;
|
|
||||||
const a = posOf(cur);
|
|
||||||
for (let k = 0; k < pool.length; k++) {
|
|
||||||
const b = posOf(pool[k]);
|
|
||||||
const d = Math.hypot(a.x - b.x, a.y - b.y) + Math.abs(a.y - b.y) * 0.3; // 偏好同列,少横跳
|
|
||||||
if (d < bestD) {
|
|
||||||
bestD = d;
|
|
||||||
best = k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cur = pool[best];
|
|
||||||
pool.splice(best, 1);
|
|
||||||
order.push(cur);
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
})();
|
|
||||||
if (!path.length) return;
|
|
||||||
|
|
||||||
// 子格插值:把"格到格"展开成连续坐标(段越长走得越快,节奏才不拖)
|
interface Active {
|
||||||
const samples: { x: number; y: number; idx: number }[] = [];
|
el: HTMLElement;
|
||||||
for (let k = 0; k < path.length; k++) {
|
x: number;
|
||||||
const a = posOf(path[k]);
|
y: number;
|
||||||
const b = posOf(path[(k + 1) % path.length]); // 收尾回到起点,循环才无缝
|
until: number; // 点亮保持截止时刻(去抖:判定圈边缘防类名高频翻转)
|
||||||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
lit: boolean;
|
||||||
const steps = Math.max(1, Math.round(dist / 6));
|
}
|
||||||
for (let s = 0; s < steps; s++) {
|
const active: Active[] = [];
|
||||||
const t = s / steps;
|
for (const c of cells) {
|
||||||
samples.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, idx: path[k] });
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eaten = new Set<number>();
|
// 每虫独立频率/相位:每轴双余弦叠加(幅度和 ≤0.94,到界余弦自然折返无跳变);
|
||||||
let i = 0;
|
// 频率互不成整数比 → 轨迹拟周期永不重置,契合"持续氛围"
|
||||||
let timer = 0;
|
const FLY = flies.map((_, i) => ({
|
||||||
const trail: { x: number; y: number }[] = [];
|
fx: [0.00013 * (1 + i * 0.31), 0.00047 * (1 - i * 0.07)],
|
||||||
const paintTrail = () => {
|
fy: [0.00011 * (1 + i * 0.19), 0.00053 * (1 + i * 0.11)],
|
||||||
if (!trailEl) return;
|
px: [i * 2.4, i * 2.4 + 1.7],
|
||||||
trailEl.style.boxShadow = trail
|
py: [i * 2.4 + 3.1, i * 2.4 + 4.9],
|
||||||
.map((pt, k) => `${pt.x + 5}px ${pt.y + 5}px 0 ${Math.max(1, 4 - k)}px color-mix(in oklab, var(--accent) ${42 - k * 5}%, transparent)`)
|
ax: [0.62, 0.32],
|
||||||
.join(',');
|
ay: [0.58, 0.36],
|
||||||
};
|
bw: 0.0009 * (1 + i * 0.21), // 呼吸:逐虫独立周期
|
||||||
const monthsReal = (() => {
|
bp: i * 1.9,
|
||||||
const set = new Set<string>();
|
}));
|
||||||
cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
|
|
||||||
return [...set].filter(Boolean).sort();
|
|
||||||
})();
|
|
||||||
|
|
||||||
const tick = () => {
|
const HOLD = 150;
|
||||||
const s = samples[i++];
|
const R2 = 36; // 6px 判定半径的平方——免开方
|
||||||
if (!s) return;
|
|
||||||
// 蛇头坐标必须落在网格包围盒内(列 0..52、行 0..6)——越界即坐标轴 bug 的信号
|
let raf = 0;
|
||||||
const maxX = Math.floor(cells.length / 7) * PITCH;
|
const frame = (t: number) => {
|
||||||
const maxY = 6 * PITCH;
|
raf = requestAnimationFrame(frame);
|
||||||
if (s.x > maxX || s.y > maxY) return;
|
const xs: number[] = [];
|
||||||
if (head) head.style.transform = `translate(${s.x + 5}px, ${s.y + 5}px)`;
|
const ys: number[] = [];
|
||||||
trail.unshift({ x: s.x, y: s.y });
|
for (let i = 0; i < flies.length; i++) {
|
||||||
if (trail.length > 8) trail.pop();
|
const f = FLY[i];
|
||||||
paintTrail();
|
const dx =
|
||||||
const idx = s.idx;
|
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 count = Number(cells[idx]?.dataset.count ?? 0);
|
const dy =
|
||||||
if (count > 0 && !eaten.has(idx)) {
|
f.ay[0] * Math.cos(t * f.fy[0] + f.py[0]) + f.ay[1] * Math.cos(t * f.fy[1] + f.py[1]);
|
||||||
eaten.add(idx);
|
xs.push(midX + halfW * dx);
|
||||||
cells[idx].classList.add('hm-eaten');
|
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));
|
||||||
}
|
}
|
||||||
// 空格不染色:P1 的路径只在有贡献的格子间滑行,空区由拖尾光带表达
|
for (const c of active) {
|
||||||
// 月柱联动:点亮到当前格所属月份
|
let hit = false;
|
||||||
const mk = (cells[idx]?.dataset.date ?? '').slice(0, 7);
|
for (let i = 0; i < xs.length; i++) {
|
||||||
const mi = monthsReal.indexOf(mk);
|
const ddx = c.x - xs[i];
|
||||||
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
|
const ddy = c.y - ys[i];
|
||||||
if (i >= samples.length) {
|
if (ddx * ddx + ddy * ddy <= R2) {
|
||||||
i = 0; // 无缝循环:路径本身首尾相接
|
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 指数衰减,终点是原 ink 底色
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 离视口停 rAF、回来续跑;rAF 时间戳连续,相位无缝衔接
|
||||||
new IntersectionObserver(
|
new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isIntersecting && !timer) timer = window.setInterval(tick, 24);
|
if (e.isIntersecting && !raf) raf = requestAnimationFrame(frame);
|
||||||
if (!e.isIntersecting && timer) {
|
else if (!e.isIntersecting && raf) {
|
||||||
window.clearInterval(timer);
|
cancelAnimationFrame(raf);
|
||||||
timer = 0;
|
raf = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -241,6 +230,11 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
height: 11px;
|
height: 11px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
/* 萤火点亮回落:进入快通道由 .hm-lit 覆写,离开 800ms easeOutExpo≈指数衰减;
|
||||||
|
高亮只借道 filter/box-shadow,不改 background——终点永远是原 ink 底色 */
|
||||||
|
transition:
|
||||||
|
filter 800ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
box-shadow 800ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
/* 空档 l0 用极淡描边标位;l1-l4 = ink-4 → ink-1(亮暗模式各自成对,多则更醒目) */
|
/* 空档 l0 用极淡描边标位;l1-l4 = ink-4 → ink-1(亮暗模式各自成对,多则更醒目) */
|
||||||
.hm-l0 { border: 1px solid var(--line); }
|
.hm-l0 { border: 1px solid var(--line); }
|
||||||
@@ -257,17 +251,26 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
|
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
|
||||||
/* P1 智能寻食蛇(选定方案):头与拖尾是浮动态,位置由引擎 transform 控制 */
|
/* 萤火虫体:位置由引擎 transform 驱动;初始 opacity:0 防 JS 未跑时滞在左上角 */
|
||||||
.hm-stage { position: relative; }
|
.hm-fly {
|
||||||
.hm-head {
|
position: absolute;
|
||||||
position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 50%;
|
left: 0;
|
||||||
background: var(--accent);
|
top: 0;
|
||||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent), 0 0 10px color-mix(in oklab, var(--accent) 55%, transparent);
|
width: 4px;
|
||||||
pointer-events: none; z-index: 3;
|
height: 4px;
|
||||||
transition: transform 90ms linear;
|
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;
|
||||||
|
}
|
||||||
|
.hm-lit {
|
||||||
|
filter: brightness(1.7);
|
||||||
|
box-shadow: 0 0 6px color-mix(in oklab, var(--accent) 45%, transparent);
|
||||||
|
transition-duration: 120ms;
|
||||||
}
|
}
|
||||||
.hm-trail { position: absolute; left: 0; top: 6px; width: 1px; height: 1px; pointer-events: none; z-index: 2; }
|
|
||||||
.hm-eaten { background: var(--accent) !important; }
|
|
||||||
.hm-bars {
|
.hm-bars {
|
||||||
display: flex; align-items: flex-end; gap: 5px; list-style: none;
|
display: flex; align-items: flex-end; gap: 5px; list-style: none;
|
||||||
padding: 0; margin: var(--space-2) 0 0; height: 56px;
|
padding: 0; margin: var(--space-2) 0 0; height: 56px;
|
||||||
@@ -275,16 +278,13 @@ const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
|||||||
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; height: 100%; justify-content: flex-end; }
|
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; height: 100%; justify-content: flex-end; }
|
||||||
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; }
|
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; }
|
||||||
.hm-bar-fill {
|
.hm-bar-fill {
|
||||||
display: block; width: 100%; height: 0;
|
display: block; width: 100%; height: var(--h, 0%);
|
||||||
background: var(--ink-4); border-radius: 2px 2px 0 0;
|
background: var(--ink-4); border-radius: 2px 2px 0 0;
|
||||||
transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
|
|
||||||
}
|
}
|
||||||
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); }
|
|
||||||
.hm-bar-label { font-size: 10px; color: var(--ink-4); }
|
.hm-bar-label { font-size: 10px; color: var(--ink-4); }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hm-head, .hm-trail { display: none; }
|
.hm-fly { display: none; }
|
||||||
/* 引擎不启动时柱子也要立起来(否则少动用户看到的柱子全是 0 高度) */
|
.hm-c { transition: none; }
|
||||||
.hm-bar-fill { height: var(--h, 0%) !important; transition: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hm-legend {
|
.hm-legend {
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图动效预览方案「星轨」:格子即星——档位只决定微光强弱(越密越亮),
|
|
||||||
// 一颗彗星(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>
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图动效预览 · 方案「萤火」: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>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图 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>
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图动效候选「织锦」(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>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图 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>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
// 热力图 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>
|
|
||||||
@@ -117,6 +117,11 @@ ok('5i·开源页零第三方外链图(含退役资产)', !osState.thirdPart
|
|||||||
ok('5i·打字机动效存在', osState.typing);
|
ok('5i·打字机动效存在', osState.typing);
|
||||||
ok('5j·连续贡献同源计算 + 更新时间标注', osState.streakLine && osState.updated);
|
ok('5j·连续贡献同源计算 + 更新时间标注', osState.streakLine && osState.updated);
|
||||||
|
|
||||||
|
// 5m:热力图定稿萤火——4 只在跑(引擎给了 transform 才算活)
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
ok('5m·开源页萤火 4 只', (await page.locator('.hm-fly').count()) === 4);
|
||||||
|
ok('5m·萤火在动', await page.locator('.hm-fly').first().evaluate((el) => el.style.transform.length > 5));
|
||||||
|
|
||||||
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
||||||
const before = await page.getAttribute('html', 'data-theme');
|
const before = await page.getAttribute('html', 'data-theme');
|
||||||
await page.click('[data-theme-toggle]');
|
await page.click('[data-theme-toggle]');
|
||||||
|
|||||||
Reference in new issue
Block a user