惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

W
WeLiveSecurity
The Last Watchdog
The Last Watchdog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
G
Google Developers Blog
博客园 - 叶小钗
雷峰网
雷峰网
人人都是产品经理
人人都是产品经理
博客园_首页
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 三生石上(FineUI控件)
Help Net Security
Help Net Security
Cloudbric
Cloudbric
AI
AI
N
News | PayPal Newsroom
博客园 - 聂微东
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 【当耐特】
Forbes - Security
Forbes - Security
美团技术团队
Stack Overflow Blog
Stack Overflow Blog
SecWiki News
SecWiki News
H
Heimdal Security Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
MyScale Blog
MyScale Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Proofpoint News Feed
S
Security @ Cisco Blogs
Google DeepMind News
Google DeepMind News
V
V2EX
大猫的无限游戏
大猫的无限游戏
阮一峰的网络日志
阮一峰的网络日志
S
Security Affairs
L
LangChain Blog
The Hacker News
The Hacker News
F
Full Disclosure
aimingoo的专栏
aimingoo的专栏
Hacker News - Newest:
Hacker News - Newest: "LLM"
腾讯CDC
Webroot Blog
Webroot Blog
A
About on SuperTechFans
H
Hacker News: Front Page
Cyberwarzone
Cyberwarzone
WordPress大学
WordPress大学
L
LINUX DO - 热门话题
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Attack and Defense Labs
Attack and Defense Labs
M
MIT News - Artificial intelligence

洛屿的小站

STM32F407 IAR环境下uC/OS-III移植完整指南 STM32 UCOS3+HAL库集成中的FPU HardFault问题 一种简单的卡尔曼滤波器设计 Cadence - 应律而动,落指成音 Zephyr - 聆风之息,为你而奏 不改一行插件代码,实现消息优先级与阻断 洛玖定时任务系统 你好,2026 stm32f4xx-ads1256驱动 stm32f4xx-ad9854并行驱动 主动式网站状态监测实现及其应用 右键菜单加入用Trae打开文件和文件夹 三角洲行动ID映射表 洛玖SDK说明 为网页文章开头添加原文连接 Hexo-Butterfly主题在主页添加GitHub贡献日历 Proteus中555定时器仿真问题 装饰器 洛玖开发日记 STS3032舵机获取力矩输出 kotlin网页前后端那些事 mspm0g3507-ad9850 奇怪的bug Paddle模型转PaddleLite 人工智能考核 构建一个yolov3网络 yolo和paddle模型常见输出参数 PaddleDetection 随便写的一些东西 实验室C语言第一次考核题目讲解及相关代码解读 C语言神经网络房价预测系统 C、C++数组,指针,指针数组,数组指针的区别 C、C++的大括号是必须的部分吗? C、C++预处理详解 C、C++其他关键字详解 C、C++存储类型关键字详解 C、C++控制语句关键字详解 C、C++数据类型关键字详解 C、C++关键字 C++药品管理系统 Bi-LSTM(Attention)的PyTorch实现 C语言实现波士顿房价预测 easy库的使用 edgeboardFZ3A相关问题 Predict.py的编写 Paddle环境搭建 百度Paddle模型训练 GMD09601-0.96OLED显示屏 【LeetCode 1617】统计子树中城市之间最大距离 C语言学习相关 STM32阵列按键 CH32(F10X F20X) 最短路 拓扑排序
在 butterfly 主题中添加首页点集动画(基于p2line项目)
洛屿 · 2026-03-10 · via 洛屿的小站
1
2
#home-line-animation
canvas#line-canvas
1
themes/butterfly/source/line.json
1
2
3
4
5
6
7
8
lineAnimation: {
mode: '!{theme.line_animation && theme.line_animation.mode ? theme.line_animation.mode : "sequence"}',
duration: !{theme.line_animation && theme.line_animation.duration ? theme.line_animation.duration : 3000},
random_duration: !{theme.line_animation && theme.line_animation.random_duration ? theme.line_animation.random_duration : 3000},
position_mode: '!{theme.line_animation && theme.line_animation.position_mode ? theme.line_animation.position_mode : "center"}',
offset_ratio: !{theme.line_animation && theme.line_animation.offset_ratio ? JSON.stringify(theme.line_animation.offset_ratio) : "{\"x\":0,\"y\":0}"},
scale: !{theme.line_animation && theme.line_animation.scale ? theme.line_animation.scale : 1}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
diff --git a/source/js/main.js b/source/js/main.js
index e00d435..96c4e1c 100644
--- a/source/js/main.js
+++ b/source/js/main.js
@@ -62,6 +62,416 @@ document.addEventListener('DOMContentLoaded', function () {
})
}

+ /**
+ * 首頁線條動畫
+ */
+ const initHomeLineAnimation = () => {
+ if (!GLOBAL_CONFIG_SITE.isHome) {
+ window.__homeLineAnimator && window.__homeLineAnimator.destroy()
+ window.__homeLineAnimator = null
+ return
+ }
+
+ const canvas = document.getElementById('line-canvas')
+ const header = document.getElementById('page-header')
+ if (!canvas || !header) return
+
+ window.__homeLineAnimator && window.__homeLineAnimator.destroy()
+ const animator = createHomeLineAnimator(canvas, header)
+ if (!animator) return
+ window.__homeLineAnimator = animator
+ let hasPlayed = false
+ let shouldForcePlay = false
+ try {
+ hasPlayed = window.sessionStorage && window.sessionStorage.getItem('homeLinePlayed') === '1'
+ const navEntry = window.performance && window.performance.getEntriesByType
+ ? window.performance.getEntriesByType('navigation')[0]
+ : null
+ const navType = navEntry && navEntry.type ? navEntry.type : ''
+ shouldForcePlay = navType === 'reload' || navType === 'navigate'
+ } catch (error) {
+ hasPlayed = false
+ shouldForcePlay = true
+ }
+
+ if (shouldForcePlay) {
+ animator.initOnce(() => {
+ try {
+ window.sessionStorage && window.sessionStorage.setItem('homeLinePlayed', '1')
+ } catch (error) {
+ // ignore
+ }
+ })
+ } else if (hasPlayed) {
+ animator.renderStatic()
+ } else {
+ animator.initOnce(() => {
+ try {
+ window.sessionStorage && window.sessionStorage.setItem('homeLinePlayed', '1')
+ } catch (error) {
+ // ignore
+ }
+ })
+ }
+ }
+
+ /**
+ * 創建首頁線條動畫控制器
+ */
+ const createHomeLineAnimator = (canvas, header) => {
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return null
+
+ const baseRoot = GLOBAL_CONFIG.root.endsWith('/') ? GLOBAL_CONFIG.root : `${GLOBAL_CONFIG.root}/`
+ const dataUrl = `${baseRoot}line.json`
+ const maxPoints = 8000
+ let resizeObserver = null
+ let rafId = 0
+ let resizeHandler = null
+ let points = []
+ let mappedPoints = []
+ let startTime = 0
+ let lastDrawCount = 0
+ let lastProgress = 0
+ let drawOrder = []
+ let canvasWidth = 0
+ let canvasHeight = 0
+ let destroyed = false
+ let completed = false
+ let mode = 'once'
+ const getDuration = () => {
+ const config = GLOBAL_CONFIG && GLOBAL_CONFIG.lineAnimation ? GLOBAL_CONFIG.lineAnimation : {}
+ const modeValue = config.mode || 'sequence'
+ const durationValue = modeValue === 'random'
+ ? Number(config.random_duration || config.duration || 3000)
+ : Number(config.duration || 3000)
+ return Number.isFinite(durationValue) && durationValue > 0 ? durationValue : 3000
+ }
+
+ /**
+ * 取得點集位置配置
+ */
+ const getPositionConfig = () => {
+ const config = GLOBAL_CONFIG && GLOBAL_CONFIG.lineAnimation ? GLOBAL_CONFIG.lineAnimation : {}
+ const modeValue = config.position_mode || 'center'
+ const ratio = config.offset_ratio || {}
+ const ratioX = Number(ratio.x)
+ const ratioY = Number(ratio.y)
+ const scaleValue = Number(config.scale)
+ return {
+ mode: modeValue,
+ offsetXRatio: normalizeRatio(ratioX),
+ offsetYRatio: normalizeRatio(ratioY),
+ scale: normalizeScale(scaleValue)
+ }
+ }
+
+ /**
+ * 正規化偏移占比
+ */
+ const normalizeRatio = value => {
+ if (!Number.isFinite(value)) return 0
+ return Math.max(-1, Math.min(1, value))
+ }
+
+ const normalizeScale = value => {
+ if (!Number.isFinite(value)) return 1
+ return Math.max(0.1, Math.min(2, value))
+ }
+
+ /**
+ * 取得位置錨點
+ */
+ const getPositionAnchor = modeValue => {
+ switch (modeValue) {
+ case 'left_top':
+ return { anchorX: 0, anchorY: 0 }
+ case 'left_bottom':
+ return { anchorX: 0, anchorY: 1 }
+ case 'right_top':
+ return { anchorX: 1, anchorY: 0 }
+ case 'right_bottom':
+ return { anchorX: 1, anchorY: 1 }
+ case 'middle_left':
+ return { anchorX: 0, anchorY: 0.5 }
+ case 'middle_right':
+ return { anchorX: 1, anchorY: 0.5 }
+ default:
+ return { anchorX: 0.5, anchorY: 0.5 }
+ }
+ }
+ const pointRadius = 0.5
+ const pointSprite = createPointSprite(pointRadius)
+ const spriteHalfSize = pointSprite ? pointSprite.width / 2 : 0
+
+ /**
+ * 建立點的離屏貼圖
+ */
+ function createPointSprite (radius) {
+ const size = Math.max(4, Math.ceil(radius * 6))
+ const sprite = document.createElement('canvas')
+ sprite.width = size
+ sprite.height = size
+ const spriteCtx = sprite.getContext('2d')
+ if (!spriteCtx) return null
+
+ const center = size / 2
+ const glowRadius = radius * 3
+ const gradient = spriteCtx.createRadialGradient(center, center, 0, center, center, glowRadius)
+ gradient.addColorStop(0, 'rgba(255, 255, 255, 0.95)')
+ gradient.addColorStop(0.45, 'rgba(255, 255, 255, 0.7)')
+ gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
+ spriteCtx.fillStyle = gradient
+ spriteCtx.beginPath()
+ spriteCtx.arc(center, center, glowRadius, 0, Math.PI * 2)
+ spriteCtx.fill()
+ return sprite
+ }
+
+ /**
+ * 下採樣點集
+ */
+ const downsample = list => {
+ if (!list || list.length <= maxPoints) return list || []
+ const step = Math.ceil(list.length / maxPoints)
+ return list.filter((_, index) => index % step === 0)
+ }
+
+ /**
+ * 計算點集邊界
+ */
+ const getBounds = list => {
+ let minX = Infinity
+ let minY = Infinity
+ let maxX = -Infinity
+ let maxY = -Infinity
+
+ list.forEach(item => {
+ minX = Math.min(minX, item.x)
+ minY = Math.min(minY, item.y)
+ maxX = Math.max(maxX, item.x)
+ maxY = Math.max(maxY, item.y)
+ })
+
+ return { minX, minY, maxX, maxY }
+ }
+
+ /**
+ * 映射點集到畫布坐標
+ */
+ const mapPoints = (list, width, height) => {
+ if (!list.length || width === 0 || height === 0) {
+ return []
+ }
+
+ const padding = Math.max(20, Math.min(width, height) * 0.06)
+ const { minX, minY, maxX, maxY } = getBounds(list)
+ const rangeX = Math.max(1, maxX - minX)
+ const rangeY = Math.max(1, maxY - minY)
+ const fitScale = Math.min((width - padding * 2) / rangeX, (height - padding * 2) / rangeY)
+ const { mode, offsetXRatio, offsetYRatio, scale } = getPositionConfig()
+ const appliedScale = fitScale * scale
+ const { anchorX, anchorY } = getPositionAnchor(mode)
+ const availableX = Math.max(0, width - rangeX * appliedScale)
+ const availableY = Math.max(0, height - rangeY * appliedScale)
+ const offsetX = availableX * anchorX - minX * appliedScale + availableX * offsetXRatio
+ const offsetY = availableY * anchorY - minY * appliedScale + availableY * offsetYRatio
+
+ const mapped = list.map(point => ({
+ x: point.x * appliedScale + offsetX,
+ y: point.y * appliedScale + offsetY
+ }))
+
+ return mapped
+ }
+
+ /**
+ * 重置畫布尺寸並重新映射點集
+ */
+ const resizeCanvas = () => {
+ canvasWidth = header.clientWidth
+ canvasHeight = header.clientHeight
+ if (!canvasWidth || !canvasHeight) return
+
+ const dpr = window.devicePixelRatio || 1
+ canvas.width = Math.round(canvasWidth * dpr)
+ canvas.height = Math.round(canvasHeight * dpr)
+ canvas.style.width = `${canvasWidth}px`
+ canvas.style.height = `${canvasHeight}px`
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+
+ mappedPoints = mapPoints(points, canvasWidth, canvasHeight)
+ const modeValue = GLOBAL_CONFIG && GLOBAL_CONFIG.lineAnimation ? GLOBAL_CONFIG.lineAnimation.mode : 'sequence'
+ if (modeValue === 'random') {
+ drawOrder = buildRandomOrder(mappedPoints.length)
+ } else {
+ drawOrder = Array.from({ length: mappedPoints.length }, (_, index) => index)
+ }
+ startTime = 0
+ lastDrawCount = 0
+ lastProgress = 0
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+ if (mode === 'static' || completed) {
+ renderFull()
+ }
+ }
+
+ /**
+ * 繪製指定區間點集
+ */
+ /**
+ * 產生隨機順序索引
+ */
+ const buildRandomOrder = count => {
+ const order = Array.from({ length: count }, (_, index) => index)
+ for (let i = count - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ const temp = order[i]
+ order[i] = order[j]
+ order[j] = temp
+ }
+ return order
+ }
+
+ /**
+ * 繪製指定區間點集
+ */
+ const getPointByOrder = index => {
+ const orderIndex = drawOrder[index]
+ return mappedPoints[orderIndex]
+ }
+
+ const drawPointsRange = (fromIndex, toIndex) => {
+ if (fromIndex >= toIndex) return
+ if (pointSprite) {
+ for (let i = fromIndex; i < toIndex; i++) {
+ const point = getPointByOrder(i)
+ ctx.drawImage(pointSprite, point.x - spriteHalfSize, point.y - spriteHalfSize)
+ }
+ } else {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'
+ ctx.beginPath()
+ for (let i = fromIndex; i < toIndex; i++) {
+ const point = getPointByOrder(i)
+ ctx.moveTo(point.x + pointRadius, point.y)
+ ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2)
+ }
+ ctx.fill()
+ }
+ }
+
+ /**
+ * 靜態渲染全部點集
+ */
+ const renderFull = () => {
+ if (!mappedPoints.length) return
+ if (!drawOrder.length || drawOrder.length !== mappedPoints.length) {
+ drawOrder = Array.from({ length: mappedPoints.length }, (_, index) => index)
+ }
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+ drawPointsRange(0, mappedPoints.length)
+ lastDrawCount = mappedPoints.length
+ lastProgress = 1
+ }
+
+ /**
+ * 繪製當前動畫幀
+ */
+ const drawOnce = (time, onComplete) => {
+ if (destroyed) return
+ if (!mappedPoints.length) {
+ rafId = requestAnimationFrame(nextTime => drawOnce(nextTime, onComplete))
+ return
+ }
+
+ if (!startTime) startTime = time
+ const elapsed = time - startTime
+ const duration = getDuration()
+ const progress = Math.min(1, elapsed / duration)
+ const visibleCount = Math.max(1, Math.floor(progress * mappedPoints.length))
+ drawPointsRange(lastDrawCount, visibleCount)
+
+ lastDrawCount = visibleCount
+ lastProgress = progress
+ if (progress < 1) {
+ rafId = requestAnimationFrame(nextTime => drawOnce(nextTime, onComplete))
+ } else {
+ completed = true
+ if (typeof onComplete === 'function') onComplete()
+ }
+ }
+
+ /**
+ * 加載點集數據
+ */
+ const loadData = async () => {
+ if (window.__homeLineData) return window.__homeLineData
+ const response = await fetch(dataUrl)
+ const data = await response.json()
+ window.__homeLineData = data
+ return data
+ }
+
+ /**
+ * 初始化動畫(只播放一次)
+ */
+ const initOnce = async onComplete => {
+ try {
+ const data = await loadData()
+ points = downsample(data)
+ completed = false
+ mode = 'once'
+ resizeCanvas()
+ if (window.ResizeObserver) {
+ resizeObserver = new ResizeObserver(resizeCanvas)
+ resizeObserver.observe(header)
+ } else {
+ resizeHandler = () => resizeCanvas()
+ window.addEventListener('resize', resizeHandler)
+ }
+ rafId = requestAnimationFrame(nextTime => drawOnce(nextTime, onComplete))
+ } catch (error) {
+ destroy()
+ }
+ }
+
+ /**
+ * 靜態渲染
+ */
+ const renderStatic = async () => {
+ try {
+ const data = await loadData()
+ points = downsample(data)
+ completed = true
+ mode = 'static'
+ resizeCanvas()
+ if (window.ResizeObserver) {
+ resizeObserver = new ResizeObserver(resizeCanvas)
+ resizeObserver.observe(header)
+ } else {
+ resizeHandler = () => resizeCanvas()
+ window.addEventListener('resize', resizeHandler)
+ }
+ renderFull()
+ } catch (error) {
+ destroy()
+ }
+ }
+
+ /**
+ * 銷毀動畫並清理資源
+ */
+ const destroy = () => {
+ destroyed = true
+ if (rafId) cancelAnimationFrame(rafId)
+ if (resizeObserver) resizeObserver.disconnect()
+ if (resizeHandler) window.removeEventListener('resize', resizeHandler)
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+ }
+
+ return { initOnce, renderStatic, destroy }
+ }
+
/**
* 代碼
* 只適用於Hexo默認的代碼渲染
*/
@@ -806,6 +1216,7 @@ document.addEventListener('DOMContentLoaded', function () {

scrollFnToDo()
GLOBAL_CONFIG_SITE.isHome && scrollDownInIndex()
+ initHomeLineAnimation()
addHighlightTool()
GLOBAL_CONFIG.isPhotoFigcaption && addPhotoFigcaption()
scrollFn()