450 lines
15 KiB
Plaintext
450 lines
15 KiB
Plaintext
doctype html
|
|
html.scroll-smooth(lang=head.lang)
|
|
include src/components/Head
|
|
|
|
body.m-0.p-0.bg-nls-white.text-nls-black(class="dark:bg-nls-black dark:text-white")
|
|
main.flex.flex-col
|
|
article(itemscope, itemtype="http://schema.org/Person")
|
|
include src/components/Landingpage
|
|
include src/components/Professional
|
|
include src/components/Portfolio
|
|
include src/components/Brands
|
|
include src/components/Academia
|
|
include src/components/Footer
|
|
|
|
script(src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js")
|
|
|
|
script.
|
|
const footerEl = document.querySelector("#footer");
|
|
const footerObserver = new IntersectionObserver((payload) => {
|
|
const hasScrolledTo = payload.pop().isIntersecting;
|
|
if (hasScrolledTo && window.hasOwnProperty("umami")) {
|
|
umami.track("scrolled to", {position: "footer", id: "footer", visitDuration: getVisitDuration()});
|
|
console.debug("scrolled to footer", {visitDuration: getVisitDuration()});
|
|
}
|
|
});
|
|
footerObserver.observe(footerEl);
|
|
|
|
const sections = document.querySelectorAll("section");
|
|
const sectionObserver = (id) =>
|
|
new IntersectionObserver((payload) => {
|
|
const hasScrolledTo = payload.pop().isIntersecting;
|
|
if (hasScrolledTo && window.hasOwnProperty("umami")) {
|
|
umami.track("scrolled to", {position: "section", id, visitDuration: getVisitDuration()});
|
|
console.debug("scrolled to section with id", {id, visitDuation: getVisitDuration()});
|
|
}
|
|
});
|
|
sections.forEach((section) => sectionObserver(section.id).observe(section));
|
|
|
|
// carousel.js - Just the essentials
|
|
function carouselPrev(carouselId) {
|
|
const track = document.querySelector(`#${carouselId} .carousel-track`);
|
|
const slideWidth = track.querySelector(".carousel-slide").offsetWidth;
|
|
track.scrollBy({left: -slideWidth, behavior: "smooth"});
|
|
updateCarouselDots(carouselId);
|
|
}
|
|
|
|
function carouselNext(carouselId) {
|
|
const track = document.querySelector(`#${carouselId} .carousel-track`);
|
|
const slideWidth = track.querySelector(".carousel-slide").offsetWidth;
|
|
track.scrollBy({left: slideWidth, behavior: "smooth"});
|
|
updateCarouselDots(carouselId);
|
|
}
|
|
|
|
function carouselGoTo(carouselId, index) {
|
|
const track = document.querySelector(`#${carouselId} .carousel-track`);
|
|
const slideWidth = track.querySelector(".carousel-slide").offsetWidth;
|
|
track.scrollTo({left: slideWidth * index, behavior: "smooth"});
|
|
updateCarouselDots(carouselId);
|
|
}
|
|
|
|
function updateCarouselDots(carouselId) {
|
|
// Debounced dot state update
|
|
clearTimeout(window.carouselDotTimeout);
|
|
window.carouselDotTimeout = setTimeout(() => {
|
|
const track = document.querySelector(`#${carouselId} .carousel-track`);
|
|
const dots = document.querySelectorAll(`#${carouselId} .carousel-dot`);
|
|
const slideWidth = track.querySelector(".carousel-slide").offsetWidth;
|
|
const currentIndex = Math.round(track.scrollLeft / slideWidth);
|
|
|
|
dots.forEach((dot, index) => {
|
|
if (index === currentIndex) {
|
|
dot.className = dot.className.replace(/bg-gray-\d+/, "bg-nls-orange");
|
|
} else {
|
|
dot.className = dot.className.replace(/bg-nls-\w+/, "bg-gray-300 dark:bg-gray-600");
|
|
}
|
|
});
|
|
}, 100);
|
|
}
|
|
|
|
// Auto-initialize all carousels
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
document.querySelectorAll(".carousel-track").forEach((track) => {
|
|
track.addEventListener(
|
|
"scroll",
|
|
() => {
|
|
const carouselId = track.closest(".carousel-container").id;
|
|
updateCarouselDots(carouselId);
|
|
},
|
|
{passive: true},
|
|
);
|
|
});
|
|
});
|
|
|
|
// Guilloche Curtain
|
|
class GuillocheCurtain {
|
|
constructor(canvas, options = {}) {
|
|
this.canvas = canvas;
|
|
this.containerHeight = window.innerHeight;
|
|
this.containerWidth = window.innerWidth;
|
|
this.options = {
|
|
// Visual parameters
|
|
spread: 0.25,
|
|
lineWidth: 0.8,
|
|
opacity: 0.6,
|
|
splineCount: 40,
|
|
groupCount: 6,
|
|
|
|
// Animation parameters
|
|
animationSpeed: 0.005,
|
|
nodeCount: 7,
|
|
nodeSpeed: {min: 0.3, max: 0.8},
|
|
|
|
// Color parameters
|
|
hueBase: 0.55,
|
|
hueVariation: 0.1,
|
|
saturation: {min: 0.3, max: 0.7},
|
|
lightness: {min: 0.25, max: 0.65},
|
|
|
|
// Spline parameters
|
|
segments: 100,
|
|
offset: 5,
|
|
startOffset: 0.0,
|
|
endOffset: 0.0,
|
|
offsetTransition: "smooth",
|
|
canvasExtension: 1.0,
|
|
|
|
// Wave complexity
|
|
waveFrequencies: [1, 2, 3, 5, 8, 13],
|
|
waveAmplitudes: [0.4, 0.25, 0.15, 0.1, 0.02, 0.001],
|
|
|
|
...options,
|
|
};
|
|
|
|
// Interaction state
|
|
this.mouse = {
|
|
x: 0,
|
|
y: 0,
|
|
normalizedX: 0.5, // 0-1 range
|
|
normalizedY: 0.5, // 0-1 range
|
|
};
|
|
|
|
// Dynamic parameters that respond to interaction
|
|
this.dynamic = {
|
|
hueBase: this.options.hueBase,
|
|
saturation: this.options.saturation,
|
|
lightness: this.options.lightness,
|
|
};
|
|
|
|
// Smoothing for parameter transitions
|
|
this.smoothing = {
|
|
parameterLerp: 0.05,
|
|
};
|
|
|
|
this.scene = new THREE.Scene();
|
|
this.camera = new THREE.OrthographicCamera(-2, 2, 1.5, -1.5, 0.1, 1000);
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
alpha: true,
|
|
antialias: true,
|
|
});
|
|
|
|
this.time = 0;
|
|
this.splineGroups = [];
|
|
this.nodes = [];
|
|
|
|
this.init();
|
|
this.setupInteraction();
|
|
this.createNodes();
|
|
this.createSplines();
|
|
this.animate();
|
|
}
|
|
|
|
init() {
|
|
this.renderer.setSize(this.containerWidth, this.containerHeight);
|
|
this.renderer.setClearColor(0x000000, 0);
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.camera.position.z = 1;
|
|
}
|
|
|
|
setupInteraction() {
|
|
const updateMousePosition = (clientX, clientY) => {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const x = clientX - rect.left;
|
|
const y = clientY - rect.top;
|
|
|
|
// Update current position
|
|
this.mouse.x = x;
|
|
this.mouse.y = y;
|
|
|
|
// Normalize to 0-1 range
|
|
this.mouse.normalizedX = Math.max(0, Math.min(1, x / this.canvas.clientWidth));
|
|
this.mouse.normalizedY = Math.max(0, Math.min(1, y / this.canvas.clientHeight));
|
|
};
|
|
|
|
// Mouse events
|
|
this.canvas.addEventListener("mousemove", (e) => {
|
|
updateMousePosition(e.clientX, e.clientY);
|
|
});
|
|
|
|
// Mouse enter/leave for graceful transitions
|
|
this.canvas.addEventListener("mouseenter", () => {
|
|
// Smooth entrance - no abrupt changes needed
|
|
});
|
|
|
|
this.canvas.addEventListener("mouseleave", () => {
|
|
// Gradually return to center when mouse leaves
|
|
this.mouse.normalizedX = 0.5;
|
|
this.mouse.normalizedY = 0.5;
|
|
});
|
|
}
|
|
|
|
updateDynamicParameters() {
|
|
const {normalizedX, normalizedY} = this.mouse;
|
|
const {parameterLerp} = this.smoothing;
|
|
|
|
// Color temperature interaction (Implementation 6)
|
|
// X-axis: Cool blues (left) → Warm oranges (right)
|
|
// Y-axis: Affects saturation and lightness
|
|
|
|
// Hue mapping: 0.6 (blue) to 0.1 (orange) based on X position
|
|
const targetHue = 0.6 - normalizedX * 0.5; // Maps 0.6 → 0.1
|
|
this.dynamic.hueBase = this.dynamic.hueBase * (1 - parameterLerp) + targetHue * parameterLerp;
|
|
|
|
// Y position affects saturation (top = high saturation, bottom = low)
|
|
const targetSatMin = 0.2 + (1 - normalizedY) * 0.4; // 0.2-0.6 range
|
|
const targetSatMax = 0.5 + (1 - normalizedY) * 0.4; // 0.5-0.9 range
|
|
|
|
this.dynamic.saturation.min = this.dynamic.saturation.min * (1 - parameterLerp) + targetSatMin * parameterLerp;
|
|
this.dynamic.saturation.max = this.dynamic.saturation.max * (1 - parameterLerp) + targetSatMax * parameterLerp;
|
|
|
|
// Y position affects lightness (top = bright, bottom = dark)
|
|
const targetLightMin = 0.15 + (1 - normalizedY) * 0.3; // 0.15-0.45 range
|
|
const targetLightMax = 0.45 + (1 - normalizedY) * 0.4; // 0.45-0.85 range
|
|
|
|
this.dynamic.lightness.min = this.dynamic.lightness.min * (1 - parameterLerp) + targetLightMin * parameterLerp;
|
|
this.dynamic.lightness.max = this.dynamic.lightness.max * (1 - parameterLerp) + targetLightMax * parameterLerp;
|
|
}
|
|
|
|
createNodes() {
|
|
for (let i = 0; i < this.options.nodeCount; i++) {
|
|
this.nodes.push({
|
|
x: (Math.random() - 0.5) * 3,
|
|
y: (Math.random() - 0.5) * 2,
|
|
phase: Math.random() * Math.PI * 2,
|
|
radius: 0.2 + Math.random() * 0.3,
|
|
speed: this.options.nodeSpeed.min + Math.random() * (this.options.nodeSpeed.max - this.options.nodeSpeed.min),
|
|
});
|
|
}
|
|
}
|
|
|
|
getOffsetTransition(t) {
|
|
const {startOffset, endOffset, offsetTransition} = this.options;
|
|
|
|
switch (offsetTransition) {
|
|
case "linear":
|
|
return startOffset + (endOffset - startOffset) * t;
|
|
case "sine":
|
|
return startOffset + (endOffset - startOffset) * Math.sin(t * Math.PI * 0.5);
|
|
case "smooth":
|
|
default:
|
|
const t2 = t * t;
|
|
const t3 = t2 * t;
|
|
return startOffset + (endOffset - startOffset) * (3 * t2 - 2 * t3);
|
|
}
|
|
}
|
|
|
|
createSplines() {
|
|
for (let groupIndex = 0; groupIndex < this.options.groupCount; groupIndex++) {
|
|
const group = new THREE.Group();
|
|
const splines = [];
|
|
|
|
for (let i = 0; i < this.options.splineCount; i++) {
|
|
const t = i / (this.options.splineCount - 1);
|
|
const normalizedOffset = (t - 0.2) * this.options.offset;
|
|
const offset = normalizedOffset * this.options.spread;
|
|
|
|
const spline = this.createSingleSpline(offset, groupIndex, i);
|
|
splines.push({line: spline, offset});
|
|
group.add(spline);
|
|
}
|
|
|
|
this.splineGroups.push({
|
|
group,
|
|
splines,
|
|
baseOffset: groupIndex * 0.6,
|
|
phase: groupIndex * 0.8,
|
|
});
|
|
this.scene.add(group);
|
|
}
|
|
}
|
|
|
|
createSingleSpline(offset, groupIndex, splineIndex) {
|
|
const points = this.generateSplinePoints(offset, groupIndex, splineIndex, 0);
|
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
|
|
// Enhanced color calculation using dynamic parameters
|
|
const normalizedGroup = groupIndex / (this.options.groupCount - 1);
|
|
const normalizedSpline = splineIndex / (this.options.splineCount - 1);
|
|
|
|
const hue = this.dynamic.hueBase + normalizedGroup * this.options.hueVariation + Math.sin(normalizedSpline * Math.PI * 2) * 0.05;
|
|
|
|
const saturation = this.dynamic.saturation.min + (this.dynamic.saturation.max - this.dynamic.saturation.min) * (1 - Math.abs(offset) / this.options.spread);
|
|
|
|
const lightness = this.dynamic.lightness.min + (this.dynamic.lightness.max - this.dynamic.lightness.min) * (1 - (Math.abs(offset) / this.options.spread) * 0.6);
|
|
|
|
const material = new THREE.LineBasicMaterial({
|
|
color: new THREE.Color().setHSL(hue, saturation, lightness),
|
|
transparent: true,
|
|
opacity: (this.options.opacity / ((this.options.splineCount - splineIndex) * 0.1)) * (1 - (Math.abs(offset) / this.options.spread) * 0.1),
|
|
linewidth: this.options.lineWidth,
|
|
});
|
|
|
|
return new THREE.Line(geometry, material);
|
|
}
|
|
|
|
generateSplinePoints(offset, groupIndex, splineIndex, timeOffset = 0) {
|
|
const points = [];
|
|
const {segments} = this.options;
|
|
|
|
for (let i = 0; i <= segments; i++) {
|
|
const t = i / segments;
|
|
const baseX = -(2 + this.options.canvasExtension) + t * (4 + 2 * this.options.canvasExtension);
|
|
|
|
let x = baseX;
|
|
let y = 0;
|
|
|
|
// Apply node influences with original wave amplitudes
|
|
this.nodes.forEach((node, nodeIndex) => {
|
|
const distance = Math.abs(baseX - node.x);
|
|
const influence = Math.exp(-distance * distance * 2);
|
|
|
|
const animatedPhase = node.phase + (this.time + timeOffset) * node.speed + groupIndex * 0.2 + splineIndex * 0.01;
|
|
|
|
const waveAmplitude = node.radius * influence;
|
|
|
|
// Use original static amplitudes
|
|
this.options.waveFrequencies.forEach((freq, freqIndex) => {
|
|
const amplitude = this.options.waveAmplitudes[freqIndex] || 0.1;
|
|
const phaseMultiplier = 1 + freqIndex * 0.3;
|
|
|
|
y += Math.sin(t * Math.PI * freq + animatedPhase * phaseMultiplier) * waveAmplitude * amplitude;
|
|
});
|
|
|
|
x += Math.cos(t * Math.PI * 6 + animatedPhase * 0.9) * waveAmplitude * 0.1;
|
|
});
|
|
|
|
const transitionOffset = this.getOffsetTransition(t);
|
|
y += offset * (1 + Math.sin(t * Math.PI * 2 + (this.time + timeOffset) * 0.5) * 0.3) + transitionOffset;
|
|
|
|
points.push(new THREE.Vector3(x, y, 0));
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
updateSplineGeometry(splineData, groupIndex, splineIndex) {
|
|
const {line, offset} = splineData;
|
|
const points = this.generateSplinePoints(offset, groupIndex, splineIndex);
|
|
line.geometry.setFromPoints(points);
|
|
|
|
// Update material colors based on dynamic parameters
|
|
const normalizedGroup = groupIndex / (this.options.groupCount - 1);
|
|
const normalizedSpline = splineIndex / (this.options.splineCount - 1);
|
|
|
|
const hue = this.dynamic.hueBase + normalizedGroup * this.options.hueVariation + Math.sin(normalizedSpline * Math.PI * 2) * 0.05;
|
|
|
|
const saturation = this.dynamic.saturation.min + (this.dynamic.saturation.max - this.dynamic.saturation.min) * (1 - Math.abs(offset) / this.options.spread);
|
|
|
|
const lightness = this.dynamic.lightness.min + (this.dynamic.lightness.max - this.dynamic.lightness.min) * (1 - (Math.abs(offset) / this.options.spread) * 0.6);
|
|
|
|
line.material.color.setHSL(hue, saturation, lightness);
|
|
}
|
|
|
|
animate() {
|
|
// Update dynamic parameters based on mouse interaction
|
|
this.updateDynamicParameters();
|
|
|
|
// Use original static animation speed
|
|
this.time += this.options.animationSpeed;
|
|
|
|
// Animate nodes with boundary constraints
|
|
this.nodes.forEach((node, index) => {
|
|
node.x += Math.sin(this.time * 0.3 + index) * 0.002;
|
|
node.y += Math.cos(this.time * 0.4 + index * 1.3) * 0.002;
|
|
|
|
node.x = Math.max(-1.5, Math.min(1.5, node.x));
|
|
node.y = Math.max(-1, Math.min(1, node.y));
|
|
});
|
|
|
|
// Update spline groups
|
|
this.splineGroups.forEach((splineGroup, groupIndex) => {
|
|
const {group, splines, phase} = splineGroup;
|
|
|
|
group.position.y = Math.sin(this.time * 0.2 + phase) * 0.08;
|
|
group.position.x = Math.cos(this.time * 0.15 + phase) * 0.04;
|
|
group.rotation.z = Math.sin(this.time * 0.1 + phase) * 0.015;
|
|
|
|
splines.forEach((splineData, splineIndex) => {
|
|
this.updateSplineGeometry(splineData, groupIndex, splineIndex);
|
|
});
|
|
});
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
requestAnimationFrame(() => this.animate());
|
|
}
|
|
|
|
updateOptions(newOptions) {
|
|
Object.assign(this.options, newOptions);
|
|
this.rebuild();
|
|
}
|
|
|
|
rebuild() {
|
|
this.splineGroups.forEach(({group}) => {
|
|
group.children.forEach((child) => {
|
|
child.geometry.dispose();
|
|
child.material.dispose();
|
|
});
|
|
this.scene.remove(group);
|
|
});
|
|
|
|
this.splineGroups = [];
|
|
this.createSplines();
|
|
}
|
|
|
|
resize() {
|
|
console.log("Resizing canvas and updating camera");
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
this.renderer.setSize(width, height);
|
|
|
|
const aspect = width / height;
|
|
this.camera.left = -2 * aspect;
|
|
this.camera.right = 2 * aspect;
|
|
this.camera.top = 1.5;
|
|
this.camera.bottom = -1.5;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
destroy() {
|
|
this.splineGroups.forEach(({group}) => {
|
|
group.children.forEach((child) => {
|
|
child.geometry.dispose();
|
|
child.material.dispose();
|
|
});
|
|
});
|
|
this.renderer.dispose();
|
|
}
|
|
}
|