@things-factory/figure-ui 10.1.223 → 10.1.224

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 (59) 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.ts +14 -7
  5. package/client/modeller/figure-capability-panel.ts +145 -1
  6. package/client/modeller/figure-data-panel.ts +495 -188
  7. package/client/modeller/figure-links.ts +130 -12
  8. package/client/modeller/figure-preview.ts +19 -2
  9. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  10. package/client/modeller/links.ts +58 -0
  11. package/client/modeller/refusals.ts +12 -4
  12. package/client/modeller/slot-layout-host.ts +76 -5
  13. package/dist-client/modeller/canvas.js +2 -2
  14. package/dist-client/modeller/canvas.js.map +1 -1
  15. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  16. package/dist-client/modeller/consumer-capabilities.js +60 -0
  17. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  18. package/dist-client/modeller/data-interface.d.ts +83 -17
  19. package/dist-client/modeller/data-interface.js +194 -82
  20. package/dist-client/modeller/data-interface.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +5 -2
  22. package/dist-client/modeller/editor.js +16 -5
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  25. package/dist-client/modeller/figure-capability-panel.js +123 -1
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  28. package/dist-client/modeller/figure-data-panel.js +461 -180
  29. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  30. package/dist-client/modeller/figure-links.d.ts +11 -0
  31. package/dist-client/modeller/figure-links.js +137 -16
  32. package/dist-client/modeller/figure-links.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +4 -0
  34. package/dist-client/modeller/figure-preview.js +19 -1
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/links.d.ts +22 -0
  39. package/dist-client/modeller/links.js +48 -0
  40. package/dist-client/modeller/links.js.map +1 -1
  41. package/dist-client/modeller/refusals.js +14 -6
  42. package/dist-client/modeller/refusals.js.map +1 -1
  43. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  44. package/dist-client/modeller/slot-layout-host.js +68 -6
  45. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +7 -7
  48. package/test/browser/data-table-page.js +9 -0
  49. package/test/browser/links.check.mjs +25 -3
  50. package/test/consumer-capabilities.test.ts +116 -0
  51. package/test/data-motion.test.ts +94 -27
  52. package/test/links.test.ts +15 -0
  53. package/test/slot-layout-host.test.ts +63 -5
  54. package/translations/en.json +81 -23
  55. package/translations/es.json +81 -23
  56. package/translations/ja.json +81 -23
  57. package/translations/ko.json +81 -23
  58. package/translations/ms.json +81 -23
  59. package/translations/zh.json +81 -23
@@ -4,34 +4,42 @@ import { css, html, LitElement, nothing } from 'lit';
4
4
  import { customElement, property, state } from 'lit/decorators.js';
5
5
  import { i18next, localize } from '@operato/i18n';
6
6
  import { FIGURE_UNITS, figureDataExample, inferFigureDataSchema } from '@hatiolab/figure-model';
7
- import { accessorOfRow, behaviorKeyOf, behaviorsOf, bindingDraftOf, domainOf, endlessJointsOf, fieldAt, fieldOfKind, fitsField, partsOf, portDraftOf, rotationStepsOf, sampleOf, schemaRowsOf, schemaWithField, schemaWithFieldReplaced, schemaWithoutField } from './data-interface.js';
7
+ import { accessorOfRow, behaviorKeyOf, behaviorsOf, bindingDraftOf, domainOf, endlessJointsOf, fieldAt, fieldOfKind, fitsField, freeCodeOf, legendRoleOf, methodOf, methodsOf, partsOf, rotationStepsOf, sampleOf, schemaRowsOf, schemaWithField, schemaWithFieldReplaced, schemaWithoutField, schemaFollowingCodes, stateInputsOf, tableColumnsOf, tableDraftOf, tableWithKeys, tableWithLook, tableWithTargets } from './data-interface.js';
8
8
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js';
9
+ import { figureBindingsTakenOver, figureCapabilityDescriptorOf } from '@hatiolab/things-scene';
10
+ import { consumerDescriptors } from './consumer-capabilities.js';
9
11
  import { HelpToggles } from './help.js';
10
12
  const KINDS = ['number', 'boolean', 'text', 'object', 'list'];
11
13
  /**
12
14
  * 데이터 -- the data a site sends this figure, in its own names, and what each value moves (ADR-0051 §8, data binding
13
15
  * design decision 4). The schema is free: fields the author names, a tree of objects and lists. A binding reads one place
14
- * of it and moves one thing the figure can do -- a control, a colour, a motion group's clip, speed or pause, a status
15
- * port, its position, the items in its places -- by the board's rules (value, map, range, scale). Every change is one
16
- * part edit, and whether it holds is the kernel's check.
16
+ * 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
17
+ * group's clip, speed or pause, its position, the items in its places; by a code table or a range table, each value's
18
+ * look goes to several targets at once -- parts' colour and effect, controls' values, a motion group. The field's kind
19
+ * says which methods there are. Every change is one part edit, and whether it holds is the kernel's check.
17
20
  *
18
21
  * The sample is not saved: data tried here, read the one way a placed figure reads it, and -- in the preview -- the figure
19
- * running on it (`data-sample`).
22
+ * running on it (`data-sample`); a sample legend pasted for a table's role shows it as a board's legend would
23
+ * (`legend-sample`).
20
24
  */
21
25
  let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement) {
22
26
  constructor() {
23
27
  super(...arguments);
24
28
  this.help = new HelpToggles(this);
25
29
  this.locked = false;
30
+ /** The part chosen in the modeller, put among a table's parts by one button. */
31
+ this.selected = '';
26
32
  /** The schema row chosen, by its path; its field is edited under it. */
27
33
  this.chosen = '';
28
34
  this.adding = { name: '', kind: 'number' };
29
35
  /** A site's data pasted to start the schema from; nothing is kept until the author says so. */
30
36
  this.pasted = '';
31
- this.connecting = { path: '', behavior: '' };
37
+ /** A binding as it is being added: the field (or a new field's name), the method, the behavior or the table's targets. */
38
+ this.connecting = { path: '', name: '', method: '', behavior: '', parts: [], inputs: [], group: '' };
32
39
  /** The binding opened to edit its rule, by id. */
33
40
  this.opened = '';
34
- this.porting = '';
41
+ /** Sample legends by role, as JSON typed; not saved. */
42
+ this.legendSample = {};
35
43
  /** The rotation card as filled in: the joint, and the fields for on, speed (and its most rpm) and direction. */
36
44
  this.rotating = { input: '', on: '', speed: '', max: 0, direction: '', forward: '', reverse: '' };
37
45
  /** The sample as typed, JSON; empty until edited, the schema's example shown until then. */
@@ -116,6 +124,17 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
116
124
  gap: 4px 6px;
117
125
  align-items: center;
118
126
  }
127
+ div[codes] {
128
+ display: grid;
129
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 30px;
130
+ gap: 4px 6px;
131
+ align-items: center;
132
+ }
133
+ div[codes] button[act] {
134
+ width: 30px;
135
+ margin: 0;
136
+ padding: 0;
137
+ }
119
138
  div[ranges] {
120
139
  display: grid;
121
140
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) 30px;
@@ -127,6 +146,54 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
127
146
  margin: 0;
128
147
  padding: 0;
129
148
  }
149
+ /* A table's rows: the key (a code, or from and to), a cell per column, and a remove button. */
150
+ div[looks] {
151
+ display: grid;
152
+ gap: 4px 6px;
153
+ align-items: center;
154
+ overflow-x: auto;
155
+ }
156
+ div[looks] > small {
157
+ color: var(--md-sys-color-on-surface-variant);
158
+ }
159
+ div[looks] input,
160
+ div[looks] select {
161
+ min-width: 0;
162
+ width: 100%;
163
+ box-sizing: border-box;
164
+ }
165
+ div[looks] button[act] {
166
+ width: 30px;
167
+ margin: 0;
168
+ padding: 0;
169
+ }
170
+ /* A colour and the button that clears it, on one line at the height of the cells beside it. */
171
+ div[looks] span[colour] {
172
+ display: flex;
173
+ gap: 2px;
174
+ align-items: center;
175
+ min-width: 0;
176
+ }
177
+ div[looks] span[colour] input[type='color'] {
178
+ flex: 1;
179
+ height: var(--figure-control-height);
180
+ padding: 0 2px;
181
+ }
182
+ div[looks] span[colour] button[act] {
183
+ flex: none;
184
+ width: 24px;
185
+ }
186
+ div[chips] {
187
+ display: flex;
188
+ flex-wrap: wrap;
189
+ gap: 4px 10px;
190
+ align-items: center;
191
+ }
192
+ div[chips] label {
193
+ display: inline-flex;
194
+ gap: 4px;
195
+ align-items: center;
196
+ }
130
197
  textarea {
131
198
  width: 100%;
132
199
  min-height: 96px;
@@ -151,9 +218,19 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
151
218
  return nothing;
152
219
  const rows = schemaRowsOf(asset);
153
220
  return html `
154
- ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${this.renderPorts(asset)} ${rows.length ? this.renderSample(asset) : nothing}
221
+ ${this.renderCapabilityData(asset)} ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${rows.length ? this.renderSample(asset) : nothing}
155
222
  `;
156
223
  }
224
+ /** The data a product's capability the figure wears reads itself (ADR-0167 14): said, read only. */
225
+ renderCapabilityData(asset) {
226
+ const names = asset.document.capabilities.names ?? [];
227
+ const reading = consumerDescriptors().filter(d => names.includes(d.name) && d.data?.length);
228
+ if (!reading.length)
229
+ return nothing;
230
+ return html `<section>
231
+ ${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>`)}
232
+ </section>`;
233
+ }
157
234
  /* ---------------------------------------------------------------- the schema */
158
235
  renderSchema(asset, rows) {
159
236
  const schema = asset.dataInterface?.schema ?? [];
@@ -346,141 +423,309 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
346
423
  const name = b.kind === 'pose' ? i18next.t('figure.label.data-axis-' + b.axis) : (choice?.name ?? key.slice(key.indexOf(':') + 1));
347
424
  return i18next.t('figure.label.data-behavior-' + b.kind, { name });
348
425
  }
426
+ /** What a table shows on, as one line: its parts, the controls it sets, its motion group. */
427
+ targetsName(asset, b) {
428
+ const inputs = stateInputsOf(asset);
429
+ const group = (asset.motionGroups ?? []).find(g => g.id === b.targets.group);
430
+ return [
431
+ ...(b.targets.parts ?? []),
432
+ ...(b.targets.inputs ?? []).map(id => inputs.find(i => i.id === id)?.label ?? id),
433
+ ...(group ? [group.label] : [])
434
+ ].join(', ');
435
+ }
349
436
  renderBindings(asset, rows) {
350
437
  const bindings = asset.dataInterface?.bindings ?? [];
351
438
  const choices = behaviorsOf(asset);
439
+ const resting = figureBindingsTakenOver(asset);
352
440
  return html `<section>
353
441
  <h3>${i18next.t('figure.label.data-bindings')}${this.help.button('figure.text.data-bindings-explained')}</h3>
354
442
  ${this.help.hint('figure.text.data-bindings-explained')}
355
- ${bindings.map(b => this.renderBinding(asset, choices, b))}
356
- ${rows.length ? this.renderAddBinding(asset, rows, choices) : html `<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
443
+ ${bindings.map(b => this.renderBinding(asset, choices, b, resting))}
444
+ ${this.renderAddBinding(asset, rows, choices)}
357
445
  ${this.renderRotation(asset, rows)}
358
446
  </section>`;
359
447
  }
360
- renderBinding(asset, choices, b) {
448
+ renderBinding(asset, choices, b, resting) {
361
449
  const open = this.opened === b.id;
362
- const set = (patch) => {
363
- const next = { ...b, ...patch };
364
- if (patch.param === undefined && 'param' in patch)
365
- delete next.param;
366
- this.edit({ kind: 'set-data-binding', binding: next });
367
- };
450
+ const set = (next) => this.edit({ kind: 'set-data-binding', binding: next });
451
+ const what = b.rule === 'table' ? this.targetsName(asset, b) : this.behaviorName(asset, choices, b.behavior);
368
452
  return html `<section item>
369
453
  <div head>
370
454
  <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
371
- <strong>${b.accessor}</strong> → ${this.behaviorName(asset, choices, b.behavior)} <small>· ${i18next.t('figure.label.data-rule-' + b.rule)}</small>
455
+ <strong>${b.label ?? b.accessor}</strong> → ${what} <small>· ${i18next.t('figure.label.data-method-' + methodOf(b))}</small>
372
456
  </button>
373
457
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
374
458
  <md-icon>close</md-icon>
375
459
  </button>
376
460
  </div>
377
- ${open ? this.renderBindingDetail(asset, b, set) : nothing}
461
+ ${resting.filter(r => r.id === b.id).map(r => this.renderResting(r))}
462
+ ${open ? (b.rule === 'table' ? this.renderTable(asset, b, set) : this.renderValueDetail(asset, b, set)) : nothing}
378
463
  </section>`;
379
464
  }
380
- renderBindingDetail(asset, b, set) {
465
+ /**
466
+ * A binding a product's capability takes over where the product registers it (ADR-0167 17): there it rests, whole or for
467
+ * the targets named; elsewhere (a board without the product) it works.
468
+ */
469
+ renderResting(r) {
470
+ const where = r.capability.split('.')[0];
471
+ const name = figureCapabilityDescriptorOf(r.capability)?.label ?? r.capability;
472
+ const targets = [...r.parts, ...r.inputs].join(', ');
473
+ 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>`;
474
+ }
475
+ renderValueDetail(asset, b, set) {
381
476
  const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'));
382
477
  const value = (e) => e.target.value;
383
478
  const behavior = b.behavior;
479
+ const patch = (p) => {
480
+ const next = { ...b, ...p };
481
+ if ('param' in p && p.param === undefined)
482
+ delete next.param;
483
+ set(next);
484
+ };
384
485
  if (behavior.kind === 'items')
385
- return this.renderItemFields(asset, b, node, set);
486
+ return this.renderItemFields(asset, b, node, patch);
386
487
  return html `<div form>
387
- <span>${i18next.t('figure.label.data-rule')}</span>
388
- <select ?disabled=${this.locked} @change=${(e) => set({ rule: value(e), param: firstParamOf(value(e), node, b) })}>
389
- ${['value', 'map', 'range', 'scale'].map(r => html `<option value=${r} ?selected=${r === b.rule}>${i18next.t('figure.label.data-rule-' + r)}</option>`)}
390
- </select>
391
- ${behavior.kind === 'state'
488
+ <span>${i18next.t('figure.label.data-method')}</span>
489
+ ${node?.kind === 'number'
490
+ ? html `<select ?disabled=${this.locked} @change=${(e) => patch({ rule: value(e), param: value(e) === 'scale' ? { scale: 1, offset: 0 } : undefined })}>
491
+ ${['value', 'scale'].map(r => html `<option value=${r} ?selected=${r === b.rule}>${i18next.t('figure.label.data-method-' + r)}</option>`)}
492
+ </select>`
493
+ : html `<span>${i18next.t('figure.label.data-method-value')}</span>`}
494
+ ${behavior.kind === 'state'
392
495
  ? html `<span>${i18next.t('figure.label.data-source-mode')}</span>
393
- <select ?disabled=${this.locked} @change=${(e) => set({ behavior: { ...behavior, sourceMode: value(e) } })}>
394
- ${['observed', 'commanded'].map(m => html `<option value=${m} ?selected=${behavior.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
395
- </select>`
496
+ <select ?disabled=${this.locked} @change=${(e) => patch({ behavior: { ...behavior, sourceMode: value(e) } })}>
497
+ ${['observed', 'commanded'].map(m => html `<option value=${m} ?selected=${behavior.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
498
+ </select>`
396
499
  : nothing}
397
- ${behavior.kind === 'pose'
500
+ ${behavior.kind === 'pose'
398
501
  ? html `<span>${i18next.t('figure.label.data-seconds')}</span>
399
- <input type="number" min="0" step="any" .value=${String(behavior.seconds ?? 0)} ?disabled=${this.locked} @change=${(e) => set({ behavior: { ...behavior, seconds: e.target.valueAsNumber || 0 } })} />`
502
+ <input type="number" min="0" step="any" .value=${String(behavior.seconds ?? 0)} ?disabled=${this.locked} @change=${(e) => patch({ behavior: { ...behavior, seconds: e.target.valueAsNumber || 0 } })} />`
400
503
  : nothing}
401
- ${b.rule === 'scale'
504
+ ${b.rule === 'scale'
402
505
  ? html `<span>${i18next.t('figure.label.data-scale')}</span>
403
- <input type="number" step="any" .value=${String(b.param?.scale ?? 1)} ?disabled=${this.locked} @change=${(e) => set({ param: { ...b.param, scale: e.target.valueAsNumber } })} />
404
- <span>${i18next.t('figure.label.data-offset')}</span>
405
- <input type="number" step="any" .value=${String(b.param?.offset ?? 0)} ?disabled=${this.locked} @change=${(e) => set({ param: { ...b.param, offset: e.target.valueAsNumber } })} />`
506
+ <input type="number" step="any" .value=${String(b.param?.scale ?? 1)} ?disabled=${this.locked} @change=${(e) => patch({ param: { ...b.param, scale: e.target.valueAsNumber } })} />
507
+ <span>${i18next.t('figure.label.data-offset')}</span>
508
+ <input type="number" step="any" .value=${String(b.param?.offset ?? 0)} ?disabled=${this.locked} @change=${(e) => patch({ param: { ...b.param, offset: e.target.valueAsNumber } })} />`
406
509
  : nothing}
510
+ </div>`;
511
+ }
512
+ /**
513
+ * A table (ADR-0166 10): what it shows on -- parts, controls, a motion group -- its rows, a code or an interval each
514
+ * with a cell per column, the look of every other value, and whether a board may swap it for a legend.
515
+ */
516
+ renderTable(asset, b, set) {
517
+ const value = (e) => e.target.value;
518
+ const parts = partsOf(asset);
519
+ const inputs = stateInputsOf(asset);
520
+ const groups = asset.motionGroups ?? [];
521
+ const targets = b.targets;
522
+ const toggle = (list, id, on) => (on ? [...(list ?? []), id] : (list ?? []).filter(x => x !== id));
523
+ const retarget = (t) => set(tableWithTargets(b, t));
524
+ return html `<div form>
525
+ <span>${i18next.t('figure.label.data-label')}</span>
526
+ <input .value=${b.label ?? ''} placeholder=${b.accessor} ?disabled=${this.locked} @change=${(e) => {
527
+ const label = value(e).trim();
528
+ const next = { ...b, label };
529
+ if (!label)
530
+ delete next.label;
531
+ set(next);
532
+ }} />
407
533
  </div>
408
- ${b.rule === 'map' ? this.renderCases(asset, b, node, set) : b.rule === 'range' ? this.renderRanges(asset, b, set) : nothing}`;
534
+ ${this.renderTargets(parts, inputs, groups, targets, {
535
+ part: (id, on) => retarget({ ...targets, parts: toggle(targets.parts, id, on) }),
536
+ input: (id, on) => retarget({ ...targets, inputs: toggle(targets.inputs, id, on) }),
537
+ group: id => retarget({ ...targets, group: id || undefined })
538
+ })}
539
+ ${b.lookup === 'code' ? this.renderCodeRows(asset, b, set) : this.renderRangeRows(asset, b, set)}
540
+ <label toggle>
541
+ <input type="checkbox" .checked=${!!b.legend} ?disabled=${this.locked} @change=${(e) => {
542
+ const next = { ...b };
543
+ if (e.target.checked)
544
+ next.legend = { role: legendRoleOf(b.id) };
545
+ else
546
+ delete next.legend;
547
+ set(next);
548
+ }} />
549
+ ${i18next.t('figure.label.data-legend-swappable')}
550
+ </label>
551
+ ${b.legend
552
+ ? html `<div form>
553
+ <span>${i18next.t('figure.label.data-legend-role')}</span>
554
+ <input .value=${b.legend.role} ?disabled=${this.locked} @change=${(e) => set({ ...b, legend: { role: value(e).trim() } })} />
555
+ </div>
556
+ <p hint>${i18next.t('figure.text.data-legend-explained')}</p>`
557
+ : nothing}`;
409
558
  }
410
- /** A result editor fitting what the behavior takes: a colour, a clip of the group, true or false, a code, a number. */
411
- renderResult(asset, b, value, write) {
412
- const behavior = b.behavior;
559
+ /** Which parts, controls and motion group a table shows on: chips to tick, and the part chosen in the modeller by one button. */
560
+ renderTargets(parts, inputs, groups, t, on) {
561
+ const chosen = (e) => e.target.checked;
562
+ return html `<div form>
563
+ <span>${i18next.t('figure.label.data-target-parts')}</span>
564
+ <div chips>
565
+ ${parts.map(p => html `<label><input type="checkbox" .checked=${!!t.parts?.includes(p)} ?disabled=${this.locked} @change=${(e) => on.part(p, chosen(e))} />${p}</label>`)}
566
+ ${this.selected && parts.includes(this.selected) && !t.parts?.includes(this.selected)
567
+ ? 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>`
568
+ : nothing}
569
+ </div>
570
+ ${inputs.length
571
+ ? html `<span>${i18next.t('figure.label.data-target-inputs')}</span>
572
+ <div chips>
573
+ ${inputs.map(i => html `<label><input type="checkbox" .checked=${!!t.inputs?.includes(i.id)} ?disabled=${this.locked} @change=${(e) => on.input(i.id, chosen(e))} />${i.label}</label>`)}
574
+ </div>`
575
+ : nothing}
576
+ ${groups.length
577
+ ? html `<span>${i18next.t('figure.label.data-target-group')}</span>
578
+ <select ?disabled=${this.locked} @change=${(e) => on.group(e.target.value)}>
579
+ <option value="" ?selected=${!t.group}>${i18next.t('figure.label.data-none')}</option>
580
+ ${groups.map(g => html `<option value=${g.id} ?selected=${g.id === t.group}>${g.label}</option>`)}
581
+ </select>`
582
+ : nothing}
583
+ </div>`;
584
+ }
585
+ /** The column heads of a table's rows, after the key's own one or two. */
586
+ columnHeads(columns) {
587
+ return columns.map(c => c.kind === 'value'
588
+ ? html `<small>${c.label}${c.unit ? ` (${c.unit}${c.min !== undefined && c.max !== undefined ? ` ${c.min}~${c.max}` : ''})` : ''}</small>`
589
+ : html `<small>${i18next.t('figure.label.data-column-' + c.kind)}</small>`);
590
+ }
591
+ /** 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. */
592
+ lookCells(asset, columns, look, write) {
593
+ const without = (key) => {
594
+ const next = { ...look };
595
+ delete next[key];
596
+ return next;
597
+ };
413
598
  const typed = (e) => e.target.value;
414
- if (behavior.kind === 'palette')
415
- /* A colour not set is not black: it is offered, started at the colour the token has now. */
416
- return value === undefined
417
- ? html `<button act ?disabled=${this.locked} @click=${() => write(asset.palette[behavior.token] ?? '#000000')}>${i18next.t('figure.button.data-set-result')}</button>`
418
- : html `<input type="color" .value=${String(value)} ?disabled=${this.locked} @change=${(e) => write(typed(e))} />`;
419
- if (behavior.kind === 'motion-clip') {
420
- const clips = (asset.motionGroups ?? []).find(g => g.id === behavior.group)?.clips ?? [];
421
- return html `<select ?disabled=${this.locked} @change=${(e) => write(typed(e))}>
422
- ${clips.map(clip => html `<option value=${clip} ?selected=${clip === value}>${clip}</option>`)}
423
- </select>`;
424
- }
425
- if (behavior.kind === 'motion-paused')
426
- return html `<select ?disabled=${this.locked} @change=${(e) => write(typed(e) === 'true')}>
427
- ${[false, true].map(v => html `<option value=${String(v)} ?selected=${v === value}>${i18next.t(v ? 'figure.label.data-paused' : 'figure.label.data-playing')}</option>`)}
599
+ return columns.map(c => {
600
+ if (c.kind === 'color')
601
+ return look.color === undefined
602
+ ? html `<button act ?disabled=${this.locked} @click=${() => write({ ...look, color: '#888888' })}>${i18next.t('figure.button.data-set-result')}</button>`
603
+ : html `<span colour><input type="color" .value=${look.color} ?disabled=${this.locked} @change=${(e) => 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>`;
604
+ if (c.kind === 'effect')
605
+ return html `<select ?disabled=${this.locked} @change=${(e) => (typed(e) ? write({ ...look, effect: { kind: typed(e) } }) : write(without('effect')))}>
606
+ ${['', 'blink', 'glow'].map(k => html `<option value=${k} ?selected=${(look.effect?.kind ?? '') === k}>${i18next.t('figure.label.data-effect-' + (k || 'none'))}</option>`)}
607
+ </select>`;
608
+ if (c.kind === 'value') {
609
+ const v = look.values?.[c.id];
610
+ return html `<input type="number" step="any" min=${c.min ?? ''} max=${c.max ?? ''} .value=${v === undefined ? '' : String(v)} ?disabled=${this.locked} @change=${(e) => {
611
+ const n = e.target.valueAsNumber;
612
+ const values = { ...look.values };
613
+ if (Number.isNaN(n))
614
+ delete values[c.id];
615
+ else
616
+ values[c.id] = n;
617
+ write(Object.keys(values).length ? { ...look, values } : without('values'));
618
+ }} />`;
619
+ }
620
+ if (c.kind === 'clip')
621
+ return html `<select ?disabled=${this.locked} @change=${(e) => (typed(e) ? write({ ...look, clip: typed(e) }) : write(without('clip')))}>
622
+ <option value="" ?selected=${look.clip === undefined}>-</option>
623
+ ${c.clips.map(clip => html `<option value=${clip} ?selected=${clip === look.clip}>${clip}</option>`)}
624
+ </select>`;
625
+ if (c.kind === 'speed')
626
+ return html `<input type="number" min="0" max="3" step="any" .value=${look.speed === undefined ? '' : String(look.speed)} ?disabled=${this.locked} @change=${(e) => {
627
+ const n = e.target.valueAsNumber;
628
+ write(Number.isNaN(n) ? without('speed') : { ...look, speed: n });
629
+ }} />`;
630
+ return html `<select ?disabled=${this.locked} @change=${(e) => (typed(e) ? write({ ...look, paused: typed(e) === 'true' }) : write(without('paused')))}>
631
+ <option value="" ?selected=${look.paused === undefined}>-</option>
632
+ ${[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>`)}
428
633
  </select>`;
429
- if (behavior.kind === 'status')
430
- return html `<input .value=${String(value ?? '')} ?disabled=${this.locked} @change=${(e) => write(typed(e))} />`;
431
- return html `<input type="number" step="any" .value=${value === undefined ? '' : String(value)} ?disabled=${this.locked} @change=${(e) => write(e.target.valueAsNumber)} />`;
634
+ });
635
+ }
636
+ /** 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. */
637
+ renderOtherwise(asset, b, columns, needed, set, keyCells) {
638
+ const look = b.table.default;
639
+ if (look === undefined)
640
+ 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>`;
641
+ const { default: _, ...rest } = b.table;
642
+ return html `<div looks style="grid-template-columns: ${gridOf(keyCells, columns.length)}">
643
+ <span style="grid-column: span ${keyCells}">${i18next.t('figure.label.data-otherwise')}</span>
644
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, 'default', l)))}
645
+ ${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>close</md-icon></button>`}
646
+ </div>`;
432
647
  }
433
- /** A result per value of a closed field, and one for every other value. */
434
- renderCases(asset, b, node, set) {
435
- const param = b.param ?? {};
648
+ /**
649
+ * A code table's rows: a code each, typed, with its look. A closed text's values follow the codes (ADR-0166 risk 3):
650
+ * a code added, renamed or taken away is the field's value too; true and false are both rows.
651
+ */
652
+ renderCodeRows(asset, b, set) {
653
+ const columns = tableColumnsOf(asset, b);
654
+ const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'));
655
+ const boolean = node?.kind === 'boolean';
656
+ const rows = Object.entries(b.table).filter(([k]) => k !== 'default');
436
657
  const domain = node ? domainOf(node).map(String) : [];
437
- const keys = [...new Set([...domain, ...Object.keys(param).filter(k => k !== 'default')])];
438
- const write = (key) => (v) => {
439
- const next = { ...param };
440
- if (v === undefined || (typeof v === 'number' && Number.isNaN(v)))
441
- delete next[key];
442
- else
443
- next[key] = v;
444
- set({ param: next });
658
+ const covered = domain.length > 0 && domain.every(v => b.table[v] !== undefined);
659
+ /* The binding and the field it reads, in one undo step. */
660
+ const rekeyed = (list) => {
661
+ const next = tableWithKeys(b, list);
662
+ const schema = boolean ? undefined : schemaFollowingCodes(asset, next, list.map(([k]) => k));
663
+ this.edit({ kind: 'set-data-binding', binding: next, ...(schema ? { schema } : {}) });
445
664
  };
446
- const word = (k) => (node?.kind === 'boolean' ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k);
447
- /* Every value of a closed field has its own result: no other value comes, unless one was set before. */
448
- const covered = domain.length > 0 && domain.every(v => param[v] !== undefined);
449
- return html `<div cases>
450
- ${keys.map(k => html `<span>${word(k)}</span>${this.renderResult(asset, b, param[k], write(k))}`)}
451
- ${covered && param.default === undefined ? nothing : html `<span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, write('default'))}`}
452
- </div>`;
665
+ const rekey = (i, typed) => {
666
+ const key = typed.trim();
667
+ if (!key || key === 'default' || rows.some(([k], j) => j !== i && k === key))
668
+ return this.requestUpdate();
669
+ rekeyed(rows.map(([k, v], j) => (j === i ? [key, v] : [k, v])));
670
+ };
671
+ const word = (k) => (boolean ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k);
672
+ return html `<div looks style="grid-template-columns: ${gridOf(1, columns.length)}">
673
+ <small>${i18next.t('figure.label.data-case-value')}</small>${this.columnHeads(columns)}<span></span>
674
+ ${rows.map(([k, look], i) => html `${boolean
675
+ ? html `<span>${word(k)}</span>`
676
+ : html `<input .value=${k} aria-label=${i18next.t('figure.label.data-case-value')} ?disabled=${this.locked} @change=${(e) => rekey(i, e.target.value)} />`}
677
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
678
+ ${boolean || rows.length < 2 && domain.length > 0
679
+ ? html `<span></span>`
680
+ : html `<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => rekeyed(rows.filter((_, j) => j !== i))}><md-icon>close</md-icon></button>`}`)}
681
+ </div>
682
+ ${this.renderOtherwise(asset, b, columns, domain.length > 0 && !covered, set, 1)}
683
+ ${boolean
684
+ ? nothing
685
+ : html `<button act ?disabled=${this.locked} @click=${() => rekeyed([...rows, [freeCodeOf(rows.map(([k]) => k)), {}]])}>
686
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-case')}
687
+ </button>`}`;
453
688
  }
454
- /** Intervals of a number, each from (inclusive) to (exclusive), either end left open, and one result for the rest. */
455
- renderRanges(asset, b, set) {
456
- const param = b.param ?? {};
457
- const entries = Object.entries(param).filter(([k]) => k !== 'default');
458
- const rebuild = (list) => set({ param: { ...Object.fromEntries(list), ...(param.default !== undefined ? { default: param.default } : {}) } });
689
+ /**
690
+ * A range table's rows: from (inclusive) and to (exclusive), either end open, with a look each. Giving the last row,
691
+ * open above, an end starts the next row there; a row open above is the last there can be.
692
+ */
693
+ renderRangeRows(asset, b, set) {
694
+ const columns = tableColumnsOf(asset, b);
695
+ const rows = Object.entries(b.table).filter(([k]) => k !== 'default');
459
696
  const ends = (key) => {
460
697
  const [from = '', to = ''] = key.split('~');
461
698
  return { from, to };
462
699
  };
463
- const rekey = (i, end, typed) => rebuild(entries.map(([k, v], j) => {
464
- if (j !== i)
465
- return [k, v];
466
- const e = ends(k);
467
- const next = { ...e, [end]: typed.trim() };
468
- return [`${next.from}~${next.to}`, v];
469
- }));
470
- return html `<div ranges>
471
- <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small><small></small><span></span>
472
- ${entries.map(([k, v], i) => {
700
+ const rekey = (i, end, typed) => {
701
+ const was = ends(rows[i][0]);
702
+ const next = { ...was, [end]: typed.trim() };
703
+ const key = `${next.from}~${next.to}`;
704
+ /* A key another row has would take that row's place: not taken. */
705
+ if (rows.some(([k], j) => j !== i && k === key))
706
+ return this.requestUpdate();
707
+ const list = rows.map(([k, v], j) => (j === i ? [key, v] : [k, v]));
708
+ /* The last row, open above, given an end: the next row starts there, open above, so every value still has a row. */
709
+ if (i === rows.length - 1 && end === 'to' && was.to === '' && next.to !== '')
710
+ list.push([`${next.to}~`, {}]);
711
+ set(tableWithKeys(b, list));
712
+ };
713
+ const last = rows.at(-1);
714
+ const lastEnd = last ? ends(last[0]).to : '';
715
+ const open = !!last && lastEnd === '';
716
+ return html `<div looks style="grid-template-columns: ${gridOf(2, columns.length)}">
717
+ <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small>${this.columnHeads(columns)}<span></span>
718
+ ${rows.map(([k, look], i) => {
473
719
  const e = ends(k);
474
720
  return html `<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev) => rekey(i, 'from', ev.target.value)} />
475
721
  <input type="number" step="any" .value=${e.to} ?disabled=${this.locked} @change=${(ev) => rekey(i, 'to', ev.target.value)} />
476
- ${this.renderResult(asset, b, v, out => rebuild(entries.map(([kk, vv], j) => [kk, j === i ? out : vv])))}
477
- <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>`;
722
+ ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
723
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => set(tableWithKeys(b, rows.filter((_, j) => j !== i)))}><md-icon>close</md-icon></button>`;
478
724
  })}
479
725
  </div>
480
- <div cases>
481
- <span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, v => set({ param: { ...param, ...(v === undefined ? {} : { default: v }) } }))}
482
- </div>
483
- <button act ?disabled=${this.locked} @click=${() => rebuild([...entries, [freeRangeKey(entries.map(([k]) => k)), entries.at(-1)?.[1] ?? param.default ?? 0]])}>
726
+ ${this.renderOtherwise(asset, b, columns, false, set, 2)}
727
+ ${open ? html `<p hint>${i18next.t('figure.text.data-range-close-last')}</p>` : nothing}
728
+ <button act ?disabled=${this.locked || open} @click=${() => set(tableWithKeys(b, [...rows, [`${lastEnd}~`, {}]]))}>
484
729
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-range')}
485
730
  </button>`;
486
731
  }
@@ -517,36 +762,74 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
517
762
  }} />
518
763
  </div>`;
519
764
  }
765
+ /**
766
+ * 「연결 추가」 (ADR-0166 10): a field there or a new name, then a method the field's kind allows. As it is or scaled,
767
+ * the one thing it moves; a table, the parts, controls and motion group it shows on -- a new name makes the field.
768
+ */
520
769
  renderAddBinding(asset, rows, choices) {
521
770
  const bindable = rows.filter(r => r.node.kind !== 'object');
522
- if (!bindable.length)
523
- return nothing;
524
771
  const a = this.connecting;
525
- const row = bindable.find(r => r.path === a.path) ?? bindable[0];
526
- const fitting = choices.filter(c => fitsField(row.node, c));
772
+ /* 「새 필드」: a value no field path can have (a path is names and `[]`). */
773
+ const NEW = '+';
774
+ const row = a.path === NEW || !bindable.length ? null : (bindable.find(r => r.path === a.path) ?? bindable[0]);
775
+ const methods = methodsOf(row?.node ?? null, choices);
776
+ const method = methods.includes(a.method) ? a.method : methods[0];
777
+ const table = method === 'code' || method === 'ranges';
778
+ const fitting = row && !table ? choices.filter(c => fitsField(row.node, c)) : [];
527
779
  const choice = fitting.find(c => c.key === a.behavior) ?? fitting[0];
528
- /* A list is read whole by items; one value of it, through its first element. */
529
- const accessor = row.node.kind === 'list' && choice?.kind === 'items' ? row.path.replace(/\[\]$/, '') : accessorOfRow(row.path);
780
+ const set = (patch) => (this.connecting = { ...a, ...patch });
781
+ const typed = (e) => e.target.value;
782
+ const name = a.name.trim();
783
+ const targets = { parts: a.parts, inputs: a.inputs, ...(a.group ? { group: a.group } : {}) };
784
+ const aimed = a.parts.length > 0 || a.inputs.length > 0 || !!a.group;
785
+ const ready = table ? aimed && (row !== null || /^[A-Za-z_][\w-]*$/.test(name)) : !!choice && !!row;
786
+ const add = () => {
787
+ if (table) {
788
+ const { binding, schema } = tableDraftOf(asset, row ? { path: row.path, node: row.node } : { name }, method, targets);
789
+ this.edit({ kind: 'set-data-binding', binding, ...(schema ? { schema } : {}) });
790
+ this.opened = binding.id;
791
+ }
792
+ else {
793
+ /* A list is read whole by items; one value of it, through its first element. */
794
+ const accessor = row.node.kind === 'list' && choice.kind === 'items' ? row.path.replace(/\[\]$/, '') : accessorOfRow(row.path);
795
+ const binding = bindingDraftOf(asset, accessor, row.node, choice, method);
796
+ this.edit({ kind: 'set-data-binding', binding });
797
+ this.opened = binding.id;
798
+ }
799
+ this.connecting = { path: '', name: '', method: '', behavior: '', parts: [], inputs: [], group: '' };
800
+ };
801
+ const toggle = (list, id, on) => (on ? [...list, id] : list.filter(x => x !== id));
530
802
  return html `<div form>
531
803
  <span>${i18next.t('figure.label.data-field')}</span>
532
- <select @change=${(e) => (this.connecting = { ...a, path: e.target.value })}>
804
+ <select @change=${(e) => set({ path: typed(e), name: '' })}>
533
805
  ${bindable.map(r => html `<option value=${r.path} ?selected=${r === row}>${r.path}</option>`)}
806
+ <option value=${NEW} ?selected=${row === null}>${i18next.t('figure.label.data-new-field')}</option>
534
807
  </select>
535
- <span>${i18next.t('figure.label.data-behavior')}</span>
536
- <select ?disabled=${!fitting.length} @change=${(e) => (this.connecting = { ...a, behavior: e.target.value })}>
537
- ${fitting.map(c => html `<option value=${c.key} ?selected=${c === choice}>${this.behaviorName(asset, choices, c.behavior)}</option>`)}
808
+ ${row === null
809
+ ? html `<span>${i18next.t('figure.label.data-name')}</span>
810
+ <input .value=${a.name} @input=${(e) => set({ name: typed(e) })} />`
811
+ : nothing}
812
+ <span>${i18next.t('figure.label.data-method')}</span>
813
+ <select ?disabled=${!methods.length} @change=${(e) => set({ method: typed(e) })}>
814
+ ${methods.map(m => html `<option value=${m} ?selected=${m === method}>${i18next.t('figure.label.data-method-' + m)}</option>`)}
538
815
  </select>
816
+ ${!table && fitting.length
817
+ ? html `<span>${i18next.t('figure.label.data-behavior')}</span>
818
+ <select @change=${(e) => set({ behavior: typed(e) })}>
819
+ ${fitting.map(c => html `<option value=${c.key} ?selected=${c === choice}>${this.behaviorName(asset, choices, c.behavior)}</option>`)}
820
+ </select>`
821
+ : nothing}
539
822
  </div>
540
- ${fitting.length ? nothing : html `<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
541
- <button
542
- act
543
- ?disabled=${this.locked || !choice}
544
- @click=${() => {
545
- const binding = bindingDraftOf(asset, accessor, row.node, choice);
546
- this.edit({ kind: 'set-data-binding', binding });
547
- this.opened = binding.id;
548
- }}
549
- >
823
+ ${table
824
+ ? this.renderTargets(partsOf(asset), stateInputsOf(asset), asset.motionGroups ?? [], targets, {
825
+ part: (id, on) => set({ parts: toggle(a.parts, id, on) }),
826
+ input: (id, on) => set({ inputs: toggle(a.inputs, id, on) }),
827
+ group: id => set({ group: id })
828
+ })
829
+ : nothing}
830
+ ${table && !aimed ? html `<p hint>${i18next.t('figure.text.data-table-needs-target')}</p>` : nothing}
831
+ ${methods.length ? nothing : html `<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
832
+ <button act ?disabled=${this.locked || !ready} @click=${add}>
550
833
  <md-icon>add</md-icon>${i18next.t('figure.button.add-data-binding')}
551
834
  </button>`;
552
835
  }
@@ -624,52 +907,6 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
624
907
  </button>
625
908
  </details>`;
626
909
  }
627
- /* ---------------------------------------------------------------- status ports */
628
- renderPorts(asset) {
629
- const ports = asset.dataInterface?.ports ?? [];
630
- const tokens = Object.keys(asset.palette);
631
- const parts = partsOf(asset);
632
- const set = (port, patch) => this.edit({ kind: 'set-status-port', port: { ...port, ...patch } });
633
- const chosen = (e) => [...e.target.selectedOptions].map(o => o.value);
634
- return html `<section>
635
- <h3>${i18next.t('figure.label.data-ports')}${this.help.button('figure.text.data-ports-explained')}</h3>
636
- ${this.help.hint('figure.text.data-ports-explained')}
637
- ${ports.map(port => html `<section item>
638
- <div head>
639
- <strong>${port.label}</strong>
640
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-port')} @click=${() => this.edit({ kind: 'remove-status-port', id: port.id })}>
641
- <md-icon>close</md-icon>
642
- </button>
643
- </div>
644
- <div form>
645
- <span>${i18next.t('figure.label.data-label')}</span>
646
- <input .value=${port.label} ?disabled=${this.locked} @change=${(e) => set(port, { label: e.target.value.trim() })} />
647
- <span>${i18next.t('figure.label.data-port-tokens')}</span>
648
- <select multiple size=${Math.min(4, Math.max(tokens.length, 1))} ?disabled=${this.locked} @change=${(e) => set(port, { tokens: chosen(e) })}>
649
- ${tokens.map(t => html `<option value=${t} ?selected=${port.tokens.includes(t)}>${t}</option>`)}
650
- </select>
651
- <span>${i18next.t('figure.label.data-port-parts')}</span>
652
- <select multiple size=${Math.min(4, Math.max(parts.length, 1))} ?disabled=${this.locked} @change=${(e) => set(port, { parts: chosen(e) })}>
653
- ${parts.map(p => html `<option value=${p} ?selected=${port.parts.includes(p)}>${p}</option>`)}
654
- </select>
655
- </div>
656
- </section>`)}
657
- <div form>
658
- <span>${i18next.t('figure.label.data-label')}</span>
659
- <input .value=${this.porting} @input=${(e) => (this.porting = e.target.value)} />
660
- </div>
661
- <button
662
- act
663
- ?disabled=${this.locked || !this.porting.trim()}
664
- @click=${() => {
665
- this.edit({ kind: 'set-status-port', port: portDraftOf(asset, this.porting) });
666
- this.porting = '';
667
- }}
668
- >
669
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-port')}
670
- </button>
671
- </section>`;
672
- }
673
910
  /* ---------------------------------------------------------------- the sample */
674
911
  /** 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. */
675
912
  renderSample(asset) {
@@ -682,8 +919,10 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
682
919
  catch {
683
920
  unreadable = true;
684
921
  }
685
- const read = unreadable ? null : sampleOf(asset, data);
922
+ const legends = this.sampleLegends();
923
+ const read = unreadable ? null : sampleOf(asset, data, legends);
686
924
  const r = read?.result;
925
+ const tables = (asset.dataInterface?.bindings ?? []).filter((b) => b.rule === 'table');
687
926
  return html `<section>
688
927
  <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
689
928
  ${this.help.hint('figure.text.data-sample-explained')}
@@ -699,11 +938,14 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
699
938
  ${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}:
700
939
  ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`, m.paused ? i18next.t('figure.label.data-paused') : ''].filter(Boolean).join(' ')}
701
940
  </li>`)}
702
- ${Object.entries(r.status).map(([port, code]) => html `<li>${(asset.dataInterface?.ports ?? []).find(p => p.id === port)?.label ?? port}: ${code}</li>`)}
941
+ ${tables.map(b => this.renderTableUse(asset, b, read.uses[b.id]))}
942
+ ${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>`)}
703
943
  ${Object.entries(r.pose).map(([axis, p]) => html `<li>${i18next.t('figure.label.data-axis-' + axis)} = ${p.value}</li>`)}
704
944
  ${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>`)}
705
945
  </ul>`}
946
+ ${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}
706
947
  ${r && Object.keys(r.pose).length ? html `<p hint>${i18next.t('figure.text.data-sample-moves-all')}</p>` : nothing}
948
+ ${this.renderSampleLegends(tables)}
707
949
  <label toggle>
708
950
  <input
709
951
  type="checkbox"
@@ -717,6 +959,52 @@ let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement
717
959
  </label>
718
960
  </section>`;
719
961
  }
962
+ /** 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. */
963
+ renderTableUse(asset, b, use) {
964
+ if (!use || use.value === undefined)
965
+ return nothing;
966
+ const word = (v) => (typeof v === 'boolean' || v === 'true' || v === 'false' ? i18next.t(String(v) === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : String(v));
967
+ const row = use.key === null ? i18next.t('figure.label.data-none') : use.key === 'default' ? i18next.t('figure.label.data-otherwise') : word(use.key);
968
+ const from = use.source === 'figure' ? i18next.t('figure.label.data-table-own') : i18next.t('figure.label.data-table-legend', { name: use.legend ?? '' });
969
+ return html `<li>${b.label ?? b.accessor} = ${word(use.value)} → ${row} <small>(${from})</small></li>`;
970
+ }
971
+ /** The sample legends as read: by role, those that are JSON. */
972
+ sampleLegends() {
973
+ const out = {};
974
+ for (const [role, text] of Object.entries(this.legendSample)) {
975
+ if (!text.trim())
976
+ continue;
977
+ try {
978
+ out[role] = JSON.parse(text);
979
+ }
980
+ catch {
981
+ /* Not JSON yet: the figure's own table shows. */
982
+ }
983
+ }
984
+ return out;
985
+ }
986
+ /**
987
+ * A legend pasted for each table role, to see the figure as a board's legend of that role would show it -- not saved.
988
+ * The preview shows it too, as a scene's legend named as the role.
989
+ */
990
+ renderSampleLegends(tables) {
991
+ const roles = [...new Set(tables.flatMap(b => (b.legend ? [b.legend.role] : [])))];
992
+ if (!roles.length)
993
+ return nothing;
994
+ return html `<details>
995
+ <summary>${i18next.t('figure.label.data-sample-legend')}</summary>
996
+ <p hint>${i18next.t('figure.text.data-sample-legend-explained')}</p>
997
+ ${roles.map(role => html `<label>${role}</label>
998
+ <textarea
999
+ .value=${this.legendSample[role] ?? ''}
1000
+ placeholder='{ "CODE": { "color": "#22c55e" }, "default": {} }'
1001
+ @input=${(e) => {
1002
+ this.legendSample = { ...this.legendSample, [role]: e.target.value };
1003
+ this.dispatchEvent(new CustomEvent('legend-sample', { detail: { legends: this.sampleLegends() }, bubbles: true, composed: true }));
1004
+ }}
1005
+ ></textarea>`)}
1006
+ </details>`;
1007
+ }
720
1008
  try(typed) {
721
1009
  this.sample = typed;
722
1010
  try {
@@ -742,6 +1030,10 @@ __decorate([
742
1030
  property({ type: Boolean }),
743
1031
  __metadata("design:type", Object)
744
1032
  ], FigureDataPanel.prototype, "locked", void 0);
1033
+ __decorate([
1034
+ property(),
1035
+ __metadata("design:type", Object)
1036
+ ], FigureDataPanel.prototype, "selected", void 0);
745
1037
  __decorate([
746
1038
  state(),
747
1039
  __metadata("design:type", Object)
@@ -765,7 +1057,7 @@ __decorate([
765
1057
  __decorate([
766
1058
  state(),
767
1059
  __metadata("design:type", Object)
768
- ], FigureDataPanel.prototype, "porting", void 0);
1060
+ ], FigureDataPanel.prototype, "legendSample", void 0);
769
1061
  __decorate([
770
1062
  state(),
771
1063
  __metadata("design:type", Object)
@@ -790,21 +1082,10 @@ function nodeOfKind(kind) {
790
1082
  return { kind, item: { kind: 'object', fields: [] } };
791
1083
  return { kind };
792
1084
  }
793
- /** The param a rule starts with when the author switches to it: one result per value, one open interval, a unit scale, or none. */
794
- function firstParamOf(rule, node, b) {
795
- const keep = b.param?.default;
796
- if (rule === 'value')
797
- return undefined;
798
- if (rule === 'scale')
799
- return { scale: 1, offset: 0 };
800
- if (rule === 'range')
801
- return { [`${node?.kind === 'number' ? (node.min ?? '') : ''}~`]: keep ?? 0 };
802
- const domain = node ? domainOf(node).map(String) : [];
803
- return domain.length ? Object.fromEntries(domain.map(v => [v, keep ?? 0])) : { default: keep ?? 0 };
804
- }
805
- /** The next interval: from the last one's end, open above; open at both ends when there is none. */
806
- function freeRangeKey(taken) {
807
- const end = taken.at(-1)?.split('~')[1] ?? '';
808
- return `${end}~`;
1085
+ /** A value in a sentence: a text as it is, anything else as JSON. */
1086
+ const shownValue = (v) => (typeof v === 'string' ? v : JSON.stringify(v ?? ''));
1087
+ /** A table row's columns: the key's one or two, a cell per column, and the remove button. */
1088
+ function gridOf(keyCells, columns) {
1089
+ return `${'minmax(64px, 1fr) '.repeat(keyCells)}${'minmax(72px, 1fr) '.repeat(columns)}30px`;
809
1090
  }
810
1091
  //# sourceMappingURL=figure-data-panel.js.map