customer-presentation/protected/timeline.js
2026-04-23 16:42:34 +02:00

471 lines
22 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();
// Dismiss the scroll hint on the very first wheel tick — instant fade.
// A separate class from `.is-scrolled` (which keys off 40px of travel
// and controls the front-matter) so the hint goes the moment the
// reader commits, not after inertia catches up.
document.getElementById('page-timeline')?.classList.add('hint-dismissed');
}
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);
// 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", "stack-scene", "words-scene",
* "bifrost", "bifrost-meaning", "bifrost-join"
*/
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();
// 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.
}
})();