page transition, optimize lighting
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Preserves the orbit camera across the reload that a theme switch triggers.
|
||||
// chrome.js fires thrive:store-camera right before reloading; the saved pose is
|
||||
// restored (and cleared) on the next load of the same page, so regular
|
||||
// navigation still gets the default framing.
|
||||
const KEY = `thrive-camera:${location.pathname}`;
|
||||
|
||||
export function bindCameraMemory( camera, controls ) {
|
||||
window.addEventListener( 'thrive:store-camera', () => {
|
||||
try {
|
||||
sessionStorage.setItem( KEY, JSON.stringify( {
|
||||
position: camera.position.toArray(),
|
||||
target: controls.target.toArray(),
|
||||
} ) );
|
||||
} catch {
|
||||
// storage unavailable: the camera just resets
|
||||
}
|
||||
} );
|
||||
|
||||
let saved = null;
|
||||
try {
|
||||
saved = JSON.parse( sessionStorage.getItem( KEY ) );
|
||||
sessionStorage.removeItem( KEY );
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
if ( saved ) {
|
||||
camera.position.fromArray( saved.position );
|
||||
controls.target.fromArray( saved.target );
|
||||
controls.update();
|
||||
}
|
||||
}
|
||||
+20
-25
@@ -1,38 +1,30 @@
|
||||
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.
|
||||
// Shared page chrome: theme attribute for the CSS, page transitions, and the
|
||||
// dark/light toggle. Imported for its side effects by every page.
|
||||
//
|
||||
// Navigation choreography: the shapes fly out first (thrive:exit), then the
|
||||
// browser's cross-document view transition (see @view-transition in the CSS)
|
||||
// crossfades the last frame into the next page, whose shapes fly in. No
|
||||
// blocking overlay - pages blend into each other.
|
||||
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 ) => {
|
||||
// shapes fly out and the page content fades (html.leaving) at the same
|
||||
// time; navigation happens mid-flight so the document crossfade blends
|
||||
// moving frames instead of an empty pause
|
||||
const leaveTo = ( action ) => {
|
||||
window.dispatchEvent( new Event( 'thrive:exit' ) );
|
||||
setTimeout( () => fade.classList.remove( 'done' ), EXIT_MS - FADE_MS + 100 );
|
||||
setTimeout( action, EXIT_MS + 100 );
|
||||
document.documentElement.classList.add( 'leaving' );
|
||||
setTimeout( action, EXIT_MS * 0.75 );
|
||||
};
|
||||
|
||||
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; } );
|
||||
leaveTo( () => { location.href = link.href; } );
|
||||
} );
|
||||
|
||||
const button = document.createElement( 'button' );
|
||||
@@ -41,11 +33,14 @@ window.addEventListener( 'DOMContentLoaded', () => {
|
||||
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( () => {
|
||||
// reloads the page behind the same fly-out + crossfade; the camera pose
|
||||
// is stashed so the reload comes back to the same view
|
||||
window.dispatchEvent( new Event( 'thrive:store-camera' ) );
|
||||
leaveTo( () => {
|
||||
toggleTheme();
|
||||
location.reload();
|
||||
} );
|
||||
} );
|
||||
document.body.appendChild( button );
|
||||
// scene pages carry the toggle in the top nav; the landing gets it floating
|
||||
( document.querySelector( '.page-nav' ) || document.body ).appendChild( button );
|
||||
} );
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Keeps the gradient's bright spot gravitating toward the projection wall as
|
||||
// the camera orbits: the wall centre (x = -10 in every scene) is projected into
|
||||
// screen space each frame and eased into the CSS gradient position.
|
||||
const WALL_CENTRE = new THREE.Vector3( -10, 0, 0 );
|
||||
|
||||
export function bindGlow( camera ) {
|
||||
const projected = new THREE.Vector3();
|
||||
let x = 12;
|
||||
let y = 50;
|
||||
let writtenX = null;
|
||||
let writtenY = null;
|
||||
|
||||
return function update() {
|
||||
projected.copy( WALL_CENTRE ).project( camera );
|
||||
|
||||
// when the wall goes behind the camera the projection flips sides;
|
||||
// leave the glow where it last was until the wall swings back
|
||||
if ( projected.z < 1 ) {
|
||||
const targetX = THREE.MathUtils.clamp( ( projected.x + 1 ) * 50, -40, 140 );
|
||||
const targetY = THREE.MathUtils.clamp( ( 1 - projected.y ) * 50, -40, 140 );
|
||||
x += ( targetX - x ) * 0.08;
|
||||
y += ( targetY - y ) * 0.08;
|
||||
}
|
||||
|
||||
// writing the property repaints the whole gradient, so only touch the
|
||||
// DOM while the glow is actually moving
|
||||
if ( writtenX !== null && Math.abs( x - writtenX ) < 0.05 && Math.abs( y - writtenY ) < 0.05 ) return;
|
||||
writtenX = x;
|
||||
writtenY = y;
|
||||
document.body.style.setProperty( '--glow-x', `${x.toFixed( 2 )}%` );
|
||||
document.body.style.setProperty( '--glow-y', `${y.toFixed( 2 )}%` );
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import './chrome.js';
|
||||
import { sceneColors } from './theme.js';
|
||||
import { bindWorld } from './transit.js';
|
||||
import { bindGlow } from './glow.js';
|
||||
import { bindCameraMemory } from './cameraMemory.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
|
||||
@@ -35,6 +37,8 @@ export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
|
||||
|
||||
const controls = new OrbitControls( camera, renderer.domElement );
|
||||
controls.target.set( ...target );
|
||||
bindCameraMemory( camera, controls );
|
||||
const updateGlow = bindGlow( camera );
|
||||
|
||||
// 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 ) );
|
||||
@@ -78,6 +82,7 @@ export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
|
||||
for ( const update of updates ) update( time );
|
||||
|
||||
updateWorld();
|
||||
updateGlow();
|
||||
controls.update();
|
||||
renderer.render( scene, camera );
|
||||
}
|
||||
|
||||
+2
-2
@@ -29,6 +29,6 @@ const fills = {
|
||||
|
||||
export function sceneColors() {
|
||||
return currentTheme() === 'dark'
|
||||
? { dark: true, light: 0xffb37a, line: 0xf5e9de, shadow: 0xff9a5c, fills }
|
||||
: { dark: false, light: 0xffb37a, line: 0x1b0a02, shadow: 0xf44f04, fills };
|
||||
? { dark: true, light: 0xffb37a, line: 0x1b0a02, shadow: 0xffb385, fills }
|
||||
: { dark: false, light: 0xffb37a, line: 0xffffff, shadow: 0xd94403, fills };
|
||||
}
|
||||
|
||||
+5
-4
@@ -9,8 +9,9 @@ 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 ) {
|
||||
// function every frame. baseScale is the group's resting scale, for pages whose
|
||||
// raw geometry is smaller than the others.
|
||||
export function bindWorld( world, baseScale = 1 ) {
|
||||
let mode = 'enter';
|
||||
let start = performance.now();
|
||||
|
||||
@@ -22,10 +23,10 @@ export function bindWorld( world ) {
|
||||
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 ) ) );
|
||||
world.scale.setScalar( baseScale * Math.max( 0.001, easeOutCubic( k ) ) );
|
||||
} else {
|
||||
const k = Math.min( 1, ( performance.now() - start ) / EXIT_MS );
|
||||
world.scale.setScalar( 1 + easeInCubic( k ) * 7 );
|
||||
world.scale.setScalar( baseScale * ( 1 + easeInCubic( k ) * 7 ) );
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user