customer-presentation/protected/timeline.js
Arlind Ukshini 9b2c166b6c timeline + overview: pacing, labeling, and viewing guidance
Timeline page:
- .page-sub: final sentence wrapped in .page-sub-accent and styled
  crimson so the rhetorical beat lifts off the block.
- Scroll-to-begin hint is now toggled from applyScroll() against
  state.target, so it dismisses on first commit AND re-appears when
  the reader scrolls all the way back to the start. onWheel no longer
  hard-adds .hint-dismissed; the applyScroll toggle drives both ways.

Overview page:
- Hero scroll icon: size and presence bumped (2px line, 44px tall, 11px
  chevron, weight 600, color:var(--ink)) so it reads as a confident
  cue, not a whisper at the bottom of the hero.
- Architecture scene gains a title bar pinned with the scene: "The
  Fenja AI platform in four steps" with a 1/4 → 4/4 counter driven by
  the scroll-trigger onUpdate. Bar is placed below the site-mark's
  fixed position so the two don't collide.
- Dot-nav: dot size 5px → 10px (1.5px ring) for better click target +
  visual weight. Buttons for "Words" and "Participate" removed — the
  corresponding intermediate sections now map to their nearest
  surviving dot in bifrost.js's scroll-spy (words-scene →
  stack-scene, bifrost-meaning → bifrost).
- Renames: "Hero" → "Fenja introduction", "Architecture" →
  "Capabilities", "Bifrost" → "Project Bifrost".
- scrollTo() adds a per-scene SCENE_ANCHOR_OFFSET — stack-scene lands
  +2100px into its 5000px pin so the reader arrives on the fully
  stacked state instead of an empty pre-animation frame.

Welcome step (public/entrance.html):
- New .welcome-note callout between definitions and CTA advising
  desktop viewing and gentle scrolling so readers don't fly past
  animated sections before they've resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:45:24 +02:00

474 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();
// 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", "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.
}
})();