fix(heatmap): 修蛇头坐标轴反了跑出边界;P1 智能寻食蛇落进正式开源页

用户选定 P1,同时报障「贪吃蛇跑到了窗口之外」。

根因:网格是**列优先**索引(idx → 列=floor(idx/7)、行=idx%7),P1 引擎的 posOf
把 x/y 写反——蛇头 y 会到 676px(网格仅 91px 高),一路走出下边界。
预览页坐标已修正;正式热力图按 P1 落地时直接用正确坐标系。

P1 落进正式开源页(Heatmap.astro):
- 头(accent 圆点)+ 8 节渐隐拖尾,沿「最近邻路径」只在有贡献的格子间滑行,
  空格不染色(371 格仅 51 天活跃,全网扫描会让蛇 86% 行程空转)
- 吃到的格子点亮 accent;月柱随蛇头所在月份递增点亮
- 路径首尾相接实现无缝循环;离开视口即停;reduced-motion 下头/拖尾隐藏、
  柱子以终态呈现;无 JS 兜底同款(noscript 样式)
- 清理死分支:P1 路径只访问活跃格子,walked 分支永不触发

验证: 构建 51 页 exit 0;E2E 56/56;
      Playwright 实测蛇头 transform 始终在网格包围盒内(x≤676、y≤83 含居中偏移)、
      eaten 递增至 51、月柱 13/13 点亮、reduced-motion 头隐藏柱立起、无 JS 柱立起

Spec: none (P1 为用户已选定方案的落地 + 坐标 bug 修复)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-04 23:04:14 +08:00
1 parent 6e343c80dc
commit 129b23cd2b
2 files changed
+179 -1

No files matched your search

+176
View File
@@ -29,6 +29,17 @@ try {
const streak = streakStats(weeks.flat());
const updatedOn = new Date().toISOString().slice(0, 10);
const summary = labels.summary.replace('{n}', String(total));
// 月度聚合(P1 蛇 + 月柱联动):柱子给"哪个月最忙"的粗粒度答案
const byMonth = new Map<string, number>();
for (const week of weeks) {
for (const day of week) {
const key = day.date.slice(0, 7);
byMonth.set(key, (byMonth.get(key) ?? 0) + day.count);
}
}
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).filter(([m]) => m);
const maxMonth = Math.max(...months.map(([, v]) => v), 1);
---
<section class="heatmap">
@@ -40,12 +51,17 @@ const summary = labels.summary.replace('{n}', String(total));
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
</p>
<div class="hm-stage" data-snake>
<span class="hm-head" aria-hidden="true"></span>
<span class="hm-trail" aria-hidden="true"></span>
<div class="hm-scroll" data-anchor-latest>
<div class="hm-grid" role="img" aria-label={summary}>
{weeks.map((week) =>
week.map((day) => (
<span
class={`hm-c hm-l${levelOf(day.count)}`}
data-count={day.count}
data-date={day.date}
title={`${day.date} · ${day.count}`}
aria-hidden="true"
/>
@@ -53,6 +69,16 @@ const summary = labels.summary.replace('{n}', String(total));
)}
</div>
</div>
</div>
<ul class="hm-bars" aria-hidden="true">
{months.map(([m, v]) => (
<li>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month={m} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<noscript><style>.hm-bar-fill{height:var(--h,0%)!important}</style></noscript>
<p class="hm-legend">
<span aria-hidden="true">{labels.less}</span>
{[0, 1, 2, 3, 4].map((l) => (
@@ -67,6 +93,126 @@ const summary = labels.summary.replace('{n}', String(total));
document.querySelectorAll('[data-anchor-latest]').forEach((el) => { el.scrollLeft = el.scrollWidth; });
</script>
<script>
// P1 · 智能寻食蛇(Phase 5j 用户选定):只按「最近的有贡献格子」前进,子格插值滑行,
// 循环播放(离开视口即停;减少动态效果下完全静态——不启动引擎)。
// 坐标系:格子按列优先排(idx → 列=floor(idx/7)、行=idx%7);此前 x/y 写反导致
// 蛇头走出下边界(2026-11-04 用户实测),修正后必须断言头坐标始终在网格包围盒内。
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const PITCH = 13; // 10px 格 + 3px 缝
const stage = document.querySelector('.hm-stage');
if (!stage || reduce) return;
const cells = [...stage.querySelectorAll<HTMLElement>('.hm-c')];
if (!cells.length) return;
const head = stage.querySelector<HTMLElement>('.hm-head');
const trailEl = stage.querySelector<HTMLElement>('.hm-trail');
const bars = [...document.querySelectorAll<HTMLElement>('.hm-bar-fill')];
const posOf = (idx: number) => ({ x: Math.floor(idx / 7) * PITCH, y: (idx % 7) * PITCH });
/** 最近邻路径:只走有贡献的格子(真实数据 371 格仅 ~51 天活跃,
全网扫描会让蛇 86% 行程空转——这是 P1 与 P2/P3 的本质区别) */
const path = (() => {
const pool = cells
.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;
// 子格插值:把"格到格"展开成连续坐标(段越长走得越快,节奏才不拖)
const samples: { x: number; y: number; idx: number }[] = [];
for (let k = 0; k < path.length; k++) {
const a = posOf(path[k]);
const b = posOf(path[(k + 1) % path.length]); // 收尾回到起点,循环才无缝
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const steps = Math.max(1, Math.round(dist / 6));
for (let s = 0; s < steps; s++) {
const t = s / steps;
samples.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, idx: path[k] });
}
}
const eaten = new Set<number>();
let i = 0;
let timer = 0;
const trail: { x: number; y: number }[] = [];
const paintTrail = () => {
if (!trailEl) return;
trailEl.style.boxShadow = trail
.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)`)
.join(',');
};
const monthsReal = (() => {
const set = new Set<string>();
cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
return [...set].filter(Boolean).sort();
})();
const tick = () => {
const s = samples[i++];
if (!s) return;
// 蛇头坐标必须落在网格包围盒内(列 0..52、行 0..6)——越界即坐标轴 bug 的信号
const maxX = Math.floor(cells.length / 7) * PITCH;
const maxY = 6 * PITCH;
if (s.x > maxX || s.y > maxY) return;
if (head) head.style.transform = `translate(${s.x + 5}px, ${s.y + 5}px)`;
trail.unshift({ x: s.x, y: s.y });
if (trail.length > 8) trail.pop();
paintTrail();
const idx = s.idx;
const count = Number(cells[idx]?.dataset.count ?? 0);
if (count > 0 && !eaten.has(idx)) {
eaten.add(idx);
cells[idx].classList.add('hm-eaten');
}
// 空格不染色:P1 的路径只在有贡献的格子间滑行,空区由拖尾光带表达
// 月柱联动:点亮到当前格所属月份
const mk = (cells[idx]?.dataset.date ?? '').slice(0, 7);
const mi = monthsReal.indexOf(mk);
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
if (i >= samples.length) {
i = 0; // 无缝循环:路径本身首尾相接
}
};
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting && !timer) timer = window.setInterval(tick, 24);
if (!e.isIntersecting && timer) {
window.clearInterval(timer);
timer = 0;
}
}
},
{ threshold: 0.15 },
).observe(stage);
})();
</script>
<style>
.heatmap { margin-top: var(--space-6); }
.hm-title {
@@ -111,6 +257,36 @@ const summary = labels.summary.replace('{n}', String(total));
margin-right: 4px;
}
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
/* P1 智能寻食蛇(选定方案):头与拖尾是浮动态,位置由引擎 transform 控制 */
.hm-stage { position: relative; }
.hm-head {
position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
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);
pointer-events: none; z-index: 3;
transition: transform 90ms linear;
}
.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 {
display: flex; align-items: flex-end; gap: 5px; list-style: none;
padding: 0; margin: var(--space-2) 0 0; height: 56px;
}
.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-fill {
display: block; width: 100%; height: 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); }
@media (prefers-reduced-motion: reduce) {
.hm-head, .hm-trail { display: none; }
/* 引擎不启动时柱子也要立起来(否则少动用户看到的柱子全是 0 高度) */
.hm-bar-fill { height: var(--h, 0%) !important; transition: none; }
}
.hm-legend {
display: flex;
align-items: center;
+3 -1
View File
@@ -149,7 +149,9 @@ const maxMonth = Math.max(...months.map(([, v]) => v));
const mode = stage.dataset.mode ?? 'smart';
const bodyLen = Number(stage.dataset.body ?? 5);
const weeks = Math.ceil(cells.length / 7);
const posOf = (idx: number) => ({ x: (idx % 7) * PITCH, y: Math.floor(idx / 7) * PITCH });
// 列优先索引:idx → 列=floor(idx/7)、行=idx%7。此前 x/y 写反,
// 蛇头 y 会到 676px(网格仅 91px 高)一路走出下边界(用户实测「跑到窗口之外」)。
const posOf = (idx: number) => ({ x: Math.floor(idx / 7) * PITCH, y: (idx % 7) * PITCH });
/** 最近邻路径:只走有贡献的格子(P1 用;P2/P3 用蛇形全网) */
const smartPath = () => {