feat: the automation chapter opens with a scroll film
The homepage read as sections to scroll past. The automation chapter now opens the way the social media films do: one continuous shot on a dark stage that stays in place while the visitor scrolls. Scrolling moves the camera (close on a dot hopping between two squares, pull back to a whole office carrying things from tool to tool, freeze, lines take over) and changes the statement; a second clock keeps the dots moving when the visitor stops. The statements are the approved ones from the film. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,12 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
|||||||
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit
|
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit
|
||||||
edge that sheds digits; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam`,
|
edge that sheds digits; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam`,
|
||||||
`BitGlow`. Reuse them instead of inventing new effects.
|
`BitGlow`. Reuse them instead of inventing new effects.
|
||||||
|
- Scroll films live in `apps/web/src/components/film/`: `ScrollFilm` is a dark stage that stays in place
|
||||||
|
while the visitor scrolls; scrolling is the timeline (camera, statements), a second clock keeps things
|
||||||
|
alive when the visitor stops. They are the website form of the films in `apps/video` (same pattern: close
|
||||||
|
on something abstract, pull back, resolve) and use their approved statements. `OrderFilm` opens the
|
||||||
|
automation chapter. Coordinates rendered on the first frame must be rounded (see `motion.ts`), or server
|
||||||
|
and browser disagree in the last digit.
|
||||||
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
||||||
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
||||||
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BitAura } from "@/src/components/bits/BitAura";
|
import { BitAura } from "@/src/components/bits/BitAura";
|
||||||
|
import { OrderFilm } from "@/src/components/film/OrderFilm";
|
||||||
import { Block } from "@/src/components/home/Block";
|
import { Block } from "@/src/components/home/Block";
|
||||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||||
import { Faq } from "@/src/components/home/Faq";
|
import { Faq } from "@/src/components/home/Faq";
|
||||||
@@ -114,6 +115,8 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
|
<OrderFilm />
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="03"
|
index="03"
|
||||||
label={automation.label}
|
label={automation.label}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useContent } from "@/src/components/ContentProvider";
|
||||||
|
import { ORDER_SHOT, OrderShot } from "./OrderShot";
|
||||||
|
import { ScrollFilm, type FilmClock } from "./ScrollFilm";
|
||||||
|
import { progress } from "./motion";
|
||||||
|
|
||||||
|
/** While which frames each of the five statements stands: hook, reveal, problem, turn, calm. */
|
||||||
|
const SLOTS = [
|
||||||
|
{ from: 0, to: ORDER_SHOT.reveal + 20 },
|
||||||
|
{ from: ORDER_SHOT.reveal + 30, to: ORDER_SHOT.wide + 30 },
|
||||||
|
{ from: ORDER_SHOT.wide + 30, to: ORDER_SHOT.freeze + 20 },
|
||||||
|
{ from: ORDER_SHOT.sink, to: ORDER_SHOT.flow + 20 },
|
||||||
|
{ from: ORDER_SHOT.flow + 20, to: ORDER_SHOT.end },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The walkers' own time: it runs with the clock and slows to a halt at the freeze. */
|
||||||
|
const Walking: React.FC<{ clock: FilmClock }> = ({ clock }) => {
|
||||||
|
const walk = React.useRef(0);
|
||||||
|
const seen = React.useRef(clock.live);
|
||||||
|
if (clock.live !== seen.current) {
|
||||||
|
seen.current = clock.live;
|
||||||
|
walk.current +=
|
||||||
|
clock.step *
|
||||||
|
(1 - progress(clock.shot, ORDER_SHOT.freeze, ORDER_SHOT.freeze + 30));
|
||||||
|
}
|
||||||
|
return <OrderShot clock={clock} walk={walk.current} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The opening of the automation chapter: from ping-pong to tools that talk to each other. */
|
||||||
|
export const OrderFilm: React.FC = () => {
|
||||||
|
const { film } = useContent().automation;
|
||||||
|
const captions = SLOTS.map((slot, index) => ({
|
||||||
|
...slot,
|
||||||
|
lines: film.statements[index] ?? [],
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ScrollFilm
|
||||||
|
frames={ORDER_SHOT.end}
|
||||||
|
captions={captions}
|
||||||
|
description={film.description}
|
||||||
|
stillFrame={ORDER_SHOT.flow + 60}
|
||||||
|
>
|
||||||
|
{(clock) => <Walking clock={clock} />}
|
||||||
|
</ScrollFilm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { FilmClock } from "./ScrollFilm";
|
||||||
|
import { hopAt, inOut, mix, out, progress, type Point } from "./motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The automation film as a scroll shot (twin of `OrderFilm` in apps/video).
|
||||||
|
* Close up, a dot hops between two squares: ping-pong. The camera pulls back:
|
||||||
|
* it is one of many, a whole office carrying things from tool to tool.
|
||||||
|
* Everything stops, the dots sink onto a line, and the lines carry the work.
|
||||||
|
*/
|
||||||
|
export const ORDER_SHOT = {
|
||||||
|
/** The camera starts to pull back. */
|
||||||
|
reveal: 75,
|
||||||
|
/** The whole picture is in view. */
|
||||||
|
wide: 195,
|
||||||
|
/** Everything slows to a halt. */
|
||||||
|
freeze: 310,
|
||||||
|
sink: 345,
|
||||||
|
lines: 365,
|
||||||
|
flow: 440,
|
||||||
|
end: 560,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const T = ORDER_SHOT;
|
||||||
|
const INK = "#f8fafc";
|
||||||
|
const PAPER = "#0f172a";
|
||||||
|
const MARK = "#fde68a";
|
||||||
|
const LIGHT = "129, 140, 248";
|
||||||
|
|
||||||
|
const COLS = 4;
|
||||||
|
const HERO_ROW = 2;
|
||||||
|
const PITCH = { x: 220, y: 190 } as const;
|
||||||
|
const TILE = 96;
|
||||||
|
const ZOOM_IN = 2.6;
|
||||||
|
const ZOOM_OUT = 0.8;
|
||||||
|
/** Height of a hop as a share of its length. */
|
||||||
|
const ARC = 0.22;
|
||||||
|
const CLOSE_HOP_FRAMES = 30;
|
||||||
|
const PULSE_EVERY = 80;
|
||||||
|
const PULSE_FRAMES = 55;
|
||||||
|
|
||||||
|
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
|
||||||
|
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
|
||||||
|
const stop = (row: number, col: number): Point => ({
|
||||||
|
x: colX(col),
|
||||||
|
y: rowY(row),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Each row has someone carrying things round its four tools, in their own order and pace. */
|
||||||
|
const WALKERS = [
|
||||||
|
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
|
||||||
|
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
|
||||||
|
{ route: [1, 2, 3, 0], hopFrames: 29, offset: 0 },
|
||||||
|
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
|
||||||
|
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const Dot: React.FC<{
|
||||||
|
at: (back: number) => Point;
|
||||||
|
radius: number;
|
||||||
|
zoom: number;
|
||||||
|
opacity: number;
|
||||||
|
}> = ({ at, radius, zoom, opacity }) => {
|
||||||
|
if (opacity <= 0) return null;
|
||||||
|
const point = at(0);
|
||||||
|
return (
|
||||||
|
<g opacity={Math.min(1, opacity)}>
|
||||||
|
{[1, 2, 3, 4, 5].map((k) => {
|
||||||
|
const ghost = at(k * 2.2);
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={k}
|
||||||
|
cx={ghost.x}
|
||||||
|
cy={ghost.y}
|
||||||
|
r={radius * (1 - k * 0.13)}
|
||||||
|
fill={MARK}
|
||||||
|
opacity={0.3 - k * 0.05}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={radius}
|
||||||
|
fill={MARK}
|
||||||
|
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${MARK})` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The picture at one moment. `walk` is the walkers' own time: it stops at the freeze. */
|
||||||
|
export const OrderShot: React.FC<{ clock: FilmClock; walk: number }> = ({
|
||||||
|
clock,
|
||||||
|
walk,
|
||||||
|
}) => {
|
||||||
|
const { shot, live } = clock;
|
||||||
|
const back = inOut(progress(shot, T.reveal, T.wide));
|
||||||
|
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
|
||||||
|
/** Everything outside the opening picture fades in as the camera pulls back. */
|
||||||
|
const others = progress(back, 0.05, 0.5);
|
||||||
|
const hair = 2.2 / zoom;
|
||||||
|
const sink = inOut(progress(shot, T.sink, T.sink + 30));
|
||||||
|
const radius = 12 / Math.sqrt(zoom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={`scale(${zoom})`}>
|
||||||
|
{WALKERS.map((walker, row) => {
|
||||||
|
const hero = row === HERO_ROW;
|
||||||
|
const y = rowY(row);
|
||||||
|
const away = Math.abs(row - HERO_ROW);
|
||||||
|
|
||||||
|
// The line that replaces the walking.
|
||||||
|
const lineStart = T.lines + away * 12;
|
||||||
|
const drawn = out(progress(shot, lineStart, lineStart + 45));
|
||||||
|
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
|
||||||
|
|
||||||
|
// Work travelling along the line by itself, in its own time.
|
||||||
|
const flowing = progress(
|
||||||
|
shot,
|
||||||
|
T.flow + away * 10,
|
||||||
|
T.flow + away * 10 + 10,
|
||||||
|
);
|
||||||
|
const pulseT = ((live + away * 23) % PULSE_EVERY) / PULSE_FRAMES;
|
||||||
|
const pulseX =
|
||||||
|
flowing > 0 && pulseT <= 1
|
||||||
|
? mix(colX(0), colX(COLS - 1), inOut(pulseT))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const shown =
|
||||||
|
(hero ? 1 : others) * (1 - progress(shot, lineStart, lineStart + 18));
|
||||||
|
const settle = (point: Point): Point => ({
|
||||||
|
x: point.x,
|
||||||
|
y: mix(point.y, y, sink),
|
||||||
|
});
|
||||||
|
const onRoute = (ago: number): Point =>
|
||||||
|
settle(
|
||||||
|
hopAt(
|
||||||
|
walk - ago + walker.offset,
|
||||||
|
walker.route.map((col) => stop(row, col)),
|
||||||
|
walker.hopFrames,
|
||||||
|
ARC,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// While the camera is close, the one in the middle plays ping-pong between two squares.
|
||||||
|
const pingPong = (ago: number): Point =>
|
||||||
|
settle(
|
||||||
|
hopAt(
|
||||||
|
walk - ago,
|
||||||
|
[stop(row, 1), stop(row, 2)],
|
||||||
|
CLOSE_HOP_FRAMES,
|
||||||
|
ARC * 1.4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const walkerNow = onRoute(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={row}>
|
||||||
|
{drawn > 0 && (
|
||||||
|
<path
|
||||||
|
d={`M${colX(0)} ${y}H${lineEnd}`}
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={hair * 1.3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={hero ? 1 : others}
|
||||||
|
style={{
|
||||||
|
filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${LIGHT}, 0.9))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: COLS }, (_unused, col) => {
|
||||||
|
const x = colX(col);
|
||||||
|
const opening = hero && (col === 1 || col === 2);
|
||||||
|
const touched =
|
||||||
|
shown > 0.5 &&
|
||||||
|
(!hero || others > 0.5) &&
|
||||||
|
Math.hypot(walkerNow.x - x, walkerNow.y - y) < TILE * 0.42;
|
||||||
|
const lit =
|
||||||
|
pulseX === null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, 1 - Math.abs(pulseX - x) / 110) * flowing;
|
||||||
|
return (
|
||||||
|
<g key={col} opacity={opening ? 1 : others}>
|
||||||
|
<rect
|
||||||
|
x={x - TILE / 2}
|
||||||
|
y={y - TILE / 2}
|
||||||
|
width={TILE}
|
||||||
|
height={TILE}
|
||||||
|
rx={TILE * 0.24}
|
||||||
|
fill={PAPER}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={x - TILE / 2}
|
||||||
|
y={y - TILE / 2}
|
||||||
|
width={TILE}
|
||||||
|
height={TILE}
|
||||||
|
rx={TILE * 0.24}
|
||||||
|
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
||||||
|
stroke={touched ? MARK : INK}
|
||||||
|
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
||||||
|
strokeWidth={touched ? hair * 1.6 : hair}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pulseX !== null && (
|
||||||
|
<circle
|
||||||
|
cx={pulseX}
|
||||||
|
cy={y}
|
||||||
|
r={radius * 0.8}
|
||||||
|
fill={MARK}
|
||||||
|
opacity={(hero ? 1 : others) * flowing}
|
||||||
|
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${MARK})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hero && (
|
||||||
|
<Dot
|
||||||
|
at={pingPong}
|
||||||
|
radius={radius}
|
||||||
|
zoom={zoom}
|
||||||
|
opacity={shown * (1 - others)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Dot
|
||||||
|
at={onRoute}
|
||||||
|
radius={radius}
|
||||||
|
zoom={zoom}
|
||||||
|
opacity={hero ? shown * others : shown}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { progress } from "./motion";
|
||||||
|
|
||||||
|
/** One statement of a film: its lines, and while which frames of the shot it stands. */
|
||||||
|
export type FilmCaption = {
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
readonly from: number;
|
||||||
|
readonly to: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Two clocks: where the shot is (set by scrolling) and time that simply passes. */
|
||||||
|
export type FilmClock = {
|
||||||
|
/** Frame of the shot. Scrolling moves it; it stands still when the visitor does. */
|
||||||
|
readonly shot: number;
|
||||||
|
/** Frames at 30 per second since the film came into view. Things that live keep moving on it. */
|
||||||
|
readonly live: number;
|
||||||
|
/** Frames since the last tick, for things that keep their own pace. */
|
||||||
|
readonly step: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScrollFilmProps {
|
||||||
|
/** The last frame of the shot, reached when the film has been scrolled through. */
|
||||||
|
frames: number;
|
||||||
|
captions: readonly FilmCaption[];
|
||||||
|
/** What the film shows, for screen readers. */
|
||||||
|
description: string;
|
||||||
|
/** The frame shown without motion: when reduced motion is requested. */
|
||||||
|
stillFrame: number;
|
||||||
|
/** The picture, as SVG content in a 1000 x 700 stage with (0, 0) in the centre. */
|
||||||
|
children: (clock: FilmClock) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE = { width: 1000, height: 700 } as const;
|
||||||
|
const FPS = 30;
|
||||||
|
const MAX_STEP_S = 0.1;
|
||||||
|
/** How quickly the shot follows the scroll position: lower is smoother. */
|
||||||
|
const FOLLOW = 0.14;
|
||||||
|
const IN_FRAMES = 14;
|
||||||
|
const OUT_FRAMES = 10;
|
||||||
|
const EDGE_FADE =
|
||||||
|
"linear-gradient(to bottom, transparent, #000 10%, #000 90%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollFilm: one continuous shot on a dark stage that stays in place while the
|
||||||
|
* visitor scrolls through it. Scrolling is the timeline: it moves the camera and
|
||||||
|
* changes the statement. It is the website form of the social media films
|
||||||
|
* (apps/video, `FilmStage`): one picture, one statement at a time.
|
||||||
|
*/
|
||||||
|
export const ScrollFilm: React.FC<ScrollFilmProps> = ({
|
||||||
|
frames,
|
||||||
|
captions,
|
||||||
|
description,
|
||||||
|
stillFrame,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const trackRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [clock, setClock] = React.useState<FilmClock>({
|
||||||
|
shot: 0,
|
||||||
|
live: 0,
|
||||||
|
step: 0,
|
||||||
|
});
|
||||||
|
const [still, setStill] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
setStill(true);
|
||||||
|
setClock({ shot: stillFrame, live: 0, step: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let last = 0;
|
||||||
|
let shot = 0;
|
||||||
|
let live = 0;
|
||||||
|
let visible = false;
|
||||||
|
|
||||||
|
const target = () => {
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const distance = rect.height - window.innerHeight;
|
||||||
|
return distance <= 0 ? 0 : progress(-rect.top, 0, distance) * frames;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
frame = window.requestAnimationFrame(tick);
|
||||||
|
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
const step = seconds * FPS;
|
||||||
|
live += step;
|
||||||
|
shot += (target() - shot) * FOLLOW;
|
||||||
|
setClock({ shot, live, step });
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
const next = entry?.isIntersecting ?? false;
|
||||||
|
if (next === visible) return;
|
||||||
|
visible = next;
|
||||||
|
if (visible) {
|
||||||
|
last = performance.now();
|
||||||
|
frame = window.requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(track);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [frames, stillFrame]);
|
||||||
|
|
||||||
|
const { shot } = clock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={`relative bg-slate-900 ${still ? "" : "h-[340svh] md:h-[420svh]"}`}
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 flex h-[100svh] flex-col items-center justify-center overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-70"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
|
||||||
|
backgroundSize: "20px 20px",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="bit-aurora opacity-60" aria-hidden="true" />
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={description}
|
||||||
|
className="relative mt-16 block max-h-[46svh] w-full max-w-[1100px] flex-1 md:mt-24 md:max-h-[50svh]"
|
||||||
|
style={{
|
||||||
|
maskImage: EDGE_FADE,
|
||||||
|
WebkitMaskImage: EDGE_FADE,
|
||||||
|
maskComposite: "intersect",
|
||||||
|
WebkitMaskComposite: "source-in",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children(clock)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* One statement at a time, in the type of the headlines. */}
|
||||||
|
<div
|
||||||
|
className="relative mt-4 h-[24svh] w-full px-5 text-center md:mt-8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{captions.map((caption, index) => {
|
||||||
|
const arrive =
|
||||||
|
index === 0
|
||||||
|
? 1
|
||||||
|
: progress(shot, caption.from, caption.from + IN_FRAMES);
|
||||||
|
const leave =
|
||||||
|
index === captions.length - 1
|
||||||
|
? 1
|
||||||
|
: 1 - progress(shot, caption.to - OUT_FRAMES, caption.to);
|
||||||
|
const opacity = still
|
||||||
|
? stillFrame >= caption.from && stillFrame < caption.to
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
: Math.min(arrive, leave);
|
||||||
|
if (opacity <= 0) return null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={caption.lines.join(" ")}
|
||||||
|
className="absolute inset-x-5 top-0 m-0 font-sans text-[9.5vw] font-black leading-[1.04] tracking-tighter text-white md:text-6xl lg:text-7xl"
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
transform: `translateY(${(1 - arrive) * 18}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caption.lines.map((line, i) => (
|
||||||
|
<span
|
||||||
|
key={line}
|
||||||
|
className={`block ${i > 0 && i === caption.lines.length - 1 ? "text-slate-400" : ""}`}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where the shot is. */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{captions.map((caption) => (
|
||||||
|
<span
|
||||||
|
key={caption.from}
|
||||||
|
className="h-0.5 w-8 overflow-hidden rounded-full bg-slate-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-white"
|
||||||
|
style={{
|
||||||
|
width: `${progress(shot, caption.from, caption.to) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The statements as plain text, for screen readers and without JavaScript. */}
|
||||||
|
<p className="sr-only">
|
||||||
|
{captions.map((caption) => caption.lines.join(" ")).join(" ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Motion helpers for the scroll films. All pure functions of a frame or a time,
|
||||||
|
* the same ones the social media films use, so both move alike.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Point = { readonly x: number; readonly y: number };
|
||||||
|
|
||||||
|
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
|
/** 0 before `from`, 1 after `to`, linear in between. */
|
||||||
|
export const progress = (frame: number, from: number, to: number): number =>
|
||||||
|
clamp01((frame - from) / (to - from));
|
||||||
|
|
||||||
|
export const mix = (from: number, to: number, t: number): number =>
|
||||||
|
from + (to - from) * t;
|
||||||
|
|
||||||
|
export const inOut = (t: number): number =>
|
||||||
|
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||||
|
|
||||||
|
export const out = (t: number): number => 1 - (1 - t) ** 3;
|
||||||
|
|
||||||
|
const easeInOut = (t: number): number =>
|
||||||
|
t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
const wrap = (index: number, length: number): number =>
|
||||||
|
((index % length) + length) % length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where something is that hops from stop to stop in an arc, round and round.
|
||||||
|
* It rests on `stops[0]` at time 0 and reaches the next stop every
|
||||||
|
* `framesPerHop`. The arc rises by `arcRatio` times the distance covered.
|
||||||
|
*/
|
||||||
|
export const hopAt = (
|
||||||
|
time: number,
|
||||||
|
stops: readonly Point[],
|
||||||
|
framesPerHop: number,
|
||||||
|
arcRatio: number,
|
||||||
|
): Point => {
|
||||||
|
const hop = Math.floor(time / framesPerHop);
|
||||||
|
const from = stops[wrap(hop, stops.length)];
|
||||||
|
const to = stops[wrap(hop + 1, stops.length)];
|
||||||
|
if (!from || !to) return { x: 0, y: 0 };
|
||||||
|
const t = easeInOut(time / framesPerHop - hop);
|
||||||
|
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||||
|
// Rounded: sine and square root differ in the last digit between the server
|
||||||
|
// and the browser, and the first frame is rendered by both.
|
||||||
|
return {
|
||||||
|
x: round(mix(from.x, to.x, t)),
|
||||||
|
y: round(
|
||||||
|
mix(from.y, to.y, t) - Math.sin(Math.PI * t) * distance * arcRatio,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -277,6 +277,17 @@ export const de: Content = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
automation: {
|
automation: {
|
||||||
|
film: {
|
||||||
|
description:
|
||||||
|
"Ein Punkt springt zwischen zwei Quadraten hin und her. Die Kamera fährt zurück: Es sind viele Reihen, in denen jemand Dinge von Programm zu Programm trägt. Dann verbinden Linien die Programme, und die Arbeit läuft von selbst.",
|
||||||
|
statements: [
|
||||||
|
["Sieht aus wie", "Pingpong."],
|
||||||
|
["Ist aber", "Ihr Büro."],
|
||||||
|
["Jede Bestellung.", "Von Hand."],
|
||||||
|
["Oder Ihre Tools", "reden miteinander."],
|
||||||
|
["Und Ihr Team", "hat Zeit."],
|
||||||
|
],
|
||||||
|
},
|
||||||
label: "Prozessautomatisierung",
|
label: "Prozessautomatisierung",
|
||||||
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
||||||
intro:
|
intro:
|
||||||
|
|||||||
@@ -273,6 +273,17 @@ export const en: Content = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
automation: {
|
automation: {
|
||||||
|
film: {
|
||||||
|
description:
|
||||||
|
"A dot hops back and forth between two squares. The camera pulls back: there are many rows in which someone carries things from tool to tool. Then lines connect the tools and the work runs by itself.",
|
||||||
|
statements: [
|
||||||
|
["Looks like", "ping-pong."],
|
||||||
|
["It's your", "office."],
|
||||||
|
["Every order.", "By hand."],
|
||||||
|
["Or your tools", "talk to each other."],
|
||||||
|
["And your team", "has time."],
|
||||||
|
],
|
||||||
|
},
|
||||||
label: "Process automation",
|
label: "Process automation",
|
||||||
title: { lead: "Let the systems", rest: "do the copying." },
|
title: { lead: "Let the systems", rest: "do the copying." },
|
||||||
intro:
|
intro:
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ export type Content = {
|
|||||||
readonly scenes: SceneLabels;
|
readonly scenes: SceneLabels;
|
||||||
};
|
};
|
||||||
readonly automation: {
|
readonly automation: {
|
||||||
|
/** The film that opens the chapter: what it shows, and its five statements in order. */
|
||||||
|
readonly film: {
|
||||||
|
readonly description: string;
|
||||||
|
readonly statements: readonly (readonly string[])[];
|
||||||
|
};
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly title: SplitTitle;
|
readonly title: SplitTitle;
|
||||||
readonly intro: string;
|
readonly intro: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user