@things-factory/figure-ui 10.1.5 → 10.1.7

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