Why: pivot the experience from a personal invitation for Project Bifrost
participants to a customer-facing presentation that can be shown to
prospects like Novo Nordisk while still mentioning Bifrost in context.
Major changes:
- Entrance: re-worded title/body away from "invitation" into "introduction"; kept Fenja AI / Project Bifrost definition blocks.
- Timeline: page-sub reworked to also speak to highly-regulated private orgs (data, IP, regulated workflows, US-vendor dependency) alongside public sector.
- "Backed by Innovationsfonden" pairs with new "Part of BioInnovation Institute AI Lab" line on entrance and Scene 1 hero.
- Removed: stack-scene (4 capabilities) and words-scene ("This is why we've invited you") — archived at protected/_archive/stack-scene.html for restore.
- Removed: bifrost-join CTA + Innovationsfonden footer section.
- Inlined the standalone /deepdive architecture explainer into #overview-scroll after #bifrost-meaning; platform.js detects scroller and skips its own Lenis setup when integrated.
- New: Wiki deep-dive section (#wiki-deepdive) — scattered knowledge cluster → Fenja AI Compiler → layered page stack with citations, plus pinned scrubbed beat-by-beat reveal.
- New: Implementation roadmap section (#platform-roadmap) — four stage cards + GOVERN & SCALE band + footer, with click-to-expand card-morph (FLIP-based; same DOM element grows into the featured panel).
- Dot-nav: 4 → 8 entries — Welcome / Timeline / Fenja introduction / Project Bifrost / Architecture / Wiki / Deployment / Roadmap.
- Deployment options: scroll-tied fade-in for the whole section + sticky-damping at centre for a subtle dwell stop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
780 lines
33 KiB
JavaScript
780 lines
33 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
||
// 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',
|
||
'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',
|
||
'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 included so once the Deployment Options
|
||
// section is centred, the wheel multiplier drops — the
|
||
// reader has to scroll a few extra ticks to continue, which
|
||
// pairs with the scroll-tied fade-in to give the section a
|
||
// subtle "stop" feel.
|
||
const sceneIds = ['hero', 'bifrost', 'platform-cards'];
|
||
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');
|
||
}
|
||
});
|
||
|
||
/* 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');
|
||
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.
|
||
//
|
||
// 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);
|
||
default: return 0;
|
||
}
|
||
}
|
||
|
||
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 : target.offsetTop;
|
||
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 };
|
||
})();
|