Files
questions/hero.js
T

588 lines
21 KiB
JavaScript
Raw Normal View History

// Westra Engineering's hero: the name, parted by the scroll, over a
// lightning line drawn full-screen on a sticky canvas.
//
// Content-owned: portal knows only site.yaml's `hero: {kind: module,
// module: hero.js}` and the contract - `mount(container) -> handle`
// with `stop()` - and serves this file same-origin at /site/hero.js.
// The module owns its look. The stylesheet it injects below restyles
// portal's .hero-module header the way uhhm's hero does: sticky at
// the viewport top for the whole page, so the line keeps running
// behind the translucent cards that scroll over it. On the landing
// page the name drawn here is the wordmark, so the image one is
// hidden there.
//
// Scroll is the timeline. Over the first SPLIT_RANGE of a viewport
// "Westra" slides left and "Engineering" right, each until it has
// left the screen, the way the logo already sets them: Westra to the
// left, Engineering to the right. The logo's ribbon and tagline are
// not drawn: the line takes the ribbon's place under the two words.
// The transform follows the scroll
// position through a short exponential lag (TAU_MS), so a wheel notch
// reads as a movement, not a jump; with reduced motion asked for it
// follows exactly. The copy under the name fades over the same range
// so it does not ghost through the cards.
//
// The line is /usr/share/gpupaper/shaders/light_line.frag, Brandon
// Fogerty's "Lightning" in glslsandbox conventions (time, mouse,
// resolution), the one gpupaper draws on the desktop. It is embedded
// verbatim; the two edits made to it at compile time are listed and
// explained at FRAG_EDITS. WebGL 1 is enough for it and is everywhere
// (a CSS paint worklet is not: no shaders, no per-pixel access, and
// Chromium only). Without WebGL the name and the scroll still work
// over plain paper.
const STYLE_ID = 'westra-hero-style';
// Viewport heights of scroll over which the name parts completely.
const SPLIT_RANGE = 0.6;
// How far the transform lags the scroll, as the time constant of an
// exponential approach: after one TAU it has closed 63% of the gap.
const TAU_MS = 140;
// The shader is cheap per pixel but runs for every pixel, three times
// over (its loop), and a 4K screen at a device pixel ratio of 2 is 33
// million pixels a frame: choppy on an integrated GPU. The line is a
// glow, so it is drawn at no more than this many pixels and the
// browser scales the canvas up to the screen; at 1080p that is about
// 0.7 of the screen's pixels, at 4K about a third.
const MAX_PIXELS = 1.2e6;
// /usr/share/gpupaper/shaders/light_line.frag, as is.
const LIGHT_LINE_FRAG = `// Lightning
// By: Brandon Fogerty
// bfogerty at gmail dot com
// xdpixel.com
#ifdef GL_ES
precision mediump float;
#endif
uniform float time;
uniform vec2 mouse;
uniform vec2 resolution;
float Hash( vec2 p)
{
vec3 p2 = vec3(p.xy,2.0);
return fract(sin(dot(p2,vec3(27.1,20.7, 2.4)))*0.5453123);
}
float noise(in vec2 p)
{
vec2 i = floor(p);
vec2 f = fract(p);
f *= f * (3.0-2.0*f);
return mix(mix(Hash(i + vec2(0.,0.)), Hash(i + vec2(1.,0.)),f.x),
mix(Hash(i + vec2(0.,1.)), Hash(i + vec2(1.,1.)),f.x),
f.y);
}
float fbm(vec2 p)
{
float v = 0.0;
v += noise(p*1.0) * .5;
v += noise(p*2.) * .25;
v += noise(p*4.) * .15;
return v;
}
void main( void )
{
vec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0;
vec2 center = vec2(0.0, 0.0);
float angle = time * 0.4;
//uv.x = cos(angle) * (uv.x - center.x) - sin(angle) * (uv.y - center.y) + center.x;
//uv.y = sin(angle) * (uv.x - center.y) + cos(angle) * (uv.y - center.y) + center.y;
vec3 finalColor = vec3( 0.0 );
for( int i=0; i < 3; ++i )
{
float t = abs( 1.4 / (sin(uv.y + fbm( uv + time * 0.04 )) * 100.0) );
finalColor += t * vec3( 2.9, 1.5, .25 );
finalColor = vec3(t);
}
gl_FragColor = vec4( finalColor, 1.0 );
}
`;
// The edits, as exact replacements, so the source above stays a copy
// of the file. Each must match or the line is not drawn at all.
//
// 1. mediump → highp where the GPU has it: the hash takes sin() of a
// large dot product, which at 16-bit precision bands into stripes
// on phones. The two uniforms the second edit uses go in here too,
// after the precision statement, where GLSL ES wants them.
// 2. One line after uv is set: uv.y is shifted by `shift`, so the line
// (which the shader draws where uv.y + fbm crosses zero, about 45%
// below the middle of its own frame) lies under the two words, at
// the height of the logo's ribbon, wherever the layout has put
// them (see size()). The shader's own wander stays.
// 3. The last line. The file paints the line white on black, hot in
// the middle. Here it is one blue, the words' own (--accent), with
// the line's brightness as alpha, so the paper shows through
// everywhere else and the middle is that same blue, fully opaque.
const FRAG_EDITS = [
[
'#ifdef GL_ES\nprecision mediump float;\n#endif\n',
'#ifdef GL_ES\nprecision PRECISION float;\n#endif\nuniform vec3 tint;\nuniform float shift;\n',
],
[
'\tvec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0;\n',
'\tvec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0;\n\tuv.y -= shift;\n',
],
[
'\tgl_FragColor = vec4( finalColor, 1.0 );\n',
'\tfloat a = clamp( finalColor.r, 0.0, 1.0 );\n' + '\tgl_FragColor = vec4( tint * a, a );\n',
],
];
const VERT = 'attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); }';
const CSS = `
.hero-module {
/* Sticky at the viewport top for the whole scroll (its containing
block is the page itself), z-index 0 so the cards (z-index 1)
stack over it. The height is a floor, frozen to a measured pixel
value by the module once it runs (see size()); the svh ladder is
the first paint and the no-JS fallback. A floor and not a height,
so a copy that needs more than a screen (a phone) grows the header
rather than being clipped, and the cards start under it. */
position: sticky;
top: 0;
z-index: 0;
max-width: none;
width: 100%;
min-height: 100vh;
min-height: 100svh;
padding: 0;
justify-content: flex-end;
overflow: hidden;
}
/* The piece is whatever the header has left above the copy, not
portal's 55svh reservation; the name centres in it. It keeps a
quarter of the screen at least, so a long copy cannot squeeze the
name out. (A picture strip does not belong in this header: it would
be pinned under the name for the whole page. It lives on the pages
without the module, /prosjekter.) */
.hero-module .hero-piece {
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 26svh;
}
/* The name drawn below is the wordmark here. Who is here (portal
0.6: on the wordmark's own line, .hero-top, which would sit level
with the mark) goes to the top right corner of the header instead,
the sticky header being its containing block now that the copy is
not positioned. */
.hero-module .wordmark {
display: none;
}
.hero-module .hero-top {
position: absolute;
top: 0.9rem;
right: 1.5rem;
width: auto;
justify-content: flex-end;
}
.hero-module .hero-top .session {
position: static;
transform: none;
}
.hero-module .hero-copy {
position: static;
padding: 0 1.5rem 3.5rem;
gap: 0.6rem;
text-shadow: 0 0.12em 1.4em var(--hero-glow);
}
/* Behind everything in the header (the header is a stacking context
of its own, so -1 is still above the page). */
.westra-line {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
display: block;
pointer-events: none;
}
/* The name, laid out as the logo lays it out: a box with the logo's
proportions (its 430x128 viewBox), Westra large at the top left,
Engineering under it and to the right. Positions and sizes are the
logo's own numbers as hundredths of the box width (cqw), so the
layout is the logo's at every size; the ribbon's place under both
words is where the line is aimed (see aim()). --p is the parting:
Westra leaves by half the viewport plus its own width, Engineering
the other way. */
.westra-name {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 1.5rem;
box-sizing: border-box;
container-type: size;
pointer-events: none;
--p: 0;
}
/* As wide as the piece allows, and no taller than most of it. */
.westra-mark {
position: relative;
width: min(100%, 40rem, 260cqh);
aspect-ratio: 430 / 128;
container-type: inline-size;
font-family: Georgia, 'Times New Roman', 'Liberation Serif', serif;
font-variant: small-caps;
font-feature-settings: 'smcp' 1;
font-weight: 600;
letter-spacing: 0.028em;
line-height: 1;
color: var(--accent);
}
.westra-mark > * {
position: absolute;
white-space: nowrap;
}
/* A halo of the paper's own colour around the glyphs, dense close in
and fading out, so the line's glow does not blur the words where it
runs under or through them. */
.westra-mark .westra-a,
.westra-mark .westra-b {
will-change: transform;
text-shadow:
0 0 0.04em var(--paper),
0 0 0.1em var(--paper),
0 0 0.2em var(--paper),
0 0 0.4em var(--paper),
0 0.06em 0.9em var(--hero-glow);
}
/* x 6, baseline 54, size 54 */
.westra-mark .westra-a {
left: 1.4cqw;
top: 2.8cqw;
font-size: 12.56cqw;
transform: translateX(calc(var(--p) * (-50vw - 100%)));
}
/* x 150, baseline 96, size 41 */
.westra-mark .westra-b {
left: 34.9cqw;
top: 14.8cqw;
font-size: 9.53cqw;
transform: translateX(calc(var(--p) * (50vw + 100%)));
}
`;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
const clamp01 = (x) => Math.min(1, Math.max(0, x));
// Gentle in, gentle out: the halves start moving as the scroll does,
// without a kick.
const ease = (p) => (p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p));
// "#rgb" / "#rrggbb" from a custom property, as a 0..1 triple.
function rgb(value, fallback) {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((value || '').trim());
if (!m) return fallback;
let h = m[1];
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
}
class WestraHero {
constructor(container) {
ensureStyle();
this.stopped = false;
this.hero = container.closest('.hero') || container.parentElement;
this.copy = this.hero ? this.hero.querySelector('.hero-copy') : null;
this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
this.dark = window.matchMedia('(prefers-color-scheme: dark)');
this.canvas = document.createElement('canvas');
this.canvas.className = 'westra-line';
this.name = document.createElement('div');
this.name.className = 'westra-name';
const mark = document.createElement('div');
mark.className = 'westra-mark';
const a = document.createElement('span');
a.className = 'westra-a';
a.textContent = 'Westra';
const b = document.createElement('span');
b.className = 'westra-b';
b.textContent = 'Engineering';
mark.append(a, b);
this.name.append(mark);
this.mark = mark;
// The canvas covers the whole sticky header, under everything
// in it; the name sits in the piece, the space above the copy.
(this.hero || container).prepend(this.canvas);
container.append(this.name);
this.width = 0;
this.height = 1;
this.boxHeight = 1;
this.progress = 0;
this.t0 = performance.now();
this.last = this.t0;
this.raf = 0;
this.gl = this.initGl();
this.size();
this._onResize = () => this.onResize();
this._onScroll = () => this.wake();
this._onTheme = () => {
this.recolor();
this.wake();
};
this._onLost = (e) => {
e.preventDefault();
this.gl = null;
};
this._onRestored = () => {
this.gl = this.initGl();
this.size();
this.wake();
};
window.addEventListener('resize', this._onResize);
window.addEventListener('scroll', this._onScroll, { passive: true });
if (this.hero && typeof ResizeObserver === 'function') {
this.observer = new ResizeObserver(() => {
if (this.stopped) return;
if (this.hero.offsetHeight !== this.boxHeight) {
this.fit();
this.wake();
}
});
this.observer.observe(this.hero);
}
this.dark.addEventListener('change', this._onTheme);
this.reduced.addEventListener('change', this._onTheme);
this.canvas.addEventListener('webglcontextlost', this._onLost);
this.canvas.addEventListener('webglcontextrestored', this._onRestored);
this.wake();
}
// The frame loop runs while the line animates; with reduced motion
// the line stands still and frames are drawn only when the scroll,
// size or theme changes, until the transform has settled.
wake() {
if (this.stopped || this.raf) return;
this.raf = requestAnimationFrame((now) => this.frame(now));
}
frame(now) {
this.raf = 0;
if (this.stopped) return;
const dt = Math.min(100, now - this.last);
this.last = now;
const target = clamp01(window.scrollY / (this.height * SPLIT_RANGE));
if (this.reduced.matches) {
this.progress = target;
} else {
this.progress += (target - this.progress) * (1 - Math.exp(-dt / TAU_MS));
if (Math.abs(target - this.progress) < 0.0005) this.progress = target;
}
const p = ease(this.progress);
this.name.style.setProperty('--p', p.toFixed(4));
// The copy goes sooner than the name: it has to be gone before
// the first card reaches it, on a short screen too.
if (this.copy) {
const visible = 1 - clamp01(this.progress * 2.5);
this.copy.style.opacity = visible.toFixed(3);
this.copy.style.visibility = visible <= 0.01 ? 'hidden' : '';
}
this.draw(this.reduced.matches ? 0 : (now - this.t0) / 1000);
const settled = this.progress === target;
if (!this.reduced.matches || !settled) this.wake();
}
// The header's height is frozen from one measurement of the
// viewport rather than left to svh: on phones the box would
// otherwise follow the address bar in and out and the name with
// it. Re-measured when the width changes (a rotation, a window
// resize), not when only the height moves a little (that bar).
onResize() {
const dw = window.innerWidth !== this.width;
const dh = Math.abs(window.innerHeight - this.height) > this.height * 0.25;
if (dw || dh) this.size();
this.wake();
}
size() {
this.width = window.innerWidth;
this.height = Math.max(1, window.innerHeight);
if (this.hero) this.hero.style.minHeight = this.height + 'px';
this.fit();
}
// The canvas covers the header's real box, which is the screen
// unless the copy needs more; measured, since the copy's height
// is the fonts'.
fit() {
this.boxHeight = Math.max(1, this.hero ? this.hero.offsetHeight : this.height);
const dpr = window.devicePixelRatio || 1;
const scale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (this.width * this.boxHeight)));
this.canvas.width = Math.max(1, Math.round(this.width * scale));
this.canvas.height = Math.max(1, Math.round(this.boxHeight * scale));
if (this.gl) {
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
this.aim();
}
}
// Where the logo's ribbon would be on screen (its lower edge, y
// 116 of the box's 128), as the shader's uv.y (+1 at the top, -1
// at the foot): the line's own zero crossing sits about 0.45
// below its frame's middle, so the frame is shifted up by that
// much less.
aim() {
const hero = this.hero ? this.hero.getBoundingClientRect() : { top: 0 };
const mark = this.mark.getBoundingClientRect();
const ribbonY = mark.top - hero.top + mark.height * (116 / 128);
const uvY = 1 - 2 * (ribbonY / this.boxHeight);
this.gl.uniform1f(this.u.shift, uvY + 0.45);
}
initGl() {
const gl = this.canvas.getContext('webgl', {
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
powerPreference: 'low-power',
});
if (!gl) return null;
const highp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
const precision = highp && highp.precision > 0 ? 'highp' : 'mediump';
let frag = LIGHT_LINE_FRAG;
for (const [from, to] of FRAG_EDITS) {
if (!frag.includes(from)) {
console.warn('westra hero: light_line.frag no longer matches its edits; line not drawn');
return null;
}
frag = frag.replace(from, to.replace('PRECISION', precision));
}
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.warn('westra hero: shader', gl.getShaderInfoLog(s));
return null;
}
return s;
};
const vs = compile(gl.VERTEX_SHADER, VERT);
const fs = compile(gl.FRAGMENT_SHADER, frag);
if (!vs || !fs) return null;
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn('westra hero: link', gl.getProgramInfoLog(program));
return null;
}
gl.useProgram(program);
// One triangle that covers the clip space; the shader only
// reads gl_FragCoord.
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
this.u = {
time: gl.getUniformLocation(program, 'time'),
resolution: gl.getUniformLocation(program, 'resolution'),
tint: gl.getUniformLocation(program, 'tint'),
shift: gl.getUniformLocation(program, 'shift'),
};
gl.clearColor(0, 0, 0, 0);
this.gl = gl;
this.recolor();
this.aim();
return gl;
}
// The line is the words' blue: --accent from the page's own
// palette (custom.css sets it for both schemes), read when the
// scheme changes rather than every frame.
recolor() {
if (!this.gl) return;
const cs = getComputedStyle(this.hero || document.documentElement);
const tint = rgb(cs.getPropertyValue('--accent'), [0.13, 0.19, 0.31]);
this.gl.uniform3f(this.u.tint, tint[0], tint[1], tint[2]);
}
draw(time) {
const gl = this.gl;
if (!gl) return;
gl.uniform1f(this.u.time, time);
gl.uniform2f(this.u.resolution, this.canvas.width, this.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
stop() {
this.stopped = true;
if (this.raf) cancelAnimationFrame(this.raf);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('scroll', this._onScroll);
if (this.observer) this.observer.disconnect();
this.dark.removeEventListener('change', this._onTheme);
this.reduced.removeEventListener('change', this._onTheme);
this.canvas.removeEventListener('webglcontextlost', this._onLost);
this.canvas.removeEventListener('webglcontextrestored', this._onRestored);
if (this.gl) {
const lose = this.gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
if (this.hero) this.hero.style.minHeight = '';
if (this.copy) {
this.copy.style.opacity = '';
this.copy.style.visibility = '';
}
this.canvas.remove();
this.name.remove();
}
}
export function mount(container) {
return new WestraHero(container);
}