01
Proyecto uno
Mock: qué problema resolvía y qué aporté.
Fig. 01 · AutorMiguel Gutiérrezgt2rz.dev
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
01 / Qué hago
Convierto ideas en productos digitales.
Diseño, construyo y lanzo.
De la primera línea al último detalle.
Potenciado con IA.
Haz scroll
import { lerp } from '../../core/math';
import { FLOOR_LEVEL, createMarbleTrack, restOn, type Point } from '../../shared/marble';
import { against, type Spring } from '../../shared/spring';
export { springCompression, springShape, type Spring } from '../../shared/spring';
// The hero's marble run. The marbles land on the real letters, so the route is built
// from the measured layout instead of fixed coordinates. Pure: give it boxes, get the
// pieces to draw and each marble's route. BEATS mirrors hero.guion.md.
/** A letter as the marble sees it: `top` is the cap height, `bottom` the baseline. */
export interface Box {
left: number;
right: number;
top: number;
bottom: number;
}
export interface HeroLayout {
width: number;
height: number;
radius: number;
/** First letter of the first line: where the marble lands. */
first: Box;
/** Second letter of the first line: it carries the launcher. */
launcher: Box;
/** Last letter of the first line: where the launcher throws it, and the grey marble waits. */
lineEnd: Box;
/** Last letter of the last line: where it bounces. */
last: Box;
}
export type Line = [from: Point, to: Point];
/** Where on the chapter's scroll (0–1) each thing happens. */
export const BEATS = {
zoomedOut: 0.26,
released: 0.3,
onFirstLetter: 0.4,
firstHop: 0.43,
entersLauncher: 0.455,
fired: 0.48,
onLineEnd: 0.53,
/** The marble reaches the grey one waiting there, and passes the run on to it. */
relay: 0.545,
offFirstLine: 0.56,
onLastLetter: 0.59,
hitsSpring: 0.63,
springLoaded: 0.648,
onFirstShelf: 0.7,
offFirstShelf: 0.745,
onSecondShelf: 0.775,
offSecondShelf: 0.82,
onFloor: 0.85,
hitsSideSpring: 0.875,
sideSpringLoaded: 0.89,
/** The grey marble, shot back, reaches the second yellow one and passes the run on again. */
secondRelay: 0.92,
};
/** The last strip has landed: from here they are one surface. */
export const STRIPS_DOWN = BEATS.hitsSpring + 0.12;
const point = (x: number, y: number): Point => ({ x, y });
const middle = (box: Box) => (box.left + box.right) / 2;
export function buildHeroRun(layout: HeroLayout) {
const { width, height, radius, first, launcher, lineEnd, last } = layout;
// Waits out of sight, above the screen, and drops in.
const landing = point(lerp(first.left, first.right, 0.45), first.top - radius);
const start = point(landing.x, -radius * 3);
const firstHop = point(lerp(landing.x, middle(launcher), 0.55), landing.y);
// The launcher: a pad on top of the second letter. The marble hops onto it and is
// thrown clear over the rest of the line, onto its last letter.
const onPad = point(middle(launcher), launcher.top - radius);
const pad: Line = [
point(lerp(launcher.left, launcher.right, 0.12), launcher.top),
point(lerp(launcher.left, launcher.right, 0.88), launcher.top),
];
// A grey marble waits on the far side of that letter. The thrown one lands short of
// it, rolls into it and stops; the grey one carries the run on from there.
const lineTop = lineEnd.top - radius;
const letterWidth = lineEnd.right - lineEnd.left;
const waiting = point(lineEnd.right - Math.max(radius, letterWidth * 0.28), lineTop);
const touching = point(waiting.x - radius * 2, lineTop);
const onLineEnd = point(
Math.min(touching.x - radius * 0.6, lineEnd.left + letterWidth * 0.25),
lineTop,
);
// High enough to read as a throw, low enough to stay on screen.
const throwRise = Math.min(height * 0.17, lineEnd.top - radius * 4);
// The angle it leaves the pad at: the slope of the arc at its start.
const throwAngle = Math.atan2(4 * throwRise, onLineEnd.x - onPad.x);
const rollOff = point(lineEnd.right + radius * 0.6, lineEnd.top - radius);
const bounce = point(Math.max(middle(last), rollOff.x + radius), last.top - radius);
// Past the last letter and below the baseline: a spring, leaning left. The marble
// arcs over the edge of the letter onto its plate, squeezes it, and is thrown back
// to the left, under the word. As far right as the screen allows.
const lean = 0.38;
const axis = point(-Math.sin(lean), -Math.cos(lean));
const springLength = radius * 4.2;
const plateAtRest = point(
Math.min(last.right + radius * 3.6, width - radius * 3),
last.bottom + radius * 2.4,
);
const spring: Spring = {
base: point(plateAtRest.x - axis.x * springLength, plateAtRest.y - axis.y * springLength),
axis,
length: springLength,
halfWidth: radius,
};
const loaded = against(spring, 1, radius);
// Under the word the run turns back: two shelves stepping down to the left, then
// the floor. Short hops and drops off an edge, so every flight is a visible curve.
const floorY = height * FLOOR_LEVEL;
const levelAt = (t: number) => lerp(plateAtRest.y, floorY, t);
// A slight slope to the left, enough to roll.
const tip = height * 0.009;
const shelves: Line[] = [
[point(width * 0.8, levelAt(0.3) - tip), point(width * 0.56, levelAt(0.3) + tip)],
[point(width * 0.54, levelAt(0.65) - tip), point(width * 0.34, levelAt(0.65) + tip)],
];
// Each shelf is the top side of a rectangle: this is how far the block hangs below it.
// Never so far that a marble on the floor could not roll under the lower shelf.
const headroom = floorY - radius * 2 - (levelAt(0.65) + tip);
const shelfDepth = Math.max(4, Math.min(radius * 1.3, headroom - 4));
// The floor is level where the marbles meet, then climbs to a rise and comes back
// down to leave the screen at floor height. Uphill a marble slows, and that is the
// point: it reaches the top only as the chapter finishes what it has to say.
const riseFoot = point(width * 0.42 + radius * 1.5, floorY);
const riseTop = point(width * 0.74, floorY - radius * 2.4);
const riseEnd = point(width * 0.95, floorY);
const floor: Point[] = [
point(width * 0.035, floorY),
riseFoot,
riseTop,
riseEnd,
point(width * 1.06, floorY),
];
const onFloor = (x: number) => point(x, floorY - radius);
// The throw from the spring: a real arc, as high as fits under the word.
const onFirstShelf = restOn(...shelves[0], 0.22, radius);
const ceiling = last.bottom + radius * 1.3;
const peakOf = (rise: number) => {
let peak = Infinity; for (let u = 0; u <= 1; u += 0.02) {
peak = Math.min(peak, lerp(loaded.y, onFirstShelf.y, u) - 4 * rise * u * (1 - u));
}
return peak;
};
let throwBack = radius * 5;
while (throwBack > radius && peakOf(throwBack) < ceiling) throwBack -= radius * 0.25;
// At the left end of the floor, a second spring lying on its side. It catches the
// marble coming down from the shelves and shoots it back along the floor.
const sideSpring: Spring = {
base: point(width * 0.035, floorY - radius),
axis: point(1, 0),
length: radius * 3.2,
halfWidth: radius * 0.8,
};
// A second yellow marble waits further along the floor. The grey one, shot back,
// rolls into it and stops; the yellow one leaves the screen on the right, at the
// height the next chapter's marble comes in.
const finisherWaits = onFloor(width * 0.42);
const landsOnFloor = onFloor(Math.max(width * 0.24, against(sideSpring, 0, radius).x + radius * 3));
// The protagonist: dropped, thrown, and at rest once it has passed the run on.
const track = createMarbleTrack(start, radius)
.hold(BEATS.released)
.fall(BEATS.onFirstLetter, landing)
// It does not stop dead: two hops to the right, the second onto the launcher.
.bounce(BEATS.firstHop, firstHop, radius * 1.5)
.bounce(BEATS.entersLauncher, onPad, radius * 0.7)
// Held while the pad winds up, then thrown.
.hold(BEATS.fired)
.bounce(BEATS.onLineEnd, onLineEnd, throwRise)
.roll(BEATS.relay, touching, 'out')
.hold(1)
.build();
// The grey marble: still until it is hit, then the middle of the run is its own.
const relay = createMarbleTrack(waiting, radius)
.hold(BEATS.relay)
.roll(BEATS.offFirstLine, rollOff, 'in')
.fall(BEATS.onLastLetter, bounce)
// One arc, up over the edge of the letter and down to the right of it: it keeps
// travelling sideways as it falls, as anything thrown does.
.bounce(BEATS.hitsSpring, against(spring, 0, radius), radius * 9.5)
// Its weight squeezes the spring, and the spring throws it back.
.roll(BEATS.springLoaded, loaded, 'out')
.bounce(BEATS.onFirstShelf, onFirstShelf, throwBack)
// Down the shelves, right to left: it rolls to an edge and drops off it.
.roll(BEATS.offFirstShelf, restOn(...shelves[0], 1.04, radius), 'in')
.fall(BEATS.onSecondShelf, restOn(...shelves[1], 0.3, radius))
.roll(BEATS.offSecondShelf, restOn(...shelves[1], 1.05, radius), 'in')
.fall(BEATS.onFloor, landsOnFloor)
// Into the side spring, which squeezes and shoots it back to the right.
.roll(BEATS.hitsSideSpring, against(sideSpring, 0, radius))
.roll(BEATS.sideSpringLoaded, against(sideSpring, 1, radius), 'out')
.roll(BEATS.secondRelay, point(finisherWaits.x - radius * 2, finisherWaits.y), 'out')
.hold(1)
.build();
// The second yellow marble: knocked on, it climbs the rise, losing speed all the
// way, and is at the top as the chapter ends.
const onRiseTop = restOn(riseFoot, riseTop, 1, radius);
const finisher = createMarbleTrack(finisherWaits, radius)
.hold(BEATS.secondRelay)
.roll(1, onRiseTop, 'out')
.build();
// Its way out, over the top and down the far side. On its own clock (0–1), because
// it happens after the chapter's choreography, while the chapter holds.
const finisherExit = createMarbleTrack(onRiseTop, radius)
.roll(0.1, restOn(riseTop, riseEnd, 0.05, radius))
.roll(0.82, onFloor(riseEnd.x), 'in')
.roll(1, onFloor(width * 1.06))
.build();
return {
pad,
throwAngle,
spring,
sideSpring,
shelves,
shelfDepth,
floor,
track,
relay,
finisher,
finisherExit,
};
}
export type HeroRun = ReturnType<typeof buildHeroRun>;
/**
* The full stop after the name becoming the marble. `leaving` runs 0–1 as the opening
* screen goes: first the dot grows to the marble's size, then it shoots up off the screen.
*/
export function introDot(leaving: number, radius: number, dotRadius: number, height: number) {
const grown = Math.min(1, Math.max(0, leaving / 0.45));
const risen = Math.min(1, Math.max(0, (leaving - 0.3) / 0.7));
return {
scale: lerp(dotRadius / radius, 1, 1 - (1 - grown) ** 2),
lift: risen * risen * (height + radius * 2),
rotation: leaving * Math.PI * 2,
};
}
/** How far the camera is into the headline: `maxScale` inside a letter, 1 once zoomed out. */
export function heroZoom(progress: number, maxScale: number): number {
const t = Math.min(1, Math.max(0, progress / BEATS.zoomedOut));
// Exponential, so the zoom-out feels like a steady pull instead of a rush at the start.
return maxScale ** (1 - t);
}
import { clamp, lerp } from '../core/math';
// The marble's route as a pure function of scroll progress. It looks like physics
// (accelerating falls, parabolic bounces, rolling) but it is authored, so the marble
// follows the scroll in both directions and every chapter can test its own route.
export interface Point {
x: number;
y: number;
}
export interface MarbleState extends Point {
/** Radians. Derived from horizontal travel, so it rolls back when scrolling up. */
rotation: number;
/** 1 at rest, lower for an instant around each impact. */
squash: number;
}
type RollEase = 'linear' | 'in' | 'out';
interface Segment {
kind: 'hold' | 'fall' | 'launch' | 'bounce' | 'roll';
start: number;
end: number;
from: Point;
to: Point;
rise: number;
ease: RollEase;
}
export interface MarbleTrack {
at(progress: number): MarbleState;
/** Progress of every landing, in order. Chapters hang their reactions on these. */
impacts: readonly number[];
}
// How long around an impact the marble stays flattened, in progress units.
const SQUASH_WINDOW = 0.008;const SQUASH_AMOUNT = 0.28;
const EASES: Record<RollEase, (u: number) => number> = {
linear: (u) => u,
in: (u) => u * u,
out: (u) => 1 - (1 - u) * (1 - u),
};
/** One size for the whole page, scaled to the screen. */
export const marbleRadius = (viewportWidth: number): number =>
clamp(viewportWidth * 0.0125, 10, 18);
/**
* The height of the floor the marble leaves one chapter on and enters the next on, as a
* fraction of the screen. Shared, so the hand-over between chapters lines up.
*/
export const FLOOR_LEVEL = 0.92;
/**
* The height a marble is at when it is thrown out of one chapter sideways and shows up
* in the next, as a fraction of the screen. Shared for the same reason as the floor.
*/
export const FLIGHT_LEVEL = 0.6;
/**
* What a marble is about to do: its route from `progress` to `lookahead` further on,
* as points to draw a dotted line through. Empty when it is not going anywhere, so
* nothing is drawn for a marble at rest.
*/
export function forecast(
track: MarbleTrack,
progress: number,
lookahead: number,
samples = 28,
): Point[] {
const points: Point[] = [];
let travelled = 0;
for (let i = 0; i <= samples; i++) {
const { x, y } = track.at(Math.min(1, progress + (lookahead * i) / samples));
const previous = points[points.length - 1];
if (previous) travelled += Math.hypot(x - previous.x, y - previous.y);
points.push({ x, y });
}
return travelled < 6 ? [] : points;
}
/** Centre of a marble resting on the line from → to, at fraction t along it. */
export function restOn(from: Point, to: Point, t: number, radius: number): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
// Of the two normals, the one pointing up (SVG y grows downwards).
const side = dx >= 0 ? 1 : -1;
return {
x: lerp(from.x, to.x, t) + ((side * dy) / length) * radius,
y: lerp(from.y, to.y, t) - ((side * dx) / length) * radius,
};
}
export function createMarbleTrack(origin: Point, radius: number) {
const segments: Segment[] = [];
const impacts: number[] = [];
let cursor = origin;
let time = 0;
const add = (segment: Omit<Segment, 'start' | 'from'>) => {
if (segment.end <= time) {
throw new Error(`Marble segment must end after ${time}, got ${segment.end}`);
}
segments.push({ ...segment, start: time, from: cursor });
cursor = segment.to;
time = segment.end;
return builder;
};
const builder = {
hold: (until: number) =>
add({ kind: 'hold', end: until, to: cursor, rise: 0, ease: 'linear' }),
/** Drops with gravity and lands on `to`. */
fall(until: number, to: Point) {
impacts.push(until);
return add({ kind: 'fall', end: until, to, rise: 0, ease: 'linear' });
},
/** Shot upwards: fast at first, slowing down until it peaks at `to`. */
launch: (until: number, to: Point) =>
add({ kind: 'launch', end: until, to, rise: 0, ease: 'linear' }),
/**
* Leaves the ground, peaks `rise` above the straight line, lands on `to`.
* With `lands` false it is only the first part of a longer flight.
*/
bounce(until: number, to: Point, rise: number, lands = true) {
if (lands) impacts.push(until);
return add({ kind: 'bounce', end: until, to, rise, ease: 'linear' });
},
/**
* Thrown hard: a straight line at a steady pace, ending in an impact. For what a
* spring fires across a short distance, where gravity has no time to bend the path.
*/
shot(until: number, to: Point) {
impacts.push(until);
return add({ kind: 'roll', end: until, to, rise: 0, ease: 'linear' });
},
/** Rolls in a straight line. `in` speeds up (down a ramp), `out` comes to a stop. */
roll: (until: number, to: Point, ease: RollEase = 'linear') =>
add({ kind: 'roll', end: until, to, rise: 0, ease }),
build: (): MarbleTrack => ({ at, impacts }),
};
function position(progress: number): Point {
const segment = segments.findLast((candidate) => candidate.start <= progress) ?? segments[0];
if (!segment) return origin;
const u = clamp((progress - segment.start) / (segment.end - segment.start));
const { from, to } = segment;
switch (segment.kind) {
case 'hold':
return from;
case 'fall':
return { x: lerp(from.x, to.x, u), y: lerp(from.y, to.y, u * u) };
case 'launch':
return { x: lerp(from.x, to.x, u), y: lerp(from.y, to.y, EASES.out(u)) };
case 'bounce':
return {
x: lerp(from.x, to.x, u),
y: lerp(from.y, to.y, u) - 4 * segment.rise * u * (1 - u),
};
case 'roll': {
const eased = EASES[segment.ease](u);
return { x: lerp(from.x, to.x, eased), y: lerp(from.y, to.y, eased) };
}
}
}
function at(progress: number): MarbleState {
const clamped = clamp(progress);
const { x, y } = position(clamped);
const nearest = Math.min(...impacts.map((impact) => Math.abs(clamped - impact)), Infinity);
return {
x,
y,
rotation: (x - origin.x) / radius,
squash: 1 - SQUASH_AMOUNT * clamp(1 - nearest / SQUASH_WINDOW),
};
}
return builder;
}
02 / Proyectos
Productos llevados de la idea a producción. Abre cualquiera para verlo por dentro.
01
Mock: qué problema resolvía y qué aporté.
02
Mock: qué problema resolvía y qué aporté.
03
Mock: qué problema resolvía y qué aporté.
04
Mock: qué problema resolvía y qué aporté.
03 / Cómo lo construyo
Paso 0 / 4
01
Entiendo el problema, a quién le duele y cómo sabremos que está resuelto.
02
Le doy forma a la solución y la pongo delante de usuarios antes de escribir código.
03
La desarrollo por entregas pequeñas, medibles y fáciles de cambiar.
04
La publico, mido qué pasa de verdad y sigo mejorándola.
04 / Contacto
Un asistente con IA recoge tu solicitud en tres preguntas y me la deja lista.
o escríbeme directamente →asistente.ia · demo
en esperaen líneaescribiendo
Hola, soy el asistente de Miguel. Recojo tu solicitud y se la dejo lista.
¿Qué quieres construir?
Respuestas simuladas · no se envía ni se guarda nada
import { lerp } from '../../core/math';
import { FLOOR_LEVEL, createMarbleTrack, restOn, type Point } from '../../shared/marble';
import { against, type Spring } from '../../shared/spring';
export { springCompression, springShape, type Spring } from '../../shared/spring';
// The hero's marble run. The marbles land on the real letters, so the route is built
// from the measured layout instead of fixed coordinates. Pure: give it boxes, get the
// pieces to draw and each marble's route. BEATS mirrors hero.guion.md.
/** A letter as the marble sees it: `top` is the cap height, `bottom` the baseline. */
export interface Box {
left: number;
right: number;
top: number;
bottom: number;
}
export interface HeroLayout {
width: number;
height: number;
radius: number;
/** First letter of the first line: where the marble lands. */
first: Box;
/** Second letter of the first line: it carries the launcher. */
launcher: Box;
/** Last letter of the first line: where the launcher throws it, and the grey marble waits. */
lineEnd: Box;
/** Last letter of the last line: where it bounces. */
last: Box;
}
export type Line = [from: Point, to: Point];
/** Where on the chapter's scroll (0–1) each thing happens. */
export const BEATS = {
zoomedOut: 0.26,
released: 0.3,
onFirstLetter: 0.4,
firstHop: 0.43,
entersLauncher: 0.455,
fired: 0.48,
onLineEnd: 0.53,
/** The marble reaches the grey one waiting there, and passes the run on to it. */
relay: 0.545,
offFirstLine: 0.56,
onLastLetter: 0.59,
hitsSpring: 0.63,
springLoaded: 0.648,
onFirstShelf: 0.7,
offFirstShelf: 0.745,
onSecondShelf: 0.775,
offSecondShelf: 0.82,
onFloor: 0.85,
hitsSideSpring: 0.875,
sideSpringLoaded: 0.89,
/** The grey marble, shot back, reaches the second yellow one and passes the run on again. */
secondRelay: 0.92,
};
/** The last strip has landed: from here they are one surface. */
export const STRIPS_DOWN = BEATS.hitsSpring + 0.12;
const point = (x: number, y: number): Point => ({ x, y });
const middle = (box: Box) => (box.left + box.right) / 2;
export function buildHeroRun(layout: HeroLayout) {
const { width, height, radius, first, launcher, lineEnd, last } = layout;
// Waits out of sight, above the screen, and drops in.
const landing = point(lerp(first.left, first.right, 0.45), first.top - radius);
const start = point(landing.x, -radius * 3);
const firstHop = point(lerp(landing.x, middle(launcher), 0.55), landing.y);
// The launcher: a pad on top of the second letter. The marble hops onto it and is
// thrown clear over the rest of the line, onto its last letter.
const onPad = point(middle(launcher), launcher.top - radius);
const pad: Line = [
point(lerp(launcher.left, launcher.right, 0.12), launcher.top),
point(lerp(launcher.left, launcher.right, 0.88), launcher.top),
];
// A grey marble waits on the far side of that letter. The thrown one lands short of
// it, rolls into it and stops; the grey one carries the run on from there.
const lineTop = lineEnd.top - radius;
const letterWidth = lineEnd.right - lineEnd.left;
const waiting = point(lineEnd.right - Math.max(radius, letterWidth * 0.28), lineTop);
const touching = point(waiting.x - radius * 2, lineTop);
const onLineEnd = point(
Math.min(touching.x - radius * 0.6, lineEnd.left + letterWidth * 0.25),
lineTop,
);
// High enough to read as a throw, low enough to stay on screen.
const throwRise = Math.min(height * 0.17, lineEnd.top - radius * 4);
// The angle it leaves the pad at: the slope of the arc at its start.
const throwAngle = Math.atan2(4 * throwRise, onLineEnd.x - onPad.x);
const rollOff = point(lineEnd.right + radius * 0.6, lineEnd.top - radius);
const bounce = point(Math.max(middle(last), rollOff.x + radius), last.top - radius);
// Past the last letter and below the baseline: a spring, leaning left. The marble
// arcs over the edge of the letter onto its plate, squeezes it, and is thrown back
// to the left, under the word. As far right as the screen allows.
const lean = 0.38;
const axis = point(-Math.sin(lean), -Math.cos(lean));
const springLength = radius * 4.2;
const plateAtRest = point(
Math.min(last.right + radius * 3.6, width - radius * 3),
last.bottom + radius * 2.4,
);
const spring: Spring = {
base: point(plateAtRest.x - axis.x * springLength, plateAtRest.y - axis.y * springLength),
axis,
length: springLength,
halfWidth: radius,
};
const loaded = against(spring, 1, radius);
// Under the word the run turns back: two shelves stepping down to the left, then
// the floor. Short hops and drops off an edge, so every flight is a visible curve.
const floorY = height * FLOOR_LEVEL;
const levelAt = (t: number) => lerp(plateAtRest.y, floorY, t);
// A slight slope to the left, enough to roll.
const tip = height * 0.009;
const shelves: Line[] = [
[point(width * 0.8, levelAt(0.3) - tip), point(width * 0.56, levelAt(0.3) + tip)],
[point(width * 0.54, levelAt(0.65) - tip), point(width * 0.34, levelAt(0.65) + tip)],
];
// Each shelf is the top side of a rectangle: this is how far the block hangs below it.
// Never so far that a marble on the floor could not roll under the lower shelf.
const headroom = floorY - radius * 2 - (levelAt(0.65) + tip);
const shelfDepth = Math.max(4, Math.min(radius * 1.3, headroom - 4));
// The floor is level where the marbles meet, then climbs to a rise and comes back
// down to leave the screen at floor height. Uphill a marble slows, and that is the
// point: it reaches the top only as the chapter finishes what it has to say.
const riseFoot = point(width * 0.42 + radius * 1.5, floorY);
const riseTop = point(width * 0.74, floorY - radius * 2.4);
const riseEnd = point(width * 0.95, floorY);
const floor: Point[] = [
point(width * 0.035, floorY),
riseFoot,
riseTop,
riseEnd,
point(width * 1.06, floorY),
];
const onFloor = (x: number) => point(x, floorY - radius);
// The throw from the spring: a real arc, as high as fits under the word.
const onFirstShelf = restOn(...shelves[0], 0.22, radius);
const ceiling = last.bottom + radius * 1.3;
const peakOf = (rise: number) => {
let peak = Infinity; for (let u = 0; u <= 1; u += 0.02) {
peak = Math.min(peak, lerp(loaded.y, onFirstShelf.y, u) - 4 * rise * u * (1 - u));
}
return peak;
};
let throwBack = radius * 5;
while (throwBack > radius && peakOf(throwBack) < ceiling) throwBack -= radius * 0.25;
// At the left end of the floor, a second spring lying on its side. It catches the
// marble coming down from the shelves and shoots it back along the floor.
const sideSpring: Spring = {
base: point(width * 0.035, floorY - radius),
axis: point(1, 0),
length: radius * 3.2,
halfWidth: radius * 0.8,
};
// A second yellow marble waits further along the floor. The grey one, shot back,
// rolls into it and stops; the yellow one leaves the screen on the right, at the
// height the next chapter's marble comes in.
const finisherWaits = onFloor(width * 0.42);
const landsOnFloor = onFloor(Math.max(width * 0.24, against(sideSpring, 0, radius).x + radius * 3));
// The protagonist: dropped, thrown, and at rest once it has passed the run on.
const track = createMarbleTrack(start, radius)
.hold(BEATS.released)
.fall(BEATS.onFirstLetter, landing)
// It does not stop dead: two hops to the right, the second onto the launcher.
.bounce(BEATS.firstHop, firstHop, radius * 1.5)
.bounce(BEATS.entersLauncher, onPad, radius * 0.7)
// Held while the pad winds up, then thrown.
.hold(BEATS.fired)
.bounce(BEATS.onLineEnd, onLineEnd, throwRise)
.roll(BEATS.relay, touching, 'out')
.hold(1)
.build();
// The grey marble: still until it is hit, then the middle of the run is its own.
const relay = createMarbleTrack(waiting, radius)
.hold(BEATS.relay)
.roll(BEATS.offFirstLine, rollOff, 'in')
.fall(BEATS.onLastLetter, bounce)
// One arc, up over the edge of the letter and down to the right of it: it keeps
// travelling sideways as it falls, as anything thrown does.
.bounce(BEATS.hitsSpring, against(spring, 0, radius), radius * 9.5)
// Its weight squeezes the spring, and the spring throws it back.
.roll(BEATS.springLoaded, loaded, 'out')
.bounce(BEATS.onFirstShelf, onFirstShelf, throwBack)
// Down the shelves, right to left: it rolls to an edge and drops off it.
.roll(BEATS.offFirstShelf, restOn(...shelves[0], 1.04, radius), 'in')
.fall(BEATS.onSecondShelf, restOn(...shelves[1], 0.3, radius))
.roll(BEATS.offSecondShelf, restOn(...shelves[1], 1.05, radius), 'in')
.fall(BEATS.onFloor, landsOnFloor)
// Into the side spring, which squeezes and shoots it back to the right.
.roll(BEATS.hitsSideSpring, against(sideSpring, 0, radius))
.roll(BEATS.sideSpringLoaded, against(sideSpring, 1, radius), 'out')
.roll(BEATS.secondRelay, point(finisherWaits.x - radius * 2, finisherWaits.y), 'out')
.hold(1)
.build();
// The second yellow marble: knocked on, it climbs the rise, losing speed all the
// way, and is at the top as the chapter ends.
const onRiseTop = restOn(riseFoot, riseTop, 1, radius);
const finisher = createMarbleTrack(finisherWaits, radius)
.hold(BEATS.secondRelay)
.roll(1, onRiseTop, 'out')
.build();
// Its way out, over the top and down the far side. On its own clock (0–1), because
// it happens after the chapter's choreography, while the chapter holds.
const finisherExit = createMarbleTrack(onRiseTop, radius)
.roll(0.1, restOn(riseTop, riseEnd, 0.05, radius))
.roll(0.82, onFloor(riseEnd.x), 'in')
.roll(1, onFloor(width * 1.06))
.build();
return {
pad,
throwAngle,
spring,
sideSpring,
shelves,
shelfDepth,
floor,
track,
relay,
finisher,
finisherExit,
};
}
export type HeroRun = ReturnType<typeof buildHeroRun>;
/**
* The full stop after the name becoming the marble. `leaving` runs 0–1 as the opening
* screen goes: first the dot grows to the marble's size, then it shoots up off the screen.
*/
export function introDot(leaving: number, radius: number, dotRadius: number, height: number) {
const grown = Math.min(1, Math.max(0, leaving / 0.45));
const risen = Math.min(1, Math.max(0, (leaving - 0.3) / 0.7));
return {
scale: lerp(dotRadius / radius, 1, 1 - (1 - grown) ** 2),
lift: risen * risen * (height + radius * 2),
rotation: leaving * Math.PI * 2,
};
}
/** How far the camera is into the headline: `maxScale` inside a letter, 1 once zoomed out. */
export function heroZoom(progress: number, maxScale: number): number {
const t = Math.min(1, Math.max(0, progress / BEATS.zoomedOut));
// Exponential, so the zoom-out feels like a steady pull instead of a rush at the start.
return maxScale ** (1 - t);
}
import { clamp, lerp } from '../core/math';
// The marble's route as a pure function of scroll progress. It looks like physics
// (accelerating falls, parabolic bounces, rolling) but it is authored, so the marble
// follows the scroll in both directions and every chapter can test its own route.
export interface Point {
x: number;
y: number;
}
export interface MarbleState extends Point {
/** Radians. Derived from horizontal travel, so it rolls back when scrolling up. */
rotation: number;
/** 1 at rest, lower for an instant around each impact. */
squash: number;
}
type RollEase = 'linear' | 'in' | 'out';
interface Segment {
kind: 'hold' | 'fall' | 'launch' | 'bounce' | 'roll';
start: number;
end: number;
from: Point;
to: Point;
rise: number;
ease: RollEase;
}
export interface MarbleTrack {
at(progress: number): MarbleState;
/** Progress of every landing, in order. Chapters hang their reactions on these. */
impacts: readonly number[];
}
// How long around an impact the marble stays flattened, in progress units.
const SQUASH_WINDOW = 0.008;const SQUASH_AMOUNT = 0.28;
const EASES: Record<RollEase, (u: number) => number> = {
linear: (u) => u,
in: (u) => u * u,
out: (u) => 1 - (1 - u) * (1 - u),
};
/** One size for the whole page, scaled to the screen. */
export const marbleRadius = (viewportWidth: number): number =>
clamp(viewportWidth * 0.0125, 10, 18);
/**
* The height of the floor the marble leaves one chapter on and enters the next on, as a
* fraction of the screen. Shared, so the hand-over between chapters lines up.
*/
export const FLOOR_LEVEL = 0.92;
/**
* The height a marble is at when it is thrown out of one chapter sideways and shows up
* in the next, as a fraction of the screen. Shared for the same reason as the floor.
*/
export const FLIGHT_LEVEL = 0.6;
/**
* What a marble is about to do: its route from `progress` to `lookahead` further on,
* as points to draw a dotted line through. Empty when it is not going anywhere, so
* nothing is drawn for a marble at rest.
*/
export function forecast(
track: MarbleTrack,
progress: number,
lookahead: number,
samples = 28,
): Point[] {
const points: Point[] = [];
let travelled = 0;
for (let i = 0; i <= samples; i++) {
const { x, y } = track.at(Math.min(1, progress + (lookahead * i) / samples));
const previous = points[points.length - 1];
if (previous) travelled += Math.hypot(x - previous.x, y - previous.y);
points.push({ x, y });
}
return travelled < 6 ? [] : points;
}
/** Centre of a marble resting on the line from → to, at fraction t along it. */
export function restOn(from: Point, to: Point, t: number, radius: number): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
// Of the two normals, the one pointing up (SVG y grows downwards).
const side = dx >= 0 ? 1 : -1;
return {
x: lerp(from.x, to.x, t) + ((side * dy) / length) * radius,
y: lerp(from.y, to.y, t) - ((side * dx) / length) * radius,
};
}
export function createMarbleTrack(origin: Point, radius: number) {
const segments: Segment[] = [];
const impacts: number[] = [];
let cursor = origin;
let time = 0;
const add = (segment: Omit<Segment, 'start' | 'from'>) => {
if (segment.end <= time) {
throw new Error(`Marble segment must end after ${time}, got ${segment.end}`);
}
segments.push({ ...segment, start: time, from: cursor });
cursor = segment.to;
time = segment.end;
return builder;
};
const builder = {
hold: (until: number) =>
add({ kind: 'hold', end: until, to: cursor, rise: 0, ease: 'linear' }),
/** Drops with gravity and lands on `to`. */
fall(until: number, to: Point) {
impacts.push(until);
return add({ kind: 'fall', end: until, to, rise: 0, ease: 'linear' });
},
/** Shot upwards: fast at first, slowing down until it peaks at `to`. */
launch: (until: number, to: Point) =>
add({ kind: 'launch', end: until, to, rise: 0, ease: 'linear' }),
/**
* Leaves the ground, peaks `rise` above the straight line, lands on `to`.
* With `lands` false it is only the first part of a longer flight.
*/
bounce(until: number, to: Point, rise: number, lands = true) {
if (lands) impacts.push(until);
return add({ kind: 'bounce', end: until, to, rise, ease: 'linear' });
},
/**
* Thrown hard: a straight line at a steady pace, ending in an impact. For what a
* spring fires across a short distance, where gravity has no time to bend the path.
*/
shot(until: number, to: Point) {
impacts.push(until);
return add({ kind: 'roll', end: until, to, rise: 0, ease: 'linear' });
},
/** Rolls in a straight line. `in` speeds up (down a ramp), `out` comes to a stop. */
roll: (until: number, to: Point, ease: RollEase = 'linear') =>
add({ kind: 'roll', end: until, to, rise: 0, ease }),
build: (): MarbleTrack => ({ at, impacts }),
};
function position(progress: number): Point {
const segment = segments.findLast((candidate) => candidate.start <= progress) ?? segments[0];
if (!segment) return origin;
const u = clamp((progress - segment.start) / (segment.end - segment.start));
const { from, to } = segment;
switch (segment.kind) {
case 'hold':
return from;
case 'fall':
return { x: lerp(from.x, to.x, u), y: lerp(from.y, to.y, u * u) };
case 'launch':
return { x: lerp(from.x, to.x, u), y: lerp(from.y, to.y, EASES.out(u)) };
case 'bounce':
return {
x: lerp(from.x, to.x, u),
y: lerp(from.y, to.y, u) - 4 * segment.rise * u * (1 - u),
};
case 'roll': {
const eased = EASES[segment.ease](u);
return { x: lerp(from.x, to.x, eased), y: lerp(from.y, to.y, eased) };
}
}
}
function at(progress: number): MarbleState {
const clamped = clamp(progress);
const { x, y } = position(clamped);
const nearest = Math.min(...impacts.map((impact) => Math.abs(clamped - impact)), Infinity);
return {
x,
y,
rotation: (x - origin.x) / radius,
squash: 1 - SQUASH_AMOUNT * clamp(1 - nearest / SQUASH_WINDOW),
};
}
return builder;
}
Miguel Gutiérrez
01 / Inicio02 / Proyectos03 / Proceso04 / Contacto
Portfolio · 2026 ·