diff --git a/src/pages/heatmap-preview.astro b/src/pages/heatmap-preview.astro index a52e314..5652bad 100644 --- a/src/pages/heatmap-preview.astro +++ b/src/pages/heatmap-preview.astro @@ -1,37 +1,30 @@ --- /** - * 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。 + * 热力图动效方案预览 v2(Phase 5j)——用户看过 v1 五案后觉得不够,要求「类似贪吃蛇」的 + * 方案并再深入设计 4–6 个。本页给 6 个更深入的方案(F–K),v1 的 A–E 已下线。 * - * 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮; - * 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案 - * 落进 Heatmap.astro,然后删掉本路由。 - * - * 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion - * 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。 + * 设计红线(六案都守):灰阶骨架、accent 只做高亮不做数据编码(数据仍由 ink 四档承载)、 + * prefers-reduced-motion 一律静态、零第三方依赖、离开视口即停(省电)。 */ import BaseLayout from '../layouts/BaseLayout.astro'; import HeatGrid from '../components/geek/HeatGrid.astro'; -import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap'; +import { loadHeatmap, type HeatDay } from '../lib/heatmap'; const { weeks, total } = await loadHeatmap(); const days: HeatDay[] = weeks.flat(); const buildTime = new Date().toISOString().slice(0, 10); -/* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */ +/* ── 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数)── */ const countOf = new Map(days.map((d) => [d.date, d.count])); -const today = new Date(); const isoOf = (d: Date) => d.toISOString().slice(0, 10); -// 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径), -// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。 -let current = 0; const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? ''; +let current = 0; if (lastActive) { const start = new Date(`${lastActive}T00:00:00Z`); for (let i = 0; ; i++) { const d = new Date(start); d.setUTCDate(start.getUTCDate() - i); - const c = countOf.get(isoOf(d)) ?? 0; - if (c > 0) current++; + if ((countOf.get(isoOf(d)) ?? 0) > 0) current++; else break; } } @@ -42,260 +35,356 @@ for (const d of days) { if (run > longest) longest = run; } const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]); -const activeDays = days.filter((d) => d.count > 0).length; -const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays }; +/* ── 月度聚合(J 方案用)── */ +const byMonth = new Map(); +for (const d of days) { + const key = d.date.slice(0, 7); + byMonth.set(key, (byMonth.get(key) ?? 0) + d.count); +} +const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)); +const maxMonth = Math.max(...months.map(([, v]) => v)); + +/* ── 最后活跃日的坐标(H 涟漪终点起点)── */ +const lastActiveIdx = days.findLastIndex((d) => d.date === lastActive); +const lastActiveCol = Math.floor(lastActiveIdx / 7); +const lastActiveRow = lastActiveIdx % 7; + +const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '' }; +const dataForClient = { cells: days.map((d) => d.count), weeks: weeks.length }; --- - -

热力图:五个动效方案

+ +

热力图:六个更深入的方案

- 同一份真实数据(近一年 {total} 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续 - {stats.current} 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。 + 同一份真实数据(近一年 {stats.total} 次贡献 · 当前连续 + {stats.current} 天 · 最长 {stats.longest} 天 · 单日最高 {stats.best} 次), + 数据更新于 {buildTime}。
- 看完告诉我选哪个(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。 - 五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。 + 上一版的五个方案已按你的反馈下线;这一版六个都往「有生命、有叙事」的方向推。 + 回字母告诉我选哪个(也可以说「F 但慢一点」「F + J 组合」)。

- -
+ +
- A -

入场波纹

-

滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。 - 优点有生命感、不打扰、只播一次;代价纯装饰,不提供额外信息。

+ F +

贪吃蛇

+

一条蛇沿着贡献格蛇形爬过:吃到有贡献的格子就把它们点亮并"长"一点,空格直接穿过。 + 优点最有生命感、天然表达"这一年被一点点吃掉";代价是持续动画(离开视口即暂停)。

-
- +
+ +
+

准备出发… ·

+
+ + +
+
+ G +

心跳呼吸

+

每格按当日贡献量呼吸:越忙的日子跳得越快、越亮,静下来时整片缓慢起伏。 + 优点像一张"活的"心电图,无需交互;代价持续动画(纯 CSS,合成层,很省)。

+
+
+
- -
+ +
- B -

光标光晕

-

鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。 - 优点能精确读数、零轮播;代价触屏设备没有指针,需退化为点按读数。

+ H +

雨滴涟漪

+

从"最后一次贡献"那一格出发,涟漪一圈圈向外扩散,扫过的格子闪一下再落定。 + 优点叙事清晰(现在 → 过去)、只播一次;代价纯装饰。

-
- - +
+
-

把指针移到格子上 →

+

· 起点 {lastActive}

- -
+ +
- C -

年度回放

-

拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。 - 优点最有叙事感、能直观感受节奏;代价多一层交互,需要一点耐心。

+ I +

3D 地形

+

把贡献量当"海拔":忙的日子凸起来,整块网格随鼠标轻轻倾斜,像俯瞰一年的地貌。 + 优点一眼看出密集区、视觉独特;代价倾斜会略降可读性。

-
- -
-
- - - 累计 {total} 次 · 第 {weeks.length} 周 -
-
- - -
-
- D -

连续电流

-

当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。 - 优点把"坚持"这件事讲成故事;代价只覆盖最近一小段,全年的信息仍靠静态格子。

-
-
-
- {stats.current} - 连续天数{lastActive ? `(截至 ${lastActive})` : ''} - 最长 {stats.longest} 天 · 单日最高 {stats.best} 次 -
-
- +
+
+
- -
+ +
- E -

悬停周详情

-

指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。 - 优点信息密度最高、动效克制;代价仍是鼠标优先,触屏靠点按。

+ J +

周冠军柱

+

网格下方按月长出一排柱子,进视口时逐根升起;指针移到柱子上,对应那些格子同时亮起。 + 优点把"哪几个月最忙"讲清楚、信息量大;代价多一层图表,页面变高。

-
-
- -
- +
+
+
    + {months.map(([m, v]) => ( +
  • + + {m.slice(5)} +
  • + ))} +
-

数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。

+ +
+
+ K +

显微镜

+

指针所在处浮起一枚圆镜,把周围 5×5 的格子放大显示,并标出当天日期与次数。 + 优点小格子也读得清、趣味强;代价触屏需点按。

+
+
+ + +
+

把指针移到任意格子上

+
+ +

数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下六案全部静态。

+ + @@ -321,80 +410,107 @@ const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: be .pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); } .pv-pros b { color: var(--ink-2); } .pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); } - - .hm-wrap { overflow-x: auto; padding: 4px 0; } - - /* ── A:入场波纹 ── */ - /* 注意:网格是子组件渲染的,页面级 scoped 样式够不到它内部元素,必须 :global() */ - [data-cascade] :global(.hg-c) { opacity: 0.18; } - [data-cascade].play :global(.hg-c) { - animation: hmRise 420ms cubic-bezier(0.22, 0.8, 0.3, 1) var(--d) forwards; - } - @keyframes hmRise { - from { opacity: 0.18; transform: translateY(3px) scale(0.86); } - to { opacity: 1; transform: none; } - } - - /* ── B:光标光晕 ── */ - .hm-spot { position: relative; isolation: isolate; } - .hm-glow { - position: absolute; inset: 0; pointer-events: none; opacity: 0; - transition: opacity 200ms var(--ease); - background: radial-gradient(90px 90px at var(--x, 50%) var(--y, 50%), - color-mix(in oklab, var(--accent) 26%, transparent), transparent 70%); - mix-blend-mode: multiply; - } - .hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); } - - /* ── C:年度回放 ── */ - .hm-ctrl { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); flex-wrap: wrap; } + .hm-wrap { overflow-x: auto; padding: 6px 0; } + .hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } .hm-btn { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); - background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; + background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px; cursor: pointer; } .hm-btn:hover { border-color: var(--accent); } - .hm-range { flex: 1; min-width: 160px; accent-color: var(--accent); } - /* ── D:连续电流 ── */ - .hm-streak { display: flex; flex-direction: column; gap: var(--space-3); } - .hm-streak-num { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; } - .hm-streak-num strong { font-size: 34px; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; } - .hm-streak-num span { font-size: 12.5px; color: var(--ink-3); } - .hm-streak-sub { color: var(--ink-4) !important; } - :global(.hg-flow) { position: relative; } - :global(.hg-flow)::after { - content: ''; position: absolute; inset: 0; border-radius: 2px; - background: var(--accent); opacity: 0; - animation: hmFlow 1.6s ease-in-out var(--flow, 0ms) infinite; + /* ── F 蛇:头 + 吃过 ── */ + :global(.snake-head) { + outline: 2px solid var(--accent); + outline-offset: 1px; + z-index: 1; + position: relative; } - @keyframes hmFlow { - 0%, 100% { opacity: 0; } - 45% { opacity: 0.55; } + :global(.snake-passed) { + background: color-mix(in oklab, var(--accent) 16%, transparent); + transition: background 220ms var(--ease); + } + :global(.snake-eaten) { + background: var(--accent) !important; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent); + transform: scale(1.12); + transition: background 180ms var(--ease), transform 180ms var(--ease); } - .no-flow :global(.hg-flow)::after { animation: none; } - /* ── E:悬停周详情 ── */ - .hm-inspector { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; } - .hm-inspector .hm-wrap { flex: 1 1 560px; min-width: 0; } - :global(.hg-c.lift) { - transform: translateY(-2px); - outline: 1px solid color-mix(in oklab, var(--accent) 45%, transparent); - transition: transform 140ms var(--ease); + /* ── G 呼吸 ── */ + :global(.hg-breath) { animation: hmBreath var(--breath, 2.6s) ease-in-out infinite; } + @keyframes hmBreath { + 0%, 100% { opacity: 0.55; transform: scale(0.94); } + 50% { opacity: 1; transform: scale(1.06); } } - .hm-week { - flex: 0 0 190px; border: 1px solid var(--line); border-radius: 8px; - padding: var(--space-3); background: var(--bg-soft); font-size: 12.5px; color: var(--ink-3); + + /* ── H 涟漪 ── */ + .play :global(.hg-c) { animation: hmWave 700ms ease-out var(--wave, 0ms) both; } + @keyframes hmWave { + 0% { transform: scale(0.7); filter: brightness(1.6); } + 60% { transform: scale(1.12); } + 100% { transform: none; filter: none; } } - .hm-week p { margin: 0 0 4px; } - .hm-week-total strong { font-size: 22px; color: var(--ink-1); font-variant-numeric: tabular-nums; } - .hm-week-best { color: var(--ink-4); } + + /* ── I 3D 地形 ── */ + .hm-3d { perspective: 900px; padding: var(--space-3) 0; overflow-x: auto; } + .hm-3d-inner { + transform: rotateX(var(--rx, 6deg)) rotateY(var(--ry, 0deg)); + transform-style: preserve-3d; + transition: transform 160ms var(--ease); + will-change: transform; + } + :global(.hg-raise) { + transform: translateZ(var(--z, 0px)); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.18); + } + + /* ── J 周冠军柱 ── */ + .hm-bars { + display: flex; align-items: flex-end; gap: 6px; list-style: none; + padding: 0; margin: var(--space-3) 0 0; height: 92px; + } + .hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; } + .hm-bar-btn { + width: 100%; height: 100%; display: flex; align-items: flex-end; + background: none; border: 0; padding: 0; cursor: pointer; + } + .hm-bar-fill { + display: block; width: 100%; height: 0; + background: var(--ink-4); border-radius: 2px 2px 0 0; + transition: height 520ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease); + } + .grow .hm-bar-fill { height: var(--h, 0%); } + .hm-bar-btn:hover .hm-bar-fill, .hm-bar-btn:focus-visible .hm-bar-fill { background: var(--accent); } + .hm-bar-label { font-size: 10.5px; color: var(--ink-4); } + :global(.hg-month) { background: var(--accent) !important; } + + /* ── K 显微镜 ── */ + .hm-lens-wrap { position: relative; } + .hm-lens { + position: absolute; left: var(--lx, 0); top: var(--ly, 0); translate: -50% -50%; + width: 132px; height: 132px; border-radius: 50%; + border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line)); + background: color-mix(in oklab, var(--bg) 92%, transparent); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); + backdrop-filter: blur(3px); + pointer-events: none; z-index: 4; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; + } + .hm-lens-grid { + display: grid; grid-template-columns: repeat(5, 14px); grid-template-rows: repeat(5, 14px); gap: 3px; + } + .hm-lens-grid i { + width: 14px; height: 14px; border-radius: 2px; + background: color-mix(in oklab, var(--ink-1) calc(var(--v) * 11%), transparent); + border: 1px solid color-mix(in oklab, var(--line) 80%, transparent); + } + .hm-lens-tip { font-size: 10px; color: var(--ink-3); } + :global(.hg-focus) { outline: 2px solid var(--accent); outline-offset: 1px; } @media (prefers-reduced-motion: reduce) { - [data-cascade].play :global(.hg-c) { animation: none; opacity: 1; } - :global(.hg-flow)::after { animation: none; } - .hm-glow { display: none; } - } - @media (max-width: 620px) { - .hm-week { flex-basis: 100%; } + :global(.hg-breath) { animation: none; opacity: 1; } + .play :global(.hg-c) { animation: none; } + .hm-3d-inner { transform: none !important; transition: none; } + .grow .hm-bar-fill { transition: none; } }