-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.html
More file actions
501 lines (457 loc) · 31.8 KB
/
index.html
File metadata and controls
501 lines (457 loc) · 31.8 KB
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
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic 3D Data Visualizer</title>
<style>
body { margin: 0; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #111; }
canvas { display: block; }
#controls-wrapper { position: fixed; top: 20px; left: 0; z-index: 10; }
#controls {
position: relative;
left: 20px;
background: rgba(22, 22, 22, 0.6);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
width: 280px;
transition: transform 0.4s ease-in-out;
}
#controls.collapsed { transform: translateX(calc(-100% - 20px)); }
#collapse-toggle-button {
position: absolute;
top: 50%;
left: 100%;
transform: translateY(-50%);
width: 30px;
height: 60px;
background-color: rgba(22, 22, 22, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: none;
color: white;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.control-button {
flex-grow: 1; display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 10px; font-size: 0.9em; font-weight: 500; color: white;
background-color: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px; cursor: pointer; transition: all 0.2s ease;
}
.control-button:hover:not(:disabled) { background-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); }
.control-button:disabled { color: rgba(255, 255, 255, 0.4); cursor: not-allowed; opacity: 0.7; }
.control-button svg { width: 20px; height: 20px; fill: currentColor; flex-shrink: 0; }
.button-group { display: flex; gap: 10px; }
.divider { height: 1px; background-color: rgba(255, 255, 255, 0.2); margin: 5px 0; }
#data-controls, #tour-controls, #in-tour-controls {
display: flex; flex-direction: column; gap: 12px;
}
#in-tour-controls { display: none; }
#file-upload { display: none; }
#recording-notice { font-size: 0.8em; color: rgba(255, 255, 255, 0.6); text-align: center; display: none; }
</style>
</head>
<body>
<div id="controls-wrapper">
<div id="controls">
<div id="data-controls">
<div class="button-group">
<button id="upload-button" class="control-button" title="Upload your own JSON data file">
<svg viewBox="0 0 24 24"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"></path></svg>
<span>Upload</span>
</button>
<input type="file" id="file-upload" accept=".json">
<button id="download-button" class="control-button" title="Download the current data as a JSON template">
<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>
<span>Download</span>
</button>
</div>
</div>
<div class="divider"></div>
<div id="tour-controls">
<button id="start-button" class="control-button" title="Watch an interactive preview of the tour">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
<span>Preview Full Tour</span>
</button>
<div class="button-group">
<button id="record-horizontal-button" class="control-button" title="Record a 16:9 video for YouTube">
<svg viewBox="0 0 24 24"><path d="M10 16.5v-9l6 4.5-6 4.5zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></path></svg>
<span>Record 16:9</span>
</button>
<button id="record-vertical-button" class="control-button" title="Record a 9:16 video for TikTok/Shorts">
<svg viewBox="0 0 24 24"><path d="M10 16.5v-9l6 4.5-6 4.5zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></path></svg>
<span>Record 9:16</span>
</button>
</div>
</div>
<div id="in-tour-controls">
<button id="stop-button" class="control-button" title="Stop the tour or recording">
<svg viewBox="0 0 24 24"><path d="M6 6h12v12H6z"></path></svg>
<span>Stop</span>
</button>
</div>
<p id="recording-notice">Keep this tab visible for smooth recording.</p>
<button id="collapse-toggle-button">«</button>
</div>
</div>
<script type="importmap">{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
import TWEEN from 'https://unpkg.com/@tweenjs/tween.js@21.0.0/dist/tween.esm.js';
// --- STATE MANAGEMENT & GLOBALS ---
let scene, camera, renderer, clock, orbitControls, ground;
let mediaRecorder, recordedChunks = [];
let videoMimeType, videoFileExtension;
let isTourActive = false, isRecording = false;
let activeTourCompletionCallback = null;
const chartObjects = [], objectsToAnimate = [], birds = [], clouds = [];
const defaultLabelStyles = {
borderColor: 'rgba(0, 255, 255, 0.7)',
title: { font: 'bold 44px -apple-system, sans-serif', color: 'rgba(255, 255, 255, 0.9)' },
description: { font: '30px -apple-system, sans-serif', color: 'rgba(255, 255, 255, 0.6)' },
value: { font: 'bold 96px -apple-system, sans-serif', color: '#00ffff' },
rank: { font: '36px -apple-system, sans-serif', color: 'rgba(255, 255, 255, 0.5)' }
};
let currentDataSet = {
"sceneSettings": { "heightScale": 0.8 },
"data": [
{ "name": "Banana", "value": 1.1, "rank": 33, "description": "100g serving", "color": "#FFE135", "columnColor": "#4d4300", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Avocado", "value": 2.0, "rank": 32, "description": "Rich in healthy fats", "color": "#568203", "columnColor": "#2b4101", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Potato", "value": 2.5, "rank": 31, "description": "Versatile tuber", "color": "#D2B48C", "columnColor": "#544838", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Rice", "value": 2.7, "rank": 30, "description": "Staple food crop", "color": "#FFFFFF", "columnColor": "#4d4d4d", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Broccoli", "value": 2.8, "rank": 29, "description": "Nutrient-rich green", "color": "#008000", "columnColor": "#003300", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Pasta", "value": 5.0, "rank": 28, "description": "From durum wheat", "color": "#FDF5E6", "columnColor": "#655e52", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Lentils", "value": 9.0, "rank": 20, "description": "Edible pulse", "color": "#90785A", "columnColor": "#4a3c2d", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Tofu", "value": 17.0, "rank": 15, "description": "Soybean curd", "color": "#F0EAD6", "columnColor": "#605b55", "primitiveScale": 1.0, "modelPath": null },
{ "name": "Chicken", "value": 31.0, "rank": 5, "description": "Lean poultry", "color": "#FFDAB9", "columnColor": "#806d5c", "primitiveScale": 1.0, "modelPath": null,
"labelStyle": {
"borderColor": "rgba(255, 215, 0, 0.7)",
"title": { "color": "#FFDAB9" },
"value": { "color": "#FFD700", "font": "bold 100px -apple-system, sans-serif" },
"rank": { "color": "rgba(255, 215, 0, 0.5)" }
}
},
{ "name": "Tuna", "value": 33.0, "rank": 4, "description": "High in omega-3", "color": "#8A7F80", "columnColor": "#453f40", "primitiveScale": 1.2, "modelPath": null }
]
};
const ui = {
controls: document.getElementById('controls'),
tourControls: document.getElementById('tour-controls'),
inTourControls: document.getElementById('in-tour-controls'),
dataControls: document.getElementById('data-controls'),
stopButton: document.getElementById('stop-button'),
uploadButton: document.getElementById('upload-button'),
fileUpload: document.getElementById('file-upload'),
downloadButton: document.getElementById('download-button'),
recordingNotice: document.getElementById('recording-notice'),
collapseButton: document.getElementById('collapse-toggle-button'),
startButton: document.getElementById('start-button'),
recordHorizontalButton: document.getElementById('record-horizontal-button'),
recordVerticalButton: document.getElementById('record-vertical-button'),
};
// --- FUNCTIONS (ORDERED BY BEST PRACTICES) ---
/**
* Clears all chart-related objects from the scene for regeneration.
*/
function clearChart() {
chartObjects.forEach(obj => {
scene.remove(obj);
if (obj.geometry) obj.geometry.dispose();
if (obj.material) obj.material.dispose();
if (obj.texture) obj.texture.dispose();
});
chartObjects.length = 0;
objectsToAnimate.length = 0;
}
/**
* Builds the entire 3D chart from a JSON data object.
*/
function buildSceneFromData(jsonData) {
clearChart();
currentDataSet = jsonData;
const heightScale = jsonData.sceneSettings.heightScale || 0.8;
const columnWidth = 4, spacing = 3, totalWidth = jsonData.data.length * (columnWidth + spacing), startX = -totalWidth / 2;
jsonData.data.forEach((item, index) => {
const columnHeight = item.value * heightScale;
const x = startX + index * (columnWidth + spacing);
const columnColor = item.columnColor ? new THREE.Color(item.columnColor) : 0x2a2a2a;
const column = new THREE.Mesh(new THREE.BoxGeometry(columnWidth, columnHeight, columnWidth), new THREE.MeshStandardMaterial({ color: columnColor, roughness: 0.8, metalness: 0.2 }));
column.position.set(x, columnHeight / 2, 0);
scene.add(column);
const labelCanvas = createTextCanvas(item.name, `${item.value}g`, `#${item.rank}`, item.description, item.labelStyle);
const labelTexture = new THREE.CanvasTexture(labelCanvas);
const labelMaterial = new THREE.SpriteMaterial({ map: labelTexture, transparent: true });
const labelSprite = new THREE.Sprite(labelMaterial);
labelSprite.scale.set(5.12, 2.56, 1);
labelSprite.position.set(x, columnHeight + 3.2, 0);
scene.add(labelSprite);
labelSprite.texture = labelTexture;
const primitive = createPrimitive(item);
primitive.position.set(x, columnHeight + 1, 0);
scene.add(primitive);
chartObjects.push(column, labelSprite, primitive);
objectsToAnimate.push({ column, label: labelSprite, primitive });
});
}
/**
* Sets up all event listeners for the UI controls.
*/
function setupEventListeners() {
ui.startButton.addEventListener('click', () => startTour());
ui.recordHorizontalButton.addEventListener('click', () => prepareAndStartRecording(16/9, 'horizontal'));
ui.recordVerticalButton.addEventListener('click', () => prepareAndStartRecording(9/16, 'vertical'));
ui.uploadButton.addEventListener('click', () => ui.fileUpload.click());
ui.fileUpload.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const jsonData = JSON.parse(e.target.result);
buildSceneFromData(jsonData);
} catch (error) {
alert('Error parsing JSON file. Please check the format.');
console.error("JSON Parsing Error:", error);
}
};
reader.readAsText(file);
event.target.value = '';
});
ui.downloadButton.addEventListener('click', () => {
const dataStr = JSON.stringify(currentDataSet, null, 2);
const dataBlob = new Blob([dataStr], {type: "application/json"});
const url = URL.createObjectURL(dataBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'dataset_template.json';
a.click();
URL.revokeObjectURL(url);
});
ui.stopButton.addEventListener('click', stopTour);
ui.collapseButton.addEventListener('click', () => {
ui.controls.classList.toggle('collapsed');
ui.collapseButton.innerText = ui.controls.classList.contains('collapsed') ? '»' : '«';
});
window.addEventListener('resize', onWindowResize, false);
}
// --- All other functions are defined below init() and animate() ---
/**
* Main initialization function.
*/
function init() {
scene = new THREE.Scene(); clock = new THREE.Clock();
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 15, 40);
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.5;
document.body.appendChild(renderer.domElement);
orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.target.set(0, 5, 0);
orbitControls.maxPolarAngle = Math.PI / 2; // Prevent going below ground
scene.add(new THREE.HemisphereLight(0xccccff, 0x000000, 1.2));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
keyLight.position.set(-30, 50, 40);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 0.8);
rimLight.position.set(20, 30, -50);
scene.add(rimLight);
const fillLight = new THREE.DirectionalLight(0xffffff, 0.6);
fillLight.position.set(40, 30, 40);
scene.add(fillLight);
const sky = new Sky(); sky.scale.setScalar(450000); scene.add(sky);
const sun = new THREE.Vector3(); const phi = THREE.MathUtils.degToRad(60); const theta = THREE.MathUtils.degToRad(180);
sun.setFromSphericalCoords(1, phi, theta); sky.material.uniforms['sunPosition'].value.copy(sun);
const groundGeo = new THREE.PlaneGeometry(500, 500);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x000, roughness: 0.8, metalness: 0, transparent: true, opacity: 0.75
});
ground = new THREE.Mesh(groundGeo, groundMaterial);
ground.position.y = -0.01;
ground.rotateX(-Math.PI / 2);
scene.add(ground);
const grid = new THREE.GridHelper(500, 60, 0x00ffff, 0x00ffff);
grid.material.transparent = true;
grid.material.opacity = 0.15;
scene.add(grid);
createCityscape(); createBirds(); createClouds();
buildSceneFromData(currentDataSet);
setupEventListeners(); // Listeners are set up after scene is built
animate();
}
/**
* The main animation loop.
*/
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (orbitControls.enabled) orbitControls.update();
TWEEN.update();
birds.forEach(b => {b.position.addScaledVector(b.velocity, delta); if(b.position.x > 150) b.position.x = -150;});
clouds.forEach(c => {c.position.addScaledVector(c.velocity, delta); if(c.position.x > 200) c.position.x = -200;});
renderer.render(scene, camera);
}
// --- UI & TOUR STATE MANAGEMENT ---
function toggleUIState(tourActive) {
isTourActive = tourActive;
orbitControls.enabled = !tourActive;
ui.tourControls.style.display = tourActive ? 'none' : 'flex';
ui.inTourControls.style.display = tourActive ? 'flex' : 'none';
ui.dataControls.style.pointerEvents = tourActive ? 'none' : 'auto';
ui.dataControls.style.opacity = tourActive ? 0.5 : 1;
}
function stopTour() {
// To prevent a jarring camera snap, we first determine what the camera
// is currently looking at. The tour animation always targets a point on the Z=0 plane.
// We calculate this point of intersection to use as the starting point for our new animation.
const currentLookAt = new THREE.Vector3();
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
// Calculate the intersection of the camera's view vector with the Z=0 plane.
// This is safe because the tour animation always sets the look-at point's z-coordinate to 0.
if (Math.abs(camDir.z) > 1e-6) { // Avoid division by zero if looking parallel to the plane
const t = -camera.position.z / camDir.z;
currentLookAt.copy(camera.position).addScaledVector(camDir, t);
} else { // Fallback if camera is perfectly horizontal
currentLookAt.copy(camera.position).add(camDir.setZ(0).normalize().multiplyScalar(30));
}
// Now, stop all ongoing tour animations.
TWEEN.removeAll();
if(isRecording) { mediaRecorder.stop(); isRecording = false; }
if (activeTourCompletionCallback) { activeTourCompletionCallback(); }
// Smoothly animate camera to the final overview position from its current state.
const returnDuration = 1500;
const heightScale = currentDataSet.sceneSettings.heightScale || 0.8;
const totalHeight = currentDataSet.data.reduce((sum, item) => sum + (item.value * heightScale), 0);
const avgHeight = totalHeight > 0 ? totalHeight / currentDataSet.data.length : 5; // Fallback height
const finalPosition = new THREE.Vector3(0, avgHeight + 20, 80);
const finalTarget = new THREE.Vector3(0, avgHeight, 0);
// Instead of tweening the stale `orbitControls.target`, we tween our calculated `currentLookAt` point.
// This ensures a smooth transition from where the camera was actually looking.
new TWEEN.Tween(currentLookAt)
.to(finalTarget, returnDuration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => { camera.lookAt(currentLookAt); })
.start();
new TWEEN.Tween(camera.position)
.to(finalPosition, returnDuration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onComplete(() => {
orbitControls.target.copy(finalTarget); // Sync orbitControls.target at the very end.
resetTourState();
})
.start();
}
function resetTourState() {
toggleUIState(false);
ui.recordingNotice.style.display = 'none';
}
/**
* Creates and starts a smooth camera tour through all data points.
*/
function startTour(onCompleteCallback = null) {
if (isTourActive) return;
toggleUIState(true);
activeTourCompletionCallback = onCompleteCallback;
if (isRecording) ui.recordingNotice.style.display = 'block';
const DURATION = 1500; // Travel time between points
const PAUSE = 1200; // Pause duration at each point
const lookAtProxy = { x: orbitControls.target.x, y: orbitControls.target.y, z: orbitControls.target.z };
// This tween runs for the entire tour, making the camera follow the lookAtProxy
const updateTween = new TWEEN.Tween({}).to({}, Infinity).onUpdate(() => {
camera.lookAt(lookAtProxy.x, lookAtProxy.y, lookAtProxy.z);
}).start();
// A dummy tween to act as the start of the animation chain
let masterChainStarter = new TWEEN.Tween({});
let lastTweenInChain = masterChainStarter;
// Build the sequence of movements and pauses
objectsToAnimate.forEach((obj) => {
const columnHeight = obj.column.geometry.parameters.height;
const focusY = columnHeight + 2.0; // Focus point between primitive and label
const cameraY = focusY + 2; // Position camera slightly higher to look down
const targetPosition = new THREE.Vector3(obj.column.position.x, cameraY, 15);
const newLookAtPosition = { x: obj.column.position.x, y: focusY, z: 0 };
// Tween for camera movement
const positionTween = new TWEEN.Tween(camera.position).to(targetPosition, DURATION).easing(TWEEN.Easing.Quadratic.InOut);
const lookAtTween = new TWEEN.Tween(lookAtProxy).to(newLookAtPosition, DURATION).easing(TWEEN.Easing.Quadratic.InOut);
positionTween.onStart(() => lookAtTween.start()); // Move camera and lookAt point simultaneously
// Create an explicit "pause" tween that does nothing for the PAUSE duration
const pauseTween = new TWEEN.Tween({}).to({}, PAUSE);
// Chain the animations: last animation -> move -> pause
lastTweenInChain.chain(positionTween);
positionTween.chain(pauseTween);
// The end of the chain is now the pause, for the next iteration to link to
lastTweenInChain = pauseTween;
});
// Create the final zoom-out animation
const heightScale = currentDataSet.sceneSettings.heightScale || 0.8;
const totalHeight = currentDataSet.data.reduce((sum, item) => sum + (item.value * heightScale), 0);
const avgHeight = totalHeight / currentDataSet.data.length;
const finalPosition = new THREE.Vector3(0, avgHeight + 20, 80);
const finalLookat = { x: 0, y: avgHeight, z: 0 };
const finalPositionTween = new TWEEN.Tween(camera.position).to(finalPosition, DURATION * 1.5).easing(TWEEN.Easing.Quadratic.InOut);
const finalLookAtTween = new TWEEN.Tween(lookAtProxy).to(finalLookat, DURATION * 1.5).easing(TWEEN.Easing.Quadratic.InOut);
finalPositionTween.onStart(() => finalLookAtTween.start());
// Chain the final zoom-out to the very end of the sequence (after the last pause)
lastTweenInChain.chain(finalPositionTween);
// Define what happens upon final completion of the entire tour
finalPositionTween.onComplete(() => {
updateTween.stop(); // Stop the lookAt-tracking tween
if(isRecording) { mediaRecorder.stop(); isRecording = false; }
orbitControls.target.set(finalLookat.x, finalLookat.y, finalLookat.z);
if (activeTourCompletionCallback) activeTourCompletionCallback();
resetTourState();
});
// Start the entire animation sequence by starting the first dummy tween
masterChainStarter.start();
}
// --- UTILITY & HELPER FUNCTIONS (CONDENSED) ---
function onWindowResize() { renderer.setSize(window.innerWidth, window.innerHeight); camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); }
function prepareAndStartRecording(aspectRatio, name) { let width, height; if (aspectRatio > 1) { width = Math.min(window.innerWidth, 1920); height = width / aspectRatio; } else { height = Math.min(window.innerHeight, 1920); width = height * aspectRatio; } renderer.setSize(width, height); camera.aspect = aspectRatio; camera.updateProjectionMatrix(); startRecording(name, onWindowResize); }
function startRecording(fileName, onStopCallback) { setupMediaRecorder(fileName); recordedChunks = []; mediaRecorder.start(); isRecording = true; startTour(onStopCallback); }
function setupMediaRecorder(fileName) { const mp4MimeType = 'video/mp4'; const webmFallbackMimeType = 'video/webm; codecs=vp9'; if (MediaRecorder.isTypeSupported(mp4MimeType)) { videoMimeType = mp4MimeType; videoFileExtension = 'mp4'; } else { videoMimeType = webmFallbackMimeType; videoFileExtension = 'webm'; } const stream = renderer.domElement.captureStream(30); mediaRecorder = new MediaRecorder(stream, { mimeType: videoMimeType }); mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) recordedChunks.push(event.data); }; mediaRecorder.onstop = () => { const blob = new Blob(recordedChunks, { type: videoMimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `protein-chart-${fileName}.${videoFileExtension}`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); }; }
function createTextCanvas(title, value, rank, description, customStyles = {}) {
const styles = {
...defaultLabelStyles, ...customStyles,
title: { ...defaultLabelStyles.title, ...customStyles?.title },
description: { ...defaultLabelStyles.description, ...customStyles?.description },
value: { ...defaultLabelStyles.value, ...customStyles?.value },
rank: { ...defaultLabelStyles.rank, ...customStyles?.rank },
};
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 256; const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(10, 10, 20, 0.7)'; ctx.roundRect(0, 0, canvas.width, canvas.height, 30); ctx.fill();
const borderColor = styles.borderColor || defaultLabelStyles.borderColor;
ctx.strokeStyle = borderColor; ctx.lineWidth = 4; ctx.shadowColor = borderColor; ctx.shadowBlur = 15; ctx.roundRect(0, 0, canvas.width, canvas.height, 30); ctx.stroke(); ctx.shadowBlur = 0;
ctx.textAlign = 'center';
ctx.fillStyle = styles.title.color; ctx.font = styles.title.font; ctx.fillText(title, canvas.width / 2, 65);
if (description) { ctx.fillStyle = styles.description.color; ctx.font = styles.description.font; ctx.fillText(description, canvas.width / 2, 105); }
ctx.fillStyle = styles.value.color; ctx.font = styles.value.font; ctx.fillText(value, canvas.width / 2, 195);
ctx.textAlign = 'right';
ctx.fillStyle = styles.rank.color; ctx.font = styles.rank.font; ctx.fillText(rank, canvas.width - 30, 220);
return canvas;
}
function createCityscape() { const cityMaterial = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9, metalness: 0.2 }); const litMaterial = new THREE.MeshStandardMaterial({ emissive: 0xffffbb, emissiveIntensity: 0.5, color: 0x1a1a1a }); for (let i = 0; i < 150; i++) { const height = Math.random() * 80 + 10; const width = Math.random() * 10 + 5; const depth = Math.random() * 10 + 5; const material = Math.random() > 0.9 ? litMaterial : cityMaterial; const box = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material); box.position.set( (Math.random() - 0.5) * 500, height / 2, (Math.random() * -150) - 150 ); scene.add(box); } }
function createBirds() { const birdGeo = new THREE.ConeGeometry(0.3, 1, 4); birdGeo.rotateX(Math.PI / 2); const birdMaterial = new THREE.MeshBasicMaterial({ color: 0x222222 }); for (let i = 0; i < 20; i++) { const bird = new THREE.Mesh(birdGeo, birdMaterial); bird.position.set( Math.random() * 200 - 100, Math.random() * 30 + 40, Math.random() * -100 - 50 ); bird.velocity = new THREE.Vector3(Math.random() * 20 + 10, 0, 0); birds.push(bird); scene.add(bird); } }
function createClouds() { const cloudTexture = new THREE.CanvasTexture(createCloudCanvas()); const cloudMaterial = new THREE.PointsMaterial({ size: 60, map: cloudTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.3 }); const cloudGeo = new THREE.BufferGeometry(); const vertices = []; for (let i = 0; i < 50; i++) { vertices.push(Math.random() * 400 - 200, Math.random() * 25 + 30, Math.random() * -150 - 50); } cloudGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); const cloudPoints = new THREE.Points(cloudGeo, cloudMaterial); cloudPoints.velocity = new THREE.Vector3(5, 0, 0); clouds.push(cloudPoints); scene.add(cloudPoints); }
function createCloudCanvas() { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const context = canvas.getContext('2d'); const gradient = context.createRadialGradient(canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2); gradient.addColorStop(0, 'rgba(220,220,255,1)'); gradient.addColorStop(1, 'rgba(220,220,255,0)'); context.fillStyle = gradient; context.fillRect(0, 0, canvas.width, canvas.height); return canvas; }
function createPrimitive(item) { let geometry, material; const scale = item.primitiveScale || 1.0; const color = item.color || '#cccccc'; material = new THREE.MeshStandardMaterial({ color: new THREE.Color(color), roughness: 0.5 }); switch(item.name) { case "Banana": geometry = new THREE.SphereGeometry(0.5, 32, 16); break; case "Avocado": geometry = new THREE.SphereGeometry(0.6, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2); break; case "Potato": geometry = new THREE.IcosahedronGeometry(0.6, 0); break; case "Rice": geometry = new THREE.SphereGeometry(0.7, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2); material.side = THREE.DoubleSide; break; case "Broccoli": geometry = new THREE.ConeGeometry(0.6, 1, 32); break; default: geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8); } const mesh = new THREE.Mesh(geometry, material); mesh.scale.set(scale, scale, scale); return mesh; }
// --- START APPLICATION ---
init();
</script>
</body>
</html>