@things-factory/figure-ui 10.1.161 → 10.1.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/client/modeller/data-interface.ts +235 -67
  2. package/client/modeller/editor.ts +3 -3
  3. package/client/modeller/figure-data-panel.ts +602 -193
  4. package/client/modeller/figure-preview.ts +78 -26
  5. package/client/modeller/refusals.ts +18 -14
  6. package/dist-client/modeller/data-interface.d.ts +59 -27
  7. package/dist-client/modeller/data-interface.js +237 -60
  8. package/dist-client/modeller/data-interface.js.map +1 -1
  9. package/dist-client/modeller/editor.d.ts +1 -1
  10. package/dist-client/modeller/editor.js +2 -2
  11. package/dist-client/modeller/editor.js.map +1 -1
  12. package/dist-client/modeller/figure-data-panel.d.ts +35 -13
  13. package/dist-client/modeller/figure-data-panel.js +598 -186
  14. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  15. package/dist-client/modeller/figure-preview.d.ts +10 -3
  16. package/dist-client/modeller/figure-preview.js +81 -26
  17. package/dist-client/modeller/figure-preview.js.map +1 -1
  18. package/dist-client/modeller/refusals.d.ts +2 -2
  19. package/dist-client/modeller/refusals.js +24 -18
  20. package/dist-client/modeller/refusals.js.map +1 -1
  21. package/dist-client/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +6 -6
  23. package/test/browser/product-page.check.mjs +1 -1
  24. package/test/browser/screen.check.mjs +7 -7
  25. package/test/data-motion.test.ts +143 -50
  26. package/translations/en.json +109 -43
  27. package/translations/es.json +109 -43
  28. package/translations/ja.json +109 -43
  29. package/translations/ko.json +109 -43
  30. package/translations/ms.json +109 -43
  31. package/translations/zh.json +109 -43
@@ -3,11 +3,12 @@ import { customElement, property, query, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { compileFigureAsset } from '@hatiolab/figure-model'
6
- import type { FigureAsset } from '@hatiolab/figure-model'
6
+ import type { FigureAsset, FigureDataRow, FigureDataValue } from '@hatiolab/figure-model'
7
7
  import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
11
+ import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
11
12
  import { derivedOf, type FigureRevision } from './revision.js'
12
13
  import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewTypeOf, setupPartOf } from '@things-factory/figure-viewer/dist-client/preview-model.js'
13
14
 
@@ -208,8 +209,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
208
209
  @state() private giving: Record<string, number> = {}
209
210
  /** Per motion group, the clip played here and how fast (ADR-0051 §3.2) -- the preview's, as a board's \`motion\`. */
210
211
  @state() private moving: Record<string, { clip: string; speed: number }> = {}
211
- /** Data values tried in the data tab: the preview's figures run on them as a live snapshot; null when none are. */
212
- @property({ attribute: false }) sample: Record<string, unknown> | null = null
212
+ /** Data values given here by accessor, as a board's saved `figureValues`: what moves a group whose clip or speed the data writes. */
213
+ @state() private valuing: Record<string, FigureDataValue> = {}
214
+ /** Data tried in the data tab: the preview's figures take it as their \`data\`, as a board's data source gives it; null when none is. */
215
+ @property({ attribute: false }) sample: unknown = null
213
216
  /** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
214
217
  @property() selected = ''
215
218
  @state() private setupPart = ''
@@ -261,7 +264,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
261
264
  </select>
262
265
  <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
263
266
  </div>` : nothing}
264
- ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
267
+ ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
265
268
  }
266
269
 
267
270
  private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
@@ -303,24 +306,72 @@ export class FigurePreview extends localize(i18next)(LitElement) {
303
306
  <output>${value.toFixed(2)}</output>
304
307
  </label>`
305
308
  })}
309
+ ${valueRowsOf(asset).map(row => this.renderDataValue(row))}
306
310
  ${(asset.motionGroups ?? []).map(group => {
307
311
  const playing = this.moving[group.id] ?? { clip: group.defaultClip, speed: 0 }
308
- return html`<label>
309
- <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
310
- <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
311
- ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
312
- </select>
313
- </label>
314
- <label>
315
- <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
316
- <span></span>
317
- <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e: Event) => this.move(group.id, { ...playing, speed: Number((e.target as HTMLInputElement).value) })} />
318
- <output>${playing.speed.toFixed(2)}</output>
319
- </label>`
312
+ /* A field the data writes wins over the group's own setting, here as on a board: it is moved by the data values above. */
313
+ const byData = motionWrittenByData(asset)[group.id] ?? { clip: false, speed: false }
314
+ return html`${byData.clip
315
+ ? nothing
316
+ : html`<label>
317
+ <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
318
+ <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
319
+ ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
320
+ </select>
321
+ </label>`}
322
+ ${byData.speed
323
+ ? nothing
324
+ : html`<label>
325
+ <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
326
+ <span></span>
327
+ <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e: Event) => this.move(group.id, { ...playing, speed: Number((e.target as HTMLInputElement).value) })} />
328
+ <output>${playing.speed.toFixed(2)}</output>
329
+ </label>`}`
320
330
  })}
321
331
  </div>`
322
332
  }
323
333
 
334
+ /** A value the data's bindings read, as the board's saved values give it: a number on a slider when it has a range, a choice when it is closed. */
335
+ private renderDataValue(row: FigureDataRow) {
336
+ const node = row.node
337
+ const label = row.label ?? row.path
338
+ const fallback = 'default' in node ? node.default : undefined
339
+ const value = this.valuing[row.path] ?? fallback
340
+ if (node.kind === 'number') {
341
+ const ranged = node.min !== undefined && node.max !== undefined && node.max > node.min
342
+ return html`<label>
343
+ <span>${label} <span drive>${node.unit ?? ''}</span></span>
344
+ <span></span>
345
+ ${ranged
346
+ ? html`<input type="range" min=${node.min!} max=${node.max!} step=${Math.max((node.max! - node.min!) / 100, 0.01)} .value=${String(value ?? node.min)} @input=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
347
+ <output>${value === undefined ? '' : Number(value).toFixed(2)}</output>`
348
+ : html`<input type="number" step="any" .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
349
+ <span></span>`}
350
+ </label>`
351
+ }
352
+ const domain = domainOf(node)
353
+ if (!domain.length)
354
+ return html`<label>
355
+ <span>${label}</span>
356
+ <input aria-label=${label} .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
357
+ </label>`
358
+ return html`<label>
359
+ <span>${label}</span>
360
+ <select aria-label=${label} @change=${(e: Event) => this.value(row, (e.target as HTMLSelectElement).value)}>
361
+ ${domain.map(v => html`<option value=${String(v)} ?selected=${v === value}>${typeof v === 'boolean' ? i18next.t(v ? 'figure.label.data-true' : 'figure.label.data-false') : v}</option>`)}
362
+ </select>
363
+ </label>`
364
+ }
365
+
366
+ private value(row: FigureDataRow, typed: string) {
367
+ const value = typedValueOf(row.node, typed)
368
+ const next = { ...this.valuing }
369
+ if (typeof value === 'number' && Number.isNaN(value)) delete next[row.path]
370
+ else next[row.path] = value
371
+ this.valuing = next
372
+ this.tellInstances('figureValues', this.valuing)
373
+ }
374
+
324
375
  private move(group: string, playing: { clip: string; speed: number }) {
325
376
  this.moving = { ...this.moving, [group]: playing }
326
377
  this.tellInstances('motion', Object.fromEntries(Object.entries(this.moving).map(([id, m]) => [id, { ...m, paused: false }])))
@@ -352,23 +403,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
352
403
  this.giving = {}
353
404
  }
354
405
 
355
- private tellInstances(key: 'clips' | 'parameters' | 'motion' | 'figureData', values: Record<string, unknown> | null) {
356
- for (const component of this.scene?.root?.components ?? []) {
357
- const value = values === null ? null : { ...values }
358
- /* A live snapshot is transient on the figure (its \`figureData\` accessor), never written into the saved model. */
359
- if (key === 'figureData') (component as { figureData?: unknown }).figureData = value
360
- else component.set?.(key, value)
361
- }
406
+ private tellInstances(key: 'clips' | 'parameters' | 'motion' | 'figureValues', values: Record<string, unknown> | null) {
407
+ for (const component of this.scene?.root?.components ?? []) component.set?.(key, values === null ? null : { ...values })
408
+ }
409
+
410
+ /** The tried data to the figures standing now, as their \`data\`: the preview's board is not saved. */
411
+ private tellData(data: unknown) {
412
+ for (const component of this.scene?.root?.components ?? []) component.set?.('data', data === null || data === undefined ? null : structuredClone(data))
362
413
  }
363
414
 
364
415
 
365
- /** The data sample to the figures standing now: a live snapshot, transient on each, never saved with the board. */
416
+ /** The data sample to the figures standing now. */
366
417
  private tellSample() {
367
- if (this.sample) this.tellInstances('figureData', { ...this.sample })
418
+ if (this.sample !== null && this.sample !== undefined) this.tellData(this.sample)
368
419
  }
369
420
 
370
421
  willUpdate(changed: Map<string, unknown>) {
371
- if (changed.has('sample') && this.scene) this.tellInstances('figureData', this.sample ? { ...this.sample } : null)
422
+ if (changed.has('sample') && this.scene) this.tellData(this.sample)
372
423
  if (!this.hasUpdated) return
373
424
  if (!this.active) {
374
425
  this.stale = true
@@ -436,6 +487,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
436
487
  if (!again) this.teardown()
437
488
  this.driving = {}
438
489
  this.moving = {}
490
+ this.valuing = {}
439
491
  registerFigure(type, asset)
440
492
  this.registered = type
441
493
  const { components, board } = previewInstances(type, size)
@@ -243,25 +243,27 @@ export function editRefusalOf(error: unknown, say: Say): string {
243
243
  }
244
244
 
245
245
  /**
246
- * A data property, a connection, a motion group or a clip's tracks refused (ADR-0051 §2.2, §3.2), said with the ids and
247
- * numbers the kernel gave. Nothing for any other refusal.
246
+ * A data field, a binding, a status port, a motion group or a clip's tracks refused (ADR-0051 §8, §3.2), said with the ids
247
+ * and numbers the kernel gave. Nothing for any other refusal.
248
248
  */
249
249
  export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
250
250
  const invalidId = detailsOf(error, 'data-id-invalid')
251
251
  if (invalidId) return say('figure.text.data-id-invalid', { id: invalidId.id })
252
252
  const twice = detailsOf(error, 'data-id-duplicate')
253
253
  if (twice) return say('figure.text.data-id-duplicate', { id: twice.id })
254
- const property = detailsOf(error, 'data-property-invalid')
255
- if (property) return say('figure.text.data-property-invalid-' + property.field, { property: property.property })
256
- const absent = detailsOf(error, 'data-property-absent')
257
- if (absent) return say('figure.text.data-property-absent', { property: absent.property })
258
- const target = detailsOf(error, 'data-target-absent')
259
- if (target) return say('figure.text.data-target-absent-' + target.target, { id: target.id })
260
- const writers = detailsOf(error, 'data-target-duplicate')
261
- if (writers) return say('figure.text.data-target-duplicate', { a: writers.connections[0], b: writers.connections[1], id: writers.id })
262
- const domain = detailsOf(error, 'data-transform-domain')
254
+ const field = detailsOf(error, 'data-field-invalid')
255
+ if (field) return say('figure.text.data-field-invalid-' + field.what, {})
256
+ const fieldTwice = detailsOf(error, 'data-field-duplicate')
257
+ if (fieldTwice) return say('figure.text.data-field-duplicate', { name: fieldTwice.name })
258
+ const accessor = detailsOf(error, 'data-accessor-invalid')
259
+ if (accessor) return say('figure.text.data-accessor-invalid-' + accessor.why, { accessor: accessor.accessor })
260
+ const behavior = detailsOf(error, 'data-behavior-absent')
261
+ if (behavior) return say('figure.text.data-behavior-absent-' + behavior.behavior, { id: behavior.id })
262
+ const writers = detailsOf(error, 'data-behavior-duplicate')
263
+ if (writers) return say('figure.text.data-behavior-duplicate', { a: writers.bindings[0], b: writers.bindings[1], id: writers.id })
264
+ const domain = detailsOf(error, 'data-rule-domain')
263
265
  if (domain)
264
- return say('figure.text.data-transform-' + domain.why, {
266
+ return say('figure.text.data-rule-' + domain.why, {
265
267
  from: domain.from ?? '',
266
268
  to: domain.to ?? '',
267
269
  min: domain.min === undefined ? '' : numberOf(domain.min),
@@ -270,8 +272,10 @@ export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
270
272
  high: domain.within ? numberOf(domain.within[1]) : '',
271
273
  value: domain.value === undefined ? '' : String(domain.value)
272
274
  })
273
- const connected = detailsOf(error, 'data-property-connected')
274
- if (connected) return say('figure.text.data-property-connected', { property: connected.property, connections: connected.connections.join(', ') })
275
+ const port = detailsOf(error, 'data-port-invalid')
276
+ if (port) return say('figure.text.data-port-invalid-' + port.what, { port: port.port, id: port.id ?? '' })
277
+ const bound = detailsOf(error, 'data-port-bound')
278
+ if (bound) return say('figure.text.data-port-bound', { port: bound.port, bindings: bound.bindings.join(', ') })
275
279
  const clipAbsent = detailsOf(error, 'motion-clip-absent')
276
280
  if (clipAbsent) return say('figure.text.motion-clip-absent', { clip: clipAbsent.clip, group: clipAbsent.group })
277
281
  const shared = detailsOf(error, 'motion-clip-shared')
@@ -1,42 +1,74 @@
1
- import type { FigureAsset, FigureDataConnection, FigureDataProperty, FigureDataResult, FigureDataTarget, FigureDataValue, FigureDataValueIssue } from '@hatiolab/figure-model';
2
- /** Something a connection can write, with what the screen shows of it. */
3
- export interface DataTargetChoice {
4
- /** One string per target, for a select's value. */
1
+ import type { FigureAsset, FigureDataBehavior, FigureDataBinding, FigureDataField, FigureDataNode, FigureDataRow, FigureDataValue, FigureDataValueIssue, FigureStatusPort } from '@hatiolab/figure-model';
2
+ import { type FigureDataResult } from '@hatiolab/things-scene';
3
+ /** Something a binding can move, with what the screen shows of it. */
4
+ export interface BehaviorChoice {
5
+ /** One string per behavior, for a select's value. */
5
6
  key: string;
6
- target: FigureDataTarget;
7
- /** The control's label, the colour token, or the group's label. */
7
+ behavior: FigureDataBehavior;
8
+ /** The screen's word for the kind: a control, a colour, a group's clip, … */
9
+ kind: FigureDataBehavior['kind'];
10
+ /** The control's label, the colour token, the group's label, the port's label, the axis. */
8
11
  name: string;
9
12
  unit?: string;
10
13
  min?: number;
11
14
  max?: number;
12
15
  }
13
- export declare const targetKeyOf: (t: FigureDataTarget) => string;
16
+ export declare const behaviorKeyOf: (b: FigureDataBehavior) => string;
14
17
  /**
15
- * 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
16
- * moves a control, a colour or which clip of a group plays (through a case per value). A control is offered observed: a
17
- * value read from the machine applies as it comes.
18
+ * Everything the figure can do that data may drive: each control, each colour, each motion group's clip, speed and
19
+ * pause, each status port, its position and heading, and -- when it has places -- the items in them.
18
20
  */
19
- export declare function dataTargetsOf(asset: FigureAsset, kind: FigureDataProperty['kind']): DataTargetChoice[];
20
- /** The values a lookup has a case for: an enum's values, or both booleans. Nothing for a number. */
21
- export declare const domainOf: (property: FigureDataProperty) => FigureDataValue[];
21
+ export declare function behaviorsOf(asset: FigureAsset): BehaviorChoice[];
22
+ /** Whether a field of this kind can drive the behavior, by some rule -- the choices offered for it. */
23
+ export declare function fitsField(node: FigureDataNode, choice: BehaviorChoice): boolean;
22
24
  /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
23
- export declare function freeDataIdOf(taken: Iterable<string>, base: string): string;
25
+ export declare function freeIdOf(taken: Iterable<string>, base: string): string;
26
+ /** The values a closed field takes: a closed text's values, both booleans. Empty when open. */
27
+ export declare const domainOf: (node: FigureDataNode) => FigureDataValue[];
24
28
  /**
25
- * 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,
26
- * or scaled from another; an enum or a boolean case by case, each case at what the target shows now -- the control's
27
- * rest value, the colour, the group's first clip. The kernel says whether it holds.
29
+ * A first binding of a field to a behavior, for the author to adjust: the value as it is when it already fits, a scale
30
+ * when only its unit or range differs, one result per value (a map) for a closed field, a default for an open one.
31
+ * Each result starts at what the behavior shows now -- the control's rest value, the colour, the group's default clip --
32
+ * or, for a clip, the clip of the value's own name. The kernel says whether it holds.
28
33
  */
29
- export declare function connectionDraftOf(asset: FigureAsset, property: FigureDataProperty, choice: DataTargetChoice): FigureDataConnection;
30
- /** What a property looks like when first made: a number 0 to 1, a boolean off, an enum of two values. */
31
- export declare function propertyDraftOf(asset: FigureAsset, label: string, kind: FigureDataProperty['kind']): FigureDataProperty;
32
- /** A value typed in a sample field, as the property takes it: a number, true or false, or the enum value. */
33
- export declare function sampleValueOf(property: FigureDataProperty, typed: string): FigureDataValue;
34
+ export declare function bindingDraftOf(asset: FigureAsset, accessor: string, node: FigureDataNode, choice: BehaviorChoice): FigureDataBinding;
35
+ /** A row's path (`L[].F`) as the accessor a value binding reads (`L[0].F`): the first element of each list. */
36
+ export declare const accessorOfRow: (path: string) => string;
37
+ /** The field at a row's path, or undefined. */
38
+ export declare function fieldAt(schema: readonly FigureDataField[], path: string): FigureDataField | undefined;
39
+ /** The schema with a field added under a path ('' for the top): a copy, the given one left as it is. */
40
+ export declare function schemaWithField(schema: readonly FigureDataField[], under: string, field: FigureDataField): FigureDataField[];
41
+ /** The schema with the field at a path replaced (its name kept unless the new one says another). */
42
+ export declare function schemaWithFieldReplaced(schema: readonly FigureDataField[], path: string, field: FigureDataField): FigureDataField[];
43
+ /** The schema without the field at a path. */
44
+ export declare function schemaWithoutField(schema: readonly FigureDataField[], path: string): FigureDataField[];
45
+ /** A field of another kind, its name, label and json flag kept, the rest started fresh. */
46
+ export declare function fieldOfKind(field: FigureDataField, kind: FigureDataNode['kind']): FigureDataField;
47
+ /** The rows of the schema, each with the bindings that read it (figure-model's view). */
48
+ export declare const schemaRowsOf: (asset: FigureAsset) => FigureDataRow[];
34
49
  /**
35
- * What a sample of values would move, as the figure would run it: the kernel's check and evaluation of the interface.
36
- * Refused whole when one value cannot be taken, with each one's reason, as a live snapshot is.
50
+ * What a sample of data would do on this figure, read the one way a placed figure reads it (things-scene's figure
51
+ * inputs): what it moves, or why it is refused, each value's reason. Nothing here reads data itself.
37
52
  */
38
- export declare function dataSampleOf(asset: FigureAsset, values: Record<string, FigureDataValue>): {
39
- result: FigureDataResult;
40
- } | {
53
+ export declare function sampleOf(asset: FigureAsset, sample: unknown): {
41
54
  issues: FigureDataValueIssue[];
55
+ result: FigureDataResult;
42
56
  };
57
+ /** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
58
+ export declare const valueRowsOf: (asset: FigureAsset) => FigureDataRow[];
59
+ /** A value typed or chosen for a row, as its kind reads it: a number (NaN when empty), true or false, or the text. */
60
+ export declare function typedValueOf(node: FigureDataNode, typed: string): FigureDataValue;
61
+ /** The parts a status port may show on: the parts the figure's appearance names. */
62
+ export declare const partsOf: (asset: FigureAsset) => string[];
63
+ /** A first status port: the label as given, on the first colour token, no part yet. The kernel says whether it holds. */
64
+ export declare function portDraftOf(asset: FigureAsset, label: string): FigureStatusPort;
65
+ /** Whether a text is an accessor the kernel takes. */
66
+ export declare const accessorTaken: (accessor: string) => boolean;
67
+ /**
68
+ * Which fields of each motion group the data binds: its clip, its speed. A field bound wins over the group's own setting
69
+ * whenever the data, a saved value or the schema's default gives it a value, so the preview drives it through the data.
70
+ */
71
+ export declare function motionWrittenByData(asset: FigureAsset): Record<string, {
72
+ clip: boolean;
73
+ speed: boolean;
74
+ }>;