move to vite, landing page, add more shapes
This commit is contained in:
@@ -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 );
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user