@things-factory/figure-ui 10.1.223 → 10.1.225

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 (80) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor-styles.ts +3 -2
  5. package/client/modeller/editor.ts +17 -8
  6. package/client/modeller/figure-capability-panel.ts +151 -1
  7. package/client/modeller/figure-clips.ts +1 -1
  8. package/client/modeller/figure-data-panel.ts +509 -199
  9. package/client/modeller/figure-links.ts +130 -12
  10. package/client/modeller/figure-preview.ts +65 -8
  11. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  12. package/client/modeller/links.ts +58 -0
  13. package/client/modeller/product-session.ts +3 -2
  14. package/client/modeller/refusals.ts +12 -4
  15. package/client/modeller/slot-layout-host.ts +76 -5
  16. package/client/pages/figure-modeller-page.ts +2 -2
  17. package/dist-client/modeller/canvas.js +2 -2
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  20. package/dist-client/modeller/consumer-capabilities.js +60 -0
  21. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  22. package/dist-client/modeller/data-interface.d.ts +83 -17
  23. package/dist-client/modeller/data-interface.js +194 -82
  24. package/dist-client/modeller/data-interface.js.map +1 -1
  25. package/dist-client/modeller/editor-styles.js +3 -2
  26. package/dist-client/modeller/editor-styles.js.map +1 -1
  27. package/dist-client/modeller/editor.d.ts +5 -2
  28. package/dist-client/modeller/editor.js +21 -8
  29. package/dist-client/modeller/editor.js.map +1 -1
  30. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  31. package/dist-client/modeller/figure-capability-panel.js +129 -1
  32. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-clips.js +1 -1
  34. package/dist-client/modeller/figure-clips.js.map +1 -1
  35. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  36. package/dist-client/modeller/figure-data-panel.js +476 -191
  37. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  38. package/dist-client/modeller/figure-links.d.ts +11 -0
  39. package/dist-client/modeller/figure-links.js +137 -16
  40. package/dist-client/modeller/figure-links.js.map +1 -1
  41. package/dist-client/modeller/figure-preview.d.ts +4 -0
  42. package/dist-client/modeller/figure-preview.js +65 -7
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  45. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  46. package/dist-client/modeller/links.d.ts +22 -0
  47. package/dist-client/modeller/links.js +48 -0
  48. package/dist-client/modeller/links.js.map +1 -1
  49. package/dist-client/modeller/product-session.d.ts +4 -2
  50. package/dist-client/modeller/product-session.js +3 -2
  51. package/dist-client/modeller/product-session.js.map +1 -1
  52. package/dist-client/modeller/refusals.js +14 -6
  53. package/dist-client/modeller/refusals.js.map +1 -1
  54. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  55. package/dist-client/modeller/slot-layout-host.js +68 -6
  56. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.js +2 -2
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +8 -8
  61. package/test/ai-proposal.test.ts +2 -2
  62. package/test/browser/data-table-page.js +9 -0
  63. package/test/browser/links.check.mjs +25 -3
  64. package/test/browser/product-page.check.mjs +25 -0
  65. package/test/browser/side-panel-sweep.check.mjs +182 -0
  66. package/test/consumer-capabilities.test.ts +116 -0
  67. package/test/data-motion.test.ts +94 -27
  68. package/test/figure-assistant-surface.test.ts +1 -1
  69. package/test/first-save.test.ts +2 -2
  70. package/test/links.test.ts +15 -0
  71. package/test/product-session.test.ts +5 -5
  72. package/test/proposal-record.test.ts +3 -3
  73. package/test/revert-keeping.test.ts +1 -1
  74. package/test/slot-layout-host.test.ts +63 -5
  75. package/translations/en.json +82 -23
  76. package/translations/es.json +82 -23
  77. package/translations/ja.json +82 -23
  78. package/translations/ko.json +82 -23
  79. package/translations/ms.json +82 -23
  80. package/translations/zh.json +82 -23
@@ -5,7 +5,16 @@ import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
7
  import { FIGURE_UNITS, figureDataExample, inferFigureDataSchema } from '@hatiolab/figure-model'
8
- import type { FigureAsset, FigureDataBinding, FigureDataField, FigureDataNode, FigureDataRow, FigureDataValue, FigureStatusPort } from '@hatiolab/figure-model'
8
+ import type {
9
+ FigureAsset,
10
+ FigureDataBinding,
11
+ FigureDataField,
12
+ FigureDataNode,
13
+ FigureDataRow,
14
+ FigureDataTableBinding,
15
+ FigureDataTableEntry,
16
+ FigureDataValueBinding
17
+ } from '@hatiolab/figure-model'
9
18
 
10
19
  import {
11
20
  accessorOfRow,
@@ -17,8 +26,11 @@ import {
17
26
  fieldAt,
18
27
  fieldOfKind,
19
28
  fitsField,
29
+ freeCodeOf,
30
+ legendRoleOf,
31
+ methodOf,
32
+ methodsOf,
20
33
  partsOf,
21
- portDraftOf,
22
34
  rotationStepsOf,
23
35
  sampleOf,
24
36
  schemaRowsOf,
@@ -26,9 +38,20 @@ import {
26
38
  schemaWithField,
27
39
  schemaWithFieldReplaced,
28
40
  schemaWithoutField,
29
- type BehaviorChoice
41
+ schemaFollowingCodes,
42
+ stateInputsOf,
43
+ tableColumnsOf,
44
+ tableDraftOf,
45
+ tableWithKeys,
46
+ tableWithLook,
47
+ tableWithTargets,
48
+ type BehaviorChoice,
49
+ type DataMethod,
50
+ type TableColumn
30
51
  } from './data-interface.js'
31
52
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
53
+ import { figureBindingsTakenOver, figureCapabilityDescriptorOf, type FigureRestingBinding } from '@hatiolab/things-scene'
54
+ import { consumerDescriptors } from './consumer-capabilities.js'
32
55
  import { HelpToggles } from './help.js'
33
56
 
34
57
  const KINDS: FigureDataNode['kind'][] = ['number', 'boolean', 'text', 'object', 'list']
@@ -36,12 +59,14 @@ const KINDS: FigureDataNode['kind'][] = ['number', 'boolean', 'text', 'object',
36
59
  /**
37
60
  * 데이터 -- the data a site sends this figure, in its own names, and what each value moves (ADR-0051 §8, data binding
38
61
  * design decision 4). The schema is free: fields the author names, a tree of objects and lists. A binding reads one place
39
- * of it and moves one thing the figure can do -- a control, a colour, a motion group's clip, speed or pause, a status
40
- * port, its position, the items in its places -- by the board's rules (value, map, range, scale). Every change is one
41
- * part edit, and whether it holds is the kernel's check.
62
+ * of it and shows it in the figure (ADR-0166): as it is or scaled, it moves one thing -- a control, a colour, a motion
63
+ * group's clip, speed or pause, its position, the items in its places; by a code table or a range table, each value's
64
+ * look goes to several targets at once -- parts' colour and effect, controls' values, a motion group. The field's kind
65
+ * says which methods there are. Every change is one part edit, and whether it holds is the kernel's check.
42
66
  *
43
67
  * The sample is not saved: data tried here, read the one way a placed figure reads it, and -- in the preview -- the figure
44
- * running on it (`data-sample`).
68
+ * running on it (`data-sample`); a sample legend pasted for a table's role shows it as a board's legend would
69
+ * (`legend-sample`).
45
70
  */
46
71
  @customElement('figure-data-panel')
47
72
  export class FigureDataPanel extends localize(i18next)(LitElement) {
@@ -124,6 +149,17 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
124
149
  gap: 4px 6px;
125
150
  align-items: center;
126
151
  }
152
+ div[codes] {
153
+ display: grid;
154
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 30px;
155
+ gap: 4px 6px;
156
+ align-items: center;
157
+ }
158
+ div[codes] button[act] {
159
+ width: 30px;
160
+ margin: 0;
161
+ padding: 0;
162
+ }
127
163
  div[ranges] {
128
164
  display: grid;
129
165
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) 30px;
@@ -135,6 +171,54 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
135
171
  margin: 0;
136
172
  padding: 0;
137
173
  }
174
+ /* A table's rows: the key (a code, or from and to), a cell per column, and a remove button. */
175
+ div[looks] {
176
+ display: grid;
177
+ gap: 4px 6px;
178
+ align-items: center;
179
+ overflow-x: auto;
180
+ }
181
+ div[looks] > small {
182
+ color: var(--md-sys-color-on-surface-variant);
183
+ }
184
+ div[looks] input,
185
+ div[looks] select {
186
+ min-width: 0;
187
+ width: 100%;
188
+ box-sizing: border-box;
189
+ }
190
+ div[looks] button[act] {
191
+ width: 30px;
192
+ margin: 0;
193
+ padding: 0;
194
+ }
195
+ /* A colour and the button that clears it, on one line at the height of the cells beside it. */
196
+ div[looks] span[colour] {
197
+ display: flex;
198
+ gap: 2px;
199
+ align-items: center;
200
+ min-width: 0;
201
+ }
202
+ div[looks] span[colour] input[type='color'] {
203
+ flex: 1;
204
+ height: var(--figure-control-height);
205
+ padding: 0 2px;
206
+ }
207
+ div[looks] span[colour] button[act] {
208
+ flex: none;
209
+ width: 24px;
210
+ }
211
+ div[chips] {
212
+ display: flex;
213
+ flex-wrap: wrap;
214
+ gap: 4px 10px;
215
+ align-items: center;
216
+ }
217
+ div[chips] label {
218
+ display: inline-flex;
219
+ gap: 4px;
220
+ align-items: center;
221
+ }
138
222
  textarea {
139
223
  width: 100%;
140
224
  min-height: 96px;
@@ -156,16 +240,20 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
156
240
 
157
241
  @property({ attribute: false }) asset?: FigureAsset
158
242
  @property({ type: Boolean }) locked = false
243
+ /** The part chosen in the modeller, put among a table's parts by one button. */
244
+ @property() selected = ''
159
245
 
160
246
  /** The schema row chosen, by its path; its field is edited under it. */
161
247
  @state() private chosen = ''
162
248
  @state() private adding = { name: '', kind: 'number' as FigureDataNode['kind'] }
163
249
  /** A site's data pasted to start the schema from; nothing is kept until the author says so. */
164
250
  @state() private pasted = ''
165
- @state() private connecting = { path: '', behavior: '' }
251
+ /** A binding as it is being added: the field (or a new field's name), the method, the behavior or the table's targets. */
252
+ @state() private connecting = { path: '', name: '', method: '' as DataMethod | '', behavior: '', parts: [] as string[], inputs: [] as string[], group: '' }
166
253
  /** The binding opened to edit its rule, by id. */
167
254
  @state() private opened = ''
168
- @state() private porting = ''
255
+ /** Sample legends by role, as JSON typed; not saved. */
256
+ @state() private legendSample: Record<string, string> = {}
169
257
  /** The rotation card as filled in: the joint, and the fields for on, speed (and its most rpm) and direction. */
170
258
  @state() private rotating = { input: '', on: '', speed: '', max: 0, direction: '', forward: '', reverse: '' }
171
259
  /** The sample as typed, JSON; empty until edited, the schema's example shown until then. */
@@ -177,10 +265,20 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
177
265
  if (!asset) return nothing
178
266
  const rows = schemaRowsOf(asset)
179
267
  return html`
180
- ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${this.renderPorts(asset)} ${rows.length ? this.renderSample(asset) : nothing}
268
+ ${this.renderCapabilityData(asset)} ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${rows.length ? this.renderSample(asset) : nothing}
181
269
  `
182
270
  }
183
271
 
272
+ /** The data a product's capability the figure wears reads itself (ADR-0167 14): said, read only. */
273
+ private renderCapabilityData(asset: FigureAsset) {
274
+ const names = asset.document.capabilities.names ?? []
275
+ const reading = consumerDescriptors().filter(d => names.includes(d.name) && d.data?.length)
276
+ if (!reading.length) return nothing
277
+ return html`<section>
278
+ ${reading.map(d => html`<p hint>${i18next.t('figure.text.data-capability-fields', { name: d.label, fields: d.data!.map(f => f.name).join(', ') })}</p>`)}
279
+ </section>`
280
+ }
281
+
184
282
  /* ---------------------------------------------------------------- the schema */
185
283
 
186
284
  private renderSchema(asset: FigureAsset, rows: FigureDataRow[]) {
@@ -248,7 +346,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
248
346
  this.edit({ kind: 'set-data-schema', schema: schemaWithoutField(schema, row.path) })
249
347
  }}
250
348
  >
251
- <md-icon>close</md-icon>
349
+ <md-icon>delete</md-icon>
252
350
  </button>
253
351
  </div>
254
352
  <div form>
@@ -314,22 +412,25 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
314
412
  private renderAddField(schema: readonly FigureDataField[], under: string) {
315
413
  const a = this.adding
316
414
  const named = a.name.trim()
415
+ const add = () => {
416
+ if (this.locked || !named) return
417
+ this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) } as FigureDataField) })
418
+ this.adding = { ...a, name: '' }
419
+ }
317
420
  return html`<div form>
318
421
  <span>${i18next.t('figure.label.data-name')}</span>
319
- <input .value=${a.name} @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })} />
422
+ <input
423
+ aria-label=${i18next.t('figure.label.data-name')}
424
+ .value=${a.name}
425
+ @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })}
426
+ @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && !e.isComposing && add()}
427
+ />
320
428
  <span>${i18next.t('figure.label.data-kind')}</span>
321
429
  <select @change=${(e: Event) => (this.adding = { ...a, kind: (e.target as HTMLSelectElement).value as FigureDataNode['kind'] })}>
322
430
  ${KINDS.map(k => html`<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
323
431
  </select>
324
432
  </div>
325
- <button
326
- act
327
- ?disabled=${this.locked || !named}
328
- @click=${() => {
329
- this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) } as FigureDataField) })
330
- this.adding = { ...a, name: '' }
331
- }}
332
- >
433
+ <button act ?disabled=${this.locked || !named} @click=${add}>
333
434
  <md-icon>add</md-icon>${i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })}
334
435
  </button>`
335
436
  }
@@ -366,156 +467,333 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
366
467
 
367
468
  /* ---------------------------------------------------------------- the bindings */
368
469
 
369
- private behaviorName(asset: FigureAsset, choices: BehaviorChoice[], b: FigureDataBinding['behavior']): string {
470
+ private behaviorName(asset: FigureAsset, choices: BehaviorChoice[], b: FigureDataValueBinding['behavior']): string {
370
471
  const key = behaviorKeyOf(b)
371
472
  const choice = choices.find(c => c.key === key)
372
473
  const name = b.kind === 'pose' ? i18next.t('figure.label.data-axis-' + b.axis) : (choice?.name ?? key.slice(key.indexOf(':') + 1))
373
474
  return i18next.t('figure.label.data-behavior-' + b.kind, { name })
374
475
  }
375
476
 
477
+ /** What a table shows on, as one line: its parts, the controls it sets, its motion group. */
478
+ private targetsName(asset: FigureAsset, b: FigureDataTableBinding): string {
479
+ const inputs = stateInputsOf(asset)
480
+ const group = (asset.motionGroups ?? []).find(g => g.id === b.targets.group)
481
+ return [
482
+ ...(b.targets.parts ?? []),
483
+ ...(b.targets.inputs ?? []).map(id => inputs.find(i => i.id === id)?.label ?? id),
484
+ ...(group ? [group.label] : [])
485
+ ].join(', ')
486
+ }
487
+
376
488
  private renderBindings(asset: FigureAsset, rows: FigureDataRow[]) {
377
489
  const bindings = asset.dataInterface?.bindings ?? []
378
490
  const choices = behaviorsOf(asset)
491
+ const resting = figureBindingsTakenOver(asset)
379
492
  return html`<section>
380
493
  <h3>${i18next.t('figure.label.data-bindings')}${this.help.button('figure.text.data-bindings-explained')}</h3>
381
494
  ${this.help.hint('figure.text.data-bindings-explained')}
382
- ${bindings.map(b => this.renderBinding(asset, choices, b))}
383
- ${rows.length ? this.renderAddBinding(asset, rows, choices) : html`<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
495
+ ${bindings.map(b => this.renderBinding(asset, choices, b, resting))}
496
+ ${this.renderAddBinding(asset, rows, choices)}
384
497
  ${this.renderRotation(asset, rows)}
385
498
  </section>`
386
499
  }
387
500
 
388
- private renderBinding(asset: FigureAsset, choices: BehaviorChoice[], b: FigureDataBinding) {
501
+ private renderBinding(asset: FigureAsset, choices: BehaviorChoice[], b: FigureDataBinding, resting: FigureRestingBinding[]) {
389
502
  const open = this.opened === b.id
390
- const set = (patch: Partial<FigureDataBinding>) => {
391
- const next = { ...b, ...patch }
392
- if (patch.param === undefined && 'param' in patch) delete next.param
393
- this.edit({ kind: 'set-data-binding', binding: next })
394
- }
503
+ const set = (next: FigureDataBinding) => this.edit({ kind: 'set-data-binding', binding: next })
504
+ const what = b.rule === 'table' ? this.targetsName(asset, b) : this.behaviorName(asset, choices, b.behavior)
395
505
  return html`<section item>
396
506
  <div head>
397
507
  <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
398
- <strong>${b.accessor}</strong> → ${this.behaviorName(asset, choices, b.behavior)} <small>· ${i18next.t('figure.label.data-rule-' + b.rule)}</small>
508
+ <strong>${b.label ?? b.accessor}</strong> → ${what} <small>· ${i18next.t('figure.label.data-method-' + methodOf(b))}</small>
399
509
  </button>
400
510
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
401
- <md-icon>close</md-icon>
511
+ <md-icon>delete</md-icon>
402
512
  </button>
403
513
  </div>
404
- ${open ? this.renderBindingDetail(asset, b, set) : nothing}
514
+ ${resting.filter(r => r.id === b.id).map(r => this.renderResting(r))}
515
+ ${open ? (b.rule === 'table' ? this.renderTable(asset, b, set) : this.renderValueDetail(asset, b, set)) : nothing}
405
516
  </section>`
406
517
  }
407
518
 
408
- private renderBindingDetail(asset: FigureAsset, b: FigureDataBinding, set: (patch: Partial<FigureDataBinding>) => void) {
519
+ /**
520
+ * A binding a product's capability takes over where the product registers it (ADR-0167 17): there it rests, whole or for
521
+ * the targets named; elsewhere (a board without the product) it works.
522
+ */
523
+ private renderResting(r: FigureRestingBinding) {
524
+ const where = r.capability.split('.')[0]
525
+ const name = figureCapabilityDescriptorOf(r.capability)?.label ?? r.capability
526
+ const targets = [...r.parts, ...r.inputs].join(', ')
527
+ return html`<p hint>${r.whole ? i18next.t('figure.text.data-binding-rests', { where, name }) : i18next.t('figure.text.data-binding-rests-targets', { where, name, targets })}</p>`
528
+ }
529
+
530
+ private renderValueDetail(asset: FigureAsset, b: FigureDataValueBinding, set: (next: FigureDataBinding) => void) {
409
531
  const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'))
410
532
  const value = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
411
533
  const behavior = b.behavior
412
- if (behavior.kind === 'items') return this.renderItemFields(asset, b, node, set)
534
+ const patch = (p: Partial<FigureDataValueBinding>) => {
535
+ const next = { ...b, ...p }
536
+ if ('param' in p && p.param === undefined) delete next.param
537
+ set(next)
538
+ }
539
+ if (behavior.kind === 'items') return this.renderItemFields(asset, b, node, patch)
413
540
  return html`<div form>
414
- <span>${i18next.t('figure.label.data-rule')}</span>
415
- <select ?disabled=${this.locked} @change=${(e: Event) => set({ rule: value(e) as FigureDataBinding['rule'], param: firstParamOf(value(e) as FigureDataBinding['rule'], node, b) })}>
416
- ${(['value', 'map', 'range', 'scale'] as const).map(r => html`<option value=${r} ?selected=${r === b.rule}>${i18next.t('figure.label.data-rule-' + r)}</option>`)}
417
- </select>
418
- ${behavior.kind === 'state'
419
- ? html`<span>${i18next.t('figure.label.data-source-mode')}</span>
420
- <select ?disabled=${this.locked} @change=${(e: Event) => set({ behavior: { ...behavior, sourceMode: value(e) as 'observed' | 'commanded' } })}>
421
- ${(['observed', 'commanded'] as const).map(m => html`<option value=${m} ?selected=${behavior.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
422
- </select>`
423
- : nothing}
424
- ${behavior.kind === 'pose'
425
- ? html`<span>${i18next.t('figure.label.data-seconds')}</span>
426
- <input type="number" min="0" step="any" .value=${String(behavior.seconds ?? 0)} ?disabled=${this.locked} @change=${(e: Event) => set({ behavior: { ...behavior, seconds: (e.target as HTMLInputElement).valueAsNumber || 0 } })} />`
427
- : nothing}
428
- ${b.rule === 'scale'
429
- ? html`<span>${i18next.t('figure.label.data-scale')}</span>
430
- <input type="number" step="any" .value=${String(b.param?.scale ?? 1)} ?disabled=${this.locked} @change=${(e: Event) => set({ param: { ...b.param, scale: (e.target as HTMLInputElement).valueAsNumber } })} />
431
- <span>${i18next.t('figure.label.data-offset')}</span>
432
- <input type="number" step="any" .value=${String(b.param?.offset ?? 0)} ?disabled=${this.locked} @change=${(e: Event) => set({ param: { ...b.param, offset: (e.target as HTMLInputElement).valueAsNumber } })} />`
541
+ <span>${i18next.t('figure.label.data-method')}</span>
542
+ ${node?.kind === 'number'
543
+ ? html`<select ?disabled=${this.locked} @change=${(e: Event) => patch({ rule: value(e) as 'value' | 'scale', param: value(e) === 'scale' ? { scale: 1, offset: 0 } : undefined })}>
544
+ ${(['value', 'scale'] as const).map(r => html`<option value=${r} ?selected=${r === b.rule}>${i18next.t('figure.label.data-method-' + r)}</option>`)}
545
+ </select>`
546
+ : html`<span>${i18next.t('figure.label.data-method-value')}</span>`}
547
+ ${behavior.kind === 'state'
548
+ ? html`<span>${i18next.t('figure.label.data-source-mode')}</span>
549
+ <select ?disabled=${this.locked} @change=${(e: Event) => patch({ behavior: { ...behavior, sourceMode: value(e) as 'observed' | 'commanded' } })}>
550
+ ${(['observed', 'commanded'] as const).map(m => html`<option value=${m} ?selected=${behavior.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
551
+ </select>`
552
+ : nothing}
553
+ ${behavior.kind === 'pose'
554
+ ? html`<span>${i18next.t('figure.label.data-seconds')}</span>
555
+ <input type="number" min="0" step="any" .value=${String(behavior.seconds ?? 0)} ?disabled=${this.locked} @change=${(e: Event) => patch({ behavior: { ...behavior, seconds: (e.target as HTMLInputElement).valueAsNumber || 0 } })} />`
556
+ : nothing}
557
+ ${b.rule === 'scale'
558
+ ? html`<span>${i18next.t('figure.label.data-scale')}</span>
559
+ <input type="number" step="any" .value=${String(b.param?.scale ?? 1)} ?disabled=${this.locked} @change=${(e: Event) => patch({ param: { ...b.param, scale: (e.target as HTMLInputElement).valueAsNumber } })} />
560
+ <span>${i18next.t('figure.label.data-offset')}</span>
561
+ <input type="number" step="any" .value=${String(b.param?.offset ?? 0)} ?disabled=${this.locked} @change=${(e: Event) => patch({ param: { ...b.param, offset: (e.target as HTMLInputElement).valueAsNumber } })} />`
562
+ : nothing}
563
+ </div>`
564
+ }
565
+
566
+ /**
567
+ * A table (ADR-0166 10): what it shows on -- parts, controls, a motion group -- its rows, a code or an interval each
568
+ * with a cell per column, the look of every other value, and whether a board may swap it for a legend.
569
+ */
570
+ private renderTable(asset: FigureAsset, b: FigureDataTableBinding, set: (next: FigureDataBinding) => void) {
571
+ const value = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
572
+ const parts = partsOf(asset)
573
+ const inputs = stateInputsOf(asset)
574
+ const groups = asset.motionGroups ?? []
575
+ const targets = b.targets
576
+ const toggle = (list: string[] | undefined, id: string, on: boolean) => (on ? [...(list ?? []), id] : (list ?? []).filter(x => x !== id))
577
+ const retarget = (t: typeof targets) => set(tableWithTargets(b, t))
578
+ return html`<div form>
579
+ <span>${i18next.t('figure.label.data-label')}</span>
580
+ <input .value=${b.label ?? ''} placeholder=${b.accessor} ?disabled=${this.locked} @change=${(e: Event) => {
581
+ const label = value(e).trim()
582
+ const next = { ...b, label }
583
+ if (!label) delete (next as { label?: string }).label
584
+ set(next)
585
+ }} />
586
+ </div>
587
+ ${this.renderTargets(parts, inputs, groups, targets, {
588
+ part: (id, on) => retarget({ ...targets, parts: toggle(targets.parts, id, on) }),
589
+ input: (id, on) => retarget({ ...targets, inputs: toggle(targets.inputs, id, on) }),
590
+ group: id => retarget({ ...targets, group: id || undefined })
591
+ })}
592
+ ${b.lookup === 'code' ? this.renderCodeRows(asset, b, set) : this.renderRangeRows(asset, b, set)}
593
+ <label toggle>
594
+ <input type="checkbox" .checked=${!!b.legend} ?disabled=${this.locked} @change=${(e: Event) => {
595
+ const next = { ...b }
596
+ if ((e.target as HTMLInputElement).checked) next.legend = { role: legendRoleOf(b.id) }
597
+ else delete next.legend
598
+ set(next)
599
+ }} />
600
+ ${i18next.t('figure.label.data-legend-swappable')}
601
+ </label>
602
+ ${b.legend
603
+ ? html`<div form>
604
+ <span>${i18next.t('figure.label.data-legend-role')}</span>
605
+ <input .value=${b.legend.role} ?disabled=${this.locked} @change=${(e: Event) => set({ ...b, legend: { role: value(e).trim() } })} />
606
+ </div>
607
+ <p hint>${i18next.t('figure.text.data-legend-explained')}</p>`
608
+ : nothing}`
609
+ }
610
+
611
+ /** Which parts, controls and motion group a table shows on: chips to tick, and the part chosen in the modeller by one button. */
612
+ private renderTargets(
613
+ parts: string[],
614
+ inputs: Array<{ id: string; label: string }>,
615
+ groups: NonNullable<FigureAsset['motionGroups']>,
616
+ t: { parts?: string[]; inputs?: string[]; group?: string },
617
+ on: { part: (id: string, on: boolean) => void; input: (id: string, on: boolean) => void; group: (id: string) => void }
618
+ ) {
619
+ const chosen = (e: Event) => (e.target as HTMLInputElement).checked
620
+ return html`<div form>
621
+ <span>${i18next.t('figure.label.data-target-parts')}</span>
622
+ <div chips>
623
+ ${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>`)}
624
+ ${this.selected && parts.includes(this.selected) && !t.parts?.includes(this.selected)
625
+ ? 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>`
433
626
  : nothing}
434
627
  </div>
435
- ${b.rule === 'map' ? this.renderCases(asset, b, node, set) : b.rule === 'range' ? this.renderRanges(asset, b, set) : nothing}`
628
+ ${inputs.length
629
+ ? html`<span>${i18next.t('figure.label.data-target-inputs')}</span>
630
+ <div chips>
631
+ ${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>`)}
632
+ </div>`
633
+ : nothing}
634
+ ${groups.length
635
+ ? html`<span>${i18next.t('figure.label.data-target-group')}</span>
636
+ <select ?disabled=${this.locked} @change=${(e: Event) => on.group((e.target as HTMLSelectElement).value)}>
637
+ <option value="" ?selected=${!t.group}>${i18next.t('figure.label.data-none')}</option>
638
+ ${groups.map(g => html`<option value=${g.id} ?selected=${g.id === t.group}>${g.label}</option>`)}
639
+ </select>`
640
+ : nothing}
641
+ </div>`
436
642
  }
437
643
 
438
- /** A result editor fitting what the behavior takes: a colour, a clip of the group, true or false, a code, a number. */
439
- private renderResult(asset: FigureAsset, b: FigureDataBinding, value: FigureDataValue | undefined, write: (v: FigureDataValue | undefined) => void) {
440
- const behavior = b.behavior
441
- const typed = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
442
- if (behavior.kind === 'palette')
443
- /* A colour not set is not black: it is offered, started at the colour the token has now. */
444
- return value === undefined
445
- ? html`<button act ?disabled=${this.locked} @click=${() => write(asset.palette[behavior.token] ?? '#000000')}>${i18next.t('figure.button.data-set-result')}</button>`
446
- : html`<input type="color" .value=${String(value)} ?disabled=${this.locked} @change=${(e: Event) => write(typed(e))} />`
447
- if (behavior.kind === 'motion-clip') {
448
- const clips = (asset.motionGroups ?? []).find(g => g.id === behavior.group)?.clips ?? []
449
- return html`<select ?disabled=${this.locked} @change=${(e: Event) => write(typed(e))}>
450
- ${clips.map(clip => html`<option value=${clip} ?selected=${clip === value}>${clip}</option>`)}
451
- </select>`
644
+ /** The column heads of a table's rows, after the key's own one or two. */
645
+ private columnHeads(columns: TableColumn[]) {
646
+ return columns.map(c =>
647
+ c.kind === 'value'
648
+ ? html`<small>${c.label}${c.unit ? ` (${c.unit}${c.min !== undefined && c.max !== undefined ? ` ${c.min}~${c.max}` : ''})` : ''}</small>`
649
+ : html`<small>${i18next.t('figure.label.data-column-' + c.kind)}</small>`
650
+ )
651
+ }
652
+
653
+ /** One look's cells, a column each: a colour, an effect, a control's value, a clip, a speed, a pause -- empty leaves the target as it is. */
654
+ private lookCells(asset: FigureAsset, columns: TableColumn[], look: FigureDataTableEntry, write: (look: FigureDataTableEntry) => void) {
655
+ const without = (key: keyof FigureDataTableEntry) => {
656
+ const next = { ...look }
657
+ delete next[key]
658
+ return next
452
659
  }
453
- if (behavior.kind === 'motion-paused')
454
- return html`<select ?disabled=${this.locked} @change=${(e: Event) => write(typed(e) === 'true')}>
455
- ${[false, true].map(v => html`<option value=${String(v)} ?selected=${v === value}>${i18next.t(v ? 'figure.label.data-paused' : 'figure.label.data-playing')}</option>`)}
660
+ const typed = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
661
+ return columns.map(c => {
662
+ if (c.kind === 'color')
663
+ return look.color === undefined
664
+ ? html`<button act ?disabled=${this.locked} @click=${() => write({ ...look, color: '#888888' })}>${i18next.t('figure.button.data-set-result')}</button>`
665
+ : html`<span colour><input type="color" .value=${look.color} ?disabled=${this.locked} @change=${(e: Event) => write({ ...look, color: typed(e) })} /><button act ?disabled=${this.locked} title=${i18next.t('figure.button.data-clear-result')} @click=${() => write(without('color'))}><md-icon>close</md-icon></button></span>`
666
+ if (c.kind === 'effect')
667
+ return html`<select ?disabled=${this.locked} @change=${(e: Event) => (typed(e) ? write({ ...look, effect: { kind: typed(e) as 'blink' | 'glow' } }) : write(without('effect')))}>
668
+ ${(['', 'blink', 'glow'] as const).map(k => html`<option value=${k} ?selected=${(look.effect?.kind ?? '') === k}>${i18next.t('figure.label.data-effect-' + (k || 'none'))}</option>`)}
669
+ </select>`
670
+ if (c.kind === 'value') {
671
+ const v = look.values?.[c.id]
672
+ return html`<input type="number" step="any" min=${c.min ?? ''} max=${c.max ?? ''} .value=${v === undefined ? '' : String(v)} ?disabled=${this.locked} @change=${(e: Event) => {
673
+ const n = (e.target as HTMLInputElement).valueAsNumber
674
+ const values = { ...look.values }
675
+ if (Number.isNaN(n)) delete values[c.id]
676
+ else values[c.id] = n
677
+ write(Object.keys(values).length ? { ...look, values } : without('values'))
678
+ }} />`
679
+ }
680
+ if (c.kind === 'clip')
681
+ return html`<select ?disabled=${this.locked} @change=${(e: Event) => (typed(e) ? write({ ...look, clip: typed(e) }) : write(without('clip')))}>
682
+ <option value="" ?selected=${look.clip === undefined}>-</option>
683
+ ${c.clips.map(clip => html`<option value=${clip} ?selected=${clip === look.clip}>${clip}</option>`)}
684
+ </select>`
685
+ if (c.kind === 'speed')
686
+ return html`<input type="number" min="0" max="3" step="any" .value=${look.speed === undefined ? '' : String(look.speed)} ?disabled=${this.locked} @change=${(e: Event) => {
687
+ const n = (e.target as HTMLInputElement).valueAsNumber
688
+ write(Number.isNaN(n) ? without('speed') : { ...look, speed: n })
689
+ }} />`
690
+ return html`<select ?disabled=${this.locked} @change=${(e: Event) => (typed(e) ? write({ ...look, paused: typed(e) === 'true' }) : write(without('paused')))}>
691
+ <option value="" ?selected=${look.paused === undefined}>-</option>
692
+ ${[false, true].map(v => html`<option value=${String(v)} ?selected=${v === look.paused}>${i18next.t(v ? 'figure.label.data-paused' : 'figure.label.data-playing')}</option>`)}
456
693
  </select>`
457
- if (behavior.kind === 'status') return html`<input .value=${String(value ?? '')} ?disabled=${this.locked} @change=${(e: Event) => write(typed(e))} />`
458
- return html`<input type="number" step="any" .value=${value === undefined ? '' : String(value)} ?disabled=${this.locked} @change=${(e: Event) => write((e.target as HTMLInputElement).valueAsNumber)} />`
694
+ })
459
695
  }
460
696
 
461
- /** A result per value of a closed field, and one for every other value. */
462
- private renderCases(asset: FigureAsset, b: FigureDataBinding, node: FigureDataNode | undefined, set: (patch: Partial<FigureDataBinding>) => void) {
463
- const param = b.param ?? {}
697
+ /** 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. */
698
+ private renderOtherwise(asset: FigureAsset, b: FigureDataTableBinding, columns: TableColumn[], needed: boolean, set: (next: FigureDataBinding) => void, keyCells: number) {
699
+ const look = b.table.default
700
+ if (look === undefined)
701
+ 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>`
702
+ const { default: _, ...rest } = b.table
703
+ return html`<div looks style="grid-template-columns: ${gridOf(keyCells, columns.length)}">
704
+ <span style="grid-column: span ${keyCells}">${i18next.t('figure.label.data-otherwise')}</span>
705
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, 'default', l)))}
706
+ ${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>`}
707
+ </div>`
708
+ }
709
+
710
+ /**
711
+ * A code table's rows: a code each, typed, with its look. A closed text's values follow the codes (ADR-0166 risk 3):
712
+ * a code added, renamed or taken away is the field's value too; true and false are both rows.
713
+ */
714
+ private renderCodeRows(asset: FigureAsset, b: FigureDataTableBinding, set: (next: FigureDataBinding) => void) {
715
+ const columns = tableColumnsOf(asset, b)
716
+ const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'))
717
+ const boolean = node?.kind === 'boolean'
718
+ const rows = Object.entries(b.table).filter(([k]) => k !== 'default')
464
719
  const domain = node ? domainOf(node).map(String) : []
465
- const keys = [...new Set([...domain, ...Object.keys(param).filter(k => k !== 'default')])]
466
- const write = (key: string) => (v: FigureDataValue | undefined) => {
467
- const next = { ...param }
468
- if (v === undefined || (typeof v === 'number' && Number.isNaN(v))) delete next[key]
469
- else next[key] = v
470
- set({ param: next })
720
+ const covered = domain.length > 0 && domain.every(v => b.table[v] !== undefined)
721
+ /* The binding and the field it reads, in one undo step. */
722
+ const rekeyed = (list: Array<[string, FigureDataTableEntry]>) => {
723
+ const next = tableWithKeys(b, list)
724
+ const schema = boolean ? undefined : schemaFollowingCodes(asset, next, list.map(([k]) => k))
725
+ this.edit({ kind: 'set-data-binding', binding: next, ...(schema ? { schema } : {}) })
471
726
  }
472
- const word = (k: string) => (node?.kind === 'boolean' ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k)
473
- /* Every value of a closed field has its own result: no other value comes, unless one was set before. */
474
- const covered = domain.length > 0 && domain.every(v => param[v] !== undefined)
475
- return html`<div cases>
476
- ${keys.map(k => html`<span>${word(k)}</span>${this.renderResult(asset, b, param[k], write(k))}`)}
477
- ${covered && param.default === undefined ? nothing : html`<span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, write('default'))}`}
478
- </div>`
727
+ const rekey = (i: number, typed: string) => {
728
+ const key = typed.trim()
729
+ if (!key || key === 'default' || rows.some(([k], j) => j !== i && k === key)) return this.requestUpdate()
730
+ rekeyed(rows.map(([k, v], j) => (j === i ? [key, v] : [k, v])))
731
+ }
732
+ const word = (k: string) => (boolean ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k)
733
+ return html`<div looks style="grid-template-columns: ${gridOf(1, columns.length)}">
734
+ <small>${i18next.t('figure.label.data-case-value')}</small>${this.columnHeads(columns)}<span></span>
735
+ ${rows.map(
736
+ ([k, look], i) => html`${boolean
737
+ ? html`<span>${word(k)}</span>`
738
+ : 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)} />`}
739
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
740
+ ${boolean || rows.length < 2 && domain.length > 0
741
+ ? html`<span></span>`
742
+ : 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>`}`
743
+ )}
744
+ </div>
745
+ ${this.renderOtherwise(asset, b, columns, domain.length > 0 && !covered, set, 1)}
746
+ ${boolean
747
+ ? nothing
748
+ : html`<button act ?disabled=${this.locked} @click=${() => rekeyed([...rows, [freeCodeOf(rows.map(([k]) => k)), {}]])}>
749
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-case')}
750
+ </button>`}`
479
751
  }
480
752
 
481
- /** Intervals of a number, each from (inclusive) to (exclusive), either end left open, and one result for the rest. */
482
- private renderRanges(asset: FigureAsset, b: FigureDataBinding, set: (patch: Partial<FigureDataBinding>) => void) {
483
- const param = b.param ?? {}
484
- const entries = Object.entries(param).filter(([k]) => k !== 'default')
485
- const rebuild = (list: Array<[string, FigureDataValue]>) => set({ param: { ...Object.fromEntries(list), ...(param.default !== undefined ? { default: param.default } : {}) } })
753
+ /**
754
+ * A range table's rows: from (inclusive) and to (exclusive), either end open, with a look each. Giving the last row,
755
+ * open above, an end starts the next row there; a row open above is the last there can be.
756
+ */
757
+ private renderRangeRows(asset: FigureAsset, b: FigureDataTableBinding, set: (next: FigureDataBinding) => void) {
758
+ const columns = tableColumnsOf(asset, b)
759
+ const rows = Object.entries(b.table).filter(([k]) => k !== 'default')
486
760
  const ends = (key: string) => {
487
761
  const [from = '', to = ''] = key.split('~')
488
762
  return { from, to }
489
763
  }
490
- const rekey = (i: number, end: 'from' | 'to', typed: string) =>
491
- rebuild(
492
- entries.map(([k, v], j) => {
493
- if (j !== i) return [k, v]
494
- const e = ends(k)
495
- const next = { ...e, [end]: typed.trim() }
496
- return [`${next.from}~${next.to}`, v]
497
- })
498
- )
499
- return html`<div ranges>
500
- <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small><small></small><span></span>
501
- ${entries.map(([k, v], i) => {
764
+ const rekey = (i: number, end: 'from' | 'to', typed: string) => {
765
+ const was = ends(rows[i]![0])
766
+ const next = { ...was, [end]: typed.trim() }
767
+ const key = `${next.from}~${next.to}`
768
+ /* A key another row has would take that row's place: not taken. */
769
+ if (rows.some(([k], j) => j !== i && k === key)) return this.requestUpdate()
770
+ const list: Array<[string, FigureDataTableEntry]> = rows.map(([k, v], j) => (j === i ? [key, v] : [k, v]))
771
+ /* The last row, open above, given an end: the next row starts there, open above, so every value still has a row. */
772
+ if (i === rows.length - 1 && end === 'to' && was.to === '' && next.to !== '') list.push([`${next.to}~`, {}])
773
+ set(tableWithKeys(b, list))
774
+ }
775
+ const last = rows.at(-1)
776
+ const lastEnd = last ? ends(last[0]).to : ''
777
+ const open = !!last && lastEnd === ''
778
+ return html`<div looks style="grid-template-columns: ${gridOf(2, columns.length)}">
779
+ <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small>${this.columnHeads(columns)}<span></span>
780
+ ${rows.map(([k, look], i) => {
502
781
  const e = ends(k)
503
782
  return html`<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'from', (ev.target as HTMLInputElement).value)} />
504
783
  <input type="number" step="any" .value=${e.to} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'to', (ev.target as HTMLInputElement).value)} />
505
- ${this.renderResult(asset, b, v, out => rebuild(entries.map(([kk, vv], j) => [kk, j === i ? (out as FigureDataValue) : vv])))}
506
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => rebuild(entries.filter((_, j) => j !== i))}><md-icon>close</md-icon></button>`
784
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
785
+ <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>`
507
786
  })}
508
787
  </div>
509
- <div cases>
510
- <span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, v => set({ param: { ...param, ...(v === undefined ? {} : { default: v }) } }))}
511
- </div>
512
- <button act ?disabled=${this.locked} @click=${() => rebuild([...entries, [freeRangeKey(entries.map(([k]) => k)), entries.at(-1)?.[1] ?? param.default ?? 0]])}>
788
+ ${this.renderOtherwise(asset, b, columns, false, set, 2)}
789
+ ${open ? html`<p hint>${i18next.t('figure.text.data-range-close-last')}</p>` : nothing}
790
+ <button act ?disabled=${this.locked || open} @click=${() => set(tableWithKeys(b, [...rows, [`${lastEnd}~`, {}]]))}>
513
791
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-range')}
514
792
  </button>`
515
793
  }
516
794
 
517
795
  /** Which field of each element is its id, its type, its place, its height in the stack, its status. */
518
- private renderItemFields(asset: FigureAsset, b: FigureDataBinding, node: FigureDataNode | undefined, set: (patch: Partial<FigureDataBinding>) => void) {
796
+ private renderItemFields(asset: FigureAsset, b: FigureDataValueBinding, node: FigureDataNode | undefined, set: (patch: Partial<FigureDataValueBinding>) => void) {
519
797
  const behavior = b.behavior
520
798
  if (behavior.kind !== 'items') return nothing
521
799
  let element: FigureDataNode | undefined = node
@@ -524,7 +802,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
524
802
  const pick = (role: 'id' | 'type' | 'place' | 'stack' | 'status', typed: string) => {
525
803
  const next: Record<string, unknown> = { ...behavior, [role]: typed }
526
804
  if (!typed) delete next[role]
527
- set({ behavior: next as FigureDataBinding['behavior'] })
805
+ set({ behavior: next as FigureDataValueBinding['behavior'] })
528
806
  }
529
807
  return html`<div form>
530
808
  ${node?.kind === 'list'
@@ -541,40 +819,78 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
541
819
  const slot = (e.target as HTMLInputElement).value.trim()
542
820
  const next: Record<string, unknown> = { ...behavior, slot }
543
821
  if (!slot) delete next.slot
544
- set({ behavior: next as FigureDataBinding['behavior'] })
822
+ set({ behavior: next as FigureDataValueBinding['behavior'] })
545
823
  }} />
546
824
  </div>`
547
825
  }
548
826
 
827
+ /**
828
+ * 「연결 추가」 (ADR-0166 10): a field there or a new name, then a method the field's kind allows. As it is or scaled,
829
+ * the one thing it moves; a table, the parts, controls and motion group it shows on -- a new name makes the field.
830
+ */
549
831
  private renderAddBinding(asset: FigureAsset, rows: FigureDataRow[], choices: BehaviorChoice[]) {
550
832
  const bindable = rows.filter(r => r.node.kind !== 'object')
551
- if (!bindable.length) return nothing
552
833
  const a = this.connecting
553
- const row = bindable.find(r => r.path === a.path) ?? bindable[0]!
554
- const fitting = choices.filter(c => fitsField(row.node, c))
834
+ /* 「새 필드」: a value no field path can have (a path is names and `[]`). */
835
+ const NEW = '+'
836
+ const row = a.path === NEW || !bindable.length ? null : (bindable.find(r => r.path === a.path) ?? bindable[0]!)
837
+ const methods = methodsOf(row?.node ?? null, choices)
838
+ const method = methods.includes(a.method as DataMethod) ? (a.method as DataMethod) : methods[0]
839
+ const table = method === 'code' || method === 'ranges'
840
+ const fitting = row && !table ? choices.filter(c => fitsField(row.node, c)) : []
555
841
  const choice = fitting.find(c => c.key === a.behavior) ?? fitting[0]
556
- /* A list is read whole by items; one value of it, through its first element. */
557
- const accessor = row.node.kind === 'list' && choice?.kind === 'items' ? row.path.replace(/\[\]$/, '') : accessorOfRow(row.path)
842
+ const set = (patch: Partial<typeof a>) => (this.connecting = { ...a, ...patch })
843
+ const typed = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
844
+ const name = a.name.trim()
845
+ const targets = { parts: a.parts, inputs: a.inputs, ...(a.group ? { group: a.group } : {}) }
846
+ const aimed = a.parts.length > 0 || a.inputs.length > 0 || !!a.group
847
+ const ready = table ? aimed && (row !== null || /^[A-Za-z_][\w-]*$/.test(name)) : !!choice && !!row
848
+ const add = () => {
849
+ if (table) {
850
+ const { binding, schema } = tableDraftOf(asset, row ? { path: row.path, node: row.node } : { name }, method as 'code' | 'ranges', targets)
851
+ this.edit({ kind: 'set-data-binding', binding, ...(schema ? { schema } : {}) })
852
+ this.opened = binding.id
853
+ } else {
854
+ /* A list is read whole by items; one value of it, through its first element. */
855
+ const accessor = row!.node.kind === 'list' && choice!.kind === 'items' ? row!.path.replace(/\[\]$/, '') : accessorOfRow(row!.path)
856
+ const binding = bindingDraftOf(asset, accessor, row!.node, choice!, method as 'value' | 'scale')
857
+ this.edit({ kind: 'set-data-binding', binding })
858
+ this.opened = binding.id
859
+ }
860
+ this.connecting = { path: '', name: '', method: '', behavior: '', parts: [], inputs: [], group: '' }
861
+ }
862
+ const toggle = (list: string[], id: string, on: boolean) => (on ? [...list, id] : list.filter(x => x !== id))
558
863
  return html`<div form>
559
864
  <span>${i18next.t('figure.label.data-field')}</span>
560
- <select @change=${(e: Event) => (this.connecting = { ...a, path: (e.target as HTMLSelectElement).value })}>
865
+ <select @change=${(e: Event) => set({ path: typed(e), name: '' })}>
561
866
  ${bindable.map(r => html`<option value=${r.path} ?selected=${r === row}>${r.path}</option>`)}
867
+ <option value=${NEW} ?selected=${row === null}>${i18next.t('figure.label.data-new-field')}</option>
562
868
  </select>
563
- <span>${i18next.t('figure.label.data-behavior')}</span>
564
- <select ?disabled=${!fitting.length} @change=${(e: Event) => (this.connecting = { ...a, behavior: (e.target as HTMLSelectElement).value })}>
565
- ${fitting.map(c => html`<option value=${c.key} ?selected=${c === choice}>${this.behaviorName(asset, choices, c.behavior)}</option>`)}
869
+ ${row === null
870
+ ? html`<span>${i18next.t('figure.label.data-name')}</span>
871
+ <input .value=${a.name} @input=${(e: Event) => set({ name: typed(e) })} />`
872
+ : nothing}
873
+ <span>${i18next.t('figure.label.data-method')}</span>
874
+ <select ?disabled=${!methods.length} @change=${(e: Event) => set({ method: typed(e) as DataMethod })}>
875
+ ${methods.map(m => html`<option value=${m} ?selected=${m === method}>${i18next.t('figure.label.data-method-' + m)}</option>`)}
566
876
  </select>
877
+ ${!table && fitting.length
878
+ ? html`<span>${i18next.t('figure.label.data-behavior')}</span>
879
+ <select @change=${(e: Event) => set({ behavior: typed(e) })}>
880
+ ${fitting.map(c => html`<option value=${c.key} ?selected=${c === choice}>${this.behaviorName(asset, choices, c.behavior)}</option>`)}
881
+ </select>`
882
+ : nothing}
567
883
  </div>
568
- ${fitting.length ? nothing : html`<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
569
- <button
570
- act
571
- ?disabled=${this.locked || !choice}
572
- @click=${() => {
573
- const binding = bindingDraftOf(asset, accessor, row.node, choice!)
574
- this.edit({ kind: 'set-data-binding', binding })
575
- this.opened = binding.id
576
- }}
577
- >
884
+ ${table
885
+ ? this.renderTargets(partsOf(asset), stateInputsOf(asset), asset.motionGroups ?? [], targets, {
886
+ part: (id, on) => set({ parts: toggle(a.parts, id, on) }),
887
+ input: (id, on) => set({ inputs: toggle(a.inputs, id, on) }),
888
+ group: id => set({ group: id })
889
+ })
890
+ : nothing}
891
+ ${table && !aimed ? html`<p hint>${i18next.t('figure.text.data-table-needs-target')}</p>` : nothing}
892
+ ${methods.length ? nothing : html`<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
893
+ <button act ?disabled=${this.locked || !ready} @click=${add}>
578
894
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-binding')}
579
895
  </button>`
580
896
  }
@@ -654,56 +970,6 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
654
970
  </details>`
655
971
  }
656
972
 
657
- /* ---------------------------------------------------------------- status ports */
658
-
659
- private renderPorts(asset: FigureAsset) {
660
- const ports = asset.dataInterface?.ports ?? []
661
- const tokens = Object.keys(asset.palette)
662
- const parts = partsOf(asset)
663
- const set = (port: FigureStatusPort, patch: Partial<FigureStatusPort>) => this.edit({ kind: 'set-status-port', port: { ...port, ...patch } })
664
- const chosen = (e: Event) => [...(e.target as HTMLSelectElement).selectedOptions].map(o => o.value)
665
- return html`<section>
666
- <h3>${i18next.t('figure.label.data-ports')}${this.help.button('figure.text.data-ports-explained')}</h3>
667
- ${this.help.hint('figure.text.data-ports-explained')}
668
- ${ports.map(
669
- port => html`<section item>
670
- <div head>
671
- <strong>${port.label}</strong>
672
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-port')} @click=${() => this.edit({ kind: 'remove-status-port', id: port.id })}>
673
- <md-icon>close</md-icon>
674
- </button>
675
- </div>
676
- <div form>
677
- <span>${i18next.t('figure.label.data-label')}</span>
678
- <input .value=${port.label} ?disabled=${this.locked} @change=${(e: Event) => set(port, { label: (e.target as HTMLInputElement).value.trim() })} />
679
- <span>${i18next.t('figure.label.data-port-tokens')}</span>
680
- <select multiple size=${Math.min(4, Math.max(tokens.length, 1))} ?disabled=${this.locked} @change=${(e: Event) => set(port, { tokens: chosen(e) })}>
681
- ${tokens.map(t => html`<option value=${t} ?selected=${port.tokens.includes(t)}>${t}</option>`)}
682
- </select>
683
- <span>${i18next.t('figure.label.data-port-parts')}</span>
684
- <select multiple size=${Math.min(4, Math.max(parts.length, 1))} ?disabled=${this.locked} @change=${(e: Event) => set(port, { parts: chosen(e) })}>
685
- ${parts.map(p => html`<option value=${p} ?selected=${port.parts.includes(p)}>${p}</option>`)}
686
- </select>
687
- </div>
688
- </section>`
689
- )}
690
- <div form>
691
- <span>${i18next.t('figure.label.data-label')}</span>
692
- <input .value=${this.porting} @input=${(e: Event) => (this.porting = (e.target as HTMLInputElement).value)} />
693
- </div>
694
- <button
695
- act
696
- ?disabled=${this.locked || !this.porting.trim()}
697
- @click=${() => {
698
- this.edit({ kind: 'set-status-port', port: portDraftOf(asset, this.porting) })
699
- this.porting = ''
700
- }}
701
- >
702
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-port')}
703
- </button>
704
- </section>`
705
- }
706
-
707
973
  /* ---------------------------------------------------------------- the sample */
708
974
 
709
975
  /** 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. */
@@ -716,8 +982,10 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
716
982
  } catch {
717
983
  unreadable = true
718
984
  }
719
- const read = unreadable ? null : sampleOf(asset, data)
985
+ const legends = this.sampleLegends()
986
+ const read = unreadable ? null : sampleOf(asset, data, legends)
720
987
  const r = read?.result
988
+ const tables = (asset.dataInterface?.bindings ?? []).filter((b): b is FigureDataTableBinding => b.rule === 'table')
721
989
  return html`<section>
722
990
  <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
723
991
  ${this.help.hint('figure.text.data-sample-explained')}
@@ -736,11 +1004,14 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
736
1004
  ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`, m.paused ? i18next.t('figure.label.data-paused') : ''].filter(Boolean).join(' ')}
737
1005
  </li>`
738
1006
  )}
739
- ${Object.entries(r!.status).map(([port, code]) => html`<li>${(asset.dataInterface?.ports ?? []).find(p => p.id === port)?.label ?? port}: ${code}</li>`)}
1007
+ ${tables.map(b => this.renderTableUse(asset, b, read!.uses[b.id]))}
1008
+ ${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>`)}
740
1009
  ${Object.entries(r!.pose).map(([axis, p]) => html`<li>${i18next.t('figure.label.data-axis-' + axis)} = ${p!.value}</li>`)}
741
1010
  ${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>`)}
742
1011
  </ul>`}
1012
+ ${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}
743
1013
  ${r && Object.keys(r.pose).length ? html`<p hint>${i18next.t('figure.text.data-sample-moves-all')}</p>` : nothing}
1014
+ ${this.renderSampleLegends(tables)}
744
1015
  <label toggle>
745
1016
  <input
746
1017
  type="checkbox"
@@ -755,6 +1026,53 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
755
1026
  </section>`
756
1027
  }
757
1028
 
1029
+ /** 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. */
1030
+ private renderTableUse(asset: FigureAsset, b: FigureDataTableBinding, use: { value: unknown; key: string | null; source: string; legend?: string } | undefined) {
1031
+ if (!use || use.value === undefined) return nothing
1032
+ 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))
1033
+ const row = use.key === null ? i18next.t('figure.label.data-none') : use.key === 'default' ? i18next.t('figure.label.data-otherwise') : word(use.key)
1034
+ const from = use.source === 'figure' ? i18next.t('figure.label.data-table-own') : i18next.t('figure.label.data-table-legend', { name: use.legend ?? '' })
1035
+ return html`<li>${b.label ?? b.accessor} = ${word(use.value)} → ${row} <small>(${from})</small></li>`
1036
+ }
1037
+
1038
+ /** The sample legends as read: by role, those that are JSON. */
1039
+ private sampleLegends(): Record<string, unknown> {
1040
+ const out: Record<string, unknown> = {}
1041
+ for (const [role, text] of Object.entries(this.legendSample)) {
1042
+ if (!text.trim()) continue
1043
+ try {
1044
+ out[role] = JSON.parse(text)
1045
+ } catch {
1046
+ /* Not JSON yet: the figure's own table shows. */
1047
+ }
1048
+ }
1049
+ return out
1050
+ }
1051
+
1052
+ /**
1053
+ * A legend pasted for each table role, to see the figure as a board's legend of that role would show it -- not saved.
1054
+ * The preview shows it too, as a scene's legend named as the role.
1055
+ */
1056
+ private renderSampleLegends(tables: FigureDataTableBinding[]) {
1057
+ const roles = [...new Set(tables.flatMap(b => (b.legend ? [b.legend.role] : [])))]
1058
+ if (!roles.length) return nothing
1059
+ return html`<details>
1060
+ <summary>${i18next.t('figure.label.data-sample-legend')}</summary>
1061
+ <p hint>${i18next.t('figure.text.data-sample-legend-explained')}</p>
1062
+ ${roles.map(
1063
+ role => html`<label>${role}</label>
1064
+ <textarea
1065
+ .value=${this.legendSample[role] ?? ''}
1066
+ placeholder='{ "CODE": { "color": "#22c55e" }, "default": {} }'
1067
+ @input=${(e: Event) => {
1068
+ this.legendSample = { ...this.legendSample, [role]: (e.target as HTMLTextAreaElement).value }
1069
+ this.dispatchEvent(new CustomEvent('legend-sample', { detail: { legends: this.sampleLegends() }, bubbles: true, composed: true }))
1070
+ }}
1071
+ ></textarea>`
1072
+ )}
1073
+ </details>`
1074
+ }
1075
+
758
1076
  private try(typed: string) {
759
1077
  this.sample = typed
760
1078
  try {
@@ -781,18 +1099,10 @@ function nodeOfKind(kind: FigureDataNode['kind']): FigureDataNode {
781
1099
  return { kind } as FigureDataNode
782
1100
  }
783
1101
 
784
- /** The param a rule starts with when the author switches to it: one result per value, one open interval, a unit scale, or none. */
785
- function firstParamOf(rule: FigureDataBinding['rule'], node: FigureDataNode | undefined, b: FigureDataBinding): FigureDataBinding['param'] {
786
- const keep = b.param?.default
787
- if (rule === 'value') return undefined
788
- if (rule === 'scale') return { scale: 1, offset: 0 }
789
- if (rule === 'range') return { [`${node?.kind === 'number' ? (node.min ?? '') : ''}~`]: keep ?? 0 }
790
- const domain = node ? domainOf(node).map(String) : []
791
- return domain.length ? Object.fromEntries(domain.map(v => [v, keep ?? 0])) : { default: keep ?? 0 }
792
- }
1102
+ /** A value in a sentence: a text as it is, anything else as JSON. */
1103
+ const shownValue = (v: unknown): string => (typeof v === 'string' ? v : JSON.stringify(v ?? ''))
793
1104
 
794
- /** The next interval: from the last one's end, open above; open at both ends when there is none. */
795
- function freeRangeKey(taken: string[]): string {
796
- const end = taken.at(-1)?.split('~')[1] ?? ''
797
- return `${end}~`
1105
+ /** A table row's columns: the key's one or two, a cell per column, and the remove button. */
1106
+ function gridOf(keyCells: number, columns: number): string {
1107
+ return `${'minmax(64px, 1fr) '.repeat(keyCells)}${'minmax(72px, 1fr) '.repeat(columns)}30px`
798
1108
  }