@things-factory/figure-ui 10.1.156 → 10.1.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/client/modeller/clips.ts +43 -4
  2. package/client/modeller/data-interface.ts +110 -0
  3. package/client/modeller/editor.ts +16 -2
  4. package/client/modeller/figure-clips.ts +125 -12
  5. package/client/modeller/figure-data-panel.ts +308 -0
  6. package/client/modeller/figure-preview.ts +41 -4
  7. package/client/modeller/refusals.ts +46 -0
  8. package/dist-client/modeller/clips.d.ts +32 -2
  9. package/dist-client/modeller/clips.js +32 -4
  10. package/dist-client/modeller/clips.js.map +1 -1
  11. package/dist-client/modeller/data-interface.d.ts +42 -0
  12. package/dist-client/modeller/data-interface.js +95 -0
  13. package/dist-client/modeller/data-interface.js.map +1 -0
  14. package/dist-client/modeller/editor.d.ts +3 -0
  15. package/dist-client/modeller/editor.js +18 -0
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/figure-clips.d.ts +10 -0
  18. package/dist-client/modeller/figure-clips.js +125 -10
  19. package/dist-client/modeller/figure-clips.js.map +1 -1
  20. package/dist-client/modeller/figure-data-panel.d.ts +39 -0
  21. package/dist-client/modeller/figure-data-panel.js +317 -0
  22. package/dist-client/modeller/figure-data-panel.js.map +1 -0
  23. package/dist-client/modeller/figure-preview.d.ts +7 -0
  24. package/dist-client/modeller/figure-preview.js +49 -4
  25. package/dist-client/modeller/figure-preview.js.map +1 -1
  26. package/dist-client/modeller/refusals.d.ts +5 -0
  27. package/dist-client/modeller/refusals.js +58 -0
  28. package/dist-client/modeller/refusals.js.map +1 -1
  29. package/dist-client/route.d.ts +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +6 -6
  32. package/test/data-motion.test.ts +135 -0
  33. package/translations/en.json +85 -2
  34. package/translations/es.json +84 -1
  35. package/translations/ja.json +85 -2
  36. package/translations/ko.json +85 -2
  37. package/translations/ms.json +85 -2
  38. package/translations/zh.json +85 -2
@@ -50,12 +50,51 @@ export function clipsOf(asset: FigureAsset): Clip[] {
50
50
  return [...clips.values()]
51
51
  }
52
52
 
53
- /** The controls no clip plays yet -- the ones a new track can take. One driver per control. */
54
- export function undrivenOf(asset: FigureAsset): string[] {
55
- const driven = new Set((asset.drivers ?? []).map(d => d.state))
56
- return asset.document.model.inputs.filter(i => i.role === 'state' && !driven.has(i.id)).map(i => i.id)
53
+ /**
54
+ * The controls a new track of `clip` can take (ADR-0051 §3.2): one the clip does not play yet, and that no clip plays
55
+ * outside the clip's motion group -- `group` for a clip not made yet. A clip in no group takes only a control no clip
56
+ * plays. Without a clip, the controls no clip plays at all.
57
+ */
58
+ export function undrivenOf(asset: FigureAsset, clip = '', group?: string): string[] {
59
+ const groupOf = new Map((asset.motionGroups ?? []).flatMap(g => g.clips.map(c => [c, g.id] as const)))
60
+ const mine = group ?? (clip ? groupOf.get(clip) : undefined)
61
+ const taken = (state: string) =>
62
+ (asset.drivers ?? []).some(d => d.state === state && (d.clip === clip || mine === undefined || groupOf.get(d.clip) !== mine))
63
+ return asset.document.model.inputs.filter(i => i.role === 'state' && !taken(i.id)).map(i => i.id)
64
+ }
65
+
66
+ /** A motion group as the panel shows it: its setting, and the clips it holds in the clips' order. */
67
+ export interface ClipGroup {
68
+ id: string
69
+ label: string
70
+ clips: string[]
71
+ defaultClip: string
72
+ transitionSeconds: number
73
+ }
74
+
75
+ /** The figure's motion groups, and the clips in none -- the ones a group can take. */
76
+ export function clipGroupsOf(asset: FigureAsset): { groups: ClipGroup[]; free: string[] } {
77
+ const groups = (asset.motionGroups ?? []).map(g => ({ ...g, clips: [...g.clips] }))
78
+ const grouped = new Set(groups.flatMap(g => g.clips))
79
+ const free = [...new Set((asset.drivers ?? []).map(d => d.clip))].filter(c => !grouped.has(c))
80
+ return { groups, free }
81
+ }
82
+
83
+ /** A group id not yet taken: `motion`, then `motion-2`, `motion-3`, … An id is not made from a label a person types. */
84
+ export function freeGroupIdOf(asset: FigureAsset): string {
85
+ const taken = new Set((asset.motionGroups ?? []).map(g => g.id))
86
+ if (!taken.has('motion')) return 'motion'
87
+ let n = 2
88
+ while (taken.has(`motion-${n}`)) n++
89
+ return `motion-${n}`
57
90
  }
58
91
 
92
+ /** The edit that writes a group back with a change: one whole group, as the kernel takes it. */
93
+ export const groupEdit = (group: ClipGroup, patch: Partial<Omit<ClipGroup, 'id'>> = {}) => ({
94
+ kind: 'set-motion-group' as const,
95
+ group: { id: group.id, label: patch.label ?? group.label, clips: patch.clips ?? group.clips, defaultClip: patch.defaultClip ?? group.defaultClip, transitionSeconds: patch.transitionSeconds ?? group.transitionSeconds }
96
+ })
97
+
59
98
  /** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
60
99
  export function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[] {
61
100
  const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))
@@ -0,0 +1,110 @@
1
+ /*
2
+ * The figure's public data properties and what each moves (ADR-0051 §2.2): what the data tab offers and shows. Every
3
+ * rule is figure-model's -- which transform a property may take, whether a connection holds, what a sample moves; this
4
+ * only lists what there is to choose and drafts a first connection the kernel then checks. Kept apart from the element so
5
+ * it is tested without a browser.
6
+ */
7
+ import { compileFigureDataInterface, evaluateFigureDataInterface, validateFigureDataValues, FIGURE_PART_ID } from '@hatiolab/figure-model'
8
+ import type { FigureAsset, FigureDataConnection, FigureDataProperty, FigureDataResult, FigureDataTarget, FigureDataValue, FigureDataValueIssue } from '@hatiolab/figure-model'
9
+
10
+ /** Something a connection can write, with what the screen shows of it. */
11
+ export interface DataTargetChoice {
12
+ /** One string per target, for a select's value. */
13
+ key: string
14
+ target: FigureDataTarget
15
+ /** The control's label, the colour token, or the group's label. */
16
+ name: string
17
+ unit?: string
18
+ min?: number
19
+ max?: number
20
+ }
21
+
22
+ export const targetKeyOf = (t: FigureDataTarget): string =>
23
+ t.kind === 'state' ? `state:${t.input}` : t.kind === 'palette' ? `palette:${t.token}` : `${t.kind}:${t.group}`
24
+
25
+ /**
26
+ * What a property of this kind can be connected to: a number moves a control or a group's speed; an enum or a boolean
27
+ * moves a control, a colour or which clip of a group plays (through a case per value). A control is offered observed: a
28
+ * value read from the machine applies as it comes.
29
+ */
30
+ export function dataTargetsOf(asset: FigureAsset, kind: FigureDataProperty['kind']): DataTargetChoice[] {
31
+ const out: DataTargetChoice[] = []
32
+ for (const input of asset.document.model.inputs) {
33
+ if (input.role !== 'state') continue
34
+ const target: FigureDataTarget = { kind: 'state', input: input.id, sourceMode: 'observed' }
35
+ out.push({ key: targetKeyOf(target), target, name: asset.stateInputs?.[input.id]?.label ?? input.id, unit: input.unit, min: input.min, max: input.max })
36
+ }
37
+ if (kind !== 'number')
38
+ for (const token of Object.keys(asset.palette)) {
39
+ const target: FigureDataTarget = { kind: 'palette', token }
40
+ out.push({ key: targetKeyOf(target), target, name: token })
41
+ }
42
+ for (const group of asset.motionGroups ?? []) {
43
+ const target: FigureDataTarget = kind === 'number' ? { kind: 'motion-speed', group: group.id } : { kind: 'motion-clip', group: group.id }
44
+ out.push({ key: targetKeyOf(target), target, name: group.label })
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** The values a lookup has a case for: an enum's values, or both booleans. Nothing for a number. */
50
+ export const domainOf = (property: FigureDataProperty): FigureDataValue[] =>
51
+ property.kind === 'enum' ? [...property.values] : property.kind === 'boolean' ? [false, true] : []
52
+
53
+ /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
54
+ export function freeDataIdOf(taken: Iterable<string>, base: string): string {
55
+ const used = new Set(taken)
56
+ const cleaned = base.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '') || 'data'
57
+ const id = FIGURE_PART_ID.test(cleaned) ? cleaned : 'data'
58
+ if (!used.has(id)) return id
59
+ let n = 2
60
+ while (used.has(`${id}-${n}`)) n++
61
+ return `${id}-${n}`
62
+ }
63
+
64
+ /**
65
+ * A first connection of a property to a target, for the author to adjust: a number as it is in the control's own unit,
66
+ * or scaled from another; an enum or a boolean case by case, each case at what the target shows now -- the control's
67
+ * rest value, the colour, the group's first clip. The kernel says whether it holds.
68
+ */
69
+ export function connectionDraftOf(asset: FigureAsset, property: FigureDataProperty, choice: DataTargetChoice): FigureDataConnection {
70
+ const taken = (asset.dataInterface?.connections ?? []).map(c => c.id)
71
+ const id = freeDataIdOf(taken, `${property.id}-${choice.target.kind === 'state' ? choice.target.input : choice.target.kind === 'palette' ? 'colour' : choice.target.group}`)
72
+ const target = choice.target
73
+ if (property.kind === 'number') {
74
+ const same = target.kind === 'motion-speed' || (target.kind === 'state' && choice.unit === property.unit)
75
+ return { id, property: property.id, transform: same ? { kind: 'identity' } : { kind: 'affine', scale: 1, offset: 0 }, target }
76
+ }
77
+ const now: FigureDataValue =
78
+ target.kind === 'state'
79
+ ? (asset.stateDefaults[target.input] ?? 0)
80
+ : target.kind === 'palette'
81
+ ? (asset.palette[target.token] ?? '#000000')
82
+ : ((asset.motionGroups ?? []).find(g => g.id === target.group)?.defaultClip ?? '')
83
+ return { id, property: property.id, transform: { kind: 'lookup', cases: domainOf(property).map(when => ({ when, value: now })) }, target }
84
+ }
85
+
86
+ /** What a property looks like when first made: a number 0 to 1, a boolean off, an enum of two values. */
87
+ export function propertyDraftOf(asset: FigureAsset, label: string, kind: FigureDataProperty['kind']): FigureDataProperty {
88
+ const id = freeDataIdOf((asset.dataInterface?.properties ?? []).map(p => p.id), label)
89
+ if (kind === 'number') return { id, label, kind, unit: 'ratio', min: 0, max: 1, default: 0 }
90
+ if (kind === 'boolean') return { id, label, kind, default: false }
91
+ return { id, label, kind, values: ['off', 'on'], default: 'off' }
92
+ }
93
+
94
+ /** A value typed in a sample field, as the property takes it: a number, true or false, or the enum value. */
95
+ export function sampleValueOf(property: FigureDataProperty, typed: string): FigureDataValue {
96
+ if (property.kind === 'number') return typed.trim() === '' ? Number.NaN : Number(typed)
97
+ if (property.kind === 'boolean') return typed === 'true'
98
+ return typed
99
+ }
100
+
101
+ /**
102
+ * What a sample of values would move, as the figure would run it: the kernel's check and evaluation of the interface.
103
+ * Refused whole when one value cannot be taken, with each one's reason, as a live snapshot is.
104
+ */
105
+ export function dataSampleOf(asset: FigureAsset, values: Record<string, FigureDataValue>): { result: FigureDataResult } | { issues: FigureDataValueIssue[] } {
106
+ const plan = compileFigureDataInterface(asset)
107
+ if (!plan) return { issues: [] }
108
+ const issues = validateFigureDataValues(plan, values)
109
+ return issues.length ? { issues } : { result: evaluateFigureDataInterface(plan, values) }
110
+ }
@@ -24,6 +24,7 @@ import { motionEndsOf, type MotionEnds } from './motion-ends.js'
24
24
  import './figure-links.js'
25
25
  import './figure-controls.js'
26
26
  import './figure-clips.js'
27
+ import './figure-data-panel.js'
27
28
  import './figure-preview.js'
28
29
  import './figure-settings.js'
29
30
  import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
@@ -68,10 +69,10 @@ import { startingShapeKindOfBody } from './starting-shape.js'
68
69
 
69
70
 
70
71
  /**
71
- * The side's tabs: properties, what it does (places), motion, the verdict, the view, the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
72
+ * The side's tabs: properties, what it does (places), motion, its data (ADR-0051), the verdict, the view, the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
72
73
  * parts keeps showing the newly chosen part.
73
74
  */
74
- type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
75
+ type SideTab = 'properties' | 'capability' | 'motion' | 'data' | 'verdict' | 'view' | 'history'
75
76
 
76
77
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
77
78
  const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
@@ -749,6 +750,8 @@ export class FigureEditor extends LitElement {
749
750
  return this.lastAnchors
750
751
  }
751
752
  private lastAnchors: Record<string, ScaleAnchor> = {}
753
+ /** Data values tried in the data tab, run by the preview's figure; null when none are (ADR-0051 §2.2). */
754
+ @state() private dataSample: Record<string, unknown> | null = null
752
755
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
753
756
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
754
757
  const asset = this.shown()?.asset
@@ -1549,6 +1552,7 @@ export class FigureEditor extends LitElement {
1549
1552
  .context=${view.context ?? ''}
1550
1553
  .view=${this.view}
1551
1554
  .selected=${this.selected}
1555
+ .sample=${this.dataSample}
1552
1556
  @configure-size=${async (e: CustomEvent<{ part: string }>) => {
1553
1557
  this.middle = 'edit'
1554
1558
  this.selected = e.detail.part
@@ -1585,6 +1589,7 @@ export class FigureEditor extends LitElement {
1585
1589
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
1586
1590
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1587
1591
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1592
+ ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1588
1593
  ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1589
1594
  ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
1590
1595
  ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
@@ -1656,6 +1661,15 @@ export class FigureEditor extends LitElement {
1656
1661
  ${this.selected && part ? this.renderLinks(unavailable, 'motion') : html`<section><p hint>${i18next.t('figure.text.choose-a-part-to-move')}</p></section>`}
1657
1662
  ${this.renderControls(view, unavailable)}
1658
1663
  </div>
1664
+ <div panel ?off=${tab !== 'data'}>
1665
+ ${this.renderSubject()}
1666
+ <figure-data-panel
1667
+ .asset=${asset}
1668
+ ?locked=${unavailable}
1669
+ @data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.values)}
1670
+ @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1671
+ ></figure-data-panel>
1672
+ </div>
1659
1673
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
1660
1674
  <div panel flush ?off=${tab !== 'view'}>
1661
1675
  <figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>
@@ -6,7 +6,7 @@ import { customElement, property, state } from 'lit/decorators.js'
6
6
  import { i18next, localize } from '@operato/i18n'
7
7
  import type { FigureAsset } from '@hatiolab/figure-model'
8
8
 
9
- import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipTrack } from './clips.js'
9
+ import { clipGroupsOf, clipsOf, freeGroupIdOf, groupEdit, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
10
10
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
11
11
  import { HelpToggles } from './help.js'
12
12
 
@@ -15,6 +15,9 @@ import { HelpToggles } from './help.js'
15
15
  * control. Scrubbing a clip shows its pose at that moment (one batch of input changes, not saved as the default); a key
16
16
  * is added at the scrubbed moment with the control's value there, so a pose is made with the controls' sliders and
17
17
  * then kept as a key. Every change is one part edit.
18
+ *
19
+ * Clips of one motion group play one at a time on the same controls -- idle, walk, run (ADR-0051 §3.2): only clips of
20
+ * one group may play the same control, so a new clip is put in a group as it is made when it is to take one.
18
21
  */
19
22
  @customElement('figure-clips')
20
23
  export class FigureClips extends localize(i18next)(LitElement) {
@@ -98,7 +101,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
98
101
 
99
102
  /** Where each clip is scrubbed to, in seconds. */
100
103
  @state() private at: Record<string, number> = {}
101
- @state() private adding = { state: '', clip: '', name: '', duration: 2 }
104
+ @state() private adding = { state: '', clip: '', name: '', duration: 2, group: '' }
105
+ @state() private grouping = { clip: '', label: '' }
102
106
 
103
107
  render() {
104
108
  const asset = this.asset
@@ -110,7 +114,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
110
114
  ${this.help.hint('figure.text.clips-explained')}
111
115
  ${clips.map(clip => this.renderClip(clip))}
112
116
  </section>
113
- ${this.renderAdd(asset, clips)}
117
+ ${this.renderAdd(asset, clips)} ${this.renderGroups(asset)}
114
118
  `
115
119
  }
116
120
 
@@ -155,7 +159,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
155
159
  <option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
156
160
  <option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
157
161
  </select>
158
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state })}>
162
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state, clip: clip.name })}>
159
163
  <md-icon>close</md-icon>
160
164
  </button>
161
165
  </div>
@@ -185,19 +189,22 @@ export class FigureClips extends localize(i18next)(LitElement) {
185
189
  }
186
190
 
187
191
  private renderAdd(asset: FigureAsset, clips: Clip[]) {
188
- const free = undrivenOf(asset)
189
- if (!free.length) return clips.length ? nothing : html`<p hint>${i18next.t('figure.text.no-controls')}</p>`
190
192
  const a = this.adding
191
- const stateId = free.includes(a.state) ? a.state : free[0]!
192
193
  const joining = clips.find(c => c.name === a.clip)
194
+ const { groups } = clipGroupsOf(asset)
195
+ const group = joining ? undefined : groups.find(g => g.id === a.group)
196
+ /* The controls this clip may take: not played by it yet, and by no clip outside its group. */
197
+ const free = undrivenOf(asset, joining?.name ?? '', group?.id)
198
+ if (!free.length && !clips.length && !groups.length) return html`<p hint>${i18next.t('figure.text.no-controls')}</p>`
199
+ const stateId = free.includes(a.state) ? a.state : free[0]
193
200
  const name = joining ? joining.name : a.name.trim()
194
- const input = asset.document.model.inputs.find(i => i.id === stateId)!
201
+ const input = asset.document.model.inputs.find(i => i.id === stateId)
195
202
  const duration = joining ? joining.duration : a.duration
196
- const ready = !!name && duration > 0
203
+ const ready = !!name && duration > 0 && !!input
197
204
  return html`<section><h3>${i18next.t('figure.label.add-track')}</h3>
198
205
  <div form>
199
206
  <span>${i18next.t('figure.label.controls')}</span>
200
- <select @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
207
+ <select ?disabled=${!free.length} @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
201
208
  ${free.map(id => html`<option value=${id} ?selected=${id === stateId}>${asset.stateInputs?.[id]?.label ?? id}</option>`)}
202
209
  </select>
203
210
  <span>${i18next.t('figure.label.clip')}</span>
@@ -205,6 +212,13 @@ export class FigureClips extends localize(i18next)(LitElement) {
205
212
  <option value="" ?selected=${!joining}>${i18next.t('figure.label.new-clip')}</option>
206
213
  ${clips.map(c => html`<option value=${c.name} ?selected=${c === joining}>${c.name}</option>`)}
207
214
  </select>
215
+ ${joining || !groups.length
216
+ ? nothing
217
+ : html`<span>${i18next.t('figure.label.motion-group')}</span>
218
+ <select @change=${(e: Event) => (this.adding = { ...a, group: (e.target as HTMLSelectElement).value })}>
219
+ <option value="" ?selected=${!group}>${i18next.t('figure.label.no-motion-group')}</option>
220
+ ${groups.map(g => html`<option value=${g.id} ?selected=${g === group}>${g.label}</option>`)}
221
+ </select>`}
208
222
  ${joining
209
223
  ? nothing
210
224
  : html`<span>${i18next.t('figure.label.clip-name')}</span>
@@ -221,9 +235,10 @@ export class FigureClips extends localize(i18next)(LitElement) {
221
235
  state: stateId,
222
236
  clip: name,
223
237
  ...(joining ? {} : { duration }),
238
+ ...(group ? { group: group.id } : {}),
224
239
  keys: [
225
- { seconds: 0, value: input.min },
226
- { seconds: duration, value: input.max }
240
+ { seconds: 0, value: input!.min },
241
+ { seconds: duration, value: input!.max }
227
242
  ]
228
243
  })}
229
244
  >
@@ -231,6 +246,104 @@ export class FigureClips extends localize(i18next)(LitElement) {
231
246
  </button></section>`
232
247
  }
233
248
 
249
+ /**
250
+ * The motion groups: a name, the clip that plays until another is chosen, how long a change of clip blends, and the
251
+ * clips each holds. A group is made from a clip in none; a clip in none joins one here. Every change is one edit.
252
+ */
253
+ private renderGroups(asset: FigureAsset) {
254
+ const { groups, free } = clipGroupsOf(asset)
255
+ if (!groups.length && !free.length) return nothing
256
+ const g = this.grouping
257
+ const first = free.includes(g.clip) ? g.clip : free[0]
258
+ return html`<section>
259
+ <h3>${i18next.t('figure.label.motion-groups')}${this.help.button('figure.text.motion-groups-explained')}</h3>
260
+ ${this.help.hint('figure.text.motion-groups-explained')}
261
+ ${groups.map(group => this.renderGroup(group, free))}
262
+ ${free.length
263
+ ? html`<div form>
264
+ <span>${i18next.t('figure.label.motion-group-name')}</span>
265
+ <input .value=${g.label} placeholder=${i18next.t('figure.label.motion-group')} @input=${(e: Event) => (this.grouping = { ...g, label: (e.target as HTMLInputElement).value })} />
266
+ <span>${i18next.t('figure.label.clip')}</span>
267
+ <select @change=${(e: Event) => (this.grouping = { ...g, clip: (e.target as HTMLSelectElement).value })}>
268
+ ${free.map(c => html`<option value=${c} ?selected=${c === first}>${c}</option>`)}
269
+ </select>
270
+ </div>
271
+ <button
272
+ act
273
+ ?disabled=${this.locked || !first || !g.label.trim()}
274
+ @click=${() => {
275
+ this.edit({ kind: 'set-motion-group', group: { id: freeGroupIdOf(asset), label: g.label.trim(), clips: [first!], defaultClip: first!, transitionSeconds: 0.2 } })
276
+ this.grouping = { clip: '', label: '' }
277
+ }}
278
+ >
279
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-motion-group')}
280
+ </button>`
281
+ : nothing}
282
+ </section>`
283
+ }
284
+
285
+ private renderGroup(group: ClipGroup, free: string[]) {
286
+ const value = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
287
+ return html`<section clip>
288
+ <div track-head>
289
+ <strong>${group.label}</strong>
290
+ <span></span>
291
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-motion-group')} @click=${() => this.edit({ kind: 'remove-motion-group', id: group.id })}>
292
+ <md-icon>close</md-icon>
293
+ </button>
294
+ </div>
295
+ <div form>
296
+ <span>${i18next.t('figure.label.motion-group-name')}</span>
297
+ <input .value=${group.label} ?disabled=${this.locked} @change=${(e: Event) => value(e).trim() && this.edit(groupEdit(group, { label: value(e).trim() }))} />
298
+ <span>${i18next.t('figure.label.motion-group-default')}</span>
299
+ <select ?disabled=${this.locked} @change=${(e: Event) => this.edit(groupEdit(group, { defaultClip: value(e) }))}>
300
+ ${group.clips.map(c => html`<option value=${c} ?selected=${c === group.defaultClip}>${c}</option>`)}
301
+ </select>
302
+ <span>${i18next.t('figure.label.motion-group-transition')}</span>
303
+ <input
304
+ type="number"
305
+ min="0"
306
+ step="any"
307
+ .value=${String(group.transitionSeconds)}
308
+ ?disabled=${this.locked}
309
+ @change=${(e: Event) => this.edit(groupEdit(group, { transitionSeconds: (e.target as HTMLInputElement).valueAsNumber }))}
310
+ />
311
+ </div>
312
+ <div keys>
313
+ ${group.clips.map(
314
+ c => html`<span key
315
+ >${c}
316
+ <button
317
+ ?disabled=${this.locked || group.clips.length < 2}
318
+ title=${i18next.t('figure.button.remove-clip-from-group')}
319
+ aria-label=${i18next.t('figure.button.remove-clip-from-group')}
320
+ @click=${() => {
321
+ const clips = group.clips.filter(x => x !== c)
322
+ this.edit(groupEdit(group, { clips, defaultClip: group.defaultClip === c ? clips[0]! : group.defaultClip }))
323
+ }}
324
+ >
325
+ <md-icon>close</md-icon>
326
+ </button></span
327
+ >`
328
+ )}
329
+ </div>
330
+ ${free.length
331
+ ? html`<select
332
+ aria-label=${i18next.t('figure.button.add-clip-to-group')}
333
+ ?disabled=${this.locked}
334
+ @change=${(e: Event) => {
335
+ const c = value(e)
336
+ if (c) this.edit(groupEdit(group, { clips: [...group.clips, c] }))
337
+ ;(e.target as HTMLSelectElement).value = ''
338
+ }}
339
+ >
340
+ <option value="" selected>${i18next.t('figure.button.add-clip-to-group')}</option>
341
+ ${free.map(c => html`<option value=${c}>${c}</option>`)}
342
+ </select>`
343
+ : nothing}
344
+ </section>`
345
+ }
346
+
234
347
  /** Show the clip's pose at a moment: one batch of input changes, not saved as the default. */
235
348
  /** Show the clip's pose at a moment. While the bar is dragged the pose is live (drawn, not recorded); the release
236
349
  * applies it as one batch of input changes. */