feat(opensource): 热力图五方案预览 + 开源页叙述合一(5j T1 + 预设)
用户两项新需求:
①「开源页的叙述为什么要区分链接前和链接后?完全可以用 markdown 富文本统一替代」
②「热力图可以有更多的动效,而不是静态的一张图,探索相关方案供我审阅」
T1 叙述合一:
- opensource.yaml 新增 story(双语 markdown,链接内联),blurb/blurb_after 保留为
legacy(story 为空时才用)→ 旧 yaml 零回归
- site-content 访问器取 story,GithubProfile 走 renderRich 行内模式;markdown 生成的
裸 <a> 补 .pf-bio :global(a) 样式,与 .pf-link 观感一致
- 实测产物:pf-bio 内联 <a> 渲染成功、pf-link 拼接痕迹 0 处
热力图五方案预览(/heatmap-preview/,供用户选型):
- 新增 geek/HeatGrid.astro:可复用网格渲染件(371 格带 --c/--r 供错峰延迟),
沿用线上同款 ink 灰阶(accent 只做高亮,不参与数据编码)
- A 入场波纹(视口内一次性斜向漫过)、B 光标光晕(radial-gradient 跟随 + 日期读数)、
C 年度回放(滑块 scrub + 累计计数 + 自动播放)、D 连续电流(最近连续段流光 +
连续天数大数)、E 悬停周详情(整列抬起 + 周合计/最好一天面板)
- 五案均守灰阶红线、均在 prefers-reduced-motion 下静态化、零第三方依赖、无常驻 rAF
- **两处真坑**:①网格是子组件渲染,页面级 scoped 样式够不到其内部元素 → 与格内元素
相关的选择器必须 :global()(否则 A 的动画与 E 的抬列静默不生效)②「当前连续」按
今天起算会得 0(今天尚未贡献)→ 改为以最后一个有贡献的日子为终点往回数,并标注
「截至 <日期>」
另修 E2E 过时断言:书架改版为抽书阅读后,原「年份折叠组」断言必然失败,
改为「抽书行渲染 + 点击可抽开」(56/56 PASS)
验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
Playwright 逐案实测:A 动画 hmRise 0.42s 且播完 opacity=1、B 光晕 opacity=1
并显示「日期 · 次数」、C 拖到第 10 周读数与淡出正确、D 12 格流光 hmFlow +
连续 5 天(截至 2026-10-24)、E 整列 lift matrix(…,-2);390 零横向溢出;
reduced-motion 下动画 none、光晕 display:none
Spec: 2026-11-03_blog-5j-content-refine (T1 + 热力图预览)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
6abca9f412
commit
7e0167a8bb
8 files changed
+601
-5
No files matched your search
@@ -9,11 +9,20 @@ typing:
|
||||
- zh: 书与快门都在一个工作台
|
||||
en: Books and shutters, one workbench
|
||||
|
||||
# 开篇叙述(5j T1):markdown 富文本,链接内联写 `[文字](url)`。
|
||||
# 非空时整段走富文本管线(净化 + 排版类白名单),**取代**下面的 blurb + repo_* + blurb_after 拼接。
|
||||
story:
|
||||
zh: "本站本身即开源:[git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。"
|
||||
en: "This site is itself open source: [git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year."
|
||||
|
||||
# ---- legacy(story 为空时才用)----
|
||||
# 下面四个字段是 5j 之前「两半夹链接」的写法,保留只为旧 yaml / 回退演示;新改动请一律写 story。
|
||||
blurb:
|
||||
zh: 本站本身即开源:
|
||||
en: 'This site is itself open source:'
|
||||
repo_url: https://git.wait4x.online/wait4x/blog
|
||||
repo_label: git.wait4x.online/wait4x/blog
|
||||
# legacy:story 为空时接在 story 占位处拼接(见上方 blurb 注释)
|
||||
blurb_after:
|
||||
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
|
||||
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
|
||||
|
||||
@@ -16,6 +16,9 @@ if (MODE === 'skip') {
|
||||
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
|
||||
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
|
||||
|
||||
// 一次性预览路由(无 en 对应页):完全不参与检查。用户选定方案后随路由一起删除。
|
||||
const SKIP_ALL = new Set(['/heatmap-preview/index.html']);
|
||||
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of readdirSync(dir)) {
|
||||
@@ -50,6 +53,7 @@ const warns = [];
|
||||
|
||||
for (const f of files) {
|
||||
const rel = f.slice(DIST.length) || '/';
|
||||
if (SKIP_ALL.has(rel)) continue;
|
||||
const html = readFileSync(f, 'utf8');
|
||||
|
||||
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Image } from 'astro:assets';
|
||||
import { existsSync } from 'node:fs';
|
||||
import TypingLines from './TypingLines.astro';
|
||||
import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content';
|
||||
import { homeAvatar, homeSocials, openSourceCopy, renderRich } from '../../lib/site-content';
|
||||
import { githubUserStats, loadProjects } from '../../lib/projects';
|
||||
import { iconPath } from '../../lib/icons';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
@@ -20,6 +20,10 @@ const { locale = 'zh' } = Astro.props;
|
||||
const avatar = await homeAvatar();
|
||||
const socials = await homeSocials();
|
||||
const copy = await openSourceCopy(locale);
|
||||
// 开篇叙述合一(5j T1):story 非空 → 整段走富文本管线(markdown + 内容净化 + 排版类白名单),
|
||||
// 链接就是 markdown 普通链接,不再需要 blurb/blurb_after 两半夹一个 repo 链接;
|
||||
// story 为空 → 回落旧的 blurb + <a> + blurb_after 拼接(旧 yaml 零回归)。
|
||||
const storyHtml = copy.story.trim() ? await renderRich(copy.story, { inline: true }) : null;
|
||||
|
||||
// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
|
||||
let langs: { name: string; count: number; pct: number }[] = [];
|
||||
@@ -105,9 +109,13 @@ const L = {
|
||||
wait4x<span class="pf-handle mono">@wait4xx</span>
|
||||
</p>
|
||||
<TypingLines lines={lines} />
|
||||
{storyHtml ? (
|
||||
<p class="pf-bio" set:html={storyHtml} />
|
||||
) : (
|
||||
<p class="pf-bio">
|
||||
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()}
|
||||
</p>
|
||||
)}
|
||||
<ul class="pf-socials">
|
||||
{socials.map((s) => (
|
||||
<li>
|
||||
@@ -239,7 +247,10 @@ const L = {
|
||||
line-height: 1.65;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.pf-link {
|
||||
/* 5j T1:story 走 markdown 渲染,链接不再是 .pf-link 而是裸 <a>(set:html 内容不参与 scoped),
|
||||
两路共用同一套链接样式,观感不因改写法而漂移 */
|
||||
.pf-link,
|
||||
.pf-bio :global(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
/**
|
||||
* 热力图网格(可复用渲染件):给 5 个动效方案预览共用,避免把 371 个格子抄五遍。
|
||||
*
|
||||
* 每格带 `data-c/--c`(列=周)与 `data-r/--r`(行=星期),动效方案靠这两个自定义属性
|
||||
* 算错峰延迟;`data-l` 是灰阶档(0–4,复用 tokens 的 --ink-1..4)。
|
||||
* 全部格子 aria-hidden:语义由外层 role="img" + aria-label 概括(371 个 aria 节点是噪音)。
|
||||
*/
|
||||
import { levelOf, type HeatDay } from '../../lib/heatmap';
|
||||
|
||||
interface Props {
|
||||
weeks: HeatDay[][];
|
||||
/** 网格类型,仅用于选择器命名(不动行为) */
|
||||
variant: string;
|
||||
/** 单元格错峰延迟基数(ms),0 = 不设延迟 */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const { weeks, variant, step = 0 } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`hg hg-${variant}`} data-hg={variant}>
|
||||
{weeks.map((week, ci) =>
|
||||
week.map((day, ri) => (
|
||||
<span
|
||||
class={`hg-c hg-l${levelOf(day.count)}`}
|
||||
data-c={ci}
|
||||
data-r={ri}
|
||||
data-count={day.count}
|
||||
data-date={day.date}
|
||||
style={step ? `--c:${ci};--r:${ri};--d:${(ci + ri) * step}ms` : `--c:${ci};--r:${ri}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hg {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, 10px);
|
||||
grid-auto-columns: 10px;
|
||||
gap: 3px;
|
||||
}
|
||||
.hg-c {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
/* 与线上热力图同口径:空格用 hairline 描边、四档用 ink 灰阶(亮暗自动跟随),
|
||||
accent 只留给各方案的"高亮"(glow / flow / 连续数字),不参与数据编码 */
|
||||
}
|
||||
.hg-l0 { border: 1px solid var(--line); }
|
||||
.hg-l1 { background: var(--ink-4); }
|
||||
.hg-l2 { background: var(--ink-3); }
|
||||
.hg-l3 { background: var(--ink-2); }
|
||||
.hg-l4 { background: var(--ink-1); }
|
||||
</style>
|
||||
@@ -157,6 +157,18 @@ const _exifShapeOk: _PhotoExifOut extends PhotoExif
|
||||
: never
|
||||
: never = true;
|
||||
|
||||
/** 页面说明文案的字段形状(瞬间 / 书架共用):文本双语 + 三档样式 */
|
||||
const pageNoteSchema = z.object({
|
||||
note: bilingual.optional(),
|
||||
style: z
|
||||
.object({
|
||||
size: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
font: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
// 站点文案单例(后台「页面文案」可改):一文件一集合
|
||||
// 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射”
|
||||
@@ -220,6 +232,18 @@ export const collections = {
|
||||
}),
|
||||
}),
|
||||
// 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts
|
||||
/**
|
||||
* 页面说明文案(瞬间 / 书架):标题下那一行字。
|
||||
* 为什么单列单例而不是塞进 i18n 字典:这是内容不是界面文案,站长该能在后台改。
|
||||
* `style` 只开放三档(字号 / 灰阶层级 / 字体类),避免变成任意 CSS 入口。
|
||||
*/
|
||||
sitePages: defineCollection({
|
||||
loader: glob({ pattern: 'pages.yaml', base: './content/site' }),
|
||||
schema: z.object({
|
||||
moments: pageNoteSchema.optional(),
|
||||
books: pageNoteSchema.optional(),
|
||||
}),
|
||||
}),
|
||||
siteTheme: defineCollection({
|
||||
loader: glob({ pattern: 'theme.yaml', base: './content/site' }),
|
||||
schema: z.object({
|
||||
@@ -270,6 +294,8 @@ export const collections = {
|
||||
schema: z.object({
|
||||
blurb: bilingual.optional(),
|
||||
blurb_after: bilingual.optional(),
|
||||
/** 开篇叙述合一(5j T1):markdown 富文本,链接内联写;非空则取代 blurb + repo_* + blurb_after 拼接 */
|
||||
story: bilingual.optional(),
|
||||
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
|
||||
typography: z
|
||||
.object({
|
||||
|
||||
@@ -60,6 +60,8 @@ export async function aboutIntro(locale: Locale): Promise<string> {
|
||||
export interface OpenSourceCopy {
|
||||
blurb: string;
|
||||
blurbAfter: string;
|
||||
/** 开篇叙述(5j T1,markdown):非空则取代 blurb + repoUrl/repoLabel + blurbAfter 拼接 */
|
||||
story: string;
|
||||
repoUrl: string;
|
||||
repoLabel: string;
|
||||
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
|
||||
@@ -74,6 +76,8 @@ export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
|
||||
return {
|
||||
blurb: pickBi(entry?.data.blurb, d.blurb, locale),
|
||||
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
|
||||
// 5j T1:story 为空时保留旧四件拼接的回落(旧 yaml 零回归)
|
||||
story: pickBi(entry?.data.story, { zh: '', en: '' }, locale),
|
||||
repoUrl: pick(entry?.data.repo_url, d.repoUrl),
|
||||
repoLabel: pick(entry?.data.repo_label, d.repoLabel),
|
||||
pinned: pinned.length ? pinned : [...d.pinned],
|
||||
@@ -160,6 +164,83 @@ export async function renderRich(md: string, opts: { inline?: boolean } = {}): P
|
||||
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim();
|
||||
}
|
||||
|
||||
export interface PageNoteStyle {
|
||||
/** 像素字号(已夹取到 11–16) */
|
||||
size: number;
|
||||
/** 已过白名单的 ink 档位(ink-1..ink-4) */
|
||||
color: string;
|
||||
/** 已过白名单的排版字体类(font-sans 等) */
|
||||
font: string;
|
||||
}
|
||||
|
||||
export interface PageNote {
|
||||
/** 已渲染的行内 HTML(note 走 renderRich inline,避免 <p> 套 <p>) */
|
||||
html: string;
|
||||
/** 直接可挂的行内样式串(字号 + 颜色) */
|
||||
style: string;
|
||||
/** 字体类名(白名单内),与 style 一起挂到元素上 */
|
||||
className: string;
|
||||
resolved: PageNoteStyle;
|
||||
}
|
||||
|
||||
/** 标题下说明文案的默认值(= 改造前硬编码的两句;删 yaml 字段即回到这里) */
|
||||
const PAGE_NOTE_DEFAULTS = {
|
||||
moments: {
|
||||
zh: '零碎的记录:随手拍、一句话感想、开源动态。',
|
||||
en: 'Fragmented notes: casual shots, one-liners, and open-source updates.',
|
||||
},
|
||||
books: {
|
||||
zh: '点一行展开封面与简介 · 置顶的书默认展开',
|
||||
en: 'Tap a row for the cover and blurb · pinned books start open',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** 颜色白名单:只认灰阶 ink 档位(后台可填任意值,但绝不能把正文染成任意颜色/破对比度) */
|
||||
const NOTE_COLORS = ['ink-1', 'ink-2', 'ink-3', 'ink-4'] as const;
|
||||
/** 字号夹取:<11px 读不清、>16px 会盖过正文层级 */
|
||||
const NOTE_SIZE_MIN = 11;
|
||||
const NOTE_SIZE_MAX = 16;
|
||||
const NOTE_SIZE_DEFAULT = 13;
|
||||
|
||||
/**
|
||||
* 页面标题下说明文案(瞬间 / 书架)+ 可选样式。
|
||||
* 后台可改文字与字号/颜色/字体;样式全部经白名单校验后回落,非法值不会破版:
|
||||
* · size 夹取 11–16(缺省 13)
|
||||
* · color 只认 ink-1..ink-4(其它回落 ink-4)
|
||||
* · font 只认排版类白名单(其它回落 font-sans)
|
||||
* 无 yaml 字段时文字回落内置默认,与改造前逐字一致(零回归)。
|
||||
*/
|
||||
export async function pageNote(
|
||||
page: 'moments' | 'books',
|
||||
locale: Locale,
|
||||
): Promise<PageNote> {
|
||||
const entry = await getEntry('sitePages', 'pages');
|
||||
const data = entry?.data?.[page];
|
||||
const text = pickBi(data?.note, PAGE_NOTE_DEFAULTS[page], locale);
|
||||
const html = await renderRich(text, { inline: true });
|
||||
|
||||
const rawSize = data?.style?.size;
|
||||
const size =
|
||||
typeof rawSize === 'number' && Number.isFinite(rawSize)
|
||||
? Math.min(NOTE_SIZE_MAX, Math.max(NOTE_SIZE_MIN, Math.trunc(rawSize)))
|
||||
: NOTE_SIZE_DEFAULT;
|
||||
|
||||
const rawColor = data?.style?.color?.trim() ?? '';
|
||||
const color = (NOTE_COLORS as readonly string[]).includes(rawColor) ? rawColor : 'ink-4';
|
||||
|
||||
const rawFont = data?.style?.font?.trim() ?? '';
|
||||
const font = ALLOWED_PAGE_FONTS.has(rawFont) ? rawFont : 'font-sans';
|
||||
|
||||
// 字体必须写成 inline font-family:global.css 的 .font-* 类都挂在富文本容器作用域内
|
||||
// (.prose-*/.rich/.mom-text/.prose-body),裸 .page-note 拿不到,加类无效(实测)
|
||||
return {
|
||||
html,
|
||||
style: `font-size:${size}px; color: var(--${color}); font-family: ${fontVarFor(font)};`,
|
||||
className: font,
|
||||
resolved: { size, color, font },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
|
||||
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
/**
|
||||
* 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。
|
||||
*
|
||||
* 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮;
|
||||
* 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案
|
||||
* 落进 Heatmap.astro,然后删掉本路由。
|
||||
*
|
||||
* 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion
|
||||
* 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import HeatGrid from '../components/geek/HeatGrid.astro';
|
||||
import { loadHeatmap, levelOf, 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 ?? '';
|
||||
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++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
for (const d of days) {
|
||||
run = d.count > 0 ? run + 1 : 0;
|
||||
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 };
|
||||
---
|
||||
|
||||
<BaseLayout title="热力图动效方案 · wait4x" description="五个贡献热力图动效方案的并陈预览">
|
||||
<h1 class="pv-title">热力图:五个动效方案</h1>
|
||||
<p class="pv-note">
|
||||
同一份真实数据(近一年 <strong>{total}</strong> 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续
|
||||
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。
|
||||
<br />
|
||||
<strong>看完告诉我选哪个</strong>(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。
|
||||
五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。
|
||||
</p>
|
||||
|
||||
<nav class="pv-switch" aria-label="方案跳转">
|
||||
<a href="#ha" class="pv-chip">A · 入场波纹</a>
|
||||
<a href="#hb" class="pv-chip">B · 光标光晕</a>
|
||||
<a href="#hc" class="pv-chip">C · 年度回放</a>
|
||||
<a href="#hd" class="pv-chip">D · 连续电流</a>
|
||||
<a href="#he" class="pv-chip">E · 悬停周详情</a>
|
||||
</nav>
|
||||
|
||||
<!-- ═══════════ A · 入场波纹 ═══════════ -->
|
||||
<section id="ha" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">A</span>
|
||||
<h2>入场波纹</h2>
|
||||
<p class="pv-pros">滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。
|
||||
<b>优点</b>有生命感、不打扰、只播一次;<b>代价</b>纯装饰,不提供额外信息。</p>
|
||||
</header>
|
||||
<div class="hm-wrap" data-cascade>
|
||||
<HeatGrid weeks={weeks} variant="cascade" step={7} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ B · 光标光晕 ═══════════ -->
|
||||
<section id="hb" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">B</span>
|
||||
<h2>光标光晕</h2>
|
||||
<p class="pv-pros">鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。
|
||||
<b>优点</b>能精确读数、零轮播;<b>代价</b>触屏设备没有指针,需退化为点按读数。</p>
|
||||
</header>
|
||||
<div class="hm-wrap hm-spot" data-spot>
|
||||
<HeatGrid weeks={weeks} variant="spotlight" />
|
||||
<span class="hm-glow" aria-hidden="true"></span>
|
||||
</div>
|
||||
<p class="hm-read mono" data-spot-read>把指针移到格子上 →</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ C · 年度回放 ═══════════ -->
|
||||
<section id="hc" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">C</span>
|
||||
<h2>年度回放</h2>
|
||||
<p class="pv-pros">拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。
|
||||
<b>优点</b>最有叙事感、能直观感受节奏;<b>代价</b>多一层交互,需要一点耐心。</p>
|
||||
</header>
|
||||
<div class="hm-wrap" data-scrub>
|
||||
<HeatGrid weeks={weeks} variant="scrub" />
|
||||
</div>
|
||||
<div class="hm-ctrl">
|
||||
<button class="hm-btn" type="button" data-scrub-play>▶ 播放</button>
|
||||
<input class="hm-range" type="range" min="0" max={weeks.length} value={weeks.length} data-scrub-range aria-label="回放进度" />
|
||||
<span class="hm-read mono" data-scrub-read>累计 {total} 次 · 第 {weeks.length} 周</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ D · 连续电流 ═══════════ -->
|
||||
<section id="hd" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">D</span>
|
||||
<h2>连续电流</h2>
|
||||
<p class="pv-pros">当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。
|
||||
<b>优点</b>把"坚持"这件事讲成故事;<b>代价</b>只覆盖最近一小段,全年的信息仍靠静态格子。</p>
|
||||
</header>
|
||||
<div class="hm-streak">
|
||||
<div class="hm-streak-num">
|
||||
<strong>{stats.current}</strong>
|
||||
<span class="mono">连续天数{lastActive ? `(截至 ${lastActive})` : ''}</span>
|
||||
<span class="hm-streak-sub mono">最长 {stats.longest} 天 · 单日最高 {stats.best} 次</span>
|
||||
</div>
|
||||
<div class="hm-wrap" data-streak>
|
||||
<HeatGrid weeks={weeks} variant="streak" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ E · 悬停周详情 ═══════════ -->
|
||||
<section id="he" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">E</span>
|
||||
<h2>悬停周详情</h2>
|
||||
<p class="pv-pros">指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。
|
||||
<b>优点</b>信息密度最高、动效克制;<b>代价</b>仍是鼠标优先,触屏靠点按。</p>
|
||||
</header>
|
||||
<div class="hm-inspector">
|
||||
<div class="hm-wrap" data-insp>
|
||||
<HeatGrid weeks={weeks} variant="inspector" />
|
||||
</div>
|
||||
<aside class="hm-week" data-insp-panel>
|
||||
<p class="mono">把指针移到任意一周</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。</p>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// ── A · 入场波纹:进视口一次性播放(不进视口不播,省电)──
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const cascades = document.querySelectorAll<HTMLElement>('[data-cascade]');
|
||||
if (!reduce && cascades.length) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add('play');
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -10% 0px' },
|
||||
);
|
||||
cascades.forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
// ── B · 光标光晕:只更新两个 CSS 变量,不跑 rAF 循环 ──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-spot]')) {
|
||||
const glow = wrap.querySelector<HTMLElement>('.hm-glow');
|
||||
const read = document.querySelector<HTMLElement>('[data-spot-read]');
|
||||
const move = (x: number, y: number) => {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
if (!glow) return;
|
||||
glow.style.setProperty('--x', `${x - r.left}px`);
|
||||
glow.style.setProperty('--y', `${y - r.top}px`);
|
||||
glow.style.opacity = '1';
|
||||
};
|
||||
wrap.addEventListener('pointermove', (e) => move(e.clientX, e.clientY));
|
||||
wrap.addEventListener('pointerleave', () => {
|
||||
if (glow) glow.style.opacity = '0';
|
||||
});
|
||||
// 读数:事件委托到格子(点按在触屏也work)
|
||||
const show = (cell: HTMLElement) => {
|
||||
if (!read) return;
|
||||
read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
|
||||
};
|
||||
wrap.addEventListener('pointerover', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) show(t);
|
||||
});
|
||||
wrap.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) show(t);
|
||||
});
|
||||
}
|
||||
|
||||
// ── C · 年度回放:滑块控制显示到第几周 + 累计数 ──
|
||||
const counts = Array.from(document.querySelectorAll<HTMLElement>('[data-scrub] .hg-c')).reduce<
|
||||
Record<number, number>
|
||||
>((acc, cell) => {
|
||||
const c = Number(cell.dataset.c ?? 0);
|
||||
acc[c] = (acc[c] ?? 0) + Number(cell.dataset.count ?? 0);
|
||||
return acc;
|
||||
}, {});
|
||||
const weeksTotal = Object.keys(counts).length;
|
||||
const cum = (upto: number) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < upto; i++) sum += counts[i] ?? 0;
|
||||
return sum;
|
||||
};
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-scrub]')) {
|
||||
const range = document.querySelector<HTMLInputElement>('[data-scrub-range]');
|
||||
const read = document.querySelector<HTMLElement>('[data-scrub-read]');
|
||||
const play = document.querySelector<HTMLButtonElement>('[data-scrub-play]');
|
||||
const apply = (v: number) => {
|
||||
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
||||
const visible = Number(cell.dataset.c ?? 0) < v;
|
||||
cell.style.opacity = visible ? '' : '0.06';
|
||||
}
|
||||
if (read) read.textContent = `累计 ${cum(v)} 次 · 第 ${v} 周`;
|
||||
};
|
||||
range?.addEventListener('input', () => apply(Number(range.value)));
|
||||
let timer = 0;
|
||||
play?.addEventListener('click', () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = 0;
|
||||
play.textContent = '▶ 播放';
|
||||
return;
|
||||
}
|
||||
play.textContent = '⏸ 暂停';
|
||||
if (!range) return;
|
||||
range.value = '0';
|
||||
apply(0);
|
||||
timer = window.setInterval(() => {
|
||||
if (!range) return;
|
||||
const next = Math.min(weeksTotal, Number(range.value) + 2);
|
||||
range.value = String(next);
|
||||
apply(next);
|
||||
if (next >= weeksTotal) {
|
||||
clearInterval(timer);
|
||||
timer = 0;
|
||||
play.textContent = '▶ 播放';
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
apply(Number(range?.value ?? weeksTotal));
|
||||
}
|
||||
|
||||
// ── D · 连续电流:最近连续段逐格流光(延迟按位置递增)──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-streak]')) {
|
||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')].filter((c) => Number(c.dataset.count ?? 0) > 0);
|
||||
const tail = cells.slice(-12);
|
||||
tail.forEach((c, i) => {
|
||||
c.style.setProperty('--flow', `${i * 90}ms`);
|
||||
c.classList.add('hg-flow');
|
||||
});
|
||||
wrap.classList.toggle('no-flow', reduce);
|
||||
}
|
||||
|
||||
// ── E · 悬停周详情:抬列 + 面板读数 ──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-insp]')) {
|
||||
const panel = document.querySelector<HTMLElement>('[data-insp-panel]');
|
||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
||||
const byWeek = new Map<number, HTMLElement[]>();
|
||||
for (const c of cells) {
|
||||
const w = Number(c.dataset.c ?? 0);
|
||||
(byWeek.get(w) ?? byWeek.set(w, []).get(w)!).push(c);
|
||||
}
|
||||
const showWeek = (w: number) => {
|
||||
wrap.querySelectorAll('.hg-c.lift').forEach((c) => c.classList.remove('lift'));
|
||||
const group = byWeek.get(w);
|
||||
if (!group) return;
|
||||
group.forEach((c) => c.classList.add('lift'));
|
||||
const totalWeek = group.reduce((s, c) => s + Number(c.dataset.count ?? 0), 0);
|
||||
const best = group.reduce((a, b) => (Number(b.dataset.count ?? 0) > Number(a.dataset.count ?? 0) ? b : a));
|
||||
const first = group[0]?.dataset.date ?? '';
|
||||
const last = group[group.length - 1]?.dataset.date ?? '';
|
||||
if (panel) {
|
||||
panel.innerHTML =
|
||||
`<p class="mono">${first} → ${last}</p>` +
|
||||
`<p class="hm-week-total"><strong>${totalWeek}</strong> 次</p>` +
|
||||
`<p class="mono hm-week-best">最好的一天 ${best.dataset.date} · ${best.dataset.count} 次</p>`;
|
||||
}
|
||||
};
|
||||
wrap.addEventListener('pointerover', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
||||
});
|
||||
wrap.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pv-title { font-size: var(--text-h1); color: var(--ink-1); margin-top: var(--space-5); }
|
||||
.pv-note { font-size: 14px; color: var(--ink-3); line-height: 1.75; max-width: var(--measure); margin-block: var(--space-2) var(--space-4); }
|
||||
.pv-switch {
|
||||
position: sticky; top: 0; z-index: 3; display: flex; flex-wrap: wrap; gap: var(--space-2);
|
||||
padding: var(--space-2) 0; background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(6px); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pv-chip {
|
||||
font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-2);
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px;
|
||||
transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.pv-chip:hover { border-color: var(--accent); color: var(--ink-1); }
|
||||
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
|
||||
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
|
||||
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
|
||||
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
|
||||
.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-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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@keyframes hmFlow {
|
||||
0%, 100% { opacity: 0; }
|
||||
45% { opacity: 0.55; }
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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); }
|
||||
|
||||
@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%; }
|
||||
}
|
||||
</style>
|
||||
+10
-3
@@ -187,10 +187,17 @@ ok('归档 首年展开/往年折叠 + 月分组', arch.total >= 1 && arch.open
|
||||
|
||||
await page.goto(BASE + '/books/', { waitUntil: 'networkidle' });
|
||||
const books = await page.evaluate(() => {
|
||||
const all = [...document.querySelectorAll('.year-group')];
|
||||
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false };
|
||||
// 书架已改版为「抽书阅读」(Phase 5i T7):不再有年份折叠组,
|
||||
// 改为每本书一个 details 行;断言 = 至少一行、首行收起、点击能展开出正文。
|
||||
const all = [...document.querySelectorAll('.book')];
|
||||
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length };
|
||||
});
|
||||
ok('书架 当年展开/往昔折叠', books.total >= 1 && books.open === 1 && books.firstOpen, JSON.stringify(books));
|
||||
ok('书架 抽书行渲染', books.total >= 1, JSON.stringify(books));
|
||||
await page.locator('.book').first().locator('summary').click();
|
||||
await page.waitForTimeout(250);
|
||||
ok('书架 点击可抽开', (await page.locator('.book[open] .body').count()) === 1);
|
||||
await page.locator('.book').first().locator('summary').click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// 4) 命令面板 + 搜索
|
||||
await page.keyboard.press('Control+k');
|
||||
|
||||
Reference in new issue
Block a user