function isMobileDevice() {
if (navigator.userAgentData && navigator.userAgentData.mobile) {
return true;
}
const regex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
if (regex.test(navigator.userAgent)) {
return true;
}
if (window.matchMedia("(max-width: 768px)").matches) {
return true;
}
return false;
}
// Import the necessary components from the Three.js library
import * as THREE from 'three';
// Import the OrbitControls for mouse interaction
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Global State Variables ---
let isThreeJsInitialized = false;
let threeJsApp = null; // Will hold our animation state and objects
// --- Three.js Initialization and Control ---
function initializeThreeJs() {
if (isMobileDevice()) {
console.log("Mobile device detected, skipping Three.js initialization.");
document.body.classList.add('is-mobile');
const container = document.querySelector('#home-content #scene-container');
if (container) {
container.innerHTML = `
The interactive globe is best viewed on a desktop. We're working on a mobile-friendly experience!
`;
}
return;
}
if (isThreeJsInitialized) return;
const container = document.querySelector('#home-content #scene-container');
if (!container) return;
isThreeJsInitialized = true;
// Clear any previous canvas
while (container.firstChild) {
container.removeChild(container.firstChild);
}
const loadingOverlay = document.getElementById('loading-overlay');
const sloganElement = document.getElementById('main-slogan');
const finaleLogoContainer = document.getElementById('finale-logo-container');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.sortObjects = false;
container.appendChild(renderer.domElement);
const loadingManager = new THREE.LoadingManager(() => {
loadingOverlay.style.opacity = '0';
setTimeout(() => { loadingOverlay.style.display = 'none'; }, 500);
});
scene.add(new THREE.AmbientLight(0xffffff, 0.3));
const pointLight = new THREE.PointLight(0xffffff, 1.2);
pointLight.position.set(5, 3, 5);
scene.add(pointLight);
const textureLoader = new THREE.TextureLoader(loadingManager);
const starfieldTexture = textureLoader.load('assets/starfield.jpg');
starfieldTexture.wrapS = starfieldTexture.wrapT = THREE.RepeatWrapping;
starfieldTexture.repeat.set(4, 4);
const starfield = new THREE.Mesh(new THREE.SphereGeometry(90, 64, 64), new THREE.MeshBasicMaterial({ map: starfieldTexture, side: THREE.BackSide }));
scene.add(starfield);
const earth = new THREE.Mesh(new THREE.SphereGeometry(2, 64, 64), new THREE.MeshPhongMaterial({ map: textureLoader.load('assets/earth_daymap.jpg'), bumpMap: textureLoader.load('assets/earth_bumpmap.jpg'), bumpScale: 0.05, specularMap: textureLoader.load('assets/earth_watermap.png'), specular: new THREE.Color('grey') }));
scene.add(earth);
const initialCities = [ { name: 'Tokyo', lat: 35.68, lon: 139.69 }, { name: 'London', lat: 51.50, lon: -0.12 }, { name: 'Paris', lat: 48.85, lon: 2.35 }, { name: 'Sydney', lat: -33.86, lon: 151.20 }, { name: 'São Paulo', lat: -23.55, lon: -46.63 }, { name: 'Cairo', lat: 30.04, lon: 31.23 }, { name: 'Beijing', lat: 39.90, lon: 116.40 }, { name: 'Singapore', lat: 1.35, lon: 103.81 }, { name: 'New York', lat: 40.71, lon: -74.00 }, { name: 'Los Angeles', lat: 34.05, lon: -118.24 }, { name: 'Chicago', lat: 41.87, lon: -87.62 }, { name: 'Houston', lat: 29.76, lon: -95.36 }, { name: 'Phoenix', lat: 33.44, lon: -112.07 }, { name: 'San Francisco', lat: 37.77, lon: -122.41 }, { name: 'Dallas', lat: 32.77, lon: -96.79 }, { name: 'Seattle', lat: 47.60, lon: -122.33 }, { name: 'Miami', lat: 25.76, lon: -80.19 }, { name: 'Atlanta', lat: 33.74, lon: -84.38 }, { name: 'Boston', lat: 42.36, lon: -71.05 }, { name: 'Denver', lat: 39.73, lon: -104.99 }, ];
const growthCities = [ { name: 'Portland', lat: 45.51, lon: -122.67 }, { name: 'Charlotte', lat: 35.22, lon: -80.84 }, { name: 'Mexico City', lat: 19.43, lon: -99.13 }, { name: 'Berlin', lat: 52.52, lon: 13.40 }, { name: 'Mumbai', lat: 19.07, lon: 72.87 }, { name: 'Vancouver', lat: 49.28, lon: -123.12 }, ];
const nodesGroup = new THREE.Group();
const linesGroup = new THREE.Group();
const pulsesGroup = new THREE.Group();
const cityMeshes = [];
linesGroup.renderOrder = 1; pulsesGroup.renderOrder = 2; nodesGroup.renderOrder = 3;
const logoTexture = textureLoader.load('assets/logo.png');
const idleLineColor = new THREE.Color(0x00aaff);
const glowLineColor = new THREE.Color(0x0077ff);
const greenPulseColor = new THREE.Color(0x00ffaa);
function createCityNode(city) {
const material = new THREE.SpriteMaterial({ map: logoTexture, color: 0x00aaff, transparent: true, opacity: 0.7, blending: THREE.AdditiveBlending, depthWrite: false });
const sprite = new THREE.Sprite(material);
const phi = (90 - city.lat) * (Math.PI / 180);
const theta = (city.lon + 180) * (Math.PI / 180);
const radius = 2.05;
sprite.position.set(-(radius * Math.sin(phi) * Math.cos(theta)), (radius * Math.cos(phi)), (radius * Math.sin(phi) * Math.sin(theta)));
sprite.userData = {
city: city.name,
lastPulseTime: Math.random() * 5.0,
pulseInterval: 5.0 + Math.random() * 5.0,
animState: 'initial',
animProgress: 0
};
return sprite;
}
initialCities.forEach(city => {
const node = createCityNode(city);
node.scale.set(0.1, 0.1, 1);
node.userData.animState = 'stable';
nodesGroup.add(node);
cityMeshes.push(node);
});
scene.add(nodesGroup, linesGroup, pulsesGroup);
const canvas = document.createElement('canvas');
canvas.width = 64; canvas.height = 64;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
context.fillStyle = gradient;
context.fillRect(0, 0, 64, 64);
const pulseTexture = new THREE.CanvasTexture(canvas);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const raycaster = new THREE.Raycaster();
const mousePosition = new THREE.Vector2();
threeJsApp = {
appState: 'GROWTH',
isGlobeFocused: true,
timeSinceLastSpawn: 0,
spawnInterval: 3.0,
nextCityIndex: 0,
activePulses: [],
lineMeshes: [],
cityMeshes,
earth,
sloganElement,
finaleLogoContainer,
renderer,
scene,
camera,
nodesGroup,
linesGroup,
pulsesGroup,
clock: new THREE.Clock(),
controls,
raycaster,
mousePosition,
createNetworkLine,
createCityNode,
triggerPulse,
idleLineColor,
glowLineColor,
greenPulseColor,
pulseTexture,
growthCities
};
resetAnimation();
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Only add mousemove listener for custom cursor if not a mobile device
if (!isMobileDevice()) {
window.addEventListener('mousemove', (event) => {
mousePosition.x = (event.clientX / window.innerWidth) * 2 - 1;
mousePosition.y = -(event.clientY / window.innerHeight) * 2 + 1;
document.getElementById('custom-cursor').style.left = event.clientX + 'px';
document.getElementById('custom-cursor').style.top = event.clientY + 'px';
});
}
}
function resetAnimation() {
if (!threeJsApp) return;
threeJsApp.appState = 'GROWTH';
threeJsApp.isGlobeFocused = true;
threeJsApp.timeSinceLastSpawn = 0;
threeJsApp.nextCityIndex = 0;
threeJsApp.spawnInterval = 3.0;
threeJsApp.activePulses.forEach(p => threeJsApp.pulsesGroup.remove(p));
threeJsApp.activePulses = [];
threeJsApp.lineMeshes.forEach(l => threeJsApp.linesGroup.remove(l));
threeJsApp.lineMeshes = [];
for (let i = 0; i < threeJsApp.cityMeshes.length; i++) {
for (let j = i + 1; j < threeJsApp.cityMeshes.length; j++) {
const line = createNetworkLine(threeJsApp.cityMeshes[i], threeJsApp.cityMeshes[j]);
threeJsApp.linesGroup.add(line);
threeJsApp.lineMeshes.push(line);
}
}
threeJsApp.sloganElement.classList.remove('visible');
threeJsApp.finaleLogoContainer.classList.remove('visible');
const focusRotationY = 0 * (Math.PI / 180);
const focusRotationX = 30 * (Math.PI / 180);
threeJsApp.earth.rotation.set(focusRotationX, focusRotationY, 0);
threeJsApp.nodesGroup.rotation.set(focusRotationX, focusRotationY, 0);
threeJsApp.linesGroup.rotation.set(focusRotationX, focusRotationY, 0);
threeJsApp.pulsesGroup.rotation.set(focusRotationX, focusRotationY, 0);
animate();
}
function triggerPulse(line, originNode, color = threeJsApp.glowLineColor) {
if (!threeJsApp) return;
const pulseMaterial = new THREE.SpriteMaterial({ map: threeJsApp.pulseTexture, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false });
pulseMaterial.color.copy(color);
const pulse = new THREE.Sprite(pulseMaterial);
pulse.userData = {
curve: line.userData.curve,
progress: 0,
isForward: line.userData.curve.v0.equals(originNode.position),
isBlue: color.equals(threeJsApp.glowLineColor)
};
threeJsApp.pulsesGroup.add(pulse);
threeJsApp.activePulses.push(pulse);
}
function createNetworkLine(startNode, endNode) {
if (!threeJsApp) return;
const startPos = startNode.position;
const endPos = endNode.position;
const midPos = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5).normalize().multiplyScalar(2.2);
const curve = new THREE.QuadraticBezierCurve3(startPos, midPos, endPos);
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(curve.getPoints(50)), new THREE.LineBasicMaterial({ color: threeJsApp.idleLineColor, transparent: true, opacity: 0.15, blending: THREE.AdditiveBlending }));
line.userData.curve = curve; line.userData.glowIntensity = 0;
return line;
}
function animate() {
if (!threeJsApp || !document.getElementById('home-content').classList.contains('active')) {
return;
}
requestAnimationFrame(animate);
const delta = threeJsApp.clock.getDelta();
if (!threeJsApp.isGlobeFocused) {
const rotationSpeed = 0.05 * delta;
threeJsApp.earth.rotation.y += rotationSpeed;
threeJsApp.nodesGroup.rotation.y += rotationSpeed;
threeJsApp.linesGroup.rotation.y += rotationSpeed;
threeJsApp.pulsesGroup.rotation.y += rotationSpeed;
}
if (threeJsApp.appState === 'GROWTH') {
threeJsApp.timeSinceLastSpawn += delta;
if (threeJsApp.timeSinceLastSpawn > threeJsApp.spawnInterval && threeJsApp.nextCityIndex < threeJsApp.growthCities.length) {
const newCityData = threeJsApp.growthCities[threeJsApp.nextCityIndex];
const newNode = threeJsApp.createCityNode(newCityData);
newNode.scale.set(0, 0, 0);
threeJsApp.cityMeshes.forEach(existingNode => {
const newLine = createNetworkLine(newNode, existingNode);
threeJsApp.linesGroup.add(newLine);
threeJsApp.lineMeshes.push(newLine);
});
threeJsApp.nodesGroup.add(newNode);
threeJsApp.cityMeshes.push(newNode);
threeJsApp.nextCityIndex++;
threeJsApp.timeSinceLastSpawn = 0;
threeJsApp.spawnInterval = Math.max(0.5, threeJsApp.spawnInterval * 0.9);
} else if (threeJsApp.nextCityIndex >= threeJsApp.growthCities.length) {
threeJsApp.appState = 'FINALE';
}
} else if (threeJsApp.appState === 'FINALE') {
threeJsApp.appState = 'FINALE_SEQUENCE';
threeJsApp.sloganElement.textContent = "Thinking BEYOND AI";
threeJsApp.sloganElement.classList.add('visible');
setTimeout(() => {
if (threeJsApp.appState !== 'FINALE_SEQUENCE') return;
threeJsApp.finaleLogoContainer.classList.add('visible');
setTimeout(() => {
if (threeJsApp.appState !== 'FINALE_SEQUENCE') return;
threeJsApp.isGlobeFocused = false;
threeJsApp.appState = 'IDLE';
}, 2000);
}, 3000);
}
threeJsApp.raycaster.setFromCamera(threeJsApp.mousePosition, threeJsApp.camera);
const sourceNode = threeJsApp.raycaster.intersectObjects(threeJsApp.nodesGroup.children)[0]?.object || null;
threeJsApp.cityMeshes.forEach(node => {
if (node.userData.animState !== 'stable') {
node.userData.animProgress += delta * 2;
let scale = 0;
if (node.userData.animState === 'initial') { scale = THREE.MathUtils.lerp(0, 0.12, node.userData.animProgress); if (node.userData.animProgress >= 1) { node.userData.animState = 'shrink'; node.userData.animProgress = 0; }}
else if (node.userData.animState === 'shrink') { scale = THREE.MathUtils.lerp(0.12, 0.09, node.userData.animProgress); if (node.userData.animProgress >= 1) { node.userData.animState = 'growFinal'; node.userData.animProgress = 0; }}
else if (node.userData.animState === 'growFinal') { scale = THREE.MathUtils.lerp(0.09, 0.1, node.userData.animProgress); if (node.userData.animProgress >= 1) { node.userData.animState = 'stable'; }}
node.scale.set(scale, scale, 1);
}
const isSource = (node === sourceNode);
if(node.userData.animState === 'stable') {
node.scale.x = node.scale.y = THREE.MathUtils.lerp(node.scale.x, isSource ? 0.2 : 0.1, delta * 5);
}
node.userData.lastPulseTime += delta;
if (isSource) {
if (node.userData.lastPulseTime > 0.5) {
node.userData.lastPulseTime = 0;
threeJsApp.lineMeshes.forEach(line => {
if (line.userData.curve.v0.equals(node.position) || line.userData.curve.v2.equals(node.position)) {
triggerPulse(line, node);
}
});
}
} else {
if (node.userData.lastPulseTime > node.userData.pulseInterval) {
node.userData.lastPulseTime = 0;
threeJsApp.lineMeshes.forEach(line => {
if (line.userData.curve.v0.equals(node.position) || line.userData.curve.v2.equals(node.position)) {
triggerPulse(line, node, threeJsApp.greenPulseColor);
}
});
}
}
});
threeJsApp.activePulses.forEach((pulse, index) => {
pulse.userData.progress += delta * 0.4;
if (pulse.userData.progress >= 1) {
threeJsApp.pulsesGroup.remove(pulse);
threeJsApp.activePulses.splice(index, 1);
} else {
const t = pulse.userData.isForward ? pulse.userData.progress : 1 - pulse.userData.progress;
pulse.userData.curve.getPoint(t, pulse.position);
const baseScale = pulse.userData.isBlue ? 0.03 + Math.random() * 0.04 : 0.01 + Math.random() * 0.02;
const scale = baseScale * Math.sin(Math.PI * pulse.userData.progress);
pulse.scale.set(scale, scale, 1);
pulse.material.opacity = Math.sin(Math.PI * pulse.userData.progress);
}
});
threeJsApp.controls.update();
threeJsApp.renderer.render(threeJsApp.scene, threeJsApp.camera);
}
// --- UI Logic ---
function setupNavigation() {
const navLinks = document.querySelectorAll('.top-nav a[data-page]');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetPageId = link.getAttribute('data-page');
const targetPage = document.getElementById(targetPageId);
document.querySelector('.content-section.active')?.classList.remove('active');
if (targetPage) {
targetPage.classList.add('active');
if(targetPageId === 'home-content') {
if (!isThreeJsInitialized) {
initializeThreeJs();
} else {
resetAnimation();
}
}
}
});
});
}
function setupPortfolioCards() {
const cards = document.querySelectorAll('.portfolio-card');
cards.forEach(card => {
card.addEventListener('click', function() {
// Toggle the active class on the clicked card
this.classList.toggle('active');
});
});
}
function setupContactForm() {
const contactForm = document.querySelector('.contact-form');
if (!contactForm) return;
contactForm.addEventListener('submit', (e) => {
e.preventDefault();
const name = document.getElementById('name').value;
const contactInfo = document.getElementById('contact-info').value;
const message = document.getElementById('message').value;
const submitButton = contactForm.querySelector('.submit-button');
const subject = `Project Inquiry from ${name}`;
const body = `Name: ${name}\nContact Info: ${contactInfo}\n\nMessage:\n${message}`;
const mailtoLink = `mailto:contact@knightTechStudios.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
const originalButtonText = submitButton.textContent;
submitButton.textContent = 'Opening Email Client...';
window.location.href = mailtoLink;
setTimeout(() => {
submitButton.textContent = originalButtonText;
}, 3000);
});
}
// --- SCRIPT INITIALIZATION ---
try {
initializeThreeJs();
setupNavigation();
setupPortfolioCards();
setupContactForm();
} catch (error) {
console.error("A critical error occurred in the main script:", error);
}