@things-factory/figure-ui 10.1.38 → 10.1.39

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.
@@ -335,6 +335,59 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
335
335
  return unwrap<FigureInspection>(response, 'inspectFigure')
336
336
  }
337
337
 
338
+ /**
339
+ * 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.
340
+ *
341
+ * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는
342
+ * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —
343
+ * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.
344
+ */
345
+ export async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {
346
+ const response = await client.query({
347
+ query: gql`
348
+ query InspectFigureDraft($source: String!, $id: String) {
349
+ inspectFigureDraft(source: $source, id: $id) {
350
+ blocked
351
+ findings {
352
+ code
353
+ message
354
+ why
355
+ how
356
+ at
357
+ fixes {
358
+ findingWay
359
+ part
360
+ axis
361
+ from
362
+ to
363
+ resolved
364
+ safe
365
+ }
366
+ fixPlan {
367
+ resolved
368
+ safe
369
+ fixes {
370
+ findingWay
371
+ part
372
+ axis
373
+ from
374
+ to
375
+ resolved
376
+ safe
377
+ }
378
+ }
379
+ blocking
380
+ }
381
+ }
382
+ }
383
+ `,
384
+ variables: { source, id },
385
+ fetchPolicy: 'network-only'
386
+ })
387
+
388
+ return unwrap<FigureInspection>(response, 'inspectFigureDraft')
389
+ }
390
+
338
391
  /** 발행된 판들 — 최근 것부터 열 개. */
339
392
  /**
340
393
  * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.
@@ -1,5 +1,6 @@
1
1
  import '@material/web/icon/icon.js'
2
2
 
3
+ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
3
4
  import { css, html, LitElement, nothing } from 'lit'
4
5
  import { customElement, property, state } from 'lit/decorators.js'
5
6
 
@@ -31,6 +32,7 @@ import type { FigureGateFinding, FigureGateFix, FigureGateFixPlan, FigureInspect
31
32
  @customElement('figure-history-panel')
32
33
  export class FigureHistoryPanel extends localize(i18next)(LitElement) {
33
34
  static styles = [
35
+ gateFindingStyles,
34
36
  ScrollbarStyles,
35
37
  css`
36
38
  :host {
@@ -191,77 +193,6 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
191
193
  게이트 사유. 막는 것은 색으로도 말하고, 알리는 것은 조용히 둔다 — 둘을 같은 톤으로 두면
192
194
  「이 중 어느 것이 발행을 막았나」를 사람이 다시 세어야 한다.
193
195
  */
194
- ul[gate] {
195
- margin: var(--spacing-small, 6px) 0 0;
196
- padding: 0;
197
- list-style: none;
198
- display: flex;
199
- flex-direction: column;
200
- gap: 3px;
201
- }
202
-
203
- li[head] {
204
- font: var(--label-font, inherit);
205
- font-size: 0.72rem;
206
- color: var(--md-sys-color-error);
207
- }
208
-
209
- li[head][quiet] {
210
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
211
- }
212
-
213
- li[finding] {
214
- display: flex;
215
- flex-direction: column;
216
- align-items: flex-start;
217
- gap: 3px;
218
- padding: 3px 6px;
219
- font-size: 0.72rem;
220
- line-height: 1.5;
221
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
222
- background-color: var(--md-sys-color-surface-container);
223
- border-radius: 4px;
224
- }
225
-
226
- li[finding][blocking] {
227
- color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
228
- background-color: var(--md-sys-color-error-container);
229
- }
230
-
231
- li[finding] code[where] {
232
- font-family: var(--mono-font, monospace);
233
- font-size: 0.68rem;
234
- opacity: 0.85;
235
- }
236
-
237
- li[finding] details[affected] {
238
- width: 100%;
239
- color: inherit;
240
- }
241
-
242
- li[finding] details[affected] summary {
243
- cursor: pointer;
244
- font-weight: 600;
245
- }
246
-
247
- /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
248
- li[finding] span[why-line] {
249
- opacity: 0.8;
250
- }
251
-
252
- /* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
253
- li[finding] span[how-line] {
254
- display: flex;
255
- align-items: flex-start;
256
- gap: 4px;
257
- padding-top: 2px;
258
- font-weight: 600;
259
- }
260
- li[finding] span[how-line] md-icon {
261
- --md-icon-size: 13px;
262
- flex: none;
263
- margin-top: 1px;
264
- }
265
196
 
266
197
  /* 설명으로 끝내지 않는다. 이 단추는 원본을 바꾸지 않고 모델러에 후보만 연다. */
267
198
  li[finding] button[preview-fix] {
@@ -332,6 +263,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
332
263
  * 화면이 없는 단추를 그리면 눌러도 아무 일이 없다.
333
264
  */
334
265
  @property({ type: String }) format: 'v2' | 'v3' = 'v2'
266
+ /**
267
+ * 행의 타입. 판정 하나(`type-is-placeholder`)가 이것을 본다 — V3 자산은 타입을 원본에 적지 않아서, 타입을 바꾸면
268
+ * 원본도 dirty 도 그대로다. 그래서 이 값이 바뀌면 판정을 다시 묻는다. 안 그러면 타입을 정한 뒤에도 「임시 타입」
269
+ * 판정이 남아 발행 단추가 잠긴 채다.
270
+ */
271
+ @property({ type: String }) figureType?: string
335
272
  /** 직전 발행판의 기준 상자. 외형 변화 안내에서 X·Z 복원 선택지에만 쓴다. */
336
273
  @property({ attribute: false }) releasedBase?: { x: number; y: number; z: number }
337
274
 
@@ -354,7 +291,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
354
291
  저장이 끝난 순간(dirty 가 풀림) 정본이 바뀌었으니 판정을 다시 묻는다. 저작 중(dirty)에는
355
292
  묻지 않는다 — 서버에 있는 것은 저장 전 정본이라, 그 답은 화면에 있는 것과 다른 것을 말한다.
356
293
  */
357
- if (changes.has('figureId') || changes.has('dirty')) {
294
+ if (changes.has('figureId') || changes.has('dirty') || changes.has('figureType')) {
358
295
  void this.inspect()
359
296
  }
360
297
  }
@@ -474,17 +411,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
474
411
  * 준다 — 화면이 문장을 지으면 서버가 거절할 때와 다른 말을 하게 된다.
475
412
  */
476
413
  private renderFinding(finding: FigureGateFinding, blocking: boolean) {
477
- return html`
478
- <li finding ?blocking=${blocking}>
479
- <span msg>${finding.message}</span>
480
- ${finding.at ? html`
481
- <details affected>
482
- <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
483
- <code where>${finding.at}</code>
484
- </details>
485
- ` : nothing}
486
- ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
487
- ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
414
+ return renderGateFinding(finding, blocking, html`
488
415
  ${this.offersFit(finding.code)
489
416
  ? html`<button fit-base @click=${() => this.fitBase()}>
490
417
  ${i18next.t('figure.button.fit-base-to-parts')}
@@ -522,8 +449,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
522
449
  ${i18next.t('figure.button.apply-recommended-fix')}
523
450
  </button>
524
451
  ` : nothing}
525
- </li>
526
- `
452
+ `)
527
453
  }
528
454
 
529
455
  /**
@@ -0,0 +1,247 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { parseV3Asset, v3CostOf } from '@hatiolab/figure-model'
8
+
9
+ import { inspectFigureDraft } from '../graphql/index.js'
10
+ import type { FigureInspection } from '../types.js'
11
+ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
12
+ import { capacityFrom, MEASURED } from './scene-budget.js'
13
+ import type { FigureCapacity } from './scene-budget.js'
14
+
15
+ /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
16
+ export const VERDICT_SETTLE_MS = 400
17
+
18
+ /**
19
+ * 판정 — the V3 modeller's verdict while editing (parity row ①).
20
+ *
21
+ * The V2 modeller shows, as the author edits, how many of the figure fit on one screen and what would stop it
22
+ * being published. The V3 page showed neither: the author learned what blocked a release only after saving, in the
23
+ * release panel. This tab answers both from the source being edited.
24
+ *
25
+ * The count is computed here, from `v3CostOf` and the same `capacityFrom` the catalogue cards use, so the tab and
26
+ * the card cannot disagree. The findings are not: they come from the server's release door (`inspectFigureDraft`),
27
+ * the same door and the same sentences as the release panel. A second judgement on the screen would say something
28
+ * different the first time either changed.
29
+ *
30
+ * No grade. V2's grade measures V2 reuse; nothing measures it for V3, so the tab shows no letter rather than one
31
+ * nothing computed.
32
+ */
33
+ @customElement('figure-v3-verdict')
34
+ export class FigureV3Verdict extends localize(i18next)(LitElement) {
35
+ static styles = [
36
+ gateFindingStyles,
37
+ css`
38
+ :host {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 14px;
42
+ padding: 12px;
43
+ overflow: auto;
44
+ font-size: 0.76rem;
45
+ color: var(--md-sys-color-on-surface);
46
+ }
47
+ section > h3 {
48
+ margin: 0 0 6px 0;
49
+ font-size: var(--ui-t-micro, 11px);
50
+ font-weight: 700;
51
+ letter-spacing: 0.04em;
52
+ text-transform: uppercase;
53
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
54
+ }
55
+ p {
56
+ margin: 4px 0 0;
57
+ }
58
+ p[lead],
59
+ p[basis],
60
+ p[quiet] {
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ }
63
+ div[count] {
64
+ display: flex;
65
+ align-items: baseline;
66
+ gap: 6px;
67
+ }
68
+ span[capacity] {
69
+ font-size: 2.1rem;
70
+ font-weight: 800;
71
+ line-height: 1;
72
+ letter-spacing: -0.01em;
73
+ color: var(--md-sys-color-primary);
74
+ }
75
+ span[unit] {
76
+ font-size: 0.9rem;
77
+ font-weight: 600;
78
+ color: var(--md-sys-color-primary);
79
+ }
80
+ p[clear] {
81
+ color: var(--md-sys-color-primary);
82
+ font-weight: 600;
83
+ }
84
+ p[failed] {
85
+ color: var(--md-sys-color-error);
86
+ }
87
+ button[fit] {
88
+ align-self: flex-start;
89
+ margin-top: 4px;
90
+ padding: 4px 10px;
91
+ font: inherit;
92
+ color: var(--md-sys-color-on-secondary-container);
93
+ background: var(--md-sys-color-secondary-container);
94
+ border: none;
95
+ border-radius: 999em;
96
+ cursor: pointer;
97
+ }
98
+ `
99
+ ]
100
+
101
+ /** The source being edited, serialised. Every change of it is a new question. */
102
+ @property({ type: String }) source?: string
103
+ /** The saved row, if any — the server reads its type and last release from it. */
104
+ @property({ type: String }) figureId?: string
105
+ /** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
106
+ @property({ type: String }) figureType?: string
107
+
108
+ @state() private capacity?: FigureCapacity
109
+ /** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
110
+ @state() private bodies = 0
111
+ @state() private inspection?: FigureInspection
112
+ @state() private failure = ''
113
+ @state() private asking = false
114
+
115
+ private timer?: ReturnType<typeof setTimeout>
116
+ /** Answers can come back out of order; only the newest question's answer is shown. */
117
+ private asked = 0
118
+
119
+ disconnectedCallback() {
120
+ clearTimeout(this.timer)
121
+ super.disconnectedCallback()
122
+ }
123
+
124
+ updated(changes: Map<string, unknown>) {
125
+ if (changes.has('source')) this.measure()
126
+ if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
127
+ }
128
+
129
+ private measure() {
130
+ let capacity: FigureCapacity | undefined
131
+ let bodies = 0
132
+ try {
133
+ const cost = this.source ? v3CostOf(parseV3Asset(this.source)) : undefined
134
+ capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
135
+ bodies = cost ? cost.groups + cost.hidden : 0
136
+ } catch {
137
+ /* A source the format refuses has no count; the server's finding below says why. */
138
+ capacity = undefined
139
+ }
140
+ this.capacity = capacity
141
+ this.bodies = bodies
142
+ this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: capacity?.instances } }))
143
+ }
144
+
145
+ private schedule() {
146
+ clearTimeout(this.timer)
147
+ if (!this.source) {
148
+ this.inspection = undefined
149
+ return
150
+ }
151
+ this.timer = setTimeout(() => this.ask(), VERDICT_SETTLE_MS)
152
+ }
153
+
154
+ private async ask() {
155
+ const question = ++this.asked
156
+ const source = this.source!
157
+ this.asking = true
158
+ try {
159
+ const inspection = await inspectFigureDraft(source, this.figureId)
160
+ if (question !== this.asked) return
161
+ this.inspection = inspection
162
+ this.failure = ''
163
+ } catch (error) {
164
+ if (question !== this.asked) return
165
+ /* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
166
+ this.inspection = undefined
167
+ this.failure = error instanceof Error ? error.message : String(error)
168
+ } finally {
169
+ if (question === this.asked) this.asking = false
170
+ }
171
+ }
172
+
173
+ render() {
174
+ return html`${this.renderCount()} ${this.renderGate()}`
175
+ }
176
+
177
+ private renderCount() {
178
+ const capacity = this.capacity
179
+ return html`
180
+ <section>
181
+ <h3>${i18next.t('figure.label.maturity')}</h3>
182
+ <p lead>${i18next.t('figure.text.how-many-fit')}</p>
183
+ ${capacity
184
+ ? html`
185
+ <div count>
186
+ <span capacity>${capacity.instances.toLocaleString()}</span>
187
+ <span unit>${i18next.t('figure.text.instances-unit')}</span>
188
+ </div>
189
+ <p basis>
190
+ ${i18next.t('figure.text.bound-by-' + capacity.bound, {
191
+ per: capacity.per.toLocaleString(),
192
+ budget: capacity.budget.toLocaleString()
193
+ })}
194
+ <em>
195
+ ${capacity.bound === 'groups'
196
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
197
+ : i18next.t('figure.text.budget-not-measured-yet')}
198
+ </em>
199
+ </p>
200
+ `
201
+ : html`<p quiet>${i18next.t('figure.text.maturity-blocked-no-parts')}</p>`}
202
+ </section>
203
+ `
204
+ }
205
+
206
+ private renderGate() {
207
+ const findings = this.inspection?.findings ?? []
208
+ const blocking = findings.filter(finding => finding.blocking)
209
+ const advisory = findings.filter(finding => !finding.blocking)
210
+ return html`
211
+ <section>
212
+ <h3>${i18next.t('figure.label.release-verdict')}</h3>
213
+ ${this.failure
214
+ ? html`<p failed role="alert">${i18next.t('figure.text.verdict-not-received', { reason: this.failure })}</p>`
215
+ : !this.inspection
216
+ ? html`<p quiet>${i18next.t(this.asking ? 'figure.text.verdict-asking' : 'figure.text.verdict-waiting')}</p>`
217
+ : blocking.length
218
+ ? html`<p>${i18next.t('figure.text.cannot-release-now')}</p>`
219
+ : html`<p clear>${i18next.t('figure.text.nothing-blocks-release')}</p>`}
220
+ ${blocking.length
221
+ ? html`<ul gate>
222
+ <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
223
+ ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code)))}
224
+ </ul>`
225
+ : nothing}
226
+ ${advisory.length
227
+ ? html`<ul gate>
228
+ <li head quiet>${i18next.t('figure.text.does-not-block')}</li>
229
+ ${advisory.map(finding => renderGateFinding(finding, false))}
230
+ </ul>`
231
+ : nothing}
232
+ </section>
233
+ `
234
+ }
235
+
236
+ /** The same way out the release panel offers for the same two findings — declare the space from the parts. */
237
+ private actionsFor(code: string) {
238
+ if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
239
+ /* A new figure with no parts is refused as undeclared too, but fitting needs parts to measure (seen 2026-09-24). */
240
+ if (!this.bodies) return nothing
241
+ return html`<button fit @click=${this.fit}>${i18next.t('figure.button.fit-base-to-parts')}</button>`
242
+ }
243
+
244
+ private fit = () => {
245
+ this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
246
+ }
247
+ }
@@ -0,0 +1,105 @@
1
+ import { css, html, nothing, type TemplateResult } from 'lit'
2
+ import { i18next } from '@operato/i18n'
3
+
4
+ import type { FigureGateFinding } from '../types.js'
5
+
6
+ /**
7
+ * One release-gate finding as a line on screen — the measured fact, where, why it matters, and what to change.
8
+ *
9
+ * Two places draw these: the release panel (`figure-history-panel`) and the V3 modeller's verdict while editing
10
+ * (`figure-v3-verdict`). They show the same findings from the same server door, so they draw them one way; a copy
11
+ * in each would drift the first time one of them is touched. The caller passes its own action buttons, because
12
+ * what can be done about a finding depends on the screen it is shown on.
13
+ */
14
+ export function renderGateFinding(finding: FigureGateFinding, blocking: boolean, actions: unknown = nothing): TemplateResult {
15
+ return html`
16
+ <li finding ?blocking=${blocking}>
17
+ <span msg>${finding.message}</span>
18
+ ${finding.at
19
+ ? html`
20
+ <details affected>
21
+ <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
22
+ <code where>${finding.at}</code>
23
+ </details>
24
+ `
25
+ : nothing}
26
+ ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
27
+ ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
28
+ ${actions}
29
+ </li>
30
+ `
31
+ }
32
+
33
+ export const gateFindingStyles = css`
34
+ ul[gate] {
35
+ margin: var(--spacing-small, 6px) 0 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 3px;
41
+ }
42
+
43
+ li[head] {
44
+ font: var(--label-font, inherit);
45
+ font-size: 0.72rem;
46
+ color: var(--md-sys-color-error);
47
+ }
48
+
49
+ li[head][quiet] {
50
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
51
+ }
52
+
53
+ li[finding] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ align-items: flex-start;
57
+ gap: 3px;
58
+ padding: 3px 6px;
59
+ font-size: 0.72rem;
60
+ line-height: 1.5;
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ background-color: var(--md-sys-color-surface-container);
63
+ border-radius: 4px;
64
+ }
65
+
66
+ li[finding][blocking] {
67
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
68
+ background-color: var(--md-sys-color-error-container);
69
+ }
70
+
71
+ li[finding] code[where] {
72
+ font-family: var(--mono-font, monospace);
73
+ font-size: 0.68rem;
74
+ opacity: 0.85;
75
+ }
76
+
77
+ li[finding] details[affected] {
78
+ width: 100%;
79
+ color: inherit;
80
+ }
81
+
82
+ li[finding] details[affected] summary {
83
+ cursor: pointer;
84
+ font-weight: 600;
85
+ }
86
+
87
+ /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
88
+ li[finding] span[why-line] {
89
+ opacity: 0.8;
90
+ }
91
+
92
+ /* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
93
+ li[finding] span[how-line] {
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: 4px;
97
+ padding-top: 2px;
98
+ font-weight: 600;
99
+ }
100
+ li[finding] span[how-line] md-icon {
101
+ --md-icon-size: 13px;
102
+ flex: none;
103
+ margin-top: 1px;
104
+ }
105
+ `