@things-factory/figure-ui 10.1.162 → 10.1.164

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