teqo-backup/scripts/lib/chartMotion.mjs
Francisco Solla 39933fc004 feat(C237-C239): colunas emparelhadas e motion determinístico no gerador de gráficos
- C237 (CENA 02F): --paired desenha duas colunas zero-based por categoria
  (inicial cinza, recente vermelha, valência positiva compartilhada), com
  legenda de períodos, valor sobre cada barra e rótulo sob o par; até 4
  categorias; design DEGRADED com sign-off humano de 2026-09-29.
- C238/C239 (CENAS 02G/02H): --motion gera MP4 H.264 local (6s @ 30fps,
  frames determinísticos por seek + ffmpeg do repo) para o par emparelhado,
  a coluna de 2 períodos e o número-âncora; C238 com sign-off humano de
  2026-09-30 e C239 aguardando gate.
- Superfície: CLI, SKILL.md, hi-fi e specs novos (chartMotion/
  chartMotionRender; SCRIPTS_SPEC_PINNED sincronizado).

Design tier: DEGRADED (inline, tier 4 — frontier sem quota).
2026-09-29 22:37:02 -03:00

172 lines
6.8 KiB
JavaScript

/**
* Chart motion (C238/C239): the deterministic timeline that animates the
* pieces without touching the approved static design. The schedule is data,
* not CSS — the renderer serializes each stage into `--md` (delay) and `--mud`
* (duration) custom properties on the element and the stylesheet carries only
* the keyframes and the easing, so the clock is pinned here and tested
* directly.
*
* Variants: the paired columns (C238 prototype) and, in the C239 extension,
* the two-period column and the anchor number. The capture never records in
* real time: every CSS animation is seeked to the frame time (30fps grid, 6s),
* screenshotted, and the MP4 is encoded offline from those exact timestamps
* (see `chartMotionRender.mjs`).
*/
export const MOTION_FPS = 30
export const MOTION_DURATION_S = 6
export const MOTION_FRAMES = MOTION_DURATION_S * MOTION_FPS
export const MOTION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'
/**
* Shell stages (seconds). The card is already composed for reading: the rule,
* kicker, headline and subtitle enter first, then the data marks grow, and the
* footer (source + brand) lands last — right before the static hold. `legend`
* belongs to the paired variant; `number`/`copy` belong to the anchor.
*/
export const MOTION_STAGES = {
topRule: { kind: 'sweep', delay: 0, duration: 0.45 },
kicker: { kind: 'rise', delay: 0.2, duration: 0.45 },
headline: { kind: 'rise', delay: 0.35, duration: 0.55 },
subtitle: { kind: 'rise', delay: 0.65, duration: 0.45 },
legend: { kind: 'fade', delay: 0.8, duration: 0.4 },
number: { kind: 'rise', delay: 1.05, duration: 0.6 },
copy: { kind: 'fade', delay: 1.7, duration: 0.45 },
footer: { kind: 'fade', delay: 3.1, duration: 0.5 },
}
/**
* Per-mark stages of the bar variants: the mark grows, the label enters with
* the growth and the value lands after the bar settles. The paired columns
* stagger by pair (the two columns of a pair grow together); the two-period
* column staggers per column, left to right (the recent red one last).
*/
export const PAIR_MOTION = {
firstAt: 1.1,
stagger: 0.3,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
export const COLUMN_MOTION = {
firstAt: 1.1,
stagger: 0.35,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
/** Milliseconds-rounded seconds: the inline CSS vars must read `1.4s`, not `1.4000000000000001s`. */
const round3 = (value) => Math.round(value * 1000) / 1000
const markMotion = (config, index) => {
const barAt = round3(config.firstAt + index * config.stagger)
return {
bar: { kind: 'grow', delay: barAt, duration: config.barDuration },
label: {
kind: 'fade',
delay: round3(barAt + config.labelOffset),
duration: config.labelDuration,
},
value: {
kind: 'rise',
delay: round3(barAt + config.valueOffset),
duration: config.valueDuration,
},
}
}
/** @param {number} index pair position, left to right (0-based) */
export const pairMotion = (index) => markMotion(PAIR_MOTION, index)
/** @param {number} index column position, left to right (0-based) */
export const columnMotion = (index) => markMotion(COLUMN_MOTION, index)
/**
* Motion variant of a spec — `null` means the shape has no certified motion
* yet (the builder refuses instead of animating something undesigned).
*/
export const motionKindFor = (spec) => {
if (spec.pairedColumns) return 'paired'
if (spec.chartType === 'anchor') return 'anchor'
if (spec.chartType === 'column') return 'column'
return null
}
const SHELL_STAGES = ['topRule', 'kicker', 'headline', 'subtitle', 'footer']
/** Every stage a spec animates — the clock guard reads this list. */
export const motionStages = (spec) => {
const kind = motionKindFor(spec)
const shell = SHELL_STAGES.map((name) => MOTION_STAGES[name])
if (kind === 'anchor') return [...shell, MOTION_STAGES.number, MOTION_STAGES.copy]
const rows = Array.isArray(spec.rows) ? spec.rows : []
const marks = rows.flatMap((_row, index) =>
Object.values(kind === 'column' ? columnMotion(index) : pairMotion(index)),
)
return kind === 'paired' ? [...shell, MOTION_STAGES.legend, ...marks] : [...shell, ...marks]
}
/** Last stage end (seconds) of a stage list. */
export const motionEnd = (stages) =>
Math.max(...stages.map((stage) => stage.delay + stage.duration))
/**
* Fail-closed: the shape must have a certified motion, the column motion is
* certified for the two-period pair (C239) and the piece must be fully settled
* inside the exported duration (the tail is a static hold the editor can cut
* into).
*/
export const validateMotion = (spec) => {
const kind = motionKindFor(spec)
if (!kind) {
throw new Error(
'motion indisponível para esta variante: o protótipo cobre as colunas emparelhadas, a coluna de 2 períodos e o número-âncora.',
)
}
if (kind === 'column' && (!Array.isArray(spec.rows) || spec.rows.length !== 2)) {
throw new Error(
`o motion da coluna está certificado para 2 períodos (${spec.rows?.length ?? 0} recebidos): o protótipo C239 cobre o par de períodos.`,
)
}
const end = motionEnd(motionStages(spec))
if (end > MOTION_DURATION_S) {
throw new Error(
`motion termina em ${end}s (> ${MOTION_DURATION_S}s): reduza os pontos antes de gerar.`,
)
}
return { kind, end, duration: MOTION_DURATION_S }
}
/** Deterministic 30fps grid (ms): the capture seeks every animation to these. */
export const motionFrameTimes = () =>
Array.from({ length: MOTION_FRAMES }, (_value, index) => (index * 1000) / MOTION_FPS)
/**
* Keyframes and the generic animated classes. Timings never live here: each
* element carries `--md`/`--mud` from the schedule above. `backwards` fill
* means the from-state holds through the delay and the element ends on its
* natural (static) styles — the last frame is exactly the approved PNG.
*/
export const MOTION_CSS = `@keyframes motion-sweep { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes motion-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes motion-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes motion-grow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
.motion .motion-sweep,
.motion .motion-rise,
.motion .motion-fade,
.motion .motion-grow {
animation-fill-mode: backwards;
animation-timing-function: ${MOTION_EASING};
animation-duration: var(--mud, 0.5s);
animation-delay: var(--md, 0s);
}
.motion .motion-sweep { animation-name: motion-sweep; transform-origin: left center; }
.motion .motion-rise { animation-name: motion-rise; }
.motion .motion-fade { animation-name: motion-fade; }
.motion .motion-grow { animation-name: motion-grow; transform-origin: bottom center; }`