@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.
- package/client/modeller/data-interface.ts +334 -79
- package/client/modeller/editor.ts +4 -3
- package/client/modeller/figure-data-panel.ts +683 -193
- package/client/modeller/figure-preview.ts +46 -30
- package/client/modeller/refusals.ts +18 -14
- package/client/viewparts/figure-detail-dock.ts +9 -3
- package/dist-client/modeller/data-interface.d.ts +91 -30
- package/dist-client/modeller/data-interface.js +314 -71
- 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 +3 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +42 -13
- package/dist-client/modeller/figure-data-panel.js +679 -186
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +7 -5
- package/dist-client/modeller/figure-preview.js +47 -30
- 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/dist-client/viewparts/figure-detail-dock.js +9 -3
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
- package/package.json +7 -7
- package/test/browser/build.mjs +9 -0
- package/test/browser/detail-tabs-page.js +36 -0
- package/test/browser/detail-tabs.check.mjs +123 -0
- package/test/data-motion.test.ts +173 -53
- package/translations/en.json +122 -43
- package/translations/es.json +122 -43
- package/translations/ja.json +122 -43
- package/translations/ko.json +122 -43
- package/translations/ms.json +122 -43
- 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,
|
|
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
|
+
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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:
|
|
21
|
-
*
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
@state() private
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
83
|
-
const connections = asset.dataInterface?.connections ?? []
|
|
178
|
+
const rows = schemaRowsOf(asset)
|
|
84
179
|
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}
|
|
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
|
-
|
|
102
|
-
|
|
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>${
|
|
108
|
-
<button
|
|
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-
|
|
114
|
-
<input .value=${
|
|
115
|
-
<span>${i18next.t('figure.label.
|
|
116
|
-
<input .value=${
|
|
117
|
-
|
|
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)
|
|
120
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
128
|
-
:
|
|
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
|
-
|
|
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
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
${
|
|
145
|
-
|
|
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
|
|
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.
|
|
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
|
|
157
|
-
${
|
|
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 || !
|
|
327
|
+
?disabled=${this.locked || !named}
|
|
163
328
|
@click=${() => {
|
|
164
|
-
this.edit({ kind: 'set-data-
|
|
165
|
-
this.adding = { ...a,
|
|
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-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
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
|
-
<
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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({
|
|
193
|
-
${(['observed', 'commanded'] as const).map(m => html`<option value=${m} ?selected=${
|
|
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
|
-
${
|
|
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'
|
|
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(
|
|
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(
|
|
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
|
-
${
|
|
211
|
-
|
|
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
|
|
215
|
-
private renderCases(asset: FigureAsset,
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
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
|
-
${
|
|
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
|
-
)}
|
|
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
|
-
|
|
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
|
|
237
|
-
const
|
|
238
|
-
const choice =
|
|
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-
|
|
241
|
-
<select @change=${(e: Event) => (this.connecting = { ...a,
|
|
242
|
-
${
|
|
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-
|
|
245
|
-
<select ?disabled=${!
|
|
246
|
-
${
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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-
|
|
260
|
-
${this.help.hint('figure.text.data-
|
|
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
|
-
|
|
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
|
-
)}
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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(
|
|
295
|
-
this.sample =
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
302
|
-
this.dispatchEvent(new CustomEvent('data-sample', { detail: {
|
|
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
|
+
}
|