@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,895 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing, svg } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
6
+
7
+ import { i18next, localize } from '@operato/i18n'
8
+ import { ScrollbarStyles } from '@operato/styles'
9
+ import { compile, costCurveSvg, costOf, scoreOf, validate } from '@hatiolab/figure-model'
10
+ import type { FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'
11
+
12
+ import { capacityOf } from './scene-budget.js'
13
+ import { maturityOf } from './maturity.js'
14
+ import type { MaturityAxis } from './maturity.js'
15
+ import type { FigureCapacity } from './scene-budget.js'
16
+
17
+ /**
18
+ * 판단 패널. 모델러 화면에 **늘 떠 있다.**
19
+ *
20
+ * 저장을 선택해야 아는 것이 아니다. 부품을 하나 놓을 때마다 무게와 점수가 움직이고,
21
+ * 그것이 보여야 저작자가 고칠 수 있다. 「무엇 때문에 점수가 낮은가」를 말해 주지
22
+ * 않으면 점수는 잔소리일 뿐이다.
23
+ *
24
+ * ## 셈을 여기서 하지 않는다
25
+ *
26
+ * 검증·원가·점수는 전부 `@hatiolab/figure-model` 이 한다. 화면은 **부르고 보여 줄
27
+ * 뿐**이다. 한도나 등급 경계를 화면이 다시 정하면 서버가 거절할 것을 화면이
28
+ * 통과시키게 된다.
29
+ */
30
+ @customElement('figure-report')
31
+ export class FigureReport extends localize(i18next)(LitElement) {
32
+ static styles = [
33
+ ScrollbarStyles,
34
+ css`
35
+ :host {
36
+ display: flex;
37
+ flex-direction: column;
38
+ overflow-y: auto;
39
+ background-color: var(--md-sys-color-surface-container-lowest);
40
+ border-left: 1px solid var(--md-sys-color-outline-variant);
41
+ color: var(--md-sys-color-on-surface);
42
+ font: var(--label-font, inherit);
43
+ /*
44
+ 이 패널의 본문 크기를 여기서 한 번 정한다.
45
+
46
+ 전에는 규칙마다 따로 적었다. 그러면 빠뜨린 규칙이 본문 기본 크기(1rem)를
47
+ 물려받아 그 줄만 제목처럼 커진다. 오류가 아니라서 화면을 띄워 봐야 안다.
48
+ 이 파일에서만 두 번 났다.
49
+
50
+ form 요소(input · select · button)는 글꼴을 물려받지 않으므로 각자 적어야 한다.
51
+ 그것 말고는 여기 값을 따른다.
52
+ */
53
+ font-size: 0.76rem;
54
+ }
55
+
56
+ section {
57
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
58
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
59
+ }
60
+ section > h3 {
61
+ margin: 0 0 6px 0;
62
+ font-size: var(--ui-t-micro, 11px);
63
+ font-weight: 700;
64
+ letter-spacing: 0.04em;
65
+ text-transform: uppercase;
66
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
67
+ }
68
+
69
+ /* ── 몇 개까지 편한가 ───────────────────────────────────────────
70
+ 로우폴리의 값은 「얼마나 많이 편하게 놓을 수 있나」다. 그 답이 머리에 온다.
71
+ 등급은 형상 자체가 한도 안인지를 말할 뿐이라 옆의 작은 badge 로 둔다. */
72
+ div[verdict] {
73
+ display: flex;
74
+ align-items: baseline;
75
+ gap: 6px;
76
+ }
77
+ span[capacity] {
78
+ font-size: 2.1rem;
79
+ font-weight: 800;
80
+ line-height: 1;
81
+ letter-spacing: -0.01em;
82
+ color: var(--md-sys-color-primary);
83
+ }
84
+ span[unit] {
85
+ font-size: 0.9rem;
86
+ font-weight: 600;
87
+ color: var(--md-sys-color-primary);
88
+ }
89
+ /*
90
+ 등급 badge.
91
+
92
+ 전에는 "C 0" 만 있었다. 글자 하나와 숫자 하나인데 어디에도 뜻이 없어서, 크게 뜬
93
+ 개수 옆에 빨간 badge 가 붙은 것으로만 읽혔다. 0 을 100점 만점의 0 점으로 읽는 것이
94
+ 자연스럽다. 실제 뜻은 "선언한 한도에 남은 여유가 0" 이다.
95
+
96
+ 그래서 무엇을 측정하는지를 badge 안에 적고, 눈금은 바로 아래 줄에 늘 보이게 둔다.
97
+ */
98
+ span[grade] {
99
+ display: flex;
100
+ align-items: baseline;
101
+ gap: 5px;
102
+ margin-left: auto;
103
+ padding: 3px 10px;
104
+ font-size: 0.76rem;
105
+ font-weight: 700;
106
+ color: var(--md-sys-color-on-primary-container);
107
+ background-color: var(--md-sys-color-primary-container);
108
+ border-radius: 999em;
109
+ }
110
+ span[grade] small {
111
+ font-size: 0.68rem;
112
+ font-weight: 500;
113
+ }
114
+ /*
115
+ C 는 한도를 지킨 것이다. 빨강은 넘긴 것(D)에만 쓴다. 지킨 것에 빨강을 쓰면
116
+ 고칠 것이 없는데도 고쳐야 할 것처럼 보인다.
117
+ */
118
+ span[grade][tight] {
119
+ color: var(--md-sys-color-on-tertiary-container, #3d2f00);
120
+ background-color: var(--md-sys-color-tertiary-container, #ffdf9a);
121
+ }
122
+ /*
123
+ Pair the container with its on-container.
124
+
125
+ This used to fill with --md-sys-color-error and write with
126
+ --md-sys-color-on-error-container. Once the palette made containers
127
+ neutral and moved the meaning into the text colour, those two became the
128
+ same red and the badge went blank -- a filled pill with no readable text.
129
+ */
130
+ span[grade][over] {
131
+ color: var(--md-sys-color-on-error-container);
132
+ background-color: var(--md-sys-color-error-container);
133
+ }
134
+
135
+ /* 등급 눈금. 접어 두지 않는다 — 한 번 보고 마는 값이 아니라 볼 때마다 필요하다 */
136
+ p[scale] {
137
+ display: flex;
138
+ flex-wrap: wrap;
139
+ gap: 2px 10px;
140
+ margin: 6px 0 0 0;
141
+ font-size: 0.7rem;
142
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
143
+ }
144
+ p[scale] b {
145
+ font-weight: 700;
146
+ }
147
+ p[scale] span[now] b {
148
+ color: var(--md-sys-color-primary);
149
+ }
150
+ p[scale] span[now] {
151
+ color: var(--md-sys-color-on-surface);
152
+ }
153
+ /* 이 큰 숫자가 어느 축인지 먼저 말한다 — 성숙도의 한 축이지 전부가 아니다 */
154
+ p[lead] {
155
+ margin: 0 0 4px 0;
156
+ font-size: 0.74rem;
157
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
158
+ }
159
+
160
+ /* 근거를 함께 낸다 — 숫자만 크게 내걸면 재 본 값처럼 읽힌다 */
161
+ p[basis] {
162
+ margin: 6px 0 0 0;
163
+ font-size: 0.72rem;
164
+ line-height: 1.55;
165
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
166
+ }
167
+ p[basis] em {
168
+ font-style: normal;
169
+ color: var(--md-sys-color-tertiary);
170
+ }
171
+ span[score] {
172
+ font-size: 0.8rem;
173
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
174
+ }
175
+
176
+ /* 무엇 때문에 이 등급인가 — 한도에 가장 가까운 항목 */
177
+ p[because] {
178
+ margin: 8px 0 0 0;
179
+ /* 본문 크기를 물려받으면 이 줄만 제목처럼 커진다 — 화면을 띄워 보고 알았다 */
180
+ font-size: 0.78rem;
181
+ line-height: 1.6;
182
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
183
+ }
184
+ p[because] strong {
185
+ color: var(--md-sys-color-on-surface);
186
+ }
187
+
188
+ /* ── 성숙도 radar ────────────────────────────────────────────────
189
+ 여섯 축을 한 그림으로. 목록만 두면 「어느 쪽이 모자란가」가 안 보인다.
190
+ 못 잰 축은 점을 찍지 않고 살만 흐리게 남긴다 — 0 으로 찍으면 「나쁘다」가 된다. */
191
+ div[radar] {
192
+ display: flex;
193
+ justify-content: center;
194
+ padding: 2px 0 6px 0;
195
+ color: var(--md-sys-color-primary);
196
+ }
197
+ div[radar] svg {
198
+ width: 100%;
199
+ max-width: 240px;
200
+ height: auto;
201
+ }
202
+ div[radar] polygon[web] {
203
+ fill: none;
204
+ stroke: var(--md-sys-color-outline-variant);
205
+ stroke-width: 0.6;
206
+ }
207
+ div[radar] line[spoke] {
208
+ stroke: var(--md-sys-color-outline-variant);
209
+ stroke-width: 0.6;
210
+ }
211
+ div[radar] polygon[shape] {
212
+ fill: color-mix(in srgb, var(--md-sys-color-primary) 22%, transparent);
213
+ stroke: var(--md-sys-color-primary);
214
+ stroke-width: 1.4;
215
+ stroke-linejoin: round;
216
+ }
217
+ div[radar] circle[dot] {
218
+ fill: var(--md-sys-color-primary);
219
+ }
220
+ div[radar] text {
221
+ font-size: 5.2px;
222
+ fill: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
223
+ }
224
+
225
+ /* 그림에 자리를 주지 않은 축을 글로 밝힌다 */
226
+ p[unmeasured] {
227
+ margin: 0 0 8px 0;
228
+ font-size: 0.7rem;
229
+ line-height: 1.5;
230
+ color: var(--md-sys-color-tertiary);
231
+ }
232
+
233
+ /* ── 성숙도 축 ──────────────────────────────────────────────────
234
+ 한 숫자로 합치지 않는다. 합치면 「무엇을 고쳐야 하나」가 사라진다. */
235
+ ul[axes] {
236
+ margin: 0;
237
+ padding: 0;
238
+ list-style: none;
239
+ display: flex;
240
+ flex-direction: column;
241
+ gap: 7px;
242
+ }
243
+ ul[axes] li {
244
+ display: grid;
245
+ grid-template-columns: 1fr auto;
246
+ gap: 2px 8px;
247
+ font-size: 0.75rem;
248
+ }
249
+ ul[axes] li[blocked] {
250
+ opacity: 0.7;
251
+ }
252
+ ul[axes] em {
253
+ grid-column: 1 / -1;
254
+ font-style: normal;
255
+ font-size: 0.68rem;
256
+ line-height: 1.5;
257
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
258
+ }
259
+ /* 못 잰 축은 0 으로 두지 않는다 — 0 은 「나쁘다」는 뜻이 되어 버린다 */
260
+ ul[axes] li[blocked] em {
261
+ color: var(--md-sys-color-tertiary);
262
+ }
263
+ span[reading] {
264
+ font-family: var(--mono-font, monospace);
265
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
266
+ }
267
+
268
+ /* ── 예산 막대 ────────────────────────────────────────────────── */
269
+ ul[budgets] {
270
+ margin: 0;
271
+ padding: 0;
272
+ list-style: none;
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 6px;
276
+ }
277
+ ul[budgets] li {
278
+ display: grid;
279
+ grid-template-columns: 1fr auto;
280
+ gap: 2px 8px;
281
+ font-size: 0.75rem;
282
+ }
283
+ div[bar] {
284
+ grid-column: 1 / -1;
285
+ height: 4px;
286
+ background-color: var(--md-sys-color-surface-container-highest);
287
+ border-radius: 2px;
288
+ overflow: hidden;
289
+ }
290
+ div[bar] > i {
291
+ display: block;
292
+ height: 100%;
293
+ background-color: var(--md-sys-color-primary);
294
+ }
295
+ div[bar] > i[tight] {
296
+ background-color: var(--md-sys-color-error);
297
+ }
298
+
299
+ /* ── 무게 ─────────────────────────────────────────────────────── */
300
+ dl[metrics] {
301
+ margin: 0;
302
+ display: grid;
303
+ grid-template-columns: 1fr auto;
304
+ gap: 4px 8px;
305
+ font-size: 0.78rem;
306
+ }
307
+ dl[metrics] dt {
308
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
309
+ }
310
+ dl[metrics] dd {
311
+ margin: 0;
312
+ text-align: right;
313
+ font-family: var(--mono-font, monospace);
314
+ }
315
+
316
+ /* ── 추이 ──────────────────────────────────────────────────────────
317
+ 그림은 자연 크기가 680px 다. 300px 칸에 넣으면 글자가 4px 이 되어 아무것도
318
+ 읽을 수 없다 — 사이드바에는 숫자를 두고, 그림은 선택해서 크게 본다. */
319
+ button[expand] {
320
+ margin-top: 6px;
321
+ padding: 4px 10px;
322
+ font: var(--label-font, inherit);
323
+ font-size: 0.74rem;
324
+ color: var(--md-sys-color-on-surface);
325
+ background: none;
326
+ border: 1px solid var(--md-sys-color-outline-variant);
327
+ border-radius: 6px;
328
+ cursor: pointer;
329
+ }
330
+ button[expand]:hover {
331
+ background-color: var(--md-sys-color-surface-container);
332
+ }
333
+
334
+ div[sheet] {
335
+ position: fixed;
336
+ inset: 0;
337
+ z-index: 10;
338
+ display: flex;
339
+ align-items: center;
340
+ justify-content: center;
341
+ padding: var(--spacing-large, 12px);
342
+ background-color: rgba(0, 0, 0, 0.32);
343
+ }
344
+ div[sheet] > figure {
345
+ max-width: 100%;
346
+ max-height: 100%;
347
+ margin: 0;
348
+ padding: var(--spacing-large, 12px);
349
+ overflow: auto;
350
+ color: var(--md-sys-color-primary);
351
+ background-color: var(--md-sys-color-surface-container-lowest);
352
+ /* 담는 면이 팝업이면 안쪽 모서리는 알약이다 — 시트 자체는 페이지 값을 쓴다 */
353
+ border-radius: 6px;
354
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
355
+ }
356
+ div[sheet] svg {
357
+ display: block;
358
+ /* 자연 크기보다 작게 줄이지 않는다 — 좁으면 넘겨 보게 둔다 */
359
+ min-width: 680px;
360
+ }
361
+ div[sheet] figcaption {
362
+ display: flex;
363
+ align-items: center;
364
+ justify-content: flex-end;
365
+ }
366
+ div[sheet] button[close] {
367
+ padding: 5px 14px;
368
+ font: var(--label-font, inherit);
369
+ color: var(--md-sys-color-on-surface);
370
+ background: none;
371
+ border: 1px solid var(--md-sys-color-outline-variant);
372
+ border-radius: 999em;
373
+ cursor: pointer;
374
+ }
375
+
376
+ /* ── 알림 ─────────────────────────────────────────────────────── */
377
+ ul[findings] {
378
+ margin: 0;
379
+ padding: 0;
380
+ list-style: none;
381
+ display: flex;
382
+ flex-direction: column;
383
+ gap: 6px;
384
+ }
385
+ ul[findings] li {
386
+ display: flex;
387
+ gap: 6px;
388
+ line-height: 1.5;
389
+ }
390
+ ul[findings] md-icon {
391
+ --md-icon-size: 16px;
392
+ flex: none;
393
+ color: var(--md-sys-color-error);
394
+ }
395
+ ul[findings] li[violation] md-icon {
396
+ color: var(--md-sys-color-tertiary);
397
+ }
398
+ /* 어긴 자리. 문구에 붙어 흐르면 문장의 일부로 읽힌다 — badge 로 보이게 뗀다 */
399
+ code[at] {
400
+ padding: 1px 5px;
401
+ font-family: var(--mono-font, monospace);
402
+ font-size: 0.68rem;
403
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
404
+ background-color: var(--md-sys-color-surface-container);
405
+ border-radius: 4px;
406
+ white-space: nowrap;
407
+ }
408
+
409
+ /*
410
+ 발견이 없을 때의 문구.
411
+
412
+ 크기를 안 적어 두었더니 본문 기본 크기를 물려받아 이 줄만 제목처럼 커졌다. 이
413
+ 패널의 글자는 전부 0.7rem 대인데 여기만 1rem 이라 아래쪽이 무너져 보였다.
414
+
415
+ 찾은 것이 있을 때의 목록(ul[findings] li)과 같은 크기로 맞춘다. 있을 때와 없을 때
416
+ 절의 생김새가 달라질 이유가 없다.
417
+
418
+ 이 파일에서 같은 실수를 두 번째로 한다. p[because] 에도 같은 주석이 있다.
419
+ */
420
+ p[quiet] {
421
+ margin: 0;
422
+ font-size: 0.76rem;
423
+ line-height: 1.6;
424
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
425
+ }
426
+ `
427
+ ]
428
+
429
+ /** 저작 중인 원본 데이터. 바뀌면 다시 측정한다. */
430
+ @property({ type: Object }) source?: FigureSource
431
+
432
+ @state() private cost?: FigureCost
433
+ @state() private score?: FigureScore
434
+ @state() private curve = ''
435
+ /** 1만 개를 놓았을 때. 저작자가 실제로 궁금한 숫자다. */
436
+ @state() private many?: FigureCost
437
+ /** 몇 개까지 놓을 수 있나. 이 패널의 머리다. */
438
+ @state() private capacity?: FigureCapacity
439
+ /** 추이 그림을 제 크기로 펼쳤나. */
440
+ @state() private expanded = false
441
+ @state() private errors: { code: string; message: string; at?: string }[] = []
442
+ @state() private violations: { code: string; message: string }[] = []
443
+
444
+ willUpdate(changed: Map<string, unknown>) {
445
+ if (changed.has('source')) {
446
+ this.measure()
447
+ }
448
+ }
449
+
450
+ render() {
451
+ if (!this.source) {
452
+ return html`
453
+ <section>
454
+ <p quiet>${i18next.t('text.report-appears-when-you-add-a-part')}</p>
455
+ </section>
456
+ `
457
+ }
458
+
459
+ // 형식을 어긴 상태에서는 셈이 뜻을 갖지 못한다 — 무엇이 잘못됐는지만 보여 준다.
460
+ if (this.errors.length > 0) {
461
+ return html`${this.renderFindings()}`
462
+ }
463
+
464
+ return html`
465
+ ${this.renderVerdict()} ${this.renderAxes()} ${this.renderBudgets()} ${this.renderMetrics()} ${this.renderCurve()}
466
+ ${this.renderFindings()} ${this.renderSheet()}
467
+ `
468
+ }
469
+
470
+ /**
471
+ * 머리 — 몇 개까지 편한가.
472
+ *
473
+ * 로우폴리의 값은 「얼마나 많이 편하게 놓을 수 있나」에 있다. 등급 B 는 그 물음에
474
+ * 직접 답하지 않는다 — 형상이 선언된 한도 안인지를 말할 뿐이다. 그래서 개수를
475
+ * 머리에 올리고 등급은 옆의 badge 로 내린다.
476
+ *
477
+ * 근거를 함께 낸다. 이 개수의 분모는 실측이 아니라 설계 문서의 목표치이고(미결
478
+ * D-20), 그 사실이 화면에 없으면 재 본 값처럼 읽힌다.
479
+ */
480
+ private renderVerdict() {
481
+ const score = this.score
482
+ const capacity = this.capacity
483
+ if (!score || !capacity) return nothing
484
+
485
+ return html`
486
+ <section>
487
+ <h3>${i18next.t('label.maturity')}</h3>
488
+ <p lead>${i18next.t('label.axis-scale')} — ${i18next.t('text.how-many-fit')}</p>
489
+ <div verdict>
490
+ <span capacity>${capacity.instances.toLocaleString()}</span>
491
+ <span unit>${i18next.t('text.instances-unit')}</span>
492
+ <span
493
+ grade
494
+ ?tight=${score.grade === 'C'}
495
+ ?over=${score.grade === 'D'}
496
+ title=${i18next.t('text.grade-explained')}
497
+ >
498
+ ${score.grade}
499
+ <small>${i18next.t('text.headroom-percent', { percent: Math.round(score.score) })}</small>
500
+ </span>
501
+ </div>
502
+ <p because>
503
+ ${i18next.t('text.grade-decided-by')}
504
+ <strong>${i18next.t('label.budget-' + score.tightest.key)}</strong>
505
+ — ${score.tightest.used} / ${score.tightest.limit}
506
+ (${Math.round((score.tightest.used / score.tightest.limit) * 100)}%)
507
+ </p>
508
+ ${this.renderGradeScale(score.grade)}
509
+ <p basis>
510
+ ${i18next.t('text.bound-by-' + capacity.bound, {
511
+ per: capacity.per.toLocaleString(),
512
+ budget: capacity.budget.toLocaleString()
513
+ })}
514
+ <em>${i18next.t('text.budget-not-measured-yet')}</em>
515
+ </p>
516
+ </section>
517
+ `
518
+ }
519
+
520
+ /**
521
+ * 등급 눈금.
522
+ *
523
+ * A·B·C·D 가 무엇을 뜻하는지 화면 어디에도 없었다. badge 만 보고는 좋은지 나쁜지도,
524
+ * 무엇을 고쳐야 오르는지도 알 수 없다. 눈금을 늘 곁에 두고 지금 어디에 있는지를
525
+ * 표시한다.
526
+ *
527
+ * 접어 두지 않는다. 한 번 배우고 마는 값이 아니라 볼 때마다 대조하는 값이다.
528
+ */
529
+ private renderGradeScale(now: string) {
530
+ const bounds: [string, string][] = [
531
+ ['A', '≤ 50%'],
532
+ ['B', '≤ 75%'],
533
+ ['C', '≤ 100%'],
534
+ ['D', '> 100%']
535
+ ]
536
+
537
+ return html`
538
+ <p scale>
539
+ <span>${i18next.t('text.grade-scale-lead')}</span>
540
+ ${bounds.map(
541
+ ([grade, bound]) => html`<span ?now=${grade === now}><b>${grade}</b> ${bound}</span>`
542
+ )}
543
+ </p>
544
+ `
545
+ }
546
+
547
+ /**
548
+ * 성숙도 — 여러 축으로 본다.
549
+ *
550
+ * 「몇 개까지 편한가」 하나로는 좋은 형상과 나쁜 형상이 구분되지 않는다. 부품 하나짜리
551
+ * 상자도 그 축에서는 만점이다.
552
+ *
553
+ * **못 재는 축을 0 으로 두지 않는다.** 0 은 「나쁘다」는 뜻이 되는데, 사실은 아직
554
+ * 측정할 방법이 없는 것이다. 측정하지 못한다고 알리고 이유를 붙인다. 지어낸 점수보다 그것이
555
+ * 이 리포트를 믿을 만하게 만든다.
556
+ */
557
+ private renderAxes() {
558
+ if (!this.cost || !this.score || !this.source) return nothing
559
+
560
+ /*
561
+ 규모는 목록에서 뺀다 — 머리로 올라갔다. 같은 숫자를 두 번 내면 읽는 사람이
562
+ 둘이 다른 것인 줄 안다.
563
+ */
564
+ const axes = maturityOf(this.source, this.cost, this.score).filter(axis => axis.key !== 'scale')
565
+
566
+ // radar 에는 여섯을 다 그린다. 규모가 어디쯤인지 함께 보여야 뜻이 있다
567
+ const all = maturityOf(this.source, this.cost, this.score)
568
+
569
+ return html`
570
+ <section>
571
+ <h3>${i18next.t('label.by-axis')}</h3>
572
+ <div radar>${this.renderRadar(all.filter(axis => axis.ratio !== undefined))}</div>
573
+ ${this.renderUnmeasured(all.filter(axis => axis.ratio === undefined))}
574
+ <ul axes>
575
+ ${axes.map(axis => this.renderAxis(axis))}
576
+ </ul>
577
+ </section>
578
+ `
579
+ }
580
+
581
+ /**
582
+ * 성숙도 radar. **측정한 축만 그린다.**
583
+ *
584
+ * 측정하지 못하는 축도 spoke 를 그려 두고 도형만 건너뛰던 때가 있었다. 그러면 그 방향을 가로지르는
585
+ * 직선이 생겨서 도형이 그쪽으로 움푹 들어간 것처럼 보인다. 0 으로 찍지 않으려고 한
586
+ * 일인데 결과는 0 으로 찍은 것과 똑같이 읽혔다. 점선 살과 물음표로는 그 인상이
587
+ * 지워지지 않았다.
588
+ *
589
+ * 그래서 아예 빼고, 못 재는 축은 그림 아래에 글로 적는다. 잴 수 없는 것은 그림에
590
+ * 자리를 주지 않는 편이 정직하다.
591
+ */
592
+ private renderRadar(axes: MaturityAxis[]) {
593
+ // 그림 안에서는 **짧은 이름**을 쓴다. 긴 이름을 그대로 쓰면 왼쪽이 잘린다 —
594
+ // 화면을 띄워 보고 알았다.
595
+ const cx = 60
596
+ const cy = 46
597
+ const r = 28
598
+
599
+ const at = (index: number, ratio: number) => {
600
+ const angle = -Math.PI / 2 + (index * 2 * Math.PI) / axes.length
601
+ return [cx + r * ratio * Math.cos(angle), cy + r * ratio * Math.sin(angle)]
602
+ }
603
+
604
+ const points = axes
605
+ .map((axis, index) => at(index, Math.max(0.04, Math.min(1, axis.ratio ?? 0))).map(n => n.toFixed(1)).join(','))
606
+ .join(' ')
607
+
608
+ return svg`
609
+ <svg viewBox="0 0 120 96" aria-hidden="true">
610
+ ${[0.33, 0.66, 1].map(
611
+ ring => svg`<polygon web points=${axes.map((_, i) => at(i, ring).map(n => n.toFixed(1)).join(',')).join(' ')}></polygon>`
612
+ )}
613
+ ${axes.map((_, index) => {
614
+ const [x, y] = at(index, 1)
615
+ return svg`<line spoke x1=${cx} y1=${cy} x2=${x.toFixed(1)} y2=${y.toFixed(1)}></line>`
616
+ })}
617
+ <polygon shape points=${points}></polygon>
618
+ ${axes.map((axis, index) => {
619
+ const [x, y] = at(index, Math.max(0.04, Math.min(1, axis.ratio ?? 0)))
620
+ return svg`<circle dot cx=${x.toFixed(1)} cy=${y.toFixed(1)} r="1.4"></circle>`
621
+ })}
622
+ ${axes.map((axis, index) => {
623
+ const [x, y] = at(index, 1.28)
624
+ const anchor = x < cx - 3 ? 'end' : x > cx + 3 ? 'start' : 'middle'
625
+ return svg`
626
+ <text x=${x.toFixed(1)} y=${(y + 1.8).toFixed(1)} text-anchor=${anchor}>
627
+ ${i18next.t('label.axis-' + axis.key + '-short')}
628
+ </text>
629
+ `
630
+ })}
631
+ </svg>
632
+ `
633
+ }
634
+
635
+ /**
636
+ * 그림에 없는 축을 글로 밝힌다.
637
+ *
638
+ * 빼기만 하면 축이 여섯인지 다섯인지 알 수 없고, 없는 것을 없다고 말하지 않은 셈이
639
+ * 된다. 무엇을 아직 못 재는지 한 줄로 적는다. 왜 못 재는지는 아래 목록의 그 항목에
640
+ * 그대로 있다.
641
+ */
642
+ private renderUnmeasured(axes: MaturityAxis[]) {
643
+ if (axes.length === 0) return nothing
644
+
645
+ return html`
646
+ <p unmeasured>
647
+ ${i18next.t('text.axes-not-measurable-yet', {
648
+ axes: axes.map(axis => i18next.t('label.axis-' + axis.key)).join(' · ')
649
+ })}
650
+ </p>
651
+ `
652
+ }
653
+
654
+ private renderAxis(axis: MaturityAxis) {
655
+ const name = i18next.t('label.axis-' + axis.key)
656
+
657
+ if (axis.blocked) {
658
+ return html`
659
+ <li blocked>
660
+ <span>${name}</span>
661
+ <span reading>${i18next.t('text.not-measurable-yet')}</span>
662
+ <em>${i18next.t(axis.blocked)}</em>
663
+ </li>
664
+ `
665
+ }
666
+
667
+ const ratio = Math.max(0, Math.min(1, axis.ratio ?? 0))
668
+ return html`
669
+ <li>
670
+ <span>${name}</span>
671
+ <span reading>${axis.readingKey ? i18next.t(axis.readingKey) : (axis.reading ?? '')}</span>
672
+ <div bar><i style="width:${Math.round(ratio * 100)}%"></i></div>
673
+ ${axis.because ? html`<em>${i18next.t(axis.because)}</em>` : ''}
674
+ </li>
675
+ `
676
+ }
677
+
678
+ /**
679
+ * 예산 막대.
680
+ *
681
+ * 숫자만 늘어놓으면 어느 것이 꽉 찼는지 눈에 안 들어온다. 한도에 가장 가까운 하나를 색으로
682
+ * 구분한다. 그것이 점수를 정한 항목이다.
683
+ *
684
+ * ## `use.label` 을 쓰지 않는 이유
685
+ *
686
+ * `FigureBudgetUse` 에 `label` 과 `why` 가 있고 「저작자에게 그대로 보여 준다」고
687
+ * 적혀 있다. 그런데 그 문구가 **한국어로 구워져** 있다. 이 제품은 5언어를 쓰므로
688
+ * 그것을 그대로 실으면 다른 언어 사용자에게 한국어가 나온다.
689
+ *
690
+ * `budgets` 는 **배열**이다. `Object.entries` 로 돌면 키가 인덱스(0·1·2…)가 되어
691
+ * `label.budget-0` 같은 없는 키를 찾는다 — 화면을 띄워 보고서야 드러났다.
692
+ * 배열을 그대로 돌고 `use.key` 를 쓴다.
693
+ *
694
+ * 그래서 **안정된 `key`** 로 이 화면의 i18n 을 찾는다. `key` 는 열거형이라 형식의
695
+ * 일부이고, 문구는 화면의 몫이다. figure-model 이 문구를 언어 중립으로 바꾸면
696
+ * 그때 `label` 을 쓸 수 있다.
697
+ */
698
+ private renderBudgets() {
699
+ const score = this.score
700
+ if (!score) return nothing
701
+
702
+ return html`
703
+ <section>
704
+ <h3>${i18next.t('label.budgets')}</h3>
705
+ <ul budgets>
706
+ ${score.budgets.map(use => {
707
+ const ratio = Math.min(1, use.ratio)
708
+ const tight = use.key === score.tightest.key
709
+ return html`
710
+ <li>
711
+ <span>${i18next.t('label.budget-' + use.key)}</span>
712
+ <span>${use.used} / ${use.limit}</span>
713
+ <div bar><i ?tight=${tight} style="width:${Math.round(ratio * 100)}%"></i></div>
714
+ </li>
715
+ `
716
+ })}
717
+ </ul>
718
+ </section>
719
+ `
720
+ }
721
+
722
+ private renderMetrics() {
723
+ const cost = this.cost
724
+ if (!cost) return nothing
725
+
726
+ return html`
727
+ <section>
728
+ <h3>${i18next.t('label.weight')}</h3>
729
+ <dl metrics>
730
+ <dt>${i18next.t('label.triangles')}</dt>
731
+ <dd>${cost.triangles.toLocaleString()}</dd>
732
+
733
+ <dt>${i18next.t('label.triangles-at-100')}</dt>
734
+ <dd>${cost.at.triangles.toLocaleString()}</dd>
735
+
736
+ <dt>${i18next.t('label.draw-calls-at-100')}</dt>
737
+ <dd>${cost.at.drawCalls.toLocaleString()}</dd>
738
+
739
+ <dt>${i18next.t('label.distinct-shapes')}</dt>
740
+ <dd>${cost.distinctShapes}</dd>
741
+
742
+ <dt>${i18next.t('label.distinct-materials')}</dt>
743
+ <dd>${cost.distinctMaterials}</dd>
744
+ </dl>
745
+ </section>
746
+ `
747
+ }
748
+
749
+ /**
750
+ * 재활용 추이 — 「많이 놓으면 어떻게 되나」에 답한다.
751
+ *
752
+ * ## 그림을 사이드바에 밀어 넣지 않는다
753
+ *
754
+ * `costCurveSvg` 의 자연 크기는 680x380 이고 글자가 11px 다. 300px 칸에서
755
+ * width:100% 로 줄이면 글자가 4px 이 되어 **아무것도 읽을 수 없다** — 화면을 띄워
756
+ * 보고서야 드러났다. 읽을 수 없는 그림은 없는 것과 같으므로, 사이드바에는 저작자가
757
+ * 실제로 궁금한 숫자(1만 개일 때)를 두고 그림은 선택해서 제 크기로 본다.
758
+ */
759
+ private renderCurve() {
760
+ const many = this.many
761
+ if (!many && !this.curve) return nothing
762
+
763
+ return html`
764
+ <section>
765
+ <h3>${i18next.t('label.reuse-trend')}</h3>
766
+ ${many
767
+ ? html`
768
+ <dl metrics>
769
+ <dt>${i18next.t('label.triangles-at-10k')}</dt>
770
+ <dd>${many.at.triangles.toLocaleString()}</dd>
771
+
772
+ <dt>${i18next.t('label.draw-calls-at-10k')}</dt>
773
+ <dd>${many.at.drawCalls.toLocaleString()}</dd>
774
+ </dl>
775
+ `
776
+ : nothing}
777
+ ${this.curve
778
+ ? html`
779
+ <button expand @click=${() => (this.expanded = true)}>
780
+ ${i18next.t('button.see-the-trend-chart')}
781
+ </button>
782
+ `
783
+ : nothing}
784
+ </section>
785
+ `
786
+ }
787
+
788
+ /** 그림을 제 크기로 보여 준다. 좁으면 줄이지 않고 넘겨 보게 둔다. */
789
+ private renderSheet() {
790
+ if (!this.expanded || !this.curve) return nothing
791
+
792
+ return html`
793
+ <div sheet @click=${() => (this.expanded = false)}>
794
+ <figure @click=${(e: Event) => e.stopPropagation()}>
795
+ ${unsafeSVG(this.curve)}
796
+ <figcaption>
797
+ <button close @click=${() => (this.expanded = false)}>${i18next.t('button.close')}</button>
798
+ </figcaption>
799
+ </figure>
800
+ </div>
801
+ `
802
+ }
803
+
804
+ /**
805
+ * 발견.
806
+ *
807
+ * 오류(막는 것)와 위반(알리는 것)을 **가려서** 보여 준다. 섞으면 저작자가 무엇을
808
+ * 반드시 고쳐야 하는지 모른다. 아무것도 없으면 「없다」고 말한다 — 빈 자리로 두면
809
+ * 검사가 돌았는지 알 수 없다.
810
+ */
811
+ private renderFindings() {
812
+ if (this.errors.length === 0 && this.violations.length === 0) {
813
+ return html`
814
+ <section>
815
+ <h3>${i18next.t('label.findings')}</h3>
816
+ <p quiet>${i18next.t('text.no-findings')}</p>
817
+ </section>
818
+ `
819
+ }
820
+
821
+ return html`
822
+ <section>
823
+ <h3>${i18next.t('label.findings')}</h3>
824
+ <ul findings>
825
+ ${this.errors.map(
826
+ e => html`
827
+ <li>
828
+ <md-icon>error</md-icon>
829
+ <span>${e.message} ${e.at ? html`<code at>${e.at}</code>` : ''}</span>
830
+ </li>
831
+ `
832
+ )}
833
+ ${this.violations.map(
834
+ v => html`
835
+ <li violation>
836
+ <md-icon>info</md-icon>
837
+ <span>${v.message}</span>
838
+ </li>
839
+ `
840
+ )}
841
+ </ul>
842
+ </section>
843
+ `
844
+ }
845
+
846
+ /** 원본 데이터를 측정해 결과를 상태에 담는다. 셈은 전부 figure-model 이 한다. */
847
+ private measure() {
848
+ const source = this.source
849
+ if (!source) {
850
+ this.cost = undefined
851
+ this.score = undefined
852
+ this.many = undefined
853
+ this.capacity = undefined
854
+ this.curve = ''
855
+ this.errors = []
856
+ this.violations = []
857
+ return
858
+ }
859
+
860
+ const result = validate(source)
861
+ this.errors = result.errors.map(e => ({ code: e.code, message: e.message, at: e.path }))
862
+ this.violations = result.violations.map(v => ({ code: v.code, message: v.message }))
863
+
864
+ if (this.errors.length > 0) {
865
+ // 형식을 어겼으면 컴파일이 던진다. 셈을 비우고 오류만 보여 준다.
866
+ this.cost = undefined
867
+ this.score = undefined
868
+ this.many = undefined
869
+ this.capacity = undefined
870
+ this.curve = ''
871
+ this.dispatchEvent(
872
+ new CustomEvent('verdict-changed', { detail: {}, bubbles: true, composed: true })
873
+ )
874
+ return
875
+ }
876
+
877
+ const blueprint = compile(source)
878
+ this.cost = costOf(blueprint, 100)
879
+ // 1만 개는 figure-model 에 다시 묻는다 — 화면이 곱셈으로 짐작하지 않는다.
880
+ this.many = costOf(blueprint, 10000)
881
+ this.score = scoreOf(blueprint)
882
+ this.capacity = capacityOf(this.cost)
883
+
884
+ // 개수를 밖으로 알린다. 탭 이름표가 이 값을 보여 주는데, 거기서 다시 셈하면
885
+ // 같은 형상이 두 자리에서 다르게 나올 수 있다.
886
+ this.dispatchEvent(
887
+ new CustomEvent('verdict-changed', {
888
+ detail: { instances: this.capacity?.instances, grade: this.score?.grade },
889
+ bubbles: true,
890
+ composed: true
891
+ })
892
+ )
893
+ this.curve = costCurveSvg(blueprint)
894
+ }
895
+ }