sumulige-claude 1.4.1 → 1.5.0
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.
- package/.claude/MEMORY.md +26 -0
- package/.claude/hooks/hook-registry.json +15 -0
- package/.claude/hooks/live-quality.cjs +286 -0
- package/.claude/hooks/plan-gate.cjs +173 -0
- package/.claude/hooks/pre-commit.cjs +15 -4
- package/.claude/quality-gate.json +19 -4
- package/.claude/rules/linus-style.md +54 -0
- package/.claude/settings.json +19 -1
- package/.claude/settings.local.json +12 -3
- package/.claude/skills/react-best-practices/SKILL.md +125 -0
- package/.claude/skills/threejs-fundamentals/SKILL.md +488 -0
- package/.claude/skills/web-design-guidelines/SKILL.md +39 -0
- package/AGENTS.md +0 -54
- package/CHANGELOG.md +58 -0
- package/demos/power-3d-scatter.html +683 -0
- package/package.json +1 -1
- package/prompts/linus-architect.md +71 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ApexOS - 3D Power Analysis</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
|
16
|
+
background: #0a0a0a;
|
|
17
|
+
color: #fff;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#container {
|
|
22
|
+
width: 100vw;
|
|
23
|
+
height: 100vh;
|
|
24
|
+
position: relative;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#info {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: 20px;
|
|
30
|
+
left: 20px;
|
|
31
|
+
z-index: 100;
|
|
32
|
+
background: rgba(0, 0, 0, 0.8);
|
|
33
|
+
padding: 20px;
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
36
|
+
backdrop-filter: blur(10px);
|
|
37
|
+
max-width: 320px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#info h1 {
|
|
41
|
+
font-size: 18px;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
margin-bottom: 8px;
|
|
44
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
45
|
+
-webkit-background-clip: text;
|
|
46
|
+
-webkit-text-fill-color: transparent;
|
|
47
|
+
background-clip: text;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#info p {
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
color: #888;
|
|
53
|
+
line-height: 1.6;
|
|
54
|
+
margin-bottom: 16px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.legend {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 8px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.legend-item {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 10px;
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.legend-color {
|
|
71
|
+
width: 12px;
|
|
72
|
+
height: 12px;
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.legend-label {
|
|
77
|
+
color: #aaa;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.legend-value {
|
|
81
|
+
margin-left: auto;
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
color: #fff;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#stats {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 20px;
|
|
89
|
+
right: 20px;
|
|
90
|
+
z-index: 100;
|
|
91
|
+
background: rgba(0, 0, 0, 0.8);
|
|
92
|
+
padding: 16px 20px;
|
|
93
|
+
border-radius: 12px;
|
|
94
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
95
|
+
backdrop-filter: blur(10px);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.stat-row {
|
|
99
|
+
display: flex;
|
|
100
|
+
justify-content: space-between;
|
|
101
|
+
gap: 40px;
|
|
102
|
+
margin-bottom: 8px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.stat-row:last-child {
|
|
106
|
+
margin-bottom: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.stat-label {
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
color: #666;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
letter-spacing: 0.5px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.stat-value {
|
|
117
|
+
font-size: 14px;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
font-variant-numeric: tabular-nums;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.stat-value.power { color: #667eea; }
|
|
123
|
+
.stat-value.hr { color: #f56565; }
|
|
124
|
+
.stat-value.time { color: #48bb78; }
|
|
125
|
+
|
|
126
|
+
#tooltip {
|
|
127
|
+
position: absolute;
|
|
128
|
+
background: rgba(0, 0, 0, 0.9);
|
|
129
|
+
padding: 12px 16px;
|
|
130
|
+
border-radius: 8px;
|
|
131
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
pointer-events: none;
|
|
134
|
+
opacity: 0;
|
|
135
|
+
transition: opacity 0.2s;
|
|
136
|
+
z-index: 200;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#tooltip.visible {
|
|
140
|
+
opacity: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#controls {
|
|
144
|
+
position: absolute;
|
|
145
|
+
bottom: 20px;
|
|
146
|
+
left: 50%;
|
|
147
|
+
transform: translateX(-50%);
|
|
148
|
+
z-index: 100;
|
|
149
|
+
display: flex;
|
|
150
|
+
gap: 12px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.control-btn {
|
|
154
|
+
background: rgba(255, 255, 255, 0.1);
|
|
155
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
156
|
+
color: #fff;
|
|
157
|
+
padding: 10px 20px;
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
font-size: 12px;
|
|
161
|
+
font-family: inherit;
|
|
162
|
+
transition: all 0.2s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.control-btn:hover {
|
|
166
|
+
background: rgba(255, 255, 255, 0.2);
|
|
167
|
+
border-color: rgba(255, 255, 255, 0.3);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.control-btn.active {
|
|
171
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
172
|
+
border-color: transparent;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Axis labels */
|
|
176
|
+
.axis-label {
|
|
177
|
+
position: absolute;
|
|
178
|
+
font-size: 11px;
|
|
179
|
+
color: #666;
|
|
180
|
+
text-transform: uppercase;
|
|
181
|
+
letter-spacing: 1px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#x-label { bottom: 60px; left: 50%; transform: translateX(-50%); }
|
|
185
|
+
#y-label { left: 20px; top: 50%; transform: rotate(-90deg) translateX(-50%); transform-origin: left center; }
|
|
186
|
+
#z-label { right: 60px; bottom: 50%; }
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
<div id="container"></div>
|
|
191
|
+
|
|
192
|
+
<div id="info">
|
|
193
|
+
<h1>3D Power Analysis</h1>
|
|
194
|
+
<p>Visualizing the relationship between Power, Time, and Heart Rate during your ride.</p>
|
|
195
|
+
<div class="legend">
|
|
196
|
+
<div class="legend-item">
|
|
197
|
+
<div class="legend-color" style="background: #667eea;"></div>
|
|
198
|
+
<span class="legend-label">X-Axis: Time</span>
|
|
199
|
+
<span class="legend-value">0-60 min</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="legend-item">
|
|
202
|
+
<div class="legend-color" style="background: #48bb78;"></div>
|
|
203
|
+
<span class="legend-label">Y-Axis: Power</span>
|
|
204
|
+
<span class="legend-value">0-400 W</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="legend-item">
|
|
207
|
+
<div class="legend-color" style="background: #f56565;"></div>
|
|
208
|
+
<span class="legend-label">Z-Axis: Heart Rate</span>
|
|
209
|
+
<span class="legend-value">100-180 bpm</span>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div id="stats">
|
|
215
|
+
<div class="stat-row">
|
|
216
|
+
<span class="stat-label">Avg Power</span>
|
|
217
|
+
<span class="stat-value power" id="avg-power">--</span>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="stat-row">
|
|
220
|
+
<span class="stat-label">Avg HR</span>
|
|
221
|
+
<span class="stat-value hr" id="avg-hr">--</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="stat-row">
|
|
224
|
+
<span class="stat-label">Duration</span>
|
|
225
|
+
<span class="stat-value time" id="duration">--</span>
|
|
226
|
+
</div>
|
|
227
|
+
<div class="stat-row">
|
|
228
|
+
<span class="stat-label">Data Points</span>
|
|
229
|
+
<span class="stat-value" id="points">--</span>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div id="tooltip"></div>
|
|
234
|
+
|
|
235
|
+
<div id="controls">
|
|
236
|
+
<button class="control-btn active" data-view="perspective">Perspective</button>
|
|
237
|
+
<button class="control-btn" data-view="top">Top (Time-Power)</button>
|
|
238
|
+
<button class="control-btn" data-view="front">Front (Power-HR)</button>
|
|
239
|
+
<button class="control-btn" data-view="side">Side (Time-HR)</button>
|
|
240
|
+
<button class="control-btn" data-action="animate">Auto Rotate</button>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<script type="importmap">
|
|
244
|
+
{
|
|
245
|
+
"imports": {
|
|
246
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
247
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
</script>
|
|
251
|
+
|
|
252
|
+
<script type="module">
|
|
253
|
+
import * as THREE from 'three';
|
|
254
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
255
|
+
|
|
256
|
+
// ============ Configuration ============
|
|
257
|
+
const CONFIG = {
|
|
258
|
+
pointCount: 600, // 10 points per minute for 60 min
|
|
259
|
+
pointSize: 0.08,
|
|
260
|
+
axisSize: 8,
|
|
261
|
+
gridDivisions: 10,
|
|
262
|
+
colors: {
|
|
263
|
+
power: 0x667eea, // Purple-blue
|
|
264
|
+
hr: 0xf56565, // Red
|
|
265
|
+
time: 0x48bb78, // Green
|
|
266
|
+
grid: 0x333333,
|
|
267
|
+
background: 0x0a0a0a
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ============ Generate Cycling Data ============
|
|
272
|
+
function generateCyclingData(pointCount) {
|
|
273
|
+
const data = [];
|
|
274
|
+
const duration = 60; // 60 minutes
|
|
275
|
+
|
|
276
|
+
// Simulate a structured workout:
|
|
277
|
+
// 0-10 min: Warmup
|
|
278
|
+
// 10-20 min: Intervals (high power)
|
|
279
|
+
// 20-35 min: Steady state
|
|
280
|
+
// 35-50 min: More intervals
|
|
281
|
+
// 50-60 min: Cooldown
|
|
282
|
+
|
|
283
|
+
for (let i = 0; i < pointCount; i++) {
|
|
284
|
+
const t = (i / pointCount) * duration;
|
|
285
|
+
|
|
286
|
+
let basePower, baseHR;
|
|
287
|
+
|
|
288
|
+
if (t < 10) {
|
|
289
|
+
// Warmup: gradually increasing
|
|
290
|
+
basePower = 150 + (t / 10) * 50;
|
|
291
|
+
baseHR = 110 + (t / 10) * 20;
|
|
292
|
+
} else if (t < 20) {
|
|
293
|
+
// Intervals: high power spikes
|
|
294
|
+
const intervalPhase = ((t - 10) % 2) / 2;
|
|
295
|
+
basePower = intervalPhase < 0.5 ? 320 : 180;
|
|
296
|
+
baseHR = intervalPhase < 0.5 ? 165 : 140;
|
|
297
|
+
} else if (t < 35) {
|
|
298
|
+
// Steady state
|
|
299
|
+
basePower = 240;
|
|
300
|
+
baseHR = 150;
|
|
301
|
+
} else if (t < 50) {
|
|
302
|
+
// More intense intervals
|
|
303
|
+
const intervalPhase = ((t - 35) % 1.5) / 1.5;
|
|
304
|
+
basePower = intervalPhase < 0.6 ? 350 : 200;
|
|
305
|
+
baseHR = intervalPhase < 0.6 ? 175 : 145;
|
|
306
|
+
} else {
|
|
307
|
+
// Cooldown
|
|
308
|
+
const cooldownProgress = (t - 50) / 10;
|
|
309
|
+
basePower = 200 - cooldownProgress * 80;
|
|
310
|
+
baseHR = 145 - cooldownProgress * 30;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Add realistic noise
|
|
314
|
+
const noise = () => (Math.random() - 0.5) * 2;
|
|
315
|
+
const power = Math.max(50, basePower + noise() * 30);
|
|
316
|
+
const hr = Math.max(90, Math.min(190, baseHR + noise() * 8));
|
|
317
|
+
|
|
318
|
+
data.push({
|
|
319
|
+
time: t,
|
|
320
|
+
power: power,
|
|
321
|
+
hr: hr,
|
|
322
|
+
// Calculate intensity zone for coloring
|
|
323
|
+
intensity: power > 300 ? 'high' : power > 220 ? 'medium' : 'low'
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return data;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ============ Scene Setup ============
|
|
331
|
+
const container = document.getElementById('container');
|
|
332
|
+
const scene = new THREE.Scene();
|
|
333
|
+
scene.background = new THREE.Color(CONFIG.colors.background);
|
|
334
|
+
|
|
335
|
+
const camera = new THREE.PerspectiveCamera(
|
|
336
|
+
60,
|
|
337
|
+
window.innerWidth / window.innerHeight,
|
|
338
|
+
0.1,
|
|
339
|
+
1000
|
|
340
|
+
);
|
|
341
|
+
camera.position.set(12, 10, 12);
|
|
342
|
+
|
|
343
|
+
const renderer = new THREE.WebGLRenderer({
|
|
344
|
+
antialias: true,
|
|
345
|
+
alpha: true
|
|
346
|
+
});
|
|
347
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
348
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
349
|
+
container.appendChild(renderer.domElement);
|
|
350
|
+
|
|
351
|
+
// Controls
|
|
352
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
353
|
+
controls.enableDamping = true;
|
|
354
|
+
controls.dampingFactor = 0.05;
|
|
355
|
+
controls.minDistance = 5;
|
|
356
|
+
controls.maxDistance = 30;
|
|
357
|
+
|
|
358
|
+
// ============ Create Axes ============
|
|
359
|
+
function createAxes() {
|
|
360
|
+
const axisGroup = new THREE.Group();
|
|
361
|
+
const size = CONFIG.axisSize;
|
|
362
|
+
|
|
363
|
+
// Axis lines with glow effect
|
|
364
|
+
const axisMaterial = (color) => new THREE.LineBasicMaterial({
|
|
365
|
+
color,
|
|
366
|
+
linewidth: 2,
|
|
367
|
+
transparent: true,
|
|
368
|
+
opacity: 0.8
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// X-axis (Time) - Blue-purple
|
|
372
|
+
const xGeom = new THREE.BufferGeometry().setFromPoints([
|
|
373
|
+
new THREE.Vector3(0, 0, 0),
|
|
374
|
+
new THREE.Vector3(size, 0, 0)
|
|
375
|
+
]);
|
|
376
|
+
axisGroup.add(new THREE.Line(xGeom, axisMaterial(CONFIG.colors.power)));
|
|
377
|
+
|
|
378
|
+
// Y-axis (Power) - Green
|
|
379
|
+
const yGeom = new THREE.BufferGeometry().setFromPoints([
|
|
380
|
+
new THREE.Vector3(0, 0, 0),
|
|
381
|
+
new THREE.Vector3(0, size, 0)
|
|
382
|
+
]);
|
|
383
|
+
axisGroup.add(new THREE.Line(yGeom, axisMaterial(CONFIG.colors.time)));
|
|
384
|
+
|
|
385
|
+
// Z-axis (HR) - Red
|
|
386
|
+
const zGeom = new THREE.BufferGeometry().setFromPoints([
|
|
387
|
+
new THREE.Vector3(0, 0, 0),
|
|
388
|
+
new THREE.Vector3(0, 0, size)
|
|
389
|
+
]);
|
|
390
|
+
axisGroup.add(new THREE.Line(zGeom, axisMaterial(CONFIG.colors.hr)));
|
|
391
|
+
|
|
392
|
+
// Grid on XZ plane (bottom)
|
|
393
|
+
const gridXZ = new THREE.GridHelper(size, CONFIG.gridDivisions, CONFIG.colors.grid, CONFIG.colors.grid);
|
|
394
|
+
gridXZ.position.set(size/2, 0, size/2);
|
|
395
|
+
gridXZ.material.transparent = true;
|
|
396
|
+
gridXZ.material.opacity = 0.3;
|
|
397
|
+
axisGroup.add(gridXZ);
|
|
398
|
+
|
|
399
|
+
// Grid on XY plane (back)
|
|
400
|
+
const gridXY = new THREE.GridHelper(size, CONFIG.gridDivisions, CONFIG.colors.grid, CONFIG.colors.grid);
|
|
401
|
+
gridXY.rotation.x = Math.PI / 2;
|
|
402
|
+
gridXY.position.set(size/2, size/2, 0);
|
|
403
|
+
gridXY.material.transparent = true;
|
|
404
|
+
gridXY.material.opacity = 0.2;
|
|
405
|
+
axisGroup.add(gridXY);
|
|
406
|
+
|
|
407
|
+
// Grid on YZ plane (left)
|
|
408
|
+
const gridYZ = new THREE.GridHelper(size, CONFIG.gridDivisions, CONFIG.colors.grid, CONFIG.colors.grid);
|
|
409
|
+
gridYZ.rotation.z = Math.PI / 2;
|
|
410
|
+
gridYZ.position.set(0, size/2, size/2);
|
|
411
|
+
gridYZ.material.transparent = true;
|
|
412
|
+
gridYZ.material.opacity = 0.2;
|
|
413
|
+
axisGroup.add(gridYZ);
|
|
414
|
+
|
|
415
|
+
return axisGroup;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ============ Create Data Points ============
|
|
419
|
+
function createScatterPoints(data) {
|
|
420
|
+
const geometry = new THREE.BufferGeometry();
|
|
421
|
+
const positions = [];
|
|
422
|
+
const colors = [];
|
|
423
|
+
const sizes = [];
|
|
424
|
+
|
|
425
|
+
const size = CONFIG.axisSize;
|
|
426
|
+
const color = new THREE.Color();
|
|
427
|
+
|
|
428
|
+
// Normalize data ranges
|
|
429
|
+
const timeRange = { min: 0, max: 60 };
|
|
430
|
+
const powerRange = { min: 50, max: 400 };
|
|
431
|
+
const hrRange = { min: 100, max: 180 };
|
|
432
|
+
|
|
433
|
+
data.forEach((point, i) => {
|
|
434
|
+
// Map to 3D coordinates
|
|
435
|
+
const x = THREE.MathUtils.mapLinear(point.time, timeRange.min, timeRange.max, 0, size);
|
|
436
|
+
const y = THREE.MathUtils.mapLinear(point.power, powerRange.min, powerRange.max, 0, size);
|
|
437
|
+
const z = THREE.MathUtils.mapLinear(point.hr, hrRange.min, hrRange.max, 0, size);
|
|
438
|
+
|
|
439
|
+
positions.push(x, y, z);
|
|
440
|
+
|
|
441
|
+
// Color based on power intensity
|
|
442
|
+
const powerNorm = (point.power - powerRange.min) / (powerRange.max - powerRange.min);
|
|
443
|
+
color.setHSL(
|
|
444
|
+
0.7 - powerNorm * 0.5, // Hue: blue to red
|
|
445
|
+
0.8,
|
|
446
|
+
0.5 + powerNorm * 0.2
|
|
447
|
+
);
|
|
448
|
+
colors.push(color.r, color.g, color.b);
|
|
449
|
+
|
|
450
|
+
// Size based on HR (higher HR = larger point)
|
|
451
|
+
const hrNorm = (point.hr - hrRange.min) / (hrRange.max - hrRange.min);
|
|
452
|
+
sizes.push(CONFIG.pointSize * (0.8 + hrNorm * 0.4));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
456
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
457
|
+
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
|
|
458
|
+
|
|
459
|
+
// Custom shader material for better looking points
|
|
460
|
+
const material = new THREE.ShaderMaterial({
|
|
461
|
+
uniforms: {
|
|
462
|
+
uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
|
|
463
|
+
},
|
|
464
|
+
vertexShader: `
|
|
465
|
+
attribute float size;
|
|
466
|
+
attribute vec3 color;
|
|
467
|
+
varying vec3 vColor;
|
|
468
|
+
uniform float uPixelRatio;
|
|
469
|
+
|
|
470
|
+
void main() {
|
|
471
|
+
vColor = color;
|
|
472
|
+
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
|
473
|
+
gl_PointSize = size * uPixelRatio * (300.0 / -mvPosition.z);
|
|
474
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
475
|
+
}
|
|
476
|
+
`,
|
|
477
|
+
fragmentShader: `
|
|
478
|
+
varying vec3 vColor;
|
|
479
|
+
|
|
480
|
+
void main() {
|
|
481
|
+
float dist = length(gl_PointCoord - vec2(0.5));
|
|
482
|
+
if (dist > 0.5) discard;
|
|
483
|
+
|
|
484
|
+
// Soft edge with glow
|
|
485
|
+
float alpha = 1.0 - smoothstep(0.3, 0.5, dist);
|
|
486
|
+
float glow = exp(-dist * 4.0) * 0.5;
|
|
487
|
+
|
|
488
|
+
gl_FragColor = vec4(vColor + glow, alpha);
|
|
489
|
+
}
|
|
490
|
+
`,
|
|
491
|
+
transparent: true,
|
|
492
|
+
depthWrite: false,
|
|
493
|
+
blending: THREE.AdditiveBlending
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return new THREE.Points(geometry, material);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ============ Create Trail Lines ============
|
|
500
|
+
function createTrailLines(data) {
|
|
501
|
+
const size = CONFIG.axisSize;
|
|
502
|
+
const timeRange = { min: 0, max: 60 };
|
|
503
|
+
const powerRange = { min: 50, max: 400 };
|
|
504
|
+
const hrRange = { min: 100, max: 180 };
|
|
505
|
+
|
|
506
|
+
const points = data.map(point => new THREE.Vector3(
|
|
507
|
+
THREE.MathUtils.mapLinear(point.time, timeRange.min, timeRange.max, 0, size),
|
|
508
|
+
THREE.MathUtils.mapLinear(point.power, powerRange.min, powerRange.max, 0, size),
|
|
509
|
+
THREE.MathUtils.mapLinear(point.hr, hrRange.min, hrRange.max, 0, size)
|
|
510
|
+
));
|
|
511
|
+
|
|
512
|
+
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
513
|
+
const material = new THREE.LineBasicMaterial({
|
|
514
|
+
color: 0x667eea,
|
|
515
|
+
transparent: true,
|
|
516
|
+
opacity: 0.3,
|
|
517
|
+
linewidth: 1
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
return new THREE.Line(geometry, material);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ============ Initialize Scene ============
|
|
524
|
+
const cyclingData = generateCyclingData(CONFIG.pointCount);
|
|
525
|
+
|
|
526
|
+
// Add elements to scene
|
|
527
|
+
const axes = createAxes();
|
|
528
|
+
scene.add(axes);
|
|
529
|
+
|
|
530
|
+
const scatterPoints = createScatterPoints(cyclingData);
|
|
531
|
+
scene.add(scatterPoints);
|
|
532
|
+
|
|
533
|
+
const trailLine = createTrailLines(cyclingData);
|
|
534
|
+
scene.add(trailLine);
|
|
535
|
+
|
|
536
|
+
// Ambient light
|
|
537
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
|
|
538
|
+
|
|
539
|
+
// ============ Update Stats ============
|
|
540
|
+
function updateStats() {
|
|
541
|
+
const avgPower = cyclingData.reduce((sum, d) => sum + d.power, 0) / cyclingData.length;
|
|
542
|
+
const avgHR = cyclingData.reduce((sum, d) => sum + d.hr, 0) / cyclingData.length;
|
|
543
|
+
const duration = cyclingData[cyclingData.length - 1].time;
|
|
544
|
+
|
|
545
|
+
document.getElementById('avg-power').textContent = `${Math.round(avgPower)} W`;
|
|
546
|
+
document.getElementById('avg-hr').textContent = `${Math.round(avgHR)} bpm`;
|
|
547
|
+
document.getElementById('duration').textContent = `${Math.round(duration)} min`;
|
|
548
|
+
document.getElementById('points').textContent = cyclingData.length.toLocaleString();
|
|
549
|
+
}
|
|
550
|
+
updateStats();
|
|
551
|
+
|
|
552
|
+
// ============ View Controls ============
|
|
553
|
+
const viewPositions = {
|
|
554
|
+
perspective: { pos: [12, 10, 12], target: [4, 4, 4] },
|
|
555
|
+
top: { pos: [4, 15, 4], target: [4, 0, 4] },
|
|
556
|
+
front: { pos: [4, 4, 15], target: [4, 4, 0] },
|
|
557
|
+
side: { pos: [15, 4, 4], target: [0, 4, 4] }
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
let isAutoRotating = false;
|
|
561
|
+
|
|
562
|
+
document.querySelectorAll('.control-btn').forEach(btn => {
|
|
563
|
+
btn.addEventListener('click', () => {
|
|
564
|
+
const view = btn.dataset.view;
|
|
565
|
+
const action = btn.dataset.action;
|
|
566
|
+
|
|
567
|
+
if (action === 'animate') {
|
|
568
|
+
isAutoRotating = !isAutoRotating;
|
|
569
|
+
controls.autoRotate = isAutoRotating;
|
|
570
|
+
controls.autoRotateSpeed = 1;
|
|
571
|
+
btn.classList.toggle('active', isAutoRotating);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (view && viewPositions[view]) {
|
|
576
|
+
const { pos, target } = viewPositions[view];
|
|
577
|
+
|
|
578
|
+
// Animate camera transition
|
|
579
|
+
const startPos = camera.position.clone();
|
|
580
|
+
const startTarget = controls.target.clone();
|
|
581
|
+
const endPos = new THREE.Vector3(...pos);
|
|
582
|
+
const endTarget = new THREE.Vector3(...target);
|
|
583
|
+
|
|
584
|
+
let t = 0;
|
|
585
|
+
const animateView = () => {
|
|
586
|
+
t += 0.05;
|
|
587
|
+
if (t >= 1) {
|
|
588
|
+
camera.position.copy(endPos);
|
|
589
|
+
controls.target.copy(endTarget);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const easeT = 1 - Math.pow(1 - t, 3); // Ease out cubic
|
|
594
|
+
camera.position.lerpVectors(startPos, endPos, easeT);
|
|
595
|
+
controls.target.lerpVectors(startTarget, endTarget, easeT);
|
|
596
|
+
requestAnimationFrame(animateView);
|
|
597
|
+
};
|
|
598
|
+
animateView();
|
|
599
|
+
|
|
600
|
+
// Update button states
|
|
601
|
+
document.querySelectorAll('.control-btn[data-view]').forEach(b => b.classList.remove('active'));
|
|
602
|
+
btn.classList.add('active');
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ============ Animation Loop ============
|
|
608
|
+
const clock = new THREE.Clock();
|
|
609
|
+
|
|
610
|
+
function animate() {
|
|
611
|
+
requestAnimationFrame(animate);
|
|
612
|
+
|
|
613
|
+
const elapsed = clock.getElapsedTime();
|
|
614
|
+
|
|
615
|
+
// Subtle point animation
|
|
616
|
+
const positions = scatterPoints.geometry.attributes.position.array;
|
|
617
|
+
const sizes = scatterPoints.geometry.attributes.size.array;
|
|
618
|
+
|
|
619
|
+
for (let i = 0; i < sizes.length; i++) {
|
|
620
|
+
const baseSize = CONFIG.pointSize * (0.8 + Math.random() * 0.4);
|
|
621
|
+
sizes[i] = baseSize * (1 + Math.sin(elapsed * 2 + i * 0.1) * 0.1);
|
|
622
|
+
}
|
|
623
|
+
scatterPoints.geometry.attributes.size.needsUpdate = true;
|
|
624
|
+
|
|
625
|
+
controls.update();
|
|
626
|
+
renderer.render(scene, camera);
|
|
627
|
+
}
|
|
628
|
+
animate();
|
|
629
|
+
|
|
630
|
+
// ============ Resize Handler ============
|
|
631
|
+
window.addEventListener('resize', () => {
|
|
632
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
633
|
+
camera.updateProjectionMatrix();
|
|
634
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
635
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
636
|
+
scatterPoints.material.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ============ Raycaster for Hover ============
|
|
640
|
+
const raycaster = new THREE.Raycaster();
|
|
641
|
+
raycaster.params.Points.threshold = 0.2;
|
|
642
|
+
const mouse = new THREE.Vector2();
|
|
643
|
+
const tooltip = document.getElementById('tooltip');
|
|
644
|
+
|
|
645
|
+
window.addEventListener('mousemove', (event) => {
|
|
646
|
+
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
647
|
+
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
648
|
+
|
|
649
|
+
raycaster.setFromCamera(mouse, camera);
|
|
650
|
+
const intersects = raycaster.intersectObject(scatterPoints);
|
|
651
|
+
|
|
652
|
+
if (intersects.length > 0) {
|
|
653
|
+
const idx = intersects[0].index;
|
|
654
|
+
const point = cyclingData[idx];
|
|
655
|
+
|
|
656
|
+
tooltip.innerHTML = `
|
|
657
|
+
<div style="color: #667eea; font-weight: 600;">Point #${idx + 1}</div>
|
|
658
|
+
<div style="margin-top: 6px;">
|
|
659
|
+
<span style="color: #888;">Time:</span>
|
|
660
|
+
<span style="color: #48bb78;">${point.time.toFixed(1)} min</span>
|
|
661
|
+
</div>
|
|
662
|
+
<div>
|
|
663
|
+
<span style="color: #888;">Power:</span>
|
|
664
|
+
<span style="color: #667eea;">${Math.round(point.power)} W</span>
|
|
665
|
+
</div>
|
|
666
|
+
<div>
|
|
667
|
+
<span style="color: #888;">HR:</span>
|
|
668
|
+
<span style="color: #f56565;">${Math.round(point.hr)} bpm</span>
|
|
669
|
+
</div>
|
|
670
|
+
`;
|
|
671
|
+
tooltip.style.left = event.clientX + 15 + 'px';
|
|
672
|
+
tooltip.style.top = event.clientY + 15 + 'px';
|
|
673
|
+
tooltip.classList.add('visible');
|
|
674
|
+
} else {
|
|
675
|
+
tooltip.classList.remove('visible');
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
console.log('ApexOS 3D Power Analysis initialized');
|
|
680
|
+
console.log(`Rendered ${cyclingData.length} data points`);
|
|
681
|
+
</script>
|
|
682
|
+
</body>
|
|
683
|
+
</html>
|