// ───────────────────────────────────────────────────────────── // 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 = [ // 2022 { date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre', hed: 'Europe begins buying a future in a language it does not write.', 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 historic.', 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 elsewhere.', 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 intern.', 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 on.', 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 sharpens them.', body: 'Trilogues yield real penalties for general-purpose models; lobbying intensifies around exemptions for “foundational” 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 friendship.', 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 summary.', 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 safe.', 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 sleeps.', 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 extraterritorial.', 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 beginning.', 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 roadmap.', 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 revoke.', 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 timetable.', 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 ceremony.', 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 “foundation-stone” initiatives, and Europe exhales carefully.', 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 ten.', 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, electrical.', 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 writing.', 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 everything.', 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 small.', 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 begun.', 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 = `
${e.date} ${e.kind}

${e.hed}

${e.body}

${e.source}
`; 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 = `${y}`; 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); }); } /* ───────────────────────────────────────────────────────────── 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. } })();