@things-factory/figure-ui 10.1.5 → 10.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/client/graphql/index.ts +35 -3
  2. package/client/modeller/figure-ask.ts +5 -5
  3. package/client/modeller/figure-canvas.ts +1 -1
  4. package/client/modeller/figure-capabilities.ts +372 -0
  5. package/client/modeller/figure-history-panel.ts +171 -18
  6. package/client/modeller/figure-inspector.ts +392 -76
  7. package/client/modeller/figure-parts.ts +15 -15
  8. package/client/modeller/figure-preview.ts +2 -2
  9. package/client/modeller/figure-report.ts +102 -37
  10. package/client/modeller/figure-settings.ts +20 -20
  11. package/client/modeller/figure-side.ts +37 -9
  12. package/client/modeller/figure-source.test.ts +15 -1
  13. package/client/modeller/figure-source.ts +31 -15
  14. package/client/modeller/figure-thumbnail.ts +49 -4
  15. package/client/modeller/maturity.ts +10 -10
  16. package/client/pages/figure-list-page.ts +152 -66
  17. package/client/pages/figure-modeller-page.ts +260 -43
  18. package/client/types.ts +25 -0
  19. package/client/viewparts/figure-thumb.ts +172 -0
  20. package/dist-client/graphql/index.d.ts +8 -1
  21. package/dist-client/graphql/index.js +32 -3
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/figure-ask.js +5 -5
  24. package/dist-client/modeller/figure-ask.js.map +1 -1
  25. package/dist-client/modeller/figure-canvas.js +1 -1
  26. package/dist-client/modeller/figure-canvas.js.map +1 -1
  27. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  28. package/dist-client/modeller/figure-capabilities.js +344 -0
  29. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  30. package/dist-client/modeller/figure-history-panel.d.ts +15 -1
  31. package/dist-client/modeller/figure-history-panel.js +165 -18
  32. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-inspector.d.ts +84 -0
  34. package/dist-client/modeller/figure-inspector.js +379 -75
  35. package/dist-client/modeller/figure-inspector.js.map +1 -1
  36. package/dist-client/modeller/figure-parts.js +15 -15
  37. package/dist-client/modeller/figure-parts.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.js +2 -2
  39. package/dist-client/modeller/figure-preview.js.map +1 -1
  40. package/dist-client/modeller/figure-report.d.ts +18 -1
  41. package/dist-client/modeller/figure-report.js +105 -37
  42. package/dist-client/modeller/figure-report.js.map +1 -1
  43. package/dist-client/modeller/figure-settings.js +19 -19
  44. package/dist-client/modeller/figure-settings.js.map +1 -1
  45. package/dist-client/modeller/figure-side.d.ts +1 -0
  46. package/dist-client/modeller/figure-side.js +36 -8
  47. package/dist-client/modeller/figure-side.js.map +1 -1
  48. package/dist-client/modeller/figure-source.d.ts +15 -8
  49. package/dist-client/modeller/figure-source.js +16 -7
  50. package/dist-client/modeller/figure-source.js.map +1 -1
  51. package/dist-client/modeller/figure-thumbnail.js +40 -4
  52. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  53. package/dist-client/modeller/maturity.js +10 -10
  54. package/dist-client/modeller/maturity.js.map +1 -1
  55. package/dist-client/pages/figure-list-page.d.ts +1 -4
  56. package/dist-client/pages/figure-list-page.js +150 -63
  57. package/dist-client/pages/figure-list-page.js.map +1 -1
  58. package/dist-client/pages/figure-modeller-page.d.ts +45 -0
  59. package/dist-client/pages/figure-modeller-page.js +259 -43
  60. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-client/types.d.ts +23 -0
  63. package/dist-client/types.js.map +1 -1
  64. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  65. package/dist-client/viewparts/figure-thumb.js +168 -0
  66. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  67. package/dist-server/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +7 -5
  69. package/test/i18n-prefix-guard.test.ts +129 -0
  70. package/test/korean-register-guard.test.ts +83 -0
  71. package/translations/en.json +307 -264
  72. package/translations/ja.json +275 -264
  73. package/translations/ko.json +307 -264
  74. package/translations/ms.json +275 -264
  75. package/translations/zh.json +275 -264
@@ -82,14 +82,6 @@ export interface BoardModel {
82
82
  figureType?: string
83
83
  detailLevel?: DetailLevel
84
84
  styleKit?: string
85
- /**
86
- * 형상 전체의 기준점.
87
- *
88
- * **부품의 `anchor` 와 다른 것이다.** 이쪽은 점(`Vec3`)이고 저쪽은 축별로 어느 면을
89
- * 붙잡느냐다. 한 형식 안에 같은 낱말이 둘인 것은 좋지 않으나, 이름을 바꾸면 저장된
90
- * 원본이 따라와야 하므로 여기서 정하지 않는다.
91
- */
92
- anchor?: Vec3
93
85
  /**
94
86
  * 움직임. **부품 바깥에 있다** — clip 하나가 부품 여럿을 함께 움직이기 때문이다.
95
87
  *
@@ -97,6 +89,14 @@ export interface BoardModel {
97
89
  * 저장하는 것만으로 **조용히 지워지기** 때문이다.
98
90
  */
99
91
  animations?: FigureSource['animations']
92
+ /**
93
+ * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.
94
+ *
95
+ * **부품이 아니라 도형 전체의 것이다** — 「이 컨베이어는 흐름의 마디다」는 부품 하나가
96
+ * 아니라 도형이 지는 성질이다. 부품 쪽 `capability` 는 그 능력을 **어느 부품이 받쳐
97
+ * 주나**를 말하는 다른 칸이다.
98
+ */
99
+ capabilities?: FigureSource['capabilities']
100
100
  }
101
101
 
102
102
  /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
@@ -159,6 +159,13 @@ export interface PartModel {
159
159
  anchor?: FigurePart['anchor']
160
160
  repeat?: FigurePart['repeat']
161
161
  label?: FigurePart['label']
162
+ /**
163
+ * 이 부품이 능력의 어느 자리인가 — 물건이 앉는 자리(slot)이거나 드나드는 문(port)이다.
164
+ *
165
+ * 이 칸이 있는 부품은 **그려지지 않는다.** 저작면에서는 어디인지 보여야 하니 그리지만,
166
+ * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
167
+ */
168
+ capability?: FigurePart['capability']
162
169
  }
163
170
 
164
171
  const RAD_TO_DEG = 180 / Math.PI
@@ -223,11 +230,11 @@ export function toFigureSource(board: BoardModel, parts: PartModel[]): FigureSou
223
230
  return withoutEmpty({
224
231
  type: board.figureType ?? '',
225
232
  base: { x: board.width, y: board.depth, z: board.height },
226
- anchor: board.anchor,
227
233
  detailLevel: board.detailLevel,
228
234
  styleKit: board.styleKit,
229
235
  parts: parts.map(part => partTo(part, half)),
230
- animations: board.animations
236
+ animations: board.animations,
237
+ capabilities: board.capabilities
231
238
  }) as FigureSource
232
239
  }
233
240
 
@@ -239,6 +246,13 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
239
246
  })
240
247
 
241
248
  const shape = withoutEmpty({ round: part.round, path: part.path, hollow: part.hollow })
249
+ /*
250
+ 재질이 하나도 없으면 **칸 자체를 안 적는다.**
251
+
252
+ 빈 객체를 적으면 형식이 「재질은 있는데 무엇으로 칠할지 모른다」로 읽어
253
+ `material-unresolvable` 을 낸다. 그리지 않는 자리(`capability`)에는 재질이 없는 것이
254
+ 정상이고, 그 부품이 조용히 오류를 달고 다니게 된다.
255
+ */
242
256
  const material = withoutEmpty({
243
257
  token: part.token,
244
258
  preset: part.preset,
@@ -262,13 +276,14 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
262
276
  }),
263
277
  segments: part.segments,
264
278
  shape: Object.keys(shape).length > 0 ? shape : undefined,
265
- material,
279
+ material: Object.keys(material).length > 0 ? material : undefined,
266
280
  materialSlot: part.materialSlot,
267
281
  keepRound: part.keepRound,
268
282
  sizing: part.sizing,
269
283
  anchor: part.anchor,
270
284
  repeat: part.repeat,
271
- label: part.label
285
+ label: part.label,
286
+ capability: part.capability
272
287
  }) as FigurePart
273
288
  }
274
289
 
@@ -283,8 +298,8 @@ export function fromFigureSource(source: FigureSource): { board: BoardModel; par
283
298
  figureType: source.type,
284
299
  detailLevel: source.detailLevel,
285
300
  styleKit: source.styleKit,
286
- anchor: source.anchor,
287
- animations: source.animations
301
+ animations: source.animations,
302
+ capabilities: source.capabilities
288
303
  }) as BoardModel
289
304
 
290
305
  return { board, parts: source.parts.map(part => partFrom(part, half)) }
@@ -333,6 +348,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
333
348
  sizing: part.sizing,
334
349
  anchor: part.anchor,
335
350
  repeat: part.repeat,
336
- label: part.label
351
+ label: part.label,
352
+ capability: part.capability
337
353
  }) as PartModel
338
354
  }
@@ -34,11 +34,40 @@ interface Shooter extends HTMLElement {
34
34
  snapshot?: (width?: number, height?: number) => Promise<string | undefined>
35
35
  }
36
36
 
37
- /** 프레임 두 장 뒤에 한 번 더 쉰다 — 씬이 서고 카메라가 자리를 잡을 틈. 씬에 그 신호가 없다. */
37
+ /**
38
+ * 씬이 서고 카메라가 자리를 잡을 틈 — 프레임 두 장 뒤에 한 번 더 쉰다. 씬에 그 신호가 없다.
39
+ *
40
+ * **rAF 만 기다리면 안 된다.** 탭이 뒤로 가거나 창이 가려지면 브라우저가 rAF 를 멈추고, 그러면
41
+ * 이 약속이 영원히 안 풀린다. 그 위에 저장이 얹혀 있어서 저장이 통째로 멈췄다 — 단추는 잠긴 채로,
42
+ * 아무 말도 없이. [확인함] 2026-09-10 포커스 없는 탭에서 `saving: true` 로 고정.
43
+ *
44
+ * 그래서 시계와 함께 경주시킨다. 늦게 깨어난 쪽은 버려지고, 화면이 안 그려지는 상황이면 아래
45
+ * `snapshot` 이 빈 것을 돌려주거나 시간이 다 된다 — 어느 쪽이든 **저장은 계속 간다.**
46
+ */
38
47
  function settle(): Promise<void> {
39
- return new Promise(resolve =>
48
+ const frames = new Promise<void>(resolve =>
40
49
  requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
41
50
  )
51
+
52
+ return Promise.race([frames, delay(600)])
53
+ }
54
+
55
+ function delay(ms: number): Promise<void> {
56
+ return new Promise(resolve => setTimeout(resolve, ms))
57
+ }
58
+
59
+ /**
60
+ * 그림을 기다리는 시간의 한도.
61
+ *
62
+ * 3D 를 세워 한 장 찍는 데 걸리는 시간은 형상과 기계에 따라 다르지만, 이 한도의 목적은 빠르기가
63
+ * 아니라 **저장이 멈추지 않는 것**이다. 넘으면 그림 없이 저장한다 — 정본이 그림보다 중하고,
64
+ * 그림은 다음에 저작면을 열 때 다시 채운다(`figure-modeller-page` 의 fillThumbnail).
65
+ */
66
+ const PATIENCE = 5000
67
+
68
+ /** 시간이 다 되면 없는 것으로 답한다. 던지지 않는다 — 그림이 없는 것은 실패가 아니다. */
69
+ function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
70
+ return Promise.race([work, delay(ms).then(() => undefined)])
42
71
  }
43
72
 
44
73
  export async function captureThumbnail(
@@ -66,9 +95,25 @@ export async function captureThumbnail(
66
95
 
67
96
  try {
68
97
  host.appendChild(shooter)
69
- await settle()
70
98
 
71
- return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
99
+ /*
100
+ **한도 안에 못 찍으면 그림 없이 간다.** 이 함수를 부르는 자리가 저장이고, 저장은 그림을
101
+ 기다리다 멈춰서는 안 된다. 못 찍은 것은 조용히 넘기지 않고 콘솔에 남긴다 — 그림이 계속
102
+ 안 생기면 그 줄이 단서가 된다.
103
+ */
104
+ const shot = await within(
105
+ (async () => {
106
+ await settle()
107
+ return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
108
+ })(),
109
+ PATIENCE
110
+ )
111
+
112
+ if (!shot) {
113
+ console.warn(`[thumbnail] ${PATIENCE}ms 안에 못 찍었습니다 — 그림 없이 저장합니다`)
114
+ }
115
+
116
+ return shot
72
117
  } catch (e) {
73
118
  console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
74
119
  return undefined
@@ -70,7 +70,7 @@ export function maturityOf(source: FigureSource, cost: FigureCost, score: Figure
70
70
  function scale(cost: FigureCost): MaturityAxis {
71
71
  const capacity = capacityFrom(cost.groups, cost.triangles)
72
72
  if (!capacity) {
73
- return { key: 'scale', blocked: 'text.maturity-blocked-no-parts' }
73
+ return { key: 'scale', blocked: 'figure.text.maturity-blocked-no-parts' }
74
74
  }
75
75
 
76
76
  return {
@@ -79,7 +79,7 @@ function scale(cost: FigureCost): MaturityAxis {
79
79
  // 목표를 넘긴 여유는 다른 축을 고치는 데 쓰는 편이 낫다.
80
80
  ratio: Math.min(1, capacity.instances / BOARD_ASSETS),
81
81
  reading: String(capacity.instances),
82
- because: 'text.maturity-scale-because'
82
+ because: 'figure.text.maturity-scale-because'
83
83
  }
84
84
  }
85
85
 
@@ -102,8 +102,8 @@ function consistency(source: FigureSource, cost: FigureCost): MaturityAxis {
102
102
  return {
103
103
  key: 'consistency',
104
104
  ratio: (3 - faults) / 3,
105
- readingKey: faults === 0 ? 'text.maturity-consistency-clean' : 'text.maturity-consistency-faults',
106
- because: 'text.maturity-consistency-because'
105
+ readingKey: faults === 0 ? 'figure.text.maturity-consistency-clean' : 'figure.text.maturity-consistency-faults',
106
+ because: 'figure.text.maturity-consistency-because'
107
107
  }
108
108
  }
109
109
 
@@ -117,7 +117,7 @@ function consistency(source: FigureSource, cost: FigureCost): MaturityAxis {
117
117
  function reuse(score: FigureScore): MaturityAxis {
118
118
  const { distinct, parts, saved } = score.reuse
119
119
  if (parts === 0) {
120
- return { key: 'reuse', blocked: 'text.maturity-blocked-no-parts' }
120
+ return { key: 'reuse', blocked: 'figure.text.maturity-blocked-no-parts' }
121
121
  }
122
122
 
123
123
  return {
@@ -125,7 +125,7 @@ function reuse(score: FigureScore): MaturityAxis {
125
125
  // ratio 가 낮을수록 많이 되쓴다 — 뒤집어서 높은 쪽이 좋게 만든다
126
126
  ratio: 1 - distinct / parts,
127
127
  reading: String(saved),
128
- because: 'text.maturity-reuse-because'
128
+ because: 'figure.text.maturity-reuse-because'
129
129
  }
130
130
  }
131
131
 
@@ -148,7 +148,7 @@ function detail(source: FigureSource, cost: FigureCost): MaturityAxis {
148
148
  // 절반쯤 쓴 것을 제일 좋게 본다. 텅 비어도, 넘쳐도 등급과 안 맞는다.
149
149
  ratio: used > 1 ? Math.max(0, 2 - used) : Math.min(1, used * 2),
150
150
  reading: `${cost.parts} / ${limit}`,
151
- because: 'text.maturity-detail-because'
151
+ because: 'figure.text.maturity-detail-because'
152
152
  }
153
153
  }
154
154
 
@@ -162,7 +162,7 @@ function detail(source: FigureSource, cost: FigureCost): MaturityAxis {
162
162
  * **아무 형상도 보여 줄 수 없는 상태**다. 축 자체가 아직 없는 것이다.
163
163
  */
164
164
  function reactive(source: FigureSource): MaturityAxis {
165
- return { key: 'reactive', blocked: 'text.maturity-blocked-no-binding' }
165
+ return { key: 'reactive', blocked: 'figure.text.maturity-blocked-no-binding' }
166
166
  }
167
167
 
168
168
  /**
@@ -205,7 +205,7 @@ function reactive(source: FigureSource): MaturityAxis {
205
205
  function readable(source: FigureSource, cost: FigureCost): MaturityAxis {
206
206
  const regions = topViewRegions(source)
207
207
  if (regions.total === 0) {
208
- return { key: 'readable', blocked: 'text.maturity-blocked-no-parts' }
208
+ return { key: 'readable', blocked: 'figure.text.maturity-blocked-no-parts' }
209
209
  }
210
210
 
211
211
  // 배경도 한 구역으로 센다 — 발자국을 얼마나 채우나가 식별성의 일부다.
@@ -221,7 +221,7 @@ function readable(source: FigureSource, cost: FigureCost): MaturityAxis {
221
221
  key: 'readable',
222
222
  ratio: Math.min(1, entropy / ceiling),
223
223
  reading: `${regions.byMaterial.size}`,
224
- because: 'text.maturity-readable-because'
224
+ because: 'figure.text.maturity-readable-because'
225
225
  }
226
226
  }
227
227
 
@@ -1,5 +1,6 @@
1
1
  import '@operato/data-grist'
2
2
  import '@material/web/icon/icon.js'
3
+ import '../viewparts/figure-thumb.js'
3
4
 
4
5
  import { css, html } from 'lit'
5
6
  import { customElement, property, query } from 'lit/decorators.js'
@@ -35,12 +36,10 @@ const CAPACITY = 'font-weight: 700; color: var(--md-sys-color-primary);'
35
36
  const UNMEASURED = 'font-style: italic; color: var(--md-sys-color-outline);'
36
37
  const RELEASED = 'font-weight: 700; color: var(--md-sys-color-primary);'
37
38
  const DRAFT = 'color: var(--md-sys-color-tertiary);'
38
- const badge = (low: boolean) =>
39
- `padding: 0 6px; border-radius: var(--md-sys-shape-corner-small, 4px); font-weight: 700; ${
40
- low
41
- ? 'color: var(--md-sys-color-on-error, #fff); background-color: var(--md-sys-color-error);'
42
- : 'color: var(--md-sys-color-on-primary-container); background-color: var(--md-sys-color-primary-container);'
43
- }`
39
+ /** 조용히 적는 숫자. 낮은 것만 점으로 눈에 걸리게 한다. */
40
+ const SCORE = 'display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums;'
41
+ const LOW_DOT =
42
+ 'width: 5px; height: 5px; border-radius: 50%; background-color: var(--md-sys-color-error); flex: none;'
44
43
 
45
44
  /*
46
45
  * `as typeof PageView` 로 조이지 않는다 — 그렇게 하면 p13n 이 얹어 주는
@@ -78,6 +77,44 @@ export class FigureListPage extends FigureListPageBase {
78
77
  width: 100%;
79
78
  background-color: var(--md-sys-color-surface);
80
79
  color: var(--md-sys-color-on-surface);
80
+ /* 떠 있는 단추의 기준 상자 */
81
+ position: relative;
82
+ }
83
+
84
+ /*
85
+ ── 만들기 단추 ────────────────────────────────────────────────────────
86
+ 이 화면의 주된 동작 하나. 목록 위에 떠 있고 자리를 먹지 않는다.
87
+
88
+ 글자를 함께 둔다(extended FAB). 아이콘만 두면 처음 온 사람은 + 표가 무엇을 만드는지
89
+ 모른다 — 이 화면에서 만들 수 있는 것이 무엇인지가 그 글자다.
90
+ */
91
+ button[fab] {
92
+ position: absolute;
93
+ right: var(--spacing-large, 12px);
94
+ bottom: var(--spacing-large, 12px);
95
+ z-index: 10;
96
+
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 6px;
100
+ padding: 10px 18px 10px 14px;
101
+ font: var(--label-font, inherit);
102
+ font-size: 0.82rem;
103
+ font-weight: 600;
104
+ color: var(--md-sys-color-on-primary-container);
105
+ background-color: var(--md-sys-color-primary-container);
106
+ border: none;
107
+ /* 떠 있는 것의 모서리는 알약이다 — 담는 면(6px)을 따르지 않는다 */
108
+ border-radius: var(--md-sys-shape-corner-full, 999px);
109
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
110
+ cursor: pointer;
111
+ }
112
+ button[fab]:hover {
113
+ color: var(--md-sys-color-on-primary, #fff);
114
+ background-color: var(--md-sys-color-primary);
115
+ }
116
+ button[fab] md-icon {
117
+ --md-icon-size: 20px;
81
118
  }
82
119
 
83
120
  /* 격자가 바깥에 요구하는 것은 확정된 높이 하나뿐이다 — 스크롤은 격자가 안에서 한다 */
@@ -90,12 +127,18 @@ export class FigureListPage extends FigureListPageBase {
90
127
  ── 그림이 주인공인 카탈로그 ────────────────────────────────────────
91
128
  카드 기본값은 글이 많은 목록(직원·결재함)에 맞춰져 있다. 여기서는 사람이 먼저 보는 것이
92
129
  형상 그림이므로, 그림 칸을 비율로 잡아 카드 폭이 달라져도 그림이 나란히 읽히게 한다.
130
+
131
+ 그림은 카드 끝까지 닿는다 — 위쪽 모서리만 카드와 같은 6px 로 깎아 한 장처럼 보이게 하고,
132
+ 그림 안에 얹히는 초안 표시·삭제는 figure-thumb 이 그린다.
93
133
  */
94
134
  ox-grist {
95
135
  --data-card-min-width: 240px;
136
+ --data-card-record-card-border-radius: 6px;
137
+ --data-card-record-card-padding: 0;
96
138
  --data-card-thumbnail-height: auto;
97
139
  --data-card-thumbnail-aspect: 4 / 3;
98
- --data-card-thumbnail-padding: var(--spacing-small);
140
+ --data-card-thumbnail-padding: 0;
141
+ --data-card-thumbnail-radius: 6px 6px 0 0;
99
142
  --data-card-thumbnail-background: var(--md-sys-color-surface-container);
100
143
 
101
144
  /*
@@ -111,6 +154,13 @@ export class FigureListPage extends FigureListPageBase {
111
154
 
112
155
  /* 상태와 성숙도는 짧은 표식이다 — 두 줄로 세우지 않고 한 줄에 뉜다 */
113
156
  --data-card-detail-direction: row;
157
+
158
+ /*
159
+ 맨 위로 가는 화살표를 만들기 단추 **위로** 올린다. 둘 다 오른쪽 아래에 떠 있으므로
160
+ 그냥 두면 겹친다 — 단추(36px + 아래 여백 12px) 위에 12px 을 띄운 자리다.
161
+ */
162
+ --data-card-fab-position-vertical: 64px;
163
+ --data-list-fab-position-vertical: 64px;
114
164
  }
115
165
  `
116
166
  ]
@@ -123,25 +173,26 @@ export class FigureListPage extends FigureListPageBase {
123
173
 
124
174
  get context() {
125
175
  return {
126
- title: i18next.t('title.figures'),
176
+ title: i18next.t('figure.title.figures'),
127
177
  search: {
128
178
  handler: (search: string) => {
129
179
  this.grist.searchText = search
130
180
  },
131
181
  value: this.grist?.searchText || '',
132
- placeholder: i18next.t('text.search-figure-by-name-or-type')
182
+ placeholder: i18next.t('figure.text.search-figure-by-name-or-type')
133
183
  },
134
184
  filter: {
135
185
  handler: () => {
136
186
  this.grist.toggleHeadroom()
137
187
  }
138
- },
139
- actions: [
140
- {
141
- title: i18next.t('button.new-figure'),
142
- action: () => this.openModeller()
143
- }
144
- ]
188
+ }
189
+ /*
190
+ **만들기는 `actions` 가 아니라 FAB 이다.**
191
+
192
+ `context.actions` 두면 페이지 바닥 줄에 글자 단추로 서는데, 그 줄은 「지금 화면에 대해
193
+ 할 일」을 늘어놓는 자리다. 만들기는 이 화면의 유일한 주된 동작이므로 줄에 섞이지 않고
194
+ 떠 있어야 한다. 카드를 가리지 않는 오른쪽 아래, 손이 가는 자리다(아래 render 의 [fab]).
195
+ */
145
196
  }
146
197
  }
147
198
 
@@ -161,6 +212,10 @@ export class FigureListPage extends FigureListPageBase {
161
212
 
162
213
  <ox-grist-personalizer slot="setting"></ox-grist-personalizer>
163
214
  </ox-grist>
215
+
216
+ <button fab title=${i18next.t('figure.button.new-figure')} @click=${() => this.openModeller()}>
217
+ <md-icon>add</md-icon>${i18next.t('figure.button.new-figure')}
218
+ </button>
164
219
  `
165
220
  }
166
221
 
@@ -179,44 +234,42 @@ export class FigureListPage extends FigureListPageBase {
179
234
  */
180
235
  emptyNote: {
181
236
  icon: 'deployed_code',
182
- title: i18next.t('text.no-figure-yet'),
183
- description: i18next.t('text.what-a-figure-is')
237
+ title: i18next.t('figure.text.no-figure-yet'),
238
+ description: i18next.t('figure.text.what-a-figure-is')
184
239
  },
185
240
 
186
241
  list: {
242
+ /* 그림 칸은 그 컬럼의 renderer 가 그린다 — 그림 없을 때의 안내까지 `figure-thumb` 안이다 */
187
243
  thumbnail: 'thumbnail',
188
- /* 그림이 없는 카드도 자리는 지킨다 — 빈 사각형으로 두지 않고 왜 없는지 말한다 */
189
- emptyThumbnail: {
190
- icon: 'deployed_code',
191
- label: i18next.t('text.thumbnail-not-made-yet')
192
- },
193
244
  fields: ['name', 'type'],
194
245
  /*
195
- **카드에는 고를 쓰는 것만 싣는다.**
246
+ ── 무게를 모른 고르게 두지 않는다 ────────────────────────────────
247
+ 이름과 그림만 실으면 저작자는 **이 형상이 무거운지 모른 채** 고른다. 그것을 저작면에
248
+ 들어가서야 아는 것은 늦다. 그래서 격자로 옮기기 전 손 카드가 실었던 셋을 그대로 싣는다 —
249
+ 몇 개까지 놓을 수 있나(배치 한도) · 성숙도 · 삼각형 수.
196
250
 
197
- 배치 한도·성숙도·삼각형 수·수정일시까지 실어 봤더니 카드가 명세표가 되었다. 카탈로그에서
198
- 사람이 하는 일은 「이거다」를 고르는 것이고, 그때 보는 것은 그림과 이름이다.
251
+ 줄에 뉜다(`--data-card-detail-direction: row`). 이름표는 달지 않는다 값 자체가
252
+ 「625개까지」·「삼각형 440개」로 읽히고, 성숙도만 숫자 하나여서 손이 갔을 이름이 뜬다.
199
253
 
200
- 숫자는 **하나만** 남긴다 성숙도다. 이것 하나가 나머지를 요약한다(배치 한도와
201
- 삼각형 수는 무게 가지만 말하는데, 성숙도는 무게·식별성·상태 표현까지 여러 축을 합친
202
- 값이다). 그 축별 내역과 나머지 숫자는 표 모드의 칸과 저작면에 있다.
203
-
204
- 이름표는 달지 않는다. 성숙도는 색과 숫자로 읽히고, 상태는 발행된 것에 아무 말도 하지
205
- 않는다(그것이 예사다) — 초안일 때만 「초안」이 뜬다.
254
+ 상태는 줄에 없다. 그림 초안 표시가 일을 한다(`figure-thumb`) — 발행된 것에는
255
+ 아무 말도 하지 않으므로 칸이 생기지 않는다.
206
256
  */
207
- details: ['state', 'score'],
208
- /* 삭제는 손이 갈 때만 나온다 — 고르려고 보는 자리에 되돌릴 수 없는 조작을 띄우지 않는다 */
209
- gutters: 'hover'
257
+ details: ['capacity', 'score', 'triangles']
210
258
  },
211
259
 
212
260
  columns: [
213
261
  { type: 'gutter', gutterName: 'sequence', fixed: true },
214
262
  {
263
+ /*
264
+ 표 모드에서만 쓰는 삭제 칸. 카드에서는 `figure-thumb` 이 그림 위에 그리므로
265
+ `forList: false` 로 내린다 — 카드 아래 단추 줄은 감춰도 자리가 남는다.
266
+ */
215
267
  type: 'gutter',
216
268
  gutterName: 'button',
217
269
  icon: 'delete',
218
- title: i18next.t('button.delete'),
270
+ title: i18next.t('figure.button.delete-figure'),
219
271
  danger: true,
272
+ forList: false,
220
273
  width: 40,
221
274
  handlers: {
222
275
  click: (_columns: any, _data: any, _column: any, record: Figure) => this.removeFigure(record)
@@ -225,7 +278,7 @@ export class FigureListPage extends FigureListPageBase {
225
278
  {
226
279
  type: 'string',
227
280
  name: 'name',
228
- header: i18next.t('label.name'),
281
+ header: i18next.t('figure.label.name'),
229
282
  record: { editable: false },
230
283
  filter: 'search',
231
284
  sortable: true,
@@ -238,7 +291,7 @@ export class FigureListPage extends FigureListPageBase {
238
291
  */
239
292
  type: 'string',
240
293
  name: 'type',
241
- header: i18next.t('label.type'),
294
+ header: i18next.t('figure.label.figure-type'),
242
295
  record: {
243
296
  editable: false,
244
297
  renderer: (value: string) => html`<code>${value}</code>`
@@ -250,7 +303,7 @@ export class FigureListPage extends FigureListPageBase {
250
303
  {
251
304
  type: 'string',
252
305
  name: 'state',
253
- header: i18next.t('label.state'),
306
+ header: i18next.t('figure.label.state'),
254
307
  record: {
255
308
  editable: false,
256
309
  /*
@@ -263,25 +316,31 @@ export class FigureListPage extends FigureListPageBase {
263
316
  */
264
317
  renderer: (value: string, _column: any, record: Figure, _rowIndex: number, field: Element) => {
265
318
  if (value !== 'released') {
266
- return html`<span style=${DRAFT}>${i18next.t('label.draft')}</span>`
319
+ return html`<span style=${DRAFT}>${i18next.t('figure.label.draft')}</span>`
267
320
  }
268
321
 
269
322
  /* 표에서는 칸이 비면 안 된다. 카드에서만 입을 다문다 — 그리는 자리가 알려 준다. */
270
323
  return field?.tagName === 'OX-CARD-FIELD'
271
324
  ? ''
272
325
  : html`<span style=${RELEASED}
273
- >${i18next.t('label.released')}
274
- ${record.version ? i18next.t('text.version-number', { version: record.version }) : ''}</span
326
+ >${i18next.t('figure.label.figure-released')}
327
+ ${record.version ? i18next.t('figure.text.version-number', { version: record.version }) : ''}</span
275
328
  >`
276
329
  }
277
330
  },
278
- /* 전체·초안·발행됨을 단추로 고른다 — 값이 둘뿐인 조건을 펼침 목록에 넣지 않는다 */
331
+ /*
332
+ 전체·초안·발행됨을 단추로 고른다 — 값이 둘뿐인 조건을 펼침 목록에 넣지 않는다.
333
+
334
+ 이름표는 비워 둔다. 단추에 「초안」·「발행됨」이라고 적혀 있으니 그 앞의 「상태」는
335
+ 같은 말을 한 번 더 하는 것이고, 걸러 보기 줄에서 자리만 먹는다.
336
+ */
279
337
  filter: {
280
338
  type: 'select-buttons',
281
339
  operator: 'in',
340
+ label: '',
282
341
  options: [
283
- { display: i18next.t('label.draft'), value: 'draft' },
284
- { display: i18next.t('label.released'), value: 'released' }
342
+ { display: i18next.t('figure.label.draft'), value: 'draft' },
343
+ { display: i18next.t('figure.label.figure-released'), value: 'released' }
285
344
  ]
286
345
  },
287
346
  width: 120
@@ -295,8 +354,7 @@ export class FigureListPage extends FigureListPageBase {
295
354
  */
296
355
  type: 'string',
297
356
  name: 'capacity',
298
- header: i18next.t('label.capacity'),
299
- label: true,
357
+ header: i18next.t('figure.label.placement-limit'),
300
358
  sortable: false,
301
359
  record: {
302
360
  editable: false,
@@ -304,10 +362,10 @@ export class FigureListPage extends FigureListPageBase {
304
362
  const capacity = capacityFrom(record.groups ?? 0, record.triangles ?? 0)
305
363
 
306
364
  return capacity
307
- ? html`<span style=${CAPACITY} title=${i18next.t('text.reuse-score-explained')}>
308
- ${i18next.t('text.fits-n-instances', { n: capacity.instances.toLocaleString() })}
365
+ ? html`<span style=${CAPACITY} title=${i18next.t('figure.text.reuse-score-explained')}>
366
+ ${i18next.t('figure.text.fits-n-instances', { n: capacity.instances.toLocaleString() })}
309
367
  </span>`
310
- : html`<span style=${UNMEASURED}>${i18next.t('text.not-measured-yet')}</span>`
368
+ : html`<span style=${UNMEASURED}>${i18next.t('figure.text.not-measured-yet')}</span>`
311
369
  }
312
370
  },
313
371
  width: 120
@@ -315,23 +373,27 @@ export class FigureListPage extends FigureListPageBase {
315
373
  {
316
374
  type: 'float',
317
375
  name: 'score',
318
- header: i18next.t('label.maturity'),
376
+ header: i18next.t('figure.label.maturity'),
319
377
  record: {
320
378
  editable: false,
321
379
  /*
322
- 카드에 남긴 하나뿐인 숫자. **이름표를 달지 않으므로** 무엇인지는 색과 손이 갔을 때
323
- 뜨는 말이 알려 준다.
380
+ 카드에 남긴 하나뿐인 숫자. **이름표를 달지 않으므로** 무엇인지는 손이 갔을 때 뜨는
381
+ 말이 알려 준다.
382
+
383
+ 색칠한 배지로 두지 않는다. 지금 카탈로그의 열넷 중 열셋이 0 점이라(선언된 묶음 한도
384
+ 3 을 넘긴다) 붉은 덩어리가 카드마다 서면 목록이 시끄럽고, 그러면서 아무것도 가르지
385
+ 못한다. 숫자는 조용히 적고 낮은 것에만 **점 하나**를 붙인다.
324
386
 
325
- 아직 재지 않은 것을 **0 으로 보여 주지 않는다.** 0 은 「가볍다」는 뜻이 되어 버린다.
326
- 등급 문자(A~D)도 여기서 만들지 않는다 경계는 `figure-model` 정하고 그것을
327
- 화면이 베끼면 두 벌이 되어 목록과 저작면이 다른 등급을 말한다.
387
+ 아직 재지 않은 것을 0 으로 보여 주지 않는다 0 은 「가볍다」는 뜻이 되어 버린다.
388
+ 등급 문자(A~D)도 여기서 만들지 않는다: 경계는 `figure-model` `scoreOf` 가 정하고,
389
+ 화면이 그것을 베끼면 두 벌이 되어 목록과 저작면이 다른 등급을 말한다.
328
390
  */
329
391
  renderer: (value: number | null) =>
330
392
  value == null
331
- ? html`<span style=${UNMEASURED}>${i18next.t('text.not-measured-yet')}</span>`
332
- : html`<span style=${badge(value < ATTENTION_BELOW)} title=${i18next.t('label.maturity')}
333
- >${Math.round(value)}</span
334
- >`
393
+ ? html`<span style=${UNMEASURED}>${i18next.t('figure.text.not-measured-yet')}</span>`
394
+ : html`<span style=${SCORE} title=${i18next.t('figure.label.maturity')}>
395
+ ${value < ATTENTION_BELOW ? html`<i style=${LOW_DOT}></i>` : ''}${Math.round(value)}
396
+ </span>`
335
397
  },
336
398
  sortable: true,
337
399
  width: 90
@@ -339,11 +401,16 @@ export class FigureListPage extends FigureListPageBase {
339
401
  {
340
402
  type: 'integer',
341
403
  name: 'triangles',
342
- header: i18next.t('label.triangles'),
343
- label: true,
404
+ header: i18next.t('figure.label.triangles'),
344
405
  record: {
345
406
  editable: false,
346
- renderer: (value: number | null) => (value == null ? '' : value.toLocaleString())
407
+ /*
408
+ 카드에서는 이름표 없이 서므로 숫자만 두면 옆의 성숙도와 구별되지 않는다. 무엇의
409
+ 수인지 값이 스스로 말하게 한다 — 표 모드에서는 머리글이 그 일을 하므로 겹치지만,
410
+ 그 겹침이 카드에서 뜻을 잃는 것보다 낫다.
411
+ */
412
+ renderer: (value: number | null) =>
413
+ value == null ? '' : i18next.t('figure.text.triangle-count', { count: value })
347
414
  },
348
415
  sortable: true,
349
416
  width: 110
@@ -351,16 +418,35 @@ export class FigureListPage extends FigureListPageBase {
351
418
  {
352
419
  type: 'integer',
353
420
  name: 'groups',
354
- header: i18next.t('label.budget-groups'),
421
+ header: i18next.t('figure.label.budget-groups'),
355
422
  record: { editable: false },
356
423
  hidden: true,
357
424
  width: 0
358
425
  },
359
426
  {
427
+ /*
428
+ **카드의 그림 칸 전체를 이 renderer 가 그린다.**
429
+
430
+ 기본 image renderer 는 그림만 그리고, 그 위에 얹혀야 하는 것(초안 표시·삭제)은 카드
431
+ 아래 gutter 줄로 밀린다. 격자로 옮기기 전 손 카드가 그것을 그림 위에 두었고 그 편이
432
+ 맞다 — 그래서 그림 칸을 `figure-thumb` 에게 넘긴다. hover 가 필요해 컴포넌트여야
433
+ 하는 사연은 그 파일에 적혀 있다.
434
+
435
+ `renderEmpty` — 그림이 아직 없는 것도 자리를 지킨다. 그 카드에도 초안 표시와 삭제가
436
+ 있어야 하고, 「그림 없음」이라고 말하는 것도 `figure-thumb` 이 한다.
437
+ */
360
438
  type: 'image',
361
439
  name: 'thumbnail',
362
440
  header: i18next.t('field.thumbnail'),
363
- record: { editable: false, align: 'center', wide: true },
441
+ record: {
442
+ editable: false,
443
+ align: 'center',
444
+ wide: true,
445
+ renderEmpty: true,
446
+ renderer: (_value: unknown, _column: any, record: Figure) => html`
447
+ <figure-thumb .figure=${record} @remove=${() => this.removeFigure(record)}></figure-thumb>
448
+ `
449
+ },
364
450
  hidden: true,
365
451
  width: 160
366
452
  },
@@ -459,7 +545,7 @@ export class FigureListPage extends FigureListPageBase {
459
545
  /** `remove` 는 HTMLElement 의 메서드다 — 가리면 조용한 결함이 된다. */
460
546
  private async removeFigure(figure: Figure) {
461
547
  // 되돌릴 수 없는 조작이므로 무엇이 사라지는지 이름으로 확인한다
462
- const message = i18next.t('text.delete-figure-confirm', { name: figure.name })
548
+ const message = i18next.t('figure.text.delete-figure-confirm', { name: figure.name })
463
549
  if (!confirm(message)) {
464
550
  return
465
551
  }