koishi-plugin-monetary-bourse 2.0.3-Alpha.12 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,469 +1,479 @@
1
- <html>
2
- <head>
3
- <style>
4
- :root {
5
- --main-color: {{MAIN_COLOR}};
6
- --glow-color: {{GLOW_COLOR}};
7
- --icon-gradient-start: {{ICON_GRADIENT_START}};
8
- --icon-gradient-end: {{ICON_GRADIENT_END}};
9
- --icon-shadow: {{ICON_SHADOW}};
10
- --change-badge-bg: {{CHANGE_BADGE_BG}};
11
-
12
- /* 优化后的配色板 */
13
- --bg-color: #0c0f15;
14
- --card-bg: #151921;
15
- --text-primary: #e1e3e6;
16
- --text-secondary: #8b919e;
17
- --border-color: #232730;
18
- --grid-line-color: #232730;
19
- }
20
-
21
- * { margin: 0; padding: 0; box-sizing: border-box; }
22
-
23
- body {
24
- padding: 32px;
25
- /* 字体栈优化:优先使用现代系统字体 */
26
- font-family: 'Roboto Mono', 'Trebuchet MS', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
27
- background-color: var(--bg-color);
28
-
29
- /* 强制 4:3 比例 (1024x768) */
30
- width: 1024px;
31
- height: 768px;
32
-
33
- color: var(--text-primary);
34
- display: flex;
35
- justify-content: center;
36
- align-items: center;
37
- overflow: hidden; /* 防止溢出 */
38
- }
39
-
40
- .card {
41
- background-color: var(--card-bg);
42
- width: 100%;
43
- height: 100%;
44
- padding: 36px 48px;
45
- border-radius: 24px;
46
- box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.05);
47
- border: 1px solid var(--border-color);
48
- position: relative; /* ensure absolute children like .update-time are positioned inside the card */
49
- display: flex;
50
- flex-direction: column;
51
- }
52
-
53
- /* 头部区域优化 */
54
- .header {
55
- display: flex;
56
- justify-content: space-between;
57
- align-items: flex-start;
58
- margin-bottom: 24px;
59
- flex-shrink: 0; /* 防止头部被压缩 */
60
- }
61
-
62
- .title-group {
63
- display: flex;
64
- flex-direction: column;
65
- gap: 8px;
66
- }
67
-
68
- .stock-name {
69
- font-size: 42px;
70
- font-weight: 700;
71
- color: #ffffff;
72
- letter-spacing: -0.5px;
73
- display: flex;
74
- align-items: center;
75
- gap: 16px;
76
- }
77
-
78
- .stock-icon {
79
- width: 44px;
80
- height: 44px;
81
- border-radius: 12px;
82
- background: linear-gradient(135deg, var(--icon-gradient-start), var(--icon-gradient-end));
83
- display: flex;
84
- align-items: center;
85
- justify-content: center;
86
- font-size: 24px;
87
- box-shadow: 0 8px 20px var(--icon-shadow);
88
- }
89
-
90
- .meta-info {
91
- display: flex;
92
- align-items: center;
93
- gap: 16px;
94
- font-size: 15px;
95
- color: var(--text-secondary);
96
- font-weight: 500;
97
- margin-left: 60px; /* 对齐文字 */
98
- }
99
-
100
- .meta-item { display: flex; align-items: center; gap: 6px; }
101
- .live-dot { width: 6px; height: 6px; background-color: #22c55e; border-radius: 50%; box-shadow: 0 0 8px #22c55e; }
102
-
103
- .price-group {
104
- text-align: right;
105
- }
106
-
107
- .current-price {
108
- font-family: 'Roboto Mono', 'Trebuchet MS', monospace;
109
- font-size: 64px;
110
- font-weight: normal;
111
- color: var(--main-color);
112
- line-height: 1;
113
- letter-spacing: -2px;
114
- }
115
-
116
- .price-change {
117
- margin-top: 8px;
118
- display: flex;
119
- align-items: center;
120
- justify-content: flex-end;
121
- gap: 12px;
122
- }
123
-
124
- .change-badge {
125
- background-color: var(--change-badge-bg);
126
- color: var(--main-color);
127
- padding: 4px 10px;
128
- border-radius: 6px;
129
- font-size: 16px;
130
- font-weight: 600;
131
- display: flex;
132
- align-items: center;
133
- gap: 4px;
134
- }
135
-
136
- /* 图表区域优化:自适应高度 */
137
- .chart-wrapper {
138
- position: relative;
139
- flex: 1; /* 占据剩余所有空间 */
140
- width: 100%;
141
- min-height: 0; /* Flexbox 溢出修复 */
142
- margin: 16px 0;
143
- border-radius: 12px;
144
- overflow: hidden;
145
- }
146
-
147
- canvas {
148
- display: block;
149
- width: 100%;
150
- height: 100%; /* 填满 wrapper */
151
- }
152
-
153
- /* 底部数据栏优化 */
154
- .footer {
155
- display: grid;
156
- grid-template-columns: repeat(4, 1fr);
157
- background: var(--card-bg); /* 改为背景色,通过 border 分割 */
158
- border-top: 1px solid var(--border-color);
159
- margin-top: auto; /* 推到底部 */
160
- padding-top: 24px;
161
- flex-shrink: 0;
162
- }
163
-
164
- .stat-box {
165
- padding: 0 24px;
166
- display: flex;
167
- flex-direction: column;
168
- border-right: 1px solid var(--border-color);
169
- }
170
- .stat-box:last-child { border-right: none; }
171
-
172
- .stat-label {
173
- font-size: 13px;
174
- color: var(--text-secondary);
175
- text-transform: uppercase;
176
- margin-bottom: 6px;
177
- letter-spacing: 0.5px;
178
- font-weight: 600;
179
- }
180
-
181
- .stat-value {
182
- font-size: 20px;
183
- font-weight: 600;
184
- color: var(--text-primary);
185
- font-family: 'Roboto Mono', monospace;
186
- letter-spacing: -0.5px;
187
- }
188
-
189
- .update-time {
190
- position: absolute;
191
- bottom: 8px;
192
- right: 8px;
193
- font-size: 12px;
194
- color: var(--text-secondary);
195
- opacity: 0.9;
196
- z-index: 2; /* make sure it's above footer */
197
- }
198
- </style>
199
- </head>
200
- <body>
201
- <div class="card">
202
- <div class="header">
203
- <div class="title-group">
204
- <div class="stock-name">
205
- <div class="stock-icon">⚡</div>
206
- {{STOCK_NAME}}
207
- </div>
208
- <div class="meta-info">
209
- <div class="meta-item"><div class="live-dot"></div> {{VIEW_LABEL}}</div>
210
- <div class="meta-item" style="opacity: 0.5">|</div>
211
- <div class="meta-item">{{CURRENT_TIME}}</div>
212
- </div>
213
- </div>
214
- <div class="price-group">
215
- <div class="current-price">{{CURRENT_PRICE}}</div>
216
- <div class="price-change">
217
- <span style="font-size: 18px; font-weight: 600; color: var(--main-color); opacity: 0.9;">{{CHANGE_VALUE}}</span>
218
- <span class="change-badge">{{CHANGE_ICON}} {{CHANGE_PERCENT}}%</span>
219
- </div>
220
- </div>
221
- </div>
222
-
223
- <div class="chart-wrapper">
224
- <!-- 增加 Canvas 分辨率以保证清晰度,CSS 会将其缩小显示 -->
225
- <canvas id="chart" width="1856" height="900"></canvas>
226
- </div>
227
-
228
- <div class="footer">
229
- <div class="stat-box">
230
- <div class="stat-label">最高 High</div>
231
- <div class="stat-value">{{HIGH_PRICE}}</div>
232
- </div>
233
- <div class="stat-box">
234
- <div class="stat-label">最低 Low</div>
235
- <div class="stat-value">{{LOW_PRICE}}</div>
236
- </div>
237
- <div class="stat-box">
238
- <div class="stat-label">开盘 Open</div>
239
- <div class="stat-value">{{START_PRICE}}</div>
240
- </div>
241
- <div class="stat-box">
242
- <div class="stat-label">振幅 Amp</div>
243
- <div class="stat-value">{{AMPLITUDE}}%</div>
244
- </div>
245
- </div>
246
- <div class="update-time">数据更新于 {{UPDATE_TIME}}</div>
247
- </div>
248
-
249
- <script>
250
- const canvas = document.getElementById('chart');
251
- const ctx = canvas.getContext('2d');
252
-
253
- // 数据注入
254
- const prices = JSON.parse('{{PRICES}}');
255
- const times = JSON.parse('{{TIMES}}');
256
- const timestamps = JSON.parse('{{TIMESTAMPS}}');
257
-
258
- // 布局参数 - 适配新的 Canvas 尺寸
259
- const W = canvas.width;
260
- const H = canvas.height;
261
- // 调整内边距,给字体更多呼吸空间
262
- const padding = { top: 60, bottom: 80, left: 20, right: 160 };
263
-
264
- // 数据计算
265
- const maxPrice = Math.max(...prices);
266
- const minPrice = Math.min(...prices);
267
- const priceRange = maxPrice - minPrice || 1;
268
- // 增加上下留白 (20%) 让曲线更平缓美观
269
- const yMin = minPrice - priceRange * 0.2;
270
- const yMax = maxPrice + priceRange * 0.2;
271
- const yRange = yMax - yMin;
272
-
273
- const tStart = timestamps[0];
274
- const tEnd = timestamps[timestamps.length - 1];
275
- const tRange = tEnd - tStart || 1;
276
-
277
- // 坐标映射函数
278
- const getX = t => padding.left + ((t - tStart) / tRange) * (W - padding.left - padding.right);
279
- const getY = p => H - padding.bottom - ((p - yMin) / yRange) * (H - padding.top - padding.bottom);
280
-
281
- // 1. 绘制网格线 (Grid) - 样式优化:更细、更淡
282
- ctx.strokeStyle = 'rgba(42, 46, 57, 0.6)';
283
- ctx.lineWidth = 2;
284
- ctx.setLineDash([8, 8]); // 更稀疏的虚线
285
-
286
- const gridCount = 4; // 减少网格线数量,更简洁
287
- for (let i = 0; i <= gridCount; i++) {
288
- const y = H - padding.bottom - (i / gridCount) * (H - padding.top - padding.bottom);
289
-
290
- // 只画横线
291
- ctx.beginPath();
292
- ctx.moveTo(padding.left, y);
293
- ctx.lineTo(W - padding.right, y);
294
- ctx.stroke();
295
-
296
- // Y轴标签 - 字体优化:变小、颜色变淡、更现代
297
- const val = yMin + (i / gridCount) * yRange;
298
- ctx.fillStyle = '#64748b'; // Slate-500
299
- // 修改字体:使用 Trebuchet MS,尺寸调整
300
- ctx.font = '500 28px "Trebuchet MS", monospace';
301
- ctx.textAlign = 'left';
302
- ctx.textBaseline = 'middle';
303
- // 增加一点左边距
304
- ctx.fillText(val.toFixed(2), W - padding.right + 24, y);
305
- }
306
- ctx.setLineDash([]);
307
-
308
- // 2. 准备路径点
309
- const points = prices.map((p, i) => ({
310
- x: getX(timestamps[i]),
311
- y: getY(p)
312
- }));
313
-
314
- // 3. 绘制渐变填充 (Area)
315
- const gradient = ctx.createLinearGradient(0, padding.top, 0, H - padding.bottom);
316
- // 使用 hex 转 rgba 模拟透明度变化 (这里假设 main color 逻辑在后端处理,此处使用 css 变量占位符不太好操作 canvas 渐变,
317
- // 但保留原逻辑即可,因为 placeholder 会被替换)
318
- gradient.addColorStop(0, '{{GRADIENT_START}}');
319
- gradient.addColorStop(1, 'rgba(0,0,0,0)'); // 透明结尾
320
-
321
- ctx.beginPath();
322
- ctx.moveTo(points[0].x, H - padding.bottom);
323
-
324
- if (points.length > 1) {
325
- ctx.lineTo(points[0].x, points[0].y);
326
- for (let i = 0; i < points.length - 1; i++) {
327
- const p0 = points[i];
328
- const p1 = points[i + 1];
329
- const midX = (p0.x + p1.x) / 2;
330
- const midY = (p0.y + p1.y) / 2;
331
- ctx.quadraticCurveTo(p0.x, p0.y, midX, midY);
332
- }
333
- const last = points[points.length - 1];
334
- ctx.lineTo(last.x, last.y);
335
- }
336
-
337
- ctx.lineTo(points[points.length - 1].x, H - padding.bottom);
338
- ctx.closePath();
339
- ctx.fillStyle = gradient;
340
- ctx.fill();
341
-
342
- // 4. 绘制主线 (Line)
343
- ctx.beginPath();
344
- ctx.lineWidth = 6; // 线条加粗一点点
345
- ctx.strokeStyle = '{{MAIN_COLOR}}';
346
- ctx.lineCap = 'round';
347
- ctx.lineJoin = 'round';
348
-
349
- if (points.length > 1) {
350
- ctx.moveTo(points[0].x, points[0].y);
351
- for (let i = 0; i < points.length - 1; i++) {
352
- const p0 = points[i];
353
- const p1 = points[i + 1];
354
- const midX = (p0.x + p1.x) / 2;
355
- const midY = (p0.y + p1.y) / 2;
356
- ctx.quadraticCurveTo(p0.x, p0.y, midX, midY);
357
- }
358
- ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
359
- }
360
- ctx.stroke();
361
-
362
- // 5. 绘制当前价格指示线 (Crosshair Line)
363
- const lastPoint = points[points.length - 1];
364
- ctx.beginPath();
365
- ctx.setLineDash([4, 6]); // 更紧密的点状线
366
- ctx.strokeStyle = '{{MAIN_COLOR}}';
367
- ctx.lineWidth = 2;
368
- ctx.moveTo(padding.left, lastPoint.y);
369
- ctx.lineTo(W - padding.right, lastPoint.y);
370
- ctx.stroke();
371
- ctx.setLineDash([]);
372
-
373
- // 6. 绘制当前价格标签 (Price Label)
374
- const currentPriceStr = prices[prices.length - 1].toFixed(2);
375
- // 标签字体优化
376
- ctx.font = 'bold 32px "Trebuchet MS", monospace';
377
- const textMetrics = ctx.measureText(currentPriceStr);
378
- const labelPadX = 24;
379
- const labelPadY = 16;
380
- const labelWidth = textMetrics.width + (labelPadX * 2);
381
- const labelHeight = 52;
382
- const labelX = W - padding.right;
383
- const labelY = lastPoint.y - labelHeight / 2;
384
-
385
- // 标签背景
386
- ctx.fillStyle = '{{MAIN_COLOR}}';
387
- ctx.beginPath();
388
- ctx.roundRect(labelX, labelY, labelWidth, labelHeight, 8); // 圆角更大
389
- ctx.fill();
390
-
391
- // 标签文字
392
- ctx.fillStyle = '#ffffff';
393
- ctx.textAlign = 'left';
394
- ctx.textBaseline = 'middle';
395
- ctx.fillText(currentPriceStr, labelX + labelPadX, lastPoint.y + 2);
396
-
397
- // 7. 绘制呼吸灯圆点
398
- const glow = ctx.createRadialGradient(lastPoint.x, lastPoint.y, 0, lastPoint.x, lastPoint.y, 40);
399
- glow.addColorStop(0, '{{GLOW_COLOR}}');
400
- glow.addColorStop(1, 'rgba(0,0,0,0)');
401
- ctx.fillStyle = glow;
402
- ctx.beginPath();
403
- ctx.arc(lastPoint.x, lastPoint.y, 40, 0, Math.PI * 2);
404
- ctx.fill();
405
-
406
- ctx.fillStyle = '#ffffff';
407
- ctx.beginPath();
408
- ctx.arc(lastPoint.x, lastPoint.y, 8, 0, Math.PI * 2);
409
- ctx.fill();
410
-
411
- // 8. X轴时间标签 (Time Labels) - 字体与布局优化
412
- ctx.textAlign = 'center';
413
- ctx.textBaseline = 'top';
414
- ctx.fillStyle = '#64748b'; // Slate-500
415
- // 修改字体:System UI font, lighter weight
416
- ctx.font = '500 24px -apple-system, BlinkMacSystemFont, "Inter", sans-serif';
417
-
418
- const occupied = [];
419
- const addLabel = (text, x) => {
420
- const w = ctx.measureText(text).width;
421
- const left = x - w / 2;
422
- const right = x + w / 2;
423
- for (const r of occupied) {
424
- if (left < r.right + 40 && right > r.left - 40) return false; // 增加间距判断
425
- }
426
- if (left < 0 || right > W) return false;
427
-
428
- occupied.push({ left, right });
429
- ctx.fillText(text, x, H - padding.bottom + 24); // 下移一点
430
- return true;
431
- };
432
-
433
- // 逻辑保持不变,但参数微调
434
- addLabel(times[0], getX(timestamps[0]));
435
-
436
- const lastIdx = times.length - 1;
437
- if (lastIdx > 0) {
438
- const lastX = getX(timestamps[lastIdx]);
439
- const lastText = times[lastIdx];
440
- const lastW = ctx.measureText(lastText).width;
441
- const lastRect = { left: lastX - lastW/2, right: lastX + lastW/2 };
442
-
443
- const totalW = W - padding.left - padding.right;
444
- const singleW = ctx.measureText("00:00").width + 80;
445
- const maxCount = Math.floor(totalW / singleW);
446
- const step = Math.max(1, Math.ceil((times.length - 2) / (maxCount - 2)));
447
-
448
- for (let i = step; i < lastIdx; i += step) {
449
- if (i >= lastIdx) break;
450
- const x = getX(timestamps[i]);
451
- const text = times[i];
452
- const w = ctx.measureText(text).width;
453
- const left = x - w/2;
454
- const right = x + w/2;
455
- let hit = false;
456
- for (const r of occupied) {
457
- if (left < r.right + 40 && right > r.left - 40) hit = true;
458
- }
459
- if (left < lastRect.right + 40 && right > lastRect.left - 40) hit = true;
460
-
461
- if (!hit) {
462
- addLabel(text, x);
463
- }
464
- }
465
- ctx.fillText(lastText, lastX, H - padding.bottom + 24);
466
- }
467
- </script>
468
- </body>
1
+ <html>
2
+ <head>
3
+ <style>
4
+ :root {
5
+ --main-color: {{MAIN_COLOR}};
6
+ --glow-color: {{GLOW_COLOR}};
7
+ --icon-gradient-start: {{ICON_GRADIENT_START}};
8
+ --icon-gradient-end: {{ICON_GRADIENT_END}};
9
+ --icon-shadow: {{ICON_SHADOW}};
10
+ --change-badge-bg: {{CHANGE_BADGE_BG}};
11
+
12
+ /* 统一配色板 */
13
+ --bg-color: #0c0f15;
14
+ --card-bg: #161b22;
15
+ --item-bg: #1f242e;
16
+ --text-primary: #f0f3f5;
17
+ --text-secondary: #8b949e;
18
+ --border-color: #30363d;
19
+
20
+ --grid-line-color: #30363d;
21
+
22
+ /* 语义色 */
23
+ --buy-color: #f87171;
24
+ --sell-color: #4ade80;
25
+ --buy-bg: rgba(248, 113, 113, 0.15);
26
+ --sell-bg: rgba(74, 222, 128, 0.15);
27
+ --pending-color: #eab308;
28
+ --pending-bg: rgba(234, 179, 8, 0.15);
29
+ --accent-color: #58a6ff;
30
+ }
31
+
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+
34
+ body {
35
+ padding: 64px;
36
+ /* 字体栈优化:优先使用现代系统字体 */
37
+ font-family: 'Roboto Mono', 'Trebuchet MS', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
38
+ background-color: var(--bg-color);
39
+
40
+ /* 强制 4:3 比例 (1024x768) */
41
+ width: 1024px;
42
+ height: 768px;
43
+
44
+ color: var(--text-primary);
45
+ display: flex;
46
+ justify-content: center;
47
+ align-items: center;
48
+ overflow: hidden; /* 防止溢出 */
49
+ }
50
+
51
+ .card {
52
+ background-color: var(--card-bg);
53
+ width: 100%;
54
+ height: 100%;
55
+ padding: 32px 40px;
56
+ border-radius: 16px;
57
+ box-shadow: 0 32px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--border-color);
58
+ position: relative; /* ensure absolute children like .update-time are positioned inside the card */
59
+ display: flex;
60
+ flex-direction: column;
61
+ }
62
+
63
+ /* 头部区域优化 */
64
+ .header {
65
+ display: flex;
66
+ justify-content: space-between;
67
+ align-items: flex-start;
68
+ margin-bottom: 24px;
69
+ flex-shrink: 0; /* 防止头部被压缩 */
70
+ }
71
+
72
+ .title-group {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 8px;
76
+ }
77
+
78
+ .stock-name {
79
+ font-size: 42px;
80
+ font-weight: 700;
81
+ color: #ffffff;
82
+ letter-spacing: -0.5px;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 16px;
86
+ }
87
+
88
+ .stock-icon {
89
+ width: 44px;
90
+ height: 44px;
91
+ border-radius: 12px;
92
+ background: linear-gradient(135deg, var(--icon-gradient-start), var(--icon-gradient-end));
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ font-size: 24px;
97
+ box-shadow: 0 8px 20px var(--icon-shadow);
98
+ }
99
+
100
+ .meta-info {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 16px;
104
+ font-size: 15px;
105
+ color: var(--text-secondary);
106
+ font-weight: 500;
107
+ margin-left: 60px; /* 对齐文字 */
108
+ }
109
+
110
+ .meta-item { display: flex; align-items: center; gap: 6px; }
111
+ .live-dot { width: 6px; height: 6px; background-color: #22c55e; border-radius: 50%; box-shadow: 0 0 8px #22c55e; }
112
+
113
+ .price-group {
114
+ text-align: right;
115
+ }
116
+
117
+ .current-price {
118
+ font-family: 'Roboto Mono', 'Trebuchet MS', monospace;
119
+ font-size: 64px;
120
+ font-weight: normal;
121
+ color: var(--main-color);
122
+ line-height: 1;
123
+ letter-spacing: -2px;
124
+ }
125
+
126
+ .price-change {
127
+ margin-top: 8px;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: flex-end;
131
+ gap: 12px;
132
+ }
133
+
134
+ .change-badge {
135
+ background-color: var(--change-badge-bg);
136
+ color: var(--main-color);
137
+ padding: 4px 10px;
138
+ border-radius: 6px;
139
+ font-size: 16px;
140
+ font-weight: 600;
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 4px;
144
+ }
145
+
146
+ /* 图表区域优化:自适应高度 */
147
+ .chart-wrapper {
148
+ position: relative;
149
+ flex: 1; /* 占据剩余所有空间 */
150
+ width: 100%;
151
+ min-height: 0; /* Flexbox 溢出修复 */
152
+ margin: 16px 0;
153
+ border-radius: 12px;
154
+ overflow: hidden;
155
+ }
156
+
157
+ canvas {
158
+ display: block;
159
+ width: 100%;
160
+ height: 100%; /* 填满 wrapper */
161
+ }
162
+
163
+ /* 底部数据栏优化 */
164
+ .footer {
165
+ display: grid;
166
+ grid-template-columns: repeat(4, 1fr);
167
+ background: var(--card-bg); /* 改为背景色,通过 border 分割 */
168
+ border-top: 1px solid var(--border-color);
169
+ margin-top: auto; /* 推到底部 */
170
+ padding-top: 24px;
171
+ flex-shrink: 0;
172
+ }
173
+
174
+ .stat-box {
175
+ padding: 0 24px;
176
+ display: flex;
177
+ flex-direction: column;
178
+ border-right: 1px solid var(--border-color);
179
+ }
180
+ .stat-box:last-child { border-right: none; }
181
+
182
+ .stat-label {
183
+ font-size: 13px;
184
+ color: var(--text-secondary);
185
+ text-transform: uppercase;
186
+ margin-bottom: 6px;
187
+ letter-spacing: 0.5px;
188
+ font-weight: 600;
189
+ }
190
+
191
+ .stat-value {
192
+ font-size: 20px;
193
+ font-weight: 600;
194
+ color: var(--text-primary);
195
+ font-family: 'Roboto Mono', monospace;
196
+ letter-spacing: -0.5px;
197
+ }
198
+
199
+ .update-time {
200
+ position: absolute;
201
+ bottom: 8px;
202
+ right: 8px;
203
+ font-size: 12px;
204
+ color: var(--text-secondary);
205
+ opacity: 0.9;
206
+ z-index: 2; /* make sure it's above footer */
207
+ }
208
+ </style>
209
+ </head>
210
+ <body>
211
+ <div class="card">
212
+ <div class="header">
213
+ <div class="title-group">
214
+ <div class="stock-name">
215
+ <div class="stock-icon">⚡</div>
216
+ {{STOCK_NAME}}
217
+ </div>
218
+ <div class="meta-info">
219
+ <div class="meta-item"><div class="live-dot"></div> {{VIEW_LABEL}}</div>
220
+ <div class="meta-item" style="opacity: 0.5">|</div>
221
+ <div class="meta-item">{{CURRENT_TIME}}</div>
222
+ </div>
223
+ </div>
224
+ <div class="price-group">
225
+ <div class="current-price">{{CURRENT_PRICE}}</div>
226
+ <div class="price-change">
227
+ <span style="font-size: 18px; font-weight: 600; color: var(--main-color); opacity: 0.9;">{{CHANGE_VALUE}}</span>
228
+ <span class="change-badge">{{CHANGE_ICON}} {{CHANGE_PERCENT}}%</span>
229
+ </div>
230
+ </div>
231
+ </div>
232
+
233
+ <div class="chart-wrapper">
234
+ <!-- 增加 Canvas 分辨率以保证清晰度,CSS 会将其缩小显示 -->
235
+ <canvas id="chart" width="1856" height="900"></canvas>
236
+ </div>
237
+
238
+ <div class="footer">
239
+ <div class="stat-box">
240
+ <div class="stat-label">最高 High</div>
241
+ <div class="stat-value">{{HIGH_PRICE}}</div>
242
+ </div>
243
+ <div class="stat-box">
244
+ <div class="stat-label">最低 Low</div>
245
+ <div class="stat-value">{{LOW_PRICE}}</div>
246
+ </div>
247
+ <div class="stat-box">
248
+ <div class="stat-label">开盘 Open</div>
249
+ <div class="stat-value">{{START_PRICE}}</div>
250
+ </div>
251
+ <div class="stat-box">
252
+ <div class="stat-label">振幅 Amp</div>
253
+ <div class="stat-value">{{AMPLITUDE}}%</div>
254
+ </div>
255
+ </div>
256
+ <div class="update-time">数据更新于 {{UPDATE_TIME}}</div>
257
+ </div>
258
+
259
+ <script>
260
+ const canvas = document.getElementById('chart');
261
+ const ctx = canvas.getContext('2d');
262
+
263
+ // 数据注入
264
+ const prices = JSON.parse('{{PRICES}}');
265
+ const times = JSON.parse('{{TIMES}}');
266
+ const timestamps = JSON.parse('{{TIMESTAMPS}}');
267
+
268
+ // 布局参数 - 适配新的 Canvas 尺寸
269
+ const W = canvas.width;
270
+ const H = canvas.height;
271
+ // 调整内边距,给字体更多呼吸空间
272
+ const padding = { top: 60, bottom: 80, left: 20, right: 160 };
273
+
274
+ // 数据计算
275
+ const maxPrice = Math.max(...prices);
276
+ const minPrice = Math.min(...prices);
277
+ const priceRange = maxPrice - minPrice || 1;
278
+ // 增加上下留白 (20%) 让曲线更平缓美观
279
+ const yMin = minPrice - priceRange * 0.2;
280
+ const yMax = maxPrice + priceRange * 0.2;
281
+ const yRange = yMax - yMin;
282
+
283
+ const tStart = timestamps[0];
284
+ const tEnd = timestamps[timestamps.length - 1];
285
+ const tRange = tEnd - tStart || 1;
286
+
287
+ // 坐标映射函数
288
+ const getX = t => padding.left + ((t - tStart) / tRange) * (W - padding.left - padding.right);
289
+ const getY = p => H - padding.bottom - ((p - yMin) / yRange) * (H - padding.top - padding.bottom);
290
+
291
+ // 1. 绘制网格线 (Grid) - 样式优化:更细、更淡
292
+ ctx.strokeStyle = 'rgba(42, 46, 57, 0.6)';
293
+ ctx.lineWidth = 2;
294
+ ctx.setLineDash([8, 8]); // 更稀疏的虚线
295
+
296
+ const gridCount = 4; // 减少网格线数量,更简洁
297
+ for (let i = 0; i <= gridCount; i++) {
298
+ const y = H - padding.bottom - (i / gridCount) * (H - padding.top - padding.bottom);
299
+
300
+ // 只画横线
301
+ ctx.beginPath();
302
+ ctx.moveTo(padding.left, y);
303
+ ctx.lineTo(W - padding.right, y);
304
+ ctx.stroke();
305
+
306
+ // Y轴标签 - 字体优化:变小、颜色变淡、更现代
307
+ const val = yMin + (i / gridCount) * yRange;
308
+ ctx.fillStyle = '#64748b'; // Slate-500
309
+ // 修改字体:使用 Trebuchet MS,尺寸调整
310
+ ctx.font = '500 28px "Trebuchet MS", monospace';
311
+ ctx.textAlign = 'left';
312
+ ctx.textBaseline = 'middle';
313
+ // 增加一点左边距
314
+ ctx.fillText(val.toFixed(2), W - padding.right + 24, y);
315
+ }
316
+ ctx.setLineDash([]);
317
+
318
+ // 2. 准备路径点
319
+ const points = prices.map((p, i) => ({
320
+ x: getX(timestamps[i]),
321
+ y: getY(p)
322
+ }));
323
+
324
+ // 3. 绘制渐变填充 (Area)
325
+ const gradient = ctx.createLinearGradient(0, padding.top, 0, H - padding.bottom);
326
+ // 使用 hex rgba 模拟透明度变化 (这里假设 main color 逻辑在后端处理,此处使用 css 变量占位符不太好操作 canvas 渐变,
327
+ // 但保留原逻辑即可,因为 placeholder 会被替换)
328
+ gradient.addColorStop(0, '{{GRADIENT_START}}');
329
+ gradient.addColorStop(1, 'rgba(0,0,0,0)'); // 透明结尾
330
+
331
+ ctx.beginPath();
332
+ ctx.moveTo(points[0].x, H - padding.bottom);
333
+
334
+ if (points.length > 1) {
335
+ ctx.lineTo(points[0].x, points[0].y);
336
+ for (let i = 0; i < points.length - 1; i++) {
337
+ const p0 = points[i];
338
+ const p1 = points[i + 1];
339
+ const midX = (p0.x + p1.x) / 2;
340
+ const midY = (p0.y + p1.y) / 2;
341
+ ctx.quadraticCurveTo(p0.x, p0.y, midX, midY);
342
+ }
343
+ const last = points[points.length - 1];
344
+ ctx.lineTo(last.x, last.y);
345
+ }
346
+
347
+ ctx.lineTo(points[points.length - 1].x, H - padding.bottom);
348
+ ctx.closePath();
349
+ ctx.fillStyle = gradient;
350
+ ctx.fill();
351
+
352
+ // 4. 绘制主线 (Line)
353
+ ctx.beginPath();
354
+ ctx.lineWidth = 6; // 线条加粗一点点
355
+ ctx.strokeStyle = '{{MAIN_COLOR}}';
356
+ ctx.lineCap = 'round';
357
+ ctx.lineJoin = 'round';
358
+
359
+ if (points.length > 1) {
360
+ ctx.moveTo(points[0].x, points[0].y);
361
+ for (let i = 0; i < points.length - 1; i++) {
362
+ const p0 = points[i];
363
+ const p1 = points[i + 1];
364
+ const midX = (p0.x + p1.x) / 2;
365
+ const midY = (p0.y + p1.y) / 2;
366
+ ctx.quadraticCurveTo(p0.x, p0.y, midX, midY);
367
+ }
368
+ ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
369
+ }
370
+ ctx.stroke();
371
+
372
+ // 5. 绘制当前价格指示线 (Crosshair Line)
373
+ const lastPoint = points[points.length - 1];
374
+ ctx.beginPath();
375
+ ctx.setLineDash([4, 6]); // 更紧密的点状线
376
+ ctx.strokeStyle = '{{MAIN_COLOR}}';
377
+ ctx.lineWidth = 2;
378
+ ctx.moveTo(padding.left, lastPoint.y);
379
+ ctx.lineTo(W - padding.right, lastPoint.y);
380
+ ctx.stroke();
381
+ ctx.setLineDash([]);
382
+
383
+ // 6. 绘制当前价格标签 (Price Label)
384
+ const currentPriceStr = prices[prices.length - 1].toFixed(2);
385
+ // 标签字体优化
386
+ ctx.font = 'bold 32px "Trebuchet MS", monospace';
387
+ const textMetrics = ctx.measureText(currentPriceStr);
388
+ const labelPadX = 24;
389
+ const labelPadY = 16;
390
+ const labelWidth = textMetrics.width + (labelPadX * 2);
391
+ const labelHeight = 52;
392
+ const labelX = W - padding.right;
393
+ const labelY = lastPoint.y - labelHeight / 2;
394
+
395
+ // 标签背景
396
+ ctx.fillStyle = '{{MAIN_COLOR}}';
397
+ ctx.beginPath();
398
+ ctx.roundRect(labelX, labelY, labelWidth, labelHeight, 8); // 圆角更大
399
+ ctx.fill();
400
+
401
+ // 标签文字
402
+ ctx.fillStyle = '#ffffff';
403
+ ctx.textAlign = 'left';
404
+ ctx.textBaseline = 'middle';
405
+ ctx.fillText(currentPriceStr, labelX + labelPadX, lastPoint.y + 2);
406
+
407
+ // 7. 绘制呼吸灯圆点
408
+ const glow = ctx.createRadialGradient(lastPoint.x, lastPoint.y, 0, lastPoint.x, lastPoint.y, 40);
409
+ glow.addColorStop(0, '{{GLOW_COLOR}}');
410
+ glow.addColorStop(1, 'rgba(0,0,0,0)');
411
+ ctx.fillStyle = glow;
412
+ ctx.beginPath();
413
+ ctx.arc(lastPoint.x, lastPoint.y, 40, 0, Math.PI * 2);
414
+ ctx.fill();
415
+
416
+ ctx.fillStyle = '#ffffff';
417
+ ctx.beginPath();
418
+ ctx.arc(lastPoint.x, lastPoint.y, 8, 0, Math.PI * 2);
419
+ ctx.fill();
420
+
421
+ // 8. X轴时间标签 (Time Labels) - 字体与布局优化
422
+ ctx.textAlign = 'center';
423
+ ctx.textBaseline = 'top';
424
+ ctx.fillStyle = '#64748b'; // Slate-500
425
+ // 修改字体:System UI font, lighter weight
426
+ ctx.font = '500 24px -apple-system, BlinkMacSystemFont, "Inter", sans-serif';
427
+
428
+ const occupied = [];
429
+ const addLabel = (text, x) => {
430
+ const w = ctx.measureText(text).width;
431
+ const left = x - w / 2;
432
+ const right = x + w / 2;
433
+ for (const r of occupied) {
434
+ if (left < r.right + 40 && right > r.left - 40) return false; // 增加间距判断
435
+ }
436
+ if (left < 0 || right > W) return false;
437
+
438
+ occupied.push({ left, right });
439
+ ctx.fillText(text, x, H - padding.bottom + 24); // 下移一点
440
+ return true;
441
+ };
442
+
443
+ // 逻辑保持不变,但参数微调
444
+ addLabel(times[0], getX(timestamps[0]));
445
+
446
+ const lastIdx = times.length - 1;
447
+ if (lastIdx > 0) {
448
+ const lastX = getX(timestamps[lastIdx]);
449
+ const lastText = times[lastIdx];
450
+ const lastW = ctx.measureText(lastText).width;
451
+ const lastRect = { left: lastX - lastW/2, right: lastX + lastW/2 };
452
+
453
+ const totalW = W - padding.left - padding.right;
454
+ const singleW = ctx.measureText("00:00").width + 80;
455
+ const maxCount = Math.floor(totalW / singleW);
456
+ const step = Math.max(1, Math.ceil((times.length - 2) / (maxCount - 2)));
457
+
458
+ for (let i = step; i < lastIdx; i += step) {
459
+ if (i >= lastIdx) break;
460
+ const x = getX(timestamps[i]);
461
+ const text = times[i];
462
+ const w = ctx.measureText(text).width;
463
+ const left = x - w/2;
464
+ const right = x + w/2;
465
+ let hit = false;
466
+ for (const r of occupied) {
467
+ if (left < r.right + 40 && right > r.left - 40) hit = true;
468
+ }
469
+ if (left < lastRect.right + 40 && right > lastRect.left - 40) hit = true;
470
+
471
+ if (!hit) {
472
+ addLabel(text, x);
473
+ }
474
+ }
475
+ ctx.fillText(lastText, lastX, H - padding.bottom + 24);
476
+ }
477
+ </script>
478
+ </body>
469
479
  </html>