customer-presentation/protected/timeline.js
Jonathan Hvid 0c4b3a438e overview: pin-with-scrub-release on cards & roadmap; fix dot-nav
initCards and initRoadmap now use a pin-with-scrub-release pattern
instead of a simple scroll-tied fade. Each section fades in over ~50vh
as it approaches viewport centre, locks in place for 100vh of scroll
input (cards extends to 150vh and fades out while still pinned;
roadmap stays visible as the page ends), then releases. Scroll itself
is never blocked — wheel/keyboard/touch all advance scroll normally
against the pin budget. platform-cards is removed from bifrost's
sticky-damping list since the new pin handles the dwell.

Dot-nav fixes for the new pins:
- activatePage now also calls __platform.init() in the same tick as
  __bifrost.init(), so pin spacers exist before scrollTo reads
  target offsets. Previously platform's MutationObserver-driven init
  fired ~80ms after scrollTo, leaving roadmap.offsetTop pointing at
  the pre-spacer position (empty space between cards and roadmap).
- scrollTo walks the offsetParent chain via offsetTopWithin() instead
  of reading target.offsetTop directly. ScrollTrigger's pinSpacing
  wraps pinned sections in a pin-spacer with position:relative, which
  becomes the section's offsetParent and makes target.offsetTop
  return ~0 — collapsing every dot click to scrollY=0 (hero).
- getSceneAnchorOffset adds cases for platform-cards / platform-roadmap
  returning (section.height - vh) / 2, so the user lands exactly at
  the pin-engagement point with the full pin budget remaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:53:01 +02:00

485 lines
23 KiB
JavaScript
Raw 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/timeline.js — timeline scroll, dot-nav, globe.
// This file also boots the Bifrost scenes (bifrost.js) the first
// time the Overview page is activated via the dot-nav or the
// "Read the editor's note" button, and fetches the user's first
// name from /auth/me so Scene 3 can personalise its sentence.
// ─────────────────────────────────────────────────────────────
// Signal to the Bifrost CSS that JS is running (enables the scroll-
// triggered reveal baseline: .js .some-element { opacity: 0 }).
// Harmless on the timeline itself — nothing on the timeline uses .js.
document.documentElement.classList.add('js');
const EVENTS = [
// 2024
{ date: 'September 2024', kind: 'Editorial', accent: 'copper',
hed: 'Three American firms run 70% of Europes cloud — and almost all of its <em>AI.</em>',
body: 'Mario Draghis verdict to the European Parliament: only four of the worlds top fifty tech companies are European. “It is too late,” he writes, to challenge American cloud providers. Without radical reform, the EU faces “slow agony.”',
source: 'The Draghi Report · Brussels' },
{ date: 'December 2024', kind: 'Field Note', accent: 'copper',
hed: 'Denmark warns: digital society is now “extremely <em>vulnerable.</em>”',
body: 'The expert group on tech giants reports: dependence on a handful of foreign suppliers is no longer a procurement question. It is a national security one. Minister Bodskov: “we need to fence in the tech giants.”',
source: 'Danish Expert Group · Copenhagen' },
// 2025
{ date: 'January 2025', kind: 'Rupture', accent: 'crimson',
hed: 'Trump refuses to rule out military force against <em>Greenland.</em>',
body: 'Two weeks before inauguration, the president-elect threatens “very high tariffs” on Denmark. The shock in Copenhagen is total.',
source: 'Mar-a-Lago · Press Conference' },
{ date: 'May 2025', kind: 'Rupture', accent: 'crimson',
hed: 'Microsoft cuts off the ICC chief prosecutor\'s <em>email.</em>',
body: 'A US tech company, complying with a US executive order, disables the digital life of an officer of an international tribunal in the Netherlands. The “kill switch” stops being theoretical.',
source: 'Associated Press · The Hague' },
{ date: 'June 2025', kind: 'Regulation', accent: 'copper',
hed: 'Microsoft admits under oath: it cannot guarantee European <em>sovereignty.</em>',
body: 'Even data on European soil, with European staff, encrypted with European keys — US authorities can compel disclosure under the CLOUD Act. The legal fiction collapses.',
source: 'French Senate Hearing · Paris' },
{ date: 'June 2025', kind: 'Field Note', accent: 'ochre',
hed: 'Copenhagens Microsoft bill jumps <em>72% in five years.</em>',
body: 'From 313 to 538 million Danish kroner. Copenhagen and Aarhus announce they will leave Microsoft entirely. The minister of emergency tells companies: “create exit plans for cloud services.”',
source: 'Copenhagen Municipality · Finance Report' },
{ date: 'Summer 2025', kind: 'Regulation', accent: 'copper',
hed: 'A Danish minister tells industry: prepare your exit plans for <em>cloud services.</em>',
body: 'Caroline Stage Olsen begins moving her ministry off Microsoft 365. The minister of emergency preparedness urges every Danish company to do the same. Continued dependence is now classified as a vulnerability.',
source: 'Danish Ministry of Digital Affairs' },
{ date: 'August 2025', kind: 'Rupture', accent: 'crimson',
hed: 'Trump threatens tariffs against any country with digital <em>regulations.</em>',
body: '“American technology is not the worlds piggy bank.” The DSA, the DMA, the AI Act — all reframed as discriminatory trade barriers. Chip export restrictions are added to the list of consequences.',
source: 'Truth Social · Washington' },
// 2026
{ date: 'January 2026', kind: 'Rupture', accent: 'crimson',
hed: 'Trump imposes tariffs on Denmark and seven <em>European nations.</em>',
body: '10% in February. 25% from June — until Denmark cedes Greenland. Denmark, Norway, Sweden, Finland, France, Germany, Netherlands, UK. The post-war alliance, weaponised.',
source: 'Presidential Executive Order' },
{ date: 'January 2026', kind: 'Rupture', accent: 'crimson',
hed: 'Denmark names the United States as a national security <em>threat.</em>',
body: 'For the first time in history, the official Danish threat assessment lists the US alongside Russia and China. Defence committee chair Rasmus Jarlov tells Washington: “You are the threat. Not them.”',
source: 'Danish Defence Intelligence · FE' },
{ date: 'February 2026', kind: 'Product', accent: 'terracotta',
hed: 'The court that prosecutes war crimes can no longer use American <em>software.</em>',
body: 'The ICC migrates to OpenDesk — an open-source suite delivered by the German Centre for Digital Sovereignty. If a global tribunal cannot trust Microsoft, the implication for every other European institution is unavoidable.',
source: 'Handelsblatt · The Hague' },
{ date: 'Q1 2026', kind: 'Regulation', accent: 'copper',
hed: 'Europe drafts a sovereignty law as US firms still hold 70% of the <em>cloud.</em>',
body: 'Europe writes rules for infrastructure it does not own. US hyperscalers add €10 billion of European capacity every quarter — more than Gaia-X spent in a decade. The servers stay in Texas. The AI models stay in California. The law changes neither.',
source: 'European Commission · Brussels' },
];
/* ─────────────────────────────────────────────────────────────
Timeline layout
───────────────────────────────────────────────────────────── */
const CARD_PITCH = 760; // horizontal spacing between card centers (doubled to fit 640-wide cards)
const LEFT_PAD = 720; // extra room so first above-card clears the title/sub
const RIGHT_PAD = 420;
const track = document.getElementById('tl-track');
const viewport = document.getElementById('tl-viewport');
const spine = document.getElementById('spine');
function buildTimeline() {
// Year ticks
const years = [...new Set(EVENTS.map(e => e.date.slice(-4)))].sort();
// Build cards
EVENTS.forEach((e, i) => {
const x = LEFT_PAD + i * CARD_PITCH;
const above = i % 2 === 0;
const card = document.createElement('article');
card.className = 'evt ' + (above ? 'above' : 'below');
card.dataset.accent = e.accent;
card.style.left = (x - 320) + 'px';
card.innerHTML = `
<span class="node"></span>
<h3>${e.hed}</h3>
<p>${e.body}</p>
<div class="source">${e.source} · ${e.date}</div>
`;
track.appendChild(card);
});
// Year ticks
years.forEach(y => {
// place tick at first event of that year
const firstIdx = EVENTS.findIndex(e => e.date.endsWith(y));
const x = LEFT_PAD + firstIdx * CARD_PITCH - CARD_PITCH * 0.42;
const tick = document.createElement('div');
tick.className = 'year-tick';
tick.style.left = x + 'px';
tick.innerHTML = `<span class="y">${y}</span>`;
track.appendChild(tick);
});
// Set track width
const totalW = LEFT_PAD + EVENTS.length * CARD_PITCH + RIGHT_PAD;
track.style.width = totalW + 'px';
}
buildTimeline();
/* Horizontal scroll + inertia — mousewheel-driven */
const state = {
target: 0,
current: 0,
max: 0,
rafPending: false,
};
function recalcMax() {
state.max = Math.max(0, track.offsetWidth - window.innerWidth);
}
recalcMax();
window.addEventListener('resize', recalcMax);
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function onWheel(e) {
// Prefer vertical delta (mousewheel); allow horizontal from trackpads too.
const dy = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
state.target = clamp(state.target + dy * 1.1, 0, state.max);
e.preventDefault();
kick();
// The hint class is toggled in applyScroll() based on state.target, so
// it dismisses on the first wheel tick (target jumps instantly) AND
// re-appears if the reader scrolls all the way back to the start.
}
function kick() {
if (state.rafPending) return;
state.rafPending = true;
requestAnimationFrame(tick);
}
function tick() {
const diff = state.target - state.current;
state.current += diff * 0.12; // soft inertia
if (Math.abs(diff) < 0.3) {
state.current = state.target;
state.rafPending = false;
} else {
requestAnimationFrame(tick);
}
applyScroll();
}
function applyScroll() {
const vw = window.innerWidth;
// Timeline starts offscreen to the right. At scroll=0 we add one full
// viewport of extra offset so no cards are visible; the hero caption has
// the page to itself. As the reader scrolls, this intro offset decays to
// zero and then the track scrolls normally.
const introTravel = vw;
const introProg = Math.min(1, state.current / introTravel);
const extraOffset = introTravel * (1 - introProg);
track.style.transform = `translate3d(${-state.current + extraOffset}px,0,0)`;
// Front-matter fades once the user has begun scrolling
const pageEl = document.getElementById('page-timeline');
pageEl.classList.toggle('is-scrolled', state.current > 40);
// Scroll-to-begin hint: tied to state.target (not state.current) so
// it leaves the moment the reader commits to a wheel tick, and comes
// back if they scroll all the way back to the start. A small threshold
// keeps it from flickering on tiny trackpad noise.
pageEl.classList.toggle('hint-dismissed', state.target > 10);
// Progress across the entire catalog (0 → 1)
const p = state.max > 0 ? state.current / state.max : 0;
// Globe opacity — peaks around the 40% mark, where the globe is doing
// its most descriptive work (mid-rotation between the Atlantic and Europe).
// Baseline 0.50, peak 0.78 at p=0.40, then softens back to 0.52 by the end.
const wrapEl = document.getElementById('globe-wrap');
if (wrapEl) {
const peak = 1 - Math.min(1, Math.abs(p - 0.40) / 0.40);
const opacity = 0.50 + peak * 0.28;
wrapEl.style.opacity = opacity.toFixed(3);
}
// Reveal cards (fade + up, one-way once visible).
// NB: account for the intro offset so cards don't arm while offscreen.
document.querySelectorAll('.evt').forEach(el => {
const left = parseFloat(el.style.left);
const onscreen = left - state.current + extraOffset;
if (onscreen < vw + 200 && onscreen > -600) {
el.classList.add('is-near');
}
});
// Continue button — visible once we're near the end and past the front matter
const nearEnd = state.max > 0 && (state.max - state.current) < (vw * 1.1);
const btn = document.getElementById('continue-btn');
if (btn) btn.classList.toggle('is-visible', nearEnd && state.current > 40);
// Globe rotation — from North America toward Europe as we advance.
const rotDeg = 95 + (-10 - 95) * p;
if (globe.setRotation) globe.setRotation(rotDeg);
// Screen label for commenting context
const idx = Math.min(EVENTS.length - 1,
Math.round(state.current / CARD_PITCH));
const slug = '01 Timeline · ' + String(idx + 1).padStart(2, '0') + '/' + EVENTS.length;
pageEl.setAttribute('data-screen-label', slug);
}
/* Keyboard navigation for accessibility */
window.addEventListener('keydown', (e) => {
if (document.getElementById('page-timeline').classList.contains('is-active')) {
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
state.target = clamp(state.target + CARD_PITCH, 0, state.max);
kick();
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
state.target = clamp(state.target - CARD_PITCH, 0, state.max);
kick();
} else if (e.key === 'Home') {
state.target = 0; kick();
} else if (e.key === 'End') {
state.target = state.max; kick();
}
}
});
viewport.addEventListener('wheel', onWheel, { passive: false });
/* Continue button — click routes to the Overview page (same dot-nav path) */
document.getElementById('continue-btn').addEventListener('click', () => {
const overBtn = document.querySelector('.dot-btn[data-target="page-overview"]');
if (overBtn) overBtn.click();
});
/* ─────────────────────────────────────────────────────────────
Globe — orthographic, paper-toned, top/bottom masked by CSS
───────────────────────────────────────────────────────────── */
const WORLD_URL = 'vendor/countries-110m.json';
const globe = { setRotation: null };
/* Kick once so initial cards reveal \u2014 deferred to next frame so layout settles
before transitions can arm. Then enable transitions on all cards. */
requestAnimationFrame(() => {
applyScroll();
requestAnimationFrame(() => {
document.querySelectorAll('.evt').forEach(el => el.classList.add('can-animate'));
});
});
(function initGlobe() {
const wrap = document.getElementById('globe-wrap');
buildGlobe(wrap, { rotateLambda: 95, rotatePhi: -10, setRotationTarget: globe });
// Second globe — sits behind the Overview page, centered on Europe.
// λ = -10° (central longitude over western Europe / Iberia)
// φ = -50° (in d3 this puts 50°N latitude at the SVG center; Germany / N. France)
const overviewWrap = document.getElementById('overview-globe');
if (overviewWrap) {
buildGlobe(overviewWrap, { rotateLambda: -10, rotatePhi: -50 });
}
})();
/* Build a single orthographic globe SVG inside `wrap`.
Options:
rotateLambda : initial longitude center (d3 convention: negate east)
rotatePhi : latitude tilt (d3: negate north latitude)
setRotationTarget : if supplied, exposes a setRotation(deg) on the object
*/
function buildGlobe(wrap, opts) {
const W = 1400, H = 1400;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const INK = '#383831';
const PAPER_LOW = '#ece5d2';
// Ocean
const ocean = document.createElementNS(svg.namespaceURI, 'circle');
ocean.setAttribute('cx', W/2); ocean.setAttribute('cy', H/2);
ocean.setAttribute('r', 560);
ocean.setAttribute('fill', PAPER_LOW);
ocean.setAttribute('opacity', '0.55');
svg.appendChild(ocean);
// defs clip
const defs = document.createElementNS(svg.namespaceURI, 'defs');
const clip = document.createElementNS(svg.namespaceURI, 'clipPath');
const clipId = 'globeClip-' + Math.random().toString(36).slice(2, 8);
clip.setAttribute('id', clipId);
const cc = document.createElementNS(svg.namespaceURI, 'circle');
cc.setAttribute('cx', W/2); cc.setAttribute('cy', H/2); cc.setAttribute('r', 560);
clip.appendChild(cc); defs.appendChild(clip); svg.appendChild(defs);
const g = document.createElementNS(svg.namespaceURI, 'g');
g.setAttribute('clip-path', `url(#${clipId})`);
svg.appendChild(g);
const gratPath = document.createElementNS(svg.namespaceURI, 'path');
gratPath.setAttribute('fill', 'none');
gratPath.setAttribute('stroke', INK);
gratPath.setAttribute('stroke-width', '0.8');
gratPath.setAttribute('opacity', '0.18');
g.appendChild(gratPath);
const countriesPath = document.createElementNS(svg.namespaceURI, 'path');
countriesPath.setAttribute('fill', '#faf6ee');
countriesPath.setAttribute('fill-opacity', '0.85');
countriesPath.setAttribute('stroke', 'none');
g.appendChild(countriesPath);
const bordersPath = document.createElementNS(svg.namespaceURI, 'path');
bordersPath.setAttribute('fill', 'none');
bordersPath.setAttribute('stroke', INK);
bordersPath.setAttribute('stroke-width', '0.8');
bordersPath.setAttribute('stroke-linejoin', 'round');
bordersPath.setAttribute('opacity', '0.55');
g.appendChild(bordersPath);
// Faint outer rim — tonal, not hard
const rim = document.createElementNS(svg.namespaceURI, 'circle');
rim.setAttribute('cx', W/2); rim.setAttribute('cy', H/2); rim.setAttribute('r', 560);
rim.setAttribute('fill', 'none');
rim.setAttribute('stroke', INK);
rim.setAttribute('stroke-width', '1');
rim.setAttribute('opacity', '0.22');
svg.appendChild(rim);
wrap.appendChild(svg);
fetch(WORLD_URL).then(r => r.json()).then(topo => {
const countries = window.topojson.feature(topo, topo.objects.countries);
const borders = window.topojson.mesh(topo, topo.objects.countries, (a,b) => a !== b);
const graticule = window.d3.geoGraticule().step([20, 20]);
const proj = window.d3.geoOrthographic()
.scale(560).translate([W/2, H/2])
.rotate([opts.rotateLambda, opts.rotatePhi ?? -10, 0]).clipAngle(90);
const pathFn = window.d3.geoPath(proj);
function render() {
gratPath.setAttribute('d', pathFn(graticule()) || '');
countriesPath.setAttribute('d', pathFn(countries) || '');
bordersPath.setAttribute('d', pathFn(borders) || '');
}
render();
if (opts.setRotationTarget) {
const phi = opts.rotatePhi ?? -10;
opts.setRotationTarget.setRotation = function(deg) {
proj.rotate([deg, phi, 0]);
render();
};
}
}).catch(err => {
console.warn('globe topology failed to load', err);
});
}
/* ─────────────────────────────────────────────────────────────
Dot-nav + Bifrost lazy-boot on Overview activation
───────────────────────────────────────────────────────────── */
/**
* Switch active page. When Overview becomes active, boot Bifrost (once)
* and optionally scroll the Overview's internal scroller to a target.
*
* @param {string} targetId e.g. "page-timeline" or "page-overview"
* @param {string?} scrollToId (Overview only) id of a scene to land on:
* "hero", "bifrost", "bifrost-meaning"
*/
function activatePage(targetId, scrollToId) {
document.querySelectorAll('.page').forEach(p => {
p.classList.toggle('is-active', p.id === targetId);
});
if (targetId === 'page-overview') {
if (window.__bifrost && typeof window.__bifrost.init === 'function') {
// Let the page transition start painting first, then init.
// 60ms is about 1.5 frames at 120Hz — plenty for the
// .page-overview.is-active class to flip, short enough that
// the user can't scroll before ScrollTriggers are wired up.
setTimeout(() => {
window.__bifrost.init();
// Run platform.js's init in the SAME tick. Without this,
// platform's own MutationObserver fires it ~140ms after
// page activation — too late for the scrollTo below, which
// reads target.offsetTop. Platform installs pin spacers for
// #platform-cards and #platform-roadmap; those spacers shift
// sections downstream of cards by 150vh, so scrolling to
// pre-spacer offsetTop lands the user in empty space.
// __platform.init is idempotent (guarded by `initialized`),
// so calling it here is a no-op if it already ran.
if (window.__platform && typeof window.__platform.init === 'function') {
window.__platform.init();
}
// After init resolves, scroll to the requested scene (or top
// if none specified). Bifrost exposes scrollTo() which drives
// Lenis on the overview's internal scroller.
if (window.__bifrost.scrollTo) {
// One more frame so Lenis has picked up its first measurements.
requestAnimationFrame(() => window.__bifrost.scrollTo(scrollToId || 'hero'));
}
}, 60);
}
}
}
/**
* Update the .dot-btn.is-active highlight. Called on nav clicks (forward
* activation) and will be called from bifrost.js on scroll to track which
* Overview scene is currently visible.
*/
function setActiveDot(targetId, scrollToId) {
document.querySelectorAll('.dot-btn').forEach(b => {
// External-target dots (e.g. Welcome) never show as active — they're
// navigation links, not section markers.
if ((b.dataset.target || '').startsWith('external-')) {
b.classList.remove('is-active');
return;
}
const pageMatch = b.dataset.target === targetId;
const scrollMatch = (b.dataset.scrollTo || '') === (scrollToId || '');
// Timeline dot has no data-scroll-to — it's active when page-timeline
// is active. Overview dots only match their specific scrollToId.
const isActive = targetId === 'page-overview'
? pageMatch && scrollMatch
: pageMatch;
b.classList.toggle('is-active', isActive);
});
}
// Expose so bifrost.js can call it from its scroll-spy. Reads happen from
// non-module code, so the global is the simplest integration surface.
window.__setActiveDot = setActiveDot;
document.querySelectorAll('.dot-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const scrollToId = btn.dataset.scrollTo || null;
// External targets navigate away rather than switching pages.
// Used by the "Welcome" dot (routes back to the entrance page at /),
// where the user sees the welcome step if their session is valid.
if (targetId && targetId.startsWith('external-')) {
const href = btn.dataset.href || '/';
window.location.href = href;
return;
}
setActiveDot(targetId, scrollToId);
activatePage(targetId, scrollToId);
});
});
/* ─────────────────────────────────────────────────────────────
First name propagation — fetched from /auth/me on load.
Used by Scene 3 ("This is why we've invited you, [Name].").
Bifrost.js looks for window.__fenjaFirstName at init time and
rewrites the .words sentence before priming the fly-in animation.
───────────────────────────────────────────────────────────── */
(async function fetchFirstName() {
try {
const res = await fetch('/auth/me', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json().catch(() => ({}));
window.__fenjaFirstName = data.firstName || null;
}
} catch {
// Offline — leave undefined; bifrost.js falls back to the
// no-name variant of the sentence.
}
})();