A presentation built as a website instead of a slide file: why, for what, and when slides are enough. The main drawing shows the same presentation as a slide and as a web page on a projector, a laptop and a phone; the slide keeps its format and shrinks, the page rearranges. Four small drawings stand next to the reasons. No prices: none are agreed for this yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
623 lines
16 KiB
TypeScript
623 lines
16 KiB
TypeScript
"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 }) => (
|
|
<motion.rect
|
|
rx={12}
|
|
fill="#0b1220"
|
|
stroke={RIM}
|
|
strokeWidth={FINE}
|
|
initial={false}
|
|
animate={{
|
|
attrX: rect.x,
|
|
attrY: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
}}
|
|
transition={MOVE}
|
|
/>
|
|
);
|
|
|
|
/** 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 (
|
|
<motion.g
|
|
style={{ originX: 0, originY: 0 }}
|
|
initial={false}
|
|
animate={{ x: fit.x, y: fit.y, scale: fit.scale }}
|
|
transition={MOVE}
|
|
>
|
|
<rect
|
|
width={SLIDE.width}
|
|
height={SLIDE.height}
|
|
rx={4}
|
|
fill={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<line
|
|
x1={12}
|
|
x2={84}
|
|
y1={18}
|
|
y2={18}
|
|
stroke={INK}
|
|
strokeWidth={4}
|
|
strokeLinecap="round"
|
|
/>
|
|
{[0, 1, 2].map((bullet) => (
|
|
<g key={bullet}>
|
|
<circle cx={14} cy={38 + bullet * 13} r={1.6} fill={RIM} />
|
|
<line
|
|
x1={20}
|
|
x2={20 + [58, 46, 52][bullet % 3]}
|
|
y1={38 + bullet * 13}
|
|
y2={38 + bullet * 13}
|
|
stroke={RIM}
|
|
strokeWidth={2.5}
|
|
strokeLinecap="round"
|
|
/>
|
|
</g>
|
|
))}
|
|
{[0, 1, 2, 3].map((bar) => {
|
|
const height = [18, 30, 24, 40][bar % 4];
|
|
return (
|
|
<rect
|
|
key={bar}
|
|
x={104 + bar * 11}
|
|
y={74 - height}
|
|
width={7}
|
|
height={height}
|
|
rx={2}
|
|
fill={bar === 3 ? MARK : RIM}
|
|
/>
|
|
);
|
|
})}
|
|
</motion.g>
|
|
);
|
|
};
|
|
|
|
const WebPage: React.FC<{ screen: Rect; index: number }> = ({
|
|
screen,
|
|
index,
|
|
}) => {
|
|
const { header, cards } = webLayout(screen, index);
|
|
return (
|
|
<g>
|
|
<motion.rect
|
|
rx={4}
|
|
fill={SOFT}
|
|
stroke={LINE}
|
|
strokeWidth={1}
|
|
initial={false}
|
|
animate={{
|
|
attrX: header.x,
|
|
attrY: header.y,
|
|
width: header.width,
|
|
height: header.height,
|
|
}}
|
|
transition={MOVE}
|
|
/>
|
|
{cards.map((card, i) => (
|
|
<g key={i}>
|
|
<motion.rect
|
|
rx={6}
|
|
fill={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
initial={false}
|
|
animate={{
|
|
attrX: card.x,
|
|
attrY: card.y,
|
|
width: card.width,
|
|
height: card.height,
|
|
}}
|
|
transition={MOVE}
|
|
/>
|
|
<motion.line
|
|
stroke={i === 2 ? MARK : INK}
|
|
strokeWidth={3}
|
|
strokeLinecap="round"
|
|
initial={false}
|
|
animate={{
|
|
x1: card.x + 9,
|
|
x2: card.x + Math.min(card.width - 9, 9 + 46),
|
|
y1: card.y + 12,
|
|
y2: card.y + 12,
|
|
}}
|
|
transition={MOVE}
|
|
/>
|
|
<motion.line
|
|
stroke={RIM}
|
|
strokeWidth={2.5}
|
|
strokeLinecap="round"
|
|
initial={false}
|
|
animate={{
|
|
x1: card.x + 9,
|
|
x2: card.x + Math.min(card.width - 9, 9 + 62),
|
|
y1: card.y + 23,
|
|
y2: card.y + 23,
|
|
}}
|
|
transition={MOVE}
|
|
/>
|
|
</g>
|
|
))}
|
|
</g>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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<HTMLDivElement>(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 (
|
|
<div ref={ref}>
|
|
<svg
|
|
viewBox={`0 0 ${view.width} ${view.height}`}
|
|
role="img"
|
|
aria-label={compare.description}
|
|
className="block h-auto w-full [--scene-label:13px] md:[--scene-label:10px]"
|
|
>
|
|
{[
|
|
{
|
|
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 && (
|
|
<g key={column.name}>
|
|
<text
|
|
x={column.stage.cx}
|
|
y={column.stage.cy - 118}
|
|
textAnchor="middle"
|
|
style={{ ...MONO, fill: INK }}
|
|
>
|
|
{column.name}
|
|
</text>
|
|
<motion.text
|
|
key={column.note}
|
|
x={column.stage.cx}
|
|
y={column.stage.cy + 128}
|
|
textAnchor="middle"
|
|
style={{ ...MONO, fill: column.colour }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.4, delay: 0.35 }}
|
|
>
|
|
{column.note}
|
|
</motion.text>
|
|
</g>
|
|
),
|
|
)}
|
|
<Frame rect={slideScreen} />
|
|
<Slide screen={slideScreen} />
|
|
<Frame rect={webScreen} />
|
|
<WebPage screen={webScreen} index={index} />
|
|
</svg>
|
|
|
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 md:mt-8">
|
|
<span className="mr-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
|
|
{compare.pickScreen}
|
|
</span>
|
|
{compare.screens.map((screen, i) => {
|
|
const selected = i === index;
|
|
return (
|
|
<button
|
|
key={screen}
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={() => {
|
|
setIndex(i);
|
|
setAuto(false);
|
|
}}
|
|
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
|
selected
|
|
? "border-white bg-white text-slate-900"
|
|
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
|
}`}
|
|
>
|
|
{screen}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 = () => (
|
|
<g fill="none" stroke={INK} strokeWidth={FINE} strokeLinejoin="round">
|
|
<motion.rect x={14} y={14} width={86} height={50} rx={5} {...draw(0)} />
|
|
<motion.path d="M 44 74 h 26 M 57 64 v 10" stroke={RIM} {...draw(0.2)} />
|
|
<motion.rect x={112} y={30} width={60} height={38} rx={4} {...draw(0.35)} />
|
|
<motion.path d="M 104 72 h 76" stroke={RIM} {...draw(0.5)} />
|
|
<motion.rect
|
|
x={190}
|
|
y={22}
|
|
width={28}
|
|
height={52}
|
|
rx={6}
|
|
fill={TINT}
|
|
{...draw(0.7)}
|
|
/>
|
|
</g>
|
|
);
|
|
|
|
/** One link: several copies of a file on one side, a single address on the other. */
|
|
const LinkDrawing: React.FC = () => (
|
|
<g>
|
|
{[0, 1, 2].map((copy) => (
|
|
<rect
|
|
key={copy}
|
|
x={18 + copy * 12}
|
|
y={18 + copy * 8}
|
|
width={38}
|
|
height={48}
|
|
rx={4}
|
|
fill="#0b1220"
|
|
stroke={LINE}
|
|
strokeWidth={1}
|
|
strokeDasharray="3 3"
|
|
/>
|
|
))}
|
|
<motion.path
|
|
d="M 96 46 h 30"
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
markerEnd="url(#reason-arrow)"
|
|
{...draw(0.2)}
|
|
/>
|
|
<rect
|
|
x={138}
|
|
y={32}
|
|
width={82}
|
|
height={28}
|
|
rx={14}
|
|
fill={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<motion.circle
|
|
cx={152}
|
|
cy={46}
|
|
r={3.5}
|
|
fill={LIVE}
|
|
animate={{ opacity: [1, 0.35, 1] }}
|
|
transition={{ duration: 1.8, repeat: Infinity }}
|
|
/>
|
|
<line
|
|
x1={162}
|
|
x2={208}
|
|
y1={46}
|
|
y2={46}
|
|
stroke={INK}
|
|
strokeWidth={2.5}
|
|
strokeLinecap="round"
|
|
/>
|
|
</g>
|
|
);
|
|
|
|
/** The real thing: a chart whose bars keep moving, because the figures are live. */
|
|
const LiveDrawing: React.FC = () => (
|
|
<g>
|
|
<rect
|
|
x={24}
|
|
y={12}
|
|
width={186}
|
|
height={66}
|
|
rx={8}
|
|
fill="#0b1220"
|
|
stroke={RIM}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<line x1={38} x2={196} y1={66} y2={66} stroke={LINE} strokeWidth={1} />
|
|
{[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 (
|
|
<motion.rect
|
|
key={bar}
|
|
x={44 + bar * 25}
|
|
width={14}
|
|
rx={3}
|
|
fill={bar === 5 ? MARK : RIM}
|
|
initial={false}
|
|
animate={{
|
|
height: [low, high, low],
|
|
attrY: [66 - low, 66 - high, 66 - low],
|
|
}}
|
|
transition={{
|
|
duration: 3.6,
|
|
delay: bar * 0.25,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
<motion.circle
|
|
cx={198}
|
|
cy={24}
|
|
r={3}
|
|
fill={LIVE}
|
|
animate={{ opacity: [1, 0.3, 1] }}
|
|
transition={{ duration: 1.6, repeat: Infinity }}
|
|
/>
|
|
</g>
|
|
);
|
|
|
|
/** Afterwards: a page that is read on its own and passed on. */
|
|
const AfterDrawing: React.FC = () => (
|
|
<g>
|
|
<rect
|
|
x={30}
|
|
y={10}
|
|
width={74}
|
|
height={70}
|
|
rx={6}
|
|
fill={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
{[0, 1, 2, 3].map((row) => (
|
|
<line
|
|
key={row}
|
|
x1={42}
|
|
x2={42 + [48, 40, 46, 30][row % 4]}
|
|
y1={26 + row * 12}
|
|
y2={26 + row * 12}
|
|
stroke={row === 0 ? INK : RIM}
|
|
strokeWidth={row === 0 ? 3 : 2.5}
|
|
strokeLinecap="round"
|
|
/>
|
|
))}
|
|
{[24, 46, 68].map((y, i) => (
|
|
<g key={y}>
|
|
<motion.path
|
|
d={`M 104 45 C 136 45 138 ${y} 168 ${y}`}
|
|
fill="none"
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
{...draw(0.2 + i * 0.2)}
|
|
/>
|
|
<circle
|
|
cx={180}
|
|
cy={y}
|
|
r={9}
|
|
fill="#0b1220"
|
|
stroke={RIM}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<circle cx={180} cy={y - 2} r={2.6} fill={RIM} />
|
|
<path
|
|
d={`M 174.5 ${y + 5.5} q 5.5 -6 11 0`}
|
|
fill="none"
|
|
stroke={RIM}
|
|
strokeWidth={1.4}
|
|
strokeLinecap="round"
|
|
/>
|
|
</g>
|
|
))}
|
|
</g>
|
|
);
|
|
|
|
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 (
|
|
<svg
|
|
viewBox="0 0 234 90"
|
|
aria-hidden="true"
|
|
className="block h-auto w-full"
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="reason-arrow"
|
|
viewBox="0 0 10 10"
|
|
refX="8"
|
|
refY="5"
|
|
markerWidth="5"
|
|
markerHeight="5"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
|
|
</marker>
|
|
</defs>
|
|
<Drawing />
|
|
</svg>
|
|
);
|
|
};
|