@things-factory/figure-ui 10.1.46 → 10.1.47

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 (138) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +61 -81
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +221 -263
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +531 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +93 -9
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +5 -2
  45. package/dist-client/modeller/figure-v3-links.js +64 -82
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +555 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +30 -1
  90. package/dist-client/modeller/v3-links.js +94 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +77 -6
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +84 -5
  135. package/translations/ja.json +86 -7
  136. package/translations/ko.json +84 -5
  137. package/translations/ms.json +86 -7
  138. package/translations/zh.json +86 -7
@@ -0,0 +1,253 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import type { V3Asset } from '@hatiolab/figure-model'
8
+
9
+ import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipTrack } from './v3-clips.js'
10
+ import { SidePanelStyles } from './side-panel-styles.js'
11
+ import { HelpToggles } from './v3-help.js'
12
+
13
+ /**
14
+ * 클립 — V2's animations (parity row ⑥): controls played by keys over time. A clip is one clock; each track plays one
15
+ * control. Scrubbing a clip shows its pose at that moment (one batch of input changes, not saved as the default); a key
16
+ * is added at the scrubbed moment with the control's value there, so a pose is made with the controls' sliders and
17
+ * then kept as a key -- the way V2's recorder worked. Every change is one part edit.
18
+ */
19
+ @customElement('figure-v3-clips')
20
+ export class FigureV3Clips extends localize(i18next)(LitElement) {
21
+ private readonly help = new HelpToggles(this)
22
+ static styles = [SidePanelStyles, css`
23
+ :host {
24
+ display: block;
25
+ }
26
+ section[clip] {
27
+ display: grid;
28
+ gap: 8px;
29
+ padding: 8px;
30
+ border: 1px solid var(--md-sys-color-outline-variant);
31
+ border-radius: 8px;
32
+ }
33
+ div[row] {
34
+ display: grid;
35
+ grid-template-columns: minmax(0, 1fr) 72px;
36
+ gap: 6px;
37
+ align-items: center;
38
+ }
39
+ div[track] {
40
+ display: grid;
41
+ gap: 4px;
42
+ padding-top: 6px;
43
+ border-top: 1px dashed var(--md-sys-color-outline-variant);
44
+ }
45
+ /*
46
+ The track's name on its own line, then how it repeats and the button that removes it. A flex row let the shared
47
+ full-width select and button squeeze the name to one letter a line (user, 2026-09-24).
48
+ */
49
+ div[track-head] {
50
+ display: grid;
51
+ grid-template-columns: minmax(0, 1fr) 30px;
52
+ gap: 4px 6px;
53
+ align-items: center;
54
+ }
55
+ div[track-head] strong {
56
+ grid-column: 1 / -1;
57
+ min-width: 0;
58
+ overflow-wrap: anywhere;
59
+ }
60
+ div[track-head] button[act] {
61
+ width: 30px;
62
+ min-height: var(--figure-control-height);
63
+ margin: 0;
64
+ padding: 0;
65
+ }
66
+ div[keys] {
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ gap: 4px;
70
+ }
71
+ span[key] {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ gap: 2px;
75
+ padding: 0 2px 0 8px;
76
+ border-radius: 999em;
77
+ background: var(--md-sys-color-surface-container);
78
+ font-variant-numeric: tabular-nums;
79
+ }
80
+ span[key] button {
81
+ width: 22px;
82
+ height: 22px;
83
+ padding: 0;
84
+ border: none;
85
+ background: none;
86
+ }
87
+ md-icon {
88
+ font-size: 16px;
89
+ width: 16px;
90
+ height: 16px;
91
+ }
92
+ `]
93
+
94
+ @property({ attribute: false }) asset?: V3Asset
95
+ /** The values the editor holds now, by input id -- what a new key takes. */
96
+ @property({ attribute: false }) values: Record<string, number> = {}
97
+ @property({ type: Boolean }) locked = false
98
+
99
+ /** Where each clip is scrubbed to, in seconds. */
100
+ @state() private at: Record<string, number> = {}
101
+ @state() private adding = { state: '', clip: '', name: '', duration: 2 }
102
+
103
+ render() {
104
+ const asset = this.asset
105
+ if (!asset) return nothing
106
+ const clips = clipsOf(asset)
107
+ return html`
108
+ <section>
109
+ <h3>${i18next.t('figure.label.clips')}${this.help.button('figure.text.clips-explained')}</h3>
110
+ ${this.help.hint('figure.text.clips-explained')}
111
+ ${clips.map(clip => this.renderClip(clip))}
112
+ </section>
113
+ ${this.renderAdd(asset, clips)}
114
+ `
115
+ }
116
+
117
+ private renderClip(clip: Clip) {
118
+ const at = Math.min(this.at[clip.name] ?? 0, clip.duration)
119
+ const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
120
+ return html`<section clip>
121
+ <div form>
122
+ <span>${i18next.t('figure.label.clip-name')}</span>
123
+ <input .value=${clip.name} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, name: (e.target as HTMLInputElement).value })} />
124
+ <span>${i18next.t('figure.label.clip-duration')}</span>
125
+ <input type="number" min="0.1" step="any" .value=${String(clip.duration)} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, duration: number(e) })} />
126
+ </div>
127
+ <div row>
128
+ <input
129
+ type="range"
130
+ min="0"
131
+ max=${clip.duration}
132
+ step=${clip.duration / 100}
133
+ .value=${String(at)}
134
+ ?disabled=${this.locked}
135
+ aria-label=${i18next.t('figure.label.clip-time')}
136
+ @input=${(e: Event) => this.scrub(clip, number(e), true)}
137
+ @change=${(e: Event) => this.scrub(clip, number(e))}
138
+ />
139
+ <small>${at.toFixed(2)} s</small>
140
+ </div>
141
+ ${clip.tracks.map(track => this.renderTrack(clip, track, at))}
142
+ </section>`
143
+ }
144
+
145
+ private renderTrack(clip: Clip, track: ClipTrack, at: number) {
146
+ const current = this.values[track.state]
147
+ return html`<div track>
148
+ <div track-head>
149
+ <strong>${track.label} <small>(${track.unit})</small></strong>
150
+ <select
151
+ aria-label=${i18next.t('figure.label.clip-loop')}
152
+ ?disabled=${this.locked}
153
+ @change=${(e: Event) => this.edit(trackEdit(clip, track, { loop: (e.target as HTMLSelectElement).value as 'wrap' | 'hold' }))}
154
+ >
155
+ <option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
156
+ <option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
157
+ </select>
158
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state })}>
159
+ <md-icon>close</md-icon>
160
+ </button>
161
+ </div>
162
+ <div keys>
163
+ ${track.keys.map(
164
+ (key, i) => html`<span key
165
+ >${key.seconds}s → ${key.value}
166
+ <button
167
+ ?disabled=${this.locked || !withoutKey(track.keys, i)}
168
+ title=${i18next.t('figure.button.remove-key')}
169
+ aria-label=${i18next.t('figure.button.remove-key')}
170
+ @click=${() => this.edit(trackEdit(clip, track, { keys: withoutKey(track.keys, i)! }))}
171
+ >
172
+ <md-icon>close</md-icon>
173
+ </button></span
174
+ >`
175
+ )}
176
+ </div>
177
+ <button
178
+ act
179
+ ?disabled=${this.locked || current === undefined}
180
+ @click=${() => this.edit(trackEdit(clip, track, { keys: withKey(track.keys, { seconds: Math.round(at * 1000) / 1000, value: current! }) }))}
181
+ >
182
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-key-here', { seconds: at.toFixed(2), value: current ?? '' })}
183
+ </button>
184
+ </div>`
185
+ }
186
+
187
+ private renderAdd(asset: V3Asset, 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
+ const a = this.adding
191
+ const stateId = free.includes(a.state) ? a.state : free[0]!
192
+ const joining = clips.find(c => c.name === a.clip)
193
+ const name = joining ? joining.name : a.name.trim()
194
+ const input = asset.document.model.inputs.find(i => i.id === stateId)!
195
+ const duration = joining ? joining.duration : a.duration
196
+ const ready = !!name && duration > 0
197
+ return html`<section><h3>${i18next.t('figure.label.add-track')}</h3>
198
+ <div form>
199
+ <span>${i18next.t('figure.label.controls')}</span>
200
+ <select @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
201
+ ${free.map(id => html`<option value=${id} ?selected=${id === stateId}>${asset.stateInputs?.[id]?.label ?? id}</option>`)}
202
+ </select>
203
+ <span>${i18next.t('figure.label.clip')}</span>
204
+ <select @change=${(e: Event) => (this.adding = { ...a, clip: (e.target as HTMLSelectElement).value })}>
205
+ <option value="" ?selected=${!joining}>${i18next.t('figure.label.new-clip')}</option>
206
+ ${clips.map(c => html`<option value=${c.name} ?selected=${c === joining}>${c.name}</option>`)}
207
+ </select>
208
+ ${joining
209
+ ? nothing
210
+ : html`<span>${i18next.t('figure.label.clip-name')}</span>
211
+ <input .value=${a.name} placeholder="cycle" @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })} />
212
+ <span>${i18next.t('figure.label.clip-duration')}</span>
213
+ <input type="number" min="0.1" step="any" .value=${String(a.duration)} @change=${(e: Event) => (this.adding = { ...a, duration: (e.target as HTMLInputElement).valueAsNumber })} />`}
214
+ </div>
215
+ <button
216
+ act
217
+ ?disabled=${this.locked || !ready}
218
+ @click=${() =>
219
+ this.edit({
220
+ kind: 'set-driver',
221
+ state: stateId,
222
+ clip: name,
223
+ ...(joining ? {} : { duration }),
224
+ keys: [
225
+ { seconds: 0, value: input.min },
226
+ { seconds: duration, value: input.max }
227
+ ]
228
+ })}
229
+ >
230
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-track')}
231
+ </button></section>`
232
+ }
233
+
234
+ /** Show the clip's pose at a moment: one batch of input changes, not saved as the default. */
235
+ /** Show the clip's pose at a moment. While the bar is dragged the pose is live (drawn, not recorded); the release
236
+ * applies it as one batch of input changes. */
237
+ private scrub(clip: Clip, seconds: number, live = false) {
238
+ if (!this.asset || !Number.isFinite(seconds)) return
239
+ this.at = { ...this.at, [clip.name]: seconds }
240
+ const changes = poseAt(this.asset, clip.name, seconds)
241
+ if (!changes.length) return
242
+ if (live) {
243
+ const values = Object.fromEntries(changes.map(c => [c.input, c.value]))
244
+ this.dispatchEvent(new CustomEvent('v3-states-live', { detail: { values }, bubbles: true, composed: true }))
245
+ return
246
+ }
247
+ this.dispatchEvent(new CustomEvent('v3-states', { detail: { changes }, bubbles: true, composed: true }))
248
+ }
249
+
250
+ private edit(edit: unknown) {
251
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
252
+ }
253
+ }
@@ -0,0 +1,212 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import type { V3Asset } from '@hatiolab/figure-model'
8
+
9
+ import { controlsOf, poseAsDefault, poseDiffers, type Control } from './v3-controls.js'
10
+ import { dialShape } from './angle-dial.js'
11
+ import { SidePanelStyles } from './side-panel-styles.js'
12
+
13
+ /**
14
+ * The figure's controls (parity row ⑥, V2's parameters and its pose tools): each value a board sets to pose the figure,
15
+ * with a slider to try it, its name, range and travel time, and a button that keeps the pose being looked at as the
16
+ * pose it stands in when placed.
17
+ *
18
+ * Trying a value is an input change (`v3-state`, not saved as the default); naming, the range and the travel time are
19
+ * one `set-state-input` part edit each; keeping the pose is one `set-state-defaults` edit. A control is made with
20
+ * a motion on a part (「움직임」), which is where it gets its meaning.
21
+ */
22
+ @customElement('figure-v3-controls')
23
+ export class FigureV3Controls extends localize(i18next)(LitElement) {
24
+ static styles = [SidePanelStyles, css`
25
+ :host {
26
+ display: block;
27
+ }
28
+ /* One control, one line: its name, a dial for an angle, a thin slider, the value (V2's preview row). */
29
+ div[control] {
30
+ display: grid;
31
+ grid-template-columns: minmax(0, 1fr) 18px minmax(64px, 1.2fr) 58px 22px;
32
+ align-items: center;
33
+ gap: 6px;
34
+ min-height: 28px;
35
+ }
36
+ div[control] > span[name] {
37
+ overflow: hidden;
38
+ text-overflow: ellipsis;
39
+ white-space: nowrap;
40
+ }
41
+ div[control] > span[name] em {
42
+ font-style: normal;
43
+ font-size: 0.64rem;
44
+ color: var(--md-sys-color-on-surface-variant);
45
+ }
46
+ div[control] input[type='number'] {
47
+ min-height: 24px;
48
+ padding: 2px 5px;
49
+ font-family: var(--mono-font, monospace);
50
+ text-align: right;
51
+ }
52
+ svg[dial] {
53
+ width: 18px;
54
+ height: 18px;
55
+ }
56
+ svg[dial] path {
57
+ fill: var(--md-sys-color-surface-container-high);
58
+ }
59
+ svg[dial] circle {
60
+ fill: none;
61
+ stroke: var(--md-sys-color-outline-variant);
62
+ }
63
+ svg[dial] line {
64
+ stroke: var(--md-sys-color-primary);
65
+ stroke-width: 2;
66
+ stroke-linecap: round;
67
+ }
68
+ button[more] {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ width: 22px;
73
+ height: 22px;
74
+ padding: 0;
75
+ border: none;
76
+ border-radius: 4px;
77
+ background: none;
78
+ color: var(--md-sys-color-on-surface-variant);
79
+ cursor: pointer;
80
+ }
81
+ button[more][aria-expanded='true'] {
82
+ color: var(--md-sys-color-primary);
83
+ background: var(--md-sys-color-surface-container);
84
+ }
85
+ button[more] md-icon {
86
+ font-size: 16px;
87
+ width: 16px;
88
+ height: 16px;
89
+ }
90
+ /* The rarely changed: the name, the range, the travel time -- opened per control. */
91
+ div[settings] {
92
+ margin: 2px 0 8px;
93
+ padding: 6px 0 6px 8px;
94
+ border-left: 2px solid var(--md-sys-color-outline-variant);
95
+ }
96
+ `]
97
+
98
+ /** The asset being edited. */
99
+ @property({ attribute: false }) asset?: V3Asset
100
+ /** The values the editor holds now, by input id. */
101
+ @property({ attribute: false }) values: Record<string, number> = {}
102
+ @property({ type: Boolean }) locked = false
103
+
104
+ /** Which control's settings are open. */
105
+ @state() private open = ''
106
+ /** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
107
+ @state() private live: Record<string, number> = {}
108
+
109
+ render() {
110
+ if (!this.asset) return nothing
111
+ const controls = controlsOf(this.asset, this.values)
112
+ return html`<section>
113
+ <h3>${i18next.t('figure.label.controls')}</h3>
114
+ ${controls.length
115
+ ? html`${controls.map(control => this.renderControl(control))}
116
+ <button act ?disabled=${this.locked || !poseDiffers(controls)} @click=${() => this.edit(poseAsDefault(controls))}>
117
+ <md-icon>push_pin</md-icon>${i18next.t('figure.button.keep-pose')}
118
+ </button>`
119
+ : html`<p hint>${i18next.t('figure.text.no-controls')}</p>`}
120
+ </section>`
121
+ }
122
+
123
+ private renderControl(control: Control) {
124
+ const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
125
+ const c = this.live[control.id] === undefined ? control : { ...control, value: this.live[control.id]! }
126
+ const open = this.open === c.id
127
+ return html`<div control>
128
+ <span name title=${c.id}>${c.label} <em>${c.unit}</em></span>
129
+ ${c.unit === 'deg' ? this.renderDial(c) : html`<span></span>`}
130
+ <input
131
+ type="range"
132
+ min=${c.min}
133
+ max=${c.max}
134
+ step=${c.step}
135
+ .value=${String(c.value)}
136
+ ?disabled=${this.locked}
137
+ aria-label=${c.label}
138
+ @input=${(e: Event) => this.drag(c, number(e))}
139
+ @change=${(e: Event) => this.try(c, number(e))}
140
+ />
141
+ <input type="number" step="any" .value=${String(Math.round(c.value * 100) / 100)} ?disabled=${this.locked} aria-label=${c.label} @change=${(e: Event) => this.try(c, number(e))} />
142
+ <button
143
+ more
144
+ aria-expanded=${String(open)}
145
+ title=${i18next.t('figure.button.control-settings')}
146
+ aria-label=${i18next.t('figure.button.control-settings')}
147
+ @click=${() => (this.open = open ? '' : c.id)}
148
+ >
149
+ <md-icon>tune</md-icon>
150
+ </button>
151
+ </div>
152
+ ${open ? this.renderSettings(c) : nothing}`
153
+ }
154
+
155
+ private renderDial(c: Control) {
156
+ const dial = dialShape(c.min, c.max, c.value)
157
+ const span = dial.radius * 2 + 2
158
+ return html`<svg dial viewBox="0 0 ${span} ${span}" aria-hidden="true">
159
+ <path d=${dial.sector}></path>
160
+ <circle cx=${dial.centre.x} cy=${dial.centre.y} r=${dial.radius}></circle>
161
+ <line x1=${dial.centre.x} y1=${dial.centre.y} x2=${dial.needle.x} y2=${dial.needle.y}></line>
162
+ </svg>`
163
+ }
164
+
165
+ private renderSettings(c: Control) {
166
+ const policy = (patch: Partial<{ label: string; sweep: number; min: number; max: number }>) =>
167
+ this.edit({
168
+ kind: 'set-state-input',
169
+ input: c.id,
170
+ label: patch.label ?? (c.named ? c.label : undefined),
171
+ sweep: patch.sweep ?? c.sweep,
172
+ ...(patch.min !== undefined ? { min: patch.min } : {}),
173
+ ...(patch.max !== undefined ? { max: patch.max } : {})
174
+ })
175
+ const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
176
+ return html`<div settings>
177
+ <div form>
178
+ <span>${i18next.t('figure.label.control-name')}</span>
179
+ <input .value=${c.named ? c.label : ''} placeholder=${c.id} ?disabled=${this.locked} @change=${(e: Event) => policy({ label: (e.target as HTMLInputElement).value })} />
180
+ <span>${i18next.t('figure.label.control-range')} (${c.unit})</span>
181
+ <div pair>
182
+ <input type="number" step="any" .value=${String(c.min)} ?disabled=${this.locked} @change=${(e: Event) => policy({ min: number(e) })} />
183
+ <input type="number" step="any" .value=${String(c.max)} ?disabled=${this.locked} @change=${(e: Event) => policy({ max: number(e) })} />
184
+ </div>
185
+ <span>${i18next.t('figure.label.control-sweep')}</span>
186
+ <input type="number" min="0" step="any" .value=${String(c.sweep)} ?disabled=${this.locked} @change=${(e: Event) => policy({ sweep: number(e) })} />
187
+ </div>
188
+ <small>${i18next.t('figure.text.control-starts-at', { value: c.start, unit: c.unit })}</small>
189
+ </div>`
190
+ }
191
+
192
+ /** While dragging: the value is told as live -- the editor draws that pose at once and records nothing. */
193
+ private drag(c: Control, value: number) {
194
+ if (!Number.isFinite(value)) return
195
+ this.live = { ...this.live, [c.id]: value }
196
+ this.dispatchEvent(new CustomEvent('v3-state-live', { detail: { input: c.id, value }, bubbles: true, composed: true }))
197
+ }
198
+
199
+ /** Try a value: the pose being looked at, clamped to the range. Not saved as the default. The slider sends it on
200
+ * release, so one drag is one step of the undo history rather than one per pixel. */
201
+ private try(c: Control, value: number) {
202
+ if (!Number.isFinite(value)) return
203
+ const clamped = Math.min(Math.max(value, c.min), c.max)
204
+ const { [c.id]: _released, ...rest } = this.live
205
+ this.live = rest
206
+ this.dispatchEvent(new CustomEvent('v3-state', { detail: { input: c.id, unit: c.unit, value: clamped }, bubbles: true, composed: true }))
207
+ }
208
+
209
+ private edit(edit: unknown) {
210
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
211
+ }
212
+ }