@things-factory/figure-ui 10.1.4 → 10.1.6

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 +122 -15
  2. package/client/modeller/figure-ask.ts +5 -5
  3. package/client/modeller/figure-canvas.ts +1 -1
  4. package/client/modeller/figure-capabilities.ts +372 -0
  5. package/client/modeller/figure-history-panel.ts +477 -0
  6. package/client/modeller/figure-inspector.ts +392 -76
  7. package/client/modeller/figure-parts.ts +15 -15
  8. package/client/modeller/figure-preview.ts +2 -2
  9. package/client/modeller/figure-report.ts +102 -37
  10. package/client/modeller/figure-settings.ts +20 -20
  11. package/client/modeller/figure-side.ts +54 -8
  12. package/client/modeller/figure-source.test.ts +15 -1
  13. package/client/modeller/figure-source.ts +31 -15
  14. package/client/modeller/figure-thumbnail.ts +49 -4
  15. package/client/modeller/maturity.ts +10 -10
  16. package/client/pages/figure-list-page.ts +450 -227
  17. package/client/pages/figure-modeller-page.ts +282 -43
  18. package/client/types.ts +42 -0
  19. package/client/viewparts/figure-thumb.ts +172 -0
  20. package/dist-client/graphql/index.d.ts +31 -4
  21. package/dist-client/graphql/index.js +102 -12
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/figure-ask.js +5 -5
  24. package/dist-client/modeller/figure-ask.js.map +1 -1
  25. package/dist-client/modeller/figure-canvas.js +1 -1
  26. package/dist-client/modeller/figure-canvas.js.map +1 -1
  27. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  28. package/dist-client/modeller/figure-capabilities.js +344 -0
  29. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  30. package/dist-client/modeller/figure-history-panel.d.ts +56 -0
  31. package/dist-client/modeller/figure-history-panel.js +490 -0
  32. package/dist-client/modeller/figure-history-panel.js.map +1 -0
  33. package/dist-client/modeller/figure-inspector.d.ts +84 -0
  34. package/dist-client/modeller/figure-inspector.js +379 -75
  35. package/dist-client/modeller/figure-inspector.js.map +1 -1
  36. package/dist-client/modeller/figure-parts.js +15 -15
  37. package/dist-client/modeller/figure-parts.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.js +2 -2
  39. package/dist-client/modeller/figure-preview.js.map +1 -1
  40. package/dist-client/modeller/figure-report.d.ts +18 -1
  41. package/dist-client/modeller/figure-report.js +105 -37
  42. package/dist-client/modeller/figure-report.js.map +1 -1
  43. package/dist-client/modeller/figure-settings.js +19 -19
  44. package/dist-client/modeller/figure-settings.js.map +1 -1
  45. package/dist-client/modeller/figure-side.d.ts +7 -0
  46. package/dist-client/modeller/figure-side.js +66 -7
  47. package/dist-client/modeller/figure-side.js.map +1 -1
  48. package/dist-client/modeller/figure-source.d.ts +15 -8
  49. package/dist-client/modeller/figure-source.js +16 -7
  50. package/dist-client/modeller/figure-source.js.map +1 -1
  51. package/dist-client/modeller/figure-thumbnail.js +40 -4
  52. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  53. package/dist-client/modeller/maturity.js +10 -10
  54. package/dist-client/modeller/maturity.js.map +1 -1
  55. package/dist-client/pages/figure-list-page.d.ts +45 -25
  56. package/dist-client/pages/figure-list-page.js +439 -247
  57. package/dist-client/pages/figure-list-page.js.map +1 -1
  58. package/dist-client/pages/figure-modeller-page.d.ts +54 -0
  59. package/dist-client/pages/figure-modeller-page.js +279 -43
  60. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-client/types.d.ts +42 -0
  63. package/dist-client/types.js.map +1 -1
  64. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  65. package/dist-client/viewparts/figure-thumb.js +168 -0
  66. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  67. package/dist-server/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +9 -5
  69. package/test/i18n-prefix-guard.test.ts +129 -0
  70. package/test/korean-register-guard.test.ts +83 -0
  71. package/translations/en.json +307 -249
  72. package/translations/ja.json +275 -249
  73. package/translations/ko.json +307 -249
  74. package/translations/ms.json +275 -249
  75. package/translations/zh.json +275 -249
  76. package/client/viewparts/figure-card.ts +0 -226
  77. package/dist-client/viewparts/figure-card.d.ts +0 -24
  78. package/dist-client/viewparts/figure-card.js +0 -221
  79. package/dist-client/viewparts/figure-card.js.map +0 -1
@@ -1,7 +1,16 @@
1
1
  import gql from 'graphql-tag'
2
2
  import { client } from '@operato/graphql'
3
3
 
4
- import type { Figure, FigureFinding, FigureListResult, FigurePatch, FigureProposal, NewFigure } from '../types.js'
4
+ import type {
5
+ Figure,
6
+ FigureFinding,
7
+ FigureInspection,
8
+ FigureListResult,
9
+ FigurePatch,
10
+ FigureProposal,
11
+ FigureVersion,
12
+ NewFigure
13
+ } from '../types.js'
5
14
 
6
15
  /**
7
16
  * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**
@@ -29,8 +38,10 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
29
38
  /**
30
39
  * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.
31
40
  *
32
- * `score`·`triangles` 함께 받는 이유는 카드에 그것을 보여 주기 때문이다. 저작자가
33
- * 목록에서 「이 Figure 무거운가」를 바로 알아야 한다.
41
+ * **`thumbnail` 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림
42
+ * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로
43
+ * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를
44
+ * 그것으로 알고, 판을 가리는 값으로도 쓴다.
34
45
  */
35
46
  const FIGURE_LIST_FIELDS = `
36
47
  id
@@ -39,26 +50,28 @@ const FIGURE_LIST_FIELDS = `
39
50
  description
40
51
  category
41
52
  state
53
+ version
42
54
  score
43
55
  triangles
44
56
  groups
45
- thumbnail
46
57
  thumbnailUpdatedAt
47
58
  updatedAt
48
59
  updater { id name }
49
60
  `
50
61
 
62
+ /**
63
+ * 목록을 가져온다.
64
+ *
65
+ * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어
66
+ * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
67
+ * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
68
+ */
51
69
  export async function fetchFigureList(params: {
52
70
  page?: number
53
71
  limit?: number
54
- search?: string
55
- category?: string
56
- state?: string
72
+ filters?: unknown[]
73
+ sortings?: { name: string; desc?: boolean }[]
57
74
  }): Promise<FigureListResult> {
58
- const filters: { name: string; operator: string; value: unknown }[] = []
59
- if (params.category) filters.push({ name: 'category', operator: 'eq', value: params.category })
60
- if (params.state) filters.push({ name: 'state', operator: 'eq', value: params.state })
61
-
62
75
  const response = await client.query({
63
76
  query: gql`
64
77
  query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
@@ -69,9 +82,9 @@ export async function fetchFigureList(params: {
69
82
  }
70
83
  `,
71
84
  variables: {
72
- filters,
85
+ filters: params.filters ?? [],
73
86
  pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },
74
- sortings: [{ name: 'updatedAt', desc: true }]
87
+ sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]
75
88
  },
76
89
  fetchPolicy: 'network-only'
77
90
  })
@@ -88,7 +101,6 @@ export async function fetchFigure(id: string): Promise<Figure> {
88
101
  ${FIGURE_LIST_FIELDS}
89
102
  source
90
103
  properties
91
- version
92
104
  }
93
105
  }
94
106
  `,
@@ -145,7 +157,7 @@ export async function updateFigure(id: string, patch: FigurePatch): Promise<Save
145
157
  mutation: gql`
146
158
  mutation ($id: String!, $patch: FigurePatch!) {
147
159
  updateFigure(id: $id, patch: $patch) {
148
- figure { ${FIGURE_LIST_FIELDS} source version }
160
+ figure { ${FIGURE_LIST_FIELDS} source }
149
161
  violations { code message at }
150
162
  }
151
163
  }
@@ -207,3 +219,98 @@ export async function proposeFigure(request: {
207
219
 
208
220
  return unwrap<FigureProposal>(response, 'proposeFigure')
209
221
  }
222
+
223
+ /**
224
+ * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.
225
+ *
226
+ * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은
227
+ * 발행을 푼다(초안으로 돌아온다).
228
+ */
229
+ export async function releaseFigure(id: string, comment?: string): Promise<Figure> {
230
+ const response = await client.mutate({
231
+ mutation: gql`
232
+ mutation ReleaseFigure($id: String!, $comment: String) {
233
+ releaseFigure(id: $id, comment: $comment) {
234
+ id
235
+ version
236
+ state
237
+ updatedAt
238
+ }
239
+ }
240
+ `,
241
+ variables: { id, comment }
242
+ })
243
+
244
+ return unwrap<Figure>(response, 'releaseFigure')
245
+ }
246
+
247
+ /** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */
248
+ export async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {
249
+ const response = await client.mutate({
250
+ mutation: gql`
251
+ mutation RevertFigureVersion($id: String!, $version: Float!) {
252
+ revertFigureVersion(id: $id, version: $version) {
253
+ figure { ${FIGURE_LIST_FIELDS} source }
254
+ violations { code message at }
255
+ }
256
+ }
257
+ `,
258
+ variables: { id, version }
259
+ })
260
+
261
+ return unwrap<SaveResult>(response, 'revertFigureVersion')
262
+ }
263
+
264
+ /**
265
+ * 발행해도 되나 — **누르기 전에** 묻는다.
266
+ *
267
+ * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이
268
+ * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.
269
+ */
270
+ export async function inspectFigure(id: string): Promise<FigureInspection> {
271
+ const response = await client.query({
272
+ query: gql`
273
+ query InspectFigure($id: String!) {
274
+ inspectFigure(id: $id) {
275
+ blocked
276
+ findings {
277
+ code
278
+ message
279
+ why
280
+ how
281
+ at
282
+ blocking
283
+ }
284
+ }
285
+ }
286
+ `,
287
+ variables: { id },
288
+ fetchPolicy: 'network-only'
289
+ })
290
+
291
+ return unwrap<FigureInspection>(response, 'inspectFigure')
292
+ }
293
+
294
+ /** 발행된 판들 — 최근 것부터 열 개. */
295
+ export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
296
+ const response = await client.query({
297
+ query: gql`
298
+ query FigureVersions($id: String!) {
299
+ figureVersions(id: $id) {
300
+ version
301
+ comment
302
+ state
303
+ updatedAt
304
+ updater { id name }
305
+ score
306
+ triangles
307
+ groups
308
+ }
309
+ }
310
+ `,
311
+ variables: { id },
312
+ fetchPolicy: 'network-only'
313
+ })
314
+
315
+ return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []
316
+ }
@@ -186,21 +186,21 @@ export class FigureAsk extends localize(i18next)(LitElement) {
186
186
  return html`
187
187
  <form @submit=${(e: Event) => this.submit(e)}>
188
188
  <md-icon lead>auto_awesome</md-icon>
189
- <span mode>${i18next.t(revising ? 'text.ask-to-revise' : 'text.ask-to-create')}</span>
189
+ <span mode>${i18next.t(revising ? 'figure.text.ask-to-revise' : 'figure.text.ask-to-create')}</span>
190
190
  <input
191
191
  ask
192
192
  ?disabled=${this.asking}
193
- placeholder=${i18next.t(revising ? 'text.ask-revise-example' : 'text.ask-create-example')}
193
+ placeholder=${i18next.t(revising ? 'figure.text.ask-revise-example' : 'figure.text.ask-create-example')}
194
194
  />
195
- <label picture ?has=${!!this.picture} title=${i18next.t('text.picture-is-for-proportions')}>
195
+ <label picture ?has=${!!this.picture} title=${i18next.t('figure.text.picture-is-for-proportions')}>
196
196
  <md-icon>image</md-icon>
197
197
  ${this.picture
198
198
  ? html`<span filename>${this.picture.name}</span>`
199
- : i18next.t('button.attach-a-picture')}
199
+ : i18next.t('figure.button.attach-a-picture')}
200
200
  <input type="file" accept="image/*" @change=${(e: Event) => this.pick(e)} />
201
201
  </label>
202
202
  <button go ?disabled=${this.asking}>
203
- ${this.asking ? i18next.t('text.asking') : i18next.t('button.ask')}
203
+ ${this.asking ? i18next.t('figure.text.asking') : i18next.t('figure.button.ask')}
204
204
  </button>
205
205
  </form>
206
206
 
@@ -302,7 +302,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
302
302
  private standUp() {
303
303
  const board = this.board
304
304
  if (!board) {
305
- this.cannot = i18next.t('text.canvas-appears-once-a-part-exists')
305
+ this.cannot = i18next.t('figure.text.canvas-appears-once-a-part-exists')
306
306
  return
307
307
  }
308
308
 
@@ -0,0 +1,372 @@
1
+ import { css, html, LitElement } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { live } from 'lit/directives/live.js'
4
+
5
+ import { i18next, localize } from '@operato/i18n'
6
+ import { ScrollbarStyles } from '@operato/styles'
7
+
8
+ import type { BoardModel, PartModel } from './figure-source.js'
9
+
10
+ /**
11
+ * 이 도형이 **무엇을 하는가.**
12
+ *
13
+ * ## 왜 탭 하나가 필요한가
14
+ *
15
+ * 지금까지 저작한 것은 **생김새**뿐이었다. 컨베이어를 아무리 잘 그려도 보드에 놓으면
16
+ * 상자 하나를 못 받는다 — 물건을 받고 담고 흘려보내는 것은 씬의 capability 가 하는 일이고,
17
+ * 손으로 쓴 컴포넌트는 클래스 선언 한 줄로 그것을 졌다. 저작한 도형에는 그 줄을 쓸 자리가
18
+ * 없어서, 여기서 고른 것이 그 줄이 된다.
19
+ *
20
+ * ## 사다리로 묻는다
21
+ *
22
+ * 능력의 이름(`Capacity` · `Holdable` · `Transferable` · `FlowNode`)을 그대로 늘어놓고
23
+ * 고르라고 하면, 저작자는 넷의 관계를 먼저 배워야 한다. 실제로 물어야 하는 것은 하나다 —
24
+ * **이 도형이 물건을 다루나, 다룬다면 지나가나.** 그래서 세 칸의 사다리로 묻고, 이름은
25
+ * 고른 뒤에 보여 준다.
26
+ *
27
+ * 윗 칸이 아랫 칸을 포함한다(`FlowNode` 가 나머지 셋을 스스로 흡수한다). 그래서 사다리이지
28
+ * 체크상자가 아니다.
29
+ */
30
+
31
+ /** 사다리 한 칸 — 화면이 묻는 말과, 그것이 되는 능력 이름. */
32
+ type Rung = 'none' | 'hold' | 'flow'
33
+
34
+ const RUNGS: Readonly<Record<Rung, readonly string[]>> = Object.freeze({
35
+ none: [],
36
+ hold: ['Capacity', 'Holdable'],
37
+ flow: ['FlowNode']
38
+ })
39
+
40
+ /** 선언된 이름 집합이 사다리 어느 칸인가. 어느 칸도 아니면 `null` — 지어내지 않는다. */
41
+ function rungOf(names: readonly string[] | undefined): Rung | null {
42
+ const has = new Set(names ?? [])
43
+ if (has.size === 0) return 'none'
44
+
45
+ for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly string[]][]) {
46
+ if (wanted.length === has.size && wanted.every(name => has.has(name))) return rung
47
+ }
48
+ return null
49
+ }
50
+
51
+ @customElement('figure-capabilities')
52
+ export class FigureCapabilities extends localize(i18next)(LitElement) {
53
+ static styles = [
54
+ ScrollbarStyles,
55
+ css`
56
+ :host {
57
+ display: flex;
58
+ flex-direction: column;
59
+ min-height: 0;
60
+ overflow-y: auto;
61
+ background-color: var(--md-sys-color-surface-container-lowest);
62
+ color: var(--md-sys-color-on-surface);
63
+ font: var(--label-font, inherit);
64
+ font-size: 0.76rem;
65
+ }
66
+
67
+ section {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: var(--spacing-medium, 8px);
71
+ padding: var(--spacing-large, 12px);
72
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
73
+ }
74
+
75
+ /* 다른 탭들과 같은 눈금이다 — 탭마다 다른 제목 규격을 쓰면 따로 논다 */
76
+ h3 {
77
+ margin: 0 0 2px 0;
78
+ font-size: var(--ui-t-micro, 11px);
79
+ font-weight: 700;
80
+ letter-spacing: 0.04em;
81
+ text-transform: uppercase;
82
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
83
+ }
84
+
85
+ p[quiet] {
86
+ margin: 0;
87
+ font-size: 0.72rem;
88
+ line-height: 1.5;
89
+ color: var(--md-sys-color-on-surface-variant);
90
+ }
91
+
92
+ /* 사다리 — 고르는 것이 하나라 라디오다 */
93
+ label[rung] {
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: var(--spacing-medium, 8px);
97
+ padding: 6px 8px;
98
+ border: 1px solid var(--md-sys-color-outline-variant);
99
+ border-radius: 4px;
100
+ cursor: pointer;
101
+ }
102
+ label[rung][on] {
103
+ border-color: var(--md-sys-color-primary);
104
+ background-color: var(--md-sys-color-surface-container);
105
+ }
106
+ label[rung]:hover:not([on]) {
107
+ background-color: var(--md-sys-color-surface-container-low);
108
+ }
109
+ label[rung] span {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 1px;
113
+ }
114
+ label[rung] strong {
115
+ font-weight: 600;
116
+ }
117
+ label[rung] em {
118
+ font-style: normal;
119
+ font-size: 0.7rem;
120
+ line-height: 1.4;
121
+ color: var(--md-sys-color-on-surface-variant);
122
+ }
123
+
124
+ input[type='radio'] {
125
+ flex: none;
126
+ margin: 2px 0 0 0;
127
+ accent-color: var(--md-sys-color-primary);
128
+ cursor: pointer;
129
+ }
130
+
131
+ /* 실제로 씬에 붙는 이름. 고른 뒤에 보여 준다 */
132
+ code[names] {
133
+ font: var(--mono-font, ui-monospace, monospace);
134
+ font-size: 0.7rem;
135
+ color: var(--md-sys-color-on-surface-variant);
136
+ }
137
+
138
+ /* 지정된 부품 한 줄 — 읽는 것이지 고치는 것이 아니다. 눌러 그 부품으로 간다 */
139
+ button[marked] {
140
+ display: flex;
141
+ align-items: baseline;
142
+ gap: var(--spacing-medium, 8px);
143
+ width: 100%;
144
+ padding: 5px 8px;
145
+ text-align: left;
146
+ font: inherit;
147
+ color: inherit;
148
+ background: none;
149
+ border: 1px solid var(--md-sys-color-outline-variant);
150
+ border-radius: 4px;
151
+ cursor: pointer;
152
+ }
153
+ button[marked]:hover {
154
+ background-color: var(--md-sys-color-surface-container);
155
+ }
156
+ button[marked] b {
157
+ flex: none;
158
+ font-weight: 600;
159
+ }
160
+ button[marked] span {
161
+ flex: none;
162
+ font-size: 0.72rem;
163
+ color: var(--md-sys-color-primary);
164
+ }
165
+ button[marked] em {
166
+ flex: 1;
167
+ min-width: 0;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ white-space: nowrap;
171
+ text-align: right;
172
+ font-style: normal;
173
+ font-size: 0.7rem;
174
+ color: var(--md-sys-color-on-surface-variant);
175
+ }
176
+
177
+ /* 부품마다 한 줄. 자리를 고른 것만 아래로 펼친다 */
178
+ div[part] {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 4px;
182
+ padding: 6px 0;
183
+ border-top: 1px solid var(--md-sys-color-outline-variant);
184
+ }
185
+ div[part]:first-of-type {
186
+ border-top: none;
187
+ }
188
+ div[head] {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: var(--spacing-medium, 8px);
192
+ }
193
+ div[head] b {
194
+ flex: 1;
195
+ min-width: 0;
196
+ overflow: hidden;
197
+ text-overflow: ellipsis;
198
+ white-space: nowrap;
199
+ font-weight: 600;
200
+ }
201
+
202
+ select,
203
+ input[type='text'],
204
+ input[type='number'] {
205
+ padding: 4px 6px;
206
+ font: var(--input-field-font, inherit);
207
+ font-size: 0.74rem;
208
+ color: var(--md-sys-color-on-surface);
209
+ background-color: var(--md-sys-color-surface);
210
+ border: 1px solid var(--md-sys-color-outline-variant);
211
+ border-radius: 4px;
212
+ }
213
+ /* 이름이 길어질 수 있는 쪽(문)까지 안 잘리는 폭이다 */
214
+ div[head] select {
215
+ flex: none;
216
+ width: 8.5em;
217
+ }
218
+
219
+ div[detail] {
220
+ display: grid;
221
+ grid-template-columns: 4.5em 1fr;
222
+ align-items: center;
223
+ gap: 4px var(--spacing-medium, 8px);
224
+ padding-left: var(--spacing-medium, 8px);
225
+ }
226
+ div[detail] > span {
227
+ font-size: 0.72rem;
228
+ color: var(--md-sys-color-on-surface-variant);
229
+ }
230
+ div[detail] input {
231
+ width: 100%;
232
+ box-sizing: border-box;
233
+ }
234
+ div[detail] em {
235
+ grid-column: 2;
236
+ font-style: normal;
237
+ font-size: 0.7rem;
238
+ color: var(--md-sys-color-on-surface-variant);
239
+ }
240
+ `
241
+ ]
242
+
243
+ @property({ type: Object }) board?: BoardModel
244
+ @property({ type: Array }) parts: PartModel[] = []
245
+
246
+ render() {
247
+ const rung = rungOf(this.board?.capabilities)
248
+ const names = this.board?.capabilities ?? []
249
+
250
+ return html`
251
+ <section>
252
+ <h3>${i18next.t('figure.label.what-this-does')}</h3>
253
+
254
+ ${this.rung('none', rung)} ${this.rung('hold', rung)} ${this.rung('flow', rung)}
255
+ ${rung === null
256
+ ? html`<p quiet>${i18next.t('figure.text.capability-hand-written')}</p>`
257
+ : ''}
258
+ ${names.length > 0 ? html`<code names>${names.join(' · ')}</code>` : ''}
259
+ </section>
260
+
261
+ ${this.renderAnchors(rung)}
262
+ `
263
+ }
264
+
265
+ /**
266
+ * 자리와 문 — **여기서는 읽기만 한다.**
267
+ *
268
+ * 지정은 속성 탭에서 한다. 부품을 고치는 곳이 한 군데여야 하기 때문이다 — 목록을 여기 또
269
+ * 두면 같은 부품을 두 화면에서 고치게 되고, 저작자는 어느 쪽이 이기는지 매번 생각해야 한다.
270
+ *
271
+ * 그래도 요약은 남긴다. 「어느 부품이 자리인가」는 부품을 하나씩 눌러 보며 알아낼 일이
272
+ * 아니다 — 자리는 그려지지 않으므로 화면을 봐서는 영영 못 찾는다.
273
+ */
274
+ private renderAnchors(rung: Rung | null) {
275
+ if (rung === 'none') {
276
+ return html`
277
+ <section>
278
+ <h3>${i18next.t('figure.label.slots-and-ports')}</h3>
279
+ <p quiet>${i18next.t('figure.text.slots-need-capability')}</p>
280
+ </section>
281
+ `
282
+ }
283
+
284
+ const marked = this.parts.filter(part => part.capability)
285
+
286
+ return html`
287
+ <section>
288
+ <h3>${i18next.t('figure.label.slots-and-ports')}</h3>
289
+ <p quiet>${i18next.t('figure.text.anchors-are-not-drawn')}</p>
290
+
291
+ ${marked.length === 0
292
+ ? html`<p quiet>${i18next.t('figure.text.designate-in-properties')}</p>`
293
+ : marked.map(part => this.renderMarked(part))}
294
+ </section>
295
+ `
296
+ }
297
+
298
+ /** 지정된 부품 한 줄. 누르면 그 부품이 골라진다 — 고치는 자리는 속성 탭이다. */
299
+ private renderMarked(part: PartModel) {
300
+ const spec = part.capability!
301
+
302
+ return html`
303
+ <button marked @click=${() => this.pick(part)}>
304
+ <b>${part.name}</b>
305
+ <span>${spec.roles.map(role => i18next.t('figure.label.role-' + role)).join(' · ')}</span>
306
+ ${spec.accepts?.length ? html`<em>${spec.accepts.join(' · ')}</em>` : ''}
307
+ </button>
308
+ `
309
+ }
310
+
311
+ /** 이 부품을 고르게 한다. 부품 목록에서 누른 것과 같은 알림이다. */
312
+ private pick(part: PartModel) {
313
+ const index = this.parts.indexOf(part)
314
+ if (index < 0) return
315
+
316
+ this.dispatchEvent(new CustomEvent('select-part', { detail: { index }, bubbles: true, composed: true }))
317
+ }
318
+
319
+ /**
320
+ * 사다리 한 칸.
321
+ *
322
+ * `checked` 를 `live()` 로 묶는다. 그냥 두면 Lit 이 **마지막으로 그린 값**과 견주므로,
323
+ * 브라우저가 DOM 을 바꿨는데 모델은 안 바뀐 경우 되돌리지 않는다. 실제로 그랬다 — 고르는
324
+ * 도중 도형이 다시 실렸더니 점은 「흐른다」에 있고 강조 상자는 「그리기만 한다」에 있었다.
325
+ * 화면이 모델과 다른 말을 하는 상태다. `live()` 는 **지금 DOM 의 값**과 견준다.
326
+ */
327
+ private rung(which: Rung, chosen: Rung | null) {
328
+ return html`
329
+ <label rung ?on=${chosen === which}>
330
+ <input
331
+ type="radio"
332
+ name="rung"
333
+ .checked=${live(chosen === which)}
334
+ @change=${() => this.setRung(which)}
335
+ />
336
+ <span>
337
+ <strong>${i18next.t(`figure.label.rung-${which}`)}</strong>
338
+ <em>${i18next.t(`figure.text.rung-${which}-note`)}</em>
339
+ </span>
340
+ </label>
341
+ `
342
+ }
343
+
344
+ /**
345
+ * 사다리를 내리면 자리도 함께 거둔다.
346
+ *
347
+ * 능력을 「그리기만 한다」로 되돌려 놓고 부품에 자리 표시가 남아 있으면, 형식은 자리를
348
+ * 그대로 실어 보내는데 씬은 그것을 쓸 클래스가 없다. **선언은 있고 길이 없는** 상태가
349
+ * 되고, 그 도형은 멀쩡해 보이면서 아무것도 못 받는다.
350
+ */
351
+ private setRung(which: Rung) {
352
+ const board = this.board
353
+ if (!board) return
354
+
355
+ this.tellBoard({ ...board, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined })
356
+
357
+ if (which === 'none') {
358
+ this.parts.forEach((part, index) => {
359
+ if (!part.capability) return
360
+ const { capability, ...rest } = part
361
+ this.dispatchEvent(
362
+ new CustomEvent('update-part', { detail: { index, part: rest }, bubbles: true, composed: true })
363
+ )
364
+ })
365
+ }
366
+ }
367
+
368
+ private tellBoard(board: BoardModel) {
369
+ this.board = board
370
+ this.dispatchEvent(new CustomEvent('update-board', { detail: { board }, bubbles: true, composed: true }))
371
+ }
372
+ }