@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.
- package/client/modeller/data-interface.ts +235 -67
- package/client/modeller/editor.ts +3 -3
- package/client/modeller/figure-data-panel.ts +602 -193
- package/client/modeller/figure-preview.ts +78 -26
- package/client/modeller/refusals.ts +18 -14
- package/dist-client/modeller/data-interface.d.ts +59 -27
- package/dist-client/modeller/data-interface.js +237 -60
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +1 -1
- package/dist-client/modeller/editor.js +2 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +35 -13
- package/dist-client/modeller/figure-data-panel.js +598 -186
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +10 -3
- package/dist-client/modeller/figure-preview.js +81 -26
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +2 -2
- package/dist-client/modeller/refusals.js +24 -18
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/browser/product-page.check.mjs +1 -1
- package/test/browser/screen.check.mjs +7 -7
- package/test/data-motion.test.ts +143 -50
- package/translations/en.json +109 -43
- package/translations/es.json +109 -43
- package/translations/ja.json +109 -43
- package/translations/ko.json +109 -43
- package/translations/ms.json +109 -43
- 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,
|
|
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 {
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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:
|
|
21
|
-
*
|
|
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 = [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
@state() private
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
83
|
-
const connections = asset.dataInterface?.connections ?? []
|
|
173
|
+
const rows = schemaRowsOf(asset)
|
|
84
174
|
return html`
|
|
85
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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>${
|
|
108
|
-
<button
|
|
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-
|
|
114
|
-
<input .value=${
|
|
115
|
-
<span>${i18next.t('figure.label.
|
|
116
|
-
<input .value=${
|
|
117
|
-
|
|
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)
|
|
120
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
128
|
-
:
|
|
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
|
-
|
|
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
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
${
|
|
145
|
-
|
|
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
|
|
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.
|
|
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
|
|
157
|
-
${
|
|
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 || !
|
|
322
|
+
?disabled=${this.locked || !named}
|
|
163
323
|
@click=${() => {
|
|
164
|
-
this.edit({ kind: 'set-data-
|
|
165
|
-
this.adding = { ...a,
|
|
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-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
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
|
-
<
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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({
|
|
193
|
-
${(['observed', 'commanded'] as const).map(m => html`<option value=${m} ?selected=${
|
|
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
|
-
${
|
|
197
|
-
?
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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(
|
|
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
|
-
${
|
|
211
|
-
|
|
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
|
|
215
|
-
private renderCases(asset: FigureAsset,
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
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
|
-
${
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
const
|
|
238
|
-
const choice =
|
|
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-
|
|
241
|
-
<select @change=${(e: Event) => (this.connecting = { ...a,
|
|
242
|
-
${
|
|
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-
|
|
245
|
-
<select ?disabled=${!
|
|
246
|
-
${
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
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-
|
|
260
|
-
${this.help.hint('figure.text.data-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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(
|
|
295
|
-
this.sample =
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
302
|
-
this.dispatchEvent(new CustomEvent('data-sample', { detail: {
|
|
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
|
+
}
|