"use client"; import * as React from "react"; import { motion } from "framer-motion"; import { useContent } from "@/src/components/ContentProvider"; import { useNarrow } from "./useNarrow"; /** * WebPresentation: the drawings of the web presentations section. The main one * shows the same presentation as a slide file and as a website on three * screens; four small ones stand next to the reasons. Same line language as * PathScenes. */ const INK = "#f8fafc"; const LINE = "#475569"; const RIM = "#64748b"; const SOFT = "#1e293b"; const MARK = "#fde68a"; const LIVE = "#10b981"; const TINT = "rgba(248, 250, 252, 0.14)"; const FINE = 1.1; const STEP_MS = 3600; const MOVE = { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const }; const MONO: React.CSSProperties = { fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace", fontSize: "var(--scene-label, 10px)", letterSpacing: "0.14em", textTransform: "uppercase", fill: "#94a3b8", }; interface Size { width: number; height: number; } interface Rect extends Size { x: number; y: number; } /** Projector, laptop, phone. */ const SCREENS: readonly Size[] = [ { width: 300, height: 170 }, { width: 230, height: 150 }, { width: 96, height: 190 }, ]; const SLIDE: Size = { width: 160, height: 90 }; const PAD = 12; const GAP = 8; const centred = (size: Size, cx: number, cy: number): Rect => ({ ...size, x: cx - size.width / 2, y: cy - size.height / 2, }); /** The slide keeps its format: it is scaled down until it fits the screen. */ const fitSlide = (screen: Rect) => { const scale = Math.min( (screen.width - 2 * PAD) / SLIDE.width, (screen.height - 2 * PAD) / SLIDE.height, ); return { scale, x: screen.x + (screen.width - SLIDE.width * scale) / 2, y: screen.y + (screen.height - SLIDE.height * scale) / 2, }; }; /** The web page rearranges: three columns, two columns and a row, or one column. */ const webLayout = (screen: Rect, index: number) => { const left = screen.x + PAD; const inner = screen.width - 2 * PAD; const header: Rect = { x: left, y: screen.y + PAD, width: inner, height: 12 }; const top = header.y + header.height + GAP; const area = screen.y + screen.height - PAD - top; if (index === 0) { const width = (inner - 2 * GAP) / 3; return { header, cards: [0, 1, 2].map((i) => ({ x: left + i * (width + GAP), y: top, width, height: area, })), }; } if (index === 1) { const width = (inner - GAP) / 2; const upper = (area - GAP) * 0.56; return { header, cards: [ { x: left, y: top, width, height: upper }, { x: left + width + GAP, y: top, width, height: upper }, { x: left, y: top + upper + GAP, width: inner, height: area - upper - GAP, }, ], }; } const height = (area - 2 * GAP) / 3; return { header, cards: [0, 1, 2].map((i) => ({ x: left, y: top + i * (height + GAP), width: inner, height, })), }; }; const Frame: React.FC<{ rect: Rect }> = ({ rect }) => ( ); /** One 16:9 slide, drawn at its own size and scaled as a whole. */ const Slide: React.FC<{ screen: Rect }> = ({ screen }) => { const fit = fitSlide(screen); return ( {[0, 1, 2].map((bullet) => ( ))} {[0, 1, 2, 3].map((bar) => { const height = [18, 30, 24, 40][bar % 4]; return ( ); })} ); }; const WebPage: React.FC<{ screen: Rect; index: number }> = ({ screen, index, }) => { const { header, cards } = webLayout(screen, index); return ( {cards.map((card, i) => ( ))} ); }; /** * PresentationCompare: the same presentation as a slide file and as a website, * on a projector, a laptop and a phone. It steps through the screens on its * own until the visitor picks one. */ export const PresentationCompare: React.FC = () => { const { compare } = useContent().presentations; const narrow = useNarrow(); const ref = React.useRef(null); const [index, setIndex] = React.useState(0); const [auto, setAuto] = React.useState(true); const [inView, setInView] = React.useState(false); React.useEffect(() => { const node = ref.current; if (!node) return; const observer = new IntersectionObserver( ([entry]) => setInView(entry?.isIntersecting ?? false), { threshold: 0.3 }, ); observer.observe(node); return () => observer.disconnect(); }, []); React.useEffect(() => { if (!auto || !inView) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const timer = window.setInterval( () => setIndex((current) => (current + 1) % SCREENS.length), STEP_MS, ); return () => window.clearInterval(timer); }, [auto, inView]); // Side by side on wide screens, one above the other on a phone. const view = narrow ? { width: 380, height: 600 } : { width: 760, height: 300 }; const stages = narrow ? [ { cx: 190, cy: 150 }, { cx: 190, cy: 450 }, ] : [ { cx: 190, cy: 150 }, { cx: 570, cy: 150 }, ]; const size = SCREENS[index] ?? SCREENS[0]; if (!size) return null; const slideScreen = centred(size, stages[0]?.cx ?? 0, stages[0]?.cy ?? 0); const webScreen = centred(size, stages[1]?.cx ?? 0, stages[1]?.cy ?? 0); const tooSmall = index === SCREENS.length - 1; return (
{[ { stage: stages[0], name: compare.slides, note: compare.slideNotes[index], colour: tooSmall ? MARK : "#94a3b8", }, { stage: stages[1], name: compare.web, note: compare.webNotes[index], colour: LIVE, }, ].map( (column) => column.stage && ( {column.name} {column.note} ), )}
{compare.pickScreen} {compare.screens.map((screen, i) => { const selected = i === index; return ( ); })}
); }; const draw = (delay: number) => ({ initial: { pathLength: 0, opacity: 0 }, whileInView: { pathLength: 1, opacity: 1 }, viewport: { once: true, margin: "-10%" }, transition: { duration: 0.7, delay, ease: "easeOut" as const }, }); /** Every screen: a projector, a laptop and a phone drawn one after another. */ const ScreensDrawing: React.FC = () => ( ); /** One link: several copies of a file on one side, a single address on the other. */ const LinkDrawing: React.FC = () => ( {[0, 1, 2].map((copy) => ( ))} ); /** The real thing: a chart whose bars keep moving, because the figures are live. */ const LiveDrawing: React.FC = () => ( {[0, 1, 2, 3, 4, 5].map((bar) => { const low = [14, 22, 18, 30, 24, 36][bar % 6] ?? 20; const high = [26, 16, 34, 22, 40, 28][bar % 6] ?? 30; return ( ); })} ); /** Afterwards: a page that is read on its own and passed on. */ const AfterDrawing: React.FC = () => ( {[0, 1, 2, 3].map((row) => ( ))} {[24, 46, 68].map((y, i) => ( ))} ); const REASON_DRAWINGS: readonly React.FC[] = [ ScreensDrawing, LinkDrawing, LiveDrawing, AfterDrawing, ]; /** The small drawing next to one of the reasons, by its position in the list. */ export const ReasonDrawing: React.FC<{ index: number }> = ({ index }) => { const Drawing = REASON_DRAWINGS[index]; if (!Drawing) return null; return ( ); };