3. Timelines¶
So far every value followed the default timeline returned by Keyed.init(). In this chapter you create timelines of your own, control their playback, scrub through them, run several at once, retime keys with pins, and count in frames instead of seconds.
Controls¶
A Timeline is a clock. Create one with new Timeline() and attach values to it with setTimeline():
Keyed.init();
tm = new Timeline().setDuration(3);
x = Keyed.ofFloat()
.setTimeline(tm)
.key(Key.at(0).setEasing(1 / 3), 60)
.key(Key.at(1.5).setEasing(1 / 3), 340)
.key(Key.at(3).setEasing(1 / 3), 60);
Note
Call Keyed.init() first: only timelines created after it advance with the sketch.
The timeline can then be controlled while it runs:
| Method | What it does |
|---|---|
play(playing = true) |
pauses or resumes; isPlaying() tells which |
to(t) |
jumps to time t |
setSpeed(s) |
2 is twice as fast; negative speeds play backwards |
loop(loop = true) |
without looping, the timeline stops at its duration |
t() |
the current time |
isFinished() |
whether a non-looping timeline reached its end |
In the sketch, Space pauses or resumes, Left and Right change the speed, L toggles looping, and R jumps back to the start.
Full sketch: Controls
let tm;
let x;
function setup() {
createCanvas(400, 400);
Keyed.init();
textFont('monospace');
// A timeline of our own, 3 seconds long and looping.
tm = new Timeline().setDuration(3);
// setTimeline() makes x follow tm instead of the default timeline.
x = Keyed.ofFloat()
.setTimeline(tm)
.key(Key.at(0).setEasing(1 / 3), 60)
.key(Key.at(1.5).setEasing(1 / 3), 340)
.key(Key.at(3).setEasing(1 / 3), 60);
}
function draw() {
background(255);
noStroke();
fill(0);
circle(x.value(), 100, 40);
// Progress bar with the playhead.
stroke(220);
line(40, 160, 360, 160);
stroke(0);
const head = map(tm.t(), 0, tm.duration(), 40, 360);
line(head, 150, head, 170);
// text() is outlined by the current stroke in p5.
noStroke();
fill(0);
text(`t ${nf(tm.t(), 1, 2)}`, 40, 210);
text(`playing ${tm.isPlaying()}`, 40, 235);
text(`speed ${nf(tm.speed(), 1, 1)}`, 40, 260);
text(`looping ${tm.isLooping()}`, 40, 285);
text(`finished ${tm.isFinished()}`, 40, 310);
fill(150);
text('SPACE play/pause R restart', 40, 350);
text('LEFT/RIGHT speed L loop', 40, 370);
}
function keyPressed() {
if (key === ' ') {
tm.play(!tm.isPlaying());
} else if (key.toLowerCase() === 'r') {
// Jumps back to the start.
tm.to(0);
} else if (key.toLowerCase() === 'l') {
// Without looping the timeline stops at its duration.
tm.loop(!tm.isLooping());
} else if (key === RIGHT_ARROW) {
tm.setSpeed(tm.speed() + 0.5);
} else if (key === LEFT_ARROW) {
// Negative speeds play backwards.
tm.setSpeed(tm.speed() - 0.5);
}
}
Scrubbing¶
Because to(t) jumps to any time, a progress bar is easy to make draggable. Pause while dragging, so the timeline doesn't advance on its own:
function draw() {
if (mouseIsPressed) {
const t = map(constrain(mouseX, barX0, barX1), barX0, barX1, 0, tm.duration());
tm.to(t);
}
// ...
}
function mousePressed() {
tm.play(false);
}
function mouseReleased() {
tm.play(true);
}
Full sketch: Scrubbing
let tm;
let pos;
const barX0 = 40;
const barX1 = 360;
const barY = 340;
function setup() {
createCanvas(400, 400);
// Call init() first: timelines created after it advance with the sketch.
Keyed.init();
textFont('monospace');
textAlign(CENTER, CENTER);
tm = new Timeline().setDuration(4);
pos = Keyed.ofVector()
.setTimeline(tm)
.key(Key.at(0).setEasing(1 / 3), createVector(80, 80))
.key(Key.at(1).setEasing(1 / 3), createVector(320, 80))
.key(Key.at(2.5).setEasing(1 / 3), createVector(320, 260))
.key(Key.at(4).setEasing(1 / 3), createVector(80, 80));
}
function draw() {
background(255);
if (mouseIsPressed) {
// to() jumps straight to a time.
const t = map(constrain(mouseX, barX0, barX1), barX0, barX1, 0, tm.duration());
tm.to(t);
}
const p = pos.value();
noStroke();
fill(0);
circle(p.x, p.y, 40);
// The bar, with a tick for every key.
stroke(220);
line(barX0, barY, barX1, barY);
for (const k of pos.keys()) {
const kx = map(k.t(), 0, tm.duration(), barX0, barX1);
line(kx, barY - 6, kx, barY + 6);
}
// The playhead.
stroke(0);
const head = map(tm.t(), 0, tm.duration(), barX0, barX1);
line(head, barY - 12, head, barY + 12);
noStroke();
fill(150);
text('drag to scrub', width / 2, barY + 30);
}
// Pause while dragging, so the timeline doesn't advance on its own.
function mousePressed() {
tm.play(false);
}
function mouseReleased() {
tm.play(true);
}
Multiple timelines¶
Each timeline is an independent clock. Here the spinner loops on the default timeline, while a card slides in on a second timeline that plays once. Pass false as the second argument of setDuration() to turn looping off:
intro = new Timeline().setDuration(1.2, false);
cardY = Keyed.ofFloat()
.setTimeline(intro)
.key(Key.at(0).setEasing(Easing.CUBIC_OUT), 420)
.key(1.2, 180);
Clicking calls intro.to(0), which replays the intro without affecting the spinner.
Full sketch: Multiple
let intro;
let angle;
let cardY;
let cardAlpha;
function setup() {
createCanvas(400, 400);
// The default timeline loops every second.
Keyed.init().setDuration(1);
textFont('monospace');
textAlign(CENTER, CENTER);
// Without setTimeline(), values follow the default timeline.
angle = Keyed.ofFloat()
.key(0, 0)
.key(1, TWO_PI);
// A second timeline that plays once: false turns looping off,
// so it stops at the end instead of starting over.
intro = new Timeline().setDuration(1.2, false);
cardY = Keyed.ofFloat()
.setTimeline(intro)
.key(Key.at(0).setEasing(Easing.CUBIC_OUT), 420)
.key(1.2, 180);
cardAlpha = Keyed.ofFloat()
.setTimeline(intro)
.key(0, 0)
.key(0.6, 255);
}
function draw() {
background(255);
// The spinner keeps turning, whatever happens to the intro.
noFill();
stroke(0);
strokeWeight(4);
const a = angle.value();
arc(200, 70, 50, 50, a, a + PI * 1.5);
strokeWeight(1);
// text() is outlined by the current stroke in p5.
noStroke();
fill(150);
text(`intro finished: ${intro.isFinished()}`, 200, 130);
noStroke();
fill(0, cardAlpha.value());
rect(80, cardY.value(), 240, 140, 12);
fill(255, cardAlpha.value());
text('click to replay', 200, cardY.value() + 70);
}
function mousePressed() {
// Restarts only the intro; the default timeline is unaffected.
intro.to(0);
}
Pins¶
When several values share a moment, say a ball arriving while it grows and turns red, retiming means editing every key. A Pin is a point in time that several keys can share. Pass the pin instead of a time:
arrive = Pin.at(1);
x = Keyed.ofFloat()
.key(Key.at(0).setEasing(1 / 3), 60)
.key(Key.at(arrive).setEasing(1 / 3), 340)
.key(Key.at(duration).setEasing(1 / 3), 60);
ballSize = Keyed.ofFloat()
.key(0, 20)
.key(arrive, 80)
.key(duration, 20);
Now arrive.to(t) moves the key in all values at once. Drag the pin in the sketch to try it. Use addListener() to get notified when a pin moves.
Full sketch: Pins
const duration = 3;
let arrive;
let x;
let ballSize;
let col;
const barX0 = 40;
const barX1 = 360;
const barY = 340;
function setup() {
createCanvas(400, 400);
Keyed.init().setDuration(duration);
textFont('monospace');
textAlign(CENTER, CENTER);
// A Pin is a point in time that several keys can share.
arrive = Pin.at(1);
// Pass the pin instead of a time. Key.at(pin) adds easing as usual.
x = Keyed.ofFloat()
.key(Key.at(0).setEasing(1 / 3), 60)
.key(Key.at(arrive).setEasing(1 / 3), 340)
.key(Key.at(duration).setEasing(1 / 3), 60);
ballSize = Keyed.ofFloat()
.key(0, 20)
.key(arrive, 80)
.key(duration, 20);
col = Keyed.ofColor()
.key(0, color(0))
.key(arrive, color(230, 60, 60))
.key(duration, color(0));
}
function draw() {
background(255);
if (mouseIsPressed) {
// Moving the pin moves its key in all three values at once.
const t = map(mouseX, barX0, barX1, 0, duration);
arrive.to(constrain(t, 0.2, duration - 0.2));
}
noStroke();
fill(col.value());
circle(x.value(), 170, ballSize.value());
// The bar, with the pin and the playhead.
stroke(220);
line(barX0, barY, barX1, barY);
const px = map(arrive.t(), 0, duration, barX0, barX1);
noStroke();
fill(230, 60, 60);
triangle(px - 7, barY - 18, px + 7, barY - 18, px, barY - 6);
stroke(0);
const head = map(Keyed.defaultTimeline().t(), 0, duration, barX0, barX1);
line(head, barY - 12, head, barY + 12);
noStroke();
fill(150);
text(`arrive = ${nf(arrive.t(), 1, 2)} s`, width / 2, barY - 40);
text('drag to move the pin', width / 2, barY + 30);
}
Frames instead of seconds¶
If you think in frames, set the unit before creating timelines. Keyed can't read the sketch's frame rate, so pass the same value:
Key times are then frames: keys at 0, 45 and 90 are 3 seconds at 30 fps.
This sketch also shows the two ways a timeline can advance:
sync(true), the default, follows the real clock. Ifdraw()is slow, it skips ahead to stay on time.sync(false)advances exactly one frame perdraw(). Ifdraw()is slow, it slows down, but never skips a frame.
In the sketch, draw() is slowed down for a moment while the mouse is held down: the top ball jumps ahead while the bottom one falls behind. sync(false) is what you want for exports.
Full sketch: FrameUnit
const fps = 30;
let synced;
let stepped;
let a;
let b;
function setup() {
createCanvas(400, 400);
frameRate(fps);
textFont('monospace');
// Keyed can't read the sketch's frame rate, so pass the same value.
Keyed.setFrameRate(fps);
// Count time in frames instead of seconds, for every timeline created afterwards.
Keyed.setUnit(Keyed.FRAME);
Keyed.init();
// Follows the real clock, measured in frames.
// If draw() is slow it skips ahead to stay on time.
synced = new Timeline().setDuration(90);
// Advances exactly one frame per draw().
// If draw() is slow it slows down, but never skips a frame.
stepped = new Timeline().setDuration(90).sync(false);
a = bounce(synced);
b = bounce(stepped);
}
// Keys at frames 0, 45 and 90: 3 seconds at 30 fps.
function bounce(tm) {
return Keyed.ofFloat()
.setTimeline(tm)
.key(Key.at(0).setEasing(1 / 3), 60)
.key(Key.at(45).setEasing(1 / 3), 340)
.key(Key.at(90).setEasing(1 / 3), 60);
}
function draw() {
// Simulates a heavy sketch while the mouse is held down.
if (mouseIsPressed) {
const end = performance.now() + 100;
while (performance.now() < end) {}
}
background(255);
lane('sync(true)', synced, a, 110);
lane('sync(false)', stepped, b, 240);
fill(150);
text('hold the mouse to slow down draw()', 40, 360);
}
function lane(label, tm, x, y) {
fill(150);
text(label, 40, y - 40);
fill(0);
text(`frame ${nf(tm.t(), 2, 1)}`, 240, y - 40);
stroke(220);
line(60, y, 340, y);
noStroke();
fill(0);
circle(x.value(), y, 30);
}
Next: Events.