move to vite, landing page, add more shapes

This commit is contained in:
Vincent van der Wal
2026-07-19 10:04:08 +02:00
parent 25595d8281
commit f724cbff6d
25 changed files with 2841 additions and 5017 deletions
+51
View File
@@ -0,0 +1,51 @@
import { currentTheme, toggleTheme } from './theme.js';
import { EXIT_MS } from './transit.js';
// Shared page chrome: theme attribute for the CSS, fade transitions between
// pages, and the dark/light toggle. Imported for its side effects by every page.
document.documentElement.dataset.theme = currentTheme();
const FADE_MS = 350;
window.addEventListener( 'DOMContentLoaded', () => {
// the overlay lives in the HTML templates so it covers the very first paint;
// pages without one (shouldn't happen) still get a working fallback
let fade = document.querySelector( '.fade' );
if ( ! fade ) {
fade = document.createElement( 'div' );
fade.className = 'fade';
document.body.appendChild( fade );
}
// double rAF so the browser commits the opaque state before transitioning
requestAnimationFrame( () => requestAnimationFrame( () => fade.classList.add( 'done' ) ) );
// fly the shapes out first, fade through the background for the tail of the
// flight, then navigate
const fadeTo = ( action ) => {
window.dispatchEvent( new Event( 'thrive:exit' ) );
setTimeout( () => fade.classList.remove( 'done' ), EXIT_MS - FADE_MS + 100 );
setTimeout( action, EXIT_MS + 100 );
};
document.addEventListener( 'click', ( event ) => {
const link = event.target.closest( 'a[href]' );
if ( ! link || link.origin !== location.origin ) return;
event.preventDefault();
fadeTo( () => { location.href = link.href; } );
} );
const button = document.createElement( 'button' );
button.className = 'theme-toggle';
button.textContent = currentTheme() === 'dark' ? '☀' : '☾';
button.title = 'toggle dark / light';
button.addEventListener( 'click', () => {
// the scenes build their materials once at startup, so switching theme
// reloads the page - the fade makes that read as a transition
fadeTo( () => {
toggleTheme();
location.reload();
} );
} );
document.body.appendChild( button );
} );
+46
View File
@@ -0,0 +1,46 @@
import * as THREE from 'three';
// WebGL draws lines at 1px regardless of linewidth, so shape edges are built
// from real cylinder tubes instead - same trick as the Metatron page
export function makeEdgeTubes( geometry, color, tubeRadius = 0.03 ) {
const edges = new THREE.EdgesGeometry( geometry );
const positions = edges.attributes.position;
const group = new THREE.Group();
const material = new THREE.MeshBasicMaterial( { color } );
const up = new THREE.Vector3( 0, 1, 0 );
const start = new THREE.Vector3();
const end = new THREE.Vector3();
for ( let i = 0; i < positions.count; i += 2 ) {
start.fromBufferAttribute( positions, i );
end.fromBufferAttribute( positions, i + 1 );
const direction = new THREE.Vector3().subVectors( end, start );
const length = direction.length();
const cylinder = new THREE.CylinderGeometry( tubeRadius, tubeRadius, length, 12 );
cylinder.translate( 0, length / 2, 0 );
const tube = new THREE.Mesh( cylinder, material );
tube.position.copy( start );
tube.quaternion.setFromUnitVectors( up, direction.normalize() );
tube.castShadow = true;
group.add( tube );
}
return group;
}
// A straight tube between two points, for edges that don't come from a geometry
export function makeTube( a, b, material, tubeRadius = 0.03 ) {
const direction = new THREE.Vector3().subVectors( b, a );
const length = direction.length();
const cylinder = new THREE.CylinderGeometry( tubeRadius, tubeRadius, length, 12 );
cylinder.translate( 0, length / 2, 0 );
const tube = new THREE.Mesh( cylinder, material );
tube.position.copy( a );
tube.quaternion.setFromUnitVectors( new THREE.Vector3( 0, 1, 0 ), direction.normalize() );
tube.castShadow = true;
return tube;
}
+88
View File
@@ -0,0 +1,88 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './chrome.js';
import { sceneColors } from './theme.js';
import { bindWorld } from './transit.js';
// Shared scene setup for the exploration pages: the landing page gradient shows
// through a transparent canvas, a side light throws a light orange shadow onto
// an invisible wall, and every shape lives in a `world` group that flies in and
// out on page transitions. Returns hooks instead of globals so pages stay small.
export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
const colors = sceneColors();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 500 );
camera.position.set( 10, 4, 12 );
const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
document.body.appendChild( renderer.domElement );
window.addEventListener( 'resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
} );
const controls = new OrbitControls( camera, renderer.domElement );
controls.target.set( ...target );
// dark mode gets a soft warm ambient so unlit faces keep their colour
if ( colors.dark ) scene.add( new THREE.AmbientLight( colors.light, 0.9 ) );
// legacy lighting was removed in three r155: intensities need the old value times pi
const spotLight = new THREE.DirectionalLight( colors.light, 1.5 * Math.PI );
spotLight.position.set( 10, 0, 0 );
spotLight.castShadow = true;
spotLight.shadow.mapSize.width = 2048;
spotLight.shadow.mapSize.height = 2048;
spotLight.shadow.camera.far = 21;
spotLight.shadow.camera.left = -10;
spotLight.shadow.camera.right = 10;
spotLight.shadow.camera.top = 10;
spotLight.shadow.camera.bottom = -10;
scene.add( spotLight );
// the wall itself is invisible: only the shadow "projection" shows, in
// light orange, floating on the gradient; front side only so the shapes
// stay fully visible when orbiting behind it
const planeGeometry = new THREE.PlaneGeometry( 20, 20, 10, 10 );
const planeMaterial = new THREE.ShadowMaterial( { color: colors.shadow, opacity: 0.9 } );
const plane = new THREE.Mesh( planeGeometry, planeMaterial );
plane.rotation.y = Math.PI / 2;
plane.position.x = -10;
plane.receiveShadow = true;
scene.add( plane );
// pages add their shapes to this group; transitions scale it in and out
const world = new THREE.Group();
scene.add( world );
const updateWorld = bindWorld( world );
const updates = [];
function animate() {
requestAnimationFrame( animate );
const time = performance.now() / 1000;
for ( const update of updates ) update( time );
updateWorld();
controls.update();
renderer.render( scene, camera );
}
animate();
return { scene: world, camera, renderer, controls, colors, onUpdate: ( fn ) => updates.push( fn ) };
}
+34
View File
@@ -0,0 +1,34 @@
const KEY = 'thrive-theme';
export function currentTheme() {
try {
return localStorage.getItem( KEY ) === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
export function toggleTheme() {
try {
localStorage.setItem( KEY, currentTheme() === 'dark' ? 'light' : 'dark' );
} catch {
// private mode: theme just won't persist
}
}
// the landing page palette, used 1:1 in the scenes; the background itself is the
// landing CSS gradient showing through a transparent canvas (see styles.scss)
const fills = {
sphere: 0xf44f04, // orange
fire: 0xf44f04, // orange
earth: 0x2a1204, // deep brown
air: 0xffd4b3, // pale tint
water: 0xff9a5c, // light orange
aether: 0xf5e9de, // cream
};
export function sceneColors() {
return currentTheme() === 'dark'
? { dark: true, light: 0xffb37a, line: 0xf5e9de, shadow: 0xff9a5c, fills }
: { dark: false, light: 0xffb37a, line: 0x1b0a02, shadow: 0xf44f04, fills };
}
+31
View File
@@ -0,0 +1,31 @@
// Shape transitions between pages, styled as one continuous forward flight:
// entering shapes grow inward from the distance into place, leaving shapes keep
// growing outward past the camera. The world keeps casting shadows while it
// scales, so the projection on the wall travels with it.
export const EXIT_MS = 600;
const ENTER_MS = 900;
const easeOutCubic = ( t ) => 1 - Math.pow( 1 - t, 3 );
const easeInCubic = ( t ) => t * t * t;
// wraps a THREE.Group holding all of a page's shapes; call the returned
// function every frame
export function bindWorld( world ) {
let mode = 'enter';
let start = performance.now();
window.addEventListener( 'thrive:exit', () => {
mode = 'exit';
start = performance.now();
} );
return function update() {
if ( mode === 'enter' ) {
const k = Math.min( 1, ( performance.now() - start ) / ENTER_MS );
world.scale.setScalar( Math.max( 0.001, easeOutCubic( k ) ) );
} else {
const k = Math.min( 1, ( performance.now() - start ) / EXIT_MS );
world.scale.setScalar( 1 + easeInCubic( k ) * 7 );
}
};
}