@things-factory/figure-ui 10.1.162 → 10.1.163

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.
@@ -3,284 +3,655 @@ 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, fieldAt, fieldOfKind, fitsField, partsOf, portDraftOf, 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 sample as typed, JSON; empty until edited, the schema's example shown until then. */
36
+ this.sample = '';
28
37
  this.sampling = false;
29
38
  }
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
- `]; }
39
+ static { this.styles = [
40
+ SidePanelStyles,
41
+ css `
42
+ :host {
43
+ display: block;
44
+ }
45
+ ul[tree] {
46
+ display: grid;
47
+ gap: 2px;
48
+ margin: 0;
49
+ padding: 0;
50
+ list-style: none;
51
+ }
52
+ ul[tree] > li > button[row] {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ gap: 0 6px;
56
+ width: 100%;
57
+ min-height: var(--figure-control-height);
58
+ margin: 0;
59
+ padding: 4px 6px;
60
+ border: 1px solid transparent;
61
+ border-radius: 6px;
62
+ background: none;
63
+ color: inherit;
64
+ font: inherit;
65
+ text-align: left;
66
+ align-items: center;
67
+ cursor: pointer;
68
+ }
69
+ ul[tree] > li > button[row][selected] {
70
+ border-color: var(--md-sys-color-primary);
71
+ }
72
+ ul[tree] > li > button[row] small {
73
+ color: var(--md-sys-color-on-surface-variant);
74
+ }
75
+ /* What the field is and what reads it: beside the name when it fits, under it when not. */
76
+ ul[tree] > li > button[row] small[meta] {
77
+ margin-left: auto;
78
+ text-align: right;
79
+ }
80
+ section[item] {
81
+ display: grid;
82
+ gap: 8px;
83
+ padding: 8px;
84
+ border: 1px solid var(--md-sys-color-outline-variant);
85
+ border-radius: 8px;
86
+ }
87
+ div[head] {
88
+ display: grid;
89
+ grid-template-columns: minmax(0, 1fr) 30px;
90
+ gap: 6px;
91
+ align-items: center;
92
+ }
93
+ div[head] button[line] {
94
+ min-width: 0;
95
+ margin: 0;
96
+ padding: 0;
97
+ border: none;
98
+ background: none;
99
+ color: inherit;
100
+ font: inherit;
101
+ text-align: left;
102
+ overflow-wrap: anywhere;
103
+ cursor: pointer;
104
+ }
105
+ div[head] button[act] {
106
+ width: 30px;
107
+ min-height: var(--figure-control-height);
108
+ margin: 0;
109
+ padding: 0;
110
+ }
111
+ div[cases] {
112
+ display: grid;
113
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
114
+ gap: 4px 6px;
115
+ align-items: center;
116
+ }
117
+ div[ranges] {
118
+ display: grid;
119
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) 30px;
120
+ gap: 4px 6px;
121
+ align-items: center;
122
+ }
123
+ div[ranges] button[act] {
124
+ width: 30px;
125
+ margin: 0;
126
+ padding: 0;
127
+ }
128
+ textarea {
129
+ width: 100%;
130
+ min-height: 96px;
131
+ box-sizing: border-box;
132
+ font-family: var(--ui-font-mono, monospace);
133
+ font-size: inherit;
134
+ }
135
+ ul[result] {
136
+ margin: 0;
137
+ padding-left: 16px;
138
+ }
139
+ md-icon {
140
+ font-size: 16px;
141
+ width: 16px;
142
+ height: 16px;
143
+ }
144
+ `
145
+ ]; }
73
146
  render() {
74
147
  const asset = this.asset;
75
148
  if (!asset)
76
149
  return nothing;
77
- const properties = asset.dataInterface?.properties ?? [];
78
- const connections = asset.dataInterface?.connections ?? [];
150
+ const rows = schemaRowsOf(asset);
79
151
  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}
152
+ ${this.renderSchema(asset, rows)} ${this.renderBindings(asset, rows)} ${this.renderPorts(asset)} ${rows.length ? this.renderSample(asset) : nothing}
93
153
  `;
94
154
  }
95
- renderProperty(p) {
96
- const set = (patch) => this.edit({ kind: 'set-data-property', property: { ...p, ...patch } });
155
+ /* ---------------------------------------------------------------- the schema */
156
+ renderSchema(asset, rows) {
157
+ const schema = asset.dataInterface?.schema ?? [];
158
+ return html `<section>
159
+ <h3>${i18next.t('figure.label.data-schema')}${this.help.button('figure.text.data-schema-explained')}</h3>
160
+ ${this.help.hint('figure.text.data-schema-explained')}
161
+ ${rows.length
162
+ ? html `<ul tree>
163
+ ${rows.map(row => html `<li>
164
+ <button row ?selected=${row.path === this.chosen} style="padding-left: ${6 + row.depth * 14}px" @click=${() => (this.chosen = row.path === this.chosen ? '' : row.path)}>
165
+ <span>${row.name}${row.label ? html ` <small>${row.label}</small>` : nothing}</span>
166
+ <small meta>${this.kindWord(row.node)}${row.bindings.length ? ` · ${i18next.t('figure.label.data-bound', { count: row.bindings.length })}` : ''}</small>
167
+ </button>
168
+ ${row.path === this.chosen ? this.renderField(asset, schema, row) : nothing}
169
+ </li>`)}
170
+ </ul>`
171
+ : html `<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
172
+ ${this.renderAddField(schema, '')} ${this.renderFromSample(schema)}
173
+ </section>`;
174
+ }
175
+ kindWord(node) {
176
+ const kind = i18next.t('figure.label.data-kind-' + node.kind);
177
+ if (node.kind === 'number') {
178
+ const range = node.min !== undefined || node.max !== undefined ? ` ${node.min ?? ''}~${node.max ?? ''}` : '';
179
+ return `${kind}${node.unit ? ` ${node.unit}` : ''}${range}`;
180
+ }
181
+ if (node.kind === 'text' && node.values)
182
+ return `${kind} (${node.values.join(', ')})`;
183
+ if (node.kind === 'list')
184
+ return `${kind} · ${i18next.t('figure.label.data-kind-' + node.item.kind)}`;
185
+ return kind;
186
+ }
187
+ /** The chosen field's own settings, and a field added under it when it holds fields. */
188
+ renderField(asset, schema, row) {
189
+ const field = fieldAt(schema, row.path);
190
+ if (!field)
191
+ return nothing;
192
+ const replace = (next) => this.edit({ kind: 'set-data-schema', schema: schemaWithFieldReplaced(schema, row.path, next) });
193
+ const set = (patch) => {
194
+ const next = { ...field, ...patch };
195
+ for (const [k, v] of Object.entries(patch))
196
+ if (v === undefined || (typeof v === 'number' && Number.isNaN(v)))
197
+ delete next[k];
198
+ replace(next);
199
+ };
97
200
  const value = (e) => e.target.value;
98
201
  const number = (e) => e.target.valueAsNumber;
202
+ const rename = (to) => {
203
+ if (!to || to === field.name)
204
+ return;
205
+ /* A field outside any list is renamed with what reads it; inside a list it is the schema alone. */
206
+ if (!row.path.includes('[]'))
207
+ this.edit({ kind: 'rename-data-field', accessor: row.path, to });
208
+ else
209
+ replace({ ...field, name: to });
210
+ };
211
+ let holder = field;
212
+ while (holder.kind === 'list')
213
+ holder = holder.item;
99
214
  return html `<section item>
100
215
  <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 })}>
216
+ <strong>${row.path}</strong>
217
+ <button
218
+ act
219
+ ?disabled=${this.locked}
220
+ title=${i18next.t('figure.button.remove-data-field')}
221
+ @click=${() => {
222
+ this.chosen = '';
223
+ this.edit({ kind: 'set-data-schema', schema: schemaWithoutField(schema, row.path) });
224
+ }}
225
+ >
103
226
  <md-icon>close</md-icon>
104
227
  </button>
105
228
  </div>
106
229
  <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'
230
+ <span>${i18next.t('figure.label.data-name')}</span>
231
+ <input .value=${field.name} ?disabled=${this.locked} @change=${(e) => rename(value(e).trim())} />
232
+ <span>${i18next.t('figure.label.data-label')}</span>
233
+ <input .value=${field.label ?? ''} ?disabled=${this.locked} @change=${(e) => set({ label: value(e).trim() || undefined })} />
234
+ <span>${i18next.t('figure.label.data-kind')}</span>
235
+ <select ?disabled=${this.locked} @change=${(e) => replace(fieldOfKind(field, value(e)))}>
236
+ ${KINDS.map(k => html `<option value=${k} ?selected=${k === field.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
237
+ </select>
238
+ ${field.kind === 'number'
112
239
  ? 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>`)}
240
+ <select ?disabled=${this.locked} @change=${(e) => set({ unit: value(e) || undefined })}>
241
+ <option value="" ?selected=${!field.unit}>-</option>
242
+ ${FIGURE_UNITS.map(u => html `<option value=${u} ?selected=${u === field.unit}>${u}</option>`)}
115
243
  </select>
116
244
  <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) })} />
245
+ <input type="number" step="any" .value=${field.min === undefined ? '' : String(field.min)} ?disabled=${this.locked} @change=${(e) => set({ min: number(e) })} />
118
246
  <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) })} />
247
+ <input type="number" step="any" .value=${field.max === undefined ? '' : String(field.max)} ?disabled=${this.locked} @change=${(e) => set({ max: number(e) })} />
120
248
  <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'
249
+ <input type="number" step="any" .value=${field.default === undefined ? '' : String(field.default)} ?disabled=${this.locked} @change=${(e) => set({ default: number(e) })} />`
250
+ : field.kind === 'boolean'
123
251
  ? 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>`)}
252
+ <select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) === '' ? undefined : value(e) === 'true' })}>
253
+ <option value="" ?selected=${field.default === undefined}>-</option>
254
+ ${[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
255
  </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>`}
256
+ : field.kind === 'text'
257
+ ? html `<span>${i18next.t('figure.label.data-values')}</span>
258
+ <input
259
+ .value=${(field.values ?? []).join(', ')}
260
+ ?disabled=${this.locked}
261
+ @change=${(e) => {
262
+ const values = value(e).split(',').map(v => v.trim()).filter(Boolean);
263
+ set({ values: values.length ? values : undefined, default: field.default !== undefined && (!values.length || values.includes(field.default)) ? field.default : undefined });
264
+ }}
265
+ />
266
+ <span>${i18next.t('figure.label.data-default')}</span>
267
+ ${field.values
268
+ ? html `<select ?disabled=${this.locked} @change=${(e) => set({ default: value(e) || undefined })}>
269
+ <option value="" ?selected=${field.default === undefined}>-</option>
270
+ ${field.values.map(v => html `<option value=${v} ?selected=${v === field.default}>${v}</option>`)}
271
+ </select>`
272
+ : html `<input .value=${field.default ?? ''} ?disabled=${this.locked} @change=${(e) => set({ default: value(e) || undefined })} />`}`
273
+ : html `<span></span>
274
+ <label toggle>
275
+ <input type="checkbox" .checked=${!!field.json} ?disabled=${this.locked} @change=${(e) => set({ json: e.target.checked ? true : undefined })} />
276
+ ${i18next.t('figure.label.data-json')}
277
+ </label>`}
278
+ ${field.kind === 'list'
279
+ ? html `<span>${i18next.t('figure.label.data-item-kind')}</span>
280
+ <select ?disabled=${this.locked} @change=${(e) => replace({ ...field, item: nodeOfKind(value(e)) })}>
281
+ ${KINDS.filter(k => k !== 'list').map(k => html `<option value=${k} ?selected=${k === field.item.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
282
+ </select>`
283
+ : nothing}
140
284
  </div>
285
+ ${holder.kind === 'object' ? this.renderAddField(schema, row.path) : nothing}
141
286
  </section>`;
142
287
  }
143
- renderAddProperty(asset) {
288
+ renderAddField(schema, under) {
144
289
  const a = this.adding;
290
+ const named = a.name.trim();
145
291
  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 })} />
292
+ <span>${i18next.t('figure.label.data-name')}</span>
293
+ <input .value=${a.name} @input=${(e) => (this.adding = { ...a, name: e.target.value })} />
148
294
  <span>${i18next.t('figure.label.data-kind')}</span>
149
295
  <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>`)}
296
+ ${KINDS.map(k => html `<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
151
297
  </select>
152
298
  </div>
153
299
  <button
154
300
  act
155
- ?disabled=${this.locked || !a.label.trim()}
301
+ ?disabled=${this.locked || !named}
156
302
  @click=${() => {
157
- this.edit({ kind: 'set-data-property', property: propertyDraftOf(asset, a.label.trim(), a.kind) });
158
- this.adding = { ...a, label: '' };
303
+ this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) }) });
304
+ this.adding = { ...a, name: '' };
159
305
  }}
160
306
  >
161
- <md-icon>add</md-icon>${i18next.t('figure.button.add-data-property')}
307
+ <md-icon>add</md-icon>${i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })}
162
308
  </button>`;
163
309
  }
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 });
310
+ /** 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. */
311
+ renderFromSample(schema) {
312
+ let guessed = null;
313
+ let unreadable = false;
314
+ if (this.pasted.trim()) {
315
+ try {
316
+ guessed = inferFigureDataSchema(JSON.parse(this.pasted));
317
+ }
318
+ catch {
319
+ unreadable = true;
320
+ }
321
+ }
322
+ return html `<details>
323
+ <summary>${i18next.t('figure.label.data-from-sample')}</summary>
324
+ <p hint>${i18next.t('figure.text.data-from-sample-explained')}</p>
325
+ <textarea .value=${this.pasted} ?disabled=${this.locked} @input=${(e) => (this.pasted = e.target.value)}></textarea>
326
+ ${unreadable ? html `<p hint>${i18next.t('figure.text.data-not-json')}</p>` : nothing}
327
+ ${guessed ? html `<p hint>${i18next.t(schema.length ? 'figure.text.data-guessed-replaces' : 'figure.text.data-guessed', { count: guessed.length })}</p>` : nothing}
328
+ <button
329
+ act
330
+ ?disabled=${this.locked || !guessed?.length}
331
+ @click=${() => {
332
+ this.edit({ kind: 'set-data-schema', schema: guessed });
333
+ this.pasted = '';
334
+ }}
335
+ >
336
+ ${i18next.t('figure.button.data-use-guessed')}
337
+ </button>
338
+ </details>`;
339
+ }
340
+ /* ---------------------------------------------------------------- the bindings */
341
+ behaviorName(asset, choices, b) {
342
+ const key = behaviorKeyOf(b);
343
+ const choice = choices.find(c => c.key === key);
344
+ const name = b.kind === 'pose' ? i18next.t('figure.label.data-axis-' + b.axis) : (choice?.name ?? key.slice(key.indexOf(':') + 1));
345
+ return i18next.t('figure.label.data-behavior-' + b.kind, { name });
168
346
  }
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;
347
+ renderBindings(asset, rows) {
348
+ const bindings = asset.dataInterface?.bindings ?? [];
349
+ const choices = behaviorsOf(asset);
350
+ return html `<section>
351
+ <h3>${i18next.t('figure.label.data-bindings')}${this.help.button('figure.text.data-bindings-explained')}</h3>
352
+ ${this.help.hint('figure.text.data-bindings-explained')}
353
+ ${bindings.map(b => this.renderBinding(asset, choices, b))}
354
+ ${rows.length ? this.renderAddBinding(asset, rows, choices) : html `<p hint>${i18next.t('figure.text.data-no-fields')}</p>`}
355
+ </section>`;
356
+ }
357
+ renderBinding(asset, choices, b) {
358
+ const open = this.opened === b.id;
359
+ const set = (patch) => {
360
+ const next = { ...b, ...patch };
361
+ if (patch.param === undefined && 'param' in patch)
362
+ delete next.param;
363
+ this.edit({ kind: 'set-data-binding', binding: next });
364
+ };
173
365
  return html `<section item>
174
366
  <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 })}>
367
+ <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
368
+ <strong>${b.accessor}</strong> → ${this.behaviorName(asset, choices, b.behavior)} <small>· ${i18next.t('figure.label.data-rule-' + b.rule)}</small>
369
+ </button>
370
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
177
371
  <md-icon>close</md-icon>
178
372
  </button>
179
373
  </div>
180
- <div form>
181
- ${c.target.kind === 'state'
374
+ ${open ? this.renderBindingDetail(asset, b, set) : nothing}
375
+ </section>`;
376
+ }
377
+ renderBindingDetail(asset, b, set) {
378
+ const node = fieldAt(asset.dataInterface?.schema ?? [], b.accessor.replace(/\[\d+\]/g, '[]'));
379
+ const value = (e) => e.target.value;
380
+ const behavior = b.behavior;
381
+ if (behavior.kind === 'items')
382
+ return this.renderItemFields(asset, b, node, set);
383
+ return html `<div form>
384
+ <span>${i18next.t('figure.label.data-rule')}</span>
385
+ <select ?disabled=${this.locked} @change=${(e) => set({ rule: value(e), param: firstParamOf(value(e), node, b) })}>
386
+ ${['value', 'map', 'range', 'scale'].map(r => html `<option value=${r} ?selected=${r === b.rule}>${i18next.t('figure.label.data-rule-' + r)}</option>`)}
387
+ </select>
388
+ ${behavior.kind === 'state'
182
389
  ? 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>`)}
390
+ <select ?disabled=${this.locked} @change=${(e) => set({ behavior: { ...behavior, sourceMode: value(e) } })}>
391
+ ${['observed', 'commanded'].map(m => html `<option value=${m} ?selected=${behavior.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
185
392
  </select>`
186
393
  : 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'
394
+ ${behavior.kind === 'pose'
395
+ ? html `<span>${i18next.t('figure.label.data-seconds')}</span>
396
+ <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 } })} />`
397
+ : nothing}
398
+ ${b.rule === 'scale'
195
399
  ? 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) } })} />
400
+ <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
401
  <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) } })} />`
402
+ <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
403
  : nothing}
200
404
  </div>
201
- ${t.kind === 'lookup' ? this.renderCases(asset, c, set) : nothing}
202
- </section>`;
405
+ ${b.rule === 'map' ? this.renderCases(asset, b, node, set) : b.rule === 'range' ? this.renderRanges(asset, b, set) : nothing}`;
203
406
  }
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 ?? []) : [];
407
+ /** A result editor fitting what the behavior takes: a colour, a clip of the group, true or false, a code, a number. */
408
+ renderResult(asset, b, value, write) {
409
+ const behavior = b.behavior;
410
+ const typed = (e) => e.target.value;
411
+ if (behavior.kind === 'palette')
412
+ /* A colour not set is not black: it is offered, started at the colour the token has now. */
413
+ return value === undefined
414
+ ? html `<button act ?disabled=${this.locked} @click=${() => write(asset.palette[behavior.token] ?? '#000000')}>${i18next.t('figure.button.data-set-result')}</button>`
415
+ : html `<input type="color" .value=${String(value)} ?disabled=${this.locked} @change=${(e) => write(typed(e))} />`;
416
+ if (behavior.kind === 'motion-clip') {
417
+ const clips = (asset.motionGroups ?? []).find(g => g.id === behavior.group)?.clips ?? [];
418
+ return html `<select ?disabled=${this.locked} @change=${(e) => write(typed(e))}>
419
+ ${clips.map(clip => html `<option value=${clip} ?selected=${clip === value}>${clip}</option>`)}
420
+ </select>`;
421
+ }
422
+ if (behavior.kind === 'motion-paused')
423
+ return html `<select ?disabled=${this.locked} @change=${(e) => write(typed(e) === 'true')}>
424
+ ${[false, true].map(v => html `<option value=${String(v)} ?selected=${v === value}>${i18next.t(v ? 'figure.label.data-paused' : 'figure.label.data-playing')}</option>`)}
425
+ </select>`;
426
+ if (behavior.kind === 'status')
427
+ return html `<input .value=${String(value ?? '')} ?disabled=${this.locked} @change=${(e) => write(typed(e))} />`;
428
+ return html `<input type="number" step="any" .value=${value === undefined ? '' : String(value)} ?disabled=${this.locked} @change=${(e) => write(e.target.valueAsNumber)} />`;
429
+ }
430
+ /** A result per value of a closed field, and one for every other value. */
431
+ renderCases(asset, b, node, set) {
432
+ const param = b.param ?? {};
433
+ const domain = node ? domainOf(node).map(String) : [];
434
+ const keys = [...new Set([...domain, ...Object.keys(param).filter(k => k !== 'default')])];
435
+ const write = (key) => (v) => {
436
+ const next = { ...param };
437
+ if (v === undefined || (typeof v === 'number' && Number.isNaN(v)))
438
+ delete next[key];
439
+ else
440
+ next[key] = v;
441
+ set({ param: next });
442
+ };
443
+ const word = (k) => (node?.kind === 'boolean' ? i18next.t(k === 'true' ? 'figure.label.data-true' : 'figure.label.data-false') : k);
444
+ /* Every value of a closed field has its own result: no other value comes, unless one was set before. */
445
+ const covered = domain.length > 0 && domain.every(v => param[v] !== undefined);
211
446
  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)} />`}`)}
447
+ ${keys.map(k => html `<span>${word(k)}</span>${this.renderResult(asset, b, param[k], write(k))}`)}
448
+ ${covered && param.default === undefined ? nothing : html `<span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, write('default'))}`}
220
449
  </div>`;
221
450
  }
222
- renderAddConnection(asset, properties) {
451
+ /** Intervals of a number, each from (inclusive) to (exclusive), either end left open, and one result for the rest. */
452
+ renderRanges(asset, b, set) {
453
+ const param = b.param ?? {};
454
+ const entries = Object.entries(param).filter(([k]) => k !== 'default');
455
+ const rebuild = (list) => set({ param: { ...Object.fromEntries(list), ...(param.default !== undefined ? { default: param.default } : {}) } });
456
+ const ends = (key) => {
457
+ const [from = '', to = ''] = key.split('~');
458
+ return { from, to };
459
+ };
460
+ const rekey = (i, end, typed) => rebuild(entries.map(([k, v], j) => {
461
+ if (j !== i)
462
+ return [k, v];
463
+ const e = ends(k);
464
+ const next = { ...e, [end]: typed.trim() };
465
+ return [`${next.from}~${next.to}`, v];
466
+ }));
467
+ return html `<div ranges>
468
+ <small>${i18next.t('figure.label.data-range-from')}</small><small>${i18next.t('figure.label.data-range-to')}</small><small></small><span></span>
469
+ ${entries.map(([k, v], i) => {
470
+ const e = ends(k);
471
+ return html `<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev) => rekey(i, 'from', ev.target.value)} />
472
+ <input type="number" step="any" .value=${e.to} ?disabled=${this.locked} @change=${(ev) => rekey(i, 'to', ev.target.value)} />
473
+ ${this.renderResult(asset, b, v, out => rebuild(entries.map(([kk, vv], j) => [kk, j === i ? out : vv])))}
474
+ <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>`;
475
+ })}
476
+ </div>
477
+ <div cases>
478
+ <span>${i18next.t('figure.label.data-otherwise')}</span>${this.renderResult(asset, b, param.default, v => set({ param: { ...param, ...(v === undefined ? {} : { default: v }) } }))}
479
+ </div>
480
+ <button act ?disabled=${this.locked} @click=${() => rebuild([...entries, [freeRangeKey(entries.map(([k]) => k)), entries.at(-1)?.[1] ?? param.default ?? 0]])}>
481
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-range')}
482
+ </button>`;
483
+ }
484
+ /** Which field of each element is its id, its type, its place, its height in the stack, its status. */
485
+ renderItemFields(asset, b, node, set) {
486
+ const behavior = b.behavior;
487
+ if (behavior.kind !== 'items')
488
+ return nothing;
489
+ let element = node;
490
+ while (element?.kind === 'list')
491
+ element = element.item;
492
+ const fields = element?.kind === 'object' ? element.fields.filter(f => f.kind === 'number' || f.kind === 'text' || f.kind === 'boolean').map(f => f.name) : [];
493
+ const pick = (role, typed) => {
494
+ const next = { ...behavior, [role]: typed };
495
+ if (!typed)
496
+ delete next[role];
497
+ set({ behavior: next });
498
+ };
499
+ return html `<div form>
500
+ ${node?.kind === 'list'
501
+ ? ['id', 'type', 'place', 'stack', 'status'].map(role => html `<span>${i18next.t('figure.label.data-item-' + role)}</span>
502
+ <select ?disabled=${this.locked} @change=${(e) => pick(role, e.target.value)}>
503
+ <option value="" ?selected=${!behavior[role]}>-</option>
504
+ ${fields.map(f => html `<option value=${f} ?selected=${f === behavior[role]}>${f}</option>`)}
505
+ </select>`)
506
+ : nothing}
507
+ <span>${i18next.t('figure.label.data-item-slot')}</span>
508
+ <input .value=${behavior.slot ?? ''} ?disabled=${this.locked} @change=${(e) => {
509
+ const slot = e.target.value.trim();
510
+ const next = { ...behavior, slot };
511
+ if (!slot)
512
+ delete next.slot;
513
+ set({ behavior: next });
514
+ }} />
515
+ </div>`;
516
+ }
517
+ renderAddBinding(asset, rows, choices) {
518
+ const bindable = rows.filter(r => r.node.kind !== 'object');
519
+ if (!bindable.length)
520
+ return nothing;
223
521
  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];
522
+ const row = bindable.find(r => r.path === a.path) ?? bindable[0];
523
+ const fitting = choices.filter(c => fitsField(row.node, c));
524
+ const choice = fitting.find(c => c.key === a.behavior) ?? fitting[0];
525
+ /* A list is read whole by items; one value of it, through its first element. */
526
+ const accessor = row.node.kind === 'list' && choice?.kind === 'items' ? row.path.replace(/\[\]$/, '') : accessorOfRow(row.path);
227
527
  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>`)}
528
+ <span>${i18next.t('figure.label.data-field')}</span>
529
+ <select @change=${(e) => (this.connecting = { ...a, path: e.target.value })}>
530
+ ${bindable.map(r => html `<option value=${r.path} ?selected=${r === row}>${r.path}</option>`)}
231
531
  </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>`)}
532
+ <span>${i18next.t('figure.label.data-behavior')}</span>
533
+ <select ?disabled=${!fitting.length} @change=${(e) => (this.connecting = { ...a, behavior: e.target.value })}>
534
+ ${fitting.map(c => html `<option value=${c.key} ?selected=${c === choice}>${this.behaviorName(asset, choices, c.behavior)}</option>`)}
235
535
  </select>
236
536
  </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')}
537
+ ${fitting.length ? nothing : html `<p hint>${i18next.t('figure.text.data-no-behavior')}</p>`}
538
+ <button
539
+ act
540
+ ?disabled=${this.locked || !choice}
541
+ @click=${() => {
542
+ const binding = bindingDraftOf(asset, accessor, row.node, choice);
543
+ this.edit({ kind: 'set-data-binding', binding });
544
+ this.opened = binding.id;
545
+ }}
546
+ >
547
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-binding')}
239
548
  </button>`;
240
549
  }
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);
550
+ /* ---------------------------------------------------------------- status ports */
551
+ renderPorts(asset) {
552
+ const ports = asset.dataInterface?.ports ?? [];
553
+ const tokens = Object.keys(asset.palette);
554
+ const parts = partsOf(asset);
555
+ const set = (port, patch) => this.edit({ kind: 'set-status-port', port: { ...port, ...patch } });
556
+ const chosen = (e) => [...e.target.selectedOptions].map(o => o.value);
245
557
  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')}
558
+ <h3>${i18next.t('figure.label.data-ports')}${this.help.button('figure.text.data-ports-explained')}</h3>
559
+ ${this.help.hint('figure.text.data-ports-explained')}
560
+ ${ports.map(port => html `<section item>
561
+ <div head>
562
+ <strong>${port.label}</strong>
563
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-port')} @click=${() => this.edit({ kind: 'remove-status-port', id: port.id })}>
564
+ <md-icon>close</md-icon>
565
+ </button>
566
+ </div>
567
+ <div form>
568
+ <span>${i18next.t('figure.label.data-label')}</span>
569
+ <input .value=${port.label} ?disabled=${this.locked} @change=${(e) => set(port, { label: e.target.value.trim() })} />
570
+ <span>${i18next.t('figure.label.data-port-tokens')}</span>
571
+ <select multiple size=${Math.min(4, Math.max(tokens.length, 1))} ?disabled=${this.locked} @change=${(e) => set(port, { tokens: chosen(e) })}>
572
+ ${tokens.map(t => html `<option value=${t} ?selected=${port.tokens.includes(t)}>${t}</option>`)}
573
+ </select>
574
+ <span>${i18next.t('figure.label.data-port-parts')}</span>
575
+ <select multiple size=${Math.min(4, Math.max(parts.length, 1))} ?disabled=${this.locked} @change=${(e) => set(port, { parts: chosen(e) })}>
576
+ ${parts.map(p => html `<option value=${p} ?selected=${port.parts.includes(p)}>${p}</option>`)}
577
+ </select>
578
+ </div>
579
+ </section>`)}
248
580
  <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>`}`)}
581
+ <span>${i18next.t('figure.label.data-label')}</span>
582
+ <input .value=${this.porting} @input=${(e) => (this.porting = e.target.value)} />
255
583
  </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>`}
584
+ <button
585
+ act
586
+ ?disabled=${this.locked || !this.porting.trim()}
587
+ @click=${() => {
588
+ this.edit({ kind: 'set-status-port', port: portDraftOf(asset, this.porting) });
589
+ this.porting = '';
590
+ }}
591
+ >
592
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-port')}
593
+ </button>
594
+ </section>`;
595
+ }
596
+ /* ---------------------------------------------------------------- the sample */
597
+ /** 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. */
598
+ renderSample(asset) {
599
+ const text = this.sample || JSON.stringify(figureDataExample(asset.dataInterface), null, 2);
600
+ let data;
601
+ let unreadable = false;
602
+ try {
603
+ data = JSON.parse(text);
604
+ }
605
+ catch {
606
+ unreadable = true;
607
+ }
608
+ const read = unreadable ? null : sampleOf(asset, data);
609
+ const r = read?.result;
610
+ return html `<section>
611
+ <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
612
+ ${this.help.hint('figure.text.data-sample-explained')}
613
+ <textarea .value=${text} @input=${(e) => this.try(e.target.value)}></textarea>
614
+ ${unreadable
615
+ ? html `<p hint>${i18next.t('figure.text.data-not-json')}</p>`
616
+ : read.issues.length
617
+ ? html `<p hint>${read.issues.map(i => i18next.t('figure.text.data-value-' + i.reason, { path: i.path || '-' })).join(' · ')}</p>`
618
+ : html `<ul result>
619
+ ${Object.entries(r.states).map(([input, s]) => html `<li>${asset.stateInputs?.[input]?.label ?? input} = ${s.value}</li>`)}
620
+ ${Object.entries(r.palette).map(([token, colour]) => html `<li>${token} = ${colour}</li>`)}
621
+ ${Object.entries(r.motion).map(([group, m]) => html `<li>
622
+ ${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}:
623
+ ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`, m.paused ? i18next.t('figure.label.data-paused') : ''].filter(Boolean).join(' ')}
624
+ </li>`)}
625
+ ${Object.entries(r.status).map(([port, code]) => html `<li>${(asset.dataInterface?.ports ?? []).find(p => p.id === port)?.label ?? port}: ${code}</li>`)}
626
+ ${Object.entries(r.pose).map(([axis, p]) => html `<li>${i18next.t('figure.label.data-axis-' + axis)} = ${p.value}</li>`)}
627
+ ${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>`)}
628
+ </ul>`}
629
+ ${r && Object.keys(r.pose).length ? html `<p hint>${i18next.t('figure.text.data-sample-moves-all')}</p>` : nothing}
263
630
  <label toggle>
264
631
  <input
265
632
  type="checkbox"
266
633
  .checked=${this.sampling}
267
634
  @change=${(e) => {
268
635
  this.sampling = e.target.checked;
269
- this.tell(values);
636
+ this.tell(unreadable ? null : data);
270
637
  }}
271
638
  />
272
639
  ${i18next.t('figure.label.data-sample-in-preview')}
273
640
  </label>
274
641
  </section>`;
275
642
  }
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])));
643
+ try(typed) {
644
+ this.sample = typed;
645
+ try {
646
+ this.tell(JSON.parse(typed));
647
+ }
648
+ catch {
649
+ /* Not JSON yet: the preview keeps what it ran on. */
650
+ }
280
651
  }
281
652
  /** 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 }));
653
+ tell(data) {
654
+ this.dispatchEvent(new CustomEvent('data-sample', { detail: { data: this.sampling ? data : null }, bubbles: true, composed: true }));
284
655
  }
285
656
  edit(edit) {
286
657
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit }, bubbles: true, composed: true }));
@@ -294,14 +665,30 @@ __decorate([
294
665
  property({ type: Boolean }),
295
666
  __metadata("design:type", Object)
296
667
  ], FigureDataPanel.prototype, "locked", void 0);
668
+ __decorate([
669
+ state(),
670
+ __metadata("design:type", Object)
671
+ ], FigureDataPanel.prototype, "chosen", void 0);
297
672
  __decorate([
298
673
  state(),
299
674
  __metadata("design:type", Object)
300
675
  ], FigureDataPanel.prototype, "adding", void 0);
676
+ __decorate([
677
+ state(),
678
+ __metadata("design:type", Object)
679
+ ], FigureDataPanel.prototype, "pasted", void 0);
301
680
  __decorate([
302
681
  state(),
303
682
  __metadata("design:type", Object)
304
683
  ], FigureDataPanel.prototype, "connecting", void 0);
684
+ __decorate([
685
+ state(),
686
+ __metadata("design:type", Object)
687
+ ], FigureDataPanel.prototype, "opened", void 0);
688
+ __decorate([
689
+ state(),
690
+ __metadata("design:type", Object)
691
+ ], FigureDataPanel.prototype, "porting", void 0);
305
692
  __decorate([
306
693
  state(),
307
694
  __metadata("design:type", Object)
@@ -314,4 +701,29 @@ FigureDataPanel = __decorate([
314
701
  customElement('figure-data-panel')
315
702
  ], FigureDataPanel);
316
703
  export { FigureDataPanel };
704
+ /** A node of a kind, started empty. */
705
+ function nodeOfKind(kind) {
706
+ if (kind === 'object')
707
+ return { kind, fields: [] };
708
+ if (kind === 'list')
709
+ return { kind, item: { kind: 'object', fields: [] } };
710
+ return { kind };
711
+ }
712
+ /** The param a rule starts with when the author switches to it: one result per value, one open interval, a unit scale, or none. */
713
+ function firstParamOf(rule, node, b) {
714
+ const keep = b.param?.default;
715
+ if (rule === 'value')
716
+ return undefined;
717
+ if (rule === 'scale')
718
+ return { scale: 1, offset: 0 };
719
+ if (rule === 'range')
720
+ return { [`${node?.kind === 'number' ? (node.min ?? '') : ''}~`]: keep ?? 0 };
721
+ const domain = node ? domainOf(node).map(String) : [];
722
+ return domain.length ? Object.fromEntries(domain.map(v => [v, keep ?? 0])) : { default: keep ?? 0 };
723
+ }
724
+ /** The next interval: from the last one's end, open above; open at both ends when there is none. */
725
+ function freeRangeKey(taken) {
726
+ const end = taken.at(-1)?.split('~')[1] ?? '';
727
+ return `${end}~`;
728
+ }
317
729
  //# sourceMappingURL=figure-data-panel.js.map