Author SHA1 Message Date
wait4xandGLM-5.3 d6dfe39db3 feat(heatmap): 热力图六案 v2 预览上线(含贪吃蛇)
按用户反馈重做:F 贪吃蛇 / G 心跳呼吸 / H 雨滴涟漪 / I 3D 地形 / J 周冠军柱 / K 显微镜。

验证: 构建 exit 0;Playwright 逐案实测动效均生效(蛇 371 格爬完吃到 51 天)

Spec: none (预览页随选型迭代)

Co-authored-by: GLM-5.3
2026-11-04 20:47:26 +08:00
wait4xandGLM-5.3 6b2e4d8666 feat(opensource): 热力图五方案动效预览上线
用户要求「热力图可以有更多的动效,而不是静态的一张图」,先上线五案供选型:
A 入场波纹 / B 光标光晕 / C 年度回放 / D 连续电流 / E 悬停周详情。
均守灰阶红线、reduced-motion 静态化、零第三方依赖。

验证: 构建 exit 0;content-lint 52 页 0/0/0;Playwright 逐案实测动效均生效
      (A hmRise 0.42s、B 光晕读数、C 第 10 周回放、D 12 格 hmFlow、E 整列 lift)

Spec: none (预览页需先上线供用户选择)

Co-authored-by: GLM-5.3
2026-11-04 20:09:54 +08:00
wait4x 545c91a0ff Update 文章 “writing-template” 2026-11-03 06:44:46 +00:00
wait4x 7149541a92 Update 文章 “mian-sha” 2026-11-03 06:42:54 +00:00
wait4x a868bf94fd Update 文章 “shu-zui” +1 2026-11-03 06:42:01 +00:00
wait4x a46430b088 Update 文章 “ni-tan” +1 2026-11-03 06:40:43 +00:00
wait4x 1ce9cd65ea Update 文章 “mian-sha” +1 2026-11-03 06:39:33 +00:00
wait4x 6e4d6c2f88 Update 页面文案 “opensource” 2026-11-03 06:38:05 +00:00
11 changed files with 618 additions and 55 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+13 -31
View File
@@ -1,20 +1,25 @@
---
title: "面纱"
title: 面纱
pubDate: 2026-03-09
category: reading
tags: [read]
tags:
- read
pinned: true
mood: '#7c3aed'
bookAuthor: '威廉·萨默塞特·毛姆'
description: '面纱之下:理想的殉道者与真实的幸存者。'
bookAuthor: 威廉·萨默塞特·毛姆
description: 面纱之下:理想的殉道者与真实的幸存者。
cover: ./assets/面纱.png
hiddenTag: false
draft: false
---
****面纱之下:理想的殉道者和真实的幸存者****
**_面纱之下:理想的殉道者和真实的幸存者_**
### 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
这是译者在后记中写下的一句话。很好印证了《面纱》这本书或者说这个题目所蕴含的真相。人们活在面纱之下,以此遮蔽他人的窥探,却也因此困于自己的面纱之下。
见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有真对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。
见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有着对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。
相比之下,凯蒂是幸运的,也最终得到了自由。在虚荣的面纱下,遮住的是世界的残酷和社会的复杂以及人性的不可知。一开始的虚荣心作祟,让她步入了和沃尔特的婚姻,却发现婚后的生活并非是自己所期望的那样,甚至从一开始她就清楚的知道自己并不爱沃尔特。于是,当她到了香港,并遇上查理的时候,她沦陷了,她在查理身上看见了自己所期待的伴侣所拥有的那些特性,幽默风趣,性感迷人,但是却忽视了人性。在去往湄潭府之前,她虚荣,她害怕,她怕沃尔特发现一切,她软弱,她想逃离这样的婚姻和查理相拥,但是她不敢付诸行动。当沃尔特推着她去面对一切的时候,当一切都败露的时候,她终究是看见了人性的恶,她见到了查理的无情和所谓的爱只是言语上的“美好”,她也真正的感到了恐惧,面对死亡的恐惧和面对沃尔特不可捉摸的想法的恐惧。在湄潭府之行的初期,她依旧活在对于过去的回忆之中,她害怕恐惧,她看不见未来。但被圣光照耀的庙堂始终矗立在彼岸,她向往那迷人的色彩,她想要去追求新的生活。当她跨出第一步并开始在修道院工作的时候,她的觉醒之路从此慢慢开始,她慢慢和自己和解,慢慢看淡背叛,慢慢看清查理,慢慢找寻着自己在生活中该有的样子,她开始慢慢摘掉自己的面纱,看清自己的虚伪和软弱,开始追寻道的意义,她感悟周围的一切,甚至想着能够将沃尔特从他的痛苦中将他拉出。然而直到沃尔特死去,他们都没能和解,她愧对沃尔特,她看到了自己的轻贱,可是她自始至终都跨不过沃尔特竖起的高墙,她在墙外,沃尔特在墙内,各自在各自的面纱下痛苦的活着。沃尔特的死,是沃尔特的一种“解脱”,也是凯蒂的一种解脱。
@@ -22,7 +27,7 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
>
> 所谓《面纱》,遮住的不仅是真相,更是我们面对真实自我的勇气。沃尔特缺乏这份勇气去接纳不完美的爱与人性,所以他困死其中;凯蒂在生死关头被迫拥有了这份勇气,所以她获得了自由。
>
> 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:****人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。****
> 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:**_人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。_**
从背叛到自我的救赎,凯蒂在远离家乡的异国,没能逃脱欲望作祟,再次回到香港之后又一次沦陷在了查理的怀抱中,但此刻的她早已不是去湄潭府的之前的自己,她的思想早已骤变。她厌恶自己肉体对自己灵魂的出卖,她选择远离这个是非之地,她选择去重新追求自由,亦如从湄潭府离开一样,她再一次走上了追寻自由的路上。她回到故土,见母亲离世,见妹妹怀有身孕,见父亲终于脱离过去的自己,她亦想脱离过去的自己,拥抱一个崭新的未来。活在面纱之下的她和她的父亲,在故事的结局,两人袒露心声,一同前往新的地方开始新的生活,去活出新的自己。
@@ -32,7 +37,7 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
沃尔特死在了他的‘正确’里,而凯蒂活在了她的‘真实’中。沃尔特的故事是带有悲惨的个人主义的牺牲,凯蒂的故事是带有幸运的个人主义的新生。沃尔特自觉没有希望,在困顿的泥潭中深陷,他没有勇气去面对新的生活和新的世界,他将自己紧紧隐藏在自己的面纱之后,他活在自己为自己构建的信念世界之中,为自己的信念,"悲壮”赴死。凯蒂活在纷繁的社会中,从小我见大我,从假我见真我,从困局中得自由。她一步一步从虚伪的面纱下得见人性、社会冷暖以及新世界的希望,湄潭府之前的生活让她自省,湄潭府之行让她感知到一切,在死亡和黑暗中得到感悟和觉醒,得见光明,再次回到香港,欲望再次作祟,这一次她没有沉沦于虚妄的现实,她终于携手勇气做出了自己的决定,踏上了"离开”的归程。
故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:****唯有真实,可得自由。****
故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:**_唯有真实,可得自由。_**
在《面纱》的故事中,沃尔特是极具“英雄主义”的个人,为自己固有的信仰慷然赴死;凯蒂是在纷杂的社会中逐步成长,在欲望和虚荣的交叉间度过早年,湄潭府一行,在黑暗中见光明,在死亡中见希望,在异国见真我,在“新世界”得自由。
@@ -41,51 +46,28 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
------------------------------------------------------------------------
- 我不了解你,我一点儿都不了解你。
- 我知道你愚蠢、轻浮、没有头脑,但是我爱你。我知道你的目标和理想既庸俗又普通,但是我爱你。我知道你是二流货色,但是我爱你。想一想真是好笑,我竭力去喜欢那些讨你喜欢的东西,忍受折磨也要对你隐瞒起自己,实际上我并不无知粗俗、不爱散播丑闻也不愚蠢。我知道你何等害怕智慧,便尽我所能让你觉得我是个大傻瓜,跟你认识的其他人一样。我知道你嫁给我只图一时利益,我是那样爱你,我不在乎。大多数人,就我所知,当他们爱一个人,却没有得到爱的回报时就会觉得委屈不平,甚至愈发愤怒和痛苦。我不是那样,我从来没有指望你爱我,我看不出任何理由让你爱我,我也从来没想过自己会被人爱。我很感激能被允许爱你,当我时常想起你高兴跟我在一起,或者当我发现你眼中闪烁着愉快的爱意时,我就会欣喜若狂。我尽量不让我的爱来烦扰你,我知道那会让我承受不起,所以我一直察言观色,留意我的爱让你厌烦的最初迹象。大部分丈夫认为那是一种权力,我却准备当成恩惠来接受。
- 他知道你虚荣、懦弱、自私自利,他想让我亲眼看到这一切。他知道你会像野兔一样,危险一来就跑得远远的。他知道我深受蒙骗才会认为你爱上了我,因为他知道你不会爱任何人,只爱你自己。他知道你会牺牲我,好让自己毫发无损地逃脱出去。
- 死亡既突然又无情,在一条条曲折的街巷上匆匆穿过。
- 黎明乍现,阳光触到雾霭,令其闪闪发白,犹如雪之幽灵降至即将熄灭的星宿之上。
- 你瞧,这里只有你跟我踏踏实实在坚实的地上行走。修女们走在天上,而你丈夫则走在黑暗里。
- 他的灵魂撕裂了,他一直活在一种虚假的构想之中,当真相击碎了幻象,他便认为现实本身也被击碎了。
- 这个时辰的城市街巷空空荡荡,比任何时候都更像一座死城。路人一个个神色迷离,不禁让人怀疑他们全都是鬼魂。天空晴朗无云,初升的太阳将圣洁和煦的光芒洒满大地。很难想象,在这个愉悦、清新、爽朗的黎明,这座城市却像被疯子一手扼住咽喉的人,在瘟疫的黑暗掌控下苟延残喘。不可思议的是,当人类在痛苦挣扎、在恐惧中走向死亡时,大自然(蓝色的天空如孩童的心一般清澈)竟会如此无动于衷。
- 安宁只存在于人的灵魂中。
- 所有的人,乃至整个人类,就如同这条河里的水滴一样,流淌不定,一滴滴彼此接近,却又相距遥远,汇成一股无名的巨流奔向大海。既然一切转瞬即逝,任何事物都无关宏旨,人们竟还要荒唐地看重那些微不足道的事情,让自己也让别人遭受不幸,这实在太可悲了。
- 小小的两滴水随着河流默默流向未知,这两滴水相对而言是那样独特,但在旁观者的眼里,不过是河流中无法辨识的组成部分。
- 我又明白什么呢,生命是那么奇特。我觉得自己就像个一辈子都住在小池塘边上的人,突然间看见了大海,让我有点喘不过气来,但心里又充满了喜悦。我不想死,想活下去,于是感到一股新的勇气。我就好像那些老水手,起航驶向尚未发现的海洋,我的灵魂渴求未知的一切。
- 小小的两滴水默默地、默默地流向那黑暗、永恒的大海。
- 道。我们有些人在鸦片中寻找道,有些人在上帝那儿,有些人寻求威士忌,或去爱里寻找。这道终归只有一条,可它不通向任何地方。
- 这一切既新奇又感人,她的心灵犹如一片平静而灰暗的大海,波浪起伏,蔚然壮观,阴郁浩瀚令人畏惧,突然间,一缕阳光投射过来,让一切变得活泼、友好而快乐。
- 人生何其短暂,世界本来就充满了苦痛,人们却还要折磨自己,这岂不太可怜了吗?
- 她的性格就像一片乡野,初见时感觉辽阔而冷漠,但不久就会在巍峨山岭的褶皱间发现一个个掩映在果树丛中的欢腾的村庄,看见郁郁葱葱的草地上欢快流淌的一条条小河。这番宜人的景象会让你惊奇,甚至熨帖安心,但远处狂风劲吹的黄褐色高地尚不足以使你感到自如自在。
- 只有一种办法赢得人心,那就是让自己成为人们会去爱的人。
- 我说不清。我不知道她们追求幻觉这一点是否真的那么重要,还是这种生活本身就很美好。我有个想法,唯一让我们有可能不带嫌恶地关注我们所生活的这个世界的,就是人类不断从混沌中创造出的美。他们描绘的画,他们谱出的乐曲,他们写的书,还有他们的生活。这一切中,最富于美的就是美好的生活,那是件完美的艺术品。
- 道就是道路和行道者。那是一条永恒的路,所有的生命存在行走其上,但它并非由生命存在所创造,因为它本身便是生命存在。它什么都是,又什么都不是。万物由道所生,与道相符,最后万物又回归于道。它是一块方形却无四角,是种声音却不被聆听,是幅图画却未有形状;它是一张巨大的网,网眼阔如海洋,却什么都无法穿过;它是万物寻求庇护的避难所;它无处可寻,但你‘不窥牖’便可‘见天道’;它要人学会欲无所欲,让一切顺其自然。谦卑者尽得保全,屈身者终将直立。‘祸兮,福之所依;福兮,祸之所伏’;但谁能说清什么时候会出现转折点?追求柔慈之人会如小孩子一样平和。柔慈为进攻者获取胜利,为守卫者求得保全。战胜自己的人最为强大。
- 自由!这便是一直萦绕在她心中的念头。尽管未来仍旧模糊,这一念头却像河上的薄雾,在清晨阳光的照耀下焕发虹彩。自由!不仅仅是挣脱烦恼的束缚,解除那让她消沉的伴侣关系的自由。自由!不仅是逃离死亡威胁的自由,更是逃离让她降低人格的爱情,逃脱所有精神束缚的自由,一种抽离出肉体的精神的自由。与自由相伴的,还有勇气,以及无论发生什么都毫不在乎的坚强气质。
- 她以为自己变了,以为自己意志坚强,会以一个冷静自持的女人的面目回到香港。心中掠过一个个崭新的念头,就像阳光下翻飞的黄色蝴蝶,让她对美好的未来充满期望。自由就像光的精灵召唤她,整个世界犹如一片广袤的平原,任她迈着轻快的步子昂首前行。她原以为自己已摆脱了肉欲和卑劣的情爱,足以过上一种纯洁健康的精神生活。她曾将自己比作黄昏时分悠然飞跃稻田的白鹭,它们就像安闲自处的头脑中翩然翱翔的片片思绪,可实际上她却仍是欲望的奴隶。
- 我有希望,也有勇气。
- 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
------------------------------------------------------------------------
+10 -5
View File
@@ -1,11 +1,16 @@
---
title: "泥潭"
title: 泥潭
pubDate: 2026-02-09
category: reading
tags: [read]
mood: '#166534'
bookAuthor: '刘楚昕'
description: '迷失于黑夜时,不妨抬头看看星空。'
tags:
- read
pinned: false
mood: '#686868'
bookAuthor: 刘楚昕
description: 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
cover: ./assets/泥潭.png
hiddenTag: false
draft: false
---
### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
+10 -5
View File
@@ -1,11 +1,16 @@
---
title: "赎罪"
title: 赎罪
pubDate: 2026-02-26
category: reading
tags: [read]
mood: '#9f1239'
bookAuthor: '伊恩·麦克尤恩'
description: '因一个人、一张图、一句台词,看了一本书。'
tags:
- read
pinned: false
mood: '#bf002f'
bookAuthor: 伊恩·麦克尤恩
description: 亲爱的塞西莉亚,我们缘分未尽
cover: ./assets/赎罪.png
hiddenTag: false
draft: false
---
### 你要回来,我会等你。
+6 -2
View File
@@ -1,10 +1,14 @@
---
title: "写作模板:这篇文章本身就是模板"
title: 写作模板:这篇文章本身就是模板
pubDate: 2026-11-02
category: tech
tags: [写作, 模板]
tags:
- 写作
- 模板
pinned: true
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
hiddenTag: true
draft: false
---
这篇不是教程,是**可复制的模板**。下面每一节都在真跑——你看到的效果,就是 Markdown 源码能产出的效果。写新文章时,把本文件复制一份、改个 slug、删掉不需要的段落即可。
+1 -12
View File
@@ -1,4 +1,3 @@
# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 6 行;留空用内置默认三行)
typing:
- zh: 气象出身,写代码为乐
en: Trained in meteorology, coding for fun
@@ -6,9 +5,6 @@ typing:
en: Self-hosting whatever can be self-hosted
- zh: 拥抱开源,热爱开源
en: Embrace open source, love open source
- zh: 书与快门都在一个工作台
en: Books and shutters, one workbench
blurb:
zh: 本站本身即开源:
en: 'This site is itself open source:'
@@ -20,11 +16,4 @@ blurb_after:
mode: all
sort: pinned-first
perSource: github
intro_from: "off"
# ---- 开篇来源说明(2026-11-02 Phase 5h)----
# 开篇已改原生 GitHub 档案卡(头像 / 统计四格 / 语言占比灰阶条),不再渲染 README 原文。
# intro_from: "off" = 用档案卡 + 上面的 blurb(当前)
# intro_from: github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染(**不推荐**:
# 会引入 7 类第三方外链图,慢网或被墙时整片空白;README 用 <div align="center"> 居中,
# 本站不认该属性 → 居中失效。这正是当初"内容没居中、图片不显示"的根因。)
intro_from: 'off'
+4
View File
@@ -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: 全跳过
+58
View File
@@ -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>
+516
View File
@@ -0,0 +1,516 @@
---
/**
* 热力图动效方案预览 v2(Phase 5j)——用户看过 v1 五案后觉得不够,要求「类似贪吃蛇」的
* 方案并再深入设计 4–6 个。本页给 6 个更深入的方案(F–K),v1 的 A–E 已下线。
*
* 设计红线(六案都守):灰阶骨架、accent 只做高亮不做数据编码(数据仍由 ink 四档承载)、
* prefers-reduced-motion 一律静态、零第三方依赖、离开视口即停(省电)。
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro';
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);
/* ── 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数)── */
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
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);
if ((countOf.get(isoOf(d)) ?? 0) > 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]);
/* ── 月度聚合(J 方案用)── */
const byMonth = new Map<string, number>();
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 };
---
<BaseLayout title="热力图动效方案 v2 · wait4x" description="六个更深入的热力图动效方案">
<h1 class="pv-title">热力图:六个更深入的方案</h1>
<p class="pv-note">
同一份真实数据(近一年 <strong>{stats.total}</strong> 次贡献 · 当前连续
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天 · 单日最高 {stats.best} 次),
数据更新于 {buildTime}。
<br />
上一版的五个方案已按你的反馈下线;这一版六个都往「有生命、有叙事」的方向推。
<strong>回字母告诉我选哪个</strong>(也可以说「F 但慢一点」「F + J 组合」)。
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#hf" class="pv-chip">F · 贪吃蛇</a>
<a href="#hg" class="pv-chip">G · 心跳呼吸</a>
<a href="#hh" class="pv-chip">H · 雨滴涟漪</a>
<a href="#hi" class="pv-chip">I · 3D 地形</a>
<a href="#hj" class="pv-chip">J · 周冠军柱</a>
<a href="#hk" class="pv-chip">K · 显微镜</a>
</nav>
<!-- ═══════════ F · 贪吃蛇 ═══════════ -->
<section id="hf" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F</span>
<h2>贪吃蛇</h2>
<p class="pv-pros">一条蛇沿着贡献格蛇形爬过:吃到有贡献的格子就把它们点亮并"长"一点,空格直接穿过。
<b>优点</b>最有生命感、天然表达"这一年被一点点吃掉";<b>代价</b>是持续动画(离开视口即暂停)。</p>
</header>
<div class="hm-wrap" data-snake>
<HeatGrid weeks={weeks} variant="snake" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · <button class="hm-btn" type="button" data-snake-replay>重新开始</button></p>
</section>
<!-- ═══════════ G · 心跳呼吸 ═══════════ -->
<section id="hg" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">G</span>
<h2>心跳呼吸</h2>
<p class="pv-pros">每格按当日贡献量呼吸:越忙的日子跳得越快、越亮,静下来时整片缓慢起伏。
<b>优点</b>像一张"活的"心电图,无需交互;<b>代价</b>持续动画(纯 CSS,合成层,很省)。</p>
</header>
<div class="hm-wrap" data-breath>
<HeatGrid weeks={weeks} variant="breath" />
</div>
</section>
<!-- ═══════════ H · 雨滴涟漪 ═══════════ -->
<section id="hh" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">H</span>
<h2>雨滴涟漪</h2>
<p class="pv-pros">从"最后一次贡献"那一格出发,涟漪一圈圈向外扩散,扫过的格子闪一下再落定。
<b>优点</b>叙事清晰(现在 → 过去)、只播一次;<b>代价</b>纯装饰。</p>
</header>
<div class="hm-wrap" data-ripple>
<HeatGrid weeks={weeks} variant="ripple" />
</div>
<p class="hm-read mono"><button class="hm-btn" type="button" data-ripple-replay>再看一次涟漪</button> · 起点 {lastActive}</p>
</section>
<!-- ═══════════ I · 3D 地形 ═══════════ -->
<section id="hi" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">I</span>
<h2>3D 地形</h2>
<p class="pv-pros">把贡献量当"海拔":忙的日子凸起来,整块网格随鼠标轻轻倾斜,像俯瞰一年的地貌。
<b>优点</b>一眼看出密集区、视觉独特;<b>代价</b>倾斜会略降可读性。</p>
</header>
<div class="hm-3d" data-terrain>
<div class="hm-3d-inner">
<HeatGrid weeks={weeks} variant="terrain" />
</div>
</div>
</section>
<!-- ═══════════ J · 周冠军柱 ═══════════ -->
<section id="hj" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">J</span>
<h2>周冠军柱</h2>
<p class="pv-pros">网格下方按月长出一排柱子,进视口时逐根升起;指针移到柱子上,对应那些格子同时亮起。
<b>优点</b>把"哪几个月最忙"讲清楚、信息量大;<b>代价</b>多一层图表,页面变高。</p>
</header>
<div class="hm-wrap" data-bars>
<HeatGrid weeks={weeks} variant="bars" />
</div>
<ul class="hm-bars" data-bars-chart>
{months.map(([m, v]) => (
<li>
<button class="hm-bar-btn" type="button" data-month={m} aria-label={`${m} 共 ${v} 次`}>
<span class="hm-bar-fill" style={`--h:${Math.round((v / maxMonth) * 100)}%`} />
</button>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
</section>
<!-- ═══════════ K · 显微镜 ═══════════ -->
<section id="hk" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">K</span>
<h2>显微镜</h2>
<p class="pv-pros">指针所在处浮起一枚圆镜,把周围 5×5 的格子放大显示,并标出当天日期与次数。
<b>优点</b>小格子也读得清、趣味强;<b>代价</b>触屏需点按。</p>
</header>
<div class="hm-wrap hm-lens-wrap" data-lens>
<HeatGrid weeks={weeks} variant="lens" />
<span class="hm-lens" data-lens-glass hidden></span>
</div>
<p class="hm-read mono" data-lens-read>把指针移到任意格子上</p>
</section>
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下六案全部静态。</p>
<script is:inline type="application/json" id="hm-data" set:html={JSON.stringify(dataForClient)} />
<span hidden data-ripple-origin={`${lastActiveCol},${lastActiveRow}`}></span>
</BaseLayout>
<script>
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const raw = document.getElementById('hm-data')?.textContent ?? '{"cells":[],"weeks":0}';
const DATA = JSON.parse(raw) as { cells: number[]; weeks: number };
const cellsAll = () => [...document.querySelectorAll<HTMLElement>('.hg-c')];
/** 离开视口即停:省电,也避免滚动时 CPU 抖动 */
const whenVisible = (el: Element, onIn: () => void, onOut?: () => void) => {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) (e.isIntersecting ? onIn : onOut)?.();
},
{ threshold: 0.15 },
);
io.observe(el);
return io;
};
/* ══════ F · 贪吃蛇 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const readEl = document.querySelector<HTMLElement>('[data-snake-read]');
// 蛇形路径:按列走,奇数列自下而上(与 GitHub 贡献蛇同构)
const path: number[] = [];
for (let c = 0; c < DATA.weeks; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse();
for (const r of rows) path.push(c * 7 + r);
}
let step = 0;
let timer = 0;
let eaten = 0;
const reset = () => {
step = 0;
eaten = 0;
cells.forEach((c) => c.classList.remove('snake-head', 'snake-eaten', 'snake-passed'));
if (readEl) readEl.textContent = '准备出发…';
};
const tick = () => {
const idx = path[step];
cells.forEach((c) => c.classList.remove('snake-head'));
const cell = cells[idx];
if (!cell) return;
cell.classList.add('snake-head');
// 爬过即留下淡色轨迹:真实数据里 371 格只有 ~51 天有贡献,
// 若只在有贡献的格子给反馈,蛇大半程看起来是"空跑"。
cell.classList.add('snake-passed');
const count = Number(cell.dataset.count ?? 0);
if (count > 0) {
cell.classList.add('snake-eaten');
eaten++;
if (readEl) readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
}
step++;
if (step >= path.length) {
if (readEl) readEl.textContent = `整年爬完:走过 ${path.length} 格,吃到 ${eaten} 天`;
clearInterval(timer);
timer = 0;
}
};
const start = () => {
reset();
if (reduce) {
cells.forEach((c) => {
c.classList.add('snake-passed');
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
});
if (readEl) readEl.textContent = '已按减少动态效果静态呈现';
return;
}
if (timer) return;
timer = window.setInterval(tick, 30);
};
whenVisible(
wrap,
() => {
if (!timer) start();
},
() => {
if (timer) {
clearInterval(timer);
timer = 0;
}
},
);
document.querySelector('[data-snake-replay]')?.addEventListener('click', () => {
if (timer) clearInterval(timer);
timer = 0;
start();
});
}
/* ══════ G · 心跳呼吸(纯 CSS 驱动,只按数据写变量)══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-breath]')) {
const max = Math.max(...DATA.cells, 1);
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const n = Number(cell.dataset.count ?? 0);
if (n <= 0) continue;
// 越忙跳得越快(时长 3.4s → 1.2s),幅度略随强度增大
const dur = 3.4 - (n / max) * 2.2;
cell.style.setProperty('--breath', `${dur.toFixed(2)}s`);
cell.classList.add('hg-breath');
}
}
/* ══════ H · 雨滴涟漪 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-ripple]')) {
const origin = document.querySelector('[data-ripple-origin]')?.getAttribute('data-ripple-origin') ?? '0,0';
const [oc, or] = origin.split(',').map(Number);
const play = () => {
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const c = Number(cell.dataset.c ?? 0);
const r = Number(cell.dataset.r ?? 0);
// 以"最后一次贡献"为圆心,按列/行距离给错峰延迟
const dist = Math.hypot(c - oc, (r - or) * 1.6);
cell.style.setProperty('--wave', `${Math.round(dist * 26)}ms`);
}
wrap.classList.remove('play');
void wrap.offsetWidth; // 强制回流,让动画重播
wrap.classList.add('play');
};
whenVisible(wrap, () => {
if (!reduce) play();
});
document.querySelector('[data-ripple-replay]')?.addEventListener('click', () => {
if (!reduce) play();
});
}
/* ══════ I · 3D 地形 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-terrain]')) {
const max = Math.max(...DATA.cells, 1);
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const n = Number(cell.dataset.count ?? 0);
cell.style.setProperty('--z', `${((n / max) * 14).toFixed(1)}px`);
if (n > 0) cell.classList.add('hg-raise');
}
if (reduce) continue;
wrap.addEventListener('pointermove', (e) => {
const r = wrap.getBoundingClientRect();
const dx = (e.clientX - r.left) / r.width - 0.5;
const dy = (e.clientY - r.top) / r.height - 0.5;
wrap.style.setProperty('--rx', `${(-dy * 10).toFixed(2)}deg`);
wrap.style.setProperty('--ry', `${(dx * 12).toFixed(2)}deg`);
});
wrap.addEventListener('pointerleave', () => {
wrap.style.setProperty('--rx', '0deg');
wrap.style.setProperty('--ry', '0deg');
});
}
/* ══════ J · 周冠军柱 ══════ */
for (const chart of document.querySelectorAll<HTMLElement>('[data-bars-chart]')) {
whenVisible(chart, () => chart.classList.add('grow'));
const grid = document.querySelector<HTMLElement>('[data-bars]');
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) {
const m = btn.dataset.month ?? '';
btn.addEventListener('pointerenter', () => {
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => {
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m));
});
});
btn.addEventListener('pointerleave', () => {
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month'));
});
btn.addEventListener('focus', () => btn.dispatchEvent(new Event('pointerenter')));
btn.addEventListener('blur', () => btn.dispatchEvent(new Event('pointerleave')));
}
}
/* ══════ K · 显微镜 ══════ */
for (const wrap of document.querySelectorAll<HTMLElement>('[data-lens]')) {
const glass = wrap.querySelector<HTMLElement>('[data-lens-glass]');
const read = document.querySelector<HTMLElement>('[data-lens-read]');
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const show = (cell: HTMLElement) => {
const c = Number(cell.dataset.c ?? 0);
const r = Number(cell.dataset.r ?? 0);
if (!glass) return;
glass.hidden = false;
// 5×5 邻域放大:用 CSS 变量把每格的"档位"喂给镜内的 25 个小格
let mini = '';
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const idx = (c + dc) * 7 + (r + dr);
const n = data(idx);
mini += `<i style="--v:${Math.min(n, 8)}"></i>`;
}
}
glass.innerHTML = `<span class="hm-lens-grid">${mini}</span><span class="hm-lens-tip mono">${cell.dataset.date} · ${cell.dataset.count} 次</span>`;
const box = wrap.getBoundingClientRect();
const cb = cell.getBoundingClientRect();
glass.style.setProperty('--lx', `${cb.left - box.left + cb.width / 2}px`);
glass.style.setProperty('--ly', `${cb.top - box.top + cb.height / 2}px`);
if (read) read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
cells.forEach((x) => x.classList.remove('hg-focus'));
cell.classList.add('hg-focus');
void r;
};
const data = (idx: number) => (idx >= 0 && idx < DATA.cells.length ? DATA.cells[idx] : 0);
wrap.addEventListener('pointermove', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) show(t);
});
wrap.addEventListener('pointerleave', () => {
if (glass) glass.hidden = true;
cells.forEach((x) => x.classList.remove('hg-focus'));
});
wrap.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) show(t);
});
}
</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: 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: 3px 10px; cursor: pointer;
}
.hm-btn:hover { border-color: var(--accent); }
/* ── F 蛇:头 + 吃过 ── */
:global(.snake-head) {
outline: 2px solid var(--accent);
outline-offset: 1px;
z-index: 1;
position: relative;
}
: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);
}
/* ── 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); }
}
/* ── 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; }
}
/* ── 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) {
: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; }
}
</style>