@springbrand/message-panel 0.1.3-alpha.9 → 0.2.0-alpha.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cloud-os/README.md +4 -3
- package/cloud-os/assets/followup-arrow.svg +3 -0
- package/cloud-os/assets/loading-corner.svg +3 -0
- package/cloud-os/assets/loading-mark.svg +16 -0
- package/cloud-os/assets/loading-spark.svg +3 -0
- package/cloud-os/assets/model-selected.svg +5 -0
- package/cloud-os/assets/thinking-star.svg +9 -0
- package/cloud-os/capability-chip.tsx +1 -1
- package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
- package/cloud-os/chat/code-runner.tsx +106 -0
- package/cloud-os/chat/image-generation.tsx +76 -0
- package/cloud-os/chat/loading-state.tsx +25 -0
- package/cloud-os/chat/markdown-message.tsx +144 -41
- package/cloud-os/chat/range.ts +8 -0
- package/cloud-os/chat/rich-blocks.tsx +285 -227
- package/cloud-os/chat/surfaces.tsx +90 -0
- package/cloud-os/chat/tool-call.tsx +94 -0
- package/cloud-os/chat/tool-presentation.ts +31 -22
- package/cloud-os/chat/tool-rows.tsx +195 -143
- package/cloud-os/chat/tool-timeline.tsx +123 -0
- package/cloud-os/chat/transcript-model.ts +258 -20
- package/cloud-os/chat/web-search.tsx +135 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +29 -88
- package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
- package/cloud-os/file-view.tsx +195 -12
- package/cloud-os/index.ts +24 -3
- package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
- package/cloud-os/primitives/collapsible.tsx +21 -0
- package/cloud-os/primitives/workshop-controls.tsx +2 -2
- package/cloud-os/styles/cloud-os.css +114 -1
- package/demo/camel-chat-showcase.tsx +21 -13
- package/demo/chat-scenarios.ts +214 -40
- package/demo/cloud-os-chat-showcase.tsx +37 -14
- package/demo/fixtures.ts +4 -5
- package/demo/message-panel-gallery.tsx +16 -2
- package/package.json +8 -4
- package/springbrand-pet/LICENSE.bloub +21 -0
- package/springbrand-pet/bot/cycles.test.ts +221 -0
- package/springbrand-pet/bot/cycles.ts +225 -0
- package/springbrand-pet/bot/decor.ts +285 -0
- package/springbrand-pet/bot/engine.test.ts +543 -0
- package/springbrand-pet/bot/engine.ts +558 -0
- package/springbrand-pet/bot/expressions.test.ts +135 -0
- package/springbrand-pet/bot/expressions.ts +192 -0
- package/springbrand-pet/bot/eyefit.ts +455 -0
- package/springbrand-pet/bot/face.test.ts +101 -0
- package/springbrand-pet/bot/face.ts +179 -0
- package/springbrand-pet/bot/math.ts +42 -0
- package/springbrand-pet/bot/profiles.ts +22 -0
- package/springbrand-pet/bot/repere.ts +31 -0
- package/springbrand-pet/bot/shape.test.ts +97 -0
- package/springbrand-pet/bot/shape.ts +310 -0
- package/springbrand-pet/bot/skins.test.ts +325 -0
- package/springbrand-pet/bot/skins.ts +161 -0
- package/springbrand-pet/bot/states.ts +616 -0
- package/springbrand-pet/drag.test.ts +19 -0
- package/springbrand-pet/index.tsx +569 -0
- package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/springbrand-pet/vitest.config.ts +9 -0
- package/src/camel/camel-chat-messages.tsx +78 -78
- package/src/camel/camel-tool-presentation.tsx +19 -15
- package/src/camel/camel-turn.ts +1 -17
- package/src/composer/composer-attachments.tsx +1 -1
- package/src/composer/composer.tsx +2 -2
- package/src/contracts.ts +0 -2
- package/src/message-panel.tsx +1 -24
- package/src/parts/index.tsx +103 -64
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
MAX_BLOCK,
|
|
4
|
+
MAX_BLOCS,
|
|
5
|
+
MAX_CYCLES,
|
|
6
|
+
MIN_BLOCK,
|
|
7
|
+
blockAt,
|
|
8
|
+
blocksWith,
|
|
9
|
+
clampDuration,
|
|
10
|
+
defaultCycle,
|
|
11
|
+
makeBlock,
|
|
12
|
+
minDurationOf,
|
|
13
|
+
moveBlock,
|
|
14
|
+
nextCycleId,
|
|
15
|
+
parseCycles,
|
|
16
|
+
totalDuration,
|
|
17
|
+
type Cycle,
|
|
18
|
+
uniqueName
|
|
19
|
+
} from './cycles'
|
|
20
|
+
import { SEQUENCE, STATES, STATE_BY_ID } from './states'
|
|
21
|
+
|
|
22
|
+
describe('cycle par defaut', () => {
|
|
23
|
+
it('reprend la sequence relevee sur la video, dans l ordre', () => {
|
|
24
|
+
expect(defaultCycle().blocks.map((b) => b.state)).toEqual(SEQUENCE)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('tient chaque etat sa duree mesuree', () => {
|
|
28
|
+
for (const block of defaultCycle().blocks) {
|
|
29
|
+
expect(block.duration).toBe(STATE_BY_ID.get(block.state)!.duration)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('est reconstruit a l identique a chaque appel', () => {
|
|
34
|
+
expect(defaultCycle()).toEqual(defaultCycle())
|
|
35
|
+
// ...sans partager d'objet, sinon editer un montage toucherait l amorce
|
|
36
|
+
expect(defaultCycle().blocks[0]).not.toBe(defaultCycle().blocks[0])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('laisse toujours de la place pour un nouveau cycle, sans collision', () => {
|
|
40
|
+
const reference = defaultCycle()
|
|
41
|
+
const un: Cycle = {
|
|
42
|
+
id: nextCycleId([reference]),
|
|
43
|
+
name: uniqueName('Mon cycle', [reference]),
|
|
44
|
+
blocks: []
|
|
45
|
+
}
|
|
46
|
+
const deux: Cycle = {
|
|
47
|
+
id: nextCycleId([reference, un]),
|
|
48
|
+
name: uniqueName('Mon cycle', [reference, un]),
|
|
49
|
+
blocks: []
|
|
50
|
+
}
|
|
51
|
+
expect(un.id).not.toBe(reference.id)
|
|
52
|
+
expect(deux.id).not.toBe(un.id)
|
|
53
|
+
expect(deux.name).not.toBe(un.name)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('durees', () => {
|
|
58
|
+
it('ne descend pas sous le plancher du moteur', () => {
|
|
59
|
+
// en dessous, le bloc est plus court que le fondu d entree du suivant
|
|
60
|
+
expect(clampDuration('idle', 0.1)).toBe(MIN_BLOCK)
|
|
61
|
+
expect(clampDuration('idle', -5)).toBe(MIN_BLOCK)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('respecte la mesure des etats qui ont besoin d aboutir', () => {
|
|
65
|
+
// le "!" revient a 2.0, le corps se recompose a 2.4
|
|
66
|
+
expect(minDurationOf('alert')).toBe(2)
|
|
67
|
+
expect(minDurationOf('burst')).toBe(2.4)
|
|
68
|
+
expect(clampDuration('orbit', 1)).toBe(2.5)
|
|
69
|
+
// un etat qui ignore le temps n a que le plancher
|
|
70
|
+
expect(minDurationOf('idle')).toBe(MIN_BLOCK)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('n autorise aucun etat a descendre sous sa mesure', () => {
|
|
74
|
+
for (const state of SEQUENCE) {
|
|
75
|
+
expect(clampDuration(state, 0)).toBeGreaterThanOrEqual(minDurationOf(state))
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('plafonne et tombe sur le pas, sans trainee de flottants', () => {
|
|
80
|
+
expect(clampDuration('idle', 999)).toBe(MAX_BLOCK)
|
|
81
|
+
expect(clampDuration('idle', 2.44)).toBe(2.4)
|
|
82
|
+
expect(clampDuration('idle', 2.46)).toBe(2.5)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('lecture', () => {
|
|
87
|
+
const cycle: Cycle = {
|
|
88
|
+
id: 'c1',
|
|
89
|
+
name: 'Test',
|
|
90
|
+
blocks: [
|
|
91
|
+
{ state: 'idle', duration: 2 },
|
|
92
|
+
{ state: 'wink', duration: 1 },
|
|
93
|
+
{ state: 'egg', duration: 3 }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it('additionne les blocs', () => {
|
|
98
|
+
expect(totalDuration(cycle.blocks)).toBe(6)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('trouve le bloc joue et le temps ecoule dedans', () => {
|
|
102
|
+
expect(blockAt(cycle.blocks, 0)).toEqual({ index: 0, elapsed: 0 })
|
|
103
|
+
expect(blockAt(cycle.blocks, 1.9)).toEqual({ index: 0, elapsed: 1.9 })
|
|
104
|
+
// la borne appartient au bloc suivant
|
|
105
|
+
expect(blockAt(cycle.blocks, 2)).toEqual({ index: 1, elapsed: 0 })
|
|
106
|
+
expect(blockAt(cycle.blocks, 3.5)).toEqual({ index: 2, elapsed: 0.5 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('boucle au-dela du dernier bloc', () => {
|
|
110
|
+
expect(blockAt(cycle.blocks, 6)).toEqual({ index: 0, elapsed: 0 })
|
|
111
|
+
expect(blockAt(cycle.blocks, 8)).toEqual({ index: 1, elapsed: 0 })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('ne casse pas sur un cycle vide', () => {
|
|
115
|
+
expect(blockAt([], 3)).toEqual({ index: 0, elapsed: 0 })
|
|
116
|
+
expect(totalDuration([])).toBe(0)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('deplace un bloc sans toucher la liste d origine', () => {
|
|
120
|
+
const blocks = cycle.blocks
|
|
121
|
+
expect(moveBlock(blocks, 0, 2).map((b) => b.state)).toEqual(['wink', 'egg', 'idle'])
|
|
122
|
+
expect(moveBlock(blocks, 2, 0).map((b) => b.state)).toEqual(['egg', 'idle', 'wink'])
|
|
123
|
+
expect(blocks.map((b) => b.state)).toEqual(['idle', 'wink', 'egg'])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('relecture du stockage', () => {
|
|
128
|
+
it('ne casse pas sur du vide ou du JSON invalide', () => {
|
|
129
|
+
expect(parseCycles(null)).toEqual([])
|
|
130
|
+
expect(parseCycles('')).toEqual([])
|
|
131
|
+
expect(parseCycles('{pas du json')).toEqual([])
|
|
132
|
+
expect(parseCycles('{"id":"c1"}')).toEqual([])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('jette les blocs dont l etat n existe plus', () => {
|
|
136
|
+
const raw = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":2},' +
|
|
137
|
+
'{"state":"disparu","duration":2}]}]'
|
|
138
|
+
expect(parseCycles(raw)[0]!.blocks.map((b) => b.state)).toEqual(['idle'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('ramene les durees aberrantes dans leurs bornes', () => {
|
|
142
|
+
const raw = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":-4},' +
|
|
143
|
+
'{"state":"egg","duration":9999}]}]'
|
|
144
|
+
expect(parseCycles(raw)[0]!.blocks.map((b) => b.duration)).toEqual([MIN_BLOCK, MAX_BLOCK])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('jette un cycle vide, sans nom, ou en double', () => {
|
|
148
|
+
expect(parseCycles('[{"id":"c1","name":"A","blocks":[]}]')).toEqual([])
|
|
149
|
+
expect(parseCycles('[{"id":"c1","blocks":[{"state":"idle","duration":2}]}]')).toEqual([])
|
|
150
|
+
const doublon = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":2}]},' +
|
|
151
|
+
'{"id":"c1","name":"B","blocks":[{"state":"egg","duration":2}]}]'
|
|
152
|
+
expect(parseCycles(doublon).map((c) => c.name)).toEqual(['A'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('ne garde que les champs du modele, pas ce qu on lui glisse en plus', () => {
|
|
156
|
+
const raw = '[{"id":"defaut","name":"Mon montage","locked":true,"secret":1,' +
|
|
157
|
+
'"blocks":[{"state":"idle","duration":2,"vitesse":3}]}]'
|
|
158
|
+
const cycle = parseCycles(raw)[0]!
|
|
159
|
+
expect(Object.keys(cycle).sort()).toEqual(['blocks', 'id', 'name'])
|
|
160
|
+
expect(Object.keys(cycle.blocks[0]!).sort()).toEqual(['duration', 'state'])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* Le stockage est modifiable et tient quelques megaoctets, alors que rien en aval n'est
|
|
165
|
+
* dimensionne pour ca. Un seul cycle de 150 000 blocs — environ 4 Mo de JSON, donc dans
|
|
166
|
+
* le budget — donnait 1 500 000 s de duree, autant de graduations a allouer et une piste
|
|
167
|
+
* de 29 700 000 px : l'onglet figeait en entrant dans la vue Animations.
|
|
168
|
+
*/
|
|
169
|
+
it('borne la taille d un montage relu', () => {
|
|
170
|
+
const blocs = Array.from({ length: 200_000 }, () => ({ state: 'idle', duration: 10 }))
|
|
171
|
+
const raw = JSON.stringify([{ id: 'c1', name: 'A', blocks: blocs }])
|
|
172
|
+
expect(parseCycles(raw)[0]!.blocks).toHaveLength(MAX_BLOCS)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
/*
|
|
176
|
+
* Le plancher est DERIVE du plus long `morph`, il n'est plus ecrit a la main. Ce test
|
|
177
|
+
* garde le lien visible : il valait 0,6 en dur, ce qui ne marchait que parce que 0,6
|
|
178
|
+
* etait justement le morph d'`orbit`. Un etat qui morphe plus lentement le suit.
|
|
179
|
+
*/
|
|
180
|
+
it('le plancher de bloc couvre le plus long fondu du catalogue', () => {
|
|
181
|
+
const plusLong = Math.max(...STATES.map((s) => s.morph))
|
|
182
|
+
expect(MIN_BLOCK).toBeGreaterThanOrEqual(plusLong)
|
|
183
|
+
// et il n'est pas gratuitement plus grand : c'est exactement ce fondu
|
|
184
|
+
expect(MIN_BLOCK).toBe(plusLong)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('borne aussi l ajout depuis l editeur, pas seulement la relecture', () => {
|
|
188
|
+
let blocs = Array.from({ length: MAX_BLOCS }, () => makeBlock('idle'))
|
|
189
|
+
expect(blocksWith(blocs, 'egg')).toHaveLength(MAX_BLOCS)
|
|
190
|
+
// et il reste possible d'ajouter juste en dessous de la borne
|
|
191
|
+
blocs = blocs.slice(0, MAX_BLOCS - 1)
|
|
192
|
+
expect(blocksWith(blocs, 'egg')).toHaveLength(MAX_BLOCS)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('borne le nombre de montages relus', () => {
|
|
196
|
+
const raw = JSON.stringify(
|
|
197
|
+
Array.from({ length: 5000 }, (_, i) => ({
|
|
198
|
+
id: `c${i}`,
|
|
199
|
+
name: `A${i}`,
|
|
200
|
+
blocks: [{ state: 'idle', duration: 2 }]
|
|
201
|
+
}))
|
|
202
|
+
)
|
|
203
|
+
expect(parseCycles(raw)).toHaveLength(MAX_CYCLES)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/*
|
|
207
|
+
* `swirl` est la transition d'entree des reglages, deliberement hors de `SEQUENCE` : un
|
|
208
|
+
* test la garde hors de la palette et de la planche. Un montage utilisateur ne se
|
|
209
|
+
* construit qu'a partir de la palette, donc elle ne peut arriver ici que par un stockage
|
|
210
|
+
* bricole a la main — et on l'y refuse comme partout ailleurs.
|
|
211
|
+
*/
|
|
212
|
+
it('refuse un etat hors catalogue, `swirl` compris', () => {
|
|
213
|
+
const raw = '[{"id":"c1","name":"A","blocks":[{"state":"swirl","duration":2},' +
|
|
214
|
+
'{"state":"idle","duration":2}]}]'
|
|
215
|
+
expect(parseCycles(raw)[0]!.blocks.map((b) => b.state)).toEqual(['idle'])
|
|
216
|
+
// un montage qui n'en contiendrait QUE devient vide, donc tombe
|
|
217
|
+
expect(parseCycles('[{"id":"c1","name":"A","blocks":[{"state":"swirl","duration":2}]}]')).toEqual(
|
|
218
|
+
[]
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { SEQUENCE, STATES, STATE_BY_ID, type StateId } from './states'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Un cycle est un montage : une suite de blocs, chacun un etat tenu pendant une
|
|
5
|
+
* duree choisie. C'est la partie "editeur" du dossier, et elle en garde les
|
|
6
|
+
* regles — donnees pures, aucune horloge, aucun import Vue : le meme cycle doit
|
|
7
|
+
* pouvoir etre relu par les tests, par le lecteur et par la timeline.
|
|
8
|
+
*
|
|
9
|
+
* Un bloc n'a pas d'identifiant : c'est une position dans une liste, la cle de
|
|
10
|
+
* rendu est l'index. Ca garde le JSON du localStorage lisible et les tests
|
|
11
|
+
* deterministes.
|
|
12
|
+
*/
|
|
13
|
+
export interface Block {
|
|
14
|
+
state: StateId
|
|
15
|
+
duration: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Cycle {
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
blocks: Block[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Plancher commun a tous les blocs. Le moteur ne garde qu'une case d'historique
|
|
26
|
+
* (`BotEngine.setState` ecrase `prev`), donc un bloc plus court que le fondu d'entree du
|
|
27
|
+
* bloc suivant saute a l'image au lieu de se fondre.
|
|
28
|
+
*
|
|
29
|
+
* DERIVE du catalogue et non ecrit a la main. La valeur etait 0,6, ce qui marchait
|
|
30
|
+
* uniquement parce que 0,6 se trouvait etre le plus long `morph` du catalogue — celui
|
|
31
|
+
* d'`orbit`. Rien ne le garantissait : ajouter un etat qui morphe en 0,8 s aurait fait
|
|
32
|
+
* trembler l'editeur sans qu'aucun test ne bronche. Maintenant le plancher suit.
|
|
33
|
+
*/
|
|
34
|
+
export const MIN_BLOCK = Math.max(...STATES.map((s) => s.morph))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Garde-fou d'editeur, pas une mesure : allonger un bloc est sans risque (les
|
|
38
|
+
* etats saturent leurs rampes et tiennent leur pose finale), mais une piste de
|
|
39
|
+
* blocs d'une minute n'est plus lisible.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_BLOCK = 10
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Combien de blocs et de montages on accepte, a l'edition comme a la relecture.
|
|
45
|
+
*
|
|
46
|
+
* Ce ne sont pas des limites de produit mais des bornes contre un stockage hostile, qui
|
|
47
|
+
* est modifiable et tient quelques megaoctets alors que rien en aval n'est dimensionne
|
|
48
|
+
* pour ca : un seul cycle de 150 000 blocs, soit environ 4 Mo de JSON, donne 1 500 000 s
|
|
49
|
+
* de duree, autant de graduations a allouer et une piste de 29 700 000 px de large.
|
|
50
|
+
* L'onglet figeait en entrant dans la vue Animations.
|
|
51
|
+
*
|
|
52
|
+
* 200 blocs font une demi-heure de montage, largement au-dela de tout usage.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_BLOCS = 200
|
|
55
|
+
export const MAX_CYCLES = 50
|
|
56
|
+
|
|
57
|
+
/** Pas de la molette et du redimensionnement, en secondes. */
|
|
58
|
+
export const STEP = 0.1
|
|
59
|
+
|
|
60
|
+
const DEFAULT_CYCLE_ID = 'defaut'
|
|
61
|
+
|
|
62
|
+
/** Duree minimale d'un bloc : le plancher moteur, ou la mesure de l'etat. */
|
|
63
|
+
export function minDurationOf(state: StateId): number {
|
|
64
|
+
return Math.max(MIN_BLOCK, STATE_BY_ID.get(state)?.minDuration ?? MIN_BLOCK)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ramene une duree dans ses bornes et sur le pas, sans trainee de flottants. */
|
|
68
|
+
export function clampDuration(state: StateId, seconds: number): number {
|
|
69
|
+
const snapped = Math.round(seconds / STEP) * STEP
|
|
70
|
+
const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped))
|
|
71
|
+
return Math.round(bounded * 100) / 100
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function makeBlock(state: StateId): Block {
|
|
75
|
+
// la duree de reference est celle relevee sur la video pour cet etat
|
|
76
|
+
return { state, duration: clampDuration(state, STATE_BY_ID.get(state)?.duration ?? 2) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Le montage releve sur la video : l'ordre de `SEQUENCE`, chaque etat tenu sa
|
|
81
|
+
* duree mesuree. Il sert d'amorce au premier lancement, puis il appartient a
|
|
82
|
+
* l'utilisateur — il s'edite et se stocke comme les autres. La reference, elle,
|
|
83
|
+
* reste dans le code : vider le stockage la fait revenir.
|
|
84
|
+
*/
|
|
85
|
+
export function defaultCycle(): Cycle {
|
|
86
|
+
return {
|
|
87
|
+
/**
|
|
88
|
+
* Nom vide = « jamais nomme par l'utilisateur », donc affiche dans la langue
|
|
89
|
+
* courante. Ecrire ici « Cycle par defaut » l'aurait fige : le nom part au
|
|
90
|
+
* localStorage des la premiere visite et redevient une donnee utilisateur,
|
|
91
|
+
* que changer de langue ne retraduirait plus.
|
|
92
|
+
*/
|
|
93
|
+
name: '',
|
|
94
|
+
id: DEFAULT_CYCLE_ID,
|
|
95
|
+
blocks: SEQUENCE.map(makeBlock)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function totalDuration(blocks: Block[]): number {
|
|
100
|
+
return blocks.reduce((sum, b) => sum + b.duration, 0)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Date de debut d'un bloc dans le montage. */
|
|
104
|
+
export function offsetOf(blocks: Block[], index: number): number {
|
|
105
|
+
let acc = 0
|
|
106
|
+
for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration
|
|
107
|
+
return acc
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Bloc joue a la date `t` et temps ecoule dedans. Au-dela du dernier bloc on
|
|
112
|
+
* retombe au debut : la lecture boucle. L'appelant verifie que le montage n'est
|
|
113
|
+
* pas vide.
|
|
114
|
+
*/
|
|
115
|
+
export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } {
|
|
116
|
+
const total = totalDuration(blocks)
|
|
117
|
+
if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 }
|
|
118
|
+
// le modulo n'est applique que s'il sert : sur une date deja dans le cycle il
|
|
119
|
+
// n'ajouterait qu'une trainee de flottants au temps ecoule
|
|
120
|
+
const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total
|
|
121
|
+
let acc = 0
|
|
122
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
123
|
+
const end = acc + blocks[i]!.duration
|
|
124
|
+
if (wrapped < end) return { index: i, elapsed: wrapped - acc }
|
|
125
|
+
acc = end
|
|
126
|
+
}
|
|
127
|
+
return { index: blocks.length - 1, elapsed: 0 }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ajoute une animation a la fin du montage (palette de droite ou carte « + »).
|
|
132
|
+
*
|
|
133
|
+
* Plafonnee a `MAX_BLOCS`, comme la relecture. Sans ca l'editeur laissait construire un
|
|
134
|
+
* montage plus grand que ce que le stockage rend au rechargement, et le travail
|
|
135
|
+
* disparaissait en silence — une borne de relecture qui n'est pas aussi une borne d'edition
|
|
136
|
+
* est un piege, pas une protection.
|
|
137
|
+
*/
|
|
138
|
+
export function blocksWith(blocks: Block[], state: StateId): Block[] {
|
|
139
|
+
if (blocks.length >= MAX_BLOCS) return blocks
|
|
140
|
+
return [...blocks, makeBlock(state)]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Deplace un bloc, en rendant une nouvelle liste (les etats Vue sont remplaces). */
|
|
144
|
+
export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
|
|
145
|
+
const next = blocks.slice()
|
|
146
|
+
const [moved] = next.splice(from, 1)
|
|
147
|
+
if (!moved) return blocks
|
|
148
|
+
next.splice(Math.min(Math.max(to, 0), next.length), 0, moved)
|
|
149
|
+
return next
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `Mon cycle`, `Mon cycle 2`, `Mon cycle 3`... — jamais deux fois le meme nom. */
|
|
153
|
+
export function uniqueName(base: string, cycles: Cycle[]): string {
|
|
154
|
+
const taken = new Set(cycles.map((c) => c.name))
|
|
155
|
+
if (!taken.has(base)) return base
|
|
156
|
+
let n = 2
|
|
157
|
+
while (taken.has(`${base} ${n}`)) n++
|
|
158
|
+
return `${base} ${n}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Identifiant sans collision, y compris avec un localStorage bricole a la main. */
|
|
162
|
+
export function nextCycleId(cycles: Cycle[]): string {
|
|
163
|
+
const taken = new Set(cycles.map((c) => c.id))
|
|
164
|
+
let n = 1
|
|
165
|
+
while (taken.has(`c${n}`)) n++
|
|
166
|
+
return `c${n}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------- lecture du stockage */
|
|
170
|
+
|
|
171
|
+
function parseBlock(raw: unknown): Block | null {
|
|
172
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
173
|
+
const { state, duration } = raw as { state?: unknown; duration?: unknown }
|
|
174
|
+
/*
|
|
175
|
+
* Valide contre SEQUENCE et non contre `STATE_BY_ID` : ce dernier contient `swirl`, qui
|
|
176
|
+
* est deliberement hors du catalogue — c'est la transition d'entree des reglages, un
|
|
177
|
+
* test la verrouille hors de la palette et de la planche. Un montage utilisateur ne se
|
|
178
|
+
* construit qu'a partir de la palette, donc un `swirl` ne peut y arriver que par un
|
|
179
|
+
* stockage bricole a la main, et il n'y a aucune raison de l'y tolerer quand on l'exclut
|
|
180
|
+
* partout ailleurs.
|
|
181
|
+
*/
|
|
182
|
+
if (typeof state !== 'string' || !SEQUENCE.includes(state as StateId)) return null
|
|
183
|
+
if (typeof duration !== 'number' || !Number.isFinite(duration)) return null
|
|
184
|
+
return { state: state as StateId, duration: clampDuration(state as StateId, duration) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null {
|
|
188
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
189
|
+
const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown }
|
|
190
|
+
if (typeof id !== 'string' || !id) return null
|
|
191
|
+
// le nom peut etre vide — c'est le montage d'amorce, qui suit la langue
|
|
192
|
+
if (typeof name !== 'string') return null
|
|
193
|
+
if (!Array.isArray(blocks)) return null
|
|
194
|
+
// on tronque AVANT de relire : valider 150 000 blocs pour n'en garder que 200 serait
|
|
195
|
+
// faire le travail qu'on cherche justement a eviter
|
|
196
|
+
const kept = blocks
|
|
197
|
+
.slice(0, MAX_BLOCS)
|
|
198
|
+
.map(parseBlock)
|
|
199
|
+
.filter((b): b is Block => b !== null)
|
|
200
|
+
if (!kept.length) return null
|
|
201
|
+
if (seen.some((c) => c.id === id)) return null
|
|
202
|
+
return { id, name, blocks: kept }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Le localStorage est modifiable a la main : on ne lui fait pas confiance, meme
|
|
207
|
+
* regle que pour le hash de l'URL. Tout ce qui ne se relit pas est jete
|
|
208
|
+
* silencieusement plutot que de casser l'application au demarrage.
|
|
209
|
+
*/
|
|
210
|
+
export function parseCycles(raw: string | null): Cycle[] {
|
|
211
|
+
if (!raw) return []
|
|
212
|
+
let data: unknown
|
|
213
|
+
try {
|
|
214
|
+
data = JSON.parse(raw)
|
|
215
|
+
} catch {
|
|
216
|
+
return []
|
|
217
|
+
}
|
|
218
|
+
if (!Array.isArray(data)) return []
|
|
219
|
+
const out: Cycle[] = []
|
|
220
|
+
for (const item of data.slice(0, MAX_CYCLES)) {
|
|
221
|
+
const cycle = parseCycle(item, out)
|
|
222
|
+
if (cycle) out.push(cycle)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|