@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
@@ -1,5 +1,7 @@
1
+ import '@material/web/icon/icon.js'
1
2
  import './figure-inspector.js'
2
3
  import './figure-capabilities.js'
4
+ import './figure-animations.js'
3
5
  import './figure-report.js'
4
6
  import './figure-settings.js'
5
7
  import './figure-history-panel.js'
@@ -14,7 +16,7 @@ import type { BoardModel, PartModel } from './figure-source.js'
14
16
  import { DEFAULT_VIEW } from './figure-view.js'
15
17
  import type { ViewSettings } from './figure-view.js'
16
18
 
17
- type Tab = 'properties' | 'capability' | 'verdict' | 'view' | 'history'
19
+ type Tab = 'properties' | 'capability' | 'animation' | 'verdict' | 'view' | 'history'
18
20
 
19
21
  /**
20
22
  * 저작면 오른쪽 칸 — 속성과 판정을 탭으로 겹쳐 둔다.
@@ -67,21 +69,21 @@ export class FigureSide extends localize(i18next)(LitElement) {
67
69
  button[tab] {
68
70
  flex: 1;
69
71
  /*
70
- **탭 이름은줄이다.** 배지를 이름 옆에 나란히 두었더니 넷이 좁은 칸을 나눠 쓰면서
71
- 이름이 접혀 「성숙 / 도」가 되었다. 이름이 접히면 그 줄이 두 줄이 되고 네 탭의 높이가
72
- 서로 달라진다.
72
+ **아이콘 하나가 한 칸을 쓴다.**
73
73
 
74
- 그래서 배지는 자리를 차지하지 않고 **이름 위에 겹친다**(아래 span[badge]). 개수는
75
- 곁들이는 것이고 이름이 먼저다.
74
+ 전에는 이름을 적었는데, 넷이 좁은 칸을 나눠 쓰면서 접혀 「성숙 / 도」가 되었다. 접히면
75
+ 탭만 줄이 되어 높이가 서로 달라진다. 다섯이 되면서 더는 줄여 짜 넣을 수 없어
76
+ 아이콘으로 바꿨고, 이름은 title 과 aria-label 로 남는다.
77
+
78
+ 배지는 여전히 자리를 차지하지 않고 **아이콘 위에 겹친다**(아래 span[badge]). 개수는
79
+ 곁들이는 것이다.
76
80
  */
77
81
  position: relative;
78
82
  display: flex;
79
83
  align-items: center;
80
84
  justify-content: center;
81
85
  white-space: nowrap;
82
- padding: 8px 6px;
83
- font: var(--label-font, inherit);
84
- font-size: 0.76rem;
86
+ padding: 7px 6px;
85
87
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
86
88
  background: none;
87
89
  border: none;
@@ -104,6 +106,11 @@ export class FigureSide extends localize(i18next)(LitElement) {
104
106
  자리를 먹지 않게 오른쪽 위에 겹쳐 둔다 — 탭 이름을 밀지 않으므로 이름이 접히지 않는다.
105
107
  숫자가 커져도(1,200 같은 것) 이름 위로 넘칠 뿐 줄을 바꾸지 않는다.
106
108
  */
109
+ button[tab] md-icon {
110
+ font-size: 20px;
111
+ width: 20px;
112
+ height: 20px;
113
+ }
107
114
  span[badge] {
108
115
  position: absolute;
109
116
  /* 이름 위에 겹치되 위로 붙지 않게 — 탭 가운데 글자와 눈높이를 조금 맞춘다 */
@@ -120,6 +127,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
120
127
  }
121
128
 
122
129
  figure-inspector,
130
+ figure-animations,
123
131
  figure-report,
124
132
  figure-settings {
125
133
  flex: 1;
@@ -139,6 +147,9 @@ export class FigureSide extends localize(i18next)(LitElement) {
139
147
  곳이라 변환된 FigureSource 를 받는다. 변환은 모델러 페이지가 한 번만 해서 내려보낸다.
140
148
  */
141
149
  @property({ type: String }) figureName = ''
150
+ /** 카탈로그 분류·태그. 속성 탭이 보여 주고 바꾼다. */
151
+ @property({ type: String }) figureCategory?: string
152
+ @property({ type: Array }) figureTags?: string[]
142
153
  /** 이력 탭이 발행하고 되돌리는 데 필요한 것들. 정본은 저작면이 든다. */
143
154
  @property({ type: String }) figureId?: string
144
155
  @property({ type: String }) figureState?: 'draft' | 'released'
@@ -156,19 +167,34 @@ export class FigureSide extends localize(i18next)(LitElement) {
156
167
  /** 판정이 알려 준 개수. 탭 이름표에 붙는다. */
157
168
  @state() private instances?: number
158
169
 
170
+ /**
171
+ * 이 도형이 든 clip 수. 탭 아이콘에 붙는다.
172
+ *
173
+ * 아이콘만으로는 「여기에 무언가 있다」를 알 수 없다. 대부분의 도형은 움직이지 않으므로
174
+ * 이 탭은 늘 비어 있을 것이고, 그러면 움직이는 도형을 열었을 때 그 사실이 눈에 걸려야 한다.
175
+ * 0 이면 아무것도 안 붙인다 — 「0」을 붙이면 없는 것이 있는 것처럼 자리를 차지한다.
176
+ */
177
+ private get clipCount(): number | undefined {
178
+ const n = this.board?.animations?.length ?? 0
179
+ return n === 0 ? undefined : n
180
+ }
181
+
159
182
  render() {
160
183
  return html`
161
184
  <div tabs>
162
- ${this.renderTab('properties', i18next.t('figure.label.properties'))}
163
- ${this.renderTab('capability', i18next.t('figure.label.tab-capability'))}
164
- ${this.renderTab('verdict', i18next.t('figure.label.maturity'), this.instances)}
165
- ${this.renderTab('view', i18next.t('figure.label.tab-environment'))}
166
- ${this.renderTab('history', i18next.t('figure.label.history'))}
185
+ ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
186
+ ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
187
+ ${this.renderTab('animation', 'animation', i18next.t('figure.label.animation'), this.clipCount)}
188
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
189
+ ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
190
+ ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
167
191
  </div>
168
192
 
169
193
  <figure-inspector
170
194
  ?off=${this.tab !== 'properties'}
171
195
  .figureName=${this.figureName}
196
+ .figureCategory=${this.figureCategory}
197
+ .figureTags=${this.figureTags}
172
198
  .board=${this.board}
173
199
  .parts=${this.parts}
174
200
  .selected=${this.selected}
@@ -180,6 +206,12 @@ export class FigureSide extends localize(i18next)(LitElement) {
180
206
  .parts=${this.parts}
181
207
  ></figure-capabilities>
182
208
 
209
+ <figure-animations
210
+ ?off=${this.tab !== 'animation'}
211
+ .board=${this.board}
212
+ .parts=${this.parts}
213
+ ></figure-animations>
214
+
183
215
  <figure-report
184
216
  ?off=${this.tab !== 'verdict'}
185
217
  .source=${this.source}
@@ -201,10 +233,26 @@ export class FigureSide extends localize(i18next)(LitElement) {
201
233
  `
202
234
  }
203
235
 
204
- private renderTab(tab: Tab, label: string, badge?: number) {
236
+ /**
237
+ * 탭 하나 — **아이콘이 이름을 대신한다.**
238
+ *
239
+ * 탭이 다섯이 되면서 이름이 좁은 칸을 나눠 쓰다 접혔다. 앞으로 더 늘어날 자리이므로 이름을
240
+ * 줄여 짜 넣는 대신 아이콘으로 바꾼다.
241
+ *
242
+ * **이름을 버리지는 않는다.** 아이콘만으로는 무엇인지 모르는 사람이 있고, 특히 「능력」처럼
243
+ * 이 도구에만 있는 개념이 그렇다. 그래서 이름은 `title` 로 남아 마우스를 올리면 나오고,
244
+ * `aria-label` 로 읽는 기계에도 그대로 간다.
245
+ */
246
+ private renderTab(tab: Tab, icon: string, label: string, badge?: number) {
205
247
  return html`
206
- <button tab ?on=${this.tab === tab} @click=${() => (this.tab = tab)}>
207
- ${label}${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
248
+ <button
249
+ tab
250
+ ?on=${this.tab === tab}
251
+ title=${label}
252
+ aria-label=${label}
253
+ @click=${() => (this.tab = tab)}
254
+ >
255
+ <md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
208
256
  </button>
209
257
  `
210
258
  }
@@ -0,0 +1,92 @@
1
+ import type { FigureSource } from '@hatiolab/figure-model'
2
+
3
+ import type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
4
+ import { diffProposal } from './proposal.js'
5
+
6
+ /**
7
+ * 한 번의 AI 후보 검토에만 존재하는 상태다.
8
+ *
9
+ * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이
10
+ * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.
11
+ */
12
+ export interface ProposalSession {
13
+ source: FigureSource
14
+ title: string
15
+ picked: Set<string>
16
+ note: string
17
+ metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
+ }
19
+
20
+ function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
21
+ return new Set(diffProposal(current, source).map(change => change.key))
22
+ }
23
+
24
+ /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
25
+ export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
26
+ const source = JSON.parse(proposal.source) as FigureSource
27
+ if (!source || !Array.isArray(source.parts)) {
28
+ throw new Error('AI 후보의 Figure 형식이 올바르지 않습니다.')
29
+ }
30
+
31
+ return {
32
+ source,
33
+ title: '',
34
+ picked: selectedKeys(current, source),
35
+ note: '',
36
+ metrics: {
37
+ grade: proposal.grade,
38
+ triangles: proposal.triangles,
39
+ groups: proposal.groups,
40
+ attempts: proposal.attempts,
41
+ quality: proposal.quality
42
+ }
43
+ }
44
+ }
45
+
46
+ /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
47
+ export function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined {
48
+ if (!current.parts.some(part => part.name === fix.part)) return undefined
49
+
50
+ const source: FigureSource = {
51
+ ...current,
52
+ parts: current.parts.map(part =>
53
+ part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part
54
+ )
55
+ }
56
+
57
+ return {
58
+ source,
59
+ title: `발행 검사 수정안 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}`,
60
+ picked: selectedKeys(current, source),
61
+ note: ''
62
+ }
63
+ }
64
+
65
+ /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
66
+ export function toggleProposalChange(session: ProposalSession, key: string): ProposalSession {
67
+ const picked = new Set(session.picked)
68
+ if (picked.has(key)) picked.delete(key)
69
+ else picked.add(key)
70
+ return { ...session, picked }
71
+ }
72
+
73
+ /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
74
+ export function feedbackFromSession(
75
+ current: FigureSource | undefined,
76
+ session: ProposalSession,
77
+ outcome: ProposalFeedback['outcome']
78
+ ): ProposalFeedback | undefined {
79
+ if (!session.metrics) return undefined
80
+
81
+ return {
82
+ outcome,
83
+ selectedChanges: outcome === 'accepted' ? session.picked.size : 0,
84
+ totalChanges: diffProposal(current, session.source).length,
85
+ note: session.note.trim().slice(0, 500) || undefined,
86
+ grade: session.metrics.grade,
87
+ quality: {
88
+ status: session.metrics.quality.status,
89
+ findingCodes: session.metrics.quality.findings.map(finding => finding.code)
90
+ }
91
+ }
92
+ }
@@ -36,10 +36,21 @@ const CAPACITY = 'font-weight: 700; color: var(--md-sys-color-primary);'
36
36
  const UNMEASURED = 'font-style: italic; color: var(--md-sys-color-outline);'
37
37
  const RELEASED = 'font-weight: 700; color: var(--md-sys-color-primary);'
38
38
  const DRAFT = 'color: var(--md-sys-color-tertiary);'
39
- /** 조용히 적는 숫자. 낮은 것만 점으로 눈에 걸리게 한다. */
40
- const SCORE = 'display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums;'
39
+ /**
40
+ * 조용히 적는 숫자. 낮은 것만 점으로 눈에 걸리게 한다.
41
+ *
42
+ * **`inline-flex` 를 쓰지 않는다.** 썼더니 점이 뜨는 카드에서만 이 값이 옆의 두 값과 밑줄이
43
+ * 어긋났다 — inline-flex 상자의 밑줄은 **첫 항목**에서 오는데, 첫 항목이 글자 없는 점이라
44
+ * 밑줄이 없어 브라우저가 상자 아래를 밑줄로 삼는다. 점이 없는 카드(50 이상)는 멀쩡했으므로
45
+ * 한 줄에 세 값이 나란히 설 때만 눈에 띄었다.
46
+ *
47
+ * 그냥 인라인으로 두면 이 상자의 밑줄이 곧 숫자의 밑줄이라 옆의 둘과 맞는다. 점은 제 몫으로
48
+ * 글자 가운데에 맞춘다.
49
+ */
50
+ const SCORE = 'font-variant-numeric: tabular-nums;'
41
51
  const LOW_DOT =
42
- 'width: 5px; height: 5px; border-radius: 50%; background-color: var(--md-sys-color-error); flex: none;'
52
+ 'display: inline-block; width: 5px; height: 5px; margin-right: 4px; vertical-align: middle; ' +
53
+ 'border-radius: 50%; background-color: var(--md-sys-color-error);'
43
54
 
44
55
  /*
45
56
  * `as typeof PageView` 로 조이지 않는다 — 그렇게 하면 p13n 이 얹어 주는
@@ -179,7 +190,7 @@ export class FigureListPage extends FigureListPageBase {
179
190
  this.grist.searchText = search
180
191
  },
181
192
  value: this.grist?.searchText || '',
182
- placeholder: i18next.t('figure.text.search-figure-by-name-or-type')
193
+ placeholder: i18next.t('figure.text.search-figure')
183
194
  },
184
195
  filter: {
185
196
  handler: () => {
@@ -200,6 +211,7 @@ export class FigureListPage extends FigureListPageBase {
200
211
  return html`
201
212
  <ox-grist
202
213
  mode="CARD"
214
+ @search-tag=${(e: CustomEvent) => this.findByTag(e.detail.tag)}
203
215
  .config=${this.gristConfig}
204
216
  .fetchHandler=${this.fetchHandler.bind(this)}
205
217
  .personalConfigProvider=${this.getPagePreferenceProvider('ox-grist')!}
@@ -219,6 +231,21 @@ export class FigureListPage extends FigureListPageBase {
219
231
  `
220
232
  }
221
233
 
234
+ /**
235
+ * 카드의 태그 알약을 눌렀다 — **그 태그로 목록을 거른다.**
236
+ *
237
+ * 검색창에 그 말을 채워 넣는다. 격자에 조건만 걸고 창을 비워 두면 저작자는 **왜 목록이
238
+ * 줄었는지 모른 채** 보게 되고, 되돌리려면 무엇을 지워야 하는지도 알 수 없다.
239
+ *
240
+ * 서버가 `tags` 뿐 아니라 이름·타입·설명·분류를 함께 훑으므로, 그 말이 다른 칸에 든 도형도
241
+ * 같이 온다. 태그만 정확히 거르는 것이 아니라 **찾기**다.
242
+ */
243
+ private findByTag(tag: string) {
244
+ this.grist.searchText = tag
245
+ /* 창의 값은 `context` 에서 나온다 — 다시 알려야 헤더가 새 값을 그린다. */
246
+ this.updateContext()
247
+ }
248
+
222
249
  async pageInitialized() {
223
250
  this.gristConfig = {
224
251
  /*
@@ -300,6 +327,31 @@ export class FigureListPage extends FigureListPageBase {
300
327
  sortable: true,
301
328
  width: 160
302
329
  },
330
+ {
331
+ /*
332
+ 태그 — **카드에서 곁줄로 보이고 검색에 걸린다.**
333
+
334
+ 서버가 이미 `type`·`name`·`description`·`category`·`tags` 를 함께 훑는다. 그런데
335
+ 화면에는 태그가 어디에도 안 보여서, 저작자가 적은 태그가 **자기 눈에 돌아오지 않았다** —
336
+ 적어 둔 것이 쓰이는지 알 수 없으면 아무도 적지 않는다.
337
+
338
+ 정렬은 걸지 않는다. 배열이라 무엇을 기준으로 줄을 세우는지 답할 수 없다.
339
+
340
+ **카드에서는 이 컬럼이 그리지 않는다.** 본문에 한 줄로 두었더니 태그가 있는 카드만
341
+ 높아져 격자가 어긋났다 — 개수가 도형마다 다르므로 본문에 두는 한 남는 문제다.
342
+ 카드에서는 `figure-thumb` 이 그림 위에 얹는다. 여기 남은 것은 표 모드의 칸이다.
343
+ */
344
+ type: 'string',
345
+ name: 'tags',
346
+ header: i18next.t('figure.label.tags'),
347
+ record: {
348
+ editable: false,
349
+ renderer: (value: string[] | null) => (value || []).join(' · ')
350
+ },
351
+ filter: 'search',
352
+ sortable: false,
353
+ width: 180
354
+ },
303
355
  {
304
356
  type: 'string',
305
357
  name: 'state',