@things-factory/figure-ui 10.1.6 → 10.1.8

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.
Files changed (51) hide show
  1. package/client/graphql/index.ts +25 -0
  2. package/client/modeller/figure-animations.ts +769 -0
  3. package/client/modeller/figure-ask.ts +6 -1
  4. package/client/modeller/figure-history-panel.ts +52 -1
  5. package/client/modeller/figure-inspector.ts +520 -120
  6. package/client/modeller/figure-side.ts +65 -17
  7. package/client/modeller/proposal-session.ts +92 -0
  8. package/client/pages/figure-list-page.ts +56 -4
  9. package/client/pages/figure-modeller-page.ts +204 -179
  10. package/client/types.ts +39 -0
  11. package/client/viewparts/figure-thumb.ts +78 -0
  12. package/dist-client/graphql/index.d.ts +3 -1
  13. package/dist-client/graphql/index.js +22 -0
  14. package/dist-client/graphql/index.js.map +1 -1
  15. package/dist-client/modeller/figure-animations.d.ts +96 -0
  16. package/dist-client/modeller/figure-animations.js +708 -0
  17. package/dist-client/modeller/figure-animations.js.map +1 -0
  18. package/dist-client/modeller/figure-ask.d.ts +3 -0
  19. package/dist-client/modeller/figure-ask.js +9 -0
  20. package/dist-client/modeller/figure-ask.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.d.ts +7 -0
  22. package/dist-client/modeller/figure-history-panel.js +50 -0
  23. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  24. package/dist-client/modeller/figure-inspector.d.ts +17 -0
  25. package/dist-client/modeller/figure-inspector.js +506 -107
  26. package/dist-client/modeller/figure-inspector.js.map +1 -1
  27. package/dist-client/modeller/figure-side.d.ts +23 -0
  28. package/dist-client/modeller/figure-side.js +68 -16
  29. package/dist-client/modeller/figure-side.js.map +1 -1
  30. package/dist-client/modeller/proposal-session.d.ts +23 -0
  31. package/dist-client/modeller/proposal-session.js +65 -0
  32. package/dist-client/modeller/proposal-session.js.map +1 -0
  33. package/dist-client/pages/figure-list-page.d.ts +10 -0
  34. package/dist-client/pages/figure-list-page.js +55 -4
  35. package/dist-client/pages/figure-list-page.js.map +1 -1
  36. package/dist-client/pages/figure-modeller-page.d.ts +50 -28
  37. package/dist-client/pages/figure-modeller-page.js +197 -176
  38. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  39. package/dist-client/tsconfig.tsbuildinfo +1 -1
  40. package/dist-client/types.d.ts +44 -0
  41. package/dist-client/types.js.map +1 -1
  42. package/dist-client/viewparts/figure-thumb.js +74 -0
  43. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  44. package/package.json +4 -5
  45. package/test/ai-proposal-contract.test.ts +169 -0
  46. package/test/i18n-prefix-guard.test.ts +42 -0
  47. package/translations/en.json +82 -6
  48. package/translations/ja.json +34 -1
  49. package/translations/ko.json +92 -6
  50. package/translations/ms.json +34 -1
  51. package/translations/zh.json +34 -1
@@ -0,0 +1,708 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { css, html, LitElement, nothing } from 'lit';
3
+ import { customElement, property, state } from 'lit/decorators.js';
4
+ import { live } from 'lit/directives/live.js';
5
+ import { i18next, localize } from '@operato/i18n';
6
+ import { ScrollbarStyles } from '@operato/styles';
7
+ import { AXES, CHANNEL_PATHS, CLIP_DRIVES, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model';
8
+ const MOTION_RECIPES = [
9
+ { id: 'conveyor', name: 'conveyor-running', drive: 'loop', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
10
+ { id: 'gate', name: 'gate-open', drive: 'hold', path: 'rotation', end: { x: 0, y: 90, z: 0 }, pivot: { x: 0, y: 0, z: 0 } },
11
+ { id: 'pusher', name: 'pusher-position', drive: 'hold', path: 'translation', end: { x: 100, y: 0, z: 0 } },
12
+ { id: 'lift', name: 'lift-height', drive: 'hold', path: 'translation', end: { x: 0, y: 100, z: 0 } }
13
+ ];
14
+ /** 새 clip 의 키 둘. **하나로 만들지 않는다** — 키 하나는 움직임이 아니라 자세다. */
15
+ const NEW_KEYS = [
16
+ { at: 0, value: { x: 0, y: 0, z: 0 } },
17
+ { at: 1, value: { x: 0, y: 0, z: 0 } }
18
+ ];
19
+ /** `scale` 만 1 에서 시작한다 — 0 은 부품을 지운다. */
20
+ function startValue(path) {
21
+ return path === 'scale' ? { x: 1, y: 1, z: 1 } : { x: 0, y: 0, z: 0 };
22
+ }
23
+ function keysFor(path) {
24
+ return NEW_KEYS.map(key => ({ at: key.at, value: startValue(path) }));
25
+ }
26
+ /** 이미 쓴 이름을 비켜 간다. `type` 처럼 저장되는 식별자는 아니지만 인스턴스가 이 이름으로 구동한다. */
27
+ function freeName(taken) {
28
+ const used = new Set(taken.map(clip => clip.name));
29
+ for (let n = 1;; n++) {
30
+ const name = `clip-${n}`;
31
+ if (!used.has(name))
32
+ return name;
33
+ }
34
+ }
35
+ function freeRecipeName(taken, base) {
36
+ const used = new Set(taken.map(clip => clip.name));
37
+ if (!used.has(base))
38
+ return base;
39
+ for (let n = 2;; n++)
40
+ if (!used.has(`${base}-${n}`))
41
+ return `${base}-${n}`;
42
+ }
43
+ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitElement) {
44
+ constructor() {
45
+ super(...arguments);
46
+ this.parts = [];
47
+ /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
48
+ this.open = 0;
49
+ this.recipeTarget = '';
50
+ }
51
+ static { this.styles = [
52
+ ScrollbarStyles,
53
+ css `
54
+ :host {
55
+ display: flex;
56
+ flex-direction: column;
57
+ min-height: 0;
58
+ overflow-y: auto;
59
+ background-color: var(--md-sys-color-surface-container-lowest);
60
+ color: var(--md-sys-color-on-surface);
61
+ font: var(--label-font, inherit);
62
+ font-size: 0.76rem;
63
+ }
64
+
65
+ section {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: var(--spacing-medium, 8px);
69
+ padding: var(--spacing-large, 12px);
70
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
71
+ }
72
+
73
+ /* 다른 탭들과 같은 눈금이다 */
74
+ h3 {
75
+ margin: 0 0 2px 0;
76
+ font-size: var(--ui-t-micro, 11px);
77
+ font-weight: 700;
78
+ letter-spacing: 0.04em;
79
+ text-transform: uppercase;
80
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
81
+ }
82
+
83
+ p[quiet] {
84
+ margin: 0;
85
+ font-size: 0.72rem;
86
+ line-height: 1.5;
87
+ color: var(--md-sys-color-on-surface-variant);
88
+ }
89
+
90
+ div[recipes] { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
91
+ button[recipe] { min-height: 54px; align-items: flex-start; flex-direction: column; text-align: left; }
92
+ button[recipe] strong { font-size: 0.74rem; }
93
+ button[recipe] span { color: var(--md-sys-color-on-surface-variant); font-size: 0.68rem; line-height: 1.35; }
94
+
95
+ /* ── clip ─────────────────────────────────────────────────────── */
96
+
97
+ div[clip] {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: var(--spacing-medium, 8px);
101
+ padding: 8px;
102
+ border: 1px solid var(--md-sys-color-outline-variant);
103
+ border-radius: 4px;
104
+ }
105
+ div[clip][on] {
106
+ border-color: var(--md-sys-color-primary);
107
+ background-color: var(--md-sys-color-surface-container-low);
108
+ }
109
+
110
+ div[head] {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 6px;
114
+ }
115
+ div[head] input[name] {
116
+ flex: 1;
117
+ min-width: 0;
118
+ }
119
+
120
+ /* ── channel ──────────────────────────────────────────────────── */
121
+
122
+ div[channel] {
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 6px;
126
+ padding: 6px 8px;
127
+ border-left: 2px solid var(--md-sys-color-outline-variant);
128
+ background-color: var(--md-sys-color-surface-container-lowest);
129
+ }
130
+ div[channel][orphan] {
131
+ border-left-color: var(--md-sys-color-error);
132
+ }
133
+
134
+ div[row] {
135
+ display: flex;
136
+ align-items: center;
137
+ gap: 6px;
138
+ }
139
+ div[row] > label {
140
+ flex: none;
141
+ min-width: 3.2em;
142
+ color: var(--md-sys-color-on-surface-variant);
143
+ }
144
+
145
+ /* ── keyframe ─────────────────────────────────────────────────── */
146
+
147
+ table[keys] {
148
+ width: 100%;
149
+ border-collapse: collapse;
150
+ }
151
+ table[keys] th {
152
+ padding: 0 2px 2px;
153
+ font-size: 0.68rem;
154
+ font-weight: 600;
155
+ text-align: left;
156
+ color: var(--md-sys-color-on-surface-variant);
157
+ }
158
+ table[keys] td {
159
+ padding: 1px 2px;
160
+ }
161
+ table[keys] input {
162
+ width: 100%;
163
+ min-width: 0;
164
+ }
165
+ table[keys] td[drop] {
166
+ width: 1.6em;
167
+ }
168
+
169
+ input,
170
+ select {
171
+ box-sizing: border-box;
172
+ padding: 3px 5px;
173
+ font: inherit;
174
+ font-size: 0.74rem;
175
+ color: var(--md-sys-color-on-surface);
176
+ background-color: var(--md-sys-color-surface-container-lowest);
177
+ border: 1px solid var(--md-sys-color-outline-variant);
178
+ border-radius: 3px;
179
+ }
180
+ input:focus,
181
+ select:focus {
182
+ outline: none;
183
+ border-color: var(--md-sys-color-primary);
184
+ }
185
+ input[bad] {
186
+ border-color: var(--md-sys-color-error);
187
+ }
188
+
189
+ button {
190
+ display: inline-flex;
191
+ align-items: center;
192
+ gap: 3px;
193
+ padding: 3px 8px;
194
+ font: inherit;
195
+ font-size: 0.72rem;
196
+ color: var(--md-sys-color-on-surface);
197
+ background-color: var(--md-sys-color-surface-container);
198
+ border: 1px solid var(--md-sys-color-outline-variant);
199
+ border-radius: 3px;
200
+ cursor: pointer;
201
+ }
202
+ button:hover:not(:disabled) {
203
+ background-color: var(--md-sys-color-surface-container-high);
204
+ }
205
+ button:disabled {
206
+ opacity: 0.4;
207
+ cursor: default;
208
+ }
209
+ button[plain] {
210
+ padding: 1px 4px;
211
+ background: none;
212
+ border: none;
213
+ color: var(--md-sys-color-on-surface-variant);
214
+ }
215
+ button[plain]:hover:not(:disabled) {
216
+ color: var(--md-sys-color-error);
217
+ background: none;
218
+ }
219
+ button md-icon {
220
+ font-size: 14px;
221
+ width: 14px;
222
+ height: 14px;
223
+ }
224
+
225
+ /* ── 예산 ─────────────────────────────────────────────────────── */
226
+
227
+ div[budget] {
228
+ display: flex;
229
+ align-items: baseline;
230
+ gap: 6px;
231
+ }
232
+ div[budget] strong {
233
+ font-size: 0.86rem;
234
+ font-weight: 700;
235
+ }
236
+ div[budget][over] strong {
237
+ color: var(--md-sys-color-error);
238
+ }
239
+
240
+ p[warn] {
241
+ margin: 0;
242
+ font-size: 0.7rem;
243
+ line-height: 1.5;
244
+ color: var(--md-sys-color-error);
245
+ }
246
+ `
247
+ ]; }
248
+ get clips() {
249
+ return this.board?.animations ?? [];
250
+ }
251
+ /** 부품 이름 — 채널이 이것을 가리킨다. */
252
+ get partNames() {
253
+ return this.parts.map(part => part.name).filter(name => !!name);
254
+ }
255
+ /**
256
+ * 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
257
+ *
258
+ * clip 수가 아니다. 한 부품을 세 clip 이 움직여도 그 부품 하나가 단독 묶음이 될 뿐이다.
259
+ * 청사진이 묶음을 가르는 기준과 같은 셈이어야 화면의 수와 판정의 수가 맞는다.
260
+ */
261
+ get movingParts() {
262
+ const moving = new Set();
263
+ for (const clip of this.clips)
264
+ for (const channel of clip.channels)
265
+ moving.add(channel.target);
266
+ return moving.size;
267
+ }
268
+ render() {
269
+ if (!this.board)
270
+ return html ``;
271
+ return html `
272
+ <section>
273
+ <h3>${i18next.t('figure.label.animation')}</h3>
274
+ <p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
275
+ </section>
276
+
277
+ ${this.renderRecipes()}
278
+ ${this.clips.length === 0 ? nothing : this.renderClips()}
279
+
280
+ <section>
281
+ <button @click=${() => this.addClip()} ?disabled=${this.partNames.length === 0}>
282
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-clip')}
283
+ </button>
284
+ ${this.partNames.length === 0
285
+ ? html `<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>`
286
+ : nothing}
287
+ </section>
288
+
289
+ ${this.clips.length === 0 ? nothing : this.renderBudget()}
290
+ `;
291
+ }
292
+ /**
293
+ * 빈 화면 — **이 탭이 무엇인지 가르치는 자리다.**
294
+ *
295
+ * 「clip 을 추가하세요」라고만 쓰면 처음 여는 사람은 clip 이 무엇인지 모른 채 단추를 누른다.
296
+ * 그래서 무엇을 만들 수 있는지 예를 들고, 그것이 어떻게 움직이는지(사실이 들어온다)를 적는다.
297
+ */
298
+ renderRecipes() {
299
+ return html `
300
+ <section>
301
+ <h3>${i18next.t('figure.label.motion-intent')}</h3>
302
+ <p quiet>${i18next.t('figure.text.motion-intent-help')}</p>
303
+ ${this.partNames.length
304
+ ? html `<select .value=${this.recipeTarget} @change=${(e) => (this.recipeTarget = e.target.value)}>
305
+ ${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
306
+ </select>
307
+ <div recipes>
308
+ ${MOTION_RECIPES.map(recipe => html `
309
+ <button recipe @click=${() => this.addRecipe(recipe)}>
310
+ <strong>${i18next.t('figure.label.recipe-' + recipe.id)}</strong>
311
+ <span>${i18next.t('figure.text.recipe-' + recipe.id)}</span>
312
+ </button>
313
+ `)}
314
+ </div>`
315
+ : html `<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>`}
316
+ </section>
317
+ `;
318
+ }
319
+ renderClips() {
320
+ return html `
321
+ <section>
322
+ ${this.clips.map((clip, at) => this.renderClip(clip, at))}
323
+ </section>
324
+ `;
325
+ }
326
+ renderClip(clip, at) {
327
+ const on = this.open === at;
328
+ const duplicate = this.clips.some((other, i) => i !== at && other.name === clip.name);
329
+ const nameless = !clip.name?.trim();
330
+ return html `
331
+ <div clip ?on=${on}>
332
+ <div head>
333
+ <button plain @click=${() => (this.open = on ? -1 : at)} title=${i18next.t('figure.label.channels')}>
334
+ <md-icon>${on ? 'expand_more' : 'chevron_right'}</md-icon>
335
+ </button>
336
+ <input
337
+ name
338
+ .value=${live(clip.name ?? '')}
339
+ ?bad=${duplicate || nameless}
340
+ placeholder=${i18next.t('figure.label.clip-name')}
341
+ title=${duplicate ? i18next.t('figure.text.clip-name-taken') : i18next.t('figure.text.clip-name-is-how-it-is-driven')}
342
+ @change=${(e) => this.renameClip(at, e.target.value)}
343
+ />
344
+ <select
345
+ .value=${live(clip.drive ?? 'loop')}
346
+ title=${i18next.t('figure.text.drive-explained')}
347
+ @change=${(e) => this.setDrive(at, e.target.value)}
348
+ >
349
+ ${CLIP_DRIVES.map(drive => html `<option value=${drive}>${i18next.t('figure.label.drive-' + drive)}</option>`)}
350
+ </select>
351
+ <button plain @click=${() => this.removeClip(at)} title=${i18next.t('figure.button.remove')}>
352
+ <md-icon>close</md-icon>
353
+ </button>
354
+ </div>
355
+
356
+ ${duplicate ? html `<p warn>${i18next.t('figure.text.clip-name-taken')}</p>` : nothing}
357
+ ${nameless ? html `<p warn>${i18next.t('figure.text.clip-needs-a-name')}</p>` : nothing}
358
+ ${on ? this.renderChannels(clip, at) : nothing}
359
+ </div>
360
+ `;
361
+ }
362
+ renderChannels(clip, at) {
363
+ return html `
364
+ <p quiet>${i18next.t('figure.text.drive-' + (clip.drive ?? 'loop') + '-note')}</p>
365
+
366
+ ${clip.channels.length === 0 ? html `<p warn>${i18next.t('figure.text.clip-needs-a-channel')}</p>` : nothing}
367
+ ${clip.channels.map((channel, j) => this.renderChannel(channel, at, j))}
368
+
369
+ <button @click=${() => this.addChannel(at)}>
370
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-channel')}
371
+ </button>
372
+ `;
373
+ }
374
+ /**
375
+ * 채널 하나 — 어느 부품의 어느 속성을 움직이나.
376
+ *
377
+ * 없는 부품을 가리키는 채널은 붉게 낸다. 부품 이름을 바꾸면 그 순간 이렇게 되는데,
378
+ * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
379
+ */
380
+ renderChannel(channel, at, j) {
381
+ const orphan = !this.partNames.includes(channel.target);
382
+ const turning = channel.path === 'rotation';
383
+ return html `
384
+ <div channel ?orphan=${orphan}>
385
+ <div row>
386
+ <label>${i18next.t('figure.label.channel-target')}</label>
387
+ <select
388
+ .value=${live(channel.target ?? '')}
389
+ @change=${(e) => this.setChannel(at, j, { target: e.target.value })}
390
+ >
391
+ ${orphan ? html `<option value=${channel.target}>${channel.target}</option>` : nothing}
392
+ ${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
393
+ </select>
394
+ <button plain @click=${() => this.removeChannel(at, j)} title=${i18next.t('figure.button.remove')}>
395
+ <md-icon>close</md-icon>
396
+ </button>
397
+ </div>
398
+
399
+ ${orphan ? html `<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
400
+
401
+ <div row>
402
+ <label>${i18next.t('figure.label.channel-path')}</label>
403
+ <select
404
+ .value=${live(channel.path)}
405
+ @change=${(e) => this.setPath(at, j, e.target.value)}
406
+ >
407
+ ${CHANNEL_PATHS.map(path => html `<option value=${path}>${i18next.t('figure.label.path-' + path)}</option>`)}
408
+ </select>
409
+ <select
410
+ .value=${live(channel.interpolation ?? 'linear')}
411
+ title=${i18next.t('figure.text.interpolation-explained')}
412
+ @change=${(e) => this.setChannel(at, j, { interpolation: e.target.value })}
413
+ >
414
+ ${INTERPOLATIONS.map(one => html `<option value=${one}>${i18next.t('figure.label.interpolation-' + one)}</option>`)}
415
+ </select>
416
+ </div>
417
+
418
+ <p quiet>${i18next.t('figure.text.path-' + channel.path + '-unit')}</p>
419
+
420
+ ${turning ? this.renderPivot(channel, at, j) : nothing}
421
+ ${this.renderKeys(channel, at, j)}
422
+ </div>
423
+ `;
424
+ }
425
+ /**
426
+ * 회전 중심 — **`rotation` 에서만 뜻이 있다.**
427
+ *
428
+ * 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
429
+ * 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
430
+ */
431
+ renderPivot(channel, at, j) {
432
+ const pivot = channel.pivot;
433
+ const on = pivot !== undefined;
434
+ return html `
435
+ <div row>
436
+ <label>${i18next.t('figure.label.channel-pivot')}</label>
437
+ <button
438
+ @click=${() => this.setChannel(at, j, { pivot: on ? undefined : { x: 0, y: 0, z: 0 } })}
439
+ title=${i18next.t('figure.text.pivot-defaults-to-the-part-centre')}
440
+ >
441
+ ${i18next.t(on ? 'figure.button.pivot-clear' : 'figure.button.pivot-set')}
442
+ </button>
443
+ ${on
444
+ ? AXES.map(axis => html `
445
+ <input
446
+ type="number"
447
+ step="1"
448
+ .value=${live(String(pivot[axis]))}
449
+ aria-label=${axis}
450
+ @change=${(e) => this.setChannel(at, j, {
451
+ pivot: { ...pivot, [axis]: Number(e.target.value) || 0 }
452
+ })}
453
+ />
454
+ `)
455
+ : nothing}
456
+ </div>
457
+ `;
458
+ }
459
+ /**
460
+ * 키프레임 표.
461
+ *
462
+ * **둘 미만으로 못 내려간다.** 하나뿐이면 움직이는 것이 아니라 자세 하나이고, 형식이
463
+ * 오류로 막는다. 저장하고 나서 알려 주는 것보다 지우는 단추를 죽이는 것이 낫다.
464
+ *
465
+ * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
466
+ * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
467
+ */
468
+ renderKeys(channel, at, j) {
469
+ const keys = channel.keys ?? [];
470
+ return html `
471
+ <table keys>
472
+ <tr>
473
+ <th>${i18next.t('figure.label.key-at')}</th>
474
+ ${AXES.map(axis => html `<th>${axis}</th>`)}
475
+ <th></th>
476
+ </tr>
477
+ ${keys.map((key, k) => {
478
+ const backwards = k > 0 && key.at <= (keys[k - 1].at ?? 0);
479
+ return html `
480
+ <tr>
481
+ <td>
482
+ <input
483
+ type="number"
484
+ step="0.1"
485
+ min="0"
486
+ ?bad=${backwards || key.at < 0}
487
+ .value=${live(String(key.at))}
488
+ title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
489
+ @change=${(e) => this.setKey(at, j, k, { at: Math.max(0, Number(e.target.value) || 0) })}
490
+ />
491
+ </td>
492
+ ${AXES.map(axis => html `
493
+ <td>
494
+ <input
495
+ type="number"
496
+ step=${channel.path === 'scale' ? '0.1' : '1'}
497
+ .value=${live(String(key.value[axis]))}
498
+ aria-label=${axis}
499
+ @change=${(e) => this.setKey(at, j, k, {
500
+ value: { ...key.value, [axis]: Number(e.target.value) || 0 }
501
+ })}
502
+ />
503
+ </td>
504
+ `)}
505
+ <td drop>
506
+ <button
507
+ plain
508
+ ?disabled=${keys.length <= 2}
509
+ title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
510
+ @click=${() => this.removeKey(at, j, k)}
511
+ >
512
+ <md-icon>close</md-icon>
513
+ </button>
514
+ </td>
515
+ </tr>
516
+ `;
517
+ })}
518
+ </table>
519
+
520
+ ${keys.some((key, k) => k > 0 && key.at <= (keys[k - 1].at ?? 0))
521
+ ? html `<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
522
+ : nothing}
523
+
524
+ <button @click=${() => this.addKey(at, j)}>
525
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
526
+ </button>
527
+ `;
528
+ }
529
+ /**
530
+ * 움직이는 부품 예산.
531
+ *
532
+ * **넘어도 막지 않는다.** 움직이는 부품은 저마다 단독 묶음이라 draw call 이 늘고 프레임마다
533
+ * 갱신이 는다 — 그 대가가 수로 보이면 저작자가 아낀다. 랜드마크 설비 하나는 정교해도 되고,
534
+ * 그 값이 도면 예산에 드러나면 된다.
535
+ */
536
+ renderBudget() {
537
+ const used = this.movingParts;
538
+ const over = used > LIMITS.animatedParts;
539
+ return html `
540
+ <section>
541
+ <h3>${i18next.t('figure.label.budget-animations')}</h3>
542
+ <div budget ?over=${over}>
543
+ <strong>${used}</strong>
544
+ <span quiet>/ ${LIMITS.animatedParts}</span>
545
+ </div>
546
+ <p quiet>${i18next.t('figure.text.animated-parts-cost')}</p>
547
+ ${over ? html `<p warn>${i18next.t('figure.text.animated-parts-over')}</p>` : nothing}
548
+ </section>
549
+ `;
550
+ }
551
+ // ─── 고치기 ───────────────────────────────────────────────────────────────
552
+ /**
553
+ * 보드에 실어 올려보낸다.
554
+ *
555
+ * `animations` 는 도형 전체의 것이므로 부품 편집(`update-part`)이 아니라 `update-board` 로
556
+ * 간다. 빈 배열은 칸을 지운다 — clip 을 다 지운 도형은 `animations: []` 가 아니라 없는 것이다.
557
+ */
558
+ tell(animations) {
559
+ if (!this.board)
560
+ return;
561
+ const board = { ...this.board };
562
+ if (animations.length === 0)
563
+ delete board.animations;
564
+ else
565
+ board.animations = animations;
566
+ this.dispatchEvent(new CustomEvent('update-board', { detail: { board }, bubbles: true, composed: true }));
567
+ }
568
+ patchClip(at, patch) {
569
+ this.tell(this.clips.map((clip, i) => (i === at ? { ...clip, ...patch } : clip)));
570
+ }
571
+ patchChannel(at, j, next) {
572
+ const clip = this.clips[at];
573
+ if (!clip)
574
+ return;
575
+ this.patchClip(at, { channels: clip.channels.map((channel, i) => (i === j ? next : channel)) });
576
+ }
577
+ addClip() {
578
+ const target = this.partNames[0];
579
+ if (!target)
580
+ return;
581
+ const clip = {
582
+ name: freeName(this.clips),
583
+ drive: 'loop',
584
+ channels: [{ target, path: 'translation', keys: keysFor('translation') }]
585
+ };
586
+ this.open = this.clips.length;
587
+ this.tell([...this.clips, clip]);
588
+ }
589
+ /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
590
+ addRecipe(recipe) {
591
+ const target = this.recipeTarget || this.partNames[0];
592
+ if (!target)
593
+ return;
594
+ const channel = {
595
+ target,
596
+ path: recipe.path,
597
+ keys: [{ at: 0, value: startValue(recipe.path) }, { at: 1, value: { ...recipe.end } }]
598
+ };
599
+ if (recipe.pivot)
600
+ channel.pivot = { ...recipe.pivot };
601
+ this.open = this.clips.length;
602
+ this.tell([...this.clips, { name: freeRecipeName(this.clips, recipe.name), drive: recipe.drive, channels: [channel] }]);
603
+ }
604
+ renameClip(at, name) {
605
+ this.patchClip(at, { name: name.trim() });
606
+ }
607
+ setDrive(at, drive) {
608
+ this.patchClip(at, { drive });
609
+ }
610
+ removeClip(at) {
611
+ if (this.open >= at)
612
+ this.open = Math.max(0, this.open - 1);
613
+ this.tell(this.clips.filter((_, i) => i !== at));
614
+ }
615
+ addChannel(at) {
616
+ const clip = this.clips[at];
617
+ const target = this.partNames[0];
618
+ if (!clip || !target)
619
+ return;
620
+ this.patchClip(at, {
621
+ channels: [...clip.channels, { target, path: 'translation', keys: keysFor('translation') }]
622
+ });
623
+ }
624
+ setChannel(at, j, patch) {
625
+ const channel = this.clips[at]?.channels[j];
626
+ if (!channel)
627
+ return;
628
+ const next = { ...channel, ...patch };
629
+ /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
630
+ if ('pivot' in patch && patch.pivot === undefined)
631
+ delete next.pivot;
632
+ this.patchChannel(at, j, next);
633
+ }
634
+ /**
635
+ * 경로를 바꾼다 — **값까지 함께 옮긴다.**
636
+ *
637
+ * 옮김·회전의 0 은 「제자리」인데 배율의 0 은 **부품을 지운다.** 경로만 바꾸고 값을 그대로
638
+ * 두면 저작자는 부품이 사라진 것을 보고 도구가 고장 난 줄 안다. 그래서 옮김·회전 ↔ 배율을
639
+ * 건널 때 시작값을 그 경로의 제자리로 되돌린다. 시각은 지킨다 — 저작자가 짠 박자다.
640
+ */
641
+ setPath(at, j, path) {
642
+ const channel = this.clips[at]?.channels[j];
643
+ if (!channel)
644
+ return;
645
+ const crossing = (channel.path === 'scale') !== (path === 'scale');
646
+ const next = {
647
+ ...channel,
648
+ path,
649
+ keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
650
+ };
651
+ /* 회전이 아니면 회전 중심은 쓰이지 않는다. 남겨 두면 검증이 「쓰이지 않는다」고 알린다. */
652
+ if (path !== 'rotation')
653
+ delete next.pivot;
654
+ this.patchChannel(at, j, next);
655
+ }
656
+ removeChannel(at, j) {
657
+ const clip = this.clips[at];
658
+ if (!clip)
659
+ return;
660
+ this.patchClip(at, { channels: clip.channels.filter((_, i) => i !== j) });
661
+ }
662
+ /** 마지막 키에서 1 초 뒤. 값은 그 키를 그대로 받는다 — 붙여 놓고 고치는 것이 짜기 쉽다. */
663
+ addKey(at, j) {
664
+ const channel = this.clips[at]?.channels[j];
665
+ if (!channel)
666
+ return;
667
+ const last = channel.keys[channel.keys.length - 1];
668
+ const key = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } };
669
+ this.patchChannel(at, j, { ...channel, keys: [...channel.keys, key] });
670
+ }
671
+ setKey(at, j, k, patch) {
672
+ const channel = this.clips[at]?.channels[j];
673
+ if (!channel)
674
+ return;
675
+ this.patchChannel(at, j, {
676
+ ...channel,
677
+ keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
678
+ });
679
+ }
680
+ /** 둘 미만으로 내려가지 않는다 — 단추가 이미 죽어 있지만 밖에서 불릴 수 있다. */
681
+ removeKey(at, j, k) {
682
+ const channel = this.clips[at]?.channels[j];
683
+ if (!channel || channel.keys.length <= 2)
684
+ return;
685
+ this.patchChannel(at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) });
686
+ }
687
+ };
688
+ __decorate([
689
+ property({ type: Object }),
690
+ __metadata("design:type", Object)
691
+ ], FigureAnimations.prototype, "board", void 0);
692
+ __decorate([
693
+ property({ type: Array }),
694
+ __metadata("design:type", Array)
695
+ ], FigureAnimations.prototype, "parts", void 0);
696
+ __decorate([
697
+ state(),
698
+ __metadata("design:type", Object)
699
+ ], FigureAnimations.prototype, "open", void 0);
700
+ __decorate([
701
+ state(),
702
+ __metadata("design:type", Object)
703
+ ], FigureAnimations.prototype, "recipeTarget", void 0);
704
+ FigureAnimations = __decorate([
705
+ customElement('figure-animations')
706
+ ], FigureAnimations);
707
+ export { FigureAnimations };
708
+ //# sourceMappingURL=figure-animations.js.map