01
Warehouse3D SCADA
- Beta
- 3D
- Tiempo real
- Web
SCADA de bodegas: un editor 2D para diseñar el layout y un visor 3D que lo muestra con inventario y telemetría de vehículos en vivo.
Fig. 01 · Storytelling by scrollMiguel Gutiérrezgt2rz.dev
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
ProductDeveloper
import { clamp, 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];
/** A beam on a pivot at its centre: it tips to whichever side carries the weight. */
export interface Seesaw {
pivot: Point;
/** From the pivot to either end. */
half: number;
depth: number;
/** Radians. How far it tips either way. It waits with its right end down. */
tilt: number;
}
/** 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,
onRamp: 0.698,
/** It has bounced itself out on the ramp, and rolls. */
rollsDownRamp: 0.72,
offRamp: 0.74,
onSeesaw: 0.757,
offSeesaw: 0.809,
onFloor: 0.829,
rollsOnFloor: 0.859,
hitsSideSpring: 0.876,
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;
// The time on the seesaw, as fractions of it: the marble lands, hops once to the right,
// and rolls on that way until the beam comes level under it. From there it is downhill
// to the left.
const SEESAW_HOP = 0.22;
const SEESAW_LEVEL = 0.48;
const point = (x: number, y: number): Point => ({ x, y });
/**
* How a seesaw that tips `tilt` either way is turned, in radians: positive with its
* right end down, as it waits, and negative once the marble's weight has brought the
* left end down.
*/
export function seesawAngle(progress: number, tilt: number): number {
const u = (progress - BEATS.onSeesaw) / (BEATS.offSeesaw - BEATS.onSeesaw);
return tilt * Math.cos(Math.PI * clamp(u / (SEESAW_LEVEL * 2)));
}
/**
* A point carried by a seesaw turned to `angle`: `along` its beam from the pivot
* (negative to the left) and `above` its middle line.
*/
export function onSeesaw(seesaw: Seesaw, angle: number, along: number, above: number): Point {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return point(
seesaw.pivot.x + along * cos + above * sin,
seesaw.pivot.y + along * sin - above * cos,
);
}
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 and steps down to the left: a ramp, a seesaw, the
// floor. Short hops and drops off an edge, so every flight is a visible curve, and
// every landing bounces before the marble rolls on.
const floorY = height * FLOOR_LEVEL;
const levelAt = (t: number) => lerp(plateAtRest.y, floorY, t);
// The ramp is a right triangle: the marble comes down its long side, the right angle
// is under its high end. Never so steep that it stops reading as something to roll on.
const rampWidth = width * 0.26;
const rampTip = point(width * 0.58, levelAt(0.34));
const rampDrop = Math.min(levelAt(0.24) - levelAt(0), rampWidth * 0.2);
const ramp: Line = [point(rampTip.x + rampWidth, rampTip.y - rampDrop), rampTip];
const rampCorner = point(ramp[0].x, rampTip.y);
// The seesaw: a beam on a pivot, waiting with its right end down. The marble comes
// down on its left half, so it sets off downhill, to the right, but its own weight is
// on the left of the pivot: the beam tips over under it and it ends up going left.
const half = Math.max(width * 0.095, radius * 4);
const depth = clamp(radius * 0.6, 8, 12);
const landsAt = -0.72 * half;
const seesaw: Seesaw = {
pivot: point(rampTip.x - width * 0.07 - landsAt, levelAt(0.64) + depth / 2),
half,
depth,
// The shorter the beam, the more it has to turn for its ends to be seen moving.
tilt: Math.asin(Math.min(0.2, (radius * 0.85) / half)),
};
/** The marble on the beam, `u` of the way through its time there. */
const riding = (along: number, u: number) =>
onSeesaw(
seesaw,
seesawAngle(lerp(BEATS.onSeesaw, BEATS.offSeesaw, u), seesaw.tilt),
along,
depth / 2 + radius,
);
const hopsTo = -0.38 * half;
const leavesAt = -(half + radius * 0.35);
// Thrown uphill, in effect: it slows, stops as the beam comes level, and runs back.
const turns = (SEESAW_LEVEL - SEESAW_HOP) / (1 - SEESAW_HOP);
const pull = (hopsTo - leavesAt) / (1 - 2 * turns); const rolling = (v: number) =>
riding(hopsTo + pull * (2 * turns * v - v * v), lerp(SEESAW_HOP, 1, v));
// 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.
// The marble that waits on it is clear of where the grey one comes down.
const waitsAt = width * 0.52;
const riseFoot = point(waitsAt + radius * 1.5, floorY);
const riseTop = point(width * 0.76, 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.
// Near its high end, to keep the hop short: a long flight would look like a line.
const onRamp = restOn(...ramp, 0.14, 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, onRamp.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 seesaw 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(waitsAt);
const landsOnFloor = onFloor(riding(leavesAt, 1).x - width * 0.1);
const hitsSideSpring = against(sideSpring, 0, radius);
// 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.onRamp, onRamp, throwBack)
// It bounces itself out down the slope, then rolls to the tip and drops off it.
.settle(BEATS.rollsDownRamp, restOn(...ramp, 0.62, radius), radius * 1.4)
// At the pace the bounces left it with: no stop in between.
.roll(BEATS.offRamp, restOn(...ramp, 1.04, radius))
// Onto the left half of the seesaw: one hop to the right, the way the beam
// slopes, and then the beam turns under it and sends it back off the left end.
.fall(BEATS.onSeesaw, riding(landsAt, 0))
.bounce(
lerp(BEATS.onSeesaw, BEATS.offSeesaw, SEESAW_HOP),
riding(hopsTo, SEESAW_HOP),
radius * 1.2,
)
.along(BEATS.offSeesaw, rolling)
.fall(BEATS.onFloor, landsOnFloor)
// It comes off the beam fast, so its bounces carry it a long way along the floor.
.settle(
BEATS.rollsOnFloor,
onFloor(lerp(landsOnFloor.x, hitsSideSpring.x, 0.62)),
radius * 1.3,
3,
)
// Into the side spring, which squeezes and shoots it back to the right.
.roll(BEATS.hitsSideSpring, hitsSideSpring)
.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,
ramp,
rampCorner,
seesaw,
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' | 'along';
start: number;
end: number;
from: Point;
to: Point;
rise: number;
ease: RollEase;
/** For `along`: where it is at each fraction of the segment. */
path?: (u: number) => Point;
}
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;// How much of its speed a marble keeps when it hits the ground. Each bounce after a
// landing lasts this much of the one before, and rises the square of it.
const RESTITUTION = 0.55;
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[] = [];
// Every landing with how hard it was: a light one flattens the marble less, for less time.
const hits: Array<{ at: number; strength: number }> = [];
const land = (at: number, strength = 1) => {
impacts.push(at);
hits.push({ at, strength });
};
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) {
land(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) land(until);
return add({ kind: 'bounce', end: until, to, rise, ease: 'linear' });
},
/**
* What a landing is followed by: a few bounces on towards `to`, each lower and
* shorter than the one before, as the marble loses its energy to the ground. The
* first peaks `rise` above the straight line.
*/
settle(until: number, to: Point, rise: number, hops = 2) {
const from = cursor;
const start = time;
const weights = Array.from({ length: hops }, (_, hop) => RESTITUTION ** hop);
const total = weights.reduce((sum, weight) => sum + weight, 0);
let done = 0;
weights.forEach((weight, hop) => {
done = hop === hops - 1 ? 1 : done + weight / total;
const end = lerp(start, until, done);
land(end, weight);
add({
kind: 'bounce',
end,
to: { x: lerp(from.x, to.x, done), y: lerp(from.y, to.y, done) },
rise: rise * weight * weight,
ease: 'linear',
});
});
return builder;
},
/**
* 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) {
land(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 }),
/**
* Carried by something that moves under it: `path` says where it is at each
* fraction of the segment, and has to start where the marble already is.
*/
along: (until: number, path: (u: number) => Point) =>
add({ kind: 'along', end: until, to: path(1), rise: 0, ease: 'linear', path }),
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) };
}
case 'along':
return segment.path?.(u) ?? to;
}
}
function at(progress: number): MarbleState {
const clamped = clamp(progress);
const { x, y } = position(clamped);
const flattened = Math.max(
0,
...hits.map(
({ at, strength }) =>
strength * clamp(1 - Math.abs(clamped - at) / (SQUASH_WINDOW * strength)),
),
);
return {
x,
y,
rotation: (x - origin.x) / radius,
squash: 1 - SQUASH_AMOUNT * flattened,
};
}
return builder;
}
01 / Qué hago
Conviertir ideas en productos digitales.
Diseñar, construir, desplegar e iterar.
Cada detalle importa.
Potenciado con IA.
Haz scroll
import { clamp, 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];
/** A beam on a pivot at its centre: it tips to whichever side carries the weight. */
export interface Seesaw {
pivot: Point;
/** From the pivot to either end. */
half: number;
depth: number;
/** Radians. How far it tips either way. It waits with its right end down. */
tilt: number;
}
/** 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,
onRamp: 0.698,
/** It has bounced itself out on the ramp, and rolls. */
rollsDownRamp: 0.72,
offRamp: 0.74,
onSeesaw: 0.757,
offSeesaw: 0.809,
onFloor: 0.829,
rollsOnFloor: 0.859,
hitsSideSpring: 0.876,
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;
// The time on the seesaw, as fractions of it: the marble lands, hops once to the right,
// and rolls on that way until the beam comes level under it. From there it is downhill
// to the left.
const SEESAW_HOP = 0.22;
const SEESAW_LEVEL = 0.48;
const point = (x: number, y: number): Point => ({ x, y });
/**
* How a seesaw that tips `tilt` either way is turned, in radians: positive with its
* right end down, as it waits, and negative once the marble's weight has brought the
* left end down.
*/
export function seesawAngle(progress: number, tilt: number): number {
const u = (progress - BEATS.onSeesaw) / (BEATS.offSeesaw - BEATS.onSeesaw);
return tilt * Math.cos(Math.PI * clamp(u / (SEESAW_LEVEL * 2)));
}
/**
* A point carried by a seesaw turned to `angle`: `along` its beam from the pivot
* (negative to the left) and `above` its middle line.
*/
export function onSeesaw(seesaw: Seesaw, angle: number, along: number, above: number): Point {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return point(
seesaw.pivot.x + along * cos + above * sin,
seesaw.pivot.y + along * sin - above * cos,
);
}
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 and steps down to the left: a ramp, a seesaw, the
// floor. Short hops and drops off an edge, so every flight is a visible curve, and
// every landing bounces before the marble rolls on.
const floorY = height * FLOOR_LEVEL;
const levelAt = (t: number) => lerp(plateAtRest.y, floorY, t);
// The ramp is a right triangle: the marble comes down its long side, the right angle
// is under its high end. Never so steep that it stops reading as something to roll on.
const rampWidth = width * 0.26;
const rampTip = point(width * 0.58, levelAt(0.34));
const rampDrop = Math.min(levelAt(0.24) - levelAt(0), rampWidth * 0.2);
const ramp: Line = [point(rampTip.x + rampWidth, rampTip.y - rampDrop), rampTip];
const rampCorner = point(ramp[0].x, rampTip.y);
// The seesaw: a beam on a pivot, waiting with its right end down. The marble comes
// down on its left half, so it sets off downhill, to the right, but its own weight is
// on the left of the pivot: the beam tips over under it and it ends up going left.
const half = Math.max(width * 0.095, radius * 4);
const depth = clamp(radius * 0.6, 8, 12);
const landsAt = -0.72 * half;
const seesaw: Seesaw = {
pivot: point(rampTip.x - width * 0.07 - landsAt, levelAt(0.64) + depth / 2),
half,
depth,
// The shorter the beam, the more it has to turn for its ends to be seen moving.
tilt: Math.asin(Math.min(0.2, (radius * 0.85) / half)),
};
/** The marble on the beam, `u` of the way through its time there. */
const riding = (along: number, u: number) =>
onSeesaw(
seesaw,
seesawAngle(lerp(BEATS.onSeesaw, BEATS.offSeesaw, u), seesaw.tilt),
along,
depth / 2 + radius,
);
const hopsTo = -0.38 * half;
const leavesAt = -(half + radius * 0.35);
// Thrown uphill, in effect: it slows, stops as the beam comes level, and runs back.
const turns = (SEESAW_LEVEL - SEESAW_HOP) / (1 - SEESAW_HOP);
const pull = (hopsTo - leavesAt) / (1 - 2 * turns); const rolling = (v: number) =>
riding(hopsTo + pull * (2 * turns * v - v * v), lerp(SEESAW_HOP, 1, v));
// 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.
// The marble that waits on it is clear of where the grey one comes down.
const waitsAt = width * 0.52;
const riseFoot = point(waitsAt + radius * 1.5, floorY);
const riseTop = point(width * 0.76, 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.
// Near its high end, to keep the hop short: a long flight would look like a line.
const onRamp = restOn(...ramp, 0.14, 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, onRamp.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 seesaw 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(waitsAt);
const landsOnFloor = onFloor(riding(leavesAt, 1).x - width * 0.1);
const hitsSideSpring = against(sideSpring, 0, radius);
// 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.onRamp, onRamp, throwBack)
// It bounces itself out down the slope, then rolls to the tip and drops off it.
.settle(BEATS.rollsDownRamp, restOn(...ramp, 0.62, radius), radius * 1.4)
// At the pace the bounces left it with: no stop in between.
.roll(BEATS.offRamp, restOn(...ramp, 1.04, radius))
// Onto the left half of the seesaw: one hop to the right, the way the beam
// slopes, and then the beam turns under it and sends it back off the left end.
.fall(BEATS.onSeesaw, riding(landsAt, 0))
.bounce(
lerp(BEATS.onSeesaw, BEATS.offSeesaw, SEESAW_HOP),
riding(hopsTo, SEESAW_HOP),
radius * 1.2,
)
.along(BEATS.offSeesaw, rolling)
.fall(BEATS.onFloor, landsOnFloor)
// It comes off the beam fast, so its bounces carry it a long way along the floor.
.settle(
BEATS.rollsOnFloor,
onFloor(lerp(landsOnFloor.x, hitsSideSpring.x, 0.62)),
radius * 1.3,
3,
)
// Into the side spring, which squeezes and shoots it back to the right.
.roll(BEATS.hitsSideSpring, hitsSideSpring)
.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,
ramp,
rampCorner,
seesaw,
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' | 'along';
start: number;
end: number;
from: Point;
to: Point;
rise: number;
ease: RollEase;
/** For `along`: where it is at each fraction of the segment. */
path?: (u: number) => Point;
}
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;// How much of its speed a marble keeps when it hits the ground. Each bounce after a
// landing lasts this much of the one before, and rises the square of it.
const RESTITUTION = 0.55;
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[] = [];
// Every landing with how hard it was: a light one flattens the marble less, for less time.
const hits: Array<{ at: number; strength: number }> = [];
const land = (at: number, strength = 1) => {
impacts.push(at);
hits.push({ at, strength });
};
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) {
land(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) land(until);
return add({ kind: 'bounce', end: until, to, rise, ease: 'linear' });
},
/**
* What a landing is followed by: a few bounces on towards `to`, each lower and
* shorter than the one before, as the marble loses its energy to the ground. The
* first peaks `rise` above the straight line.
*/
settle(until: number, to: Point, rise: number, hops = 2) {
const from = cursor;
const start = time;
const weights = Array.from({ length: hops }, (_, hop) => RESTITUTION ** hop);
const total = weights.reduce((sum, weight) => sum + weight, 0);
let done = 0;
weights.forEach((weight, hop) => {
done = hop === hops - 1 ? 1 : done + weight / total;
const end = lerp(start, until, done);
land(end, weight);
add({
kind: 'bounce',
end,
to: { x: lerp(from.x, to.x, done), y: lerp(from.y, to.y, done) },
rise: rise * weight * weight,
ease: 'linear',
});
});
return builder;
},
/**
* 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) {
land(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 }),
/**
* Carried by something that moves under it: `path` says where it is at each
* fraction of the segment, and has to start where the marble already is.
*/
along: (until: number, path: (u: number) => Point) =>
add({ kind: 'along', end: until, to: path(1), rise: 0, ease: 'linear', path }),
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) };
}
case 'along':
return segment.path?.(u) ?? to;
}
}
function at(progress: number): MarbleState {
const clamped = clamp(progress);
const { x, y } = position(clamped);
const flattened = Math.max(
0,
...hits.map(
({ at, strength }) =>
strength * clamp(1 - Math.abs(clamped - at) / (SQUASH_WINDOW * strength)),
),
);
return {
x,
y,
rotation: (x - origin.x) / radius,
squash: 1 - SQUASH_AMOUNT * flattened,
};
}
return builder;
}
Proyectos
02 / Proyectos
Productos llevados de la idea a producción. Abre cualquiera para verlo por dentro.

01
SCADA de bodegas: un editor 2D para diseñar el layout y un visor 3D que lo muestra con inventario y telemetría de vehículos en vivo.

02
Editor visual de plantillas de email para equipos: armas el correo con bloques, declaras variables con valores de ejemplo y lo exportas en HTML con los estilos en línea, listo para Gmail, Outlook y Apple Mail, con vista previa aislada, envío de prueba e historial de versiones.

03
Consola logística de un e-commerce: dibujas la zona de cada bodega y se indexa en celdas hexagonales H3, y el checkout resuelve al instante qué bodega atiende una dirección y cuánto cuesta el envío.
+4 en el archivo
Filtra por etiqueta o busca por nombre.
Ver los 7 proyectosHow to
03 / Cómo lo construyo
Un método que se repite: en cada paso sabes qué recibes.
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.
Contact
04 / Contacto
Un asistente con IA recoge lo esencial de tu proyecto y me lo deja listo.
01Me lo cuentas
Unas pocas preguntas.
02Lo reviso
Leo tu solicitud y te escribo.
03Lo hablamos
Plazos y precio, juntos.
asistente.ia
en esperaprontoescribiendo
Hola, soy el asistente de Miguel. Todavía no estoy disponible: pronto podrás contarme tu proyecto aquí.
Mientras tanto, escríbele por el formulario.
Asistente en preparación · disponible pronto
import { clamp, 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];
/** A beam on a pivot at its centre: it tips to whichever side carries the weight. */
export interface Seesaw {
pivot: Point;
/** From the pivot to either end. */
half: number;
depth: number;
/** Radians. How far it tips either way. It waits with its right end down. */
tilt: number;
}
/** 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,
onRamp: 0.698,
/** It has bounced itself out on the ramp, and rolls. */
rollsDownRamp: 0.72,
offRamp: 0.74,
onSeesaw: 0.757,
offSeesaw: 0.809,
onFloor: 0.829,
rollsOnFloor: 0.859,
hitsSideSpring: 0.876,
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;
// The time on the seesaw, as fractions of it: the marble lands, hops once to the right,
// and rolls on that way until the beam comes level under it. From there it is downhill
// to the left.
const SEESAW_HOP = 0.22;
const SEESAW_LEVEL = 0.48;
const point = (x: number, y: number): Point => ({ x, y });
/**
* How a seesaw that tips `tilt` either way is turned, in radians: positive with its
* right end down, as it waits, and negative once the marble's weight has brought the
* left end down.
*/
export function seesawAngle(progress: number, tilt: number): number {
const u = (progress - BEATS.onSeesaw) / (BEATS.offSeesaw - BEATS.onSeesaw);
return tilt * Math.cos(Math.PI * clamp(u / (SEESAW_LEVEL * 2)));
}
/**
* A point carried by a seesaw turned to `angle`: `along` its beam from the pivot
* (negative to the left) and `above` its middle line.
*/
export function onSeesaw(seesaw: Seesaw, angle: number, along: number, above: number): Point {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return point(
seesaw.pivot.x + along * cos + above * sin,
seesaw.pivot.y + along * sin - above * cos,
);
}
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 and steps down to the left: a ramp, a seesaw, the
// floor. Short hops and drops off an edge, so every flight is a visible curve, and
// every landing bounces before the marble rolls on.
const floorY = height * FLOOR_LEVEL;
const levelAt = (t: number) => lerp(plateAtRest.y, floorY, t);
// The ramp is a right triangle: the marble comes down its long side, the right angle
// is under its high end. Never so steep that it stops reading as something to roll on.
const rampWidth = width * 0.26;
const rampTip = point(width * 0.58, levelAt(0.34));
const rampDrop = Math.min(levelAt(0.24) - levelAt(0), rampWidth * 0.2);
const ramp: Line = [point(rampTip.x + rampWidth, rampTip.y - rampDrop), rampTip];
const rampCorner = point(ramp[0].x, rampTip.y);
// The seesaw: a beam on a pivot, waiting with its right end down. The marble comes
// down on its left half, so it sets off downhill, to the right, but its own weight is
// on the left of the pivot: the beam tips over under it and it ends up going left.
const half = Math.max(width * 0.095, radius * 4);
const depth = clamp(radius * 0.6, 8, 12);
const landsAt = -0.72 * half;
const seesaw: Seesaw = {
pivot: point(rampTip.x - width * 0.07 - landsAt, levelAt(0.64) + depth / 2),
half,
depth,
// The shorter the beam, the more it has to turn for its ends to be seen moving.
tilt: Math.asin(Math.min(0.2, (radius * 0.85) / half)),
};
/** The marble on the beam, `u` of the way through its time there. */
const riding = (along: number, u: number) =>
onSeesaw(
seesaw,
seesawAngle(lerp(BEATS.onSeesaw, BEATS.offSeesaw, u), seesaw.tilt),
along,
depth / 2 + radius,
);
const hopsTo = -0.38 * half;
const leavesAt = -(half + radius * 0.35);
// Thrown uphill, in effect: it slows, stops as the beam comes level, and runs back.
const turns = (SEESAW_LEVEL - SEESAW_HOP) / (1 - SEESAW_HOP);
const pull = (hopsTo - leavesAt) / (1 - 2 * turns); const rolling = (v: number) =>
riding(hopsTo + pull * (2 * turns * v - v * v), lerp(SEESAW_HOP, 1, v));
// 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.
// The marble that waits on it is clear of where the grey one comes down.
const waitsAt = width * 0.52;
const riseFoot = point(waitsAt + radius * 1.5, floorY);
const riseTop = point(width * 0.76, 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.
// Near its high end, to keep the hop short: a long flight would look like a line.
const onRamp = restOn(...ramp, 0.14, 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, onRamp.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 seesaw 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(waitsAt);
const landsOnFloor = onFloor(riding(leavesAt, 1).x - width * 0.1);
const hitsSideSpring = against(sideSpring, 0, radius);
// 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.onRamp, onRamp, throwBack)
// It bounces itself out down the slope, then rolls to the tip and drops off it.
.settle(BEATS.rollsDownRamp, restOn(...ramp, 0.62, radius), radius * 1.4)
// At the pace the bounces left it with: no stop in between.
.roll(BEATS.offRamp, restOn(...ramp, 1.04, radius))
// Onto the left half of the seesaw: one hop to the right, the way the beam
// slopes, and then the beam turns under it and sends it back off the left end.
.fall(BEATS.onSeesaw, riding(landsAt, 0))
.bounce(
lerp(BEATS.onSeesaw, BEATS.offSeesaw, SEESAW_HOP),
riding(hopsTo, SEESAW_HOP),
radius * 1.2,
)
.along(BEATS.offSeesaw, rolling)
.fall(BEATS.onFloor, landsOnFloor)
// It comes off the beam fast, so its bounces carry it a long way along the floor.
.settle(
BEATS.rollsOnFloor,
onFloor(lerp(landsOnFloor.x, hitsSideSpring.x, 0.62)),
radius * 1.3,
3,
)
// Into the side spring, which squeezes and shoots it back to the right.
.roll(BEATS.hitsSideSpring, hitsSideSpring)
.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,
ramp,
rampCorner,
seesaw,
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' | 'along';
start: number;
end: number;
from: Point;
to: Point;
rise: number;
ease: RollEase;
/** For `along`: where it is at each fraction of the segment. */
path?: (u: number) => Point;
}
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;// How much of its speed a marble keeps when it hits the ground. Each bounce after a
// landing lasts this much of the one before, and rises the square of it.
const RESTITUTION = 0.55;
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[] = [];
// Every landing with how hard it was: a light one flattens the marble less, for less time.
const hits: Array<{ at: number; strength: number }> = [];
const land = (at: number, strength = 1) => {
impacts.push(at);
hits.push({ at, strength });
};
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) {
land(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) land(until);
return add({ kind: 'bounce', end: until, to, rise, ease: 'linear' });
},
/**
* What a landing is followed by: a few bounces on towards `to`, each lower and
* shorter than the one before, as the marble loses its energy to the ground. The
* first peaks `rise` above the straight line.
*/
settle(until: number, to: Point, rise: number, hops = 2) {
const from = cursor;
const start = time;
const weights = Array.from({ length: hops }, (_, hop) => RESTITUTION ** hop);
const total = weights.reduce((sum, weight) => sum + weight, 0);
let done = 0;
weights.forEach((weight, hop) => {
done = hop === hops - 1 ? 1 : done + weight / total;
const end = lerp(start, until, done);
land(end, weight);
add({
kind: 'bounce',
end,
to: { x: lerp(from.x, to.x, done), y: lerp(from.y, to.y, done) },
rise: rise * weight * weight,
ease: 'linear',
});
});
return builder;
},
/**
* 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) {
land(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 }),
/**
* Carried by something that moves under it: `path` says where it is at each
* fraction of the segment, and has to start where the marble already is.
*/
along: (until: number, path: (u: number) => Point) =>
add({ kind: 'along', end: until, to: path(1), rise: 0, ease: 'linear', path }),
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) };
}
case 'along':
return segment.path?.(u) ?? to;
}
}
function at(progress: number): MarbleState {
const clamped = clamp(progress);
const { x, y } = position(clamped);
const flattened = Math.max(
0,
...hits.map(
({ at, strength }) =>
strength * clamp(1 - Math.abs(clamped - at) / (SQUASH_WINDOW * strength)),
),
);
return {
x,
y,
rotation: (x - origin.x) / radius,
squash: 1 - SQUASH_AMOUNT * flattened,
};
}
return builder;
}
Inicio01 / InicioProyectos02 / ProyectosProceso03 / ProcesoContacto04 / Contacto
Portfolio · 2026 ·