@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,381 @@
1
+ import { css, html, LitElement, unsafeCSS } from 'lit'
2
+ import { customElement, property, query, state } from 'lit/decorators.js'
3
+
4
+ import { i18next, localize } from '@operato/i18n'
5
+ import { compile, validate } from '@hatiolab/figure-model'
6
+ import type { FigureSource } from '@hatiolab/figure-model'
7
+ import { create, registerFigure } from '@hatiolab/things-scene'
8
+
9
+ /**
10
+ * 저작 중인 것을 **보드에 놓았을 때**의 모습.
11
+ *
12
+ * ## 편집 캔버스로는 볼 수 없다
13
+ *
14
+ * 편집 캔버스는 부품을 컴포넌트로 늘어놓는다. 그래서 늘 기준 크기다. 그런데 이 형식의
15
+ * 값 절반은 **크기가 달라졌을 때 어떻게 반응하는가**에 있다. 기둥만 늘어나는 시그널타워,
16
+ * 롤러 개수가 느는 컨베이어는 기준 크기에서는 아무것도 보여 주지 못한다.
17
+ *
18
+ * 그래서 여기서는 저작한 것을 실제로 컴파일해 `FigureInstance` 로 세운다. 보드가 하는
19
+ * 일을 그대로 한다 — 청사진을 등록하고, 타입 이름만 적은 컴포넌트를 놓는다.
20
+ *
21
+ * ## 여러 크기를 한 번에 놓는다
22
+ *
23
+ * 하나만 놓으면 "이 크기에서는 이렇다" 밖에 알 수 없다. 기준 크기와 늘린 것을 나란히
24
+ * 놓아야 무엇이 따라 늘고 무엇이 그대로인지 보인다.
25
+ *
26
+ * ## 편집하지 않는다
27
+ *
28
+ * 여기서 집거나 끌 수 없다. 편집은 캔버스에서 한다. 보여 주기만 하는 자리라 씬을
29
+ * 뷰 모드로 세운다.
30
+ */
31
+
32
+ /** 배경. 편집 캔버스와 같은 값을 쓴다 — 갈리면 두 화면이 다른 도구처럼 보인다. */
33
+ const WORKBENCH = '#7c7c7c'
34
+
35
+ /** 얼마나 키워 보나. */
36
+ export const PREVIEW_FACTOR = 2
37
+
38
+ /**
39
+ * 어느 축을 키워 놓아 보나 — **일곱 조합 전부.**
40
+ *
41
+ * 1 배 · 2 배 · 3 배를 나란히 놓던 자리다. 그러면 세 축이 늘 같이 늘어나므로, 축마다
42
+ * 따로 걸리는 규칙을 눈으로 볼 수가 없었다. 실제로 못 보고 넘어간 것들이 있다 — 컨베이어의
43
+ * 롤러가 폭을 안 따라가던 것, 문의 폭이 높이를 안 따라가던 것이 다 한 축만 늘렸을 때
44
+ * 드러나는 것이었다.
45
+ *
46
+ * 여덟이다. 첫째가 저작한 크기이고 나머지 일곱이 조합이다. 한 줄로 늘어놓으면 화면에
47
+ * 안 담겨서 **네 칸씩 두 줄**로 놓는다.
48
+ */
49
+ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
50
+ { name: '기준', axes: [] },
51
+ { name: 'x', axes: ['x'] },
52
+ { name: 'y', axes: ['y'] },
53
+ { name: 'z', axes: ['z'] },
54
+ { name: 'x·y', axes: ['x', 'y'] },
55
+ { name: 'x·z', axes: ['x', 'z'] },
56
+ { name: 'y·z', axes: ['y', 'z'] },
57
+ { name: 'x·y·z', axes: ['x', 'y', 'z'] }
58
+ ]
59
+
60
+ interface SceneHandle {
61
+ dispose?: () => void
62
+ resize?: () => void
63
+ fit?: (type?: string) => void
64
+ root?: {
65
+ invalidate?: () => void
66
+ fitCameraToModel?: (margin?: number) => void
67
+ /** 세워 둔 인스턴스들. clip 을 몰 때 전부에 같은 값을 준다. */
68
+ components?: { set?: (property: string, value: unknown) => void }[]
69
+ }
70
+ }
71
+
72
+ @customElement('figure-preview')
73
+ export class FigurePreview extends localize(i18next)(LitElement) {
74
+ static styles = css`
75
+ :host {
76
+ position: relative;
77
+ display: flex;
78
+ overflow: hidden;
79
+ background-color: ${unsafeCSS(WORKBENCH)};
80
+ font: var(--label-font, inherit);
81
+ font-size: 0.76rem;
82
+ }
83
+
84
+ div[stage] {
85
+ position: absolute;
86
+ inset: 0;
87
+ width: 100%;
88
+ height: 100%;
89
+ }
90
+
91
+ /* 어느 것이 몇 배인지 — 나란히 놓아도 표가 없으면 읽을 수 없다 */
92
+ div[legend] {
93
+ position: absolute;
94
+ top: 8px;
95
+ left: 8px;
96
+ display: flex;
97
+ gap: 6px;
98
+ padding: 4px 8px;
99
+ color: rgba(255, 255, 255, 0.9);
100
+ background-color: rgba(28, 28, 30, 0.6);
101
+ border-radius: 6px;
102
+ font-size: 0.72rem;
103
+ }
104
+
105
+ /*
106
+ Clip 조작. **저작자가 제 애니메이션을 만져 볼 유일한 자리다.**
107
+
108
+ 보드에서는 트윈이 clip 을 몬다 — 「닙이 40% 열렸다」는 사실이 어디선가 온다. 저작
109
+ 중에는 그 사실을 주는 것이 없어서, 손대지 않으면 hold 인 clip 은 0 에 머문 채
110
+ 영영 안 움직인다. 포크가 안 올라가는 것을 저작자가 「안 되는구나」로 읽게 된다.
111
+
112
+ **미리보기 것이지 자산의 것이 아니다.** 여기서 민 값은 저장되지 않는다.
113
+ */
114
+ div[clips] {
115
+ position: absolute;
116
+ right: 8px;
117
+ bottom: 8px;
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 6px;
121
+ padding: 8px 10px;
122
+ color: rgba(255, 255, 255, 0.92);
123
+ background-color: rgba(28, 28, 30, 0.72);
124
+ border-radius: 8px;
125
+ font-size: 0.72rem;
126
+ }
127
+ div[clips] label {
128
+ display: grid;
129
+ grid-template-columns: auto 96px 34px;
130
+ align-items: center;
131
+ gap: 8px;
132
+ }
133
+ div[clips] span[drive] {
134
+ opacity: 0.6;
135
+ font-size: 0.66rem;
136
+ }
137
+ div[clips] input {
138
+ width: 96px;
139
+ accent-color: var(--md-sys-color-primary);
140
+ }
141
+ div[clips] output {
142
+ font-variant-numeric: tabular-nums;
143
+ text-align: right;
144
+ opacity: 0.85;
145
+ }
146
+
147
+ /* 세울 수 없는 상태를 빈 화면으로 두지 않는다 — 왜 안 보이는지 말한다 */
148
+ div[cannot] {
149
+ position: absolute;
150
+ inset: 0;
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ padding: var(--spacing-large, 12px);
155
+ text-align: center;
156
+ line-height: 1.6;
157
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
158
+ background-color: ${unsafeCSS(WORKBENCH)};
159
+ }
160
+ `
161
+
162
+ /** 저작 중인 것. 저장 형식으로 받는다 — 보드가 소비하는 것이 이 형식이다. */
163
+ @property({ type: Object }) source?: FigureSource
164
+
165
+ @query('div[stage]') private stage!: HTMLDivElement
166
+
167
+ /** 세울 수 없는 사유. 없으면 세운 것이다. */
168
+ @state() private cannot = ''
169
+
170
+ private scene?: SceneHandle
171
+ /** 지금 세워 둔 것의 값. 같으면 다시 세우지 않는다. */
172
+ private standing?: string
173
+ private watcher?: ResizeObserver
174
+
175
+ /** 지금 민 clip 값. clip 이름 → 수. **저장되지 않는다.** */
176
+ @state() private driving: Record<string, number> = {}
177
+
178
+ render() {
179
+ const clips = this.source?.animations ?? []
180
+
181
+ return html`
182
+ <div stage></div>
183
+ ${this.cannot
184
+ ? html`<div cannot>${this.cannot}</div>`
185
+ : html`<div legend>
186
+ ${PREVIEW_WAYS.map(way => html`<span>${way.name}</span>`)}
187
+ </div>
188
+ ${clips.length > 0 ? this.renderClips(clips) : ''}`}
189
+ `
190
+ }
191
+
192
+ /**
193
+ * Clip 하나에 손잡이 하나.
194
+ *
195
+ * 수의 뜻이 `drive` 에 따라 다르므로 그것을 이름 옆에 적는다. `loop` 는 배속이라 0 이
196
+ * 멈춤이고, `hold` 는 0~1 위치라 0 이 처음이다. 둘을 같은 눈금으로 두면 「1」이 무슨
197
+ * 뜻인지 매번 헷갈린다.
198
+ */
199
+ private renderClips(clips: NonNullable<FigureSource['animations']>) {
200
+ return html`
201
+ <div clips>
202
+ ${clips.map(clip => {
203
+ const loop = (clip.drive ?? 'loop') === 'loop'
204
+ const value = this.driving[clip.name] ?? (loop ? 1 : 0)
205
+
206
+ return html`
207
+ <label>
208
+ <span>${clip.name} <span drive>${loop ? '배속' : '위치'}</span></span>
209
+ <input
210
+ type="range"
211
+ min="0"
212
+ max=${loop ? 3 : 1}
213
+ step="0.05"
214
+ .value=${String(value)}
215
+ @input=${(e: Event) => this.drive(clip.name, Number((e.target as HTMLInputElement).value))}
216
+ />
217
+ <output>${value.toFixed(2)}</output>
218
+ </label>
219
+ `
220
+ })}
221
+ </div>
222
+ `
223
+ }
224
+
225
+ /**
226
+ * 세워 둔 인스턴스 전부에 같은 값을 준다.
227
+ *
228
+ * 하나만 몰면 세 배율이 서로 다른 자세로 서서, 크기 반응을 보려고 나란히 놓은 뜻이
229
+ * 사라진다.
230
+ */
231
+ private drive(name: string, value: number): void {
232
+ this.driving = { ...this.driving, [name]: value }
233
+
234
+ for (const component of this.scene?.root?.components ?? []) {
235
+ component.set?.('clips', { ...this.driving })
236
+ }
237
+ }
238
+
239
+ willUpdate(changed: Map<string, unknown>) {
240
+ if (!this.hasUpdated) return
241
+ if (changed.has('source')) this.standUp()
242
+ }
243
+
244
+ firstUpdated() {
245
+ this.standUp()
246
+ this.watcher = new ResizeObserver(() => this.scene?.resize?.())
247
+ this.watcher.observe(this)
248
+ }
249
+
250
+ disconnectedCallback() {
251
+ super.disconnectedCallback()
252
+ this.watcher?.disconnect()
253
+ this.watcher = undefined
254
+ this.teardown()
255
+ }
256
+
257
+ /**
258
+ * 저작한 것을 컴파일해 세운다.
259
+ *
260
+ * **형식을 어겼으면 세우지 않는다.** 반쯤 세운 것을 보여 주면 무엇이 잘못됐는지 알 수
261
+ * 없다. 판정 패널이 이미 사유를 말하고 있으므로 여기서는 세울 수 없다고만 한다.
262
+ */
263
+ private standUp() {
264
+ const source = this.source
265
+ if (!source || source.parts.length === 0) {
266
+ this.cannot = i18next.t('text.preview-needs-a-part')
267
+ this.teardown()
268
+ return
269
+ }
270
+
271
+ const signature = JSON.stringify(source)
272
+ if (this.scene && this.standing === signature) return
273
+
274
+ const { errors } = validate(source)
275
+ if (errors.length > 0) {
276
+ this.cannot = i18next.t('text.preview-blocked-by-errors')
277
+ this.teardown()
278
+ return
279
+ }
280
+
281
+ this.teardown()
282
+ // 표본이 바뀌면 민 값도 처음으로 돌린다 — 다른 자산의 clip 이름이 남으면 안 된다.
283
+ this.driving = {}
284
+
285
+ try {
286
+ registerFigure(compile(source) as never)
287
+
288
+ /*
289
+ 보드가 하는 일을 그대로 한다. 컴포넌트에는 타입 이름과 자리·크기만 적는다 —
290
+ 청사진도 원본도 보드에 들어가지 않는다.
291
+
292
+ 가장 큰 것이 들어갈 만큼 판을 잡고, 세로로 줄을 나눠 나란히 놓는다.
293
+ */
294
+ const { base } = source
295
+ const biggest = PREVIEW_FACTOR
296
+ /** 이 조합에서 축별 배율. 조합에 든 축만 키운다. */
297
+ const growth = (way: (typeof PREVIEW_WAYS)[number]) => ({
298
+ x: way.axes.includes('x') ? PREVIEW_FACTOR : 1,
299
+ y: way.axes.includes('y') ? PREVIEW_FACTOR : 1,
300
+ z: way.axes.includes('z') ? PREVIEW_FACTOR : 1
301
+ })
302
+ // 여백을 넉넉히 두면 화면에서 형상이 작아진다. 가장 큰 것이 겨우 들어갈 만큼만 잡는다.
303
+ /** 네 칸씩 두 줄. */
304
+ const COLUMNS = 4
305
+ const cell = { x: base.x * biggest * 1.25, z: base.z * biggest * 1.25 }
306
+ const rows = Math.ceil(PREVIEW_WAYS.length / COLUMNS)
307
+ const board = {
308
+ width: Math.round(cell.x * COLUMNS),
309
+ height: Math.round(cell.z * rows),
310
+ /*
311
+ 판의 높이도 가장 큰 인스턴스를 담아야 한다.
312
+
313
+ 안 주면 카메라가 평면만 보고 자리를 잡아서, 키가 큰 모델은 밑동만 보인다.
314
+ 시그널타워를 2 배로 놓으면 2200 인데 판이 그것을 모르면 받침만 화면에 든다.
315
+ */
316
+ depth: Math.round(base.y * biggest * 1.1)
317
+ }
318
+
319
+ /*
320
+ 씬의 낱말이 형식과 다르다 — `width` 가 형식의 x, `height` 가 z, `depth` 가 y 다.
321
+ 옮기는 일을 여기 한 줄에 모아 둔다.
322
+
323
+ `depth` 를 늘 적는 이유가 있다. 안 적으면 평면 두 축의 기하평균으로 잡혀서,
324
+ 「y 만 키운다」가 x·z 를 안 건드리는데도 높이가 안 오른다.
325
+ */
326
+ const components = PREVIEW_WAYS.map((way, at) => {
327
+ const grow = growth(way)
328
+ const column = at % COLUMNS
329
+ const row = Math.floor(at / COLUMNS)
330
+ const width = Math.round(base.x * grow.x)
331
+ const height = Math.round(base.z * grow.z)
332
+
333
+ return {
334
+ type: source.type,
335
+ id: `at-${way.name}`,
336
+ // 칸 가운데에 놓는다 — 크기가 저마다 달라 왼쪽에 맞추면 줄이 안 읽힌다.
337
+ left: Math.round(column * cell.x + (cell.x - width) / 2),
338
+ top: Math.round(row * cell.z + (cell.z - height) / 2),
339
+ width,
340
+ height,
341
+ depth: Math.round(base.y * grow.y)
342
+ }
343
+ })
344
+
345
+ this.scene = create({
346
+ target: this.stage,
347
+ model: {
348
+ type: 'model-layer',
349
+ width: board.width,
350
+ height: board.height,
351
+ depth: board.depth,
352
+ threed: true,
353
+ environment: 'studio',
354
+ grid: true,
355
+ sky: 'color',
356
+ skyColor: WORKBENCH,
357
+ floor: false,
358
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
359
+ components
360
+ },
361
+ // 보기만 하는 자리다. 집거나 끌 수 없어야 한다.
362
+ mode: 0
363
+ }) as SceneHandle
364
+
365
+ this.standing = signature
366
+ this.scene.resize?.()
367
+ this.scene.fit?.('ratio')
368
+ this.scene.root?.fitCameraToModel?.()
369
+ this.cannot = ''
370
+ } catch (e) {
371
+ // 원인을 지어내지 않는다 — 던진 말을 그대로 보여 준다.
372
+ this.cannot = (e as Error).message
373
+ }
374
+ }
375
+
376
+ private teardown() {
377
+ this.scene?.dispose?.()
378
+ this.scene = undefined
379
+ this.standing = undefined
380
+ }
381
+ }