Github仓库:https://github.com/luoy-oss/p2line

p2line:https://p2line.drluo.top

p2line预设参数:

XDog参数:p2line-xdog-params.json

PS参数:p2line-ps-params.json


使用之前,请使用p2line导出你想要的line.json点集数据文件,将其放置在你的博客路径下的 :
/themes/butterfly/source文件夹中

例如:

你的hexo项目路径:
E:/luoy
你的butterfly主题路径:
E:/luoy/themes/butterfly
请在E:/luoy/themes/butterfly/source路径下放置line.json


添加首页画布容器

在首页 header的index,pug 内加入画布容器:

1
2
#home-line-animation
canvas#line-canvas

参考位置:/themes/butterfly/layout/includes/header/index.pug

index,pug

添加画布样式

让画布处于背景层并避免遮挡文字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#page-header
overflow: hidden

#home-line-animation
position: absolute
top: 0
right: 0
bottom: 0
left: 0
z-index: 1
pointer-events: none

canvas
width: 100%
height: 100%
display: block
opacity: .9
filter: drop-shadow(0 0 8px rgba(255, 255, 255, .35))

head.styl(0)

调整siteinfo和scroll-down的位置:

1
2
3
4
5
#site-info
z-index: 2

#scroll-down
z-index: 2

head.styl(1)

参考位置:/themes/butterfly/source/css/_layout/head.styl#L1-L86

添加点集数据

将点集文件放在主题静态目录:

1
themes/butterfly/source/line.json

注入配置到前端

在config.pug中加入动画配置:

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}
}

config.pug

参考位置:/themes/butterfly/layout/includes/head/config.pug#L87-L98

添加主逻辑(绘制与播放控制)

source/js/main.js 中加入动画逻辑,包含:

  • 点集加载与映射
  • 随机模式与顺序模式
  • 位置锚点、偏移占比与缩放
  • 首次加载与刷新播放,其余情况下静态显示

!!注意,请确保文件覆盖前你对main.js进行了备份

由于更改较多,你可以选择直接使用文件覆盖:

main.js

或者可以考虑参考以下git diff详情进行手动修改:

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()

参考位置:/themes/butterfly/source/js/main.js

在主题配置中启用与调参

_config.yml 中添加配置:

注意:
你修改的应该是主题下的config配置文件。
你的hexo项目路径:
E:/luoy
你的butterfly主题路径:
E:/luoy/themes/butterfly
请编辑E:/luoy/themes/butterfly/_config.yml,添加以下配置项

1
2
3
4
5
6
7
8
9
line_animation:
mode: random # sequence | random
duration: 2600 # ms
random_duration: 1600 # ms
scale: 1 # 0.1 ~ 2
position_mode: left_bottom # center | left_top | left_bottom | right_top | right_bottom | middle_left | middle_right
offset_ratio:
x: 0 # -1 ~ 1
y: 0 # -1 ~ 1

参考位置:/themes/butterfly/_config.yml

参数说明

  • 播放时机:首次加载与刷新播放,其余情况下静态显示
  • 随机模式mode: random 将随机选点填充,mode: sequence 将按生成的点集顺序填充
  • 时长duration 控制动画播放总时长,random_duration 随机模式下每个点的播放时长,你可以只配置duration 来设置总时长,random_duration 默认为 duration,不配置/配置错误的情况下默认为3000ms
  • 位置与偏移position_mode 控制基准位置,offset_ratio 控制相对可移动空间的占比偏移
  • 缩放scale 控制点集整体缩放