@things-factory/figure-ui 10.1.25 → 10.1.26

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 (39) hide show
  1. package/client/graphql/index.ts +4 -1
  2. package/client/modeller/figure-animations.ts +57 -5
  3. package/client/modeller/figure-history-panel.ts +2 -2
  4. package/client/modeller/figure-inspector.ts +8 -2
  5. package/client/modeller/figure-preview.ts +3 -3
  6. package/client/modeller/figure-source.ts +17 -41
  7. package/client/modeller/proposal-session.ts +8 -2
  8. package/client/pages/figure-modeller-page.ts +50 -15
  9. package/dist-client/graphql/index.js +4 -1
  10. package/dist-client/graphql/index.js.map +1 -1
  11. package/dist-client/modeller/figure-animations.d.ts +5 -0
  12. package/dist-client/modeller/figure-animations.js +55 -5
  13. package/dist-client/modeller/figure-animations.js.map +1 -1
  14. package/dist-client/modeller/figure-history-panel.js +2 -2
  15. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  16. package/dist-client/modeller/figure-inspector.js +8 -2
  17. package/dist-client/modeller/figure-inspector.js.map +1 -1
  18. package/dist-client/modeller/figure-preview.js +3 -3
  19. package/dist-client/modeller/figure-preview.js.map +1 -1
  20. package/dist-client/modeller/figure-source.d.ts +4 -1
  21. package/dist-client/modeller/figure-source.js +5 -36
  22. package/dist-client/modeller/figure-source.js.map +1 -1
  23. package/dist-client/modeller/proposal-session.d.ts +5 -0
  24. package/dist-client/modeller/proposal-session.js +3 -2
  25. package/dist-client/modeller/proposal-session.js.map +1 -1
  26. package/dist-client/pages/figure-modeller-page.d.ts +2 -22
  27. package/dist-client/pages/figure-modeller-page.js +46 -15
  28. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-server/tsconfig.tsbuildinfo +1 -0
  31. package/package.json +3 -3
  32. package/test/ai-proposal-contract.test.ts +3 -2
  33. package/test/figure-source.test.ts +11 -57
  34. package/test/i18n-prefix-guard.test.ts +2 -3
  35. package/translations/en.json +95 -80
  36. package/translations/ja.json +184 -58
  37. package/translations/ko.json +115 -100
  38. package/translations/ms.json +182 -56
  39. package/translations/zh.json +183 -57
@@ -1,4 +1,5 @@
1
1
  import gql from 'graphql-tag'
2
+ import { i18next } from '@operato/i18n'
2
3
  import { client } from '@operato/graphql'
3
4
 
4
5
  import type {
@@ -29,7 +30,9 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
29
30
  if (said) throw new Error(said)
30
31
 
31
32
  if (!response.data) {
32
- throw new Error(`서버가 ${field} 돌려주지 않았습니다.`)
33
+ /* The field name stays off the screen because the user cannot act on it; it goes to the console. */
34
+ console.error(`[figure-ui] the server returned no data for ${field}`)
35
+ throw new Error(i18next.t('figure.text.server-returned-no-data'))
33
36
  }
34
37
 
35
38
  return response.data[field] as T
@@ -380,7 +380,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
380
380
  return html`
381
381
  <section>
382
382
  <h3>${i18next.t('figure.label.animation')}</h3>
383
- <p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
384
383
  </section>
385
384
 
386
385
  ${this.renderRecipes()}
@@ -488,6 +487,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
488
487
  const nameless = !parameter.name?.trim()
489
488
  const range = parameter.range ?? NEW_RANGE
490
489
  const backwards = !(range.max > range.min)
490
+ const slowness = parameter.clip?.duration ?? 0
491
491
 
492
492
  return html`
493
493
  <div clip ?on=${on}>
@@ -552,6 +552,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
552
552
  />
553
553
  </div>
554
554
 
555
+ <div row>
556
+ <label>${i18next.t('figure.label.parameter-duration')}</label>
557
+ <input
558
+ type="number"
559
+ step="0.1"
560
+ min="0"
561
+ ?bad=${slowness < 0}
562
+ .value=${live(parameter.clip?.duration === undefined ? '' : String(parameter.clip.duration))}
563
+ placeholder="0"
564
+ aria-label="duration"
565
+ @change=${(e: Event) => this.setDuration(at, (e.target as HTMLInputElement).value)}
566
+ />
567
+ </div>
568
+ <p quiet>${i18next.t('figure.text.parameter-duration-empty-is-instant')}</p>
569
+
555
570
  ${on ? this.renderChannels('parameter', at) : nothing}
556
571
  </div>
557
572
  `
@@ -678,11 +693,18 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
678
693
  */
679
694
  private renderKeys(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
680
695
  const keys = channel.keys ?? []
696
+ /*
697
+ Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
698
+ the format refuses it. The screen marks it and leaves the number alone: rewriting what the author
699
+ typed would hide what they wrote.
700
+ */
701
+ const onValues = kind === 'parameter'
702
+ const outside = (key: Keyframe) => onValues && key.at > 1
681
703
 
682
704
  return html`
683
705
  <table keys>
684
706
  <tr>
685
- <th>${i18next.t('figure.label.key-at')}</th>
707
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
686
708
  ${AXES.map(axis => html`<th>${axis}</th>`)}
687
709
  <th></th>
688
710
  </tr>
@@ -693,11 +715,16 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
693
715
  <td>
694
716
  <input
695
717
  type="number"
696
- step="0.1"
718
+ step=${onValues ? '0.05' : '0.1'}
697
719
  min="0"
698
- ?bad=${backwards || key.at < 0}
720
+ max=${onValues ? '1' : nothing}
721
+ ?bad=${backwards || key.at < 0 || outside(key)}
699
722
  .value=${live(String(key.at))}
700
- title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
723
+ title=${backwards
724
+ ? i18next.t('figure.text.keys-must-rise')
725
+ : outside(key)
726
+ ? i18next.t('figure.text.parameter-key-within-span')
727
+ : ''}
701
728
  @change=${(e: Event) =>
702
729
  this.setKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
703
730
  />
@@ -736,6 +763,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
736
763
  ${keys.some((key, k) => k > 0 && key.at <= (keys[k - 1]!.at ?? 0))
737
764
  ? html`<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
738
765
  : nothing}
766
+ ${keys.some(outside) ? html`<p warn>${i18next.t('figure.text.parameter-key-within-span')}</p>` : nothing}
739
767
 
740
768
  <button @click=${() => this.addKey(kind, at, j)}>
741
769
  <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
@@ -883,6 +911,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
883
911
  this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } })
884
912
  }
885
913
 
914
+ /**
915
+ * Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
916
+ * a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
917
+ */
918
+ private setDuration(at: number, raw: string): void {
919
+ const parameter = this.parameters[at]
920
+ if (!parameter) return
921
+ const { duration: _dropped, ...rest } = parameter.clip
922
+ const text = raw.trim()
923
+ const clip = text === '' || !Number.isFinite(Number(text)) ? rest : { ...rest, duration: Number(text) }
924
+ this.patchParameter(at, { clip })
925
+ }
926
+
886
927
  private setDefault(at: number, value: number): void {
887
928
  this.patchParameter(at, { default: value })
888
929
  }
@@ -954,6 +995,17 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
954
995
  if (!channel) return
955
996
 
956
997
  const last = channel.keys[channel.keys.length - 1]
998
+
999
+ /*
1000
+ A parameter curve ends at 1, so appending a key would make it unreachable. Insert it between the last two keys.
1001
+ */
1002
+ if (kind === 'parameter' && channel.keys.length >= 2) {
1003
+ const before = channel.keys[channel.keys.length - 2]!
1004
+ const middle: Keyframe = { at: (before.at + last!.at) / 2, value: { ...before.value } }
1005
+ this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys.slice(0, -1), middle, last!] })
1006
+ return
1007
+ }
1008
+
957
1009
  const key: Keyframe = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } }
958
1010
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
959
1011
  }
@@ -438,11 +438,11 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
438
438
  ${(finding.fixes ?? []).map(fix => html`
439
439
  ${fix.safe ? html`
440
440
  <button apply-fix @click=${() => this.applyFix(fix)}>
441
- 안전하게 수정 적용 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} ${fix.to}
441
+ ${i18next.t('figure.button.apply-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
442
442
  </button>
443
443
  ` : nothing}
444
444
  <button preview-fix @click=${() => this.previewFix(fix)}>
445
- 수정안 미리보기 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} ${fix.to}
445
+ ${i18next.t('figure.button.preview-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
446
446
  </button>
447
447
  `)}
448
448
  </li>
@@ -1704,8 +1704,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1704
1704
  }
1705
1705
 
1706
1706
  private renderAnchors(part: FigurePart) {
1707
+ /*
1708
+ The scene draft stores the box height as depth, so format y is draft.depth and z is draft.height.
1709
+ This used to pass them swapped, and every automatic anchor still came out the same: the
1710
+ nearest-face rule reads the sign of a centred position, which does not depend on the box size.
1711
+ It is fixed so the next rule that does read the box does not inherit the mistake.
1712
+ */
1707
1713
  const base = this.draft
1708
- ? { x: this.draft.width, y: this.draft.height, z: this.draft.depth }
1714
+ ? { x: this.draft.width, y: this.draft.depth, z: this.draft.height }
1709
1715
  : { x: 0, y: 0, z: 0 }
1710
1716
  const nameOf = (rule: string, axis: Axis) =>
1711
1717
  `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
@@ -1748,7 +1754,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1748
1754
  */
1749
1755
  return html`
1750
1756
  <div anchor-row>
1751
- <label>${axis.toUpperCase()}축</label>
1757
+ <label>${i18next.t('figure.label.axis-name', { axis: axis.toUpperCase() })}</label>
1752
1758
  <div anchor-options>
1753
1759
  ${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
1754
1760
  const selected = rule === 'auto' ? !said : rule === said
@@ -47,7 +47,7 @@ export const PREVIEW_FACTOR = 2
47
47
  * 안 담겨서 **네 칸씩 두 줄**로 놓는다.
48
48
  */
49
49
  export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
50
- { name: '기준', axes: [] },
50
+ { name: 'base', axes: [] },
51
51
  { name: 'x', axes: ['x'] },
52
52
  { name: 'y', axes: ['y'] },
53
53
  { name: 'z', axes: ['z'] },
@@ -208,7 +208,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
208
208
  ${this.cannot
209
209
  ? html`<div cannot>${this.cannot}</div>`
210
210
  : html`<div legend>
211
- ${PREVIEW_WAYS.map(way => html`<span>${way.name}</span>`)}
211
+ ${PREVIEW_WAYS.map(way => html`<span>${way.axes.length === 0 ? i18next.t('figure.label.preview-base') : way.name}</span>`)}
212
212
  </div>
213
213
  ${clips.length + parameters.length > 0 ? this.renderDrivers(clips, parameters) : ''}`}
214
214
  `
@@ -253,7 +253,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
253
253
 
254
254
  return html`
255
255
  <label>
256
- <span>${clip.name} <span drive>배속</span></span>
256
+ <span>${clip.name} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
257
257
  <input
258
258
  type="range"
259
259
  min="0"
@@ -1,4 +1,11 @@
1
- import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePlacement, type FigureSource, type Vec3 } from '@hatiolab/figure-model'
1
+ import {
2
+ FIGURE_SOURCE_VERSION,
3
+ type DetailLevel,
4
+ type FigurePart,
5
+ type FigurePlacement,
6
+ type FigureSource,
7
+ type Vec3
8
+ } from '@hatiolab/figure-model'
2
9
 
3
10
  /**
4
11
  * Scene model <-> `FigureSource`.
@@ -40,9 +47,12 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
40
47
  * size.z height
41
48
  *
42
49
  * position.x left + width/2 - draft width/2
43
- * position.y zPos + depth/2 - draft depth/2 <- zPos is the underside
50
+ * position.y zPos + depth/2 <- both measured from the box bottom
44
51
  * position.z top + height/2 - draft height/2
45
52
  *
53
+ * (Source edition 2 measures y from the bottom face of the base box, as the scene's zPos does, so
54
+ * that row needs no half-box shift. x and z are still measured from the box centre. ADR-0065.)
55
+ *
46
56
  * rotation.x rotationX (scene radians, format degrees)
47
57
  * rotation.y -rotation
48
58
  * rotation.z rotationY
@@ -317,7 +327,7 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
317
327
  transform: withoutEmpty({
318
328
  position: {
319
329
  x: round4(part.left + part.width / 2 - half.x),
320
- y: round4(part.zPos + part.depth / 2 - half.y),
330
+ y: round4(part.zPos + part.depth / 2),
321
331
  z: round4(part.top + part.height / 2 - half.z)
322
332
  },
323
333
  size: { x: part.width, y: part.depth, z: part.height },
@@ -336,44 +346,9 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
336
346
  }) as FigurePart
337
347
  }
338
348
 
339
- /**
340
- * 옛 `drive: 'hold'` clip 을 파라미터로 옮긴다 — **여는 순간에.**
341
- *
342
- * 형식은 값이 만드는 자세를 `parameters` 로 옮겼고(ADR-0051 ①), `compile()` 이 옛 원본을
343
- * 청사진으로 만들 때 같은 이전을 한다. **거기서만 하면 저작 화면이 어긋난다** — 씬은
344
- * 파라미터로 세우는데 저작 화면은 원본을 그대로 읽어 애니메이션 칸에 놓는다.
345
- *
346
- * 실제로 그랬다. 카탈로그의 리프터를 열면 `lift-height` 가 애니메이션 목록에 앉고,
347
- * 미리보기는 그것에 **배속 손잡이**를 붙인다. 손잡이를 끝까지 밀어도 아무 일도 안 난다 —
348
- * 청사진에는 그 이름의 clip 이 없고 파라미터로 가 있기 때문이다. 저작자가 보는 것은
349
- * 「움직이지 않는 리프터」다.
350
- *
351
- * 범위는 `%` 0~100 이다. 옛 clip 은 제 수가 무엇인지 말한 적이 없으므로 **지어내지
352
- * 않는다** — 저작자가 mm 로 고쳐 쓸 자리를 열어 줄 뿐이다.
353
- */
354
- function partedMotion(source: FigureSource): Pick<FigureDraft, 'animations' | 'parameters'> {
355
- const animations: NonNullable<FigureDraft['animations']> = []
356
- const parameters: NonNullable<FigureDraft['parameters']> = [...(source.parameters ?? [])]
357
-
358
- for (const clip of source.animations ?? []) {
359
- const { drive, ...rest } = clip as typeof clip & { drive?: string }
360
- if (drive !== 'hold') {
361
- animations.push(rest)
362
- continue
363
- }
364
- parameters.push({ name: clip.name, range: { unit: '%', min: 0, max: 100 }, clip: { channels: clip.channels } })
365
- }
366
-
367
- return {
368
- animations: animations.length > 0 ? animations : undefined,
369
- parameters: parameters.length > 0 ? parameters : undefined
370
- }
371
- }
372
-
373
349
  /** 형식을 씬 모델로 편다. */
374
350
  export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
375
351
  const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
376
- const motion = partedMotion(source)
377
352
 
378
353
  const draft = withoutEmpty({
379
354
  version: source.version,
@@ -384,8 +359,9 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
384
359
  placement: source.placement,
385
360
  detailLevel: source.detailLevel,
386
361
  styleKit: source.styleKit,
387
- animations: motion.animations,
388
- parameters: motion.parameters,
362
+ /* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
363
+ animations: source.animations?.length ? source.animations : undefined,
364
+ parameters: source.parameters?.length ? source.parameters : undefined,
389
365
  capabilities: source.capabilities
390
366
  }) as FigureDraft
391
367
 
@@ -405,7 +381,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
405
381
  top: round4(half.z + position.z - size.z / 2),
406
382
  width: size.x,
407
383
  height: size.z,
408
- zPos: round4(half.y + position.y - size.y / 2),
384
+ zPos: round4(position.y - size.y / 2),
409
385
  depth: size.y,
410
386
 
411
387
  /*
@@ -15,6 +15,11 @@ export interface ProposalSession {
15
15
  picked: Set<string>
16
16
  note: string
17
17
  metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
+ /**
19
+ * The release-check fix this session was opened from, if any. The page translates the title from it;
20
+ * this module stays free of i18n because node tests import it directly.
21
+ */
22
+ fix?: FigureGateFix
18
23
  }
19
24
 
20
25
  function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
@@ -25,7 +30,7 @@ function selectedKeys(current: FigureSource | undefined, source: FigureSource):
25
30
  export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
26
31
  const source = JSON.parse(proposal.source) as FigureSource
27
32
  if (!source || !Array.isArray(source.parts)) {
28
- throw new Error('AI 후보의 Figure 형식이 올바르지 않습니다.')
33
+ throw new Error('the AI candidate is not a figure source: parts is missing or not an array')
29
34
  }
30
35
 
31
36
  return {
@@ -56,7 +61,8 @@ export function openSizingFix(current: FigureSource, fix: FigureGateFix): Propos
56
61
 
57
62
  return {
58
63
  source,
59
- title: `발행 검사 수정안 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}`,
64
+ title: '',
65
+ fix,
60
66
  picked: selectedKeys(current, source),
61
67
  note: ''
62
68
  }
@@ -14,7 +14,7 @@ import { i18next, localize } from '@operato/i18n'
14
14
  import { navigate, PageView } from '@operato/shell'
15
15
  import { openOverlay } from '@operato/layout'
16
16
  import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js'
17
- import { validate } from '@hatiolab/figure-model'
17
+ import { isPlaceholderType, placeholderType, TYPE_LENGTH, TYPE_PATTERN, validate } from '@hatiolab/figure-model'
18
18
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
19
19
 
20
20
  import * as edits from '../modeller/part-edits.js'
@@ -114,6 +114,20 @@ function startingModel(type: string): { draft: FigureDraft; parts: PartModel[] }
114
114
  * 바꾸는 것은 이 페이지다. 두 곳에서 고치면 실행 취소도 저장도 어느 쪽이 맞는지 알 수
115
115
  * 없게 된다.
116
116
  */
117
+ /**
118
+ * Whether this name could be released, by the format's own rules (`type-not-identifier`,
119
+ * `type-is-placeholder`). Saving is not blocked, but the type cannot change after creation, so the
120
+ * author has to hear it now to avoid a draft that can never be released.
121
+ */
122
+ function releasableType(type: string): boolean {
123
+ return (
124
+ !isPlaceholderType(type) &&
125
+ TYPE_PATTERN.test(type) &&
126
+ type.length >= TYPE_LENGTH.min &&
127
+ type.length <= TYPE_LENGTH.max
128
+ )
129
+ }
130
+
117
131
  @customElement('figure-modeller-page')
118
132
  export class FigureModellerPage extends FigureModellerPageBase {
119
133
  static styles = css`
@@ -1112,10 +1126,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1112
1126
 
1113
1127
  return html`
1114
1128
  <div decide>
1115
- <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
1129
+ <span>${this.proposalTitle(this.proposalSession)}</span>
1116
1130
  <div spacer></div>
1117
1131
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
1118
- ${stale ? html`<span role="alert">원본이 변경되었습니다. 후보를 취소하고 AI 지원에서 다시 요청하세요.</span>` : ''}
1132
+ ${stale ? html`<span role="alert">${i18next.t('figure.text.source-changed-ask-again')}</span>` : ''}
1119
1133
  <button take ?disabled=${stale || errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
1120
1134
  ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
1121
1135
  </button>
@@ -1434,7 +1448,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1434
1448
  this.proposalFeedback = []
1435
1449
  this.mode = 'edit'
1436
1450
  // 타입 이름을 사람이 정하기 전까지는 임시 이름을 쓴다. 저장할 때 확정한다.
1437
- const draftType = `FIGURE_${Date.now().toString(36).toUpperCase()}`
1451
+ const draftType = placeholderType()
1438
1452
  const { draft, parts } = startingModel(draftType)
1439
1453
 
1440
1454
  this.figure = { id: '', type: draftType, name: '' } as Figure
@@ -1719,8 +1733,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
1719
1733
  if (!this.figure.id) {
1720
1734
  // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1721
1735
  let defaultType = this.draft?.figureType || this.figure.type || ''
1722
- if (!defaultType || defaultType.startsWith('FIGURE_')) {
1723
- defaultType = 'FIGURE'
1736
+ /*
1737
+ A placeholder is not prefilled. This used to prefill 'FIGURE', inviting the author to confirm a
1738
+ meaningless name that cannot change later and that the release gate refuses.
1739
+ */
1740
+ if (!defaultType || isPlaceholderType(defaultType)) {
1741
+ defaultType = ''
1724
1742
  }
1725
1743
  this.saveModalType = defaultType
1726
1744
  this.saveModalName = this.figure.name || ''
@@ -1745,7 +1763,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1745
1763
  const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim()
1746
1764
 
1747
1765
  if (!typeToSave) {
1748
- throw new Error('타입 이름을 입력하세요.')
1766
+ throw new Error(i18next.t('figure.text.enter-a-type-name'))
1749
1767
  }
1750
1768
 
1751
1769
  const taken = await fetchFigureTypeNames()
@@ -1803,29 +1821,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
1803
1821
  }
1804
1822
  }
1805
1823
 
1824
+ /** Title of the candidate bar. A candidate opened from a release-check fix is described by translating that fix here. */
1825
+ private proposalTitle(session: proposalSessions.ProposalSession): string {
1826
+ const fix = session.fix
1827
+ if (fix) {
1828
+ return i18next.t('figure.text.release-check-fix', {
1829
+ part: fix.part,
1830
+ axis: fix.axis.toUpperCase(),
1831
+ from: fix.from ?? 'auto',
1832
+ to: fix.to
1833
+ })
1834
+ }
1835
+ return session.title || i18next.t('figure.text.assistant-proposed-a-figure')
1836
+ }
1837
+
1806
1838
  private renderSaveModal() {
1807
1839
  if (!this.showSaveModal) return nothing
1808
1840
 
1809
1841
  return html`
1810
1842
  <div class="save-modal-backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this.showSaveModal = false }}>
1811
1843
  <div class="save-modal-card" role="dialog" aria-modal="true">
1812
- <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1813
- <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '도면과 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1844
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure')}</h3>
1845
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc')}</p>
1814
1846
 
1815
1847
  <div class="save-modal-field">
1816
- <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1848
+ <label>${i18next.t('figure.label.figure-type')}</label>
1817
1849
  <input
1818
1850
  .value=${this.saveModalType}
1819
- placeholder="예: OHT, AGV_LIFTER"
1851
+ placeholder=${i18next.t('figure.text.figure-type-example')}
1820
1852
  @input=${(e: Event) => (this.saveModalType = (e.target as HTMLInputElement).value.trim().toUpperCase())}
1821
1853
  />
1854
+ ${this.saveModalType && !releasableType(this.saveModalType)
1855
+ ? html`<div class="save-modal-error">${i18next.t('figure.text.type-name-will-not-release')}</div>`
1856
+ : nothing}
1822
1857
  </div>
1823
1858
 
1824
1859
  <div class="save-modal-field">
1825
- <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1860
+ <label>${i18next.t('figure.label.name')}</label>
1826
1861
  <input
1827
1862
  .value=${this.saveModalName}
1828
- placeholder="예: 오버헤드 호이스트 트랜스포트"
1863
+ placeholder=${i18next.t('figure.text.figure-name-example')}
1829
1864
  @input=${(e: Event) => (this.saveModalName = (e.target as HTMLInputElement).value)}
1830
1865
  />
1831
1866
  </div>
@@ -1834,10 +1869,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1834
1869
 
1835
1870
  <div class="save-modal-actions">
1836
1871
  <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1837
- ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1872
+ ${i18next.t('figure.button.cancel')}
1838
1873
  </button>
1839
1874
  <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1840
- ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1875
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
1841
1876
  </button>
1842
1877
  </div>
1843
1878
  </div>
@@ -1,4 +1,5 @@
1
1
  import gql from 'graphql-tag';
2
+ import { i18next } from '@operato/i18n';
2
3
  import { client } from '@operato/graphql';
3
4
  /**
4
5
  * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**
@@ -16,7 +17,9 @@ function unwrap(response, field) {
16
17
  if (said)
17
18
  throw new Error(said);
18
19
  if (!response.data) {
19
- throw new Error(`서버가 ${field} 돌려주지 않았습니다.`);
20
+ /* The field name stays off the screen because the user cannot act on it; it goes to the console. */
21
+ console.error(`[figure-ui] the server returned no data for ${field}`);
22
+ throw new Error(i18next.t('figure.text.server-returned-no-data'));
20
23
  }
21
24
  return response.data[field];
22
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n throw new Error(`서버가 ${field} 를 돌려주지 않았습니다.`)\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
@@ -102,6 +102,11 @@ export declare class FigureAnimations extends FigureAnimations_base {
102
102
  private renameClip;
103
103
  private renameParameter;
104
104
  private setRange;
105
+ /**
106
+ * Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
107
+ * a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
108
+ */
109
+ private setDuration;
105
110
  private setDefault;
106
111
  private removeClip;
107
112
  private removeParameter;