customer-presentation/protected/timeline.js
Arlind Ukshini f2f0f8a43e Merge Project Bifrost scenes into the Overview page
Six scroll-bound scenes (hero, architecture stack, words fly-in,
aurora arc, treasure-map, join CTA) now live inside page-overview,
above the existing 23-headline timeline. The Europe map stays as a
static background that fades with scroll.

- protected/index.html: rewrote #page-overview only; timeline and
  archive sections unchanged. Site-2 palette re-mapped to site-1
  Nordic Editorial tokens, Fraunces to Newsreader, tokens scoped
  to #page-overview.
- protected/timeline.js: dot-nav boots window.__bifrost.init()
  on first Overview activation. Added .js class on documentElement.
- protected/bifrost.js (new): Lenis + ScrollTrigger wired to the
  overview's internal scroller via scrollerProxy; drives Europe
  map opacity on scroll.
- protected/vendor/{lenis,gsap,scrolltrigger}.min.js (new):
  extracted from site-2's inlined vendor blobs; CSP-compliant.
- protected/fenja/illustrations/{community,council,pilot}.svg
  (new): treasure-map stop images.

No changes to src/, server.js, deploy/, or public/. CSP stays
strict (script-src 'self'); zero inline scripts added. Auth gate
and session model untouched.
2026-04-22 17:48:44 +02:00

457 lines
22 KiB
JavaScript

// ─────────────────────────────────────────────────────────────
// protected/timeline.js — timeline scroll, dot-nav, globe, archive.
// This file now 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.
// ─────────────────────────────────────────────────────────────
// 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 = [
// 2022
{ date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre',
hed: 'Europe begins buying a future in a language it does not <em>write.</em>',
body: 'Three national ministries sign framework agreements for cloud compute measured in dollars, datacenters located far from home.',
source: 'Le Monde, Archive' },
{ date: '02 Jun 2022', kind: 'Regulation', accent: 'copper',
hed: 'Brussels publishes the first draft of an act it calls quietly <em>historic.</em>',
body: 'Early text of what will become the AI Act is circulated to member states; no one yet uses the word sovereignty in public.',
source: 'EUR-Lex, COM(2022)206' },
{ date: '11 Oct 2022', kind: 'Field Note', accent: 'ochre',
hed: 'A Danish hospital lists twelve assistants, all hosted <em>elsewhere.</em>',
body: 'An internal audit at Rigshospitalet finds twelve pilot tools in use across radiology and oncology; none run on Danish soil.',
source: 'Ugeskrift for Læger' },
// 2023
{ date: '30 Jan 2023', kind: 'Product', accent: 'terracotta',
hed: 'A San Francisco chatbot quietly becomes the continent\u2019s unofficial <em>intern.</em>',
body: 'Ministries, law firms and universities report staff pasting confidential material into a free web tool. Legal teams issue circulars.',
source: 'Financial Times' },
{ date: '22 Apr 2023', kind: 'Rupture', accent: 'crimson',
hed: 'Italy switches off the assistant, then switches it back <em>on.</em>',
body: 'A four-week ban tests the limits of national action against a foreign-hosted model; a truce is brokered behind closed doors.',
source: 'Il Sole 24 Ore' },
{ date: '14 Jul 2023', kind: 'Regulation', accent: 'copper',
hed: 'The Act grows teeth, and so does the debate about who <em>sharpens them.</em>',
body: 'Trilogues yield real penalties for general-purpose models; lobbying intensifies around exemptions for &ldquo;foundational&rdquo; providers.',
source: 'Politico Europe' },
{ date: '06 Sep 2023', kind: 'Infrastructure', accent: 'ochre',
hed: 'A hyperscaler lays its fourth European campus, and calls it an act of <em>friendship.</em>',
body: 'Ground is broken in a small Irish town; the ribbon is cut by two prime ministers and, later, unofficially, the firm\u2019s lawyers.',
source: 'Reuters' },
{ date: '01 Dec 2023', kind: 'Field Note', accent: 'ochre',
hed: 'A European bank tallies its reliance and publishes only the <em>summary.</em>',
body: 'Internal mapping exercise counts 41 AI integrations, 38 of them contingent on a single non-EU cloud provider.',
source: 'Handelsblatt' },
// 2024
{ date: '19 Feb 2024', kind: 'Regulation', accent: 'copper',
hed: 'The Act is adopted, applauded, and then filed somewhere <em>safe.</em>',
body: 'Parliament passes the final text; implementation is deferred to 2026, giving incumbents eighteen months of grace.',
source: 'Official Journal' },
{ date: '08 Apr 2024', kind: 'Infrastructure', accent: 'ochre',
hed: 'A French lab pauses its rollout and asks, in writing, where its data <em>sleeps.</em>',
body: 'CNRS requests residency guarantees for a ministry-wide assistant; the provider replies with a thirty-page legal memorandum.',
source: 'CNRS Bulletin' },
{ date: '21 May 2024', kind: 'Rupture', accent: 'crimson',
hed: 'A foreign court compels disclosure, and a continent learns the meaning of <em>extraterritorial.</em>',
body: 'Under the CLOUD Act, records held on European servers are produced for a foreign proceeding; European data-protection authorities protest.',
source: 'New York Times' },
{ date: '02 Jul 2024', kind: 'Product', accent: 'terracotta',
hed: 'An open-source model is trained in Paris and called, hopefully, a <em>beginning.</em>',
body: 'A French laboratory releases a 22-billion-parameter model under a permissive license; adoption by ministries is briefly debated.',
source: 'Le Figaro' },
{ date: '15 Sep 2024', kind: 'Field Note', accent: 'ochre',
hed: 'A Nordic ministry asks for a sovereign option, and receives a <em>roadmap.</em>',
body: 'Danish procurement office publishes an RFI for on-premise AI platforms; fourteen vendors respond, four of them European.',
source: 'Digitaliseringsstyrelsen' },
{ date: '30 Nov 2024', kind: 'Rupture', accent: 'crimson',
hed: 'A new U.S. administration promises to review, renegotiate and in some cases <em>revoke.</em>',
body: 'Transition teams signal appetite to revisit the transatlantic data framework; European counsel spend December on contingency memos.',
source: 'Bloomberg' },
// 2025
{ date: '22 Jan 2025', kind: 'Field Note', accent: 'ochre',
hed: 'A quiet exodus begins, with no press release and no <em>timetable.</em>',
body: 'Several EU agencies move sensitive workloads off non-EU clouds and into operational hardware hosted by smaller, domestic providers.',
source: 'Politico Europe' },
{ date: '18 Mar 2025', kind: 'Infrastructure', accent: 'copper',
hed: 'Fenja AI is incorporated in Copenhagen, on a Monday, without <em>ceremony.</em>',
body: 'A small team begins work on an on-premise AI platform intended for critical sectors — health, finance, energy, public administration.',
source: 'Erhvervsstyrelsen' },
{ date: '04 May 2025', kind: 'Regulation', accent: 'copper',
hed: 'The Commission funds three &ldquo;foundation-stone&rdquo; initiatives, and Europe exhales <em>carefully.</em>',
body: 'Digital Europe program awards grants to sovereign-infrastructure consortia, including a Nordic group led out of Denmark.',
source: 'DG CNECT' },
{ date: '27 Jul 2025', kind: 'Product', accent: 'terracotta',
hed: 'A regulator issues a fine of nine figures, and a precedent of <em>ten.</em>',
body: 'The Irish Data Protection Commissioner fines a major U.S. provider over transfer mechanisms; the ruling is widely read as pivotal.',
source: 'DPC Ireland' },
{ date: '14 Sep 2025', kind: 'Rupture', accent: 'crimson',
hed: 'A multi-day outage reminds a continent that sovereignty is, among other things, <em>electrical.</em>',
body: 'A regional cloud incident takes portions of public services offline for thirty-one hours; emergency plans are reviewed everywhere.',
source: 'FAZ' },
{ date: '03 Nov 2025', kind: 'Field Note', accent: 'ochre',
hed: 'Procurement teams begin to ask a new question, and in <em>writing.</em>',
body: 'Tender documents across six member states add an explicit residency clause; three require open-source inference and local hosting.',
source: 'TED — Tenders Electronic Daily' },
// 2026
{ date: '12 Jan 2026', kind: 'Product', accent: 'copper',
hed: 'Fenja announces Project Bifrost, named after the bridge that crossed <em>everything.</em>',
body: 'A client-hosted, open-source AI platform designed for regulated European sectors; first deployments announced for Q2.',
source: 'Fenja AI — Field Notes III' },
{ date: '28 Feb 2026', kind: 'Field Note', accent: 'copper',
hed: 'The first hospital signs, and asks that the news be kept <em>small.</em>',
body: 'A regional Danish health authority adopts the platform for radiology triage; the announcement is a single sentence on page nine.',
source: 'Region Hovedstaden' },
{ date: '14 Apr 2026', kind: 'Editorial', accent: 'copper',
hed: 'A catalog is closed, and a second one is quietly <em>begun.</em>',
body: 'The editors mark this issue closed, pending the events of the coming year, which will arrive on their own schedule.',
source: 'Fenja AI — Field Notes IV' },
];
/* ─────────────────────────────────────────────────────────────
Timeline layout
───────────────────────────────────────────────────────────── */
const CARD_PITCH = 380; // horizontal spacing between card centers
const LEFT_PAD = 520; // extra room so first above-card clears the title/sub
const RIGHT_PAD = 280;
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 - 160) + 'px';
card.innerHTML = `
<span class="node"></span>
<div class="tag-row">
<span class="date">${e.date}</span>
<span class="kind">${e.kind}</span>
</div>
<h3>${e.hed}</h3>
<p>${e.body}</p>
<div class="source">${e.source}</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();
}
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);
});
}
/* ─────────────────────────────────────────────────────────────
Archive table
───────────────────────────────────────────────────────────── */
(function buildArchive() {
const tbody = document.getElementById('archive-body');
EVENTS.forEach((e, i) => {
const tr = document.createElement('tr');
tr.dataset.accent = e.accent;
tr.innerHTML = `
<td class="num">${String(i + 1).padStart(2, '0')}</td>
<td class="date">${e.date}</td>
<td class="kind">${e.kind}</td>
<td class="hed">${e.hed}</td>
<td class="src">${e.source}</td>
`;
tbody.appendChild(tr);
});
})();
/* ─────────────────────────────────────────────────────────────
Dot-nav + Bifrost lazy-boot on Overview activation
───────────────────────────────────────────────────────────── */
// Switch active page. When Overview becomes active, fire
// window.__bifrost.init() (idempotent) so the scenes wire up
// exactly once, on first visit. Subsequent activations just
// refresh ScrollTrigger in case the window was resized.
function activatePage(targetId) {
document.querySelectorAll('.page').forEach(p => {
p.classList.toggle('is-active', p.id === targetId);
});
document.querySelectorAll('.dot-btn').forEach(b => {
b.classList.toggle('is-active', b.dataset.target === targetId);
});
if (targetId === 'page-overview' && window.__bifrost && typeof window.__bifrost.init === 'function') {
// Let the page transition start painting first, then init.
// 60ms is about one-and-a-half 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(), 60);
}
}
document.querySelectorAll('.dot-btn').forEach(btn => {
btn.addEventListener('click', () => activatePage(btn.dataset.target));
});