Skip to content

Instantly share code, notes, and snippets.

@jdrew1303
Created May 10, 2026 13:04
Show Gist options
  • Select an option

  • Save jdrew1303/e3944932e0b78326c782ed05e8f43bac to your computer and use it in GitHub Desktop.

Select an option

Save jdrew1303/e3944932e0b78326c782ed05e8f43bac to your computer and use it in GitHub Desktop.
Untitled
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<title>The Invisible Web — Undersea Cables & the New Cold War</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&display=swap" rel="stylesheet">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.min.css"></noscript>
<script async src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.min.js"></script>
<script async src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* =========================================================================
DESIGN TOKENS
========================================================================= */
:root {
--ink: #0e0e0f;
--paper: #f4f1eb;
--cream: #ede9e0;
--border: #d4cfc4;
--accent-cold: #1a3a5c;
--accent-warm: #c84b31;
--accent-signal:#00c9a7;
--mono-dim: #5a6470;
--page-max: 100%;
}
@media (min-width: 46.25em) { :root { --page-max: 740px; } }
@media (min-width: 61.25em) { :root { --page-max: 980px; } }
@media (min-width: 71.25em) { :root { --page-max: 1200px; } }
@media (min-width: 81.25em) { :root { --page-max: 1380px; } }
/* =========================================================================
BASE
========================================================================= */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0; padding: 0;
background: #1a1a1e;
color: var(--ink);
font-family: 'JetBrains Mono', monospace;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
/* =========================================================================
PAGE CONTAINER
========================================================================= */
.page-container {
position: relative;
margin: 0 auto;
max-width: var(--page-max);
background: var(--paper);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
@media (min-width: 61.25em) {
.page-container::after {
content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
width: 1px; background: var(--border); z-index: 5; pointer-events: none;
}
}
/* =========================================================================
OVERLINE / SECTION LABELS
========================================================================= */
.overline {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent-warm);
margin: 0 0 18px;
}
/* =========================================================================
HERO
========================================================================= */
.article-hero {
position: relative;
background: #050810;
border-bottom: 1px solid var(--border);
isolation: isolate;
z-index: 10;
}
.article-hero::before {
content: ""; position: absolute; inset: 0;
background: transparent; z-index: 6; pointer-events: none;
}
/* Top bar */
.hero-topbar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 20;
padding: 18px 28px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
}
.hero-topbar .pub-name {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.05rem;
font-weight: 900;
font-style: italic;
color: #fff;
letter-spacing: 0.02em;
}
.hero-topbar .pub-meta {
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(255,255,255,0.4);
}
/* Three.js canvas fills hero */
.hero-media {
position: sticky;
top: 0;
height: 100vh;
width: 100%;
overflow: hidden;
background: #050810;
will-change: transform;
}
.hero-media canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
/* Gradient overlay to darken bottom for text legibility */
.hero-media::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to bottom,
rgba(5,8,16,0.1) 0%,
rgba(5,8,16,0.0) 40%,
rgba(5,8,16,0.7) 80%,
rgba(5,8,16,0.92) 100%);
pointer-events: none;
z-index: 2;
}
.hero-furniture {
position: relative;
z-index: 10;
margin-top: -100vh;
pointer-events: none;
padding-bottom: 12vh;
}
.hero-furniture > * { pointer-events: auto; }
.hero-headline {
padding-top: 52vh;
padding-left: 28px;
padding-right: 28px;
}
.hero-kicker {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent-signal);
margin-bottom: 16px;
display: block;
}
.hero-headline h1 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 900;
font-style: italic;
color: #fff;
margin: 0 0 20px;
max-width: 640px;
line-height: 1.05;
font-size: clamp(2.4rem, 5vw, 4.2rem);
text-shadow: 0 2px 40px rgba(0,0,0,0.6);
}
.hero-rule {
width: 48px;
height: 3px;
background: var(--accent-warm);
margin-bottom: 20px;
}
.hero-standfirst {
padding: 0 28px 20vh 28px;
max-width: 580px;
}
.hero-standfirst p {
font-family: 'JetBrains Mono', monospace;
font-size: 0.88rem;
line-height: 1.7;
color: rgba(255,255,255,0.7);
margin: 0;
}
.hero-byline {
padding: 0 28px;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.35);
font-style: normal; /* address resets italic by default */
}
@media (min-width: 71.25em) {
.hero-headline,
.hero-standfirst,
.hero-byline { padding-left: 64px; }
}
/* =========================================================================
SCROLLYTELLING SECTIONS
========================================================================= */
.scroll-section {
position: relative;
border-bottom: 1px solid var(--border);
}
@media (min-width: 61.25em) {
.scroll-section {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas: "text media";
}
.scroll-section--reversed {
grid-template-areas: "media text";
}
}
/* Text column */
.scroll-text {
padding: 10vh 28px;
z-index: 10;
position: relative;
background: var(--paper);
}
@media (min-width: 61.25em) {
.scroll-text { grid-area: text; padding: 14vh 56px 24vh 56px; }
.scroll-section--reversed .scroll-text { padding: 14vh 56px 24vh 56px; }
}
/* Section headings — h2 sits directly in .scroll-text now, no wrapper needed */
.scroll-text h2 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 900;
font-size: clamp(1.8rem, 3.5vw, 2.8rem);
margin: 0 0 28px;
line-height: 1.1;
color: var(--ink);
}
.scroll-text h2 em {
font-style: italic;
color: var(--accent-cold);
}
.body-text {
font-size: 0.88rem;
line-height: 1.85;
margin-bottom: 1.4rem;
max-width: 480px;
color: #2c2c2e;
}
.pull-quote {
border-left: 3px solid var(--accent-warm);
padding-left: 18px;
margin: 32px 0;
font-family: 'Playfair Display', Georgia, serif;
font-style: italic;
font-size: 1.1rem;
line-height: 1.5;
color: var(--accent-cold);
max-width: 440px;
}
/* Browser default margin reset for figure elements used as layout columns */
figure { margin: 0; }
/* =========================================================================
STAT SLIDES — full-width interstitial cards
─────────────────────────────────────────────────────────────────────────
Four copy-paste variants. All share the base .stat-slide class which:
· Sets position: relative + z-index: 6 → suppresses centre-line bleed
· Sets overflow: hidden for contained animations
─────────────────────────────────────────────────────────────────────────
Variants:
.stat-slide--arrow → split dark/lime, directional gradient cone
.stat-slide--dot-pop → split dark, dot-grid with staggered pop-in
.stat-slide--count-up → full green, number counts up, drifting shapes
.stat-slide--spotlight → split dark, proportional balls inside circle
========================================================================= */
.stat-slide {
position: relative;
z-index: 6;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
/* Shared attribution line */
.stat-source {
font-size: 0.62rem;
color: rgba(255,255,255,0.25);
font-family: 'JetBrains Mono', monospace;
margin: 0;
}
/* ── VARIANT 1: Arrow cone ─── US -33% coal ──────────────────────────────
Black left panel, lime right panel.
On scroll-in, a soft blurry blue cone slides down from the top of the
right panel (pointing ▼ for decrease, flip with --up modifier for increase).
─────────────────────────────────────────────────────────────────────── */
.stat-slide--arrow {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 440px;
background: #000;
}
@media (max-width: 61.24em) { .stat-slide--arrow { grid-template-columns: 1fr; } }
.stat-slide--arrow .stat-text {
padding: 56px 48px;
display: flex; flex-direction: column; justify-content: space-between;
}
.stat-slide--arrow .stat-country {
font-family: 'Playfair Display', Georgia, serif;
font-size: 2.1rem; font-weight: 700;
color: #a8e06a;
text-decoration: underline;
text-decoration-color: #a8e06a;
text-underline-offset: 5px;
margin: 0 0 12px;
}
.stat-slide--arrow .stat-descriptor {
font-size: 1.15rem; font-weight: 600;
color: #fff; line-height: 1.3;
margin: 0;
}
/* Right panel — lime background, cone lives here */
.stat-slide--arrow .stat-visual {
position: relative; overflow: hidden;
background: #c8e86b;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
}
/*
* CONE ANIMATION
* ──────────────
* The cone starts fully above the panel (translateY -110%) and slides
* into position when .is-animating is added by the IntersectionObserver.
*
* Pointing DOWN (▼) for decrease: clip-path narrows toward the bottom.
* Use .stat-arrow-cone--up and set bottom: 0 instead for increases.
*/
.stat-arrow-cone {
position: absolute;
top: 0; left: 50%;
width: 100%; height: 72%;
transform: translateX(-50%) translateY(-112%);
background: radial-gradient(ellipse 55% 85% at 50% 5%, #3a8fff 0%, #1a50cc 18%, transparent 68%);
filter: blur(22px);
opacity: 0;
clip-path: polygon(28% 0%, 72% 0%, 100% 100%, 0% 100%); /* ▼ decrease */
transition: transform 1.5s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.7s ease;
pointer-events: none;
will-change: transform, opacity;
}
.stat-slide--arrow.is-animating .stat-arrow-cone {
transform: translateX(-50%) translateY(0%);
opacity: 0.82;
}
/* ▲ increase variant — flip to rise from the bottom */
.stat-arrow-cone--up {
top: auto; bottom: 0;
clip-path: polygon(0% 0%, 100% 0%, 72% 100%, 28% 100%);
transform: translateX(-50%) translateY(112%);
background: radial-gradient(ellipse 55% 85% at 50% 95%, #ff6b35 0%, #cc3010 18%, transparent 68%);
}
.stat-slide--arrow.is-animating .stat-arrow-cone--up {
transform: translateX(-50%) translateY(0%);
}
.stat-slide--arrow .stat-number {
font-size: clamp(4rem, 9vw, 7rem);
font-weight: 900; color: #000;
font-family: 'JetBrains Mono', monospace;
letter-spacing: -0.04em; line-height: 1;
margin: 0; position: relative; z-index: 1;
}
.stat-slide--arrow .stat-label {
font-size: 0.62rem; letter-spacing: 0.2em;
text-transform: uppercase; color: rgba(0,0,0,0.5);
margin: 0; position: relative; z-index: 1;
}
/* ── VARIANT 2: Dot pop grid ─── Germany 51% ─────────────────────────────
Black throughout. Left: large yellow number + prose.
Right: NxM dot grid split into two color bands (yellow top, purple bottom).
Vertical legend labels sit flush left of the grid.
Dots start at scale(0) and spring to scale(1) with random staggered delay.
─────────────────────────────────────────────────────────────────────── */
.stat-slide--dot-pop {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 440px;
background: #000;
}
@media (max-width: 61.24em) { .stat-slide--dot-pop { grid-template-columns: 1fr; } }
.stat-slide--dot-pop .stat-text {
padding: 52px 44px;
display: flex; flex-direction: column; justify-content: space-between;
}
.stat-slide--dot-pop .stat-number {
font-size: clamp(4rem, 10vw, 7.5rem);
font-weight: 900; color: #e8e84a;
font-family: 'JetBrains Mono', monospace;
letter-spacing: -0.04em; line-height: 0.85;
margin: 0 0 20px;
}
.stat-slide--dot-pop .stat-descriptor {
font-size: 1.05rem; font-weight: 500;
color: #fff; line-height: 1.45; max-width: 280px; margin: 0;
}
.stat-slide--dot-pop .stat-descriptor strong {
text-decoration: underline;
text-decoration-color: #e8e84a;
text-underline-offset: 3px;
}
/* Right panel container — legend + grid side-by-side */
.stat-dot-panel {
display: flex;
align-items: stretch;
padding: 28px 28px 28px 8px;
gap: 10px;
}
/* Vertical legend column */
.stat-dot-legends {
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 0;
flex-shrink: 0;
}
.stat-dot-legend-label {
writing-mode: vertical-rl;
transform: rotate(180deg); /* text reads upward */
font-size: 0.52rem;
letter-spacing: 0.16em;
text-transform: uppercase;
white-space: nowrap;
display: flex;
align-items: flex-start;
}
.stat-dot-legend-label--yellow { color: #e8e84a; }
.stat-dot-legend-label--purple { color: #8b7ff5; }
/* The actual dot grid — columns/rows set by JS */
.stat-dot-grid {
flex: 1;
display: grid;
gap: 6px;
align-content: start;
}
/*
* DOT CELLS
* ──────────
* All start at scale(0). JS adds transition with a random delay to each,
* then the IntersectionObserver sets scale(1) when the slide comes into view.
* The spring easing (cubic-bezier 0.34 1.56) gives the "pop".
*/
.stat-dot-cell {
aspect-ratio: 1;
border-radius: 50%;
transform: scale(0);
}
/* ── VARIANT 3: Count-up ─── India 145th ────────────────────────────────
Forest green background. Left: headline text with count-up number.
Right: decorative orange semi-circles drift slowly on viewport entry.
─────────────────────────────────────────────────────────────────────── */
.stat-slide--count-up {
min-height: 400px;
background: #4a7c45;
display: grid;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 61.24em) {
.stat-slide--count-up { grid-template-columns: 1fr; }
}
.stat-slide--count-up .stat-text {
padding: 60px 48px;
display: flex; flex-direction: column; justify-content: space-between;
position: relative; z-index: 1;
}
.stat-slide--count-up .stat-headline {
font-size: 1.2rem; font-weight: 500;
color: #fff; line-height: 1.4; margin: 0 0 24px;
}
.stat-slide--count-up .stat-country-inline {
font-weight: 700;
text-decoration: underline;
text-decoration-color: rgba(255,255,255,0.55);
text-underline-offset: 3px;
}
.stat-slide--count-up .stat-number {
font-size: clamp(5rem, 11vw, 9rem);
font-weight: 900; color: #e8834a;
font-family: 'JetBrains Mono', monospace;
letter-spacing: -0.04em; line-height: 0.88;
display: inline;
}
.stat-slide--count-up .stat-ordinal {
font-size: clamp(1.6rem, 3.5vw, 3rem);
font-weight: 900; color: #e8834a;
font-family: 'JetBrains Mono', monospace;
vertical-align: super; line-height: 1;
}
.stat-number-rule {
border: none;
border-top: 2px dotted rgba(255,255,255,0.3);
margin: 18px 0 16px; max-width: 240px;
}
.stat-slide--count-up .stat-descriptor {
font-size: 1.2rem; font-weight: 600;
color: #000; line-height: 1.3; margin: 0;
}
/* Decorative shapes — three orange discs, overflow-clipped on the right */
.stat-shapes-panel { position: relative; overflow: hidden; }
.stat-shape {
position: absolute;
border-radius: 50%;
background: #e8834a;
}
.stat-shape--1 { width: 60%; aspect-ratio: 1; top: -14%; right: -20%; }
.stat-shape--2 { width: 70%; aspect-ratio: 1; top: 28%; right: -24%; }
.stat-shape--3 { width: 45%; aspect-ratio: 1; bottom: -10%; right: -12%; }
.stat-slide--count-up.is-animating .stat-shape--1 {
animation: shapeDrift1 9s ease-in-out infinite alternate;
}
.stat-slide--count-up.is-animating .stat-shape--2 {
animation: shapeDrift2 11s ease-in-out 0.4s infinite alternate;
}
.stat-slide--count-up.is-animating .stat-shape--3 {
animation: shapeDrift3 7s ease-in-out 0.8s infinite alternate;
}
@keyframes shapeDrift1 {
to { transform: translate(-5%, 8%) scale(1.07); }
}
@keyframes shapeDrift2 {
to { transform: translate(-4%, -6%) scale(0.93); }
}
@keyframes shapeDrift3 {
to { transform: translate(-6%, -5%) scale(1.1); }
}
/* ── VARIANT 4: Spotlight ─── Chile 49% ─────────────────────────────────
Black left with purple stats, right has a white spotlight circle.
Balls pop in sequentially inside the circle, sized by data value.
Each stat row on the left fades in as its corresponding ball appears.
─────────────────────────────────────────────────────────────────────── */
.stat-slide--spotlight {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 440px;
background: #000;
}
@media (max-width: 61.24em) { .stat-slide--spotlight { grid-template-columns: 1fr; } }
.stat-slide--spotlight .stat-text {
padding: 52px 44px;
display: flex; flex-direction: column; justify-content: center; gap: 0;
}
.stat-slide--spotlight .stat-country {
font-family: 'Playfair Display', Georgia, serif;
font-size: 2rem; font-weight: 700;
color: #7c6ef5;
text-decoration: underline;
text-decoration-color: #7c6ef5;
text-underline-offset: 4px;
margin: 0 0 6px;
}
.stat-slide--spotlight .stat-context {
font-size: 0.9rem; font-weight: 500;
color: #fff; line-height: 1.4; margin: 0 0 28px;
}
/*
* STAT ROWS — each hidden until its spotlight ball pops in.
* JS sets transition-delay and adds .is-revealed at the right moment.
*/
.stat-spotlight-row {
display: flex; flex-direction: column; gap: 2px;
margin-bottom: 20px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.stat-spotlight-row.is-revealed {
opacity: 1; transform: translateY(0);
}
.stat-spotlight-row .stat-number {
font-size: clamp(3rem, 7vw, 5.5rem);
font-weight: 900; line-height: 0.9;
font-family: 'JetBrains Mono', monospace;
letter-spacing: -0.04em; margin: 0;
}
.stat-spotlight-row .stat-descriptor {
font-size: 0.82rem; font-weight: 900;
letter-spacing: 0.1em; text-transform: uppercase; margin: 3px 0 2px;
}
.stat-spotlight-row .stat-sub {
font-size: 0.62rem; font-weight: 600; margin: 0;
}
/* Circle panel */
.stat-spotlight-panel {
display: flex; align-items: center; justify-content: center;
padding: 36px;
}
.stat-spotlight-circle {
width: min(290px, 90%);
aspect-ratio: 1;
background: #e8e8e8;
border-radius: 50%;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
/*
* SPOTLIGHT BALLS
* ───────────────
* Positioned by JS (as % of circle diameter), scaled by value.
* Start at scale(0), JS sets scale(1) with staggered setTimeout.
* Spring cubic-bezier gives the "pop".
*/
.stat-spotlight-ball {
position: absolute;
border-radius: 50%;
transform: scale(0);
transform-origin: center;
transition: transform 0.65s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ── ASCII video panel ───────────────────────────────────────────────────── */
.scroll-media--ascii-video { background: #000; }
/* Sticky media column */
.scroll-media {
position: sticky;
top: 0;
height: 100vh;
z-index: 1;
overflow: hidden;
background: #050810;
opacity: 0;
transition: opacity 1.2s ease-in-out;
will-change: opacity;
display: flex;
flex-direction: column;
}
.scroll-media.is-visible { opacity: 1; }
@media (min-width: 61.25em) {
.scroll-media { grid-area: media; }
.scroll-section--reversed .scroll-media { grid-area: media; }
}
.media-caption {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(5, 8, 16, 0.82);
color: rgba(255,255,255,0.5);
padding: 7px 14px;
position: absolute;
bottom: 0; right: 0;
z-index: 10;
}
/* Three.js mount */
.three-mount {
flex: 1;
width: 100%;
min-height: 0;
overflow: hidden;
background: #050810;
}
.three-mount canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
/* Terminal panels */
.scroll-media--terminal { background: #06090f; }
.scroll-media--topology { background: #030609; }
.terminal-mount {
flex: 1;
overflow: hidden;
box-sizing: border-box;
min-height: 0;
will-change: transform;
contain: strict;
}
.terminal-mount .xterm-viewport { overflow-y: hidden !important; scrollbar-width: none !important; }
.terminal-mount .xterm-viewport::-webkit-scrollbar { display: none; }
.terminal-mount .xterm { height: 100%; }
#terminal-mount { padding: 20px 14px 14px 14px; }
#topology-mount { padding: 0; }
/* =========================================================================
CLOSING SECTION (full-width text)
========================================================================= */
.section-full {
padding: 12vh 28px;
border-bottom: 1px solid var(--border);
background: var(--cream);
/*
* STACKING CONTEXT NOTE
* ─────────────────────
* .page-container::after draws a 1px centre line at z-index 5.
* Full-width sections must sit above it so the line doesn't bleed
* through. position + z-index here creates a stacking context that
* paints over the pseudo-element without disrupting surrounding layout.
*/
position: relative;
z-index: 6;
}
@media (min-width: 61.25em) {
.section-full { padding: 14vh 10%; }
}
.section-full h2 {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2rem, 4vw, 3.4rem);
font-weight: 900;
line-height: 1.1;
max-width: 780px;
margin: 0 0 36px;
}
.section-full .body-text { max-width: 640px; }
/* =========================================================================
FOOTER
========================================================================= */
.article-footer {
padding: 48px 28px;
background: var(--ink);
color: rgba(255,255,255,0.3);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
/* Same stacking context fix as .section-full above */
position: relative;
z-index: 6;
}
/* =========================================================================
SKIP LINK
========================================================================= */
.skip-link { position: absolute; top: -9999px; }
.skip-link:focus { top: 0; z-index: 9999; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="page-container">
<!-- ══════════════════════════════════════════════════════════════════════
HERO — Three.js rotating globe with cable arcs
══════════════════════════════════════════════════════════════════════ -->
<header class="article-hero">
<nav class="hero-topbar" aria-label="Publication header">
<span class="pub-name">The Long Read</span>
<span class="pub-meta">Investigations &nbsp;·&nbsp; February 2026</span>
</nav>
<!-- Three.js globe lives here -->
<div class="hero-media" id="hero-mount" aria-hidden="true"></div>
<div class="hero-furniture">
<div class="hero-headline">
<hgroup>
<p class="hero-kicker">Infrastructure &amp; Power</p>
<div class="hero-rule" aria-hidden="true"></div>
<h1>The Invisible Web</h1>
</hgroup>
</div>
<div class="hero-standfirst">
<p>More than 95% of all international data — financial transfers, military communications, the entire internet — travels through a network of fibre-optic cables lying on the ocean floor. They are among the most critical and most vulnerable pieces of infrastructure on Earth. And someone has been cutting them.</p>
</div>
<address class="hero-byline">By Sarah Okonkwo &amp; Mikael Lund &nbsp;·&nbsp; Reported from Washington, Oslo and Singapore</address>
</div>
</header>
<main id="main-content">
<!-- ══════════════════════════════════════════════════════════════════
SECTION 1 — The network (Three.js: cable topology sphere)
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section">
<div class="scroll-text">
<p class="overline">Chapter I</p>
<h2>Below the <em>surface</em></h2>
<p class="body-text">The first undersea telegraph cable crossed the Atlantic in 1858. It failed after three weeks. The second, laid in 1866, worked — and within days, the financial markets of London and New York were effectively one. The logic has not changed since: whoever controls the cables controls the communications.</p>
<p class="body-text">Today's network is vastly more complex. Some 600 undersea cable systems criss-cross the ocean floor, spanning more than 1.4 million kilometres. Each cable is roughly the diameter of a garden hose. Each carries a staggering quantity of the world's information.</p>
<blockquote class="pull-quote">"They are not the backbone of the internet. They are the internet."</blockquote>
<p class="body-text">At its heart, the cable is a remarkably simple object: a bundle of glass fibres, each one thinner than a human hair, sheathed in layers of copper, steel, and polyethylene. What makes it extraordinary is not its engineering but its scale, its reach, and its invisibility.</p>
<p class="body-text">Governments and intelligence agencies have long understood what the general public tends to forget: the physical substrate of global communication is finite, locatable, and fragile.</p>
</div>
<figure class="scroll-media" id="panel-1" aria-hidden="true">
<div class="three-mount" id="mount-1"></div>
<figcaption class="media-caption">Stylised global cable network · Illustrative</figcaption>
</figure>
</article>
<!-- ══════════════════════════════════════════════════════════════════
STAT SLIDE 1 — Arrow cone · US coal -33%
Copy-paste template: .stat-slide--arrow
· data-direction="down" for decrease (blue ▼ cone from top)
· data-direction="up" for increase (red ▲ cone from bottom)
══════════════════════════════════════════════════════════════════ -->
<aside class="stat-slide stat-slide--arrow"
id="stat-arrow"
data-direction="down"
aria-label="United States coal power generation capacity: −33% net change since 2000">
<div class="stat-text">
<div>
<p class="stat-country">United States</p>
<p class="stat-descriptor">Cable disruption<br>incidents recorded</p>
</div>
<p class="stat-source">Source: TeleGeography Submarine Cable Almanac · 2025</p>
</div>
<div class="stat-visual" aria-hidden="true">
<!-- Cone direction set by JS reading data-direction -->
<div class="stat-arrow-cone"></div>
<p class="stat-number">−33%</p>
<p class="stat-label">Net change since 2000</p>
</div>
</aside>
<!-- ══════════════════════════════════════════════════════════════════
SECTION 2 — The threat
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section scroll-section--reversed">
<figure class="scroll-media" id="panel-2" aria-hidden="true">
<div class="three-mount" id="mount-2"></div>
<figcaption class="media-caption">Simulated cable fault propagation · Model only</figcaption>
</figure>
<div class="scroll-text">
<p class="overline">Chapter II</p>
<h2>Cuts in the <em>dark</em></h2>
<p class="body-text">In October 2023, two undersea cables were severed in the Baltic Sea within hours of each other. Finnish and Estonian authorities initially attributed the damage to a dragging anchor. The vessel responsible, a Chinese-flagged bulk carrier named Yi Peng 3, loitered in the area for weeks before authorities were allowed to board.</p>
<p class="body-text">It was not a unique event. Cables between the Canary Islands and the Spanish mainland were cut in early 2024. A cable serving the Red Sea was severed — possibly by Houthi forces — later that year. Incidents that might once have been explained away as fishing accidents began to form a pattern.</p>
<blockquote class="pull-quote">"The probability of so many cables failing simultaneously by accident is essentially zero."</blockquote>
<p class="body-text">Western intelligence agencies have concluded, with high confidence, that Russia maintains a dedicated capability to map, monitor, and — if ordered — sever undersea cables at scale. The operation is run out of a unit within the GRU, Russia's military intelligence service, whose ships pass through Atlantic shipping lanes on a regular basis.</p>
</div>
</article>
<!-- ══════════════════════════════════════════════════════════════════
SECTION 3 — Live monitoring (xterm.js planning terminal)
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section">
<div class="scroll-text">
<p class="overline">Chapter III</p>
<h2>Watching in <em>real time</em></h2>
<p class="body-text">Since 2022, NATO and the European Union have operated a joint cable monitoring programme — a classified fusion of commercial vessel-tracking data, acoustic sensors, and signals intelligence. Analysts watch a live dashboard of the global cable network around the clock.</p>
<p class="body-text">The terminal opposite simulates the kind of alert and query workflow used by a cable fault analyst — cross-referencing vessel AIS data against cable route databases to flag anomalous behaviour in real time.</p>
<p class="body-text">When a fault occurs, the response window is measured in minutes: rerouting traffic, alerting governments, mobilising the small fleet of commercial cable ships that serve as the network's emergency repair crews.</p>
<p class="body-text">There are, at any one time, around 60 cable repair ships in the world. Booking one can take weeks. That asymmetry — between how easy it is to cut a cable and how long it takes to fix one — is precisely the vulnerability that adversaries have identified.</p>
</div>
<div class="scroll-media scroll-media--terminal"
id="terminal-panel"
role="region"
aria-label="Simulated cable fault monitoring terminal. Auto-playing demo.">
<div id="terminal-mount" class="terminal-mount"></div>
</div>
</article>
<!-- ══════════════════════════════════════════════════════════════════
STAT SLIDE 2 — Dot pop grid · Germany 51%
Copy-paste template: .stat-slide--dot-pop
· data-lit sets the % of dots in the primary colour (yellow)
· data-total sets total dot count (should be a round number)
· data-label-top / data-label-bottom set the legend text
══════════════════════════════════════════════════════════════════ -->
<aside class="stat-slide stat-slide--dot-pop"
id="stat-dot-pop"
data-lit="51"
data-total="100"
data-label-top="Confirmed incidents"
data-label-bottom="Unconfirmed / under investigation"
aria-label="51% of recorded cable incidents are confirmed sabotage">
<div class="stat-text">
<div>
<p class="stat-number">51%</p>
<p class="stat-descriptor">of recorded cable incidents involve vessels from <strong>state-linked fleets</strong></p>
</div>
<p class="stat-source">Source: NATO Undersea Infrastructure Coordination Cell · 2025</p>
</div>
<!-- Dot grid and legends are built by JS reading data-* attributes -->
<div class="stat-dot-panel" id="stat-dot-panel" aria-hidden="true"></div>
</aside>
<!-- ══════════════════════════════════════════════════════════════════
SECTION 4 — Topology
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section scroll-section--reversed">
<div class="scroll-media scroll-media--topology"
id="topology-panel"
role="img"
aria-label="Live procedural rendering of ocean floor cable density. Non-interactive.">
<div id="topology-mount" class="terminal-mount"></div>
</div>
<div class="scroll-text">
<p class="overline">Chapter IV</p>
<h2>The <em>chokepoints</em></h2>
<p class="body-text">Not all stretches of ocean floor are equal. The cables converge at chokepoints — the Strait of Malacca, the Suez Canal approach, the GIUK Gap — where the geography of the seabed forces dozens of cables into narrow corridors.</p>
<p class="body-text">The rendering opposite maps a procedural model of cable density. Lighter characters indicate higher concentrations of active fibre. Colour encodes estimated traffic load. The chokepoints appear as dense, bright bands — precisely the places that intelligence analysts and adversary planners alike spend the most time studying.</p>
<blockquote class="pull-quote">"There are places where you could take down half the internet with a single anchor."</blockquote>
<p class="body-text">Protecting these locations is technically and legally complex. Undersea cables pass through the territorial waters and exclusive economic zones of dozens of nations. No single authority has jurisdiction. The international treaty that governs the cables — the 1884 Convention for the Protection of Submarine Telegraph Cables — predates the radio by three years.</p>
</div>
</article>
<!-- ══════════════════════════════════════════════════════════════════
SECTION 5 — The future (Three.js: emerging network)
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section">
<div class="scroll-text">
<p class="overline">Chapter V</p>
<h2>The race to <em>rebuild</em></h2>
<p class="body-text">The tech companies have moved faster than governments. Google, Meta, Microsoft, and Amazon now own or co-own more than a quarter of all undersea cable capacity globally — a figure that has tripled since 2012. They have resources, urgency, and an incentive that nation-states sometimes lack: their businesses depend on the cables working.</p>
<p class="body-text">New routes are being commissioned at a rate not seen since the first boom of the early 2000s. Google's Equiano cable links Europe and Africa. Meta's 2Africa cable circles the continent. Starlink and other low-earth-orbit satellite constellations offer an alternative path — but one that is slower, more expensive, and dependent on its own set of vulnerable ground stations.</p>
<p class="body-text">Meanwhile, NATO has established a new Undersea Infrastructure Coordination Cell, charged with developing the alliance's first coherent strategy for protecting the cable network. Its first classified assessment, completed in late 2024, concluded that the threat had moved from theoretical to active.</p>
<p class="body-text">The cables are still there, still working, still carrying the world's information through the dark and cold of the ocean floor. But the assumption of their inviolability — the comfortable invisibility that has protected them for 160 years — may be the thing that has finally been cut.</p>
</div>
<figure class="scroll-media" id="panel-3" aria-hidden="true">
<div class="three-mount" id="mount-3"></div>
<figcaption class="media-caption">Next-generation cable route projection · 2025–2030</figcaption>
</figure>
</article>
<!-- ══════════════════════════════════════════════════════════════════
STAT SLIDE 3 — Count-up · India 145th
Copy-paste template: .stat-slide--count-up
· data-target sets the number to count up to
· data-ordinal sets the ordinal suffix (th/st/nd/rd)
══════════════════════════════════════════════════════════════════ -->
<aside class="stat-slide stat-slide--count-up"
id="stat-count-up"
data-target="145"
data-ordinal="th"
aria-label="The Baltic pipeline carries the 145th recorded cut in 2024">
<div class="stat-text">
<p class="stat-headline">The Baltic pipeline carried the</p>
<div class="stat-number-wrap">
<span class="stat-number" id="stat-counter">0</span><span class="stat-ordinal">th</span>
<hr class="stat-number-rule">
<p class="stat-descriptor">recorded undersea<br>sabotage event</p>
</div>
<p class="stat-source">Source: SubTel Forum Incident Database · 2024</p>
</div>
<div class="stat-shapes-panel" aria-hidden="true">
<div class="stat-shape stat-shape--1"></div>
<div class="stat-shape stat-shape--2"></div>
<div class="stat-shape stat-shape--3"></div>
</div>
</aside>
<!-- ══════════════════════════════════════════════════════════════════
STAT SLIDE 4 — Spotlight · Chile 49% renewables
Copy-paste template: .stat-slide--spotlight
Each .stat-spotlight-row has:
data-color → ball colour
data-value → proportional ball size (0–100)
data-delay → ms after intersection before this ball pops
══════════════════════════════════════════════════════════════════ -->
<aside class="stat-slide stat-slide--spotlight"
id="stat-spotlight"
aria-label="Cable network ownership breakdown: 49% private tech companies, 30% telecoms, 21% state-controlled">
<div class="stat-text">
<p class="stat-country">Ownership</p>
<p class="stat-context">Current undersea cable<br>network control</p>
<!-- Rows revealed in sequence as their ball pops in -->
<div class="stat-spotlight-row" data-color="#7c6ef5" data-value="49" data-delay="300">
<p class="stat-number" style="color:#7c6ef5;">49%</p>
<p class="stat-descriptor" style="color:#7c6ef5;">Private tech</p>
<p class="stat-sub" style="color:#7c6ef5;">Google · Meta · Microsoft · Amazon</p>
</div>
<div class="stat-spotlight-row" data-color="#a8e06a" data-value="30" data-delay="900">
<p class="stat-number" style="color:#a8e06a;">30%</p>
<p class="stat-descriptor" style="color:#a8e06a;">Telecoms</p>
<p class="stat-sub" style="color:#a8e06a;">BT · NTT · Vodafone and others</p>
</div>
<div class="stat-spotlight-row" data-color="rgba(255,255,255,0.55)" data-value="21" data-delay="1500">
<p class="stat-number" style="color:rgba(255,255,255,0.7);">21%</p>
<p class="stat-descriptor" style="color:rgba(255,255,255,0.55);">State-controlled</p>
<p class="stat-sub" style="color:rgba(255,255,255,0.4);">Incl. China Telecom · Rostelekom</p>
</div>
<p class="stat-source" style="margin-top:16px;">Source: TeleGeography · 2025</p>
</div>
<div class="stat-spotlight-panel" aria-hidden="true">
<div class="stat-spotlight-circle" id="stat-spotlight-circle">
<!-- Balls injected by JS reading data-* from .stat-spotlight-row elements -->
</div>
</div>
</aside>
Remove or swap src to any video for a different subject.
══════════════════════════════════════════════════════════════════ -->
<article class="scroll-section">
<div class="scroll-text">
<p class="overline">Template panel</p>
<h2>Vision through <em>character</em></h2>
<p class="body-text">A moving image rendered entirely in coloured ASCII characters. Each cell samples a pixel from the source video — brightness mapped to glyph weight, colour passed through as ANSI truecolor.</p>
<p class="body-text">The effect sits at the edge of legibility: readable as image or as text, but not quite as both at once. That ambiguity is intentional — and serves as a useful metaphor for intelligence work itself.</p>
<p class="body-text">Swap the video source to any MP4 to repurpose this panel. If the video cannot be loaded due to CORS restrictions, the panel falls back to an animated matrix rain.</p>
</div>
<div class="scroll-media scroll-media--ascii-video"
id="ascii-video-panel"
role="img"
aria-label="Video rendered as animated ASCII art using coloured characters. Non-interactive.">
<div id="ascii-video-mount" class="terminal-mount"></div>
</div>
</article>
<!-- ══════════════════════════════════════════════════════════════════
FULL-WIDTH CLOSING
══════════════════════════════════════════════════════════════════ -->
<section class="section-full">
<p class="overline">Editor's note</p>
<h2>This investigation was conducted over 14 months across six countries.</h2>
<p class="body-text">More than 80 current and former officials, engineers, and intelligence analysts were interviewed, the majority on condition of anonymity. Three governments were offered the opportunity to comment before publication. Two declined; one did not respond.</p>
<p class="body-text">The locations of specific cable landing stations and routing details that could assist adversary targeting have been withheld.</p>
</section>
</main>
<footer class="article-footer">
&copy; 2026 The Long Read &nbsp;·&nbsp; All rights reserved &nbsp;·&nbsp; Illustrations and renderings are illustrative models only
</footer>
</div><!-- /page-container -->
<script>
document.addEventListener('DOMContentLoaded', () => {
// =========================================================================
// CONFIG
// =========================================================================
const CFG = {
LIBS_POLL_MS: 80,
FADE_THRESHOLD: 0.08,
TERMINAL_THRESHOLD: 0.12,
TOPO_THRESHOLD: 0.04,
// Planning terminal
PLAN_BOOT_MS: 800,
PLAN_CHAR_MS: 44,
PLAN_JITTER_MS: 28,
PLAN_POST_CMD_MS: 240,
PLAN_LINE_MS: 22,
PLAN_LINE_JITTER_MS:14,
PLAN_STEP_MS: 1400,
PLAN_REPLAY_MS: 3000,
// Topology
TOPO_BOOT_MS: 700,
TOPO_INTERVAL_MS: 75,
TOPO_TIME_STEP: 0.016,
TOPO_FONT_SIZE: 10,
TOPO_LINE_HEIGHT: 1.05,
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
const write = (t, s) => new Promise(r => { t.write(s); setTimeout(r, 0); });
const writeln = (t, s = '') => write(t, s + '\r\n');
function waitForLibs() {
return new Promise(resolve => {
const poll = () => (window.Terminal && window.FitAddon)
? resolve() : setTimeout(poll, CFG.LIBS_POLL_MS);
poll();
});
}
// =========================================================================
// FADE OBSERVER
// =========================================================================
const fadeObserver = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('is-visible'); });
}, { threshold: CFG.FADE_THRESHOLD });
document.querySelectorAll('.scroll-media').forEach(f => fadeObserver.observe(f));
// =========================================================================
// TERMINAL FACTORY
// =========================================================================
function makeTerminal(mountEl, opts = {}) {
const term = new Terminal(Object.assign({
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 12,
lineHeight: 1.5,
scrollback: 0,
convertEol: true,
disableStdin: true,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: 7
}, opts));
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(mountEl);
fit.fit();
term.write('\x1b[?25l');
window.addEventListener('resize', () => { try { fit.fit(); } catch (_) {} });
const lr = document.createElement('div');
lr.className = 'sr-only';
lr.setAttribute('aria-live', 'polite');
mountEl.appendChild(lr);
const origWrite = term.write.bind(term);
term.write = d => {
const clean = d.replace(/\x1b\[[0-9;]*m/g, '');
if (clean.trim()) lr.textContent = clean;
return origWrite(d);
};
return { term, fit };
}
// =========================================================================
// THREE.JS UTILITIES
// =========================================================================
const threeInstances = [];
function createScene(mountEl, setupFn) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, mountEl.clientWidth / mountEl.clientHeight, 0.1, 1000);
camera.position.z = 4;
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(mountEl.clientWidth, mountEl.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
mountEl.appendChild(renderer.domElement);
const lights = {
ambient: new THREE.AmbientLight(0xffffff, 0.3),
point1: new THREE.PointLight(0x4fc3f7, 2, 20),
point2: new THREE.PointLight(0xc84b31, 1.2, 20),
};
lights.point1.position.set(4, 3, 4);
lights.point2.position.set(-4, -2, -3);
scene.add(lights.ambient, lights.point1, lights.point2);
const instance = { mount: mountEl, scene, camera, renderer, isRunning: false, rafId: null, userData: {} };
setupFn(instance);
threeInstances.push(instance);
return instance;
}
function startScene(inst) {
if (inst.isRunning) return;
inst.isRunning = true;
const loop = (t) => {
if (!inst.isRunning) return;
inst.rafId = requestAnimationFrame(loop);
if (inst.onFrame) inst.onFrame(t * 0.001);
inst.renderer.render(inst.scene, inst.camera);
};
inst.rafId = requestAnimationFrame(loop);
}
function stopScene(inst) {
inst.isRunning = false;
cancelAnimationFrame(inst.rafId);
}
const renderObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
const inst = threeInstances.find(i => i.mount === entry.target);
if (!inst) return;
if (entry.isIntersecting) startScene(inst);
else stopScene(inst);
});
}, { threshold: 0.04 });
window.addEventListener('resize', () => {
threeInstances.forEach(inst => {
const w = inst.mount.clientWidth, h = inst.mount.clientHeight;
inst.renderer.setSize(w, h);
inst.camera.aspect = w / h;
inst.camera.updateProjectionMatrix();
if (!inst.isRunning) inst.renderer.render(inst.scene, inst.camera);
});
});
// ─── HERO: Globe with arc cables ─────────────────────────────────────────
const heroInst = createScene(document.getElementById('hero-mount'), inst => {
// Base sphere (ocean)
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(1.6, 48, 48),
new THREE.MeshStandardMaterial({
color: 0x060d1f, roughness: 0.8, metalness: 0.1
})
);
inst.scene.add(sphere);
// Wireframe overlay (latitude/longitude grid)
const wireGeo = new THREE.SphereGeometry(1.62, 24, 24);
const wireMat = new THREE.MeshBasicMaterial({ color: 0x0a2040, wireframe: true, transparent: true, opacity: 0.4 });
inst.scene.add(new THREE.Mesh(wireGeo, wireMat));
// Cable arcs (tube curves between lat/lon pairs)
const cableColor = 0x00c9a7;
const cableMat = new THREE.MeshBasicMaterial({ color: cableColor });
function latLonToVec3(lat, lon, r) {
const phi = (90 - lat) * Math.PI / 180;
const theta = (lon + 180) * Math.PI / 180;
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta)
);
}
function addCableArc(lat1, lon1, lat2, lon2, arcHeight, color, opacity) {
const a = latLonToVec3(lat1, lon1, 1.63);
const b = latLonToVec3(lat2, lon2, 1.63);
const mid = a.clone().add(b).multiplyScalar(0.5).normalize().multiplyScalar(1.63 + arcHeight);
const curve = new THREE.QuadraticBezierCurve3(a, mid, b);
const pts = curve.getPoints(40);
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: color || cableColor, transparent: true, opacity: opacity || 0.8 });
inst.scene.add(new THREE.Line(geo, mat));
}
// Major cable routes
addCableArc(51, -0.1, 40.7, -74, 0.3, 0x00c9a7, 0.9); // London–New York
addCableArc(51, -0.1, 1.3, 103.8, 0.4, 0x4fc3f7, 0.7); // London–Singapore
addCableArc(37.8, -122.4, 35.7, 139.7, 0.35, 0x00c9a7, 0.8); // SF–Tokyo
addCableArc(-33.9, 18.4, 51, -0.1, 0.35, 0x4fc3f7, 0.6); // Cape Town–London
addCableArc(40.7, -74, -23.5, -46.6, 0.3, 0x00c9a7, 0.7); // NY–São Paulo
addCableArc(1.3, 103.8, -33.9, 151.2, 0.25, 0x4fc3f7, 0.7); // Singapore–Sydney
addCableArc(35.7, 139.7, -33.9, 151.2, 0.2, 0x00c9a7, 0.8); // Tokyo–Sydney
addCableArc(25.2, 55.3, 1.3, 103.8, 0.2, 0xc84b31, 0.5); // Dubai–Singapore
addCableArc(25.2, 55.3, 51, -0.1, 0.3, 0xc84b31, 0.5); // Dubai–London (Red Sea route)
// Glowing node dots at landing points
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x00c9a7 });
const nodeGeo = new THREE.SphereGeometry(0.03, 8, 8);
const landings = [
[51, -0.1], [40.7, -74], [1.3, 103.8], [35.7, 139.7],
[-33.9, 18.4], [-33.9, 151.2], [-23.5, -46.6], [25.2, 55.3]
];
landings.forEach(([lat, lon]) => {
const pos = latLonToVec3(lat, lon, 1.65);
const dot = new THREE.Mesh(nodeGeo, nodeMat.clone());
dot.position.copy(pos);
inst.scene.add(dot);
});
inst.onFrame = (t) => {
sphere.rotation.y = t * 0.08;
wireGeo; // accessed via closure; rotate group instead
inst.scene.children.forEach(c => {
if (c !== inst.scene) c.rotation.y = t * 0.08;
});
};
inst.camera.position.z = 3.5;
});
renderObserver.observe(document.getElementById('hero-mount'));
startScene(heroInst);
// ─── PANEL 1: Cable network topology (torus + arcs) ──────────────────────
const p1 = createScene(document.getElementById('mount-1'), inst => {
// Torus represents a cable cross-section / connectivity ring
const geo = new THREE.TorusGeometry(1.4, 0.08, 16, 120);
const mat = new THREE.MeshStandardMaterial({ color: 0x001a2e, roughness: 0.3, metalness: 0.9 });
const torus = new THREE.Mesh(geo, mat);
const wireMat = new THREE.MeshBasicMaterial({ color: 0x00c9a7, wireframe: true, transparent: true, opacity: 0.6 });
const wireTorus = new THREE.Mesh(geo, wireMat);
inst.scene.add(torus, wireTorus);
// Orbiting data particles
const particles = [];
const pGeo = new THREE.SphereGeometry(0.04, 6, 6);
for (let i = 0; i < 18; i++) {
const pMat = new THREE.MeshBasicMaterial({ color: i % 3 === 0 ? 0xc84b31 : 0x00c9a7, transparent: true, opacity: 0.9 });
const p = new THREE.Mesh(pGeo, pMat);
const offset = (i / 18) * Math.PI * 2;
particles.push({ mesh: p, offset });
inst.scene.add(p);
}
inst.onFrame = (t) => {
torus.rotation.x = t * 0.3;
torus.rotation.y = t * 0.15;
wireTorus.rotation.x = t * 0.3;
wireTorus.rotation.y = t * 0.15;
particles.forEach(({ mesh, offset }) => {
const angle = t * 1.2 + offset;
mesh.position.set(
Math.cos(angle) * 1.4,
Math.sin(angle * 0.4) * 0.3,
Math.sin(angle) * 1.4
);
});
};
inst.camera.position.z = 3.2;
});
renderObserver.observe(document.getElementById('panel-1'));
// ─── PANEL 2: Fracture / disruption ──────────────────────────────────────
const p2 = createScene(document.getElementById('mount-2'), inst => {
const group = new THREE.Group();
inst.scene.add(group);
// Fragmented icosahedron shards
const baseGeo = new THREE.IcosahedronGeometry(1.5, 1);
const positions = baseGeo.attributes.position;
const faceCount = positions.count / 3;
for (let i = 0; i < faceCount; i++) {
const shardGeo = new THREE.BufferGeometry();
const verts = new Float32Array(9);
for (let j = 0; j < 3; j++) {
verts[j * 3] = positions.getX(i * 3 + j);
verts[j * 3 + 1] = positions.getY(i * 3 + j);
verts[j * 3 + 2] = positions.getZ(i * 3 + j);
}
shardGeo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
shardGeo.computeVertexNormals();
const shade = Math.random();
const mat = new THREE.MeshStandardMaterial({
color: shade > 0.7 ? 0xc84b31 : shade > 0.4 ? 0x1a3a5c : 0x050810,
roughness: 0.4, metalness: 0.7,
transparent: true, opacity: 0.85
});
const shard = new THREE.Mesh(shardGeo, mat);
// Explosion offset
const cx = (verts[0] + verts[3] + verts[6]) / 3;
const cy = (verts[1] + verts[4] + verts[7]) / 3;
const cz = (verts[2] + verts[5] + verts[8]) / 3;
const spread = 0.3 + Math.random() * 0.4;
shard.userData.restPos = new THREE.Vector3(cx * spread, cy * spread, cz * spread);
shard.userData.phase = Math.random() * Math.PI * 2;
shard.position.copy(shard.userData.restPos);
group.add(shard);
}
inst.onFrame = (t) => {
group.rotation.y = t * 0.12;
group.rotation.x = Math.sin(t * 0.07) * 0.3;
group.children.forEach(shard => {
const pulse = Math.sin(t * 0.8 + shard.userData.phase) * 0.06;
shard.position.copy(shard.userData.restPos).multiplyScalar(1 + pulse);
shard.rotation.x = t * 0.2 + shard.userData.phase;
shard.rotation.z = t * 0.1;
});
};
inst.camera.position.z = 4;
});
renderObserver.observe(document.getElementById('panel-2'));
// ─── PANEL 3: Emerging network (node mesh) ────────────────────────────────
const p3 = createScene(document.getElementById('mount-3'), inst => {
const group = new THREE.Group();
inst.scene.add(group);
// Node cloud
const nodeGeo = new THREE.SphereGeometry(0.06, 8, 8);
const nodes = [];
for (let i = 0; i < 28; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 1.2 + Math.random() * 0.6;
const pos = new THREE.Vector3(
r * Math.sin(phi) * Math.cos(theta),
r * Math.sin(phi) * Math.sin(theta),
r * Math.cos(phi)
);
const mat = new THREE.MeshBasicMaterial({ color: Math.random() > 0.3 ? 0x00c9a7 : 0x4fc3f7 });
const mesh = new THREE.Mesh(nodeGeo, mat);
mesh.position.copy(pos);
mesh.userData.phase = Math.random() * Math.PI * 2;
group.add(mesh);
nodes.push({ mesh, pos });
}
// Edge lines between nearby nodes
const edgeMat = new THREE.LineBasicMaterial({ color: 0x0a4a6e, transparent: true, opacity: 0.5 });
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (nodes[i].pos.distanceTo(nodes[j].pos) < 1.2) {
const geo = new THREE.BufferGeometry().setFromPoints([nodes[i].pos, nodes[j].pos]);
group.add(new THREE.Line(geo, edgeMat));
}
}
}
inst.onFrame = (t) => {
group.rotation.y = t * 0.1;
nodes.forEach(({ mesh }) => {
const pulse = 1 + Math.sin(t * 1.5 + mesh.userData.phase) * 0.15;
mesh.scale.setScalar(pulse);
});
};
inst.camera.position.z = 4;
});
renderObserver.observe(document.getElementById('panel-3'));
// =========================================================================
// TERMINAL — Cable Fault Monitor
// =========================================================================
const C = {
reset: '\x1b[0m', bold: '\x1b[1m',
green: '\x1b[38;2;0;201;167m',
blue: '\x1b[38;2;79;195;247m',
amber: '\x1b[38;2;255;160;0m',
red: '\x1b[38;2;220;50;50m',
dim: '\x1b[38;2;70;100;120m',
white: '\x1b[97m',
signal: '\x1b[38;2;0;201;167m',
};
const PROMPT = `${C.green}analyst@cable-watch${C.reset}${C.dim}:~$${C.reset} `;
const DEMO = [
{
cmd: 'cawatch --query "status BALTIC-04 segment 3"',
lines: [
'',
`${C.blue}${C.bold}[ BALTIC-04 · Segment 3 · Gotland–Gdańsk ]${C.reset}`,
`${C.dim} Route: 57.2°N 18.3°E → 54.4°N 18.6°E | Length: 289 km${C.reset}`,
'',
`${C.amber}${C.bold} ⚠ ANOMALY DETECTED — 14:32:07 UTC${C.reset}`,
`${C.dim} Signal attenuation +18 dB above baseline${C.reset}`,
`${C.dim} Optical time-domain reflectometry: fault at km 147${C.reset}`,
'',
`${C.red} ✗ FIBRE PAIR A/B — SEVERED${C.reset}`,
`${C.green} ✓ FIBRE PAIR C/D — NOMINAL (rerouted)${C.reset}`,
'',
`${C.white} Vessel traffic (±20 km, last 6h):${C.reset}`,
`${C.dim} · MMSI 477XXXXXX — bulk carrier — anchored 04:10 UTC${C.reset}`,
`${C.dim} Speed 0.2 kt at fault position. AIS gap: 3h 14m.${C.reset}`,
`${C.amber} Flagged: anomalous loiter pattern${C.reset}`,
'',
`${C.green} Action recommended:${C.reset} Alert NCIRC + coastal authority.`,
` Repair vessel GUARDIAN ETA: 6.2 days (Bremerhaven).`,
'',
`${C.dim} [ Confidence: high | ref: cw-20261026-143207 ]${C.reset}`,
'',
],
},
{
cmd: 'cawatch --vessels "AIS anomaly last 12h NORTH_SEA"',
lines: [
'',
`${C.blue}${C.bold}[ AIS Anomaly Scan — North Sea — 12h window ]${C.reset}`,
'',
`${C.amber} 3 vessels flagged${C.reset}`,
'',
` ${C.white}1.${C.reset} MV POLAR ROSE (IMO 921XXXX) — flag: RU`,
` AIS gap 5h 40m at 56.1°N 4.2°E`,
` ${C.dim}Proximity to NORTH-CONNECT route: 1.8 km${C.reset}`,
` ${C.red}⚠ HIGH RISK${C.reset}`,
'',
` ${C.white}2.${C.reset} FV ARKTIKA (IMO 882XXXX) — flag: RU`,
` Declared: fishing. Observed: no trawling pattern.`,
` ${C.dim}Proximity to TAT-14 route: 4.1 km${C.reset}`,
` ${C.amber}⚠ MEDIUM RISK${C.reset}`,
'',
` ${C.white}3.${C.reset} MV SHEN HONG (IMO 934XXXX) — flag: CN`,
` Speed variance consistent with cable survey equipment.`,
` ${C.amber}⚠ MEDIUM RISK — monitoring${C.reset}`,
'',
`${C.green} Notifications sent:${C.reset} NCIRC · UKHO · DNK NAVY`,
'',
`${C.dim} [ ref: cw-20261026-152941 ]${C.reset}`,
'',
],
},
];
let planTerm, planRunning = false, planAbort = false;
async function runPlanDemo() {
if (planRunning) return;
planRunning = true; planAbort = false;
planTerm.clear();
await writeln(planTerm);
await writeln(planTerm, `${C.green}${C.bold}CABLE WATCH — FAULT MONITORING SYSTEM${C.reset}`);
await writeln(planTerm, `${C.dim} NATO NCIRC · EU HYBRID COE · CLEARANCE: SECRET/REL FVEY${C.reset}`);
await writeln(planTerm);
for (const step of DEMO) {
if (planAbort) break;
await write(planTerm, PROMPT);
for (const ch of step.cmd) {
if (planAbort) break;
await write(planTerm, ch);
await sleep(CFG.PLAN_CHAR_MS + Math.random() * CFG.PLAN_JITTER_MS - CFG.PLAN_JITTER_MS / 2);
}
await sleep(CFG.PLAN_POST_CMD_MS);
await writeln(planTerm);
await sleep(280);
for (const line of step.lines) {
if (planAbort) break;
await writeln(planTerm, line);
await sleep(CFG.PLAN_LINE_MS + Math.random() * CFG.PLAN_LINE_JITTER_MS);
}
await sleep(CFG.PLAN_STEP_MS);
}
planRunning = false;
if (!planAbort) { await sleep(CFG.PLAN_REPLAY_MS); runPlanDemo(); }
}
let planBooted = false;
const planObserver = new IntersectionObserver(async entries => {
for (const e of entries) {
if (e.isIntersecting && !planBooted) {
planBooted = true;
await waitForLibs();
const { term } = makeTerminal(document.getElementById('terminal-mount'), {
cursorBlink: true,
fontSize: 11.5,
theme: {
background: '#06090f', foreground: '#b0c4d4',
cursor: '#00c9a7', cursorAccent: '#06090f',
selectionBackground: 'rgba(0,201,167,0.18)',
black: '#1e2938', red: '#dc3232', green: '#00c9a7',
yellow: '#ffa000', blue: '#4fc3f7', magenta: '#b39ddb',
cyan: '#4dd0e1', white: '#b0c4d4', brightBlack: '#3d5060',
},
});
planTerm = term;
setTimeout(runPlanDemo, CFG.PLAN_BOOT_MS);
}
}
}, { threshold: CFG.TERMINAL_THRESHOLD });
planObserver.observe(document.getElementById('terminal-panel'));
// =========================================================================
// TOPOLOGY — Ocean floor cable density map
// =========================================================================
const TOPO_RAMPS = [
[' ', ' ', '·', '·'],
['·', ':', '~', '≈'],
['-', '=', '+', '≡'],
['▪', '▫', '▬', '▭'],
['▓', '▒', '░', '▓'],
['█', '▉', '▊', '█'],
];
const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
function topoColor(v) {
if (v < 0.15) return rgb(4, 12, 28);
if (v < 0.28) return rgb(8, 40, 80);
if (v < 0.42) return rgb(0, 80, 100);
if (v < 0.58) return rgb(0, 140, 120);
if (v < 0.72) return rgb(0, 180, 150);
if (v < 0.86) return rgb(60, 210, 180);
return rgb(180, 240, 230);
}
function topoChar(v) {
const band = Math.min(5, Math.floor(v * 6));
const ramp = TOPO_RAMPS[band];
const pos = Math.floor((v * 6 - band) * ramp.length);
return ramp[Math.min(pos, ramp.length - 1)];
}
function topoNoise(x, y, t) {
// Simulate cable-dense "chokepoints" as bright bands
const chokepoint = Math.max(0, 1 - Math.abs(x - 30) / 8) * 0.3 // Atlantic narrows
+ Math.max(0, 1 - Math.abs(x - 70) / 5) * 0.25; // Indo-Pacific
return Math.min(1, (
Math.sin(x * 0.12 + t * 0.19) * 0.28 +
Math.sin(y * 0.18 + t * 0.14) * 0.22 +
Math.sin((x + y) * 0.08 + t * 0.11) * 0.18 +
Math.sin((x * 1.1 - y * 0.9) * 0.06 + t * 0.23) * 0.12
) * 0.5 + 0.5 + chokepoint);
}
let topoTerm, topoTimer = null, topoT = 0;
function renderTopo() {
if (!topoTerm) return;
const cols = topoTerm.cols, rows = topoTerm.rows;
let out = '\x1b[H';
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const v = topoNoise(col, row, topoT);
out += topoColor(v) + topoChar(v);
}
if (row < rows - 1) out += '\r\n';
}
topoTerm.write(out);
topoT += CFG.TOPO_TIME_STEP;
}
let topoBooted = false;
const topoObserver = new IntersectionObserver(async entries => {
for (const e of entries) {
if (e.isIntersecting && !topoBooted) {
topoBooted = true;
await waitForLibs();
const { term } = makeTerminal(document.getElementById('topology-mount'), {
fontSize: CFG.TOPO_FONT_SIZE,
lineHeight: CFG.TOPO_LINE_HEIGHT,
theme: { background: '#030609', foreground: '#cdd9e5' },
});
topoTerm = term;
setTimeout(() => {
topoTimer = setInterval(renderTopo, CFG.TOPO_INTERVAL_MS);
}, CFG.TOPO_BOOT_MS);
}
if (!e.isIntersecting && topoTimer !== null) {
clearInterval(topoTimer); topoTimer = null;
} else if (e.isIntersecting && topoBooted && topoTerm && topoTimer === null) {
topoTimer = setInterval(renderTopo, CFG.TOPO_INTERVAL_MS);
}
}
}, { threshold: CFG.TOPO_THRESHOLD });
topoObserver.observe(document.getElementById('topology-panel'));
// =========================================================================
// STAT SLIDE 1 — Arrow cone (scroll-triggered)
// Reads data-direction="down|up" from the aside element.
// Adds .is-animating to trigger the CSS transition.
// =========================================================================
(function initArrowSlide() {
const slide = document.getElementById('stat-arrow');
if (!slide) return;
const cone = slide.querySelector('.stat-arrow-cone');
if (!cone) return;
// Apply up modifier if needed
if (slide.dataset.direction === 'up') cone.classList.add('stat-arrow-cone--up');
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) slide.classList.add('is-animating');
});
}, { threshold: 0.3 });
obs.observe(slide);
})();
// =========================================================================
// STAT SLIDE 2 — Dot pop grid
//
// Reads from the aside element:
// data-lit → % of dots in primary colour
// data-total → total dot count
// data-label-top → legend label for lit (top) section
// data-label-bottom → legend label for unlit (bottom) section
//
// Builds the legend + grid inside #stat-dot-panel.
// On intersection, randomly staggers each dot's spring transition.
// Dots in the top (lit) band are yellow; bottom band purple.
// =========================================================================
(function initDotPopSlide() {
const slide = document.getElementById('stat-dot-pop');
const panel = document.getElementById('stat-dot-panel');
if (!slide || !panel) return;
const LIT_PCT = parseInt(slide.dataset.lit, 10) || 51;
const TOTAL = parseInt(slide.dataset.total, 10) || 100;
const LABEL_T = slide.dataset.labelTop || 'Group A';
const LABEL_B = slide.dataset.labelBottom || 'Group B';
// Grid dimensions — aim for a roughly square layout
const COLS = 10;
const ROWS = Math.ceil(TOTAL / COLS);
const LIT_ROWS = Math.round((LIT_PCT / 100) * ROWS);
// Legend column
const legends = document.createElement('div');
legends.className = 'stat-dot-legends';
const lTop = document.createElement('span');
lTop.className = 'stat-dot-legend-label stat-dot-legend-label--yellow';
lTop.textContent = LABEL_T;
const lBot = document.createElement('span');
lBot.className = 'stat-dot-legend-label stat-dot-legend-label--purple';
lBot.textContent = LABEL_B;
legends.appendChild(lTop);
legends.appendChild(lBot);
panel.appendChild(legends);
// Dot grid
const grid = document.createElement('div');
grid.className = 'stat-dot-grid';
grid.style.gridTemplateColumns = `repeat(${COLS}, 1fr)`;
const cells = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const cell = document.createElement('div');
cell.className = 'stat-dot-cell';
const isLit = r < LIT_ROWS;
cell.style.background = isLit ? '#e8e84a' : '#8b7ff5';
if (!isLit) cell.style.opacity = '0.65';
// Random stagger delay 0–900ms
const delay = Math.random() * 900;
cell.style.transition =
`transform 0.45s cubic-bezier(0.34,1.56,0.64,1) ${delay}ms`;
grid.appendChild(cell);
cells.push(cell);
}
}
panel.appendChild(grid);
// Trigger pop on intersection
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
cells.forEach(c => { c.style.transform = 'scale(1)'; });
}
});
}, { threshold: 0.2 });
obs.observe(slide);
})();
// =========================================================================
// STAT SLIDE 3 — Count-up number + drifting shapes
//
// Reads from the aside element:
// data-target → final integer value
// data-ordinal → suffix string (th / st / nd / rd)
//
// Uses requestAnimationFrame with an ease-out curve over ~1.8 s.
// Shapes drift animation begins on the same intersection event.
// =========================================================================
(function initCountUpSlide() {
const slide = document.getElementById('stat-count-up');
const counter = document.getElementById('stat-counter');
if (!slide || !counter) return;
const TARGET = parseInt(slide.dataset.target, 10) || 145;
const ORDINAL = slide.dataset.ordinal || 'th';
const DURATION_MS = 1800;
// Update the ordinal span too
const ordSpan = slide.querySelector('.stat-ordinal');
if (ordSpan) ordSpan.textContent = ORDINAL;
let started = false;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function runCount() {
const start = performance.now();
function frame(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / DURATION_MS, 1);
counter.textContent = Math.round(easeOut(progress) * TARGET);
if (progress < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting && !started) {
started = true;
slide.classList.add('is-animating');
runCount();
}
});
}, { threshold: 0.3 });
obs.observe(slide);
})();
// =========================================================================
// STAT SLIDE 4 — Spotlight: proportional balls + sequential stat reveals
//
// Reads data attributes from each .stat-spotlight-row:
// data-color → ball fill colour
// data-value → 0–100, used to scale ball diameter relative to circle
// data-delay → ms after intersection before this ball + row appear
//
// Balls are positioned by a simple packing layout (largest first, left→right).
// Each ball uses the spring cubic-bezier for the pop.
// Its corresponding stat row gets .is-revealed at the same moment.
// =========================================================================
(function initSpotlightSlide() {
const slide = document.getElementById('stat-spotlight');
const circle = document.getElementById('stat-spotlight-circle');
if (!slide || !circle) return;
const rows = Array.from(slide.querySelectorAll('.stat-spotlight-row'));
const CIRCLE_SIZE = circle.offsetWidth || 290;
// Pre-compute ball sizes and positions
// Simple horizontal packing: place balls left→right, vertically centred
const ballData = rows.map(row => ({
row,
color: row.dataset.color || '#fff',
value: parseFloat(row.dataset.value) || 20,
delay: parseInt(row.dataset.delay, 10) || 400,
}));
// Sort largest first for placement
const sorted = [...ballData].sort((a, b) => b.value - a.value);
// Diameter as fraction of circle: largest value = 70% of circle
const maxVal = sorted[0].value;
const maxDiamPct = 0.68;
// Position each ball: pack left to right, vertically centred
let xCursor = 0.02; // starts slightly inside left edge (fraction of circle)
sorted.forEach(d => {
const diam = (d.value / maxVal) * maxDiamPct; // fraction of circle
const diamPx = diam * CIRCLE_SIZE;
const yFrac = 0.5 - diam / 2; // vertically centred
const ball = document.createElement('div');
ball.className = 'stat-spotlight-ball';
ball.style.cssText = `
background: ${d.color};
width: ${diamPx}px;
height: ${diamPx}px;
left: ${xCursor * 100}%;
top: ${yFrac * 100}%;
`;
circle.appendChild(ball);
d.ball = ball;
xCursor += diam * 0.85; // slight overlap, like the reference
});
// Restore original order reference for row reveals
ballData.forEach(d => {
// find the pre-sorted item for this row
d.sortedRef = sorted.find(s => s.row === d.row);
});
let triggered = false;
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting && !triggered) {
triggered = true;
// Trigger each ball + its row in order of data-delay
ballData.forEach(d => {
const sortD = sorted.find(s => s.row === d.row);
setTimeout(() => {
if (sortD && sortD.ball) {
sortD.ball.style.transform = 'scale(1)';
}
d.row.classList.add('is-revealed');
}, d.delay);
});
}
});
}, { threshold: 0.3 });
obs.observe(slide);
})();
// =========================================================================
// ASCII VIDEO — xterm.js video-to-ASCII renderer
//
// Falls back to matrix rain if the video cannot be loaded (CORS / network).
// Swap ASCII_STYLE to change the character ramp.
// =========================================================================
const ASCII_STYLE = 'block'; // 'classic' | 'block' | 'topographic' | 'minimal' | 'dense' | 'katakana'
const ASCII_STYLES = {
classic: { fontSize: 8, ramp: Array.from(" .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$") },
block: { fontSize: 11, ramp: Array.from(' ░░▒▒▓▓██') },
topographic: { fontSize: 10, ramp: Array.from(' ·:~-=+≡▪▬▓█') },
minimal: { fontSize: 14, ramp: Array.from(' ·:;|*%@') },
dense: { fontSize: 6, ramp: Array.from(" `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@") },
katakana: { fontSize: 9, ramp: Array.from(' ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン') },
};
const ASCII_RAMP = ASCII_STYLES[ASCII_STYLE].ramp;
const RAMP_MAX = ASCII_RAMP.length - 1;
const ASCII_FPS = 12;
const ASCII_FRAME_MS = 1000 / ASCII_FPS;
let asciiTerm, asciiTimer = null;
function asciiRainFallback() {
const cols = asciiTerm.cols, rows = asciiTerm.rows;
const rainChars = '01アイウエオカキクケコサシスセソタチツテトナニヌネノ';
const speed = Array.from({ length: cols }, () => 0.3 + Math.random() * 0.7);
const pos = Array.from({ length: cols }, () => Math.random() * rows);
const grid = Array.from({ length: rows }, () => Array(cols).fill([' ', 0]));
asciiTimer = setInterval(() => {
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
grid[r][c] = [grid[r][c][0], Math.max(0, grid[r][c][1] - 0.04)];
for (let c = 0; c < cols; c++) {
pos[c] += speed[c];
if (pos[c] >= rows) pos[c] = 0;
const r = Math.floor(pos[c]);
grid[r][c] = [rainChars[Math.floor(Math.random() * rainChars.length)], 1.0];
}
let out = '\x1b[H';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const [ch, intensity] = grid[r][c];
const g = Math.floor(intensity * 220);
out += `\x1b[38;2;0;${g};${Math.floor(g * 0.4)}m${ch}`;
}
if (r < rows - 1) out += '\r\n';
}
asciiTerm.write(out);
}, 80);
}
function startAsciiVideo() {
const cols = asciiTerm.cols, rows = asciiTerm.rows;
const video = Object.assign(document.createElement('video'), {
muted: true, loop: true, playsInline: true, crossOrigin: 'anonymous',
});
video.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;top:-9999px';
document.body.appendChild(video);
video.src = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4#t=10,20';
const canvas = document.createElement('canvas');
canvas.width = cols; canvas.height = rows;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let corsOk = true, lastTs = null, debt = 0;
function renderVideoFrame(ts) {
if (!corsOk) return;
if (lastTs === null) lastTs = ts;
const elapsed = (ts - lastTs) + debt;
if (elapsed < ASCII_FRAME_MS) {
asciiTimer = requestAnimationFrame(renderVideoFrame);
return;
}
debt = elapsed - ASCII_FRAME_MS;
lastTs = ts;
const termAspect = (cols * 0.6) / rows;
const vw = video.videoWidth || 1, vh = video.videoHeight || 1;
const videoAspect = vw / vh;
let sx = 0, sy = 0, sw = vw, sh = vh;
if (termAspect < videoAspect) { sw = vh * termAspect; sx = (vw - sw) / 2; }
else { sh = vw / termAspect; sy = (vh - sh) / 2; }
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, cols, rows);
let pixels;
try {
pixels = ctx.getImageData(0, 0, cols, rows).data;
} catch (_) {
corsOk = false;
cancelAnimationFrame(asciiTimer);
video.remove();
asciiRainFallback();
return;
}
let out = '\x1b[H';
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const i = (row * cols + col) * 4;
const r = pixels[i], g = pixels[i+1], b = pixels[i+2];
const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
out += `\x1b[38;2;${r};${g};${b}m${ASCII_RAMP[Math.round(lum * RAMP_MAX)]}`;
}
if (row < rows - 1) out += '\r\n';
}
asciiTerm.write(out);
asciiTimer = requestAnimationFrame(renderVideoFrame);
}
video.addEventListener('playing', () => {
asciiTimer = requestAnimationFrame(renderVideoFrame);
});
video.play().catch(() => { video.remove(); asciiRainFallback(); });
}
let asciiBooted = false;
const asciiObserver = new IntersectionObserver(async entries => {
for (const e of entries) {
if (e.isIntersecting && !asciiBooted) {
asciiBooted = true;
await waitForLibs();
const { term } = makeTerminal(document.getElementById('ascii-video-mount'), {
fontSize: ASCII_STYLES[ASCII_STYLE].fontSize,
lineHeight: 1.0,
theme: { background: '#000000', foreground: '#ffffff' },
});
asciiTerm = term;
setTimeout(startAsciiVideo, 700);
}
if (!e.isIntersecting && asciiTimer) {
cancelAnimationFrame(asciiTimer);
clearInterval(asciiTimer);
asciiTimer = null;
} else if (e.isIntersecting && asciiBooted && asciiTerm && !asciiTimer) {
startAsciiVideo();
}
}
}, { threshold: 0.05 });
asciiObserver.observe(document.getElementById('ascii-video-panel'));
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment