@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
@@ -2,18 +2,6 @@ import { __decorate, __metadata } from "tslib";
2
2
  import '@material/web/icon/icon.js';
3
3
  import '../modeller/figure-side.js';
4
4
  import '../modeller/figure-ask.js';
5
- /*
6
- 대화 도크. **`/client` 서브패스로 적으면 안 된다.**
7
-
8
- 그 패키지에 `exports` 맵이 없어서 `/client` 는 소스 디렉터리로 떨어지고, webpack 이 `.ts` 를 직접
9
- 컴파일한다. 그 경로로 나온 데코레이터가 `Reflect.metadata` 를 가드 없이 불러 **브라우저에서만**
10
- 죽는다(빌드 로그는 오류 0건). board-ai 와 plant 가 차례로 밟았다.
11
-
12
- 그리고 더 센 이유가 있다 — `board-ui/dist-client` 의 모델러가 같은 엘리먼트를 **빌드된 경로로**
13
- 등록한다. 다른 경로로 적으면 webpack 이 두 모듈로 보고, 두 곳이 같은 커스텀 엘리먼트 이름을
14
- 등록해 나중에 뜨는 화면이 죽는다. 즉 「어느 경로가 되나」가 아니라 **「모두 같은 경로여야 한다」**다.
15
- */
16
- import '@things-factory/ai-assistant/dist-client/index.js';
17
5
  import '../modeller/figure-canvas.js';
18
6
  import { captureThumbnail } from '../modeller/figure-thumbnail.js';
19
7
  import '../modeller/figure-preview.js';
@@ -24,9 +12,9 @@ import { customElement, state } from 'lit/decorators.js';
24
12
  import { i18next, localize } from '@operato/i18n';
25
13
  import { navigate, PageView } from '@operato/shell';
26
14
  import { validate } from '@hatiolab/figure-model';
27
- import { activePalette } from '@hatiolab/things-scene';
28
15
  import * as edits from '../modeller/part-edits.js';
29
16
  import * as proposals from '../modeller/proposal.js';
17
+ import * as proposalSessions from '../modeller/proposal-session.js';
30
18
  import { toFigureSource, fromFigureSource } from '../modeller/figure-source.js';
31
19
  import { DEFAULT_VIEW } from '../modeller/figure-view.js';
32
20
  import { createFigure, fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js';
@@ -139,10 +127,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
139
127
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
140
128
  */
141
129
  this.saveFailure = '';
142
- /** 대화 도크가 열려 있나. 저작면의 보기 상태라 저장하지 않는다. */
143
- this.dockOpen = false;
144
- /** 받기로 고른 변경들. 처음에는 전부 골라 둔다. */
145
- this.picked = new Set();
130
+ /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
131
+ this.proposalFeedback = [];
146
132
  }
147
133
  static { this.styles = css `
148
134
  :host {
@@ -166,50 +152,10 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
166
152
  div[edits] {
167
153
  flex: 1;
168
154
  display: grid;
169
- grid-template-columns: 220px 1fr 320px 0;
155
+ grid-template-columns: 220px 1fr 320px;
170
156
  min-height: 0;
171
157
  overflow: hidden;
172
158
  }
173
- /*
174
- 대화 도크는 **불렀을 때만 자리를 받는다.** 저작의 주인공은 캔버스이고, 대화는 필요할 때
175
- 여는 것이다. 늘 열어 두면 넷으로 나뉜 화면에서 캔버스가 가장 좁아진다.
176
- */
177
- div[edits][dock] {
178
- grid-template-columns: 220px 1fr 320px 360px;
179
- }
180
- div[edits] > ox-board-ai-chat {
181
- grid-column: 4;
182
- min-width: 0;
183
- border-left: 1px solid var(--md-sys-color-outline-variant);
184
- background-color: var(--md-sys-color-surface-container-lowest);
185
- }
186
- div[edits]:not([dock]) > ox-board-ai-chat {
187
- display: none;
188
- }
189
-
190
- /* 도크를 여는 손잡이. 머리줄의 저장 단추 옆에 둔다 */
191
- button[dock] {
192
- flex: none;
193
- display: flex;
194
- align-items: center;
195
- gap: 4px;
196
- padding: 5px 10px;
197
- font: var(--label-font, inherit);
198
- font-size: 0.78rem;
199
- color: var(--md-sys-color-on-surface-variant);
200
- background: none;
201
- border: 1px solid var(--md-sys-color-outline-variant);
202
- border-radius: 6px;
203
- cursor: pointer;
204
- }
205
- button[dock][on] {
206
- color: var(--md-sys-color-primary);
207
- border-color: var(--md-sys-color-primary);
208
- background-color: var(--md-sys-color-surface-container);
209
- }
210
- button[dock] md-icon {
211
- font-size: 16px;
212
- }
213
159
 
214
160
  /*
215
161
  **세 칸의 자리를 못 박는다.**
@@ -508,6 +454,39 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
508
454
  cursor: default;
509
455
  }
510
456
 
457
+ /* AI의 평가는 판정문이 아니라 수락 전에 읽을 근거다. 숨기지 않고 변경 칩 위에 둔다. */
458
+ div[quality-review] {
459
+ padding: 7px var(--spacing-large, 12px);
460
+ background-color: var(--md-sys-color-primary-container);
461
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
462
+ color: var(--md-sys-color-on-primary-container);
463
+ font-size: 0.72rem;
464
+ line-height: 1.45;
465
+ }
466
+ div[quality-review][review] {
467
+ background-color: var(--md-sys-color-tertiary-container);
468
+ color: var(--md-sys-color-on-tertiary-container);
469
+ }
470
+ div[quality-review] strong { display: block; font-size: 0.74rem; }
471
+ div[quality-review] ul { margin: 4px 0 0; padding-left: 18px; }
472
+ div[proposal-note] {
473
+ padding: 6px var(--spacing-large, 12px) 8px;
474
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
475
+ background-color: var(--md-sys-color-surface-container-low);
476
+ }
477
+ div[proposal-note] label { display: block; font-size: 0.71rem; color: var(--md-sys-color-on-surface-variant); }
478
+ div[proposal-note] input {
479
+ box-sizing: border-box;
480
+ width: 100%;
481
+ margin-top: 4px;
482
+ padding: 5px 7px;
483
+ border: 1px solid var(--md-sys-color-outline-variant);
484
+ border-radius: 5px;
485
+ color: var(--md-sys-color-on-surface);
486
+ background: var(--md-sys-color-surface);
487
+ font: inherit;
488
+ }
489
+
511
490
  /*
512
491
  무엇을 바꾸자는 것인가 — 칩 하나가 변경 하나다.
513
492
 
@@ -568,6 +547,31 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
568
547
  this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined;
569
548
  }
570
549
  }
550
+ /**
551
+ * 페이지 밖 AI 도크에도 **지금 손에 든 정본**을 알려 준다.
552
+ *
553
+ * 모델러 안의 `figure-ask` 는 이미 `folded`를 직접 받는다. 반면 asidebar 도크는 주소의 id만
554
+ * 알았으므로 DB에 저장된 옛 판을 읽었다. 저장 전 변경을 두고 "왜 발행이 안 되나" 또는
555
+ * "이 부품을 줄여 달라"고 물으면 대화가 엉뚱한 판을 보게 되는 결함이었다.
556
+ *
557
+ * 이 이벤트는 저장하지 않는다. 도크가 대화 인자로만 잠시 들고 있고, 서버 도구도 도메인 소속
558
+ * Figure를 먼저 확인한 뒤에만 이 source를 사용한다. 페이지를 떠날 때는 아래에서 지워 오래된
559
+ * 초안이 다음 Figure에 섞이지 않게 한다.
560
+ */
561
+ updated(changed) {
562
+ if (changed.has('figure') && changed.get('figure')?.id !== this.figure?.id) {
563
+ this.proposalFeedback = [];
564
+ }
565
+ if (changed.has('board') || changed.has('parts') || changed.has('figure')) {
566
+ window.dispatchEvent(new CustomEvent('figure-ai-context', {
567
+ detail: { figureId: this.figure?.id, source: this.folded }
568
+ }));
569
+ }
570
+ }
571
+ disconnectedCallback() {
572
+ window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }));
573
+ super.disconnectedCallback();
574
+ }
571
575
  get context() {
572
576
  return {
573
577
  title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
@@ -601,17 +605,10 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
601
605
  <figure-ask
602
606
  .source=${source}
603
607
  .type=${source?.type ?? ''}
608
+ .feedback=${this.proposalFeedback}
604
609
  @proposed=${(e) => this.receive(e.detail.proposal)}
605
610
  ></figure-ask>
606
611
  ${this.saveFailure ? html `<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
607
- <button
608
- dock
609
- ?on=${this.dockOpen}
610
- title=${i18next.t('figure.text.dock-explained')}
611
- @click=${() => (this.dockOpen = !this.dockOpen)}
612
- >
613
- <md-icon>forum</md-icon>${i18next.t('figure.button.dock')}
614
- </button>
615
612
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
616
613
  ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
617
614
  </button>
@@ -621,10 +618,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
621
618
  edits
622
619
  ?collapse-left=${this.collapsed.left}
623
620
  ?collapse-right=${this.collapsed.right}
624
- ?dock=${this.dockOpen}
625
621
  @figure-rename=${(e) => this.rename(e.detail.name)}
622
+ @figure-catalog=${(e) => this.catalog(e.detail)}
626
623
  @figure-released=${(e) => this.released(e.detail.figure)}
627
624
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
625
+ @preview-sizing-fix=${(e) => this.previewSizingFix(e.detail)}
626
+ @apply-sizing-fix=${(e) => this.applySizingFix(e.detail)}
628
627
  @add-part=${(e) => this.addPart(e.detail.primitive)}
629
628
  @select-part=${(e) => (this.selected = e.detail.index)}
630
629
  @remove-part=${(e) => this.removePart(e.detail.index)}
@@ -648,8 +647,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
648
647
 
649
648
  <div middle>
650
649
  ${this.renderBar()}
651
- ${this.proposal ? this.renderDecision() : ''}
652
- ${this.proposal
650
+ ${this.proposalSession ? this.renderDecision() : ''}
651
+ ${this.proposalSession
653
652
  ? html `
654
653
  <div lanes>
655
654
  <div lane>
@@ -664,10 +663,11 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
664
663
  <div lane candidate>
665
664
  <h4>
666
665
  ${i18next.t('figure.label.proposed')}
667
- ${this.proposalOf
666
+ ${this.proposalSession.metrics
668
667
  ? html `<small>
669
- ${this.proposalOf.grade} ·
670
- ${i18next.t('figure.text.n-triangles', { count: this.proposalOf.triangles })}
668
+ ${this.proposalSession.metrics.grade} ·
669
+ ${i18next.t('figure.text.n-triangles', { count: this.proposalSession.metrics.triangles })}
670
+ ${this.proposalSession.metrics.quality ? html ` · ${this.proposalSession.metrics.quality.summary}` : ''}
671
671
  </small>`
672
672
  : ''}
673
673
  </h4>
@@ -686,6 +686,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
686
686
 
687
687
  <figure-side
688
688
  .figureName=${this.figure?.name ?? ''}
689
+ .figureCategory=${this.figure?.category}
690
+ .figureTags=${this.figure?.tags}
689
691
  .figureId=${this.figure?.id ?? ''}
690
692
  .figureState=${this.figure?.state}
691
693
  .figureVersion=${this.figure?.version ?? 0}
@@ -697,66 +699,9 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
697
699
  .view=${this.view}
698
700
  @update-view=${(e) => (this.view = e.detail.view)}
699
701
  ></figure-side>
700
-
701
- <!-- 대화 도크. 무엇을 켜고 끄는지는 renderDock 위에 적어 두었다 -->
702
- <ox-board-ai-chat
703
- .boardTools=${false}
704
- .toolCategories=${['figure-authoring']}
705
- .hostContext=${this.hostContext}
706
- .placeholder=${i18next.t('figure.text.dock-placeholder')}
707
- @board-ai-proposal-execute=${(e) => this.takeProposal(e.detail.proposal)}
708
- ></ox-board-ai-chat>
709
702
  </div>
710
703
  `;
711
704
  }
712
- /*
713
- * ── 대화 도크를 무엇으로 켜나 ───────────────────────────────────────────────
714
- *
715
- * board 의 컴포넌트를 그대로 쓰지만 **board 인 척하지 않는다.** `boardTools` 를 끄고 figure 의
716
- * 도구 범주(`figure-authoring`)만 연다. 끄지 않으면 보드 편집 도구가 함께 들려, 모델이 이
717
- * 화면에서 할 수 없는 일을 시도한다.
718
- *
719
- * board 모양의 인자(`currentBoard` · `knownTypes` · `componentSchemas` · `selectedRefids`)는
720
- * 주지 않는다 — 전부 선택이고, 채우려면 없는 것을 지어내야 한다.
721
- */
722
- /**
723
- * 어시스턴트가 「지금 무엇을 열고 있나」를 아는 길.
724
- *
725
- * 도구가 대상을 안 받았을 때 이 문맥에서 해소한다 — 사용자에게 식별자를 되묻지 않는다.
726
- * 팔레트도 함께 보낸다: 모르면 모델이 색을 지어내고, 그 후보는 검증을 지나 **그리는 시점에**
727
- * 죽는다.
728
- */
729
- get hostContext() {
730
- return {
731
- surface: 'figure-modeller',
732
- figureId: this.figure?.id || undefined,
733
- figureType: this.board?.figureType,
734
- figureName: this.figure?.name,
735
- palette: Object.keys(activePalette())
736
- };
737
- }
738
- /**
739
- * 도크의 후보를 받는다.
740
- *
741
- * **후보가 화면에 닿는 길은 하나뿐이다** — 말로 시키는 줄이든 도크든 `receive()` 로 모인다.
742
- * 두 길을 두면 「지금 / 후보」 두 칸을 두 곳에서 그리게 되고, 어느 쪽이 이기는지 저작자가
743
- * 매번 생각해야 한다.
744
- */
745
- takeProposal(proposal) {
746
- const source = proposal?.source;
747
- if (!source) {
748
- // 카드는 왔는데 후보가 없다 — 조용히 넘기지 않는다. 저작자가 눌렀으니 답이 있어야 한다.
749
- this.saveFailure = i18next.t('figure.text.proposal-had-no-source');
750
- return;
751
- }
752
- this.receive({
753
- source: JSON.stringify(source),
754
- grade: proposal.score?.grade ?? '',
755
- triangles: proposal.cost?.triangles ?? 0,
756
- groups: proposal.cost?.groups ?? 0,
757
- attempts: proposal.attempts ?? 1
758
- });
759
- }
760
705
  /**
761
706
  * 가운데 칸 위의 띠 — 무엇을 보고 있나와, 양쪽 칸을 접는 손잡이.
762
707
  *
@@ -786,7 +731,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
786
731
  </button>
787
732
  `;
788
733
  return html `<div modes>
789
- ${this.renderCollapse('left')}${this.proposal ? '' : html `${one('edit')}${one('preview')}`}
734
+ ${this.renderCollapse('left')}${this.proposalSession ? '' : html `${one('edit')}${one('preview')}`}
790
735
  <div spacer></div>
791
736
  ${this.renderCollapse('right')}
792
737
  </div>`;
@@ -817,16 +762,17 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
817
762
  rememberPanes(this.collapsed);
818
763
  }
819
764
  /**
820
- * AI 후보를 나란히 세운다.
765
+ * AI 후보를 실제 배치 크기에서도 본다.
821
766
  *
822
- * 후보는 저장 형식으로 온다. 미리보기는 모델을 받으므로 여기서 번 변환한다 —
823
- * 후보를 받아들이기 전까지는 편집 데이터가 아니므로 상태로 두지 않는다.
767
+ * 편집 캔버스 장으로는 `repeat`·`fixed`·`anchor`가 맞는지 없다. 후보는 아직
768
+ * 정본이 아니므로 저장하지 않고, `figure-preview`가 같은 FigureSource를 기준 크기와
769
+ * 일곱 가지 확장 축에서 실제 FigureInstance로 세운다. 따라서 저작자는 "좋아 보이는
770
+ * 한 장"이 아니라 배치될 때도 붙어 있는지를 보고 수락한다.
824
771
  */
825
772
  renderProposed() {
826
- if (!this.proposal)
773
+ if (!this.proposalSession)
827
774
  return '';
828
- const { board, parts } = fromFigureSource(this.proposal);
829
- return html `<figure-canvas .board=${board} .parts=${parts} .viewOnly=${true}></figure-canvas>`;
775
+ return html `<figure-preview .source=${this.proposalSession.source}></figure-preview>`;
830
776
  }
831
777
  /**
832
778
  * 무엇을 받을지 고른다.
@@ -843,21 +789,34 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
843
789
  * 안 들고, 우리가 생각 못 한 조합을 막아 주기 때문이다.
844
790
  */
845
791
  renderDecision() {
846
- if (!this.proposal)
792
+ if (!this.proposalSession)
847
793
  return '';
848
- const changes = proposals.diffProposal(this.folded, this.proposal);
849
- const picked = proposals.applyProposal(this.folded, this.proposal, this.picked);
794
+ const changes = proposals.diffProposal(this.folded, this.proposalSession.source);
795
+ const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
850
796
  const { errors, violations } = validate(picked);
851
797
  return html `
852
798
  <div decide>
853
- <span>${i18next.t('figure.text.assistant-proposed-a-figure')}</span>
799
+ <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
854
800
  <div spacer></div>
855
801
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
856
- <button take ?disabled=${errors.length > 0 || this.picked.size === 0} @click=${() => this.take()}>
857
- ${i18next.t('figure.button.take-n-changes', { n: this.picked.size })}
802
+ <button take ?disabled=${errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
803
+ ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
858
804
  </button>
859
805
  </div>
860
806
 
807
+ ${this.renderQualityReview()}
808
+ <div proposal-note>
809
+ <label>
810
+ ${i18next.t('figure.text.proposal-feedback-note')}
811
+ <input
812
+ maxlength="500"
813
+ .value=${this.proposalSession.note}
814
+ placeholder=${i18next.t('figure.text.proposal-feedback-placeholder')}
815
+ @input=${(event) => (this.proposalSession = { ...this.proposalSession, note: event.target.value })}
816
+ />
817
+ </label>
818
+ </div>
819
+
861
820
  ${changes.length > 0
862
821
  ? html `
863
822
  <div changes>
@@ -872,6 +831,24 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
872
831
  : ''}
873
832
  `;
874
833
  }
834
+ /** 후보 품질 판단의 근거를 수락 전에 전부 보여 준다. */
835
+ renderQualityReview() {
836
+ const quality = this.proposalSession?.metrics?.quality;
837
+ if (!quality)
838
+ return '';
839
+ return html `
840
+ <div quality-review ?review=${quality.status === 'review'}>
841
+ <strong>${i18next.t('figure.label.candidate-review')}</strong>
842
+ <span>${quality.summary}</span>
843
+ <span> · top-view ${Math.round(quality.visualReadability * 100)}% · ${quality.visualRegions} regions</span>
844
+ ${quality.findings.length > 0
845
+ ? html `<ul>
846
+ ${quality.findings.map(finding => html `<li><code>${finding.dimension}</code> — ${finding.message}</li>`)}
847
+ </ul>`
848
+ : ''}
849
+ </div>
850
+ `;
851
+ }
875
852
  /** 변경 하나. 무엇을 하겠다는 것인지 부호로 먼저 말한다. */
876
853
  renderChange(change) {
877
854
  const icon = {
@@ -883,7 +860,7 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
883
860
  anchor: 'my_location',
884
861
  styleKit: 'palette'
885
862
  }[change.kind];
886
- const on = this.picked.has(change.key);
863
+ const on = this.proposalSession?.picked.has(change.key);
887
864
  return html `
888
865
  <button
889
866
  change
@@ -897,12 +874,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
897
874
  `;
898
875
  }
899
876
  toggle(key) {
900
- const next = new Set(this.picked);
901
- if (next.has(key))
902
- next.delete(key);
903
- else
904
- next.add(key);
905
- this.picked = next;
877
+ if (this.proposalSession)
878
+ this.proposalSession = proposalSessions.toggleProposalChange(this.proposalSession, key);
906
879
  }
907
880
  /**
908
881
  * 후보가 왔다.
@@ -911,15 +884,29 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
911
884
  * 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
912
885
  */
913
886
  receive(proposal) {
914
- this.proposal = JSON.parse(proposal.source);
915
- // 처음에는 전부 골라 둔다 — 통째로 받는 것이 보통의 길이다.
916
- this.picked = new Set(proposals.diffProposal(this.folded, this.proposal).map(change => change.key));
917
- this.proposalOf = {
918
- grade: proposal.grade,
919
- triangles: proposal.triangles,
920
- groups: proposal.groups,
921
- attempts: proposal.attempts
922
- };
887
+ this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal);
888
+ }
889
+ /**
890
+ * 발행 게이트의 구조화된 수정안 하나를 현재 작업 사본에만 겹쳐 본다. 서버가 준 값은
891
+ * `part.anchor.axis = to`라는 데이터이지 사람이 읽을 문장을 다시 해석한 결과가 아니다.
892
+ * 원본 `board`/`parts`는 여기서 전혀 바꾸지 않고, 아래의 `take()`만 바꾼 사본을 적용한다.
893
+ */
894
+ previewSizingFix(fix) {
895
+ if (!this.folded)
896
+ return;
897
+ this.proposalSession = proposalSessions.openSizingFix(this.folded, fix);
898
+ }
899
+ /**
900
+ * 원클릭 적용은 서버가 모든 크기 변경 case를 재검사해 안전하다고 표시한 한-앵커 후보에만 연다.
901
+ * 그래도 저장은 저작자의 별도 행위다. 잘못 눌러도 DB 정본이나 발행판은 바뀌지 않는다.
902
+ */
903
+ applySizingFix(fix) {
904
+ if (!fix.safe || !this.folded)
905
+ return;
906
+ this.previewSizingFix(fix);
907
+ if (!this.proposalSession)
908
+ return;
909
+ this.take();
923
910
  }
924
911
  /**
925
912
  * 고른 변경을 받아들인다.
@@ -928,20 +915,42 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
928
915
  * 하므로 그쪽이 자연스럽다. 받아들인 결과는 다시 씬 모델로 되돌려 편집을 이어간다.
929
916
  */
930
917
  take() {
931
- if (!this.proposal)
918
+ if (!this.proposalSession)
932
919
  return;
933
- const taken = proposals.applyProposal(this.folded, this.proposal, this.picked);
920
+ const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked);
934
921
  const { board, parts } = fromFigureSource(taken);
935
922
  this.board = board;
936
923
  this.parts = parts;
937
924
  this.selected = parts.length ? 0 : -1;
938
925
  this.dirty = true;
939
- this.discard();
926
+ this.reportProposalFeedback('accepted');
927
+ this.discard(false);
940
928
  }
941
- discard() {
942
- this.proposal = undefined;
943
- this.proposalOf = undefined;
944
- this.picked = new Set();
929
+ /**
930
+ * 후보에 대한 사람의 결정을 도크에만 알린다.
931
+ *
932
+ * 기록은 분석 이벤트나 Figure 속성이 아니다. 같은 모델러 세션에서 다음 요청을 더 잘
933
+ * 이해시키는 짧은 신호일 뿐이다. 원본 source나 사람의 작업 내용을 다시 보내지 않고, 사람이
934
+ * 후보 변경 중 몇 개를 받아들였는지와 이미 화면에 보였던 품질 판정만 보낸다.
935
+ */
936
+ reportProposalFeedback(outcome) {
937
+ if (!this.proposalSession)
938
+ return;
939
+ const feedback = proposalSessions.feedbackFromSession(this.folded, this.proposalSession, outcome);
940
+ if (!feedback)
941
+ return;
942
+ this.proposalFeedback = [...this.proposalFeedback, feedback].slice(-5);
943
+ window.dispatchEvent(new CustomEvent('figure-ai-feedback', {
944
+ detail: {
945
+ figureId: this.figure?.id,
946
+ ...feedback
947
+ }
948
+ }));
949
+ }
950
+ discard(reportFeedback = true) {
951
+ if (reportFeedback)
952
+ this.reportProposalFeedback('discarded');
953
+ this.proposalSession = undefined;
945
954
  }
946
955
  /**
947
956
  * 저장할 수 있나.
@@ -1054,6 +1063,18 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1054
1063
  this.figure = { ...this.figure, name };
1055
1064
  this.dirty = true;
1056
1065
  }
1066
+ /**
1067
+ * 분류와 태그를 고친다.
1068
+ *
1069
+ * 정본(`source`)이 아니라 **카탈로그의 값**이다 — 도형이 무엇으로 생겼는지가 아니라 어디에
1070
+ * 걸리는지다. 그래서 점수도 발행도 흔들리지 않는다.
1071
+ */
1072
+ catalog(patch) {
1073
+ if (!this.figure)
1074
+ return;
1075
+ this.figure = { ...this.figure, ...patch };
1076
+ this.dirty = true;
1077
+ }
1057
1078
  /**
1058
1079
  * 부품 목록을 갈아 끼운다.
1059
1080
  *
@@ -1245,6 +1266,12 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1245
1266
  if (this.figure.id) {
1246
1267
  const result = await updateFigure(this.figure.id, {
1247
1268
  name: this.figure.name,
1269
+ /*
1270
+ 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1271
+ 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1272
+ */
1273
+ category: this.figure.category,
1274
+ tags: this.figure.tags,
1248
1275
  source: sourceJson,
1249
1276
  thumbnail
1250
1277
  });
@@ -1261,6 +1288,8 @@ let FigureModellerPage = class FigureModellerPage extends FigureModellerPageBase
1261
1288
  const result = await createFigure({
1262
1289
  type: this.folded.type,
1263
1290
  name: this.figure.name,
1291
+ category: this.figure.category,
1292
+ tags: this.figure.tags,
1264
1293
  source: sourceJson,
1265
1294
  thumbnail
1266
1295
  });
@@ -1331,19 +1360,11 @@ __decorate([
1331
1360
  __decorate([
1332
1361
  state(),
1333
1362
  __metadata("design:type", Object)
1334
- ], FigureModellerPage.prototype, "dockOpen", void 0);
1335
- __decorate([
1336
- state(),
1337
- __metadata("design:type", Object)
1338
- ], FigureModellerPage.prototype, "proposal", void 0);
1339
- __decorate([
1340
- state(),
1341
- __metadata("design:type", Object)
1342
- ], FigureModellerPage.prototype, "proposalOf", void 0);
1363
+ ], FigureModellerPage.prototype, "proposalSession", void 0);
1343
1364
  __decorate([
1344
1365
  state(),
1345
- __metadata("design:type", Object)
1346
- ], FigureModellerPage.prototype, "picked", void 0);
1366
+ __metadata("design:type", Array)
1367
+ ], FigureModellerPage.prototype, "proposalFeedback", void 0);
1347
1368
  FigureModellerPage = __decorate([
1348
1369
  customElement('figure-modeller-page')
1349
1370
  ], FigureModellerPage);