customer-presentation/protected/bifrost.js
Jonathan Hvid cf55ab5fcf board: add advisory-board reveal JS + 8 portrait images
The #board-reveal section HTML was already committed, but the GSAP
reveal timeline (members start opacity:0) lived only in the working
tree and the 8 portraits under protected/fenja/board/ were untracked.
Result: the whole "Meet the Fenja AI Advisory Board" section never
appeared in the deployed build. Commit both so it renders in the cloud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:06:56 +02:00

849 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────
// protected/bifrost.js — Project Bifrost scenes inside the
// Overview page of the timeline.
//
// This file:
// 1. Wraps the Overview's scroll-bound scenes (hero → aurora arc
// → treasure-map). The 4-card architecture stack, the "This is
// why we've invited you" word fly-in, and the Project Bifrost
// Join CTA were removed in the 2026-05-19 customer-presentation
// conversion; the architecture explainer (formerly /deepdive)
// now follows the treasure-map inline — see protected/platform.js.
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
// scroller is the Overview's internal scrolling container —
// never the window — so the three-page Timeline/Overview/
// Timeline model (each fixed-viewport) keeps working.
// 3. Drives the Europe map's opacity from the scroller's scroll
// position: fully visible at the top, fades to 0 as the user
// scrolls into the hero, fades back in on scrolling up.
// 4. Is a lazy-init module. Nothing happens at page load; the
// dot-nav handler in timeline.js calls window.__bifrost.init()
// the first time the Overview page becomes active.
//
// CSP: 'script-src self'. No inline scripts anywhere.
// ─────────────────────────────────────────────────────────────
(function () {
'use strict';
// Lazy single-shot init flag — nav can click the Overview pip
// multiple times; we only wire everything up once.
let initialized = false;
let refreshScheduled = false;
// Shared state between init() and the public surface (scrollTo etc).
// These are assigned once during init(); scrollTo reads them to drive
// the Overview's internal scroller instead of window scroll.
let lenisInstance = null;
let scrollerEl = null;
// A tiny helper: schedule a ScrollTrigger.refresh() on the next
// animation frame, de-duplicating calls within the same frame.
function scheduleRefresh() {
if (refreshScheduled || !window.ScrollTrigger) return;
refreshScheduled = true;
requestAnimationFrame(() => {
refreshScheduled = false;
window.ScrollTrigger.refresh();
});
}
function init() {
if (initialized) {
// Already booted — just re-measure, in case layout shifted while
// the page was inactive (e.g. user resized the window on Timeline).
scheduleRefresh();
return;
}
initialized = true;
// Guard: vendor libs must have loaded.
if (typeof window.gsap === 'undefined' ||
typeof window.ScrollTrigger === 'undefined' ||
typeof window.Lenis === 'undefined') {
console.warn('[bifrost] Vendor libraries (gsap/ScrollTrigger/Lenis) missing; skipping init.');
return;
}
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Reveal-on-load elements: if reduced motion, just make them visible
// and bail before registering any ScrollTrigger-bound tweens.
if (reduceMotion) {
document.querySelectorAll('#page-overview [data-reveal], #page-overview [data-reveal-lines]').forEach(el => {
el.style.opacity = '1';
el.style.transform = 'none';
});
// The hero-wrap is hidden by CSS (`.js .hero-wrap { opacity: 0 }`)
// to prevent a flash during page activation — unhide it here since
// we're skipping the fade-in tween.
const heroWrap = document.querySelector('.hero-wrap');
if (heroWrap) heroWrap.style.opacity = '1';
// Still fade the Europe map fully in — it's the scene background.
const mapEl = document.getElementById('overview-globe');
if (mapEl) mapEl.style.opacity = '1';
return;
}
// ─── Scroller setup ──────────────────────────────────────────
//
// The Overview is a fixed-position .page that contains one
// scrollable child: `#overview-scroll`. All six scenes live
// inside it. Lenis drives wheel input on that element;
// ScrollTrigger reads scroll from the same element.
const scroller = document.getElementById('overview-scroll');
if (!scroller) {
console.error('[bifrost] #overview-scroll not found');
return;
}
scrollerEl = scroller;
const gsap = window.gsap;
const ScrollTrigger = window.ScrollTrigger;
const Lenis = window.Lenis;
gsap.registerPlugin(ScrollTrigger);
// Lenis wired to the Overview's internal scroller, NOT the window.
const lenis = new Lenis({
wrapper: scroller,
content: scroller.firstElementChild,
duration: 1.15,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
wheelMultiplier: 1,
touchMultiplier: 1.5,
});
// Tell ScrollTrigger how to read/write scroll on this scroller.
ScrollTrigger.scrollerProxy(scroller, {
scrollTop(value) {
if (arguments.length) {
scroller.scrollTop = value;
}
return scroller.scrollTop;
},
getBoundingClientRect() {
// The scroller occupies the full viewport (inset: 0 on its parent).
return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
},
// Scrollbars are hidden via CSS; pinType is 'transform' for nested scrollers.
pinType: 'transform',
});
// Every ScrollTrigger below implicitly targets our scroller.
ScrollTrigger.defaults({ scroller });
// Pump Lenis via GSAP's ticker; notify ScrollTrigger on each scroll.
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
// Expose lenis to scrollTo() via the shared closure var.
lenisInstance = lenis;
// ─── Europe map fade ─────────────────────────────────────────
//
// Fully visible at scrollTop=0. Fades to 0 between 20% and 80%
// of viewport height. Fades back in on scrolling up. Opacity
// ceiling is 0.42 — matches the original .page-overview.is-active
// .overview-globe svg look.
const mapSvg = document.querySelector('#overview-globe svg');
const MAP_MAX_OPACITY = 0.42;
if (mapSvg) {
// Initial: visible (map is the hero backdrop). We'll drive opacity
// directly on every scroll tick, so kill any CSS opacity transition
// (the existing rule animates opacity over 900ms — would be janky).
mapSvg.style.transition = 'none';
mapSvg.style.opacity = MAP_MAX_OPACITY.toFixed(3);
}
function updateMapOpacity() {
if (!mapSvg) return;
const vh = window.innerHeight;
const y = scroller.scrollTop;
// Between 0.20 × vh and 0.80 × vh, ramp from full to zero.
const startFade = vh * 0.20;
const endFade = vh * 0.80;
let t;
if (y <= startFade) {
t = 0; // fully visible
} else if (y >= endFade) {
t = 1; // fully hidden
} else {
t = (y - startFade) / (endFade - startFade);
}
mapSvg.style.opacity = (MAP_MAX_OPACITY * (1 - t)).toFixed(3);
}
lenis.on('scroll', updateMapOpacity);
// Initial paint
updateMapOpacity();
// ─── Scroll-spy for the dot-nav ──────────────────────────────
//
// As the user scrolls through the Overview, update the active dot
// to match the scene currently in view. Called from lenis.on('scroll')
// at display rate; debounced implicitly by requestAnimationFrame
// through the shared refresh scheduler.
//
// Logic: a scene is "active" when its top is above the viewport's
// midpoint AND its bottom is below it. For stacked pinned scenes
// (S2) the pin duration makes "bottom" go well past the viewport,
// so the first-match wins — scenes are checked top-to-bottom.
// Every scrollable scene in the Overview, top-to-bottom. The
// scroll-spy walks this list and decides which one is "in
// view" by the viewport midline rule below. Intermediate
// scenes (bifrost-meaning, platform-question) map to a
// neighbouring dot via sceneToDot so the nav stays highlighted
// through them.
const sceneOrder = [
'hero',
'bifrost', 'bifrost-meaning', 'board-reveal',
'platform-question', 'platform-layers',
'wiki-deepdive',
'platform-cards',
'platform-roadmap',
];
// Maps a scene's id to the data-scroll-to of the dot that
// should highlight when that scene is in view.
// bifrost-meaning → bifrost (treasure-map is a
// continuation of the
// Project Bifrost reveal)
// platform-question → platform-layers (framing lead-in to
// the architecture)
const sceneToDot = {
'hero': 'hero',
'bifrost': 'bifrost',
'bifrost-meaning': 'bifrost',
'board-reveal': 'bifrost',
'platform-question': 'platform-layers',
'platform-layers': 'platform-layers',
'wiki-deepdive': 'wiki-deepdive',
'platform-cards': 'platform-cards',
'platform-roadmap': 'platform-roadmap',
};
let lastActiveScene = null;
function updateActiveSceneDot() {
if (typeof window.__setActiveDot !== 'function') return;
const midY = window.innerHeight * 0.5;
let visibleId = sceneOrder[0];
for (const id of sceneOrder) {
const el = document.getElementById(id);
if (!el) continue;
const r = el.getBoundingClientRect();
// A scene whose top is at or above the midline, but whose
// bottom hasn't scrolled past the midline yet.
if (r.top <= midY && r.bottom > midY) {
visibleId = id;
break;
}
// Edge case: scrolled past — keep latest seen as fallback.
if (r.top <= midY) visibleId = id;
}
if (visibleId !== lastActiveScene) {
lastActiveScene = visibleId;
window.__setActiveDot('page-overview', sceneToDot[visibleId] || visibleId);
}
}
lenis.on('scroll', updateActiveSceneDot);
// Initial paint: set "hero" active since we start at top.
requestAnimationFrame(updateActiveSceneDot);
// ─── Topography parallax ─────────────────────────────────────
//
// Concentric-ring topographic layer that sits behind the Europe
// map (z-index 0). Generated at runtime — same formula as the
// entrance page's .currents pattern but rotated/offset so it reads
// as a visual sibling rather than a duplicate.
//
// Parallax speed: 0.15× of the scroller's scrollTop. Slow enough to
// feel atmospheric, fast enough that the layer doesn't appear
// static on casual scroll. Applied as a translateY composed WITH
// the 40° rotate from CSS (so we build the full transform here to
// avoid clobbering the CSS rotate).
const topoWrap = document.getElementById('overview-topography');
if (topoWrap && !topoWrap.querySelector('svg')) {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
const W = 1600, H = 1600, cx = W * 0.5, cy = H * 0.5;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// Slightly different parameters from the entrance page's currents
// so the rings read as "related but not identical":
// - more rings (34 vs 26) — denser
// - smaller step (28 vs 32) — tighter
// - larger amplitude (32 vs 26) — wavier
// - lower base opacity — quieter behind the map
const RINGS = 34, BASE_R = 60, STEP = 28, AMP = 32;
for (let i = 0; i < RINGS; i++) {
const r = BASE_R + i * STEP, segs = 280;
const p1 = (i * 0.7) % (Math.PI * 2);
const p2 = (i * 1.4 + 1.3) % (Math.PI * 2);
const a1 = AMP * (0.9 + (i % 5) * 0.08);
const a2 = AMP * 0.35;
let d = '';
for (let s = 0; s <= segs; s++) {
const t = (s / segs) * Math.PI * 2;
const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15)
+ a2 * Math.sin(t * 5 + p2 + i * 0.22)
+ AMP * 0.18 * Math.sin(t * 7 + i);
const x = cx + Math.cos(t) * rr;
const y = cy + Math.sin(t) * rr * 0.92;
d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1);
}
d += ' Z';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#383831');
path.setAttribute('stroke-width', '1');
path.setAttribute('stroke-linejoin', 'round');
// Lower opacity than entrance page — sits behind the map,
// shouldn't compete for attention.
path.setAttribute('opacity', (i % 3 === 0 ? 0.07 : 0.04).toString());
svg.appendChild(path);
}
topoWrap.appendChild(svg);
}
const PARALLAX_SPEED = 0.15;
const topoSvg = topoWrap ? topoWrap.querySelector('svg') : null;
function updateTopographyParallax() {
if (!topoSvg) return;
const y = scroller.scrollTop * -PARALLAX_SPEED;
// Rotate comes from CSS (40deg); we compose our translateY into
// the same transform string so both apply. `rotate` first so the
// translate happens in screen space, not the rotated local space.
topoSvg.style.transform = `translateY(${y.toFixed(1)}px) rotate(40deg)`;
}
lenis.on('scroll', updateTopographyParallax);
// Initial paint
updateTopographyParallax();
// ─── Sticky-scroll (wheel-multiplier attenuation) ────────────
//
// Previous implementation multiplied Lenis's `.velocity` per scroll
// event — ineffective, because Lenis overwrites velocity from the
// next wheel event before that damped value is consumed.
//
// New approach: directly reduce Lenis's `wheelMultiplier` when the
// viewport center is near a sticky target. The user's wheel input
// is attenuated at the source — their next scroll produces less
// delta, so the scene noticeably holds. When the zone is exited,
// the multiplier is restored.
//
// Targets are:
// - Non-pinned scenes (hero, bifrost)
// - The treasure map (bifrost-meaning) AND each of its three
// stops individually — previously the whole 300vh section was
// one target, so users flew through the individual stops.
//
// platform-layers is GSAP-pinned and scrubbed; damping on top would
// make its beat-by-beat build feel like a drag, so we exclude it.
const BASE_WHEEL_MULT = 1.0;
const BASE_TOUCH_MULT = 1.5;
const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone
const STICKY_TOUCH_MULT = 0.55;
const STICKY_ZONE_VH = 0.18; // ±18% of viewport height from its center
// Collect sticky targets. IDs of scenes + DOM refs for the three
// individual treasure-map stops. Cached once — the list doesn't
// change after init.
function collectStickyTargets() {
const targets = [];
// platform-cards is NOT a sticky-damping target — it has its
// own GSAP pin (see initCards in platform.js) which provides
// the "stop" feel. Layering both would slow wheel input to
// 0.35× during the pin and turn the 100vh budget into a
// ~285vh slog. platform-roadmap, same.
const sceneIds = ['hero', 'bifrost'];
sceneIds.forEach(id => {
const el = document.getElementById(id);
if (el) targets.push(el);
});
// Treasure map intro + three stops each get their own sticky zone.
const mapSection = document.getElementById('bifrost-meaning');
if (mapSection) {
// Each .map-stop is a separate card along the path — sticky them
// individually so the user dwells on each one.
mapSection.querySelectorAll('.map-stop').forEach(el => targets.push(el));
}
return targets;
}
const stickyTargets = collectStickyTargets();
let wheelMultState = BASE_WHEEL_MULT;
function updateStickyDamping() {
const midY = window.innerHeight * 0.5;
const zone = window.innerHeight * STICKY_ZONE_VH;
let inZone = false;
for (const el of stickyTargets) {
const r = el.getBoundingClientRect();
const elMid = (r.top + r.bottom) * 0.5;
if (Math.abs(elMid - midY) < zone) {
inZone = true;
break;
}
}
const nextMult = inZone ? STICKY_WHEEL_MULT : BASE_WHEEL_MULT;
if (nextMult !== wheelMultState) {
wheelMultState = nextMult;
// Lenis reads options.wheelMultiplier on every wheel event —
// re-assigning takes effect immediately.
lenis.options.wheelMultiplier = nextMult;
lenis.options.touchMultiplier = inZone ? STICKY_TOUCH_MULT : BASE_TOUCH_MULT;
}
}
lenis.on('scroll', updateStickyDamping);
// Initial paint (we likely start at hero which is itself a target).
requestAnimationFrame(updateStickyDamping);
// ─── Site-2 scene animations ─────────────────────────────────
// (transplanted verbatim; all ScrollTriggers below automatically
// use the Overview scroller via ScrollTrigger.defaults above.)
// Script 1 body — HERO + SCENE 2 (architecture stack) + SCENE 3 (words) + SCENE 4 (bifrost arc)
/* -------------------------------------------------------------
HERO — single overall fade-in. The wrap is hidden via CSS
(`.js .hero-wrap { opacity: 0 }`) so the hero stays invisible
during the page-activation transition, then fades in once
Bifrost has booted.
------------------------------------------------------------- */
gsap.to('.hero-wrap', {
opacity: 1,
duration: 1.0,
ease: 'power2.out',
delay: 0.1,
});
/* -------------------------------------------------------------
ARCHITECTURE STACK + "This is why we've invited you" words —
REMOVED 2026-05-19 in the customer-presentation conversion.
The full HTML + JS is archived at
protected/_archive/stack-scene.html
so the 4-capabilities pinned-scrub sequence can be restored.
------------------------------------------------------------- */
/* Removed: stack-scene timeline (computeGridPlan + 4-card scrubbed
build + grid morph + copy-stage crossfade). Archived in
protected/_archive/stack-scene.html. */
/* Removed: words-scene "This is why we've invited you" timeline.
Archived in protected/_archive/stack-scene.html. */
/* -------------------------------------------------------------
SCENE 4 — PROJECT BIFROST REVEAL
Arc draws in, then the words settle.
------------------------------------------------------------- */
const arcMain = document.getElementById('arcMain');
const arcThin = document.getElementById('arcThin');
const arcHalo = document.getElementById('arcHalo');
[arcMain, arcThin, arcHalo].forEach(el => {
if (!el) return;
const len = el.getTotalLength();
el.style.strokeDasharray = len;
el.style.strokeDashoffset = len;
});
const tokens = gsap.utils.toArray('.bifrost-name .token');
tokens.forEach((t, i) => {
gsap.set(t, { opacity: 0, y: 40, filter: 'blur(10px)' });
});
const bifrostTl = gsap.timeline({
scrollTrigger: {
trigger: '#bifrost',
start: 'top top',
end: 'bottom bottom',
scrub: 0.6,
}
});
bifrostTl
.to('.arc-wrap', { opacity: 1, duration: 0.1, ease: 'power2.out' }, 0.02)
.to(arcHalo, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.02)
.to(arcMain, { strokeDashoffset: 0, duration: 0.5, ease: 'power2.inOut' }, 0.05)
.to(arcThin, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.12)
.to('.bifrost-eyebrow', { opacity: 1, y: 0, duration: 0.1, ease: 'power2.out' }, 0.30)
.fromTo('.bifrost-eyebrow', { y: 20 }, { y: 0, duration: 0.1 }, 0.30)
.to(tokens[0], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.15, ease: 'power3.out' }, 0.40)
.to(tokens[1], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.2, ease: 'power3.out' }, 0.50)
.to('.bifrost-sub', { opacity: 1, y: 0, duration: 0.15, ease: 'power2.out' }, 0.68)
.fromTo('.bifrost-sub', { y: 20 }, { y: 0, duration: 0.15 }, 0.68);
// Slight parallax on the arc while the user continues to scroll
bifrostTl.to('.arc-wrap', { y: -40, duration: 0.3, ease: 'none' }, 0.5);
/* -------------------------------------------------------------
On load — kick hero animation
------------------------------------------------------------- */
// Ensure ScrollTrigger measures correctly after fonts load.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => ScrollTrigger.refresh());
}
// Script 2 body — SCENE 5 (treasure-map) + SCENE 6 (join CTA + footer)
/* =================================================================
PROJECT BIFROST — "What it means" treasure-map + summary cards.
Self-contained additive IIFE. Reads the global `gsap` and
`ScrollTrigger` registered by the existing scripts above; does
not touch any pre-existing timelines or DOM nodes.
================================================================= */
/* -------------------------------------------------------------
SCENE 5 — Treasure-map path draw + per-stop reveals
------------------------------------------------------------- */
// -------- Build the path geometry from actual dot positions --------
//
// The treasure-map path needs to thread cleanly through each dot.
// The dots are positioned by CSS grid/flex, so their Y positions in
// the canvas depend on the rendered height of each stop's content
// (which varies with viewport width and font metrics). We compute
// the path here AFTER layout, so it always passes through the dots.
//
// Coordinate system: the SVG viewBox is "0 0 100 200" with
// preserveAspectRatio="none", so X is normalised to canvas width
// and Y to canvas height. We measure each dot's centre in canvas
// coordinates, normalise, and emit a chain of cubic bezier segments
// — each one bowing out to alternating sides for a meandering feel.
const mapCanvasEl = document.querySelector('.map-canvas');
const pathBg = document.getElementById('mapPathBg');
const pathDraw = document.getElementById('mapPathDraw');
let drawScrollTrigger = null;
function buildMapPath() {
if (!mapCanvasEl || !pathDraw || !pathBg) return;
const dots = mapCanvasEl.querySelectorAll('.dot-anchor');
if (dots.length < 2) return;
const cb = mapCanvasEl.getBoundingClientRect();
if (cb.width === 0 || cb.height === 0) return;
// Normalise dot centres to viewBox units (0-100 X, 0-200 Y)
const pts = Array.from(dots).map((d) => {
const r = d.getBoundingClientRect();
return {
x: ((r.left + r.width / 2 - cb.left) / cb.width) * 100,
y: ((r.top + r.height / 2 - cb.top) / cb.height) * 200,
};
});
// Build path: M to first, then cubic beziers to each subsequent
// dot. Control points sit at the same X as the dots (so the path
// exits/enters each dot along the vertical axis) but bow out to
// alternating sides between them — gives a Nordic-river feel.
const segs = [`M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`];
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
// Bow direction alternates by segment index. Amplitude is in
// viewBox X units (0-100) — clamped down on tall segments to
// avoid the path drifting outside the canvas.
const bowDir = (i % 2 === 1) ? 1 : -1;
const bowAmount = Math.min(20, (b.y - a.y) * 0.18);
const cx1 = 50 + bowAmount * bowDir;
const cy1 = a.y + (b.y - a.y) * 0.35;
const cx2 = 50 + bowAmount * bowDir;
const cy2 = a.y + (b.y - a.y) * 0.65;
segs.push(`C ${cx1.toFixed(2)} ${cy1.toFixed(2)}, ${cx2.toFixed(2)} ${cy2.toFixed(2)}, ${b.x.toFixed(2)} ${b.y.toFixed(2)}`);
}
const d = segs.join(' ');
pathBg.setAttribute('d', d);
pathDraw.setAttribute('d', d);
// Re-measure the drawn path's length and reset the dash offset
// so the draw-in animation covers the full new geometry.
const len = pathDraw.getTotalLength();
pathDraw.style.strokeDasharray = len;
// If the scroll-trigger animation already played to completion,
// keep the path drawn; otherwise hide it pending scroll.
const trig = drawScrollTrigger;
if (trig && trig.progress >= 1) {
pathDraw.style.strokeDashoffset = 0;
} else {
pathDraw.style.strokeDashoffset = len;
}
}
// Build initially after a paint
requestAnimationFrame(buildMapPath);
// Animate the accent path drawing in as the user scrolls down
// through the meaning section
if (pathDraw) {
const tween = gsap.to(pathDraw, {
strokeDashoffset: 0,
ease: 'none',
scrollTrigger: {
trigger: '#bifrost-meaning',
start: 'top 65%',
end: 'bottom 75%',
scrub: 0.6,
invalidateOnRefresh: true,
onRefresh: () => {
// Re-measure dasharray on every ScrollTrigger refresh so
// the animation stays in sync with any path changes
const len = pathDraw.getTotalLength();
pathDraw.style.strokeDasharray = len;
},
}
});
drawScrollTrigger = tween.scrollTrigger;
}
// Rebuild the path on resize (debounced) since dot positions move
let mapPathResizeTimer;
window.addEventListener('resize', () => {
clearTimeout(mapPathResizeTimer);
mapPathResizeTimer = setTimeout(() => {
buildMapPath();
ScrollTrigger.refresh();
}, 220);
});
// Mobile fallback — the SVG path is hidden and replaced by a CSS
// pseudo-element rail. Drive its progress with a CSS custom prop
// so the same scroll range animates a vertical fill.
const mapCanvas = document.querySelector('.map-canvas');
if (mapCanvas) {
const railObj = { p: 0 };
gsap.to(railObj, {
p: 100,
ease: 'none',
scrollTrigger: {
trigger: '#bifrost-meaning',
start: 'top 65%',
end: 'bottom 75%',
scrub: 0.6,
onUpdate: () => {
mapCanvas.style.setProperty('--rail-progress', railObj.p + '%');
}
}
});
}
// Per-stop reveal — dot pops in first, then content + image rise
// and fade in alongside each other. ToggleActions: play forward
// when entering, reverse when scrolling back up past the trigger.
gsap.utils.toArray('.map-stop').forEach((stop) => {
const dot = stop.querySelector('.dot');
const contentBits = stop.querySelectorAll('.stop-content > *');
const image = stop.querySelector('.stop-image');
const tl = gsap.timeline({
scrollTrigger: {
trigger: stop,
start: 'top 78%',
toggleActions: 'play none none reverse',
}
});
// Dot pops in with a subtle back ease — feels like a pin
// dropping into the map
if (dot) {
tl.to(dot, {
scale: 1, opacity: 1,
duration: 0.55, ease: 'back.out(2)',
});
}
// Text bits stagger in
if (contentBits.length) {
tl.to(contentBits, {
opacity: 1, y: 0,
duration: 0.7, stagger: 0.08,
ease: 'power3.out',
}, '-=0.35');
}
// Image animates in alongside the text (overlapping for unity)
if (image) {
tl.to(image, {
opacity: 1, y: 0,
duration: 0.9,
ease: 'power3.out',
}, '-=0.6');
}
});
// ─── Advisory board reveal ───────────────────────────────────
// The #board-reveal section (8-member grid) sits right after the
// final treasure-map stop. Header bits rise/fade in, then the
// portraits stagger up. Mirrors the .map-stop reveal pattern;
// reduced-motion users never reach this block (early return above)
// and get the static CSS fallback instead.
const boardSection = document.getElementById('board-reveal');
if (boardSection) {
const headBits = boardSection.querySelectorAll('.board-head > *');
const members = boardSection.querySelectorAll('.board-member');
const boardTl = gsap.timeline({
scrollTrigger: {
trigger: boardSection,
start: 'top 72%',
toggleActions: 'play none none reverse',
}
});
if (headBits.length) {
boardTl.to(headBits, {
opacity: 1, y: 0,
duration: 0.7, stagger: 0.1,
ease: 'power3.out',
});
}
if (members.length) {
boardTl.to(members, {
opacity: 1, y: 0,
duration: 0.7, stagger: 0.06,
ease: 'power3.out',
}, '-=0.3');
}
}
/* SCENE 6 — Join CTA + Innovationsfonden footer: REMOVED 2026-05-19
in the customer-presentation conversion. The CTA, confirmation
panel, click handler, and three-mark footer all went away with
the #bifrost-join section in protected/index.html. */
/* -------------------------------------------------------------
Refresh ScrollTrigger after fonts and images load so positions
are accurate — large embedded illustrations affect layout.
------------------------------------------------------------- */
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => ScrollTrigger.refresh());
}
// Refresh once illustrations have laid out
const illustrations = document.querySelectorAll('#bifrost-meaning img, #board-reveal img');
let pending = illustrations.length;
if (pending === 0) ScrollTrigger.refresh();
illustrations.forEach((img) => {
if (img.complete) {
if (--pending === 0) ScrollTrigger.refresh();
} else {
img.addEventListener('load', () => { if (--pending === 0) ScrollTrigger.refresh(); });
img.addEventListener('error', () => { if (--pending === 0) ScrollTrigger.refresh(); });
}
});
// ─── One consolidated resize handler ─────────────────────────
// Site 2 had two separate resize listeners; we defer to ScrollTrigger's
// own handling + our de-duped refresh. Scroller-relative measurements
// get recalculated whenever ScrollTrigger.refresh fires.
let resizeT = null;
window.addEventListener('resize', () => {
clearTimeout(resizeT);
resizeT = setTimeout(scheduleRefresh, 220);
});
// After fonts load, re-measure (headline wrap can shift positions).
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(scheduleRefresh);
}
// After all illustrations load, re-measure (treasure-map stops change height).
const bgIllustrations = document.querySelectorAll('#page-overview .stop-illust');
let pendingBg = bgIllustrations.length;
if (pendingBg === 0) scheduleRefresh();
bgIllustrations.forEach((el) => {
// Illustrations are CSS-background images; use an Image() to listen for load
const bg = getComputedStyle(el).backgroundImage;
const url = bg && bg.match(/url\("?([^")]+)"?\)/);
if (!url) { if (--pendingBg === 0) scheduleRefresh(); return; }
const img = new Image();
img.onload = img.onerror = () => { if (--pendingBg === 0) scheduleRefresh(); };
img.src = url[1];
});
// Final refresh after everything wires up
scheduleRefresh();
}
/**
* Smooth-scroll the Overview's internal scroller to a scene.
* Called by the dot-nav click handler in timeline.js.
*
* @param {string} sceneId id of the scene section (e.g. "bifrost")
* — see sceneOrder[] inside init().
* Special value "hero" scrolls to top (0).
*/
// Per-scene scroll offsets. Added to the scene's offsetTop when a
// dot-nav button anchors to it, so the reader lands AFTER the scene's
// initial reveal rather than at an empty pre-scrub frame.
//
// bifrost — section is 200vh with a scrubbed reveal that runs from
// top-top to bottom-bottom (100vh scroll range). The sub-headline
// fades in at ~0.83 of that. Offset is computed per viewport as
// 85% of vh so the reader arrives on the fully-drawn arc +
// wordmark, regardless of display size.
//
// platform-cards / platform-roadmap — pinned at 'center center', so
// the section's "intended landing" is the scroll position where
// section.centre aligns with viewport.centre. That position is
// section.offsetTop + (section.height - vh) / 2. For sections
// shorter than the viewport this offset is negative — the user
// lands just before the section's natural top, but the pin is
// engaged so visually they see the section centred at full
// opacity with the entire pin budget still ahead of them.
//
// hero — short reveal tween; offsetTop is already the correct
// landing spot so offset is 0.
function getSceneAnchorOffset(sceneId) {
const vh = window.innerHeight;
switch (sceneId) {
case 'bifrost':
return Math.round(vh * 0.85);
case 'platform-cards':
case 'platform-roadmap': {
const el = document.getElementById(sceneId);
if (!el) return 0;
return Math.round((el.offsetHeight - vh) / 2);
}
default:
return 0;
}
}
// Section's offset within the scroller's content. Walks the
// offsetParent chain summing offsetTop. Necessary because
// ScrollTrigger's pinSpacing wraps pinned sections in a pin-spacer
// div (position: relative), which becomes the section's offsetParent
// — so `target.offsetTop` alone returns ~0 and scrollTo lands the
// user at the top of the page instead of the requested section.
function offsetTopWithin(el, scroller) {
let offset = 0;
let current = el;
while (current && current !== scroller) {
offset += current.offsetTop;
const parent = current.offsetParent;
if (!parent) break;
current = parent;
}
return offset;
}
function scrollTo(sceneId) {
if (!scrollerEl) return; // init() hasn't run yet — ignore
const target = document.getElementById(sceneId);
if (!target) return;
// "hero" is the first scene and sits at scrollTop 0. Scrolling to
// the scene element directly works in most cases but produces a tiny
// non-zero offset (padding / border) — hard-code 0 for hero.
const base = sceneId === 'hero' ? 0 : offsetTopWithin(target, scrollerEl);
const scrollY = base + getSceneAnchorOffset(sceneId);
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
// Lenis does the smooth animation. `immediate: false` uses the
// same easing as wheel input — feels consistent.
lenisInstance.scrollTo(scrollY, { immediate: false });
} else {
// Fallback for pre-init / reduced-motion: hard-jump.
scrollerEl.scrollTo({ top: scrollY, behavior: 'smooth' });
}
}
// Public surface — timeline.js calls these when the Overview tab
// activates (init) or when a dot-nav button targeting a scene is
// clicked (scrollTo).
window.__bifrost = { init, scrollTo };
})();