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