|
|
|
import * as THREE from 'three';
|
|
|
|
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
|
|
|
import { Font } from 'three/examples/jsm/loaders/FontLoader.js';
|
|
|
|
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
|
|
|
|
import { lerp, randFloat } from 'three/src/math/MathUtils.js';
|
|
|
|
import { OrbitControls } from 'three/examples/jsm/Addons.js';
|
|
|
|
import { depth } from 'three/examples/jsm/nodes/Nodes.js';
|
|
|
|
|
|
|
|
class PickHelper {
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
this.raycaster = new THREE.Raycaster();
|
|
|
|
this.pickedObject = null;
|
|
|
|
this.lastObjectPicked = null;
|
|
|
|
this.sameObjectPicked = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
pick(normalizedPosition, scene, camera, time) {
|
|
|
|
// restore the color if there is a picked object
|
|
|
|
if (this.pickedObject) {
|
|
|
|
this.lastObjectPicked = this.pickedObject;
|
|
|
|
this.sameObjectPicked = false;
|
|
|
|
this.pickedObject = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
// cast a ray through the frustum
|
|
|
|
this.raycaster.setFromCamera(normalizedPosition, camera);
|
|
|
|
// get the list of objects the ray intersected
|
|
|
|
const intersectedObjects = this.raycaster.intersectObjects(scene.children);
|
|
|
|
if (intersectedObjects.length) {
|
|
|
|
// pick the first object. It's the closest one
|
|
|
|
for (let i = 0; i < intersectedObjects.length; i++){
|
|
|
|
|
|
|
|
if(intersectedObjects[i].object.geometry.type != "SphereGeometry"){
|
|
|
|
this.pickedObject = intersectedObjects[i].object;
|
|
|
|
if(intersectedObjects[i].object == this.lastObjectPicked)
|
|
|
|
this.sameObjectPicked = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.sameObjectPicked)
|
|
|
|
this.pickedObject = this.lastObjectPicked;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
class Article {
|
|
|
|
|
|
|
|
constructor(texture, title, filename, id){
|
|
|
|
this.geom = new THREE.TetrahedronGeometry(1, 2);
|
|
|
|
this.material = new THREE.MeshLambertMaterial({map: texture});
|
|
|
|
this.mesh = new THREE.Mesh(this.geom, this.material);
|
|
|
|
this.html = filename;
|
|
|
|
this.name = title;
|
|
|
|
this.id = id;
|
|
|
|
this.hover = false;
|
|
|
|
this.hoverScale = false;
|
|
|
|
this.hoverLerpTime = 0;
|
|
|
|
this.speed = randFloat(1.5, 2);
|
|
|
|
this.rotationSpeed = randFloat(0.5, 1.5);
|
|
|
|
this.scale = 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
AddToScene(scene, aspect){
|
|
|
|
this.mesh.position.x = (Math.random() - 0.5) * 8 * aspect;
|
|
|
|
this.mesh.position.y = 8 + Math.random() * 15;
|
|
|
|
scene.add(this.mesh);
|
|
|
|
}
|
|
|
|
|
|
|
|
UpdateRotation(time){
|
|
|
|
this.mesh.rotation.x += this.rotationSpeed * time;
|
|
|
|
this.mesh.rotation.y += this.rotationSpeed * time;
|
|
|
|
}
|
|
|
|
|
|
|
|
UpdatePosition(time, picker){
|
|
|
|
this.BoundsCheck();
|
|
|
|
this.HoverCheck(picker, time)
|
|
|
|
if (!this.hover)
|
|
|
|
this.mesh.position.y -= this.speed * time;
|
|
|
|
}
|
|
|
|
|
|
|
|
BoundsCheck(){
|
|
|
|
if (this.mesh.position.y < -8) {
|
|
|
|
const aspect = window.innerWidth / (window.innerHeight - 100);
|
|
|
|
this.mesh.position.y = 8 + (Math.random() * 3);
|
|
|
|
this.mesh.position.x = (Math.random() - 0.5) * 8 * aspect;
|
|
|
|
this.speed = randFloat(1.5, 2)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
UpdateScale(multipler){
|
|
|
|
this.mesh.scale.set(multipler, multipler, multipler);
|
|
|
|
}
|
|
|
|
|
|
|
|
HoverCheck(picker, time){
|
|
|
|
const hoverIncrement = 0.01;
|
|
|
|
const offHoverDecrement = 0.01;
|
|
|
|
|
|
|
|
if (picker.pickedObject === this.mesh) {
|
|
|
|
this.hover = true;
|
|
|
|
if (this.hoverLerpTime < 1) {
|
|
|
|
this.hoverLerpTime += hoverIncrement;
|
|
|
|
}
|
|
|
|
if (this.hoverLerpTime > 1) {
|
|
|
|
this.hoverLerpTime = 1;
|
|
|
|
}
|
|
|
|
this.scale = lerp(this.scale, 2, this.hoverLerpTime);
|
|
|
|
} else {
|
|
|
|
this.hover = false;
|
|
|
|
if (this.hoverLerpTime > 0) {
|
|
|
|
this.hoverLerpTime -= offHoverDecrement;
|
|
|
|
}
|
|
|
|
if (this.hoverLerpTime < 0) {
|
|
|
|
this.hoverLerpTime = 0;
|
|
|
|
}
|
|
|
|
this.scale = lerp(this.scale, 1, 1 - this.hoverLerpTime);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.UpdateScale(this.scale);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let scene, camera, renderer, cube;
|
|
|
|
let texture, planeMat, mesh, moloch_txt;
|
|
|
|
let lastTime = 0; // Keep track of the last frame time
|
|
|
|
let textGeo, textWidth, textMaterial, textMesh;
|
|
|
|
let text_Geometries = [];
|
|
|
|
let isHovering = false;
|
|
|
|
const pickPosition = {x: 0, y: 0};
|
|
|
|
const pickHelper = new PickHelper();
|
|
|
|
const object_list = []
|
|
|
|
const object_count = 20;
|
|
|
|
const fontLoader = new FontLoader();
|
|
|
|
|
|
|
|
function init() {
|
|
|
|
|
|
|
|
// Texture Loader
|
|
|
|
const loader = new THREE.TextureLoader();
|
|
|
|
texture = loader.load('/images/website/checker.png');
|
|
|
|
|
|
|
|
// Create a renderer and attach it to our document
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
renderer.setSize(window.innerWidth, window.innerHeight - 100);
|
|
|
|
document.getElementById('container').appendChild(renderer.domElement);
|
|
|
|
document.getElementById('container').style.overflowY = 'hidden';
|
|
|
|
|
|
|
|
// Create the scene
|
|
|
|
scene = new THREE.Scene();
|
|
|
|
scene.background = new THREE.Color('black');
|
|
|
|
|
|
|
|
// Camera Setup
|
|
|
|
const aspect = window.innerWidth / (window.innerHeight - 100); // Adjust for nav height
|
|
|
|
const frustumSize = 10;
|
|
|
|
camera = new THREE.OrthographicCamera(
|
|
|
|
frustumSize * aspect / -2,
|
|
|
|
frustumSize * aspect / 2,
|
|
|
|
frustumSize / 2,
|
|
|
|
frustumSize / -2,
|
|
|
|
0.1,
|
|
|
|
5000
|
|
|
|
);
|
|
|
|
|
|
|
|
//const controls = new OrbitControls(camera, renderer.domElement);
|
|
|
|
|
|
|
|
camera.position.z = 20;
|
|
|
|
|
|
|
|
// Fetch JSON data
|
|
|
|
fetch('../json/articles.json')
|
|
|
|
.then(response => response.json())
|
|
|
|
.then(jsonData => {
|
|
|
|
for (let i = 0; i < jsonData.length; i++) {
|
|
|
|
let temp_txt = loader.load('../images/' + jsonData[i]['image']);
|
|
|
|
temp_txt.minFilter = THREE.NearestFilter;
|
|
|
|
let title = jsonData[i]['name']
|
|
|
|
let filename = jsonData[i]['filename']
|
|
|
|
let article = new Article(temp_txt, title, filename, i);
|
|
|
|
article.AddToScene(scene, aspect);
|
|
|
|
object_list.push(article);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Plane Setup
|
|
|
|
{
|
|
|
|
const planeSize = 40;
|
|
|
|
|
|
|
|
|
|
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
|
|
texture.magFilter = THREE.NearestFilter;
|
|
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
|
|
const repeats = planeSize / 1;
|
|
|
|
texture.repeat.set(repeats, repeats);
|
|
|
|
|
|
|
|
const planeGeo = new THREE.SphereGeometry(10);
|
|
|
|
planeMat = new THREE.MeshPhongMaterial({
|
|
|
|
map: texture,
|
|
|
|
side: THREE.DoubleSide,
|
|
|
|
});
|
|
|
|
|
|
|
|
mesh = new THREE.Mesh(planeGeo, planeMat);
|
|
|
|
mesh.position.z = -20
|
|
|
|
scene.add(mesh);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Light Setup
|
|
|
|
{
|
|
|
|
const color = 0xFFFFFF;
|
|
|
|
const intensity = 3;
|
|
|
|
const light = new THREE.DirectionalLight(color, intensity);
|
|
|
|
light.castShadow = true;
|
|
|
|
light.position.set(1, 1, 10);
|
|
|
|
light.target.position.set(-0, 0, -0);
|
|
|
|
light.shadow.camera.top = 25;
|
|
|
|
light.shadow.camera.bottom = -25;
|
|
|
|
light.shadow.camera.left = -25;
|
|
|
|
light.shadow.camera.right = 25;
|
|
|
|
light.shadow.camera.zoom = 1;
|
|
|
|
scene.add(light);
|
|
|
|
scene.add(light.target);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Start with an initial timestamp
|
|
|
|
animate(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
function animate(time) {
|
|
|
|
requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
// Calculate the time elapsed since the last frame
|
|
|
|
const deltaTime = (time - lastTime) / 1000; // Convert time to seconds
|
|
|
|
lastTime = time;
|
|
|
|
|
|
|
|
pickHelper.pick(pickPosition, scene, camera, time);
|
|
|
|
|
|
|
|
for (let i = 0; i < object_list.length; i++){
|
|
|
|
object_list[i].UpdateRotation(deltaTime);
|
|
|
|
object_list[i].UpdatePosition(deltaTime, pickHelper);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update the plane texture offset
|
|
|
|
const scrollSpeed = 0.2;
|
|
|
|
planeMat.map.offset.y += scrollSpeed * deltaTime;
|
|
|
|
planeMat.map.offset.x += scrollSpeed / 0.75 * deltaTime;
|
|
|
|
|
|
|
|
ChangeCursor();
|
|
|
|
// Render the scene from the perspective of the camera
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle window resize
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
const aspect = window.innerWidth / (window.innerHeight - 100);
|
|
|
|
const frustumSize = 10;
|
|
|
|
camera.left = -frustumSize * aspect / 2;
|
|
|
|
camera.right = frustumSize * aspect / 2;
|
|
|
|
camera.top = frustumSize / 2;
|
|
|
|
camera.bottom = -frustumSize / 2;
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
|
|
renderer.setSize(window.innerWidth, window.innerHeight - 100);
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getCanvasRelativePosition(event) {
|
|
|
|
const rect = document.querySelector('#container').getBoundingClientRect();
|
|
|
|
return {
|
|
|
|
x: (event.clientX - rect.left) * window.innerWidth / rect.width,
|
|
|
|
y: (event.clientY - rect.top ) * (window.innerHeight - 100) / rect.height,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function setPickPosition(event) {
|
|
|
|
const pos = getCanvasRelativePosition(event);
|
|
|
|
pickPosition.x = (pos.x / window.innerWidth ) * 2 - 1;
|
|
|
|
pickPosition.y = (pos.y / ( window.innerHeight-100 ) ) * -2 + 1; // note we flip Y
|
|
|
|
}
|
|
|
|
|
|
|
|
function clearPickPosition() {
|
|
|
|
// unlike the mouse which always has a position
|
|
|
|
// if the user stops touching the screen we want
|
|
|
|
// to stop picking. For now we just pick a value
|
|
|
|
// unlikely to pick something
|
|
|
|
pickPosition.x = -100000;
|
|
|
|
pickPosition.y = -100000;
|
|
|
|
}
|
|
|
|
|
|
|
|
function objectClicked(event) {
|
|
|
|
if (pickHelper.pickedObject) {
|
|
|
|
// Find the corresponding Article object
|
|
|
|
const pickedArticle = object_list.find(article => article.mesh === pickHelper.pickedObject);
|
|
|
|
if (pickedArticle) {
|
|
|
|
window.location.href = pickedArticle.html;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function ChangeCursor(){
|
|
|
|
const pickedArticle = object_list.find(article => article.mesh === pickHelper.pickedObject);
|
|
|
|
if (pickedArticle) {
|
|
|
|
document.body.style.cursor = 'pointer';
|
|
|
|
if (!isHovering)
|
|
|
|
UpdateText(pickedArticle.name);
|
|
|
|
}else{
|
|
|
|
document.body.style.cursor = 'default';
|
|
|
|
ClearTextGeoList();
|
|
|
|
isHovering = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function UpdateText(text) {
|
|
|
|
MeasureText(text);
|
|
|
|
isHovering = true
|
|
|
|
}
|
|
|
|
|
|
|
|
function ClearTextGeoList() {
|
|
|
|
for (let i = 0; i < text_Geometries.length; i++) {
|
|
|
|
scene.remove(text_Geometries[i]);
|
|
|
|
}
|
|
|
|
text_Geometries.length = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
function MeasureText(text) {
|
|
|
|
fontLoader.load('fonts/Redaction 50_Regular.json', (font) => {
|
|
|
|
let initialFontSize = 1;
|
|
|
|
if (window.innerWidth < 1024) {
|
|
|
|
initialFontSize = 0.5;
|
|
|
|
} else if (window.innerWidth < 512) {
|
|
|
|
initialFontSize = 0.25;
|
|
|
|
}
|
|
|
|
|
|
|
|
const aspect = window.innerWidth / (window.innerHeight - 100); // Adjust for nav height
|
|
|
|
const frustumSize = 10;
|
|
|
|
const orthoWidth = (frustumSize * aspect / 2) - (frustumSize * aspect / -2);
|
|
|
|
|
|
|
|
function createTextGeometry(text, size) {
|
|
|
|
return new TextGeometry(text, {
|
|
|
|
height: 2,
|
|
|
|
depth: 1,
|
|
|
|
font: font,
|
|
|
|
size: size
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Split text into words
|
|
|
|
const split = text.split(" ");
|
|
|
|
const word_count = split.length;
|
|
|
|
const sentences = [];
|
|
|
|
|
|
|
|
let currentText = "";
|
|
|
|
let currentFontSize = initialFontSize;
|
|
|
|
let textGeo;
|
|
|
|
|
|
|
|
for (let i = 0; i < word_count; i++) {
|
|
|
|
const testText = currentText + (currentText ? " " : "") + split[i];
|
|
|
|
textGeo = createTextGeometry(testText, currentFontSize);
|
|
|
|
textGeo.computeBoundingBox();
|
|
|
|
const proportion = textGeo.boundingBox.max.x / orthoWidth;
|
|
|
|
|
|
|
|
if (proportion > 0.8) {
|
|
|
|
if (currentText) {
|
|
|
|
sentences.push(currentText);
|
|
|
|
}
|
|
|
|
currentText = split[i];
|
|
|
|
} else {
|
|
|
|
currentText = testText;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (currentText) {
|
|
|
|
sentences.push(currentText);
|
|
|
|
}
|
|
|
|
|
|
|
|
ClearTextGeoList();
|
|
|
|
|
|
|
|
const numSentences = sentences.length;
|
|
|
|
const totalHeight = (numSentences - 1) * (1.5 * currentFontSize);
|
|
|
|
const startY = totalHeight / 2;
|
|
|
|
|
|
|
|
for (let i = 0; i < sentences.length; i++) {
|
|
|
|
textGeo = createTextGeometry(sentences[i], currentFontSize);
|
|
|
|
textGeo.computeBoundingBox();
|
|
|
|
const proportion = textGeo.boundingBox.max.x / orthoWidth;
|
|
|
|
|
|
|
|
if (proportion > 0.8) {
|
|
|
|
currentFontSize *= 0.8 / proportion;
|
|
|
|
textGeo = createTextGeometry(sentences[i], currentFontSize);
|
|
|
|
textGeo.computeBoundingBox();
|
|
|
|
}
|
|
|
|
|
|
|
|
const textMaterial = new THREE.MeshNormalMaterial();
|
|
|
|
const textMesh = new THREE.Mesh(textGeo, textMaterial);
|
|
|
|
|
|
|
|
const centerOffsetX = (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x) / 2;
|
|
|
|
const centerOffsetY = (textGeo.boundingBox.max.y - textGeo.boundingBox.min.y) / 2;
|
|
|
|
const centerOffsetZ = (textGeo.boundingBox.max.z - textGeo.boundingBox.min.z) / 2;
|
|
|
|
|
|
|
|
textGeo.translate(-centerOffsetX, -centerOffsetY, -centerOffsetZ);
|
|
|
|
textMesh.rotation.x = Math.PI / 2 * 0.05;
|
|
|
|
textMesh.position.y = startY - (i * (1.5 * currentFontSize));
|
|
|
|
textMesh.position.z = 5;
|
|
|
|
text_Geometries.push(textMesh);
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = 0; i < text_Geometries.length; i++) {
|
|
|
|
scene.add(text_Geometries[i]);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener('mousemove', setPickPosition);
|
|
|
|
window.addEventListener('mouseout', clearPickPosition);
|
|
|
|
window.addEventListener('mouseleave', clearPickPosition);
|
|
|
|
window.addEventListener('click', objectClicked)
|
|
|
|
|
|
|
|
// Add touch event listeners
|
|
|
|
window.addEventListener('touchstart', onTouchStart, {passive: false});
|
|
|
|
window.addEventListener('touchmove', onTouchMove, {passive: false});
|
|
|
|
window.addEventListener('touchend', onTouchEnd, {passive: false});
|
|
|
|
window.addEventListener('touchcancel', clearPickPosition);
|
|
|
|
|
|
|
|
let touchStartTime;
|
|
|
|
const touchHoldDuration = 500; // Duration in milliseconds to distinguish between tap and hold
|
|
|
|
|
|
|
|
function onTouchStart(event) {
|
|
|
|
touchStartTime = Date.now();
|
|
|
|
setPickPosition(event.touches[0]);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onTouchMove(event) {
|
|
|
|
setPickPosition(event.touches[0]);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onTouchEnd(event) {
|
|
|
|
const touchDuration = Date.now() - touchStartTime;
|
|
|
|
clearPickPosition();
|
|
|
|
if (touchDuration < touchHoldDuration) {
|
|
|
|
// It's a tap
|
|
|
|
objectClicked(event);
|
|
|
|
} else {
|
|
|
|
// It's a hold
|
|
|
|
// Do nothing extra, as hover effect should already be handled by setPickPosition
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Initialize the application
|
|
|
|
init();
|