feat: section on web presentations, in German and English
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>
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user