Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ the scene it pulls back, so the last frame shows the whole idea. During each
scene it also pushes in very slowly.

While a shot holds, every card and label is either fully in the shot or out
of it. Arrows and lines may run off the edge. While the camera moves from
of it. Arrows and lines may run off the edge, but their labels stay whole, and
so do the labels of charts. While the camera moves from
one shot to the next, things may pass the edge of the frame for a moment.
Shots stay clear of the captions and of the parts the app covers on Shorts,
TikTok and Reels.
Expand All @@ -334,8 +335,9 @@ at the end, and let the camera find the close-ups:
- Keep a box with its label, or an icon with its caption, together as one
unit. Things far apart get their own shots.
- Lines, arrows and things added inside an element already on screen, like
a line in a chart, get no close-up of their own. Use `s.focus` when one
should.
a line in a chart, get no close-up of their own. Neither does an icon
without a label or a box without words, because up close it shows nothing
new. Give it a label, or use `s.focus` when one should get a close-up.
- When a scene goes on with the same picture, draw the carried elements with
`at: -1` in the same places. The camera then goes on from where it
stopped, and the two scenes fade into each other.
Expand Down
21 changes: 14 additions & 7 deletions engine/director.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,18 @@ export function planCamera(elements, { dur, view, frame, pace = 1, voiceEnd = nu
const hold = MIN_HOLD / pace;
const visibleDuring = (t0, t1) => items.filter((e) => e.at <= t1 && e.out > t0 + 0.25);
const picture = (list) => list.filter((e) => e.kind !== 'focus');
const solid = (list) => list.filter((e) => !['arrow', 'line', 'focus'].includes(e.kind));
// What the camera may move in on. An arrow's label is kept whole, but it
// is part of the arrow, which gets no close-up.
const solid = (list) => list.filter((e) => !['arrow', 'line', 'focus', 'label'].includes(e.kind));
// Everything on screen from t0 to t1, framed as one picture.
const overview = (t0, t1) => {
const v = picture(visibleDuring(t0, t1));
return v.length ? compose(v, v, view, frame, usable, PICTURE_FILL) : null;
};
// Whether a shot already shows these elements whole, in the free part of
// the frame, also at the end of its slow push. A beat of only arrows and
// lines fits when they are at least partly in view.
// lines fits when they are at least partly in view. An arrow's label has
// to be whole.
const fits = (shot, list) => {
const body = list.filter((e) => e.kind !== 'arrow' && e.kind !== 'line');
const tight = shot.drift === 0 ? shot : clampShot({ ...shot, z: shot.z * (1 + DRIFT) }, view, frame);
Expand Down Expand Up @@ -323,14 +326,15 @@ export function planCamera(elements, { dur, view, frame, pace = 1, voiceEnd = nu
}
continue;
}
const before = items.filter((e) => e.at < beat.start - 0.05 && e.out > beat.start + 0.3 && e.kind !== 'focus' && e.kind !== 'ink');
const before = items.filter((e) => e.at < beat.start - 0.05 && e.out > beat.start + 0.3 && !['focus', 'ink', 'label'].includes(e.kind));
if (fits(cur, fresh)) {
// It is already in the shot. The camera moves in only on a new card,
// label or picture. A line, an arrow, or something drawn inside an
// element that is already there, like a line in a chart, gets no
// close-up. It tries the new thing with its neighbours first, then on
// its own.
const own = solid(fresh).filter((e) => !before.some((f) => f.box.w * f.box.h >= e.box.w * e.box.h * 2 && contains(f.box, e.box)));
// close-up. Nor does an icon or shape without words: up close it shows
// nothing more. It tries the new thing with its neighbours first, then
// on its own.
const own = solid(fresh).filter((e) => !e.bare && !before.some((f) => f.box.w * f.box.h >= e.box.w * e.box.h * 2 && contains(f.box, e.box)));
// It is on screen already, so the push may wait a moment for its turn.
const at = Math.max(begin, last + hold);
if (!own.length || at > beat.start + 1 / pace || at - zoomed < PUSH_GAP / pace) continue;
Expand Down Expand Up @@ -447,9 +451,12 @@ export function cameraMoving(plan, t) {

// Whether scene B picks up where scene A left off: what A's last frame
// shows is mostly still there when B starts, in the same places. `last` is
// the part of the scene A's camera shows at the end.
// the part of the scene A's camera shows at the end. An arrow's label goes
// with its arrow, so it does not count on its own.
export function continues(a, b, last) {
const inView = (e) => e.box.x + e.box.w / 2 > last.x0 && e.box.x - e.box.w / 2 < last.x1 && e.box.y + e.box.h / 2 > last.y0 && e.box.y - e.box.h / 2 < last.y1;
a = a.filter((e) => e.kind !== 'label');
b = b.filter((e) => e.kind !== 'label');
const end = a.filter((e) => e.out === Infinity && inView(e));
const carried = b.filter((e) => e.at <= 0.05);
if (end.length < 2 || carried.length < 2) return false;
Expand Down
151 changes: 122 additions & 29 deletions engine/stage.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,10 @@ export class Stage {
if (sc !== 1 || rad) box = { ...box, w: (Math.abs(box.w * Math.cos(rad)) + Math.abs(box.h * Math.sin(rad))) * sc, h: (Math.abs(box.w * Math.sin(rad)) + Math.abs(box.h * Math.cos(rad))) * sc };
// _pendingExtra is already measured with the element's transform.
if (this._pendingExtra) box = unionBox(box, this._pendingExtra);
this._attach(kind, at, out, box);
this._attach(kind, at, out, box, this._pendingInfo);
this._pendingBox = null;
this._pendingExtra = null;
this._pendingInfo = null;
}
if (this._suppress > 0) return null;
if (this.t < at) return null;
Expand Down Expand Up @@ -368,6 +369,32 @@ export class Stage {
return { x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
}

// The box around text drawn beside an element, like a chart's labels, for
// the camera: where the letters land, as the browser measures them. Each
// entry: { text, x, y, font, align, base } with canvas textAlign and
// textBaseline values.
_textExtent(entries) {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
const ctx = this.ctx;
ctx.save();
for (const e of entries) {
if (!e.text) continue;
ctx.font = e.font;
ctx.textAlign = e.align ?? 'center';
ctx.textBaseline = e.base ?? 'middle';
const m = ctx.measureText(e.text);
x0 = Math.min(x0, e.x - m.actualBoundingBoxLeft - 2);
x1 = Math.max(x1, e.x + m.actualBoundingBoxRight + 2);
y0 = Math.min(y0, e.y - m.actualBoundingBoxAscent - 2);
y1 = Math.max(y1, e.y + m.actualBoundingBoxDescent + 2);
}
ctx.restore();
return x1 > x0 ? this._designBox((x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0) : null;
}

_designBox(x, y, w, h) {
const m = this._matrix();
const pts = [[x - w / 2, y - h / 2], [x + w / 2, y - h / 2], [x + w / 2, y + h / 2], [x - w / 2, y + h / 2]].map(([px, py]) => m.transformPoint(new DOMPoint(px, py)));
Expand Down Expand Up @@ -821,6 +848,8 @@ export class Stage {
const y = o.y ?? this.cy;
const shape = o.shape ?? 'rect';
this._register(o.id, x, y, w, h, shape === 'ellipse' || shape === 'circle' ? 'ellipse' : 'rect');
// A shape without words has no detail for the camera to move in on.
if (this.engine.layoutLog && !lay) this._pendingInfo = { bare: true };
const g = geom(x, y, w, h);
const plain = lay ? String(label).replace(/\*/g, '') : '';
const life = this._life(o, 'shape', { label: plain.slice(0, 40) || o.icon });
Expand Down Expand Up @@ -922,7 +951,18 @@ export class Stage {
if (this.engine.layoutLog) {
const xs = [a.x, b.x, c.x];
const ys = [a.y, b.y, c.y];
this._logLayout('arrow', this.time(o.at ?? 0), o.out === undefined ? Infinity : this.time(o.out), (Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
const at = this.time(o.at ?? 0);
const out = o.out === undefined ? Infinity : this.time(o.out);
this._logLayout('arrow', at, out, (Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
// The line may run off the edge of a close-up, but its label is text,
// so the camera keeps it whole. A label outside the frame is never
// seen, so the camera leaves it alone.
if (o.label) {
const label = this._arrowLabel(o, d, a, b, bend);
const box = this._textExtent([{ text: String(o.label), x: label.x, y: label.y, font: label.font }]);
const seen = box.x + box.w / 2 > 0 && box.x - box.w / 2 < this.W && box.y + box.h / 2 > 0 && box.y - box.h / 2 < this.H;
if (seen) this._attach('label', at, out, box);
}
}
const result = { from: a, to: b, control: c };
if (!life) return result;
Expand Down Expand Up @@ -955,41 +995,16 @@ export class Stage {
if ((head === 'start' || head === 'both') && prog > 0.2) drawHead(0.001, -1);
}
if (o.label) {
const mid = pointAt(d, L / 2);
// Put the label on the outer side of a curve, or above a straight arrow.
let nx;
let ny;
const bx = mid.x - (a.x + b.x) / 2;
const by = mid.y - (a.y + b.y) / 2;
if (bend && Math.hypot(bx, by) > 1) {
nx = bx / Math.hypot(bx, by);
ny = by / Math.hypot(bx, by);
} else {
const len = Math.hypot(dx, dy) || 1;
nx = -dy / len;
ny = dx / len;
if (ny > 0 || (ny === 0 && nx > 0)) {
nx = -nx;
ny = -ny;
}
}
const off = o.labelOffset ?? 34;
const ls = o.labelSize ?? 34;
const la = clamp((life.p - 0.55) / 0.35);
if (la > 0) {
const { x: lx, y: ly, w, size: ls, font } = this._arrowLabel(o, d, a, b, bend);
ctx.save();
ctx.globalAlpha *= la;
ctx.font = fontString(th, o.labelFont ?? 'hand', ls);
ctx.font = font;
ctx.fillStyle = colorOf(th, o.labelColor ?? o.color, 'muted');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lw = ctx.measureText(String(o.label)).width;
// Push further out when the label runs along the arrow's direction.
const extra = Math.abs(nx) * lw * 0.5;
const lx = mid.x + nx * (off + extra);
const ly = mid.y + ny * off;
ctx.fillText(String(o.label), lx, ly);
const w = ctx.measureText(String(o.label)).width;
this._logText({ text: String(o.label), x0: lx - w / 2, y0: ly - ls / 2, x1: lx + w / 2, y1: ly + ls / 2, size: ls, fg: ctx.fillStyle, bg: null });
ctx.restore();
}
Expand All @@ -998,6 +1013,40 @@ export class Stage {
return result;
}

// Where an arrow's label goes: on the outer side of a curve, or above a
// straight arrow. x, y is the label's center.
_arrowLabel(o, d, a, b, bend) {
const mid = pointAt(d, pathLength(d) / 2);
let nx;
let ny;
const bx = mid.x - (a.x + b.x) / 2;
const by = mid.y - (a.y + b.y) / 2;
if (bend && Math.hypot(bx, by) > 1) {
nx = bx / Math.hypot(bx, by);
ny = by / Math.hypot(bx, by);
} else {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
nx = -dy / len;
ny = dx / len;
if (ny > 0 || (ny === 0 && nx > 0)) {
nx = -nx;
ny = -ny;
}
}
const off = o.labelOffset ?? 34;
const size = o.labelSize ?? 34;
const font = fontString(this.theme, o.labelFont ?? 'hand', size);
this.ctx.save();
this.ctx.font = font;
const w = this.ctx.measureText(String(o.label)).width;
this.ctx.restore();
// Further out when the label runs along the arrow's direction.
const extra = Math.abs(nx) * w * 0.5;
return { x: mid.x + nx * (off + extra), y: mid.y + ny * off, w, size, font };
}

connect(a, b, o = {}) {
return this.arrow(a, b, o);
}
Expand Down Expand Up @@ -1037,6 +1086,8 @@ export class Stage {
const y = o.y ?? this.cy;
const bgSize = o.bg ? size * (o.bgScale ?? 1.7) : size;
this._register(o.id, x, y, bgSize, bgSize, o.bg === 'square' ? 'rect' : o.bg ? 'ellipse' : 'rect');
// Nor has an icon without a label.
if (this.engine.layoutLog && !o.label) this._pendingInfo = { bare: true };
if (this.engine.layoutLog && o.label) {
// The label belongs to the icon for the camera, not for arrows.
const ls = o.labelSize ?? 36;
Expand Down Expand Up @@ -1355,6 +1406,22 @@ export class Stage {
const at = this.time(o.at ?? 0);
const stagger = (o.stagger ?? 0.22) / this.pace;
this._register(o.id, x, y, w, h);
if (this.engine.layoutLog) {
// The labels under the bars and the values on top count for the
// camera, not for arrows.
const format = o.format ?? ((v) => (Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1)));
const ls = o.labelSize ?? 34;
const vs = o.valueSize ?? 40;
const text = this._textExtent(items.flatMap((d, i) => {
const cx = x - w / 2 + slot * i + slot / 2;
const labels = d.label !== undefined ? [{ text: String(d.label), x: cx, y: base + 16, font: fontString(th, 'body', ls), base: 'top' }] : [];
const values = o.values !== false ? [{ text: `${o.prefix ?? ''}${format(d.value)}${o.suffix ?? ''}`, x: cx, y: base - (d.value / max) * h - 12, font: fontString(th, 'display', vs), base: 'bottom' }] : [];
return [...labels, ...values];
}));
// The axis reaches 20 pixels past the bars on each side.
const axis = this._designBox(x, y, w + 40 + th.stroke.width * 2, h + th.stroke.width * 2);
this._pendingExtra = text ? unionBox(axis, text) : axis;
}
// Each bar can have its own `at` (seconds, a spoken word or "#marker").
const barAt = items.map((d, i) => (d.at !== undefined ? this.time(d.at) : at + 0.3 / this.pace + i * stagger));
items.forEach((d, i) => this._record('chart', barAt[i], 0.8, { sfx: o.sfx }, { label: `bar${i}` }));
Expand Down Expand Up @@ -1421,6 +1488,15 @@ export class Stage {
const hi = o.max ?? Math.max(...values) * 1.1;
const pts = values.map((v, i) => [x - w / 2 + (w * i) / Math.max(1, values.length - 1), y + h / 2 - ((v - lo) / (hi - lo || 1)) * h]);
this._register(o.id, x, y, w, h);
if (this.engine.layoutLog) {
// The ends of the axes, the dots and the labels under the axis count
// for the camera, not for arrows.
const pad = 12 + th.stroke.width * 1.3;
const around = this._designBox(x, y, w + pad * 2, h + pad * 2);
const ls = o.labelSize ?? 30;
const text = o.labels && this._textExtent(o.labels.slice(0, pts.length).map((l, i) => ({ text: String(l), x: pts[i][0], y: y + h / 2 + 14, font: fontString(th, 'body', ls), base: 'top' })));
this._pendingExtra = text ? unionBox(around, text) : around;
}
const life = this._life(o, 'chart', { enter: 'draw', dur: o.dur ?? 1.6 });
if (!life) return geom(x, y, w, h);
ctx.save();
Expand Down Expand Up @@ -1478,6 +1554,23 @@ export class Stage {
const hole = (o.donut ?? 0) * r;
const total = items.reduce((a, d) => a + d.value, 0) || 1;
this._register(o.id, x, y, r * 2, r * 2, 'ellipse');
if (this.engine.layoutLog) {
// The outline of the slices and the labels around the pie count for
// the camera, not for arrows.
const ring = this._designBox(x, y, r * 2 + th.stroke.width * 4, r * 2 + th.stroke.width * 4);
const ls = o.labelSize ?? 32;
const lr = r + (o.labelOffset ?? 56);
let a0 = -Math.PI / 2;
const text = o.labels !== false && this._textExtent(items.map((d) => {
const full = (d.value / total) * Math.PI * 2;
const mid = a0 + full / 2;
a0 += full;
if (full <= 0.001) return {};
const text = d.label !== undefined ? `${d.label} ${Math.round((d.value / total) * 100)}%` : `${Math.round((d.value / total) * 100)}%`;
return { text, x: x + Math.cos(mid) * lr, y: y + Math.sin(mid) * lr, font: fontString(th, 'body', ls, BOLD.body), align: Math.cos(mid) > 0.2 ? 'left' : Math.cos(mid) < -0.2 ? 'right' : 'center', base: 'middle' };
}));
this._pendingExtra = text ? unionBox(ring, text) : ring;
}
const life = this._life(o, 'chart', { enter: 'grow', dur: o.dur ?? 1.2 });
if (!life) return geom(x, y, r * 2, r * 2);
ctx.save();
Expand Down
Loading
Loading