@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
@@ -69,6 +69,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
69
69
  display: flex;
70
70
  flex-direction: column;
71
71
  min-height: 0;
72
+ --figure-control-height: 30px;
72
73
  overflow-y: auto;
73
74
  background-color: var(--md-sys-color-surface-container-lowest);
74
75
  color: var(--md-sys-color-on-surface);
@@ -93,9 +94,39 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
93
94
  칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
94
95
  */
95
96
  section {
96
- padding: 6px 10px;
97
+ padding: 8px 10px;
97
98
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
98
99
  }
100
+ /* 입력 행은 촘촘하게, 서로 다른 편집 의도 사이에는 호흡을 둔다. */
101
+ section + section {
102
+ margin-top: 6px;
103
+ }
104
+ section[collapsible] {
105
+ padding: 0;
106
+ }
107
+ details[group] > summary {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ min-height: 34px;
112
+ padding: 0 10px;
113
+ cursor: pointer;
114
+ list-style: none;
115
+ }
116
+ details[group] > summary::-webkit-details-marker {
117
+ display: none;
118
+ }
119
+ details[group] > summary h3 {
120
+ margin: 0;
121
+ }
122
+ details[group] > summary md-icon {
123
+ --md-icon-size: 16px;
124
+ color: var(--md-sys-color-on-surface-variant);
125
+ }
126
+ details[group] > div[group-content] {
127
+ padding: 4px 10px 8px;
128
+ border-top: 1px solid var(--md-sys-color-outline-variant);
129
+ }
99
130
  h3 {
100
131
  display: flex;
101
132
  align-items: center;
@@ -108,6 +139,42 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
108
139
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
109
140
  }
110
141
 
142
+ /* 자산 메타데이터는 부품을 고친다는 현재 문맥에서는 요약으로만 둔다. */
143
+ details[asset] {
144
+ margin: -1px 0;
145
+ }
146
+ details[asset] summary {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 6px;
150
+ min-height: 26px;
151
+ cursor: pointer;
152
+ list-style: none;
153
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
154
+ }
155
+ details[asset] summary::-webkit-details-marker {
156
+ display: none;
157
+ }
158
+ details[asset] summary::before {
159
+ content: '›';
160
+ font-size: 1.1rem;
161
+ line-height: 1;
162
+ transition: transform 120ms ease;
163
+ }
164
+ details[asset][open] summary::before {
165
+ transform: rotate(90deg);
166
+ }
167
+ details[asset] summary b {
168
+ font-size: var(--ui-t-micro, 11px);
169
+ letter-spacing: 0.04em;
170
+ }
171
+ details[asset] summary small {
172
+ overflow: hidden;
173
+ text-overflow: ellipsis;
174
+ white-space: nowrap;
175
+ font-size: 0.7rem;
176
+ }
177
+
111
178
  label {
112
179
  display: flex;
113
180
  align-items: center;
@@ -153,6 +220,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
153
220
  input,
154
221
  select {
155
222
  width: 100%;
223
+ min-height: var(--figure-control-height);
156
224
  box-sizing: border-box;
157
225
  padding: 4px 7px;
158
226
  font: var(--input-field-font, inherit);
@@ -177,6 +245,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
177
245
  */
178
246
  div[cell] {
179
247
  display: flex;
248
+ box-sizing: border-box;
249
+ height: var(--figure-control-height);
250
+ min-height: var(--figure-control-height);
180
251
  align-items: center;
181
252
  gap: 2px;
182
253
  padding-left: 6px;
@@ -197,6 +268,8 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
197
268
  }
198
269
  div[cell] input {
199
270
  min-width: 0;
271
+ min-height: 0;
272
+ height: calc(var(--figure-control-height) - 2px);
200
273
  padding: 4px 6px 4px 2px;
201
274
  font-family: var(--mono-font, monospace);
202
275
  text-align: right;
@@ -216,11 +289,163 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
216
289
  }
217
290
  div[field] + div[field],
218
291
  div[triple] + div[field],
219
- div[field] + div[triple] {
292
+ div[field] + div[triple],
293
+ div[inline-field] + div[inline-field],
294
+ div[inline-field] + div[field],
295
+ div[field] + div[inline-field],
296
+ div[vector-row] + div[vector-row],
297
+ div[vector-row] + div[field],
298
+ div[field] + div[vector-row] {
220
299
  /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
221
300
  margin-top: 5px;
222
301
  }
223
302
 
303
+ /* 짧은 라벨과 단일 값은 같은 줄에서 읽는다. 복잡한 선택기에는 쓰지 않는다. */
304
+ div[inline-field] {
305
+ display: grid;
306
+ /* 같은 편집 밀도 안에서는 입력 시작선을 맞춘다. 라벨 길이가 칸 폭을 흔들면 안 된다. */
307
+ grid-template-columns: 64px minmax(0, 1fr);
308
+ align-items: center;
309
+ gap: 7px;
310
+ }
311
+ div[inline-field] > label {
312
+ margin: 0;
313
+ white-space: nowrap;
314
+ }
315
+ /* 한 줄 입력의 설명은 입력칸을 밀어내지 않고 바로 아래 전폭에서 읽는다. */
316
+ div[inline-field] > p[hint] {
317
+ grid-column: 1 / -1;
318
+ margin: -2px 0 1px;
319
+ }
320
+ div[inline-field] > code[fixed] {
321
+ min-width: 0;
322
+ overflow: hidden;
323
+ text-overflow: ellipsis;
324
+ white-space: nowrap;
325
+ }
326
+
327
+ /* X·Y·Z는 함께 비교하는 값이므로, 묶음 제목만 왼쪽에 두고 입력은 한 행에 둔다. */
328
+ div[vector-row] {
329
+ display: grid;
330
+ grid-template-columns: 64px minmax(0, 1fr);
331
+ align-items: center;
332
+ gap: 7px;
333
+ }
334
+ div[vector-row] > label {
335
+ margin: 0;
336
+ white-space: nowrap;
337
+ }
338
+ div[vector-row] > p[hint] {
339
+ grid-column: 1 / -1;
340
+ margin: -2px 0 1px;
341
+ }
342
+ div[sizing-choices] {
343
+ display: grid;
344
+ grid-template-columns: 1fr 1fr;
345
+ gap: 5px;
346
+ }
347
+ button[sizing-choice] {
348
+ display: flex;
349
+ flex-direction: column;
350
+ align-items: flex-start;
351
+ gap: 2px;
352
+ min-height: 52px;
353
+ padding: 7px 8px;
354
+ color: var(--md-sys-color-on-surface);
355
+ background: var(--md-sys-color-surface-container-lowest);
356
+ border: 1px solid var(--md-sys-color-outline-variant);
357
+ border-radius: 7px;
358
+ text-align: left;
359
+ cursor: pointer;
360
+ }
361
+ button[sizing-choice][on] {
362
+ color: var(--md-sys-color-on-primary-container);
363
+ background: var(--md-sys-color-primary-container);
364
+ border-color: transparent;
365
+ }
366
+ button[sizing-choice] b {
367
+ font-size: 0.74rem;
368
+ }
369
+ button[sizing-choice] small {
370
+ font-size: 0.64rem;
371
+ line-height: 1.35;
372
+ opacity: 0.8;
373
+ }
374
+ div[anchor-rows] {
375
+ display: grid;
376
+ gap: 6px;
377
+ }
378
+ div[anchor-row] {
379
+ display: grid;
380
+ grid-template-columns: 46px minmax(0, 1fr);
381
+ align-items: start;
382
+ gap: 8px;
383
+ padding: 6px 7px;
384
+ background: var(--md-sys-color-surface-container);
385
+ border-radius: 7px;
386
+ }
387
+ div[anchor-row] label {
388
+ margin: 0;
389
+ font-size: 0.7rem;
390
+ font-weight: 700;
391
+ }
392
+ div[anchor-options] {
393
+ display: grid;
394
+ grid-template-columns: repeat(3, minmax(0, 1fr));
395
+ gap: 4px;
396
+ }
397
+ button[anchor-choice] {
398
+ min-width: 0;
399
+ height: var(--figure-control-height);
400
+ box-sizing: border-box;
401
+ padding: 4px 3px;
402
+ overflow: hidden;
403
+ color: var(--md-sys-color-on-surface);
404
+ font: var(--label-font, inherit);
405
+ font-size: 0.63rem;
406
+ line-height: 1.2;
407
+ text-overflow: ellipsis;
408
+ white-space: nowrap;
409
+ background: var(--md-sys-color-surface-container-lowest);
410
+ border: 1px solid var(--md-sys-color-outline-variant);
411
+ border-radius: 5px;
412
+ cursor: pointer;
413
+ }
414
+ button[anchor-choice][on] {
415
+ color: var(--md-sys-color-on-primary-container);
416
+ background: var(--md-sys-color-primary-container);
417
+ border-color: transparent;
418
+ }
419
+ details[anchor-advanced] {
420
+ grid-column: 2;
421
+ margin-top: 4px;
422
+ }
423
+ details[anchor-advanced] summary {
424
+ cursor: pointer;
425
+ font-size: 0.62rem;
426
+ color: var(--md-sys-color-on-surface-variant);
427
+ }
428
+ details[anchor-advanced] div[anchor-options] {
429
+ margin-top: 4px;
430
+ }
431
+ div[palette-meaning] {
432
+ display: grid;
433
+ gap: 2px;
434
+ margin-top: 7px;
435
+ padding: 6px 8px;
436
+ color: var(--md-sys-color-on-surface);
437
+ background: var(--md-sys-color-surface-container);
438
+ border-radius: 7px;
439
+ }
440
+ div[palette-meaning] b {
441
+ font-size: 0.72rem;
442
+ }
443
+ div[palette-meaning] small {
444
+ color: var(--md-sys-color-on-surface-variant);
445
+ font-size: 0.64rem;
446
+ line-height: 1.35;
447
+ }
448
+
224
449
  /*
225
450
  정밀 제작 도구.
226
451
 
@@ -229,13 +454,17 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
229
454
  늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
230
455
  */
231
456
  div[tools] {
232
- display: flex;
233
- flex-wrap: wrap;
234
- gap: 3px;
235
- margin-top: 6px;
457
+ display: grid;
458
+ grid-template-columns: repeat(6, minmax(0, 1fr));
459
+ gap: 4px;
236
460
  }
237
461
  div[tools] button {
238
- padding: 3px 8px;
462
+ display: grid;
463
+ place-items: center;
464
+ min-width: 0;
465
+ height: var(--figure-control-height);
466
+ box-sizing: border-box;
467
+ padding: 0;
239
468
  font: var(--label-font, inherit);
240
469
  font-size: 0.7rem;
241
470
  color: var(--md-sys-color-on-surface);
@@ -244,6 +473,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
244
473
  border-radius: 6px;
245
474
  cursor: pointer;
246
475
  }
476
+ div[tools] button md-icon {
477
+ font-size: 17px;
478
+ }
247
479
  div[tools] button[disabled] {
248
480
  opacity: 0.35;
249
481
  cursor: default;
@@ -253,11 +485,6 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
253
485
  background-color: var(--md-sys-color-primary-container);
254
486
  border-color: transparent;
255
487
  }
256
- div[tools] span[sep] {
257
- width: 1px;
258
- margin: 2px 3px;
259
- background-color: var(--md-sys-color-outline-variant);
260
- }
261
488
 
262
489
  /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
263
490
  p[axis-now] {
@@ -316,7 +543,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
316
543
  */
317
544
  div[segments] {
318
545
  display: grid;
319
- grid-template-columns: repeat(3, 1fr);
546
+ grid-template-columns: repeat(4, 1fr);
320
547
  gap: 4px;
321
548
  }
322
549
  /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
@@ -374,8 +601,8 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
374
601
  button[seg] svg {
375
602
  display: block;
376
603
  /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
377
- width: 40px;
378
- height: 40px;
604
+ width: 32px;
605
+ height: 32px;
379
606
  }
380
607
  button[seg] svg polygon {
381
608
  fill: var(--md-sys-color-surface-container-highest);
@@ -398,7 +625,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
398
625
  button[seg][on] small {
399
626
  color: inherit;
400
627
  }
401
- /* 프리셋 밖의 값도 있다. 다만 권하는 쪽을 크게 둔다 */
628
+ /* 성능·실루엣 검증을 마친 프리셋만 고른다. */
402
629
  details[free] {
403
630
  margin-top: 6px;
404
631
  }
@@ -491,6 +718,26 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
491
718
  label[toggle] input {
492
719
  width: auto;
493
720
  }
721
+ div[role-choices] {
722
+ display: grid;
723
+ grid-template-columns: repeat(3, minmax(0, 1fr));
724
+ gap: 4px;
725
+ }
726
+ div[role-choices] label[toggle] {
727
+ justify-content: center;
728
+ min-height: 30px;
729
+ margin: 0;
730
+ padding: 2px 4px;
731
+ border: 1px solid var(--md-sys-color-outline-variant);
732
+ border-radius: 6px;
733
+ font-size: 0.68rem;
734
+ text-align: center;
735
+ }
736
+ div[role-choices] label[toggle]:has(input:checked) {
737
+ color: var(--md-sys-color-on-primary-container);
738
+ background: var(--md-sys-color-primary-container);
739
+ border-color: transparent;
740
+ }
494
741
 
495
742
  p[quiet] {
496
743
  margin: 0;
@@ -531,7 +778,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
531
778
  const part = this.chosen;
532
779
  if (!part || !this.board) {
533
780
  return html `
534
- ${this.renderFigure()}
781
+ ${this.renderFigure(false)}
535
782
  <section>
536
783
  <h3>${i18next.t('figure.label.properties')}</h3>
537
784
  <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
@@ -548,7 +795,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
548
795
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
549
796
  */
550
797
  return html `
551
- ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
798
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
552
799
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
553
800
  ${this.renderBehaviour(shown)}
554
801
  `;
@@ -572,16 +819,18 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
572
819
  <section>
573
820
  <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
574
821
 
575
- ${PART_ROLES.map(one => html `
576
- <label toggle>
577
- <input
578
- type="checkbox"
579
- .checked=${live(roles.includes(one))}
580
- @change=${(e) => this.toggleRole(one, e.target.checked)}
581
- />
582
- ${i18next.t('figure.label.role-' + one)}
583
- </label>
584
- `)}
822
+ <div role-choices>
823
+ ${PART_ROLES.map(one => html `
824
+ <label toggle>
825
+ <input
826
+ type="checkbox"
827
+ .checked=${live(roles.includes(one))}
828
+ @change=${(e) => this.toggleRole(one, e.target.checked)}
829
+ />
830
+ ${i18next.t('figure.label.role-' + one)}
831
+ </label>
832
+ `)}
833
+ </div>
585
834
  ${roles.length === 0 ? html `<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
586
835
 
587
836
  ${roles.includes('slot')
@@ -666,11 +915,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
666
915
  *
667
916
  * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
668
917
  */
669
- renderFigure() {
670
- return html `
671
- <section>
672
- <h3>${i18next.t('figure.label.figure')}</h3>
673
- <div field>
918
+ renderFigure(compact = false) {
919
+ const fields = html `
920
+ <div inline-field>
674
921
  <label>${i18next.t('figure.label.name')}</label>
675
922
  <input
676
923
  .value=${this.figureName}
@@ -682,31 +929,91 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
682
929
  }))}
683
930
  />
684
931
  </div>
685
- <div field>
686
- <label>${i18next.t('figure.label.figure-type')}${this.help('figure.text.type-name-cannot-change')}</label>
932
+ <div inline-field>
933
+ <label>${i18next.t('figure.label.figure-type')}${this.helpButton('figure.text.type-name-cannot-change')}</label>
687
934
  <code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
688
935
  <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
689
936
  </code>
937
+ ${this.helpHint('figure.text.type-name-cannot-change')}
690
938
  </div>
691
939
  <!--
692
940
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
693
941
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
694
942
  -->
943
+
944
+ <div inline-field>
945
+ <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
946
+ <input
947
+ .value=${this.figureCategory ?? ''}
948
+ placeholder=${i18next.t('figure.text.category-placeholder')}
949
+ @change=${(e) => this.tellCatalog({ category: e.target.value.trim() || undefined })}
950
+ />
951
+ ${this.helpHint('figure.text.category-explained')}
952
+ </div>
953
+
954
+ <div inline-field>
955
+ <label>${i18next.t('figure.label.tags')}${this.helpButton('figure.text.tags-explained')}</label>
956
+ <input
957
+ .value=${(this.figureTags ?? []).join(', ')}
958
+ placeholder=${i18next.t('figure.text.tags-placeholder')}
959
+ @change=${(e) => this.tellTags(e.target.value)}
960
+ />
961
+ ${this.helpHint('figure.text.tags-explained')}
962
+ </div>
963
+ `;
964
+ if (compact) {
965
+ return html `
966
+ <section>
967
+ <details asset>
968
+ <summary>
969
+ <b>${i18next.t('figure.label.figure')}</b>
970
+ <small>${this.figureName || this.board?.figureType || ''}</small>
971
+ </summary>
972
+ ${fields}
973
+ </details>
974
+ </section>
975
+ `;
976
+ }
977
+ return html `
978
+ <section>
979
+ <h3>${i18next.t('figure.label.figure')}</h3>
980
+ ${fields}
695
981
  </section>
696
982
  `;
697
983
  }
984
+ /**
985
+ * 태그 줄을 목록으로.
986
+ *
987
+ * 쉼표로 가르고 `#` 은 떼어 낸다 — 사람은 `#반출` 이라고 적고, 저장되는 것은 `반출` 이다.
988
+ * 겹친 것은 한 번만 남긴다. 빈 줄은 **빈 배열**이지 `undefined` 가 아니다 — 전부 지우려는
989
+ * 저작자가 지울 방법을 잃지 않게.
990
+ */
991
+ tellTags(raw) {
992
+ const tags = [
993
+ ...new Set(raw
994
+ .split(',')
995
+ .map(one => one.trim().replace(/^#+/, '').trim())
996
+ .filter(one => one.length > 0))
997
+ ];
998
+ this.tellCatalog({ tags });
999
+ }
1000
+ /** 카탈로그 값이 바뀌었다고 알린다. 정본은 저작면이 든다 — 여기서는 알리기만 한다. */
1001
+ tellCatalog(patch) {
1002
+ this.dispatchEvent(new CustomEvent('figure-catalog', { detail: patch, bubbles: true, composed: true }));
1003
+ }
698
1004
  renderIdentity(part) {
699
1005
  return html `
700
1006
  <section>
701
1007
  <h3>${i18next.t('figure.label.part')}</h3>
702
- <div field>
703
- <label>${i18next.t('figure.label.name')}${this.help('figure.text.renaming-a-part-breaks-bindings')}</label>
1008
+ <div inline-field>
1009
+ <label>${i18next.t('figure.label.name')}${this.helpButton('figure.text.renaming-a-part-breaks-bindings')}</label>
704
1010
  <input
705
1011
  .value=${part.name}
706
1012
  @change=${(e) => this.put({ name: e.target.value })}
707
1013
  />
1014
+ ${this.helpHint('figure.text.renaming-a-part-breaks-bindings')}
708
1015
  </div>
709
- <div field>
1016
+ <div inline-field>
710
1017
  <label>${i18next.t('figure.label.shape')}</label>
711
1018
  <select @change=${(e) => this.changePrimitive(e.target.value)}>
712
1019
  ${PRIMITIVE_KINDS.map(kind => html `
@@ -726,56 +1033,74 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
726
1033
  <section>
727
1034
  <h3>${i18next.t('figure.label.placement')}</h3>
728
1035
 
729
- <label>${i18next.t('figure.label.part-position')}</label>
730
- <div triple>
731
- ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.position[axis], value => this.putTransform({ position: { ...t.position, [axis]: value } })))}
1036
+ <div vector-row>
1037
+ <label>${i18next.t('figure.label.part-position')}</label>
1038
+ <div triple>
1039
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.position[axis], value => this.putTransform({ position: { ...t.position, [axis]: value } })))}
1040
+ </div>
732
1041
  </div>
733
1042
 
734
- <div field>
1043
+ <div vector-row>
735
1044
  <label>${i18next.t('figure.label.part-size')}</label>
736
1045
  <div triple>
737
1046
  ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.size[axis], value => this.putTransform({ size: { ...t.size, [axis]: value } }), 1))}
738
1047
  </div>
739
1048
  </div>
740
1049
 
741
- <div field>
742
- <label>${i18next.t('figure.label.tools')}</label>
1050
+ <div vector-row>
1051
+ <label>${i18next.t('figure.label.rotation-degrees')}</label>
1052
+ <div triple>
1053
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), rotation[axis] ?? 0, value => this.putTransform({ rotation: { ...rotation, [axis]: value } })))}
1054
+ </div>
1055
+ </div>
1056
+
1057
+ <div vector-row>
1058
+ <label>${i18next.t('figure.label.tools')}${this.helpButton('figure.text.tools-explained')}</label>
743
1059
  <div tools>
744
- <button title=${i18next.t('figure.text.center-on-x')} @click=${() => this.ask('center', 'x')}>
745
- ${i18next.t('figure.button.center-x')}
1060
+ <button
1061
+ aria-label=${i18next.t('figure.button.center-x')}
1062
+ title=${i18next.t('figure.text.center-on-x')}
1063
+ @click=${() => this.ask('center', 'x')}
1064
+ >
1065
+ <md-icon>horizontal_distribute</md-icon>
746
1066
  </button>
747
- <button title=${i18next.t('figure.text.center-on-z')} @click=${() => this.ask('center', 'z')}>
748
- ${i18next.t('figure.button.center-z')}
1067
+ <button
1068
+ aria-label=${i18next.t('figure.button.center-z')}
1069
+ title=${i18next.t('figure.text.center-on-z')}
1070
+ @click=${() => this.ask('center', 'z')}
1071
+ >
1072
+ <md-icon>vertical_align_center</md-icon>
749
1073
  </button>
750
- <button title=${i18next.t('figure.text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
751
- ${i18next.t('figure.button.to-floor')}
1074
+ <button
1075
+ aria-label=${i18next.t('figure.button.to-floor')}
1076
+ title=${i18next.t('figure.text.put-on-floor')}
1077
+ @click=${() => this.ask('floor', 'y')}
1078
+ >
1079
+ <md-icon>vertical_align_bottom</md-icon>
752
1080
  </button>
753
- <span sep></span>
754
1081
  ${['x', 'z'].map(axis => {
755
1082
  // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
756
1083
  const able = this.board ? canMirror(this.board, this.parts, this.selected, axis) : false;
757
1084
  return html `
758
1085
  <button
759
1086
  ?disabled=${!able}
1087
+ aria-label=${i18next.t('figure.button.mirror-' + axis)}
760
1088
  title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
761
1089
  @click=${() => this.ask('mirror', axis)}
762
1090
  >
763
- ${i18next.t('figure.button.mirror-' + axis)}
1091
+ <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
764
1092
  </button>
765
1093
  `;
766
1094
  })}
767
- <span sep></span>
768
- <button title=${i18next.t('figure.text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
769
- ${i18next.t('figure.button.snap')}
1095
+ <button
1096
+ aria-label=${i18next.t('figure.button.snap')}
1097
+ title=${i18next.t('figure.text.snap-explained')}
1098
+ @click=${() => this.ask('snap', 'x')}
1099
+ >
1100
+ <md-icon>grid_on</md-icon>
770
1101
  </button>
771
1102
  </div>
772
- </div>
773
-
774
- <div field>
775
- <label>${i18next.t('figure.label.rotation-degrees')}</label>
776
- <div triple>
777
- ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), rotation[axis] ?? 0, value => this.putTransform({ rotation: { ...rotation, [axis]: value } })))}
778
- </div>
1103
+ ${this.helpHint('figure.text.tools-explained')}
779
1104
  </div>
780
1105
  </section>
781
1106
  `;
@@ -807,17 +1132,17 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
807
1132
  const wall = hollow?.wall ?? 0;
808
1133
  const reshape = (over) => this.put({ shape: { ...(part.shape ?? {}), ...over } });
809
1134
  return html `
810
- <div field>
1135
+ <div inline-field>
811
1136
  <label>${i18next.t('figure.label.corner-round')}</label>
812
1137
  ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
813
1138
  </div>
814
- <div field>
1139
+ <div inline-field>
815
1140
  <label>${i18next.t('figure.label.hollow-wall')}</label>
816
1141
  ${this.number('', wall, value => reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined }))}
817
1142
  </div>
818
1143
  ${wall > 0
819
1144
  ? html `
820
- <div field>
1145
+ <div inline-field>
821
1146
  <label>${i18next.t('figure.label.hollow-floor')}</label>
822
1147
  ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
823
1148
  </div>
@@ -859,11 +1184,13 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
859
1184
  }
860
1185
  renderSegments(part) {
861
1186
  const current = part.segments ?? SEGMENT_PRESETS[1];
1187
+ const common = SEGMENT_PRESETS.slice(0, 4);
1188
+ const advanced = SEGMENT_PRESETS.slice(4);
862
1189
  return html `
863
1190
  <div field>
864
1191
  <label>${i18next.t('figure.label.segments')}</label>
865
1192
  <div segments>
866
- ${SEGMENT_PRESETS.map(preset => {
1193
+ ${common.map(preset => {
867
1194
  const triangles = trianglesOf({ ...part, segments: preset });
868
1195
  return html `
869
1196
  <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
@@ -875,9 +1202,13 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
875
1202
  })}
876
1203
  </div>
877
1204
 
878
- <details free>
879
- <summary>${i18next.t('figure.text.other-segment-count')}</summary>
880
- ${this.number('', current, value => this.put({ segments: value }), 3)}
1205
+ <details free ?open=${advanced.includes(current)}>
1206
+ <summary>${i18next.t('figure.text.more-segment-options')}</summary>
1207
+ <div presets>
1208
+ ${advanced.map(preset => html `
1209
+ <button ?on=${current === preset} @click=${() => this.put({ segments: preset })}>${preset}</button>
1210
+ `)}
1211
+ </div>
881
1212
  </details>
882
1213
  </div>
883
1214
  `;
@@ -902,16 +1233,35 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
902
1233
  `;
903
1234
  }
904
1235
  /** 견본 한 줄. 무리마다 부른다. */
1236
+ palettePurpose(token) {
1237
+ const purposes = {
1238
+ 'palette.primary': 'primary',
1239
+ 'palette.accent': 'accent',
1240
+ 'palette.neutral': 'neutral',
1241
+ 'palette.dark': 'dark',
1242
+ 'palette.light': 'light',
1243
+ 'palette.surface': 'surface',
1244
+ 'palette.status-danger': 'danger',
1245
+ 'palette.status-warning': 'warning',
1246
+ 'palette.status-info': 'info',
1247
+ 'palette.status-success': 'success'
1248
+ };
1249
+ return i18next.t(`figure.label.palette-purpose-${purposes[token ?? ''] ?? 'registered'}`);
1250
+ }
905
1251
  swatches(entries, chosen) {
906
- return entries.map(([token, color]) => html `
1252
+ return entries.map(([token, color]) => {
1253
+ const purpose = this.palettePurpose(token);
1254
+ return html `
907
1255
  <button
908
1256
  swatch
909
1257
  ?on=${token === chosen}
910
- title=${token}
1258
+ aria-label=${purpose}
1259
+ title=${purpose}
911
1260
  style="--swatch:${color}"
912
1261
  @click=${() => this.putMaterial({ token })}
913
1262
  ></button>
914
- `);
1263
+ `;
1264
+ });
915
1265
  }
916
1266
  renderMaterial(part) {
917
1267
  const material = part.material ?? {};
@@ -933,7 +1283,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
933
1283
  <h3>${i18next.t('figure.label.part-material')}</h3>
934
1284
 
935
1285
  <div field>
936
- <label>${i18next.t('figure.label.palette-token')}</label>
1286
+ <label>${i18next.t('figure.label.palette-role')}</label>
937
1287
  <div swatches>
938
1288
  ${this.swatches(painted, material.token)}
939
1289
  </div>
@@ -945,7 +1295,10 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
945
1295
  </div>
946
1296
  `
947
1297
  : ''}
948
- <span token>${material.token ?? i18next.t('figure.label.no-color-chosen')}</span>
1298
+ <div palette-meaning>
1299
+ <b>${material.token ? this.palettePurpose(material.token) : i18next.t('figure.label.no-color-chosen')}</b>
1300
+ <small>${i18next.t('figure.text.palette-purpose-explained')}</small>
1301
+ </div>
949
1302
  </div>
950
1303
 
951
1304
  <div field>
@@ -1063,19 +1416,23 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1063
1416
  return html `
1064
1417
  <section>
1065
1418
  <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
1066
- <select @change=${(e) => this.put({ sizing: e.target.value })}>
1419
+ <div sizing-choices>
1067
1420
  ${SIZING_RULES.map(rule => html `
1068
- <option value=${rule} ?selected=${rule === sizing}>${i18next.t('figure.label.sizing-' + rule)}</option>
1421
+ <button sizing-choice ?on=${rule === sizing} @click=${() => this.put({ sizing: rule })}>
1422
+ <b>${i18next.t('figure.label.sizing-' + rule)}</b>
1423
+ <small>${i18next.t('figure.text.sizing-' + rule + '-explained')}</small>
1424
+ </button>
1069
1425
  `)}
1070
- </select>
1071
- ${sizing === 'scale' ? nothing : this.help('figure.text.sizing-costs-one-draw-call')}
1072
- ${sizing === 'repeat' ? this.help('figure.text.repeat-edits-the-unit') : nothing}
1426
+ </div>
1427
+ ${sizing === 'scale'
1428
+ ? nothing
1429
+ : html `<p cost>${i18next.t('figure.text.sizing-costs-one-draw-call')}</p>`}
1073
1430
 
1074
1431
  ${this.renderAnchors(part)}
1075
1432
 
1076
1433
  ${sizing === 'repeat'
1077
1434
  ? html `
1078
- <div field>
1435
+ <div inline-field>
1079
1436
  <label>${i18next.t('figure.label.repeat-axis')}</label>
1080
1437
  <select
1081
1438
  @change=${(e) => this.put({ repeat: { ...repeat, axis: e.target.value } })}
@@ -1083,9 +1440,10 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1083
1440
  ${['x', 'y', 'z'].map(axis => html `<option value=${axis} ?selected=${axis === repeat.axis}>${axis}</option>`)}
1084
1441
  </select>
1085
1442
  </div>
1086
- <div field>
1087
- <label>${i18next.t('figure.label.repeat-pitch')}${this.help('figure.text.repeat-pitch-explained')}</label>
1443
+ <div inline-field>
1444
+ <label>${i18next.t('figure.label.repeat-pitch')}${this.helpButton('figure.text.repeat-pitch-explained')}</label>
1088
1445
  ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1446
+ ${this.helpHint('figure.text.repeat-pitch-explained')}
1089
1447
  </div>
1090
1448
  ${this.renderRepeatCount(part)}
1091
1449
  `
@@ -1172,6 +1530,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1172
1530
  * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1173
1531
  */
1174
1532
  help(key) {
1533
+ return html `${this.helpButton(key)}${this.helpHint(key)}`;
1534
+ }
1535
+ helpButton(key) {
1175
1536
  const shown = this.opened.has(key);
1176
1537
  return html `
1177
1538
  <button
@@ -1185,18 +1546,38 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1185
1546
  >
1186
1547
  <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
1187
1548
  </button>
1188
- ${shown ? html `<p hint>${i18next.t(key)}</p>` : nothing}
1189
1549
  `;
1190
1550
  }
1551
+ helpHint(key) {
1552
+ return this.opened.has(key) ? html `<p hint>${i18next.t(key)}</p>` : nothing;
1553
+ }
1191
1554
  renderAnchors(part) {
1192
1555
  const base = this.board
1193
1556
  ? { x: this.board.width, y: this.board.height, z: this.board.depth }
1194
1557
  : { x: 0, y: 0, z: 0 };
1558
+ const nameOf = (rule, axis) => `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`;
1559
+ const setAnchor = (axis, rule) => {
1560
+ const anchor = { ...(part.anchor ?? {}) };
1561
+ /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1562
+ if (rule)
1563
+ anchor[axis] = rule;
1564
+ else
1565
+ delete anchor[axis];
1566
+ this.put({ anchor: Object.keys(anchor).length ? anchor : undefined });
1567
+ };
1568
+ const shortNameOf = (rule, axis) => {
1569
+ if (rule === 'auto')
1570
+ return i18next.t('figure.label.anchor-choice-auto');
1571
+ if (rule === 'min' || rule === 'max')
1572
+ return i18next.t(`figure.label.anchor-choice-${rule}-${axis}`);
1573
+ return i18next.t(`figure.label.anchor-choice-${rule}`);
1574
+ };
1195
1575
  return html `
1196
1576
  <div field>
1197
- <!-- anchor.x」로 적힌 사유를 화면에서 찾을 있게 이름에 그 낱말을 남긴다 -->
1577
+ <!-- 저장 키(anchor)는 남기되, 사용자는 축마다 어느 면을 기준으로 삼는지 읽는다. -->
1198
1578
  <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
1199
- ${['x', 'y', 'z'].map(axis => {
1579
+ <div anchor-rows>
1580
+ ${['x', 'y', 'z'].map(axis => {
1200
1581
  const said = part.anchor?.[axis];
1201
1582
  const auto = anchorOfPart(part, base, axis);
1202
1583
  /*
@@ -1214,32 +1595,42 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1214
1595
  `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
1215
1596
  숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
1216
1597
  */
1217
- const nameOf = (rule) => `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`;
1218
1598
  return html `
1219
- <div cell>
1220
- <span axis>${axis}</span>
1221
- <select
1222
- @change=${(e) => {
1223
- const value = e.target.value;
1224
- const anchor = { ...(part.anchor ?? {}) };
1225
- /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1226
- if (value) {
1227
- anchor[axis] = value;
1228
- }
1229
- else {
1230
- delete anchor[axis];
1231
- }
1232
- this.put({ anchor: Object.keys(anchor).length ? anchor : undefined });
1233
- }}
1234
- >
1235
- <option value="" ?selected=${!said}>
1236
- ${i18next.t('figure.label.anchor-auto', { rule: nameOf(auto) })}
1237
- </option>
1238
- ${ANCHOR_RULES.map(rule => html `<option value=${rule} ?selected=${rule === said}>${nameOf(rule)}</option>`)}
1239
- </select>
1599
+ <div anchor-row>
1600
+ <label>${axis.toUpperCase()}축</label>
1601
+ <div anchor-options>
1602
+ ${['auto', 'scale', 'min', 'center', 'max', 'span'].map(rule => {
1603
+ const selected = rule === 'auto' ? !said : rule === said;
1604
+ const fullName = rule === 'auto' ? i18next.t('figure.label.anchor-auto', { rule: nameOf(auto, axis) }) : nameOf(rule, axis);
1605
+ return html `
1606
+ <button
1607
+ anchor-choice
1608
+ ?on=${selected}
1609
+ aria-pressed=${selected ? 'true' : 'false'}
1610
+ title=${fullName}
1611
+ @click=${() => setAnchor(axis, rule === 'auto' ? undefined : rule)}
1612
+ >${shortNameOf(rule, axis)}</button>
1613
+ `;
1614
+ })}
1615
+ </div>
1616
+ <details anchor-advanced>
1617
+ <summary>${i18next.t('figure.label.anchor-advanced')}</summary>
1618
+ <div anchor-options>
1619
+ ${ANCHOR_RULES.filter(rule => rule.startsWith('aspect-')).map(rule => html `
1620
+ <button
1621
+ anchor-choice
1622
+ ?on=${rule === said}
1623
+ aria-pressed=${rule === said ? 'true' : 'false'}
1624
+ title=${nameOf(rule, axis)}
1625
+ @click=${() => setAnchor(axis, rule)}
1626
+ >${i18next.t(`figure.label.anchor-choice-${rule}`)}</button>
1627
+ `)}
1628
+ </div>
1629
+ </details>
1240
1630
  </div>
1241
1631
  `;
1242
1632
  })}
1633
+ </div>
1243
1634
  </div>
1244
1635
  `;
1245
1636
  }
@@ -1347,6 +1738,14 @@ __decorate([
1347
1738
  property({ type: String }),
1348
1739
  __metadata("design:type", Object)
1349
1740
  ], FigureInspector.prototype, "figureName", void 0);
1741
+ __decorate([
1742
+ property({ type: String }),
1743
+ __metadata("design:type", String)
1744
+ ], FigureInspector.prototype, "figureCategory", void 0);
1745
+ __decorate([
1746
+ property({ type: Array }),
1747
+ __metadata("design:type", Array)
1748
+ ], FigureInspector.prototype, "figureTags", void 0);
1350
1749
  __decorate([
1351
1750
  state(),
1352
1751
  __metadata("design:type", Object)