226 lines
8.7 KiB
TypeScript

import { SEQUENCE, STATES, STATE_BY_ID, type StateId } from './states'
/**
* Un cycle est un montage : une suite de blocs, chacun un etat tenu pendant une
* duree choisie. C'est la partie "editeur" du dossier, et elle en garde les
* regles — donnees pures, aucune horloge, aucun import Vue : le meme cycle doit
* pouvoir etre relu par les tests, par le lecteur et par la timeline.
*
* Un bloc n'a pas d'identifiant : c'est une position dans une liste, la cle de
* rendu est l'index. Ca garde le JSON du localStorage lisible et les tests
* deterministes.
*/
export interface Block {
state: StateId
duration: number
}
export interface Cycle {
id: string
name: string
blocks: Block[]
}
/**
* Plancher commun a tous les blocs. Le moteur ne garde qu'une case d'historique
* (`BotEngine.setState` ecrase `prev`), donc un bloc plus court que le fondu d'entree du
* bloc suivant saute a l'image au lieu de se fondre.
*
* DERIVE du catalogue et non ecrit a la main. La valeur etait 0,6, ce qui marchait
* uniquement parce que 0,6 se trouvait etre le plus long `morph` du catalogue — celui
* d'`orbit`. Rien ne le garantissait : ajouter un etat qui morphe en 0,8 s aurait fait
* trembler l'editeur sans qu'aucun test ne bronche. Maintenant le plancher suit.
*/
export const MIN_BLOCK = Math.max(...STATES.map((s) => s.morph))
/**
* Garde-fou d'editeur, pas une mesure : allonger un bloc est sans risque (les
* etats saturent leurs rampes et tiennent leur pose finale), mais une piste de
* blocs d'une minute n'est plus lisible.
*/
export const MAX_BLOCK = 10
/**
* Combien de blocs et de montages on accepte, a l'edition comme a la relecture.
*
* Ce ne sont pas des limites de produit mais des bornes contre un stockage hostile, qui
* est modifiable et tient quelques megaoctets alors que rien en aval n'est dimensionne
* pour ca : un seul cycle de 150 000 blocs, soit environ 4 Mo de JSON, donne 1 500 000 s
* de duree, autant de graduations a allouer et une piste de 29 700 000 px de large.
* L'onglet figeait en entrant dans la vue Animations.
*
* 200 blocs font une demi-heure de montage, largement au-dela de tout usage.
*/
export const MAX_BLOCS = 200
export const MAX_CYCLES = 50
/** Pas de la molette et du redimensionnement, en secondes. */
export const STEP = 0.1
const DEFAULT_CYCLE_ID = 'defaut'
/** Duree minimale d'un bloc : le plancher moteur, ou la mesure de l'etat. */
export function minDurationOf(state: StateId): number {
return Math.max(MIN_BLOCK, STATE_BY_ID.get(state)?.minDuration ?? MIN_BLOCK)
}
/** Ramene une duree dans ses bornes et sur le pas, sans trainee de flottants. */
export function clampDuration(state: StateId, seconds: number): number {
const snapped = Math.round(seconds / STEP) * STEP
const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped))
return Math.round(bounded * 100) / 100
}
export function makeBlock(state: StateId): Block {
// la duree de reference est celle relevee sur la video pour cet etat
return { state, duration: clampDuration(state, STATE_BY_ID.get(state)?.duration ?? 2) }
}
/**
* Le montage releve sur la video : l'ordre de `SEQUENCE`, chaque etat tenu sa
* duree mesuree. Il sert d'amorce au premier lancement, puis il appartient a
* l'utilisateur — il s'edite et se stocke comme les autres. La reference, elle,
* reste dans le code : vider le stockage la fait revenir.
*/
export function defaultCycle(): Cycle {
return {
/**
* Nom vide = « jamais nomme par l'utilisateur », donc affiche dans la langue
* courante. Ecrire ici « Cycle par defaut » l'aurait fige : le nom part au
* localStorage des la premiere visite et redevient une donnee utilisateur,
* que changer de langue ne retraduirait plus.
*/
name: '',
id: DEFAULT_CYCLE_ID,
blocks: SEQUENCE.map(makeBlock)
}
}
export function totalDuration(blocks: Block[]): number {
return blocks.reduce((sum, b) => sum + b.duration, 0)
}
/** Date de debut d'un bloc dans le montage. */
export function offsetOf(blocks: Block[], index: number): number {
let acc = 0
for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration
return acc
}
/**
* Bloc joue a la date `t` et temps ecoule dedans. Au-dela du dernier bloc on
* retombe au debut : la lecture boucle. L'appelant verifie que le montage n'est
* pas vide.
*/
export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } {
const total = totalDuration(blocks)
if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 }
// le modulo n'est applique que s'il sert : sur une date deja dans le cycle il
// n'ajouterait qu'une trainee de flottants au temps ecoule
const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total
let acc = 0
for (let i = 0; i < blocks.length; i++) {
const end = acc + blocks[i]!.duration
if (wrapped < end) return { index: i, elapsed: wrapped - acc }
acc = end
}
return { index: blocks.length - 1, elapsed: 0 }
}
/**
* Ajoute une animation a la fin du montage (palette de droite ou carte « + »).
*
* Plafonnee a `MAX_BLOCS`, comme la relecture. Sans ca l'editeur laissait construire un
* montage plus grand que ce que le stockage rend au rechargement, et le travail
* disparaissait en silence — une borne de relecture qui n'est pas aussi une borne d'edition
* est un piege, pas une protection.
*/
export function blocksWith(blocks: Block[], state: StateId): Block[] {
if (blocks.length >= MAX_BLOCS) return blocks
return [...blocks, makeBlock(state)]
}
/** Deplace un bloc, en rendant une nouvelle liste (les etats Vue sont remplaces). */
export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
const next = blocks.slice()
const [moved] = next.splice(from, 1)
if (!moved) return blocks
next.splice(Math.min(Math.max(to, 0), next.length), 0, moved)
return next
}
/** `Mon cycle`, `Mon cycle 2`, `Mon cycle 3`... — jamais deux fois le meme nom. */
export function uniqueName(base: string, cycles: Cycle[]): string {
const taken = new Set(cycles.map((c) => c.name))
if (!taken.has(base)) return base
let n = 2
while (taken.has(`${base} ${n}`)) n++
return `${base} ${n}`
}
/** Identifiant sans collision, y compris avec un localStorage bricole a la main. */
export function nextCycleId(cycles: Cycle[]): string {
const taken = new Set(cycles.map((c) => c.id))
let n = 1
while (taken.has(`c${n}`)) n++
return `c${n}`
}
/* ------------------------------------------------------- lecture du stockage */
function parseBlock(raw: unknown): Block | null {
if (typeof raw !== 'object' || raw === null) return null
const { state, duration } = raw as { state?: unknown; duration?: unknown }
/*
* Valide contre SEQUENCE et non contre `STATE_BY_ID` : ce dernier contient `swirl`, qui
* est deliberement hors du catalogue — c'est la transition d'entree des reglages, un
* test la verrouille hors de la palette et de la planche. Un montage utilisateur ne se
* construit qu'a partir de la palette, donc un `swirl` ne peut y arriver que par un
* stockage bricole a la main, et il n'y a aucune raison de l'y tolerer quand on l'exclut
* partout ailleurs.
*/
if (typeof state !== 'string' || !SEQUENCE.includes(state as StateId)) return null
if (typeof duration !== 'number' || !Number.isFinite(duration)) return null
return { state: state as StateId, duration: clampDuration(state as StateId, duration) }
}
function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null {
if (typeof raw !== 'object' || raw === null) return null
const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown }
if (typeof id !== 'string' || !id) return null
// le nom peut etre vide — c'est le montage d'amorce, qui suit la langue
if (typeof name !== 'string') return null
if (!Array.isArray(blocks)) return null
// on tronque AVANT de relire : valider 150 000 blocs pour n'en garder que 200 serait
// faire le travail qu'on cherche justement a eviter
const kept = blocks
.slice(0, MAX_BLOCS)
.map(parseBlock)
.filter((b): b is Block => b !== null)
if (!kept.length) return null
if (seen.some((c) => c.id === id)) return null
return { id, name, blocks: kept }
}
/**
* Le localStorage est modifiable a la main : on ne lui fait pas confiance, meme
* regle que pour le hash de l'URL. Tout ce qui ne se relit pas est jete
* silencieusement plutot que de casser l'application au demarrage.
*/
export function parseCycles(raw: string | null): Cycle[] {
if (!raw) return []
let data: unknown
try {
data = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(data)) return []
const out: Cycle[] = []
for (const item of data.slice(0, MAX_CYCLES)) {
const cycle = parseCycle(item, out)
if (cycle) out.push(cycle)
}
return out
}