@things-factory/figure-ui 10.1.271 → 10.1.273

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/data-interface.ts +30 -18
  4. package/client/modeller/editor-styles.ts +17 -6
  5. package/client/modeller/editor.ts +2 -0
  6. package/client/modeller/fasten-form.ts +81 -6
  7. package/client/modeller/fasten-geometry.ts +5 -3
  8. package/client/modeller/figure-cavity-drawing.ts +1 -3
  9. package/client/modeller/figure-clips.ts +101 -8
  10. package/client/modeller/figure-data-panel.ts +384 -200
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +24 -14
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +109 -37
  15. package/client/modeller/figure-parts.ts +7 -11
  16. package/client/modeller/figure-preview.ts +299 -79
  17. package/client/modeller/figure-size-editor.ts +7 -2
  18. package/client/modeller/first-save.ts +8 -0
  19. package/client/modeller/focus-on-render.ts +4 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/size-response.ts +14 -0
  22. package/client/modeller/stage-model.ts +50 -6
  23. package/dist-client/modeller/canvas.d.ts +1 -0
  24. package/dist-client/modeller/canvas.js +3 -2
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +17 -3
  27. package/dist-client/modeller/clips.js +69 -4
  28. package/dist-client/modeller/clips.js.map +1 -1
  29. package/dist-client/modeller/data-interface.d.ts +11 -8
  30. package/dist-client/modeller/data-interface.js +27 -17
  31. package/dist-client/modeller/data-interface.js.map +1 -1
  32. package/dist-client/modeller/editor-styles.js +17 -6
  33. package/dist-client/modeller/editor-styles.js.map +1 -1
  34. package/dist-client/modeller/editor.js +2 -0
  35. package/dist-client/modeller/editor.js.map +1 -1
  36. package/dist-client/modeller/fasten-form.d.ts +13 -0
  37. package/dist-client/modeller/fasten-form.js +72 -7
  38. package/dist-client/modeller/fasten-form.js.map +1 -1
  39. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  40. package/dist-client/modeller/fasten-geometry.js +5 -3
  41. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  42. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  43. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  44. package/dist-client/modeller/figure-clips.d.ts +9 -0
  45. package/dist-client/modeller/figure-clips.js +104 -8
  46. package/dist-client/modeller/figure-clips.js.map +1 -1
  47. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  48. package/dist-client/modeller/figure-data-panel.js +382 -190
  49. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  51. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +24 -14
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-first-save.js +4 -3
  55. package/dist-client/modeller/figure-first-save.js.map +1 -1
  56. package/dist-client/modeller/figure-links.d.ts +9 -0
  57. package/dist-client/modeller/figure-links.js +120 -51
  58. package/dist-client/modeller/figure-links.js.map +1 -1
  59. package/dist-client/modeller/figure-parts.js +7 -11
  60. package/dist-client/modeller/figure-parts.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.d.ts +13 -0
  62. package/dist-client/modeller/figure-preview.js +296 -80
  63. package/dist-client/modeller/figure-preview.js.map +1 -1
  64. package/dist-client/modeller/figure-size-editor.js +7 -3
  65. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  66. package/dist-client/modeller/first-save.d.ts +5 -0
  67. package/dist-client/modeller/first-save.js +7 -0
  68. package/dist-client/modeller/first-save.js.map +1 -1
  69. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  70. package/dist-client/modeller/focus-on-render.js +4 -0
  71. package/dist-client/modeller/focus-on-render.js.map +1 -0
  72. package/dist-client/modeller/pivot.d.ts +12 -0
  73. package/dist-client/modeller/pivot.js +59 -0
  74. package/dist-client/modeller/pivot.js.map +1 -0
  75. package/dist-client/modeller/size-response.d.ts +6 -0
  76. package/dist-client/modeller/size-response.js +14 -0
  77. package/dist-client/modeller/size-response.js.map +1 -1
  78. package/dist-client/modeller/stage-model.d.ts +17 -3
  79. package/dist-client/modeller/stage-model.js +44 -6
  80. package/dist-client/modeller/stage-model.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +8 -7
  83. package/test/browser/editing-page.js +1 -1
  84. package/test/browser/editing.check.mjs +6 -5
  85. package/test/browser/links.check.mjs +30 -0
  86. package/test/browser/modeller.check.mjs +2 -2
  87. package/test/browser/parts-page.js +4 -0
  88. package/test/browser/parts.check.mjs +16 -10
  89. package/test/browser/screen-page.js +12 -1
  90. package/test/browser/screen.check.mjs +142 -11
  91. package/test/browser/section.check.mjs +21 -0
  92. package/test/browser/serve-response.mjs +5 -2
  93. package/test/browser/side-panel-sweep.check.mjs +171 -10
  94. package/test/clips.test.ts +49 -4
  95. package/test/data-motion.test.ts +39 -8
  96. package/test/fasten-form.test.ts +69 -0
  97. package/test/fasten-geometry.test.ts +13 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/size-response.test.ts +10 -2
  101. package/test/stage-model.test.ts +23 -1
  102. package/translations/en.json +31 -11
  103. package/translations/es.json +30 -10
  104. package/translations/ja.json +30 -10
  105. package/translations/ko.json +32 -12
  106. package/translations/ms.json +30 -10
  107. package/translations/zh.json +30 -10
  108. package/tutorial/humanoid/README.md +22 -0
  109. package/tutorial/humanoid/chapters.mjs +247 -0
  110. package/tutorial/humanoid/encode.mjs +26 -0
  111. package/tutorial/humanoid/lib.mjs +197 -0
  112. package/tutorial/humanoid/record.mjs +65 -0
@@ -1,6 +1,8 @@
1
1
  import '@material/web/icon/icon.js'
2
2
 
3
3
  import { css, html, LitElement, nothing } from 'lit'
4
+ import JSON5 from 'json5'
5
+ import { ScrollbarStyles } from '@operato/styles'
4
6
  import { customElement, property, state } from 'lit/decorators.js'
5
7
 
6
8
  import { i18next, localize } from '@operato/i18n'
@@ -30,6 +32,8 @@ import {
30
32
  legendRoleOf,
31
33
  methodOf,
32
34
  methodsOf,
35
+ freeNumberCodeOf,
36
+ tableWithKeep,
33
37
  partsOf,
34
38
  rotationStepsOf,
35
39
  sampleOf,
@@ -51,7 +55,7 @@ import {
51
55
  } from './data-interface.js'
52
56
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
53
57
  import { dataMoveOf } from '@things-factory/figure-viewer/dist-client/data-connection.js'
54
- import { kindWords, moveWords } from '@things-factory/figure-viewer/dist-client/data-words.js'
58
+ import { kindName, kindWords, moveWords } from '@things-factory/figure-viewer/dist-client/data-words.js'
55
59
  import { figureBindingsTakenOver, figureCapabilityDescriptorOf, type FigureRestingBinding } from '@hatiolab/things-scene'
56
60
  import { consumerDescriptors } from './consumer-capabilities.js'
57
61
  import { HelpToggles } from './help.js'
@@ -70,49 +74,94 @@ const KINDS: FigureDataNode['kind'][] = ['number', 'boolean', 'text', 'object',
70
74
  * running on it (`data-sample`); a sample legend pasted for a table's role shows it as a board's legend would
71
75
  * (`legend-sample`).
72
76
  */
77
+ /** A binding being added to the open field: nothing chosen yet. */
78
+ const NO_CONNECTING = { method: '' as DataMethod | '', behavior: '', parts: [] as string[], inputs: [] as string[], group: '' }
79
+
73
80
  @customElement('figure-data-panel')
74
81
  export class FigureDataPanel extends localize(i18next)(LitElement) {
75
82
  private readonly help = new HelpToggles(this)
76
83
  static styles = [
84
+ /* The panel's scrollbars -- the sample's box among them -- in the shell's look, not the browser's. */
85
+ ScrollbarStyles,
77
86
  SidePanelStyles,
78
87
  css`
79
88
  :host {
80
89
  display: block;
81
90
  }
82
- ul[tree] {
83
- display: grid;
84
- gap: 2px;
85
- margin: 0;
86
- padding: 0;
87
- list-style: none;
88
- }
89
- ul[tree] > li > button[row] {
91
+ /* Where the sample shows, said while editing, with the press that goes there. */
92
+ p[sample-where] {
90
93
  display: flex;
91
94
  flex-wrap: wrap;
92
- gap: 0 6px;
93
- width: 100%;
95
+ align-items: center;
96
+ gap: 4px 8px;
97
+ margin: 4px 0 0;
98
+ font-size: var(--ui-t-small, 11.5px);
99
+ color: var(--md-sys-color-on-surface-variant);
100
+ }
101
+ /*
102
+ The fields as a table -- field, kind, what it moves -- a line between rows: a list of two-line names with their
103
+ kinds pushed right ran together, one field not told from the next (user 2026-11-12).
104
+ */
105
+ div[fields] {
106
+ display: grid;
107
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr);
108
+ border-top: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
109
+ }
110
+ div[fields] > div[head],
111
+ div[fields] > button[row] {
112
+ grid-column: 1 / -1;
113
+ display: grid;
114
+ grid-template-columns: subgrid;
115
+ column-gap: 8px;
116
+ align-items: center;
117
+ padding: 5px 6px;
118
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
119
+ }
120
+ div[fields] > div[head] {
121
+ font-size: var(--ui-t-micro, 11px);
122
+ color: var(--md-sys-color-on-surface-variant);
123
+ }
124
+ div[fields] > button[row] {
94
125
  min-height: var(--figure-control-height);
95
126
  margin: 0;
96
- padding: 4px 6px;
97
- border: 1px solid transparent;
98
- border-radius: 6px;
127
+ border-top: none;
128
+ border-left: none;
129
+ border-right: none;
99
130
  background: none;
100
131
  color: inherit;
101
132
  font: inherit;
102
133
  text-align: left;
103
- align-items: center;
104
134
  cursor: pointer;
105
135
  }
106
- ul[tree] > li > button[row][selected] {
107
- border-color: var(--md-sys-color-primary);
136
+ div[fields] > button[row]:hover {
137
+ background: var(--md-sys-color-surface-container-low, rgba(0, 0, 0, 0.03));
138
+ }
139
+ div[fields] > button[row][selected] {
140
+ background: var(--md-sys-color-secondary-container, rgba(0, 0, 0, 0.06));
141
+ }
142
+ div[fields] span[role='cell'] {
143
+ min-width: 0;
144
+ overflow-wrap: anywhere;
108
145
  }
109
- ul[tree] > li > button[row] small {
146
+ div[fields] span[name] {
147
+ display: grid;
148
+ }
149
+ div[fields] span[name] small,
150
+ div[fields] span[none] {
110
151
  color: var(--md-sys-color-on-surface-variant);
111
152
  }
112
- /* What the field is and what reads it: beside the name when it fits, under it when not. */
113
- ul[tree] > li > button[row] small[meta] {
114
- margin-left: auto;
115
- text-align: right;
153
+ div[fields] div[bindings] > h4 {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 4px;
157
+ margin: 12px 0 6px;
158
+ font-size: var(--ui-t-body, 13px);
159
+ font-weight: 600;
160
+ }
161
+ div[fields] > div[detail] {
162
+ grid-column: 1 / -1;
163
+ padding: 8px 6px;
164
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
116
165
  }
117
166
  section[item] {
118
167
  display: grid;
@@ -156,18 +205,6 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
156
205
  margin: 0;
157
206
  padding: 0;
158
207
  }
159
- div[cases] {
160
- display: grid;
161
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
162
- gap: 4px 6px;
163
- align-items: center;
164
- }
165
- div[codes] {
166
- display: grid;
167
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 30px;
168
- gap: 4px 6px;
169
- align-items: center;
170
- }
171
208
  div[codes] button[act] {
172
209
  width: 30px;
173
210
  margin: 0;
@@ -185,52 +222,132 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
185
222
  padding: 0;
186
223
  }
187
224
  /* A table's rows: the key (a code, or from and to), a cell per column, and a remove button. */
188
- div[looks] {
225
+ /* A table's values, a box each: the value on the head line, its settings a property grid under it (user 2026-11-12). */
226
+ div[cases] {
189
227
  display: grid;
190
- gap: 4px 6px;
228
+ gap: 8px;
229
+ }
230
+ div[case] {
231
+ border: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
232
+ border-radius: 6px;
233
+ overflow: hidden;
234
+ }
235
+ div[case-head] {
236
+ display: flex;
191
237
  align-items: center;
192
- overflow-x: auto;
238
+ gap: 6px;
239
+ min-height: var(--figure-control-height);
240
+ padding: 2px 6px;
241
+ background: var(--md-sys-color-surface-container-low, rgba(0, 0, 0, 0.03));
193
242
  }
194
- div[looks] > small {
243
+ div[case-head] small {
195
244
  color: var(--md-sys-color-on-surface-variant);
196
245
  }
197
- div[looks] input,
198
- div[looks] select {
246
+ div[case-head] input {
247
+ flex: 1;
199
248
  min-width: 0;
200
- width: 100%;
201
249
  box-sizing: border-box;
202
250
  }
203
- div[looks] button[act] {
204
- width: 30px;
251
+ div[case-head] button[act] {
252
+ margin-left: auto;
253
+ }
254
+ div[case] > p[hint] {
205
255
  margin: 0;
206
- padding: 0;
256
+ padding: 6px;
257
+ border-top: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
258
+ }
259
+ /* The 「그대로 둠」 of the other-values box: a checkbox and its word beside the box's name. */
260
+ div[case-head] label[keep] {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 4px;
264
+ font-size: var(--ui-t-small, 11.5px);
265
+ color: var(--md-sys-color-on-surface-variant);
266
+ }
267
+ div[case-head] label[keep] input {
268
+ flex: none;
269
+ width: auto;
270
+ margin: 0;
271
+ }
272
+ /* A property grid: a line a setting, its name left, its field right, a line between and between the two. */
273
+ div[props] {
274
+ display: grid;
275
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
276
+ }
277
+ div[props] > span {
278
+ display: flex;
279
+ align-items: center;
280
+ min-width: 0;
281
+ min-height: var(--figure-control-height);
282
+ padding: 2px 6px;
283
+ border-top: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
284
+ }
285
+ div[props] > span:nth-child(odd) {
286
+ border-right: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
287
+ }
288
+ div[props] > span:nth-child(odd) small {
289
+ overflow: hidden;
290
+ color: var(--md-sys-color-on-surface-variant);
291
+ white-space: nowrap;
292
+ text-overflow: ellipsis;
293
+ }
294
+ div[props] input,
295
+ div[props] select {
296
+ width: 100%;
297
+ min-width: 0;
298
+ box-sizing: border-box;
207
299
  }
208
300
  /* A colour and the button that clears it, on one line at the height of the cells beside it. */
209
- div[looks] span[colour] {
301
+ div[props] span[colour] {
210
302
  display: flex;
303
+ flex: 1;
211
304
  gap: 2px;
212
305
  align-items: center;
213
306
  min-width: 0;
214
307
  }
215
- div[looks] span[colour] input[type='color'] {
308
+ div[props] span[colour] input[type='color'] {
216
309
  flex: 1;
217
310
  height: var(--figure-control-height);
218
311
  padding: 0 2px;
219
312
  }
220
- div[looks] span[colour] button[act] {
313
+ div[props] span[colour] button[act] {
221
314
  flex: none;
222
315
  width: 24px;
223
316
  }
224
- div[chips] {
225
- display: flex;
226
- flex-wrap: wrap;
227
- gap: 4px 10px;
317
+ /* A table's targets as a property list: what each is, its name, its delete. */
318
+ div[targets] {
319
+ display: grid;
320
+ grid-template-columns: auto minmax(0, 1fr) auto;
228
321
  align-items: center;
322
+ border: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
323
+ border-radius: 6px;
324
+ overflow: hidden;
229
325
  }
230
- div[chips] label {
231
- display: inline-flex;
232
- gap: 4px;
233
- align-items: center;
326
+ div[targets] > * {
327
+ min-width: 0;
328
+ padding: 2px 6px;
329
+ border-top: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
330
+ }
331
+ div[targets] > small[head] {
332
+ grid-column: 1 / -1;
333
+ border-top: none;
334
+ padding: 4px 6px;
335
+ color: var(--md-sys-color-on-surface-variant);
336
+ background: var(--md-sys-color-surface-container-low, rgba(0, 0, 0, 0.03));
337
+ }
338
+ div[targets] > span:not([none]):nth-of-type(odd) {
339
+ color: var(--md-sys-color-on-surface-variant);
340
+ font-size: var(--ui-t-small, 11.5px);
341
+ }
342
+ div[targets] > span[none] {
343
+ grid-column: 1 / -1;
344
+ color: var(--md-sys-color-on-surface-variant);
345
+ }
346
+ div[targets] > button[act] {
347
+ margin: 0;
348
+ padding: 0 4px;
349
+ border: none;
350
+ border-top: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
234
351
  }
235
352
  textarea {
236
353
  width: 100%;
@@ -238,10 +355,11 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
238
355
  box-sizing: border-box;
239
356
  font-family: var(--ui-mono, ui-monospace, monospace);
240
357
  font-size: inherit;
358
+ resize: vertical;
241
359
  }
242
- ul[result] {
243
- margin: 0;
244
- padding-left: 16px;
360
+ /* The sample is read whole, a record of a dozen lines: room for it, not six lines of it (user 2026-11-12). */
361
+ textarea[sample] {
362
+ min-height: 240px;
245
363
  }
246
364
  md-icon {
247
365
  font-size: 16px;
@@ -255,6 +373,8 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
255
373
  @property({ type: Boolean }) locked = false
256
374
  /** The part chosen in the modeller, put among a table's parts by one button. */
257
375
  @property() selected = ''
376
+ /** Whether the editor shows the preview: the sample shows there only, the edit canvas keeping the figure as made. */
377
+ @property({ type: Boolean }) previewing = false
258
378
 
259
379
  /** The schema row chosen, by its path; its field is edited under it. */
260
380
  @state() private chosen = ''
@@ -262,7 +382,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
262
382
  /** A site's data pasted to start the schema from; nothing is kept until the author says so. */
263
383
  @state() private pasted = ''
264
384
  /** A binding as it is being added: the field (or a new field's name), the method, the behavior or the table's targets. */
265
- @state() private connecting = { path: '', name: '', method: '' as DataMethod | '', behavior: '', parts: [] as string[], inputs: [] as string[], group: '' }
385
+ @state() private connecting = { ...NO_CONNECTING }
266
386
  /** The binding opened to edit its rule, by id. */
267
387
  @state() private opened = ''
268
388
  /** Sample legends by role, as JSON typed; not saved. */
@@ -278,7 +398,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
278
398
  if (!asset) return nothing
279
399
  const rows = schemaRowsOf(asset)
280
400
  return html`
281
- ${this.renderCapabilityData(asset)} ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${rows.length ? this.renderSample(asset) : nothing}
401
+ ${this.renderCapabilityData(asset)} ${this.renderSchema(asset, rows)} ${rows.length ? this.renderSample(asset) : nothing}
282
402
  `
283
403
  }
284
404
 
@@ -296,31 +416,44 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
296
416
 
297
417
  private renderSchema(asset: FigureAsset, rows: FigureDataRow[]) {
298
418
  const schema = asset.dataInterface?.schema ?? []
419
+ const bindings = asset.dataInterface?.bindings ?? []
420
+ const choices = behaviorsOf(asset)
421
+ const resting = figureBindingsTakenOver(asset)
299
422
  return html`<section>
300
423
  <h3>${i18next.t('figure.label.data-schema')}${this.help.button('figure.text.data-schema-explained')}</h3>
301
424
  ${this.help.hint('figure.text.data-schema-explained')}
302
425
  ${rows.length
303
- ? html`<ul tree>
304
- ${rows.map(
305
- row => html`<li>
306
- <button row ?selected=${row.path === this.chosen} style="padding-left: ${6 + row.depth * 14}px" @click=${() => (this.chosen = row.path === this.chosen ? '' : row.path)}>
307
- <span>${row.name}${row.label ? html` <small>${row.label}</small>` : nothing}</span>
308
- <small meta>${this.kindWord(row.node)}${row.bindings.length ? ` · ${i18next.t('figure.label.data-bound', { count: row.bindings.length })}` : ''}</small>
426
+ ? html`<div fields role="table">
427
+ <div head role="row">
428
+ <span role="columnheader">${i18next.t('figure.label.data-column-field')}</span>
429
+ <span role="columnheader">${i18next.t('figure.label.data-column-kind')}</span>
430
+ <span role="columnheader">${i18next.t('figure.label.data-column-moves')}</span>
431
+ </div>
432
+ ${rows.map(row => {
433
+ /* What the field moves, named, in place of a count of bindings (「작동 1개」 said nothing, user 2026-11-12). */
434
+ const moves = row.bindings.map(id => bindings.find(b => b.id === id)).filter((b): b is FigureDataBinding => !!b).map(b => this.bindingWhat(asset, b))
435
+ return html`<button
436
+ row
437
+ role="row"
438
+ ?selected=${row.path === this.chosen}
439
+ @click=${() => {
440
+ this.chosen = row.path === this.chosen ? '' : row.path
441
+ this.connecting = { ...NO_CONNECTING }
442
+ }}
443
+ >
444
+ <span name role="cell" style="padding-left: ${row.depth * 14}px"><span>${row.name}</span>${row.label ? html`<small>${row.label}</small>` : nothing}</span>
445
+ <span role="cell" title=${kindWords(row.node)}>${kindName(row.node)}</span>
446
+ <span role="cell" ?none=${!moves.length}>${moves.length ? moves.join(', ') : '—'}</span>
309
447
  </button>
310
- ${row.path === this.chosen ? this.renderField(asset, schema, row) : nothing}
311
- </li>`
312
- )}
313
- </ul>`
448
+ ${row.path === this.chosen ? html`<div detail>${this.renderField(asset, schema, row)} ${this.renderFieldBindings(asset, row, choices, resting)}</div>` : nothing}`
449
+ })}
450
+ </div>`
314
451
  : html`<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
452
+ ${this.renderStrayBindings(asset, rows, choices, resting)} ${this.renderRotation(asset, rows)}
315
453
  ${this.renderAddField(schema, '')} ${this.renderFromSample(schema)}
316
454
  </section>`
317
455
  }
318
456
 
319
- /** A field's kind in the words every screen says it in (figure-viewer kindWords). */
320
- private kindWord(node: FigureDataNode): string {
321
- return kindWords(node)
322
- }
323
-
324
457
  /** The chosen field's own settings, and a field added under it when it holds fields. */
325
458
  private renderField(asset: FigureAsset, schema: readonly FigureDataField[], row: FigureDataRow) {
326
459
  const field = fieldAt(schema, row.path)
@@ -424,7 +557,14 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
424
557
  this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) } as FigureDataField) })
425
558
  this.adding = { ...a, name: '' }
426
559
  }
427
- return html`<div form>
560
+ const words = i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })
561
+ /*
562
+ Folded under its summary, as a binding's form is: two fields always open under the table read as one more row of
563
+ it (user 2026-11-12). Open while the figure has no field yet: adding one is the only thing to do.
564
+ */
565
+ return html`<details add-field ?open=${!schema.length}>
566
+ <summary>${words}</summary>
567
+ <div form>
428
568
  <span>${i18next.t('figure.label.data-name')}</span>
429
569
  <input
430
570
  aria-label=${i18next.t('figure.label.data-name')}
@@ -438,8 +578,9 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
438
578
  </select>
439
579
  </div>
440
580
  <button act ?disabled=${this.locked || !named} @click=${add}>
441
- <md-icon>add</md-icon>${i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })}
442
- </button>`
581
+ <md-icon>add</md-icon>${i18next.t('figure.button.add')}
582
+ </button>
583
+ </details>`
443
584
  }
444
585
 
445
586
  /** A site's data pasted: the schema guessed from it, kept only when the author says so -- units, ranges and closed values are theirs to set. */
@@ -448,7 +589,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
448
589
  let unreadable = false
449
590
  if (this.pasted.trim()) {
450
591
  try {
451
- guessed = inferFigureDataSchema(JSON.parse(this.pasted))
592
+ guessed = inferFigureDataSchema(JSON5.parse(this.pasted))
452
593
  } catch {
453
594
  unreadable = true
454
595
  }
@@ -474,34 +615,53 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
474
615
 
475
616
  /* ---------------------------------------------------------------- the bindings */
476
617
 
618
+ /** What a binding moves: a table's parts, a value's behavior -- one wording for the binding's line and the field's row. */
619
+ private bindingWhat(asset: FigureAsset, b: FigureDataBinding): string {
620
+ return moveWords(dataMoveOf(asset, b.rule === 'table' ? { kind: 'table', targets: b.targets } : b.behavior))
621
+ }
622
+
477
623
  /** What a behavior moves, in the words every screen says it in (figure-viewer dataMoveOf · moveWords). */
478
624
  private behaviorName(asset: FigureAsset, b: FigureDataValueBinding['behavior']): string {
479
625
  return moveWords(dataMoveOf(asset, b))
480
626
  }
481
627
 
482
- private renderBindings(asset: FigureAsset, rows: FigureDataRow[]) {
483
- const bindings = asset.dataInterface?.bindings ?? []
484
- const choices = behaviorsOf(asset)
485
- const resting = figureBindingsTakenOver(asset)
486
- return html`<section>
487
- <h3>${i18next.t('figure.label.data-bindings')}${this.help.button('figure.text.data-bindings-explained')}</h3>
628
+ /*
629
+ A field's bindings under the field, in its opened row: what the field moves and how, edited where the field is. A
630
+ list of bindings of its own below the fields said every field's name and move again (user 2026-11-12).
631
+ */
632
+ private renderFieldBindings(asset: FigureAsset, row: FigureDataRow, choices: BehaviorChoice[], resting: FigureRestingBinding[]) {
633
+ const bindings = (asset.dataInterface?.bindings ?? []).filter(b => row.bindings.includes(b.id))
634
+ const bindable = row.node.kind !== 'object'
635
+ if (!bindable && !bindings.length) return nothing
636
+ return html`<div bindings>
637
+ <h4>${i18next.t('figure.label.data-bindings-title')}${this.help.button('figure.text.data-bindings-explained')}</h4>
488
638
  ${this.help.hint('figure.text.data-bindings-explained')}
489
639
  ${bindings.map(b => this.renderBinding(asset, choices, b, resting))}
490
- ${this.renderAddBinding(asset, rows, choices)}
491
- ${this.renderRotation(asset, rows)}
492
- </section>`
640
+ ${bindable ? this.renderAddBinding(asset, row, choices) : nothing}
641
+ </div>`
642
+ }
643
+
644
+ /** A binding no field reads -- its field renamed or taken out -- kept in sight below the fields, to delete or fix. */
645
+ private renderStrayBindings(asset: FigureAsset, rows: FigureDataRow[], choices: BehaviorChoice[], resting: FigureRestingBinding[]) {
646
+ const read = new Set(rows.flatMap(r => r.bindings))
647
+ const stray = (asset.dataInterface?.bindings ?? []).filter(b => !read.has(b.id))
648
+ if (!stray.length) return nothing
649
+ return html`<div bindings>
650
+ <h4>${i18next.t('figure.label.data-bindings-without-field')}</h4>
651
+ ${stray.map(b => this.renderBinding(asset, choices, b, resting))}
652
+ </div>`
493
653
  }
494
654
 
495
655
  private renderBinding(asset: FigureAsset, choices: BehaviorChoice[], b: FigureDataBinding, resting: FigureRestingBinding[]) {
496
656
  const open = this.opened === b.id
497
657
  const set = (next: FigureDataBinding) => this.edit({ kind: 'set-data-binding', binding: next })
498
- const what = moveWords(dataMoveOf(asset, b.rule === 'table' ? { kind: 'table', targets: b.targets } : b.behavior))
658
+ const what = this.bindingWhat(asset, b)
499
659
  const rests = resting.some(r => r.id === b.id)
500
660
  return html`<section item>
501
661
  <div head>
502
662
  <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
503
663
  <strong>${b.label ?? b.accessor}</strong> → ${what}
504
- <small>${i18next.t('figure.label.data-method-' + methodOf(b))}${rests ? html` <span rests>${i18next.t('figure.label.data-binding-resting')}</span>` : nothing}</small>
664
+ <small>${i18next.t('figure.text.data-method-does-' + methodOf(b))}${rests ? html` <span rests>${i18next.t('figure.label.data-binding-resting')}</span>` : nothing}</small>
505
665
  </button>
506
666
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
507
667
  <md-icon>delete</md-icon>
@@ -604,7 +764,11 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
604
764
  : nothing}`
605
765
  }
606
766
 
607
- /** Which parts, controls and motion group a table shows on: chips to tick, and the part chosen in the modeller by one button. */
767
+ /*
768
+ Which parts, controls and motion group a table shows on, as a property list: a row a target chosen -- what it is,
769
+ its name, its delete -- and one choice to add the next. Every part and control as a tick box in one heap took two
770
+ screens and hid which were chosen (user 2026-11-12).
771
+ */
608
772
  private renderTargets(
609
773
  parts: string[],
610
774
  inputs: Array<{ id: string; label: string }>,
@@ -612,36 +776,56 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
612
776
  t: { parts?: string[]; inputs?: string[]; group?: string },
613
777
  on: { part: (id: string, on: boolean) => void; input: (id: string, on: boolean) => void; group: (id: string) => void }
614
778
  ) {
615
- const chosen = (e: Event) => (e.target as HTMLInputElement).checked
616
- return html`<div form>
617
- <span>${i18next.t('figure.label.data-target-parts')}</span>
618
- <div chips>
619
- ${parts.map(p => html`<label><input type="checkbox" .checked=${!!t.parts?.includes(p)} ?disabled=${this.locked} @change=${(e: Event) => on.part(p, chosen(e))} />${p}</label>`)}
620
- ${this.selected && parts.includes(this.selected) && !t.parts?.includes(this.selected)
621
- ? html`<button act ?disabled=${this.locked} @click=${() => on.part(this.selected, true)}><md-icon>add</md-icon>${i18next.t('figure.button.data-add-selected-part', { part: this.selected })}</button>`
622
- : nothing}
779
+ const chosenParts = t.parts ?? []
780
+ const chosenInputs = t.inputs ?? []
781
+ const freeParts = parts.filter(p => !chosenParts.includes(p))
782
+ const freeInputs = inputs.filter(i => !chosenInputs.includes(i.id))
783
+ const labelOf = (id: string) => inputs.find(i => i.id === id)?.label ?? id
784
+ const row = (kind: string, name: string, remove: () => void) =>
785
+ html`<span>${i18next.t(kind)}</span><span>${name}</span>
786
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.data-remove-target')} @click=${remove}><md-icon>delete</md-icon></button>`
787
+ const add = (e: Event) => {
788
+ const select = e.target as HTMLSelectElement
789
+ const [kind, id] = [select.value.slice(0, select.value.indexOf(':')), select.value.slice(select.value.indexOf(':') + 1)]
790
+ select.value = ''
791
+ if (kind === 'part') on.part(id, true)
792
+ else if (kind === 'input') on.input(id, true)
793
+ }
794
+ return html`<div targets>
795
+ <small head>${i18next.t('figure.label.data-targets')}</small>
796
+ ${chosenParts.map(p => row('figure.label.data-target-parts', p, () => on.part(p, false)))}
797
+ ${chosenInputs.map(id => row('figure.label.data-target-inputs', labelOf(id), () => on.input(id, false)))}
798
+ ${chosenParts.length || chosenInputs.length ? nothing : html`<span none>${i18next.t('figure.text.data-no-targets')}</span>`}
623
799
  </div>
624
- ${inputs.length
625
- ? html`<span>${i18next.t('figure.label.data-target-inputs')}</span>
626
- <div chips>
627
- ${inputs.map(i => html`<label><input type="checkbox" .checked=${!!t.inputs?.includes(i.id)} ?disabled=${this.locked} @change=${(e: Event) => on.input(i.id, chosen(e))} />${i.label}</label>`)}
628
- </div>`
629
- : nothing}
630
- ${groups.length
631
- ? html`<span>${i18next.t('figure.label.data-target-group')}</span>
632
- <select ?disabled=${this.locked} @change=${(e: Event) => on.group((e.target as HTMLSelectElement).value)}>
633
- <option value="" ?selected=${!t.group}>${i18next.t('figure.label.data-none')}</option>
634
- ${groups.map(g => html`<option value=${g.id} ?selected=${g.id === t.group}>${g.label}</option>`)}
635
- </select>`
636
- : nothing}
637
- </div>`
800
+ <div form>
801
+ <span>${i18next.t('figure.label.data-target-add')}</span>
802
+ <select ?disabled=${this.locked || !(freeParts.length + freeInputs.length)} .value=${''} @change=${add}>
803
+ <option value="">${i18next.t('figure.label.data-target-choose')}</option>
804
+ ${freeParts.length
805
+ ? html`<optgroup label=${i18next.t('figure.label.data-target-parts')}>${freeParts.map(p => html`<option value=${'part:' + p}>${p}</option>`)}</optgroup>`
806
+ : nothing}
807
+ ${freeInputs.length
808
+ ? html`<optgroup label=${i18next.t('figure.label.data-target-inputs')}>${freeInputs.map(i => html`<option value=${'input:' + i.id}>${i.label}</option>`)}</optgroup>`
809
+ : nothing}
810
+ </select>
811
+ ${this.selected && freeParts.includes(this.selected)
812
+ ? html`<span></span><button act ?disabled=${this.locked} @click=${() => on.part(this.selected, true)}><md-icon>add</md-icon>${i18next.t('figure.button.data-add-selected-part', { part: this.selected })}</button>`
813
+ : nothing}
814
+ ${groups.length
815
+ ? html`<span>${i18next.t('figure.label.data-target-group')}</span>
816
+ <select ?disabled=${this.locked} @change=${(e: Event) => on.group((e.target as HTMLSelectElement).value)}>
817
+ <option value="" ?selected=${!t.group}>${i18next.t('figure.label.data-none')}</option>
818
+ ${groups.map(g => html`<option value=${g.id} ?selected=${g.id === t.group}>${g.label}</option>`)}
819
+ </select>`
820
+ : nothing}
821
+ </div>`
638
822
  }
639
823
 
640
824
  /** The column heads of a table's rows, after the key's own one or two. */
641
825
  private columnHeads(columns: TableColumn[]) {
642
826
  return columns.map(c =>
643
827
  c.kind === 'value'
644
- ? html`<small>${c.label}${c.unit ? ` (${c.unit}${c.min !== undefined && c.max !== undefined ? ` ${c.min}~${c.max}` : ''})` : ''}</small>`
828
+ ? html`<small>${c.label}</small>`
645
829
  : html`<small>${i18next.t('figure.label.data-column-' + c.kind)}</small>`
646
830
  )
647
831
  }
@@ -665,7 +849,9 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
665
849
  </select>`
666
850
  if (c.kind === 'value') {
667
851
  const v = look.values?.[c.id]
668
- return html`<input type="number" step="any" min=${c.min ?? ''} max=${c.max ?? ''} .value=${v === undefined ? '' : String(v)} ?disabled=${this.locked} @change=${(e: Event) => {
852
+ /* Its unit and range in the field, not beside the name: 「fl-hip (deg -30~30)」 took two lines (user 2026-11-12). */
853
+ const range = [c.min !== undefined && c.max !== undefined ? `${c.min}~${c.max}` : '', c.unit ?? ''].filter(Boolean).join(' ')
854
+ return html`<input type="number" step="any" min=${c.min ?? ''} max=${c.max ?? ''} placeholder=${range} title=${range} aria-label=${c.label} .value=${v === undefined ? '' : String(v)} ?disabled=${this.locked} @change=${(e: Event) => {
669
855
  const n = (e.target as HTMLInputElement).valueAsNumber
670
856
  const values = { ...look.values }
671
857
  if (Number.isNaN(n)) delete values[c.id]
@@ -690,16 +876,32 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
690
876
  })
691
877
  }
692
878
 
879
+ /*
880
+ One value's settings as a property grid, a line a setting -- colour, effect, a control's value, clip, speed, pause --
881
+ its name left, its field right. A column a setting ran wider than the panel as controls were added (user 2026-11-12).
882
+ */
883
+ private renderLook(asset: FigureAsset, columns: TableColumn[], look: FigureDataTableEntry, write: (look: FigureDataTableEntry) => void) {
884
+ if (!columns.length) return html`<p hint>${i18next.t('figure.text.data-table-needs-target')}</p>`
885
+ const heads = this.columnHeads(columns)
886
+ const cells = this.lookCells(asset, columns, look, write)
887
+ return html`<div props>${columns.map((_, i) => html`<span>${heads[i]}</span><span>${cells[i]}</span>`)}</div>`
888
+ }
889
+
693
890
  /** The look of every other value: a row like the others, added or taken away; kept when a closed field's values are not all rows. */
694
- private renderOtherwise(asset: FigureAsset, b: FigureDataTableBinding, columns: TableColumn[], needed: boolean, set: (next: FigureDataBinding) => void, keyCells: number) {
891
+ private renderOtherwise(asset: FigureAsset, b: FigureDataTableBinding, columns: TableColumn[], needed: boolean, set: (next: FigureDataBinding) => void) {
695
892
  const look = b.table.default
696
893
  if (look === undefined)
697
894
  return html`<button act ?disabled=${this.locked} @click=${() => set(tableWithLook(b, 'default', {}))}><md-icon>add</md-icon>${i18next.t('figure.button.add-data-default')}</button>`
698
895
  const { default: _, ...rest } = b.table
699
- return html`<div looks style="grid-template-columns: ${gridOf(keyCells, columns.length)}">
700
- <span style="grid-column: span ${keyCells}">${i18next.t('figure.label.data-otherwise')}</span>
701
- ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, 'default', l)))}
702
- ${needed ? html`<span></span>` : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => set({ ...b, table: rest })}><md-icon>delete</md-icon></button>`}
896
+ /* 「그대로 둠」 (ADR-0166 amendment 1): a value the table has no row for leaves the targets as it last showed them. */
897
+ const keep = look.keep === true
898
+ return html`<div case>
899
+ <div case-head>
900
+ <strong>${i18next.t('figure.label.data-otherwise')}</strong>
901
+ <label keep><input type="checkbox" .checked=${keep} ?disabled=${this.locked} @change=${(e: Event) => set(tableWithKeep(b, (e.target as HTMLInputElement).checked))} />${i18next.t('figure.label.data-keep')}</label>
902
+ ${needed ? nothing : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => set({ ...b, table: rest })}><md-icon>delete</md-icon></button>`}
903
+ </div>
904
+ ${keep ? html`<p hint>${i18next.t('figure.text.data-keep')}</p>` : this.renderLook(asset, columns, look, l => set(tableWithLook(b, 'default', l)))}
703
905
  </div>`
704
906
  }
705
907
 
@@ -711,6 +913,8 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
711
913
  const columns = tableColumnsOf(asset, b)
712
914
  const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'))
713
915
  const boolean = node?.kind === 'boolean'
916
+ /* A number's codes are numbers, as its data gives them (ADR-0166 amendment 1). */
917
+ const numeric = node?.kind === 'number'
714
918
  const rows = Object.entries(b.table).filter(([k]) => k !== 'default')
715
919
  const domain = node ? domainOf(node).map(String) : []
716
920
  const covered = domain.length > 0 && domain.every(v => b.table[v] !== undefined)
@@ -722,26 +926,31 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
722
926
  }
723
927
  const rekey = (i: number, typed: string) => {
724
928
  const key = typed.trim()
725
- if (!key || key === 'default' || rows.some(([k], j) => j !== i && k === key)) return this.requestUpdate()
929
+ if (!key || key === 'default' || rows.some(([k], j) => j !== i && k === key) || (numeric && !Number.isFinite(Number(key)))) return this.requestUpdate()
726
930
  rekeyed(rows.map(([k, v], j) => (j === i ? [key, v] : [k, v])))
727
931
  }
728
932
  const word = (k: string) => (boolean ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k)
729
- return html`<div looks style="grid-template-columns: ${gridOf(1, columns.length)}">
730
- <small>${i18next.t('figure.label.data-case-value')}</small>${this.columnHeads(columns)}<span></span>
933
+ /* A value a box: its code on the box's head line, its settings a property grid under it. */
934
+ return html`<div cases>
731
935
  ${rows.map(
732
- ([k, look], i) => html`${boolean
733
- ? html`<span>${word(k)}</span>`
734
- : html`<input .value=${k} aria-label=${i18next.t('figure.label.data-case-value')} ?disabled=${this.locked} @change=${(e: Event) => rekey(i, (e.target as HTMLInputElement).value)} />`}
735
- ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
736
- ${boolean || rows.length < 2 && domain.length > 0
737
- ? html`<span></span>`
738
- : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => rekeyed(rows.filter((_, j) => j !== i))}><md-icon>delete</md-icon></button>`}`
936
+ ([k, look], i) => html`<div case>
937
+ <div case-head>
938
+ <small>${i18next.t('figure.label.data-case-value')}</small>
939
+ ${boolean
940
+ ? html`<strong>${word(k)}</strong>`
941
+ : html`<input .value=${k} inputmode=${numeric ? 'decimal' : 'text'} aria-label=${i18next.t('figure.label.data-case-value')} ?disabled=${this.locked} @change=${(e: Event) => rekey(i, (e.target as HTMLInputElement).value)} />`}
942
+ ${boolean || (rows.length < 2 && domain.length > 0)
943
+ ? nothing
944
+ : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => rekeyed(rows.filter((_, j) => j !== i))}><md-icon>delete</md-icon></button>`}
945
+ </div>
946
+ ${this.renderLook(asset, columns, look, l => set(tableWithLook(b, k, l)))}
947
+ </div>`
739
948
  )}
949
+ ${this.renderOtherwise(asset, b, columns, domain.length > 0 && !covered, set)}
740
950
  </div>
741
- ${this.renderOtherwise(asset, b, columns, domain.length > 0 && !covered, set, 1)}
742
951
  ${boolean
743
952
  ? nothing
744
- : html`<button act ?disabled=${this.locked} @click=${() => rekeyed([...rows, [freeCodeOf(rows.map(([k]) => k)), {}]])}>
953
+ : html`<button act ?disabled=${this.locked} @click=${() => rekeyed([...rows, [(numeric ? freeNumberCodeOf : freeCodeOf)(rows.map(([k]) => k)), {}]])}>
745
954
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-case')}
746
955
  </button>`}`
747
956
  }
@@ -771,17 +980,21 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
771
980
  const last = rows.at(-1)
772
981
  const lastEnd = last ? ends(last[0]).to : ''
773
982
  const open = !!last && lastEnd === ''
774
- return html`<div looks style="grid-template-columns: ${gridOf(2, columns.length)}">
775
- <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small>${this.columnHeads(columns)}<span></span>
983
+ return html`<div cases>
776
984
  ${rows.map(([k, look], i) => {
777
985
  const e = ends(k)
778
- return html`<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'from', (ev.target as HTMLInputElement).value)} />
779
- <input type="number" step="any" .value=${e.to} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'to', (ev.target as HTMLInputElement).value)} />
780
- ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
781
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => set(tableWithKeys(b, rows.filter((_, j) => j !== i)))}><md-icon>delete</md-icon></button>`
986
+ return html`<div case>
987
+ <div case-head>
988
+ <input type="number" step="any" aria-label=${i18next.t('figure.label.data-range-from')} placeholder=${i18next.t('figure.label.data-range-from')} .value=${e.from} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'from', (ev.target as HTMLInputElement).value)} />
989
+ <span>~</span>
990
+ <input type="number" step="any" aria-label=${i18next.t('figure.label.data-range-to')} placeholder=${i18next.t('figure.label.data-range-to')} .value=${e.to} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'to', (ev.target as HTMLInputElement).value)} />
991
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => set(tableWithKeys(b, rows.filter((_, j) => j !== i)))}><md-icon>delete</md-icon></button>
992
+ </div>
993
+ ${this.renderLook(asset, columns, look, l => set(tableWithLook(b, k, l)))}
994
+ </div>`
782
995
  })}
996
+ ${this.renderOtherwise(asset, b, columns, false, set)}
783
997
  </div>
784
- ${this.renderOtherwise(asset, b, columns, false, set, 2)}
785
998
  ${open ? html`<p hint>${i18next.t('figure.text.data-range-close-last')}</p>` : nothing}
786
999
  <button act ?disabled=${this.locked || open} @click=${() => set(tableWithKeys(b, [...rows, [`${lastEnd}~`, {}]]))}>
787
1000
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-range')}
@@ -824,48 +1037,38 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
824
1037
  * 「연결 추가」 (ADR-0166 10): a field there or a new name, then a method the field's kind allows. As it is or scaled,
825
1038
  * the one thing it moves; a table, the parts, controls and motion group it shows on -- a new name makes the field.
826
1039
  */
827
- private renderAddBinding(asset: FigureAsset, rows: FigureDataRow[], choices: BehaviorChoice[]) {
828
- const bindable = rows.filter(r => r.node.kind !== 'object')
1040
+ /** A binding added to the field whose row is open: how it is read, and what it moves. */
1041
+ private renderAddBinding(asset: FigureAsset, row: FigureDataRow, choices: BehaviorChoice[]) {
829
1042
  const a = this.connecting
830
- /* 「새 필드」: a value no field path can have (a path is names and `[]`). */
831
- const NEW = '+'
832
- const row = a.path === NEW || !bindable.length ? null : (bindable.find(r => r.path === a.path) ?? bindable[0]!)
833
- const methods = methodsOf(row?.node ?? null, choices)
1043
+ const methods = methodsOf(row.node, choices)
834
1044
  const method = methods.includes(a.method as DataMethod) ? (a.method as DataMethod) : methods[0]
835
1045
  const table = method === 'code' || method === 'ranges'
836
- const fitting = row && !table ? choices.filter(c => fitsField(row.node, c)) : []
1046
+ const fitting = !table ? choices.filter(c => fitsField(row.node, c)) : []
837
1047
  const choice = fitting.find(c => c.key === a.behavior) ?? fitting[0]
838
1048
  const set = (patch: Partial<typeof a>) => (this.connecting = { ...a, ...patch })
839
1049
  const typed = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
840
- const name = a.name.trim()
841
1050
  const targets = { parts: a.parts, inputs: a.inputs, ...(a.group ? { group: a.group } : {}) }
842
1051
  const aimed = a.parts.length > 0 || a.inputs.length > 0 || !!a.group
843
- const ready = table ? aimed && (row !== null || /^[A-Za-z_][\w-]*$/.test(name)) : !!choice && !!row
1052
+ const ready = table ? aimed : !!choice
844
1053
  const add = () => {
845
1054
  if (table) {
846
- const { binding, schema } = tableDraftOf(asset, row ? { path: row.path, node: row.node } : { name }, method as 'code' | 'ranges', targets)
847
- this.edit({ kind: 'set-data-binding', binding, ...(schema ? { schema } : {}) })
1055
+ const { binding } = tableDraftOf(asset, { path: row.path, node: row.node }, method as 'code' | 'ranges', targets)
1056
+ this.edit({ kind: 'set-data-binding', binding })
848
1057
  this.opened = binding.id
849
1058
  } else {
850
1059
  /* A list is read whole by items; one value of it, through its first element. */
851
- const accessor = row!.node.kind === 'list' && choice!.kind === 'items' ? row!.path.replace(/\[\]$/, '') : accessorOfRow(row!.path)
852
- const binding = bindingDraftOf(asset, accessor, row!.node, choice!, method as 'value' | 'scale')
1060
+ const accessor = row.node.kind === 'list' && choice!.kind === 'items' ? row.path.replace(/\[\]$/, '') : accessorOfRow(row.path)
1061
+ const binding = bindingDraftOf(asset, accessor, row.node, choice!, method as 'value' | 'scale')
853
1062
  this.edit({ kind: 'set-data-binding', binding })
854
1063
  this.opened = binding.id
855
1064
  }
856
- this.connecting = { path: '', name: '', method: '', behavior: '', parts: [], inputs: [], group: '' }
1065
+ this.connecting = { ...NO_CONNECTING }
857
1066
  }
858
1067
  const toggle = (list: string[], id: string, on: boolean) => (on ? [...list, id] : list.filter(x => x !== id))
859
- return html`<div form>
860
- <span>${i18next.t('figure.label.data-field')}</span>
861
- <select @change=${(e: Event) => set({ path: typed(e), name: '' })}>
862
- ${bindable.map(r => html`<option value=${r.path} ?selected=${r === row}>${r.path}</option>`)}
863
- <option value=${NEW} ?selected=${row === null}>${i18next.t('figure.label.data-new-field')}</option>
864
- </select>
865
- ${row === null
866
- ? html`<span>${i18next.t('figure.label.data-name')}</span>
867
- <input .value=${a.name} @input=${(e: Event) => set({ name: typed(e) })} />`
868
- : nothing}
1068
+ /* Folded until asked for: open under a field's binding, its fields read as that binding's own (user 2026-11-12). */
1069
+ return html`<details add-binding>
1070
+ <summary>${i18next.t('figure.button.add-data-binding')}</summary>
1071
+ <div form>
869
1072
  <span>${i18next.t('figure.label.data-method')}</span>
870
1073
  ${methods.length === 1
871
1074
  ? /* One way only (a text is read by a code table): said, not offered as a choice of one (sweep 2026-10-27 U10). */
@@ -890,8 +1093,9 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
890
1093
  ${table && !aimed ? html`<p hint>${i18next.t('figure.text.data-table-needs-target')}</p>` : nothing}
891
1094
  ${methods.length ? nothing : html`<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
892
1095
  <button act ?disabled=${this.locked || !ready} @click=${add}>
893
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-binding')}
894
- </button>`
1096
+ <md-icon>add</md-icon>${i18next.t('figure.button.add')}
1097
+ </button>
1098
+ </details>`
895
1099
  }
896
1100
 
897
1101
  /**
@@ -973,43 +1177,28 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
973
1177
 
974
1178
  /** Data to try, read the way a placed figure reads it: what it moves, or why it is refused; in the preview, the figure running on it. */
975
1179
  private renderSample(asset: FigureAsset) {
976
- const text = this.sample || JSON.stringify(figureDataExample(asset.dataInterface!), null, 2)
1180
+ /* Written and read as JSON5: names without quotes, comments and a last comma are allowed when typed by hand (user 2026-11-12). */
1181
+ const text = this.sample || JSON5.stringify(figureDataExample(asset.dataInterface!), null, 2)
977
1182
  let data: unknown
978
1183
  let unreadable = false
979
1184
  try {
980
- data = JSON.parse(text)
1185
+ data = JSON5.parse(text)
981
1186
  } catch {
982
1187
  unreadable = true
983
1188
  }
984
1189
  const legends = this.sampleLegends()
985
1190
  const read = unreadable ? null : sampleOf(asset, data, legends)
986
- const r = read?.result
987
1191
  const tables = (asset.dataInterface?.bindings ?? []).filter((b): b is FigureDataTableBinding => b.rule === 'table')
988
1192
  return html`<section>
989
1193
  <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
990
1194
  ${this.help.hint('figure.text.data-sample-explained')}
991
- <textarea .value=${text} @input=${(e: Event) => this.try((e.target as HTMLTextAreaElement).value)}></textarea>
1195
+ <textarea sample .value=${text} @input=${(e: Event) => this.try((e.target as HTMLTextAreaElement).value)}></textarea>
992
1196
  ${unreadable
993
1197
  ? html`<p hint>${i18next.t('figure.text.data-not-json')}</p>`
994
1198
  : read!.issues.length
995
1199
  ? html`<p hint>${read!.issues.map(i => i18next.t('figure.text.data-value-' + i.reason, { path: i.path || '-' })).join(' · ')}</p>`
996
- : html`<ul result>
997
- ${Object.entries(r!.states).map(([input, s]) => html`<li>${asset.stateInputs?.[input]?.label ?? input} = ${s.value}</li>`)}
998
- ${Object.entries(r!.palette).map(([token, colour]) => html`<li>${token} = ${colour}</li>`)}
999
- ${Object.entries(r!.motion).map(
1000
- ([group, m]) =>
1001
- html`<li>
1002
- ${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}:
1003
- ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`, m.paused ? i18next.t('figure.label.data-paused') : ''].filter(Boolean).join(' ')}
1004
- </li>`
1005
- )}
1006
- ${tables.map(b => this.renderTableUse(asset, b, read!.uses[b.id]))}
1007
- ${Object.entries(r!.parts).map(([part, look]) => html`<li>${part}: ${[look.color, look.effect ? i18next.t('figure.label.data-effect-' + look.effect.kind) : ''].filter(Boolean).join(' ')}</li>`)}
1008
- ${Object.entries(r!.pose).map(([axis, p]) => html`<li>${i18next.t('figure.label.data-axis-' + axis)} = ${p!.value}</li>`)}
1009
- ${r!.items.map(i => html`<li>${i18next.t('figure.label.data-items-count', { count: i.records.length })}${i.records.length ? `: ${i.records.map(x => x.id).join(', ')}` : ''}</li>`)}
1010
- </ul>`}
1200
+ : nothing}
1011
1201
  ${read?.notes.length ? html`<p hint>${read.notes.map(n => i18next.t('figure.text.data-note-' + n.reason, { binding: n.binding ?? '', key: n.key ?? '', column: n.column ? i18next.t('figure.label.data-column-' + n.column) : '', value: shownValue(n.value) })).join(' · ')}</p>` : nothing}
1012
- ${r && Object.keys(r.pose).length ? html`<p hint>${i18next.t('figure.text.data-sample-moves-all')}</p>` : nothing}
1013
1202
  ${this.renderSampleLegends(tables)}
1014
1203
  <label toggle>
1015
1204
  <input
@@ -1022,25 +1211,24 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
1022
1211
  />
1023
1212
  ${i18next.t('figure.label.data-sample-in-preview')}
1024
1213
  </label>
1214
+ ${this.sampling && !this.previewing
1215
+ ? html`<p hint sample-where>
1216
+ ${i18next.t('figure.text.data-sample-shown-in-preview')}
1217
+ <button quiet @click=${() => this.dispatchEvent(new CustomEvent('show-preview', { bubbles: true, composed: true }))}>
1218
+ ${i18next.t('figure.button.data-sample-show-preview')}
1219
+ </button>
1220
+ </p>`
1221
+ : nothing}
1025
1222
  </section>`
1026
1223
  }
1027
1224
 
1028
- /** Which row of a table the sample's value matched, and from which table: the figure's own or the sample legend of its role. */
1029
- private renderTableUse(asset: FigureAsset, b: FigureDataTableBinding, use: { value: unknown; key: string | null; source: string; legend?: string } | undefined) {
1030
- if (!use || use.value === undefined) return nothing
1031
- const word = (v: unknown) => (typeof v === 'boolean' || v === 'true' || v === 'false' ? i18next.t(String(v) === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : String(v))
1032
- const row = use.key === null ? i18next.t('figure.label.data-none') : use.key === 'default' ? i18next.t('figure.label.data-otherwise') : word(use.key)
1033
- const from = use.source === 'figure' ? i18next.t('figure.label.data-table-own') : i18next.t('figure.label.data-table-legend', { name: use.legend ?? '' })
1034
- return html`<li>${b.label ?? b.accessor} = ${word(use.value)} → ${row} <small>(${from})</small></li>`
1035
- }
1036
-
1037
1225
  /** The sample legends as read: by role, those that are JSON. */
1038
1226
  private sampleLegends(): Record<string, unknown> {
1039
1227
  const out: Record<string, unknown> = {}
1040
1228
  for (const [role, text] of Object.entries(this.legendSample)) {
1041
1229
  if (!text.trim()) continue
1042
1230
  try {
1043
- out[role] = JSON.parse(text)
1231
+ out[role] = JSON5.parse(text)
1044
1232
  } catch {
1045
1233
  /* Not JSON yet: the figure's own table shows. */
1046
1234
  }
@@ -1075,7 +1263,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
1075
1263
  private try(typed: string) {
1076
1264
  this.sample = typed
1077
1265
  try {
1078
- this.tell(JSON.parse(typed))
1266
+ this.tell(JSON5.parse(typed))
1079
1267
  } catch {
1080
1268
  /* Not JSON yet: the preview keeps what it ran on. */
1081
1269
  }
@@ -1101,7 +1289,3 @@ function nodeOfKind(kind: FigureDataNode['kind']): FigureDataNode {
1101
1289
  /** A value in a sentence: a text as it is, anything else as JSON. */
1102
1290
  const shownValue = (v: unknown): string => (typeof v === 'string' ? v : JSON.stringify(v ?? ''))
1103
1291
 
1104
- /** A table row's columns: the key's one or two, a cell per column, and the remove button. */
1105
- function gridOf(keyCells: number, columns: number): string {
1106
- return `${'minmax(64px, 1fr) '.repeat(keyCells)}${'minmax(72px, 1fr) '.repeat(columns)}30px`
1107
- }