@things-factory/figure-ui 10.1.162 → 10.1.164

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 (35) hide show
  1. package/client/modeller/data-interface.ts +334 -79
  2. package/client/modeller/editor.ts +4 -3
  3. package/client/modeller/figure-data-panel.ts +683 -193
  4. package/client/modeller/figure-preview.ts +46 -30
  5. package/client/modeller/refusals.ts +18 -14
  6. package/client/viewparts/figure-detail-dock.ts +9 -3
  7. package/dist-client/modeller/data-interface.d.ts +91 -30
  8. package/dist-client/modeller/data-interface.js +314 -71
  9. package/dist-client/modeller/data-interface.js.map +1 -1
  10. package/dist-client/modeller/editor.d.ts +1 -1
  11. package/dist-client/modeller/editor.js +3 -2
  12. package/dist-client/modeller/editor.js.map +1 -1
  13. package/dist-client/modeller/figure-data-panel.d.ts +42 -13
  14. package/dist-client/modeller/figure-data-panel.js +679 -186
  15. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  16. package/dist-client/modeller/figure-preview.d.ts +7 -5
  17. package/dist-client/modeller/figure-preview.js +47 -30
  18. package/dist-client/modeller/figure-preview.js.map +1 -1
  19. package/dist-client/modeller/refusals.d.ts +2 -2
  20. package/dist-client/modeller/refusals.js +24 -18
  21. package/dist-client/modeller/refusals.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/dist-client/viewparts/figure-detail-dock.js +9 -3
  24. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  25. package/package.json +7 -7
  26. package/test/browser/build.mjs +9 -0
  27. package/test/browser/detail-tabs-page.js +36 -0
  28. package/test/browser/detail-tabs.check.mjs +123 -0
  29. package/test/data-motion.test.ts +173 -53
  30. package/translations/en.json +122 -43
  31. package/translations/es.json +122 -43
  32. package/translations/ja.json +122 -43
  33. package/translations/ko.json +122 -43
  34. package/translations/ms.json +122 -43
  35. package/translations/zh.json +122 -43
@@ -3,284 +3,732 @@ import '@material/web/icon/icon.js';
3
3
  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
- import { FIGURE_UNITS } from '@hatiolab/figure-model';
7
- import { connectionDraftOf, dataSampleOf, dataTargetsOf, domainOf, propertyDraftOf, sampleValueOf, targetKeyOf } from './data-interface.js';
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';
8
8
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js';
9
9
  import { HelpToggles } from './help.js';
10
+ const KINDS = ['number', 'boolean', 'text', 'object', 'list'];
10
11
  /**
11
- * 데이터 -- the figure's public data properties and what each moves inside it (ADR-0051 §2.2). A property is a value a
12
- * consumer maps its own data onto, by its id: a number with a unit and a range, a boolean, or an enum. A connection
13
- * makes a property move a control, a colour, or which clip of a motion group plays and how fast. Every change is one
12
+ * 데이터 -- the data a site sends this figure, in its own names, and what each value moves (ADR-0051 §8, data binding
13
+ * 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
14
16
  * part edit, and whether it holds is the kernel's check.
15
17
  *
16
- * The sample is not saved: values tried here, what they would move (figure-model's evaluation), and -- in the preview --
17
- * the figure running on them (`data-sample`).
18
+ * 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`).
18
20
  */
19
21
  let FigureDataPanel = class FigureDataPanel extends localize(i18next)(LitElement) {
20
22
  constructor() {
21
23
  super(...arguments);
22
24
  this.help = new HelpToggles(this);
23
25
  this.locked = false;
24
- this.adding = { label: '', kind: 'number' };
25
- this.connecting = { property: '', target: '' };
26
- /** The sample's values as typed, by property id; a property left out reads its default. */
27
- this.sample = {};
26
+ /** The schema row chosen, by its path; its field is edited under it. */
27
+ this.chosen = '';
28
+ this.adding = { name: '', kind: 'number' };
29
+ /** A site's data pasted to start the schema from; nothing is kept until the author says so. */
30
+ this.pasted = '';
31
+ this.connecting = { path: '', behavior: '' };
32
+ /** The binding opened to edit its rule, by id. */
33
+ this.opened = '';
34
+ this.porting = '';
35
+ /** The rotation card as filled in: the joint, and the fields for on, speed (and its most rpm) and direction. */
36
+ this.rotating = { input: '', on: '', speed: '', max: 0, direction: '', forward: '', reverse: '' };
37
+ /** The sample as typed, JSON; empty until edited, the schema's example shown until then. */
38
+ this.sample = '';
28
39
  this.sampling = false;
29
40
  }
30
- static { this.styles = [SidePanelStyles, css `
31
- :host {
32
- display: block;
33
- }
34
- section[item] {
35
- display: grid;
36
- gap: 8px;
37
- padding: 8px;
38
- border: 1px solid var(--md-sys-color-outline-variant);
39
- border-radius: 8px;
40
- }
41
- div[head] {
42
- display: grid;
43
- grid-template-columns: minmax(0, 1fr) 30px;
44
- gap: 6px;
45
- align-items: center;
46
- }
47
- div[head] strong {
48
- min-width: 0;
49
- overflow-wrap: anywhere;
50
- }
51
- div[head] button[act] {
52
- width: 30px;
53
- min-height: var(--figure-control-height);
54
- margin: 0;
55
- padding: 0;
56
- }
57
- div[cases] {
58
- display: grid;
59
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
60
- gap: 4px 6px;
61
- align-items: center;
62
- }
63
- ul[result] {
64
- margin: 0;
65
- padding-left: 16px;
66
- }
67
- md-icon {
68
- font-size: 16px;
69
- width: 16px;
70
- height: 16px;
71
- }
72
- `]; }
41
+ static { this.styles = [
42
+ SidePanelStyles,
43
+ css `
44
+ :host {
45
+ display: block;
46
+ }
47
+ ul[tree] {
48
+ display: grid;
49
+ gap: 2px;
50
+ margin: 0;
51
+ padding: 0;
52
+ list-style: none;
53
+ }
54
+ ul[tree] > li > button[row] {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ gap: 0 6px;
58
+ width: 100%;
59
+ min-height: var(--figure-control-height);
60
+ margin: 0;
61
+ padding: 4px 6px;
62
+ border: 1px solid transparent;
63
+ border-radius: 6px;
64
+ background: none;
65
+ color: inherit;
66
+ font: inherit;
67
+ text-align: left;
68
+ align-items: center;
69
+ cursor: pointer;
70
+ }
71
+ ul[tree] > li > button[row][selected] {
72
+ border-color: var(--md-sys-color-primary);
73
+ }
74
+ ul[tree] > li > button[row] small {
75
+ color: var(--md-sys-color-on-surface-variant);
76
+ }
77
+ /* What the field is and what reads it: beside the name when it fits, under it when not. */
78
+ ul[tree] > li > button[row] small[meta] {
79
+ margin-left: auto;
80
+ text-align: right;
81
+ }
82
+ section[item] {
83
+ display: grid;
84
+ gap: 8px;
85
+ padding: 8px;
86
+ border: 1px solid var(--md-sys-color-outline-variant);
87
+ border-radius: 8px;
88
+ }
89
+ div[head] {
90
+ display: grid;
91
+ grid-template-columns: minmax(0, 1fr) 30px;
92
+ gap: 6px;
93
+ align-items: center;
94
+ }
95
+ div[head] button[line] {
96
+ min-width: 0;
97
+ margin: 0;
98
+ padding: 0;
99
+ border: none;
100
+ background: none;
101
+ color: inherit;
102
+ font: inherit;
103
+ text-align: left;
104
+ overflow-wrap: anywhere;
105
+ cursor: pointer;
106
+ }
107
+ div[head] button[act] {
108
+ width: 30px;
109
+ min-height: var(--figure-control-height);
110
+ margin: 0;
111
+ padding: 0;
112
+ }
113
+ div[cases] {
114
+ display: grid;
115
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
116
+ gap: 4px 6px;
117
+ align-items: center;
118
+ }
119
+ div[ranges] {
120
+ display: grid;
121
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) 30px;
122
+ gap: 4px 6px;
123
+ align-items: center;
124
+ }
125
+ div[ranges] button[act] {
126
+ width: 30px;
127
+ margin: 0;
128
+ padding: 0;
129
+ }
130
+ textarea {
131
+ width: 100%;
132
+ min-height: 96px;
133
+ box-sizing: border-box;
134
+ font-family: var(--ui-font-mono, monospace);
135
+ font-size: inherit;
136
+ }
137
+ ul[result] {
138
+ margin: 0;
139
+ padding-left: 16px;
140
+ }
141
+ md-icon {
142
+ font-size: 16px;
143
+ width: 16px;
144
+ height: 16px;
145
+ }
146
+ `
147
+ ]; }
73
148
  render() {
74
149
  const asset = this.asset;
75
150
  if (!asset)
76
151
  return nothing;
77
- const properties = asset.dataInterface?.properties ?? [];
78
- const connections = asset.dataInterface?.connections ?? [];
152
+ const rows = schemaRowsOf(asset);
79
153
  return html `
80
- <section>
81
- <h3>${i18next.t('figure.label.data-properties')}${this.help.button('figure.text.data-properties-explained')}</h3>
82
- ${this.help.hint('figure.text.data-properties-explained')}
83
- ${properties.map(p => this.renderProperty(p))}
84
- ${this.renderAddProperty(asset)}
85
- </section>
86
- <section>
87
- <h3>${i18next.t('figure.label.data-connections')}${this.help.button('figure.text.data-connections-explained')}</h3>
88
- ${this.help.hint('figure.text.data-connections-explained')}
89
- ${connections.map(c => this.renderConnection(asset, c, properties.find(p => p.id === c.property)))}
90
- ${properties.length ? this.renderAddConnection(asset, properties) : html `<p hint>${i18next.t('figure.text.data-no-properties')}</p>`}
91
- </section>
92
- ${properties.length ? this.renderSample(asset, properties) : nothing}
154
+ ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${this.renderPorts(asset)} ${rows.length ? this.renderSample(asset) : nothing}
93
155
  `;
94
156
  }
95
- renderProperty(p) {
96
- const set = (patch) => this.edit({ kind: 'set-data-property', property: { ...p, ...patch } });
157
+ /* ---------------------------------------------------------------- the schema */
158
+ renderSchema(asset, rows) {
159
+ const schema = asset.dataInterface?.schema ?? [];
160
+ return html `<section>
161
+ <h3>${i18next.t('figure.label.data-schema')}${this.help.button('figure.text.data-schema-explained')}</h3>
162
+ ${this.help.hint('figure.text.data-schema-explained')}
163
+ ${rows.length
164
+ ? html `<ul tree>
165
+ ${rows.map(row => html `<li>
166
+ <button row ?selected=${row.path === this.chosen} style="padding-left: ${6 + row.depth * 14}px" @click=${() => (this.chosen = row.path === this.chosen ? '' : row.path)}>
167
+ <span>${row.name}${row.label ? html ` <small>${row.label}</small>` : nothing}</span>
168
+ <small meta>${this.kindWord(row.node)}${row.bindings.length ? ` · ${i18next.t('figure.label.data-bound', { count: row.bindings.length })}` : ''}</small>
169
+ </button>
170
+ ${row.path === this.chosen ? this.renderField(asset, schema, row) : nothing}
171
+ </li>`)}
172
+ </ul>`
173
+ : html `<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
174
+ ${this.renderAddField(schema, '')} ${this.renderFromSample(schema)}
175
+ </section>`;
176
+ }
177
+ kindWord(node) {
178
+ const kind = i18next.t('figure.label.data-kind-' + node.kind);
179
+ if (node.kind === 'number') {
180
+ const range = node.min !== undefined || node.max !== undefined ? ` ${node.min ?? ''}~${node.max ?? ''}` : '';
181
+ return `${kind}${node.unit ? ` ${node.unit}` : ''}${range}`;
182
+ }
183
+ if (node.kind === 'text' && node.values)
184
+ return `${kind} (${node.values.join(', ')})`;
185
+ if (node.kind === 'list')
186
+ return `${kind} · ${i18next.t('figure.label.data-kind-' + node.item.kind)}`;
187
+ return kind;
188
+ }
189
+ /** The chosen field's own settings, and a field added under it when it holds fields. */
190
+ renderField(asset, schema, row) {
191
+ const field = fieldAt(schema, row.path);
192
+ if (!field)
193
+ return nothing;
194
+ const replace = (next) => this.edit({ kind: 'set-data-schema', schema: schemaWithFieldReplaced(schema, row.path, next) });
195
+ const set = (patch) => {
196
+ const next = { ...field, ...patch };
197
+ for (const [k, v] of Object.entries(patch))
198
+ if (v === undefined || (typeof v === 'number' && Number.isNaN(v)))
199
+ delete next[k];
200
+ replace(next);
201
+ };
97
202
  const value = (e) => e.target.value;
98
203
  const number = (e) => e.target.valueAsNumber;
204
+ const rename = (to) => {
205
+ if (!to || to === field.name)
206
+ return;
207
+ /* A field outside any list is renamed with what reads it; inside a list it is the schema alone. */
208
+ if (!row.path.includes('[]'))
209
+ this.edit({ kind: 'rename-data-field', accessor: row.path, to });
210
+ else
211
+ replace({ ...field, name: to });
212
+ };
213
+ let holder = field;
214
+ while (holder.kind === 'list')
215
+ holder = holder.item;
99
216
  return html `<section item>
100
217
  <div head>
101
- <strong>${p.label} <small>(${i18next.t('figure.label.data-kind-' + p.kind)})</small></strong>
102
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-property')} @click=${() => this.edit({ kind: 'remove-data-property', id: p.id })}>
218
+ <strong>${row.path}</strong>
219
+ <button
220
+ act
221
+ ?disabled=${this.locked}
222
+ title=${i18next.t('figure.button.remove-data-field')}
223
+ @click=${() => {
224
+ this.chosen = '';
225
+ this.edit({ kind: 'set-data-schema', schema: schemaWithoutField(schema, row.path) });
226
+ }}
227
+ >
103
228
  <md-icon>close</md-icon>
104
229
  </button>
105
230
  </div>
106
231
  <div form>
107
- <span>${i18next.t('figure.label.data-id')}</span>
108
- <input .value=${p.id} ?disabled=${this.locked} @change=${(e) => value(e).trim() !== p.id && this.edit({ kind: 'rename-data-property', id: p.id, to: value(e).trim() })} />
109
- <span>${i18next.t('figure.label.name')}</span>
110
- <input .value=${p.label} ?disabled=${this.locked} @change=${(e) => value(e).trim() && set({ label: value(e).trim() })} />
111
- ${p.kind === 'number'
232
+ <span>${i18next.t('figure.label.data-name')}</span>
233
+ <input .value=${field.name} ?disabled=${this.locked} @change=${(e) => rename(value(e).trim())} />
234
+ <span>${i18next.t('figure.label.data-label')}</span>
235
+ <input .value=${field.label ?? ''} ?disabled=${this.locked} @change=${(e) => set({ label: value(e).trim() || undefined })} />
236
+ <span>${i18next.t('figure.label.data-kind')}</span>
237
+ <select ?disabled=${this.locked} @change=${(e) => replace(fieldOfKind(field, value(e)))}>
238
+ ${KINDS.map(k => html `<option value=${k} ?selected=${k === field.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
239
+ </select>
240
+ ${field.kind === 'number'
112
241
  ? html `<span>${i18next.t('figure.label.data-unit')}</span>
113
- <select ?disabled=${this.locked} @change=${(e) => set({ unit: value(e) })}>
114
- ${FIGURE_UNITS.map(u => html `<option value=${u} ?selected=${u === p.unit}>${u}</option>`)}
242
+ <select ?disabled=${this.locked} @change=${(e) => set({ unit: value(e) || undefined })}>
243
+ <option value="" ?selected=${!field.unit}>-</option>
244
+ ${FIGURE_UNITS.map(u => html `<option value=${u} ?selected=${u === field.unit}>${u}</option>`)}
115
245
  </select>
116
246
  <span>${i18next.t('figure.label.data-min')}</span>
117
- <input type="number" step="any" .value=${String(p.min)} ?disabled=${this.locked} @change=${(e) => set({ min: number(e) })} />
247
+ <input type="number" step="any" .value=${field.min === undefined ? '' : String(field.min)} ?disabled=${this.locked} @change=${(e) => set({ min: number(e) })} />
118
248
  <span>${i18next.t('figure.label.data-max')}</span>
119
- <input type="number" step="any" .value=${String(p.max)} ?disabled=${this.locked} @change=${(e) => set({ max: number(e) })} />
249
+ <input type="number" step="any" .value=${field.max === undefined ? '' : String(field.max)} ?disabled=${this.locked} @change=${(e) => set({ max: number(e) })} />
120
250
  <span>${i18next.t('figure.label.data-default')}</span>
121
- <input type="number" step="any" .value=${String(p.default)} ?disabled=${this.locked} @change=${(e) => set({ default: number(e) })} />`
122
- : p.kind === 'boolean'
251
+ <input type="number" step="any" .value=${field.default === undefined ? '' : String(field.default)} ?disabled=${this.locked} @change=${(e) => set({ default: number(e) })} />`
252
+ : field.kind === 'boolean'
123
253
  ? html `<span>${i18next.t('figure.label.data-default')}</span>
124
- <select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) === 'true' })}>
125
- ${[false, true].map(b => html `<option value=${String(b)} ?selected=${b === p.default}>${i18next.t(b ? 'figure.label.data-true' : 'figure.label.data-false')}</option>`)}
254
+ <select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) === '' ? undefined : value(e) === 'true' })}>
255
+ <option value="" ?selected=${field.default === undefined}>-</option>
256
+ ${[false, true].map(b => html `<option value=${String(b)} ?selected=${b === field.default}>${i18next.t(b ? 'figure.label.data-true' : 'figure.label.data-false')}</option>`)}
126
257
  </select>`
127
- : html `<span>${i18next.t('figure.label.data-values')}</span>
128
- <input
129
- .value=${p.values.join(', ')}
130
- ?disabled=${this.locked}
131
- @change=${(e) => {
132
- const values = value(e).split(',').map(v => v.trim()).filter(Boolean);
133
- set({ values, default: values.includes(p.default) ? p.default : values[0] });
134
- }}
135
- />
136
- <span>${i18next.t('figure.label.data-default')}</span>
137
- <select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) })}>
138
- ${p.values.map(v => html `<option value=${v} ?selected=${v === p.default}>${v}</option>`)}
139
- </select>`}
258
+ : field.kind === 'text'
259
+ ? html `<span>${i18next.t('figure.label.data-values')}</span>
260
+ <input
261
+ .value=${(field.values ?? []).join(', ')}
262
+ ?disabled=${this.locked}
263
+ @change=${(e) => {
264
+ const values = value(e).split(',').map(v => v.trim()).filter(Boolean);
265
+ set({ values: values.length ? values : undefined, default: field.default !== undefined && (!values.length || values.includes(field.default)) ? field.default : undefined });
266
+ }}
267
+ />
268
+ <span>${i18next.t('figure.label.data-default')}</span>
269
+ ${field.values
270
+ ? html `<select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) || undefined })}>
271
+ <option value="" ?selected=${field.default === undefined}>-</option>
272
+ ${field.values.map(v => html `<option value=${v} ?selected=${v === field.default}>${v}</option>`)}
273
+ </select>`
274
+ : html `<input .value=${field.default ?? ''} ?disabled=${this.locked} @change=${(e) => set({ default: value(e) || undefined })} />`}`
275
+ : html `<span></span>
276
+ <label toggle>
277
+ <input type="checkbox" .checked=${!!field.json} ?disabled=${this.locked} @change=${(e) => set({ json: e.target.checked ? true : undefined })} />
278
+ ${i18next.t('figure.label.data-json')}
279
+ </label>`}
280
+ ${field.kind === 'list'
281
+ ? html `<span>${i18next.t('figure.label.data-item-kind')}</span>
282
+ <select ?disabled=${this.locked} @change=${(e) => replace({ ...field, item: nodeOfKind(value(e)) })}>
283
+ ${KINDS.filter(k => k !== 'list').map(k => html `<option value=${k} ?selected=${k === field.item.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
284
+ </select>`
285
+ : nothing}
140
286
  </div>
287
+ ${holder.kind === 'object' ? this.renderAddField(schema, row.path) : nothing}
141
288
  </section>`;
142
289
  }
143
- renderAddProperty(asset) {
290
+ renderAddField(schema, under) {
144
291
  const a = this.adding;
292
+ const named = a.name.trim();
145
293
  return html `<div form>
146
- <span>${i18next.t('figure.label.name')}</span>
147
- <input .value=${a.label} @input=${(e) => (this.adding = { ...a, label: e.target.value })} />
294
+ <span>${i18next.t('figure.label.data-name')}</span>
295
+ <input .value=${a.name} @input=${(e) => (this.adding = { ...a, name: e.target.value })} />
148
296
  <span>${i18next.t('figure.label.data-kind')}</span>
149
297
  <select @change=${(e) => (this.adding = { ...a, kind: e.target.value })}>
150
- ${['number', 'boolean', 'enum'].map(k => html `<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
298
+ ${KINDS.map(k => html `<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
151
299
  </select>
152
300
  </div>
153
301
  <button
154
302
  act
155
- ?disabled=${this.locked || !a.label.trim()}
303
+ ?disabled=${this.locked || !named}
156
304
  @click=${() => {
157
- this.edit({ kind: 'set-data-property', property: propertyDraftOf(asset, a.label.trim(), a.kind) });
158
- this.adding = { ...a, label: '' };
305
+ this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) }) });
306
+ this.adding = { ...a, name: '' };
159
307
  }}
160
308
  >
161
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-property')}
309
+ <md-icon>add</md-icon>${i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })}
162
310
  </button>`;
163
311
  }
164
- targetName(asset, c, property) {
165
- const choice = property ? dataTargetsOf(asset, property.kind).find(t => t.key === targetKeyOf(c.target)) : undefined;
166
- const name = choice?.name ?? targetKeyOf(c.target);
167
- return i18next.t('figure.label.data-target-' + c.target.kind, { name });
312
+ /** A site's data pasted: the schema guessed from it, kept only when the author says so -- units, ranges and closed values are theirs to set. */
313
+ renderFromSample(schema) {
314
+ let guessed = null;
315
+ let unreadable = false;
316
+ if (this.pasted.trim()) {
317
+ try {
318
+ guessed = inferFigureDataSchema(JSON.parse(this.pasted));
319
+ }
320
+ catch {
321
+ unreadable = true;
322
+ }
323
+ }
324
+ return html `<details>
325
+ <summary>${i18next.t('figure.label.data-from-sample')}</summary>
326
+ <p hint>${i18next.t('figure.text.data-from-sample-explained')}</p>
327
+ <textarea .value=${this.pasted} ?disabled=${this.locked} @input=${(e) => (this.pasted = e.target.value)}></textarea>
328
+ ${unreadable ? html `<p hint>${i18next.t('figure.text.data-not-json')}</p>` : nothing}
329
+ ${guessed ? html `<p hint>${i18next.t(schema.length ? 'figure.text.data-guessed-replaces' : 'figure.text.data-guessed', { count: guessed.length })}</p>` : nothing}
330
+ <button
331
+ act
332
+ ?disabled=${this.locked || !guessed?.length}
333
+ @click=${() => {
334
+ this.edit({ kind: 'set-data-schema', schema: guessed });
335
+ this.pasted = '';
336
+ }}
337
+ >
338
+ ${i18next.t('figure.button.data-use-guessed')}
339
+ </button>
340
+ </details>`;
168
341
  }
169
- renderConnection(asset, c, property) {
170
- const set = (patch) => this.edit({ kind: 'set-data-connection', connection: { ...c, ...patch } });
171
- const number = (e) => e.target.valueAsNumber;
172
- const t = c.transform;
342
+ /* ---------------------------------------------------------------- the bindings */
343
+ behaviorName(asset, choices, b) {
344
+ const key = behaviorKeyOf(b);
345
+ const choice = choices.find(c => c.key === key);
346
+ const name = b.kind === 'pose' ? i18next.t('figure.label.data-axis-' + b.axis) : (choice?.name ?? key.slice(key.indexOf(':') + 1));
347
+ return i18next.t('figure.label.data-behavior-' + b.kind, { name });
348
+ }
349
+ renderBindings(asset, rows) {
350
+ const bindings = asset.dataInterface?.bindings ?? [];
351
+ const choices = behaviorsOf(asset);
352
+ return html `<section>
353
+ <h3>${i18next.t('figure.label.data-bindings')}${this.help.button('figure.text.data-bindings-explained')}</h3>
354
+ ${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>`}
357
+ ${this.renderRotation(asset, rows)}
358
+ </section>`;
359
+ }
360
+ renderBinding(asset, choices, b) {
361
+ 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
+ };
173
368
  return html `<section item>
174
369
  <div head>
175
- <strong>${property?.label ?? c.property} → ${this.targetName(asset, c, property)}</strong>
176
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-connection')} @click=${() => this.edit({ kind: 'remove-data-connection', id: c.id })}>
370
+ <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>
372
+ </button>
373
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
177
374
  <md-icon>close</md-icon>
178
375
  </button>
179
376
  </div>
180
- <div form>
181
- ${c.target.kind === 'state'
377
+ ${open ? this.renderBindingDetail(asset, b, set) : nothing}
378
+ </section>`;
379
+ }
380
+ renderBindingDetail(asset, b, set) {
381
+ const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'));
382
+ const value = (e) => e.target.value;
383
+ const behavior = b.behavior;
384
+ if (behavior.kind === 'items')
385
+ return this.renderItemFields(asset, b, node, set);
386
+ 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'
182
392
  ? html `<span>${i18next.t('figure.label.data-source-mode')}</span>
183
- <select ?disabled=${this.locked} @change=${(e) => set({ target: { ...c.target, sourceMode: e.target.value } })}>
184
- ${['observed', 'commanded'].map(m => html `<option value=${m} ?selected=${c.target.kind === 'state' && c.target.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
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>`)}
185
395
  </select>`
186
396
  : nothing}
187
- ${t.kind === 'lookup'
188
- ? nothing
189
- : html `<span>${i18next.t('figure.label.data-transform')}</span>
190
- <select ?disabled=${this.locked} @change=${(e) => set({ transform: e.target.value === 'affine' ? { kind: 'affine', scale: 1, offset: 0 } : { kind: 'identity' } })}>
191
- <option value="identity" ?selected=${t.kind === 'identity'}>${i18next.t('figure.label.data-identity')}</option>
192
- <option value="affine" ?selected=${t.kind === 'affine'}>${i18next.t('figure.label.data-affine')}</option>
193
- </select>`}
194
- ${t.kind === 'affine'
397
+ ${behavior.kind === 'pose'
398
+ ? 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 } })} />`
400
+ : nothing}
401
+ ${b.rule === 'scale'
195
402
  ? html `<span>${i18next.t('figure.label.data-scale')}</span>
196
- <input type="number" step="any" .value=${String(t.scale)} ?disabled=${this.locked} @change=${(e) => set({ transform: { ...t, scale: number(e) } })} />
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 } })} />
197
404
  <span>${i18next.t('figure.label.data-offset')}</span>
198
- <input type="number" step="any" .value=${String(t.offset)} ?disabled=${this.locked} @change=${(e) => set({ transform: { ...t, offset: number(e) } })} />`
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 } })} />`
199
406
  : nothing}
200
407
  </div>
201
- ${t.kind === 'lookup' ? this.renderCases(asset, c, set) : nothing}
202
- </section>`;
408
+ ${b.rule === 'map' ? this.renderCases(asset, b, node, set) : b.rule === 'range' ? this.renderRanges(asset, b, set) : nothing}`;
203
409
  }
204
- /** A case per value of the property: what the target takes for it -- a number, a colour, or a clip of the group. */
205
- renderCases(asset, c, set) {
206
- if (c.transform.kind !== 'lookup')
207
- return nothing;
208
- const cases = c.transform.cases;
209
- const write = (i, value) => set({ transform: { kind: 'lookup', cases: cases.map((k, j) => (j === i ? { ...k, value } : k)) } });
210
- const clips = c.target.kind === 'motion-clip' ? ((asset.motionGroups ?? []).find(g => c.target.kind === 'motion-clip' && g.id === c.target.group)?.clips ?? []) : [];
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;
413
+ 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>`)}
428
+ </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)} />`;
432
+ }
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 ?? {};
436
+ 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 });
445
+ };
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);
211
449
  return html `<div cases>
212
- ${cases.map((k, i) => html `<span>${typeof k.when === 'boolean' ? i18next.t(k.when ? 'figure.label.data-true' : 'figure.label.data-false') : String(k.when)}</span>
213
- ${c.target.kind === 'palette'
214
- ? html `<input type="color" .value=${String(k.value)} ?disabled=${this.locked} @change=${(e) => write(i, e.target.value)} />`
215
- : c.target.kind === 'motion-clip'
216
- ? html `<select ?disabled=${this.locked} @change=${(e) => write(i, e.target.value)}>
217
- ${clips.map(clip => html `<option value=${clip} ?selected=${clip === k.value}>${clip}</option>`)}
218
- </select>`
219
- : html `<input type="number" step="any" .value=${String(k.value)} ?disabled=${this.locked} @change=${(e) => write(i, e.target.valueAsNumber)} />`}`)}
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>`;
453
+ }
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 } : {}) } });
459
+ const ends = (key) => {
460
+ const [from = '', to = ''] = key.split('~');
461
+ return { from, to };
462
+ };
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) => {
473
+ const e = ends(k);
474
+ return html `<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev) => rekey(i, 'from', ev.target.value)} />
475
+ <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>`;
478
+ })}
479
+ </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]])}>
484
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-range')}
485
+ </button>`;
486
+ }
487
+ /** Which field of each element is its id, its type, its place, its height in the stack, its status. */
488
+ renderItemFields(asset, b, node, set) {
489
+ const behavior = b.behavior;
490
+ if (behavior.kind !== 'items')
491
+ return nothing;
492
+ let element = node;
493
+ while (element?.kind === 'list')
494
+ element = element.item;
495
+ const fields = element?.kind === 'object' ? element.fields.filter(f => f.kind === 'number' || f.kind === 'text' || f.kind === 'boolean').map(f => f.name) : [];
496
+ const pick = (role, typed) => {
497
+ const next = { ...behavior, [role]: typed };
498
+ if (!typed)
499
+ delete next[role];
500
+ set({ behavior: next });
501
+ };
502
+ return html `<div form>
503
+ ${node?.kind === 'list'
504
+ ? ['id', 'type', 'place', 'stack', 'status'].map(role => html `<span>${i18next.t('figure.label.data-item-' + role)}</span>
505
+ <select ?disabled=${this.locked} @change=${(e) => pick(role, e.target.value)}>
506
+ <option value="" ?selected=${!behavior[role]}>-</option>
507
+ ${fields.map(f => html `<option value=${f} ?selected=${f === behavior[role]}>${f}</option>`)}
508
+ </select>`)
509
+ : nothing}
510
+ <span>${i18next.t('figure.label.data-item-slot')}</span>
511
+ <input .value=${behavior.slot ?? ''} ?disabled=${this.locked} @change=${(e) => {
512
+ const slot = e.target.value.trim();
513
+ const next = { ...behavior, slot };
514
+ if (!slot)
515
+ delete next.slot;
516
+ set({ behavior: next });
517
+ }} />
220
518
  </div>`;
221
519
  }
222
- renderAddConnection(asset, properties) {
520
+ renderAddBinding(asset, rows, choices) {
521
+ const bindable = rows.filter(r => r.node.kind !== 'object');
522
+ if (!bindable.length)
523
+ return nothing;
223
524
  const a = this.connecting;
224
- const property = properties.find(p => p.id === a.property) ?? properties[0];
225
- const targets = dataTargetsOf(asset, property.kind);
226
- const choice = targets.find(t => t.key === a.target) ?? targets[0];
525
+ const row = bindable.find(r => r.path === a.path) ?? bindable[0];
526
+ const fitting = choices.filter(c => fitsField(row.node, c));
527
+ 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);
227
530
  return html `<div form>
228
- <span>${i18next.t('figure.label.data-property')}</span>
229
- <select @change=${(e) => (this.connecting = { ...a, property: e.target.value })}>
230
- ${properties.map(p => html `<option value=${p.id} ?selected=${p === property}>${p.label}</option>`)}
531
+ <span>${i18next.t('figure.label.data-field')}</span>
532
+ <select @change=${(e) => (this.connecting = { ...a, path: e.target.value })}>
533
+ ${bindable.map(r => html `<option value=${r.path} ?selected=${r === row}>${r.path}</option>`)}
231
534
  </select>
232
- <span>${i18next.t('figure.label.data-target')}</span>
233
- <select ?disabled=${!targets.length} @change=${(e) => (this.connecting = { ...a, target: e.target.value })}>
234
- ${targets.map((t) => html `<option value=${t.key} ?selected=${t === choice}>${i18next.t('figure.label.data-target-' + t.target.kind, { name: t.name })}</option>`)}
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>`)}
235
538
  </select>
236
539
  </div>
237
- <button act ?disabled=${this.locked || !choice} @click=${() => this.edit({ kind: 'set-data-connection', connection: connectionDraftOf(asset, property, choice) })}>
238
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-connection')}
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
+ >
550
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-binding')}
239
551
  </button>`;
240
552
  }
241
- /** Values to try: what they move, by figure-model's evaluation; in the preview, the figure running on them. */
242
- renderSample(asset, properties) {
243
- const values = Object.fromEntries(properties.map(p => [p.id, this.sample[p.id] ?? p.default]));
244
- const outcome = dataSampleOf(asset, values);
553
+ /**
554
+ * 「+ 회전」: a joint that turns without end, turned by the data -- on, speed in rpm, direction -- in one step. The
555
+ * clips, the group and the bindings it writes are ordinary ones, edited one by one afterwards.
556
+ */
557
+ renderRotation(asset, rows) {
558
+ const joints = endlessJointsOf(asset);
559
+ if (!joints.length)
560
+ return nothing;
561
+ const r = this.rotating;
562
+ const joint = joints.find(j => j.input === r.input) ?? joints[0];
563
+ const values = rows.filter(row => !row.path.includes('[]'));
564
+ const of = (kind, closed = false) => values.filter(row => row.node.kind === kind && (!closed || domainOf(row.node).length > 0));
565
+ const speedRow = values.find(row => row.path === r.speed);
566
+ const speedMax = r.max || (speedRow?.node.kind === 'number' ? (speedRow.node.max ?? 0) : 0);
567
+ const directionRow = values.find(row => row.path === r.direction);
568
+ const directions = directionRow ? domainOf(directionRow.node).map(String) : [];
569
+ const forward = directions.includes(r.forward) ? r.forward : (directions[0] ?? '');
570
+ const reverse = directions.includes(r.reverse) ? r.reverse : (directions.find(v => v !== forward) ?? '');
571
+ const set = (patch) => (this.rotating = { ...r, ...patch });
572
+ const typed = (e) => e.target.value;
573
+ const pick = (rows, value, change) => html `<select ?disabled=${this.locked} @change=${(e) => change(typed(e))}>
574
+ <option value="" ?selected=${!value}>${i18next.t('figure.label.data-none')}</option>
575
+ ${rows.map(row => html `<option value=${row.path} ?selected=${row.path === value}>${row.path}</option>`)}
576
+ </select>`;
577
+ const choice = {
578
+ input: joint.input,
579
+ label: i18next.t('figure.label.data-rotation-of', { name: joint.label }),
580
+ ...(r.on ? { on: r.on } : {}),
581
+ ...(r.speed ? { speed: { path: r.speed, max: speedMax } } : {}),
582
+ ...(r.direction && forward && reverse ? { direction: { path: r.direction, forward, reverse } } : {})
583
+ };
584
+ const ready = !r.speed || speedMax > 0;
585
+ return html `<details>
586
+ <summary>${i18next.t('figure.label.data-rotation')}</summary>
587
+ <p hint>${i18next.t('figure.text.data-rotation-explained')}</p>
588
+ <div form>
589
+ <span>${i18next.t('figure.label.data-rotation-joint')}</span>
590
+ <select ?disabled=${this.locked} @change=${(e) => set({ input: typed(e) })}>
591
+ ${joints.map(j => html `<option value=${j.input} ?selected=${j === joint}>${j.label}</option>`)}
592
+ </select>
593
+ <span>${i18next.t('figure.label.data-rotation-on')}</span>
594
+ ${pick(of('boolean'), r.on, v => set({ on: v }))}
595
+ <span>${i18next.t('figure.label.data-rotation-speed')}</span>
596
+ ${pick(of('number'), r.speed, v => set({ speed: v, max: 0 }))}
597
+ ${r.speed
598
+ ? html `<span>${i18next.t('figure.label.data-rotation-max')}</span>
599
+ <input type="number" min="0" step="any" .value=${speedMax ? String(speedMax) : ''} ?disabled=${this.locked} @change=${(e) => set({ max: e.target.valueAsNumber || 0 })} />`
600
+ : nothing}
601
+ <span>${i18next.t('figure.label.data-rotation-direction')}</span>
602
+ ${pick(of('text', true), r.direction, v => set({ direction: v, forward: '', reverse: '' }))}
603
+ ${directions.length
604
+ ? html `<span>${i18next.t('figure.label.data-rotation-forward')}</span>
605
+ <select ?disabled=${this.locked} @change=${(e) => set({ forward: typed(e) })}>
606
+ ${directions.map(v => html `<option value=${v} ?selected=${v === forward}>${v}</option>`)}
607
+ </select>
608
+ <span>${i18next.t('figure.label.data-rotation-reverse')}</span>
609
+ <select ?disabled=${this.locked} @change=${(e) => set({ reverse: typed(e) })}>
610
+ ${directions.map(v => html `<option value=${v} ?selected=${v === reverse}>${v}</option>`)}
611
+ </select>`
612
+ : nothing}
613
+ </div>
614
+ ${ready ? nothing : html `<p hint>${i18next.t('figure.text.data-rotation-needs-max')}</p>`}
615
+ <button
616
+ act
617
+ ?disabled=${this.locked || !ready}
618
+ @click=${() => {
619
+ this.dispatchEvent(new CustomEvent('part-edits', { detail: { edits: rotationStepsOf(asset, choice) }, bubbles: true, composed: true }));
620
+ this.rotating = { input: '', on: '', speed: '', max: 0, direction: '', forward: '', reverse: '' };
621
+ }}
622
+ >
623
+ <md-icon>add</md-icon>${i18next.t('figure.button.data-add-rotation')}
624
+ </button>
625
+ </details>`;
626
+ }
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);
245
634
  return html `<section>
246
- <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
247
- ${this.help.hint('figure.text.data-sample-explained')}
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>`)}
248
657
  <div form>
249
- ${properties.map(p => html `<span>${p.label}</span>
250
- ${p.kind === 'number'
251
- ? html `<input type="number" step="any" .value=${String(values[p.id])} @change=${(e) => this.try(p, e.target.value)} />`
252
- : html `<select @change=${(e) => this.try(p, e.target.value)}>
253
- ${domainOf(p).map(v => html `<option value=${String(v)} ?selected=${v === values[p.id]}>${typeof v === 'boolean' ? i18next.t(v ? 'figure.label.data-true' : 'figure.label.data-false') : v}</option>`)}
254
- </select>`}`)}
658
+ <span>${i18next.t('figure.label.data-label')}</span>
659
+ <input .value=${this.porting} @input=${(e) => (this.porting = e.target.value)} />
255
660
  </div>
256
- ${'issues' in outcome
257
- ? html `<p hint>${outcome.issues.map(i => i18next.t('figure.text.data-value-' + i.reason, { property: i.property ?? '' })).join(' · ')}</p>`
258
- : html `<ul result>
259
- ${Object.entries(outcome.result.states).map(([input, s]) => html `<li>${asset.stateInputs?.[input]?.label ?? input} = ${s.value}</li>`)}
260
- ${Object.entries(outcome.result.palette).map(([token, colour]) => html `<li>${token} = ${colour}</li>`)}
261
- ${Object.entries(outcome.result.motion).map(([group, m]) => html `<li>${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}: ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`].filter(Boolean).join(' ')}</li>`)}
262
- </ul>`}
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
+ /* ---------------------------------------------------------------- the sample */
674
+ /** 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
+ renderSample(asset) {
676
+ const text = this.sample || JSON.stringify(figureDataExample(asset.dataInterface), null, 2);
677
+ let data;
678
+ let unreadable = false;
679
+ try {
680
+ data = JSON.parse(text);
681
+ }
682
+ catch {
683
+ unreadable = true;
684
+ }
685
+ const read = unreadable ? null : sampleOf(asset, data);
686
+ const r = read?.result;
687
+ return html `<section>
688
+ <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
689
+ ${this.help.hint('figure.text.data-sample-explained')}
690
+ <textarea .value=${text} @input=${(e) => this.try(e.target.value)}></textarea>
691
+ ${unreadable
692
+ ? html `<p hint>${i18next.t('figure.text.data-not-json')}</p>`
693
+ : read.issues.length
694
+ ? html `<p hint>${read.issues.map(i => i18next.t('figure.text.data-value-' + i.reason, { path: i.path || '-' })).join(' · ')}</p>`
695
+ : html `<ul result>
696
+ ${Object.entries(r.states).map(([input, s]) => html `<li>${asset.stateInputs?.[input]?.label ?? input} = ${s.value}</li>`)}
697
+ ${Object.entries(r.palette).map(([token, colour]) => html `<li>${token} = ${colour}</li>`)}
698
+ ${Object.entries(r.motion).map(([group, m]) => html `<li>
699
+ ${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}:
700
+ ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`, m.paused ? i18next.t('figure.label.data-paused') : ''].filter(Boolean).join(' ')}
701
+ </li>`)}
702
+ ${Object.entries(r.status).map(([port, code]) => html `<li>${(asset.dataInterface?.ports ?? []).find(p => p.id === port)?.label ?? port}: ${code}</li>`)}
703
+ ${Object.entries(r.pose).map(([axis, p]) => html `<li>${i18next.t('figure.label.data-axis-' + axis)} = ${p.value}</li>`)}
704
+ ${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
+ </ul>`}
706
+ ${r && Object.keys(r.pose).length ? html `<p hint>${i18next.t('figure.text.data-sample-moves-all')}</p>` : nothing}
263
707
  <label toggle>
264
708
  <input
265
709
  type="checkbox"
266
710
  .checked=${this.sampling}
267
711
  @change=${(e) => {
268
712
  this.sampling = e.target.checked;
269
- this.tell(values);
713
+ this.tell(unreadable ? null : data);
270
714
  }}
271
715
  />
272
716
  ${i18next.t('figure.label.data-sample-in-preview')}
273
717
  </label>
274
718
  </section>`;
275
719
  }
276
- try(property, typed) {
277
- this.sample = { ...this.sample, [property.id]: sampleValueOf(property, typed) };
278
- const properties = this.asset?.dataInterface?.properties ?? [];
279
- this.tell(Object.fromEntries(properties.map(p => [p.id, this.sample[p.id] ?? p.default])));
720
+ try(typed) {
721
+ this.sample = typed;
722
+ try {
723
+ this.tell(JSON.parse(typed));
724
+ }
725
+ catch {
726
+ /* Not JSON yet: the preview keeps what it ran on. */
727
+ }
280
728
  }
281
729
  /** The sample to the preview while it is asked for; null lets the preview's figure go back to its own values. */
282
- tell(values) {
283
- this.dispatchEvent(new CustomEvent('data-sample', { detail: { values: this.sampling ? values : null }, bubbles: true, composed: true }));
730
+ tell(data) {
731
+ this.dispatchEvent(new CustomEvent('data-sample', { detail: { data: this.sampling ? data : null }, bubbles: true, composed: true }));
284
732
  }
285
733
  edit(edit) {
286
734
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit }, bubbles: true, composed: true }));
@@ -294,14 +742,34 @@ __decorate([
294
742
  property({ type: Boolean }),
295
743
  __metadata("design:type", Object)
296
744
  ], FigureDataPanel.prototype, "locked", void 0);
745
+ __decorate([
746
+ state(),
747
+ __metadata("design:type", Object)
748
+ ], FigureDataPanel.prototype, "chosen", void 0);
297
749
  __decorate([
298
750
  state(),
299
751
  __metadata("design:type", Object)
300
752
  ], FigureDataPanel.prototype, "adding", void 0);
753
+ __decorate([
754
+ state(),
755
+ __metadata("design:type", Object)
756
+ ], FigureDataPanel.prototype, "pasted", void 0);
301
757
  __decorate([
302
758
  state(),
303
759
  __metadata("design:type", Object)
304
760
  ], FigureDataPanel.prototype, "connecting", void 0);
761
+ __decorate([
762
+ state(),
763
+ __metadata("design:type", Object)
764
+ ], FigureDataPanel.prototype, "opened", void 0);
765
+ __decorate([
766
+ state(),
767
+ __metadata("design:type", Object)
768
+ ], FigureDataPanel.prototype, "porting", void 0);
769
+ __decorate([
770
+ state(),
771
+ __metadata("design:type", Object)
772
+ ], FigureDataPanel.prototype, "rotating", void 0);
305
773
  __decorate([
306
774
  state(),
307
775
  __metadata("design:type", Object)
@@ -314,4 +782,29 @@ FigureDataPanel = __decorate([
314
782
  customElement('figure-data-panel')
315
783
  ], FigureDataPanel);
316
784
  export { FigureDataPanel };
785
+ /** A node of a kind, started empty. */
786
+ function nodeOfKind(kind) {
787
+ if (kind === 'object')
788
+ return { kind, fields: [] };
789
+ if (kind === 'list')
790
+ return { kind, item: { kind: 'object', fields: [] } };
791
+ return { kind };
792
+ }
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}~`;
809
+ }
317
810
  //# sourceMappingURL=figure-data-panel.js.map