A Pen by James Drew on CodePen.
Created
May 10, 2026 13:05
-
-
Save jdrew1303/c8e21c57692deea093ef4aa575fd4662 to your computer and use it in GitHub Desktop.
cinemagraph_site_demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en" data-color-scheme="light"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> | |
| <title>High Fidelity Scaffold</title> | |
| <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> | |
| <link rel="preconnect" href="https://uploads.guim.co.uk" crossorigin> | |
| <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&display=swap" rel="stylesheet"> | |
| <!-- xterm.js @6 --> | |
| <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> | |
| <style> | |
| /* ========================================================================== | |
| 1. DESIGN TOKENS | |
| ========================================================================== */ | |
| :root { | |
| --page-border: #dcdcdc; | |
| --page-background: #ffffff; | |
| --page-text: #121212; | |
| --page-max-width: 100%; | |
| } | |
| @media (min-width: 46.25em) { :root { --page-max-width: 740px; } } | |
| @media (min-width: 61.25em) { :root { --page-max-width: 980px; } } | |
| @media (min-width: 71.25em) { :root { --page-max-width: 1140px; } } | |
| @media (min-width: 81.25em) { :root { --page-max-width: 1300px; } } | |
| /* ========================================================================== | |
| 2. GLOBAL BASE | |
| ========================================================================== */ | |
| body { | |
| margin: 0; padding: 0; | |
| background-color: #f6f6f6; | |
| color: var(--page-text); | |
| font-family: 'JetBrains Mono', 'Menlo', 'Cascadia Code', monospace; | |
| font-variant-ligatures: contextual; | |
| letter-spacing: -0.02em; | |
| line-height: 1.6; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* Screen-reader only utility — used by terminal live-regions */ | |
| .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; | |
| } | |
| h1, h2 { | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| /* ========================================================================== | |
| 3. PAGE CONTAINER | |
| Renamed from .gs-container → .page-container | |
| ========================================================================== */ | |
| .page-container { | |
| position: relative; | |
| margin: 0 auto; | |
| max-width: var(--page-max-width); | |
| background-color: var(--page-background); | |
| border-left: 1px solid var(--page-border); | |
| border-right: 1px solid var(--page-border); | |
| box-sizing: border-box; | |
| } | |
| /* | |
| * STACKING CONTEXT NOTE — centre dividing line | |
| * ───────────────────────────────────────────── | |
| * This ::after pseudo draws a 1 px vertical rule at exactly 50% width. | |
| * It is positioned absolute within .page-container (z-index 5). | |
| * | |
| * IMPORTANT: .article-hero overrides this line inside the hero section by | |
| * creating its own stacking context (isolation: isolate + z-index: 10). | |
| * Do NOT remove isolation or z-index from .article-hero or the line will | |
| * reappear through the full-bleed video. See section 4 below. | |
| */ | |
| @media (min-width: 61.25em) { | |
| .page-container::after { | |
| content: ""; | |
| position: absolute; | |
| top: 0; bottom: 0; left: 50%; | |
| width: 1px; | |
| background-color: var(--page-border); | |
| z-index: 5; | |
| pointer-events: none; | |
| } | |
| } | |
| /* ========================================================================== | |
| 4. HERO — renamed from .layout-header | |
| ========================================================================== */ | |
| /* | |
| * STACKING CONTEXT NOTE — suppressing the centre line in the hero | |
| * ───────────────────────────────────────────────────────────────── | |
| * The .page-container::after line (z-index 5) bleeds through any child | |
| * unless that child creates its own stacking context above it. | |
| * | |
| * .article-hero does this with: | |
| * isolation: isolate → creates a new stacking context | |
| * z-index: 10 → lifts the context above z-index 5 | |
| * | |
| * The ::before pseudo-element is a transparent full-bleed layer at z-index 6 | |
| * that sits above the container's line but below the hero's own content. | |
| * It does not need a background colour — its purpose is purely to occupy the | |
| * stacking order and prevent the ::after line from painting through. | |
| * | |
| * DO NOT remove either isolation, z-index, or the ::before without | |
| * re-testing the hero at ≥61.25em viewport width. | |
| */ | |
| .article-hero { | |
| position: relative; | |
| background-color: #000; | |
| border-bottom: 1px solid var(--page-border); | |
| isolation: isolate; /* ← creates stacking context; suppresses container line */ | |
| z-index: 10; /* ← lifts context above .page-container::after (z-index 5) */ | |
| } | |
| /* Transparent full-bleed layer — sits above container line (z-index 5), | |
| below hero content (z-index 10+). Transparent background is intentional. */ | |
| .article-hero::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: transparent; | |
| z-index: 6; | |
| pointer-events: none; | |
| } | |
| /* Sticky video backdrop */ | |
| .hero-media { | |
| position: sticky; | |
| top: 0; | |
| height: 100vh; | |
| width: 100%; | |
| /* will-change: transform tells the compositor to promote this layer, | |
| preventing the sticky video from triggering full repaints on scroll */ | |
| will-change: transform; | |
| } | |
| .hero-media video { | |
| width: 100%; | |
| height: 100vh; | |
| object-fit: cover; | |
| opacity: 0.7; | |
| } | |
| /* Text content floats over the sticky video */ | |
| .hero-furniture { | |
| position: relative; | |
| z-index: 10; | |
| margin-top: -100vh; | |
| pointer-events: none; | |
| padding-bottom: 10vh; | |
| } | |
| .hero-furniture > * { pointer-events: auto; } | |
| /* Headline block */ | |
| .hero-headline { | |
| padding-top: 45vh; | |
| padding-left: 20px; | |
| } | |
| .hero-headline h1 { | |
| color: #fff; | |
| margin: 0 0 24px 0; | |
| padding-top: 12px; | |
| max-width: 500px; | |
| border-top: 6px solid #fff; | |
| } | |
| .hero-headline h1 span { | |
| font-weight: 700; | |
| line-height: 1; | |
| display: block; | |
| } | |
| .hero-headline h1 span:first-of-type { | |
| font-size: clamp(2.5rem, 5vw, 4rem); | |
| padding-bottom: 10px; | |
| } | |
| .hero-headline h1 span:last-of-type { | |
| font-size: clamp(1.2rem, 2vw, 1.7rem); | |
| font-weight: 500; | |
| } | |
| /* Standfirst block */ | |
| .hero-standfirst { | |
| padding: 0 20px 20vh 20px; | |
| max-width: 600px; | |
| } | |
| .hero-standfirst p { | |
| font-size: 1.4rem; | |
| line-height: 1.3; | |
| color: #fff; | |
| } | |
| @media (min-width: 71.25em) { | |
| .hero-headline, | |
| .hero-standfirst { padding-left: 60px; } | |
| } | |
| /* ========================================================================== | |
| 5. SCROLLYTELLING SECTIONS — renamed from .layout-section / .layout-* | |
| ========================================================================== */ | |
| .scroll-section { | |
| position: relative; | |
| border-bottom: 1px solid var(--page-border); | |
| } | |
| @media (min-width: 61.25em) { | |
| .scroll-section { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| grid-template-areas: "text media"; | |
| } | |
| } | |
| /* Left: text column */ | |
| .scroll-text { | |
| padding: 10vh 20px; | |
| z-index: 10; | |
| position: relative; | |
| } | |
| @media (min-width: 61.25em) { | |
| .scroll-text { | |
| grid-area: text; | |
| padding: 15vh 60px 25vh 60px; | |
| } | |
| } | |
| /* Right: sticky media column */ | |
| .scroll-media { | |
| position: sticky; | |
| top: 0; | |
| height: 100vh; | |
| z-index: 1; | |
| overflow: hidden; | |
| background-color: #000; | |
| opacity: 0; | |
| transition: opacity 1s ease-in-out; | |
| /* | |
| * will-change: opacity matches the transition property above. | |
| * Promotes the element to its own compositor layer, preventing | |
| * the fade-in from triggering a repaint of the text column. | |
| */ | |
| will-change: opacity; | |
| } | |
| .scroll-media.is-visible { opacity: 1; } | |
| @media (min-width: 61.25em) { | |
| .scroll-media { grid-area: media; } | |
| } | |
| .scroll-media video { | |
| width: 100%; | |
| height: 100vh; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .media-caption { | |
| font-size: 0.8rem; | |
| background: rgba(18, 18, 18, 0.8); | |
| color: #fff; | |
| padding: 8px 12px; | |
| position: absolute; | |
| bottom: 0; | |
| right: 0; | |
| z-index: 10; | |
| } | |
| /* ========================================================================== | |
| 6. ARTICLE TYPOGRAPHY | |
| ========================================================================== */ | |
| .section-heading h2 { | |
| font-size: clamp(2rem, 4vw, 3.2rem); | |
| margin: 0 0 30px 0; | |
| line-height: 1; | |
| } | |
| .body-text { | |
| font-size: 1.25rem; | |
| line-height: 1.6; | |
| margin-bottom: 1.5rem; | |
| max-width: 500px; | |
| } | |
| /* ========================================================================== | |
| 7. TERMINAL PANELS — shared | |
| ========================================================================== */ | |
| .scroll-media--terminal, | |
| .scroll-media--topology, | |
| .scroll-media--ascii-video { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .terminal-mount { | |
| flex: 1; | |
| overflow: hidden; | |
| box-sizing: border-box; | |
| min-height: 0; /* required for flex children to respect overflow: hidden */ | |
| /* | |
| * will-change: transform tells the compositor this element's children | |
| * change frequently (xterm writes every frame), keeping repaints | |
| * contained to this layer. | |
| */ | |
| will-change: transform; | |
| contain: strict; | |
| } | |
| /* Suppress xterm's built-in scrollbar — scrollback:0 in JS is the primary | |
| mechanism; this is a belt-and-braces CSS guard. */ | |
| .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%; } | |
| /* Per-panel backgrounds */ | |
| .scroll-media--terminal { background: #0a0e1a; } | |
| .scroll-media--topology { background: #050810; } | |
| .scroll-media--ascii-video { background: #000; } | |
| /* Planning terminal has breathing room; topology/ascii fill edge-to-edge */ | |
| #terminal-mount { padding: 24px 16px 16px 16px; } | |
| #topology-mount { padding: 0; } | |
| #ascii-video-mount { padding: 0; } | |
| .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"> | |
| <header class="article-hero"> | |
| <div class="hero-media"> | |
| <video autoplay muted loop playsinline | |
| aria-hidden="true" | |
| title="Aerial footage of a city at night"> | |
| <source src="https://uploads.guim.co.uk/2026/02/19/MAIN_1500_desktop.mp4" type="video/mp4" /> | |
| </video> | |
| </div> | |
| <div class="hero-furniture"> | |
| <div class="hero-headline"> | |
| <h1> | |
| <span>A war foretold:</span> | |
| <span>how the CIA and MI6 got hold of Putin's Ukraine plans</span> | |
| </h1> | |
| </div> | |
| <div class="hero-standfirst"> | |
| <p>Drawing on more than 100 interviews with senior intelligence officials, this account details how the West uncovered Vladimir Putin's plans to invade.</p> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <!-- ── Section 1 ─────────────────────────────────────────────────── --> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>Putin starts planning</h2> | |
| </div> | |
| <p class="body-text">The CIA discovered an awful lot about Putin's plans to invade Ukraine, but one thing they never worked out for sure is when he first made up his mind to go all-in.</p> | |
| <p class="body-text">During those months, Putin passed constitutional amendments to ensure he could stay in power beyond 2024. Then, locked away in isolation for months during Covid, he devoured books on Russian history and pondered his own place in it.</p> | |
| <p class="body-text">Hints of that plan first came into focus in the spring of 2021, when Russian troops began building up along Ukraine's borders and in occupied Crimea, supposedly for training exercises.</p> | |
| </div> | |
| <div class="scroll-media" aria-hidden="true"> | |
| <video class="scroll-video" preload="metadata" muted loop playsinline title="The Kremlin building in Moscow"> | |
| <source src="https://uploads.guim.co.uk/2026/02/19/The_Kremlin--bb6ac474-5134-4b09-8aae-e7920d603f5a-4.0.mp4" type="video/mp4" /> | |
| </video> | |
| <figcaption class="media-caption">The Kremlin, Moscow.</figcaption> | |
| </div> | |
| </div> | |
| <!-- ── Section 2 ─────────────────────────────────────────────────── --> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>The final warnings</h2> | |
| </div> | |
| <p class="body-text">On 22 February, the day after Putin's theatrical performance, Ukraine's own security council met in Kyiv. Zaluzhnyi tried to canvass support for martial law.</p> | |
| <p class="body-text">A few hours later, Zelenskyy was handed a red folder containing a top-secret intelligence report about a "direct physical threat" to the president.</p> | |
| <p class="body-text">Bartosz Cichocki, Poland's ambassador, remained in Kyiv. He received a classified telegram stating that the invasion would start that night.</p> | |
| <p class="body-text">He saw the people of Kyiv going about their business on a winter's evening, a jarringly normal scene given what he now knew. War was coming.</p> | |
| </div> | |
| <div class="scroll-media" aria-hidden="true"> | |
| <video class="scroll-video" preload="metadata" muted loop playsinline title="An intelligence encryption device"> | |
| <source src="https://uploads.guim.co.uk/2026/02/19/Encryptor_device--42e1f39a-6611-483d-b4e9-29299b153a40-5.0.mp4" type="video/mp4" /> | |
| </video> | |
| <figcaption class="media-caption">Intelligence encryption device.</figcaption> | |
| </div> | |
| </div> | |
| <!-- ── Section 3: Planning terminal ──────────────────────────────── --> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>See it in practice</h2> | |
| </div> | |
| <p class="body-text">The terminal opposite simulates a council officer querying an LLM-backed planning assistant — the kind of tool we help design and deploy.</p> | |
| <p class="body-text">The system draws on the local development plan, national planning policy, and case history. The officer stays in control; the model accelerates the legwork.</p> | |
| <p class="body-text">This is not a chatbot bolted on to a website. It is a deliberate, audited workflow — with clear escalation paths and a paper trail regulators can follow.</p> | |
| </div> | |
| <div class="scroll-media scroll-media--terminal" | |
| id="terminal-panel" | |
| role="region" | |
| aria-label="Simulated planning assistant terminal. Auto-playing demo, non-interactive."> | |
| <div id="terminal-mount" class="terminal-mount"></div> | |
| </div> | |
| </div> | |
| <!-- ── Section 4: Topology terminal ──────────────────────────────── --> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>Reading the territory</h2> | |
| </div> | |
| <p class="body-text">Every planning decision begins with a reading of place — its density, its edges, the way land use grades from one thing to another over the span of a street.</p> | |
| <p class="body-text">LLMs work best when they are given that same spatial grammar. We help authorities structure their local plan data, design code, and site histories in ways that a model can reason over — not just retrieve from.</p> | |
| <p class="body-text">What you see opposite is a live procedural rendering of a synthetic urban topology. The characters encode density. The colours encode use class. It updates continuously — a cartography of inference.</p> | |
| </div> | |
| <div class="scroll-media scroll-media--topology" | |
| id="topology-panel" | |
| role="img" | |
| aria-label="Live procedural map rendering. Animated ASCII art representing urban density and land use. Non-interactive."> | |
| <div id="topology-mount" class="terminal-mount"></div> | |
| </div> | |
| </div> | |
| <!-- ── Section 5: ASCII video terminal ───────────────────────────── --> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>Vision through character</h2> | |
| </div> | |
| <p class="body-text">A moving image, rendered entirely in coloured ASCII characters. Each cell in the grid is a pixel sampled from the source cinemagraph — brightness mapped to glyph weight, colour passed through directly as ANSI truecolor.</p> | |
| <p class="body-text">The effect sits at the edge of legibility: you can read it as image or as text, but not quite as both at once. That ambiguity is intentional.</p> | |
| <p class="body-text">It is also a demonstration of what becomes possible when you treat the terminal not as a text interface but as a medium — one with its own aesthetic logic, and its own way of making things visible.</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> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // ========================================================================= | |
| // CONSTANTS | |
| // All timings, font sizes, and animation parameters in one place. | |
| // ========================================================================= | |
| const CONFIG = { | |
| // General | |
| LIBS_POLL_INTERVAL_MS: 80, // how often to check xterm libs have loaded | |
| // Scroll observer | |
| FADE_THRESHOLD: 0.1, // IntersectionObserver threshold for fade-in | |
| VIDEO_THRESHOLD: 0.1, // IntersectionObserver threshold for video play | |
| TERMINAL_THRESHOLD: 0.15, // IntersectionObserver threshold for terminal boot | |
| TOPO_THRESHOLD: 0.05, | |
| ASCII_THRESHOLD: 0.05, | |
| // Planning terminal | |
| PLAN_BOOT_DELAY_MS: 700, // delay after fade-in before demo starts | |
| PLAN_CHAR_SPEED_MS: 48, // base ms per typed character | |
| PLAN_CHAR_JITTER_MS: 32, // ± random jitter on char speed | |
| PLAN_POST_CMD_DELAY_MS: 220, // pause after command before response | |
| PLAN_LINE_SPEED_MS: 26, // ms between response lines | |
| PLAN_LINE_JITTER_MS: 16, // ± random jitter on line speed | |
| PLAN_INTER_STEP_MS: 1200, // pause between demo steps | |
| PLAN_REPLAY_DELAY_MS: 2500, // pause before auto-replay | |
| // Topology | |
| TOPO_BOOT_DELAY_MS: 600, | |
| TOPO_INTERVAL_MS: 83, // render interval (~12fps) | |
| TOPO_TIME_STEP: 0.018, // animation speed (higher = faster drift) | |
| TOPO_FONT_SIZE: 11, | |
| TOPO_LINE_HEIGHT: 1.05, | |
| // ASCII video | |
| ASCII_BOOT_DELAY_MS: 700, | |
| ASCII_TARGET_FPS: 12, // target frames per second | |
| ASCII_FONT_SIZE: 11, // overridden per-style below | |
| }; | |
| // ========================================================================= | |
| // Utility: poll until async xterm scripts have executed | |
| // ========================================================================= | |
| function waitForLibs() { | |
| return new Promise(resolve => { | |
| const poll = () => (window.Terminal && window.FitAddon) | |
| ? resolve() | |
| : setTimeout(poll, CONFIG.LIBS_POLL_INTERVAL_MS); | |
| poll(); | |
| }); | |
| } | |
| // ========================================================================= | |
| // Observers — fade-in & video play/pause | |
| // ========================================================================= | |
| const figureObserver = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) e.target.classList.add('is-visible'); | |
| }); | |
| }, { threshold: CONFIG.FADE_THRESHOLD }); | |
| document.querySelectorAll('.scroll-media').forEach(f => figureObserver.observe(f)); | |
| const videoObserver = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) e.target.play().catch(() => {}); | |
| else e.target.pause(); | |
| }); | |
| }, { threshold: CONFIG.VIDEO_THRESHOLD }); | |
| document.querySelectorAll('.scroll-video').forEach(v => videoObserver.observe(v)); | |
| // ========================================================================= | |
| // Factory: create an xterm Terminal + FitAddon in a given mount element. | |
| // Includes a screen-reader live region for accessible output. | |
| // ========================================================================= | |
| function makeTerminal(mountEl, opts = {}) { | |
| const term = new Terminal(Object.assign({ | |
| fontFamily: "'JetBrains Mono', 'Fira Code', 'Courier New', monospace", | |
| fontSize: 12.5, | |
| lineHeight: 1.5, | |
| scrollback: 0, // no history accumulation → no scrollbar | |
| 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'); // hide cursor (DECTCEM off) | |
| window.addEventListener('resize', () => { try { fit.fit(); } catch (_) {} }); | |
| // WCAG: polite live region so screen readers announce terminal output | |
| // without interrupting the user. Strips ANSI codes before announcement. | |
| const liveRegion = document.createElement('div'); | |
| liveRegion.className = 'sr-only'; | |
| liveRegion.setAttribute('aria-live', 'polite'); | |
| mountEl.appendChild(liveRegion); | |
| const originalWrite = term.write.bind(term); | |
| term.write = (data) => { | |
| const cleanText = data.replace(/\x1b\[[0-9;]*m/g, ''); | |
| if (cleanText.trim().length > 0) liveRegion.textContent = cleanText; | |
| return originalWrite(data); | |
| }; | |
| return { term, fit }; | |
| } | |
| // Async write helpers | |
| 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'); | |
| // ========================================================================= | |
| // SECTION 3 — Planning terminal | |
| // ========================================================================= | |
| const PLAN = { | |
| C: { | |
| reset: '\x1b[0m', bold: '\x1b[1m', | |
| green: '\x1b[38;2;0;229;160m', | |
| blue: '\x1b[38;2;79;195;247m', | |
| amber: '\x1b[38;2;255;179;0m', | |
| red: '\x1b[38;2;255;82;82m', | |
| muted: '\x1b[38;2;84;110;122m', | |
| white: '\x1b[97m', | |
| }, | |
| }; | |
| PLAN.PROMPT = `${PLAN.C.green}officer@planning-assist${PLAN.C.reset}${PLAN.C.muted}:~$${PLAN.C.reset} `; | |
| const PLAN_DEMO = [ | |
| { | |
| cmd: 'planning-assist --query "summarise objections to ref PA/2024/03817"', | |
| response: [ | |
| '', | |
| `${PLAN.C.blue}${PLAN.C.bold}[ Retrieving application PA/2024/03817 ]${PLAN.C.reset}`, | |
| `${PLAN.C.muted} Source: IDOX / local plan / NPPF 2023${PLAN.C.reset}`, | |
| '', | |
| `${PLAN.C.amber}${PLAN.C.bold}Objections received: 47 (14 unique issues identified)${PLAN.C.reset}`, | |
| '', | |
| `${PLAN.C.green}Material planning considerations:${PLAN.C.reset}`, | |
| ` ${PLAN.C.white}1.${PLAN.C.reset} Highways — 31 reps cite increased HGV movements on`, | |
| ` Millbrook Lane (unclassified, width 4.2 m).`, | |
| ` ${PLAN.C.amber}⚠ Conflict with Local Plan Policy T4.${PLAN.C.reset}`, | |
| '', | |
| ` ${PLAN.C.white}2.${PLAN.C.reset} Visual amenity — 18 reps, loss of open aspect from`, | |
| ` nos. 14–28 Riverside Close. Block height 14.2 m vs`, | |
| ` 9 m threshold in DPD para 6.4.`, | |
| ` ${PLAN.C.red}✗ Daylight/sunlight assessment update required.${PLAN.C.reset}`, | |
| '', | |
| ` ${PLAN.C.white}3.${PLAN.C.reset} Drainage — 9 reps incl. 2 from Environment Agency.`, | |
| ` EA holding objection not yet withdrawn.`, | |
| ` ${PLAN.C.amber}⚠ Outstanding statutory consultee response.${PLAN.C.reset}`, | |
| '', | |
| `${PLAN.C.muted}Non-material: 6 reps (property values, construction noise).${PLAN.C.reset}`, | |
| '', | |
| `${PLAN.C.green}Suggested action:${PLAN.C.reset} Request revised transport note and`, | |
| `chase EA response before committee deadline 14 Mar 2025.`, | |
| '', | |
| `${PLAN.C.muted}[ Confidence: high | Audit: pa-assist-20250219-114722 ]${PLAN.C.reset}`, | |
| '', | |
| ], | |
| }, | |
| { | |
| cmd: 'planning-assist --resident "how do I comment on a planning application?"', | |
| response: [ | |
| '', | |
| `${PLAN.C.blue}${PLAN.C.bold}[ Resident assistant mode ]${PLAN.C.reset}`, | |
| '', | |
| `Here's how to have your say:`, | |
| '', | |
| ` ${PLAN.C.green}1. Find the application${PLAN.C.reset}`, | |
| ` Visit the council's Public Access portal and search`, | |
| ` by address or application reference number.`, | |
| '', | |
| ` ${PLAN.C.green}2. Submit your comments${PLAN.C.reset}`, | |
| ` Click "Make a comment". Responses are public —`, | |
| ` don't include personal contact details in the body.`, | |
| '', | |
| ` ${PLAN.C.green}3. Focus on planning matters${PLAN.C.reset}`, | |
| ` Design, traffic, noise, impact on neighbours.`, | |
| ` Officers cannot take account of house prices or`, | |
| ` general objections to change.`, | |
| '', | |
| ` ${PLAN.C.green}4. Deadline${PLAN.C.reset}`, | |
| ` 21 days from the neighbour notification letter.`, | |
| ` Late comments may be considered at officer discretion.`, | |
| '', | |
| `${PLAN.C.muted}[ planningportal.co.uk for further guidance ]${PLAN.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, `${PLAN.C.green}${PLAN.C.bold}Planning Assist Terminal${PLAN.C.reset}`); | |
| await writeln(planTerm, `${PLAN.C.muted}NPPF 2023 | Local Plan 2040 | All queries audit-logged${PLAN.C.reset}`); | |
| await writeln(planTerm); | |
| for (const step of PLAN_DEMO) { | |
| if (planAbort) break; | |
| await write(planTerm, PLAN.PROMPT); | |
| for (const ch of step.cmd) { | |
| if (planAbort) break; | |
| await write(planTerm, ch); | |
| await sleep(CONFIG.PLAN_CHAR_SPEED_MS + Math.random() * CONFIG.PLAN_CHAR_JITTER_MS - CONFIG.PLAN_CHAR_JITTER_MS / 2); | |
| } | |
| await sleep(CONFIG.PLAN_POST_CMD_DELAY_MS); | |
| await writeln(planTerm); | |
| await sleep(300); | |
| for (const line of step.response) { | |
| if (planAbort) break; | |
| await writeln(planTerm, line); | |
| await sleep(CONFIG.PLAN_LINE_SPEED_MS + Math.random() * CONFIG.PLAN_LINE_JITTER_MS); | |
| } | |
| await sleep(CONFIG.PLAN_INTER_STEP_MS); | |
| } | |
| planRunning = false; | |
| if (!planAbort) { await sleep(CONFIG.PLAN_REPLAY_DELAY_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, | |
| theme: { | |
| background: '#0a0e1a', foreground: '#cdd9e5', | |
| cursor: '#00e5a0', cursorAccent: '#0a0e1a', | |
| selectionBackground: 'rgba(0,229,160,0.2)', | |
| black: '#1e293b', red: '#ff5252', green: '#00e5a0', | |
| yellow: '#ffb300', blue: '#4fc3f7', magenta: '#ce93d8', | |
| cyan: '#4dd0e1', white: '#cdd9e5', brightBlack: '#475569', | |
| }, | |
| }); | |
| planTerm = term; | |
| setTimeout(runPlanDemo, CONFIG.PLAN_BOOT_DELAY_MS); | |
| } | |
| } | |
| }, { threshold: CONFIG.TERMINAL_THRESHOLD }); | |
| planObserver.observe(document.getElementById('terminal-panel')); | |
| // ========================================================================= | |
| // SECTION 4 — Topology terminal | |
| // ========================================================================= | |
| const TOPO_RAMPS = [ | |
| [' ', ' ', '·', '·'], | |
| ['·', ':', '~', '≈'], | |
| ['-', '=', '+', '≡'], | |
| ['▪', '▫', '▬', '▭'], | |
| ['▓', '▒', '░', '▓'], | |
| ['█', '▉', '▊', '█'], | |
| ]; | |
| const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`; | |
| function topoColor(v) { | |
| if (v < 0.15) return rgb(10, 30, 80); | |
| if (v < 0.28) return rgb(20, 80, 120); | |
| if (v < 0.42) return rgb(40, 110, 70); | |
| if (v < 0.58) return rgb(120, 130, 60); | |
| if (v < 0.72) return rgb(180, 120, 50); | |
| if (v < 0.86) return rgb(200, 160, 80); | |
| return rgb(230, 210, 200); | |
| } | |
| 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) { | |
| return ( | |
| Math.sin(x * 0.11 + t * 0.22) * 0.35 + | |
| Math.sin(y * 0.16 + t * 0.17) * 0.30 + | |
| Math.sin((x + y) * 0.07 + t * 0.13) * 0.20 + | |
| Math.sin((x * 1.2 - y * 0.8) * 0.05 + t * 0.28) * 0.15 | |
| ) * 0.5 + 0.5; | |
| } | |
| let topoTerm, topoTimer = null, topoT = 0; | |
| function renderTopo() { | |
| if (!topoTerm) return; | |
| const cols = topoTerm.cols; | |
| const 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 += CONFIG.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, fit } = makeTerminal(document.getElementById('topology-mount'), { | |
| fontSize: CONFIG.TOPO_FONT_SIZE, | |
| lineHeight: CONFIG.TOPO_LINE_HEIGHT, | |
| theme: { background: '#050810', foreground: '#cdd9e5' }, | |
| }); | |
| topoTerm = term; | |
| window.addEventListener('resize', () => { try { fit.fit(); } catch (_) {} }); | |
| setTimeout(() => { | |
| topoTimer = setInterval(renderTopo, CONFIG.TOPO_INTERVAL_MS); | |
| }, CONFIG.TOPO_BOOT_DELAY_MS); | |
| } | |
| if (!e.isIntersecting && topoTimer !== null) { | |
| clearInterval(topoTimer); | |
| topoTimer = null; | |
| } else if (e.isIntersecting && topoBooted && topoTerm && topoTimer === null) { | |
| topoTimer = setInterval(renderTopo, CONFIG.TOPO_INTERVAL_MS); | |
| } | |
| } | |
| }, { threshold: CONFIG.TOPO_THRESHOLD }); | |
| topoObserver.observe(document.getElementById('topology-panel')); | |
| // ========================================================================= | |
| // SECTION 5 — ASCII video terminal | |
| // | |
| // Frame rendering uses performance.now() delta timing to stay locked to | |
| // CONFIG.ASCII_TARGET_FPS regardless of monitor refresh rate or tab | |
| // visibility jank. The pattern is: | |
| // | |
| // 1. requestAnimationFrame fires as fast as the display allows. | |
| // 2. We measure elapsed time since the last accepted frame. | |
| // 3. We only render when elapsed ≥ frame interval. | |
| // 4. Crucially, we carry the remainder (elapsed - interval) forward | |
| // into the next delta measurement, so timing debt doesn't accumulate | |
| // and cause frames to bunch up after a slow render. | |
| // ========================================================================= | |
| // ── ASCII ramp styles ───────────────────────────────────────────────────── | |
| // Change ASCII_STYLE to switch ramp. fontSize controls resolution: | |
| // smaller font = more terminal columns/rows = higher image detail. | |
| const ASCII_STYLE = 'block'; // 'classic' | 'block' | 'topographic' | 'minimal' | 'dense' | 'katakana' | 'jim' | |
| 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(' ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン') }, | |
| jim: { fontSize: 10, ramp: Array.from(' ·:~≈-=+≡▪▫▬▭▓▒░▓█▉▊█') }, | |
| }; | |
| const ASCII_RAMP = ASCII_STYLES[ASCII_STYLE].ramp; | |
| const RAMP_MAX = ASCII_RAMP.length - 1; | |
| const FRAME_MS = 1000 / CONFIG.ASCII_TARGET_FPS; | |
| let asciiTerm, asciiTimer = null; | |
| function asciiRainFallback() { | |
| const cols = asciiTerm.cols; | |
| const rows = asciiTerm.rows; | |
| const speed = Array.from({ length: cols }, () => 0.3 + Math.random() * 0.7); | |
| const pos = Array.from({ length: cols }, () => Math.random() * rows); | |
| const rainChars = '01アイウエオカキクケコサシスセソタチツテトナニヌネノ'; | |
| const getRain = () => rainChars[Math.floor(Math.random() * rainChars.length)]; | |
| const grid = Array.from({ length: rows }, () => Array(cols).fill([' ', 0, 0, 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), 0, 0]; | |
| 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] = [getRain(), 1.0, 0, 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; | |
| const 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; | |
| // Stable delta-time ticker — see section comment above. | |
| // `lastTs` is the timestamp of the last accepted frame. | |
| // `debt` carries remainder time forward so frames don't bunch. | |
| let lastTs = null; | |
| let debt = 0; | |
| function renderVideoFrame(ts) { | |
| if (!corsOk) return; | |
| // Initialise on first call | |
| if (lastTs === null) { lastTs = ts; } | |
| const elapsed = (ts - lastTs) + debt; | |
| if (elapsed < FRAME_MS) { | |
| // Too soon — request next browser frame without rendering | |
| asciiTimer = requestAnimationFrame(renderVideoFrame); | |
| return; | |
| } | |
| // Carry remainder into next frame to keep long-run average accurate | |
| debt = elapsed - FRAME_MS; | |
| lastTs = ts; | |
| // Aspect-corrected centre crop | |
| const termAspect = (cols * 0.6) / rows; | |
| const vw = video.videoWidth || 1; | |
| const 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; | |
| const ch = ASCII_RAMP[Math.round(lum * RAMP_MAX)]; | |
| out += `\x1b[38;2;${r};${g};${b}m${ch}`; | |
| } | |
| 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, CONFIG.ASCII_BOOT_DELAY_MS); | |
| } | |
| if (!e.isIntersecting && asciiTimer) { | |
| cancelAnimationFrame(asciiTimer); | |
| clearInterval(asciiTimer); | |
| asciiTimer = null; | |
| } else if (e.isIntersecting && asciiBooted && asciiTerm && !asciiTimer) { | |
| startAsciiVideo(); | |
| } | |
| } | |
| }, { threshold: CONFIG.ASCII_THRESHOLD }); | |
| 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