@hatiolab/figure-model 0.11.0 → 0.12.0

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 (75) hide show
  1. package/dist/asset-types.d.ts +74 -29
  2. package/dist/asset-types.d.ts.map +1 -1
  3. package/dist/asset-types.js +2 -0
  4. package/dist/asset-types.js.map +1 -1
  5. package/dist/asset.d.ts.map +1 -1
  6. package/dist/asset.js +5 -3
  7. package/dist/asset.js.map +1 -1
  8. package/dist/authoring-actions.js +12 -1
  9. package/dist/authoring-actions.js.map +1 -1
  10. package/dist/authoring-bindings.js +6 -7
  11. package/dist/authoring-bindings.js.map +1 -1
  12. package/dist/authoring-dimensions.d.ts +7 -0
  13. package/dist/authoring-dimensions.d.ts.map +1 -1
  14. package/dist/authoring-dimensions.js +101 -1
  15. package/dist/authoring-dimensions.js.map +1 -1
  16. package/dist/authoring-types.d.ts +39 -4
  17. package/dist/authoring-types.d.ts.map +1 -1
  18. package/dist/capabilities.js +37 -17
  19. package/dist/capabilities.js.map +1 -1
  20. package/dist/contract.d.ts +22 -11
  21. package/dist/contract.d.ts.map +1 -1
  22. package/dist/contract.js.map +1 -1
  23. package/dist/data-interface.d.ts +8 -4
  24. package/dist/data-interface.d.ts.map +1 -1
  25. package/dist/data-interface.js +252 -172
  26. package/dist/data-interface.js.map +1 -1
  27. package/dist/follow.d.ts +9 -0
  28. package/dist/follow.d.ts.map +1 -1
  29. package/dist/follow.js +9 -0
  30. package/dist/follow.js.map +1 -1
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/item-notes.d.ts +18 -0
  36. package/dist/item-notes.d.ts.map +1 -0
  37. package/dist/item-notes.js +29 -0
  38. package/dist/item-notes.js.map +1 -0
  39. package/dist/kernel-version.d.ts +1 -1
  40. package/dist/kernel-version.js +1 -1
  41. package/dist/part-edit.d.ts +13 -12
  42. package/dist/part-edit.d.ts.map +1 -1
  43. package/dist/part-edit.js +42 -36
  44. package/dist/part-edit.js.map +1 -1
  45. package/dist/proposal.d.ts +2 -2
  46. package/dist/proposal.d.ts.map +1 -1
  47. package/dist/proposal.js +18 -13
  48. package/dist/proposal.js.map +1 -1
  49. package/dist/recipe-from-parts.d.ts.map +1 -1
  50. package/dist/recipe-from-parts.js +7 -0
  51. package/dist/recipe-from-parts.js.map +1 -1
  52. package/dist/release-contract.d.ts +1 -1
  53. package/dist/release-contract.d.ts.map +1 -1
  54. package/dist/release-contract.js +57 -5
  55. package/dist/release-contract.js.map +1 -1
  56. package/dist/render-batches.d.ts +11 -1
  57. package/dist/render-batches.d.ts.map +1 -1
  58. package/dist/render-batches.js +31 -4
  59. package/dist/render-batches.js.map +1 -1
  60. package/dist/robots.js +3 -3
  61. package/dist/robots.js.map +1 -1
  62. package/dist/slot-layout-presets.d.ts +2 -2
  63. package/dist/slot-layout-presets.js +6 -6
  64. package/dist/slot-layout-presets.js.map +1 -1
  65. package/dist/slot-layout.d.ts +2 -2
  66. package/dist/slot-layout.d.ts.map +1 -1
  67. package/dist/slot-places.d.ts +2 -1
  68. package/dist/slot-places.d.ts.map +1 -1
  69. package/dist/slot-places.js +1 -1
  70. package/dist/slot-places.js.map +1 -1
  71. package/dist/words.d.ts +10 -0
  72. package/dist/words.d.ts.map +1 -1
  73. package/dist/words.js +10 -0
  74. package/dist/words.js.map +1 -1
  75. package/package.json +1 -1
@@ -5,14 +5,16 @@
5
5
  * A figure's data and what it moves inside the figure (ADR-0051 §8, data binding design appendix A).
6
6
  *
7
7
  * The author describes the data the figure takes in the site's own field names -- objects, lists, numbers, booleans,
8
- * text -- and binds values of it to what the figure does: a state input, a colour, a motion group's clip, speed or
9
- * pause, a status port, the instance's position, the items in its places. A binding has the board's mapping shape --
10
- * an accessor into the data, a rule (`value`, `map`, `range`, `scale`) and its param -- so a person who binds data on a
11
- * board binds it on a figure in the same words. The figure's bindings are its own: every board that places it moves it
12
- * with the same data, and a board rarely binds more.
8
+ * text -- and binds values of it to what the figure does. A binding has the board's mapping shape -- an accessor into
9
+ * the data, a rule and what the rule needs -- so a person who binds data on a board binds it on a figure in the same
10
+ * words. One binding, three rules (ADR-0166): the value as it is or scaled moves one behavior (a state input, a colour,
11
+ * a motion group's clip, speed or pause, the instance's position, the items in its places); a table gives one look per
12
+ * code or per interval to several targets at once -- parts' colour and effect, state inputs' values, a motion group.
13
+ * The figure's bindings are its own: every board that places it moves it with the same data, and a board rarely binds
14
+ * more. A table a consumer may swap for a legend says so by a role; the legend is the consumer's.
13
15
  *
14
16
  * This module checks the declaration once, when the asset is read: each accessor goes down the schema to what its
15
- * behavior reads, and each rule's results are what the behavior takes over the whole of the value's domain. While the
17
+ * behavior reads, and each rule's results are what its targets take over the whole of the value's domain. While the
16
18
  * figure runs it checks the data against the schema -- a value of the wrong kind refuses the whole of it -- and gives
17
19
  * each binding the value it reads. Turning that value into the behavior's input by the rule is things-scene's, the same
18
20
  * evaluator a board mapping runs; nothing here holds time or an instance.
@@ -20,9 +22,9 @@
20
22
  import { FigureContractError } from "./contract.js";
21
23
  import { figureUnitKnown } from "./graph.js";
22
24
  import { FIGURE_PART_ID } from "./part-ids.js";
25
+ import { isFigureColour } from "./asset-types.js";
23
26
  /** The fastest a motion group plays: three times its authored speed. 0 stops it. */
24
27
  export const FIGURE_MOTION_SPEED_MAX = 3;
25
- const COLOUR = /^#[0-9a-f]{6}$/i;
26
28
  /** A field name as the board's accessor reads it bare: a letter or `_`, then letters, digits, `_` and `-`. Not `$`: the accessor reads that as a variable. */
27
29
  const FIELD_NAME = /^[A-Za-z_][\w-]*$/;
28
30
  const fail = (code, path, message, details) => {
@@ -161,12 +163,32 @@ const targetsOf = (asset) => ({
161
163
  states: new Map(asset.document.model.inputs.filter(i => i.role === 'state').map(i => [i.id, i])),
162
164
  palette: asset.palette,
163
165
  groups: new Map((asset.motionGroups ?? []).map(g => [g.id, g.clips])),
164
- ports: new Map((asset.dataInterface?.ports ?? []).map(p => [p.id, p])),
165
166
  parts: new Set(asset.appearance.map(a => a.target))
166
167
  });
167
- /** The one thing a binding writes, as a key no other binding may hold. Items are stacked, so several may fill them. */
168
- const writeKey = (b) => b.kind === 'state' ? `state ${b.input}` : b.kind === 'palette' ? `palette ${b.token}` : b.kind === 'status' ? `status ${b.port}` : b.kind === 'pose' ? `pose ${b.axis}` : b.kind === 'items' ? null : `${b.kind} ${b.group}`;
169
- const behaviorId = (b) => b.kind === 'state' ? b.input : b.kind === 'palette' ? b.token : b.kind === 'status' ? b.port : b.kind === 'pose' ? b.axis : b.kind === 'items' ? (b.slot ?? '') : b.group;
168
+ /** The one thing a value binding writes, as a key no other binding may hold. Items are stacked, so several may fill them. */
169
+ const writeKey = (b) => b.kind === 'state' ? `state ${b.input}` : b.kind === 'palette' ? `palette ${b.token}` : b.kind === 'pose' ? `pose ${b.axis}` : b.kind === 'items' ? null : `${b.kind} ${b.group}`;
170
+ const behaviorId = (b) => b.kind === 'state' ? b.input : b.kind === 'palette' ? b.token : b.kind === 'pose' ? b.axis : b.kind === 'items' ? (b.slot ?? '') : b.group;
171
+ /** The columns of a table's looks that set a motion group, with the behavior that sets the same in a value binding. */
172
+ const GROUP_COLUMNS = [
173
+ ['clip', 'motion-clip'],
174
+ ['speed', 'motion-speed'],
175
+ ['paused', 'motion-paused']
176
+ ];
177
+ /**
178
+ * What a table binding writes, under the keys a value binding's behavior holds (ADR-0166 6): each of its state inputs,
179
+ * and each of its group's clip, speed and pause a look sets. A part's colour and effect are no one binding's alone.
180
+ */
181
+ function tableWrites(b) {
182
+ const out = (b.targets.inputs ?? []).map(input => ({ key: `state ${input}`, behavior: 'state', id: input }));
183
+ const group = b.targets.group;
184
+ if (group !== undefined) {
185
+ const looks = Object.values(b.table);
186
+ for (const [column, behavior] of GROUP_COLUMNS)
187
+ if (looks.some(look => look[column] !== undefined))
188
+ out.push({ key: `${behavior} ${group}`, behavior, id: group });
189
+ }
190
+ return out;
191
+ }
170
192
  function checkBehavior(b, path) {
171
193
  if (!b || typeof b !== 'object' || Array.isArray(b))
172
194
  fail('ASSET_SCHEMA', path, 'object required');
@@ -177,21 +199,19 @@ function checkBehavior(b, path) {
177
199
  fields(b, ['kind', 'token'], [], path);
178
200
  else if (k === 'motion-clip' || k === 'motion-speed' || k === 'motion-paused')
179
201
  fields(b, ['kind', 'group'], [], path);
180
- else if (k === 'status')
181
- fields(b, ['kind', 'port'], [], path);
182
202
  else if (k === 'pose')
183
203
  fields(b, ['kind', 'axis'], ['seconds'], path);
184
204
  else if (k === 'items')
185
205
  fields(b, ['kind'], ['slot', 'id', 'type', 'place', 'stack', 'status'], path);
186
206
  else
187
- fail('ASSET_SCHEMA', `${path}.kind`, `${String(k)} is not a behavior; state, palette, motion-clip, motion-speed, motion-paused, status, pose or items`);
207
+ fail('ASSET_SCHEMA', `${path}.kind`, `${String(k)} is not a behavior; state, palette, motion-clip, motion-speed, motion-paused, pose or items`);
188
208
  if (k === 'state' && b.sourceMode !== 'observed' && b.sourceMode !== 'commanded')
189
209
  fail('ASSET_SCHEMA', `${path}.sourceMode`, 'observed or commanded');
190
210
  if (k === 'pose' && !['x', 'y', 'z', 'heading'].includes(b.axis))
191
211
  fail('ASSET_SCHEMA', `${path}.axis`, 'x, y, z or heading');
192
212
  if (k === 'pose' && b.seconds !== undefined && !(finite(b.seconds) && b.seconds >= 0))
193
213
  fail('ASSET_SCHEMA', `${path}.seconds`, 'seconds from 0');
194
- for (const f of ['input', 'token', 'group', 'port', 'slot', 'id', 'type', 'place', 'stack', 'status'])
214
+ for (const f of ['input', 'token', 'group', 'slot', 'id', 'type', 'place', 'stack', 'status'])
195
215
  if (Object.hasOwn(b, f) && (typeof b[f] !== 'string' || !b[f]))
196
216
  fail('ASSET_SCHEMA', `${path}.${f}`, 'a nonempty string');
197
217
  return b;
@@ -204,8 +224,8 @@ function closedDomain(node) {
204
224
  return node.values;
205
225
  return null;
206
226
  }
207
- /** One interval of a `range` param, as the board's evaluator reads its key: `from~to`, either end left open. */
208
- function intervalOf(key) {
227
+ /** One interval of a range table, as the board's evaluator reads its key: `from~to`, either end left open. */
228
+ export function figureIntervalOf(key) {
209
229
  const parts = key.split('~');
210
230
  if (parts.length !== 2)
211
231
  return null;
@@ -216,47 +236,14 @@ function intervalOf(key) {
216
236
  return null;
217
237
  return [from, to];
218
238
  }
239
+ const domainOf = (id, path) => (why, message, extra = {}) => refuse(path, message, { reason: 'data-rule-domain', binding: id, why, ...extra });
219
240
  /**
220
- * Whether a binding's results are what its behavior takes, over the whole of what the value can be (the detail design's
221
- * check, kept): a number's range, every closed value, both booleans -- never a sample.
241
+ * Whether a value binding's results are what its behavior takes, over the whole of what the value can be (the detail
242
+ * design's check, kept): a number's range, every closed value, both booleans -- never a sample.
222
243
  */
223
244
  function checkRule(b, node, targets, path) {
224
- const domain = (why, message, extra = {}) => refuse(path, message, { reason: 'data-rule-domain', binding: b.id, why, ...extra });
245
+ const domain = domainOf(b.id, path);
225
246
  const behavior = b.behavior;
226
- /* Is the result one the behavior takes? */
227
- const output = (value) => {
228
- if (behavior.kind === 'state') {
229
- const input = targets.states.get(behavior.input);
230
- if (!finite(value))
231
- return domain('output', `a state input takes a number in ${input.unit}`, { value });
232
- if (value < input.min || value > input.max)
233
- domain('output', `${value} is outside ${behavior.input}, ${input.min} to ${input.max}`, { value, within: [input.min, input.max] });
234
- }
235
- else if (behavior.kind === 'palette') {
236
- if (typeof value !== 'string' || !COLOUR.test(value))
237
- domain('output', 'a colour is #RRGGBB', { value });
238
- }
239
- else if (behavior.kind === 'motion-clip') {
240
- if (typeof value !== 'string' || !targets.groups.get(behavior.group).includes(value))
241
- domain('output', `${String(value)} is not a clip of ${behavior.group}`, { value });
242
- }
243
- else if (behavior.kind === 'motion-speed') {
244
- if (!finite(value) || value < 0 || value > FIGURE_MOTION_SPEED_MAX)
245
- domain('output', `a motion speed is 0 to ${FIGURE_MOTION_SPEED_MAX}`, { value });
246
- }
247
- else if (behavior.kind === 'motion-paused') {
248
- if (typeof value !== 'boolean')
249
- domain('output', 'a pause is true or false', { value });
250
- }
251
- else if (behavior.kind === 'status') {
252
- if (typeof value !== 'string' && !finite(value))
253
- domain('output', 'a status is a code', { value });
254
- }
255
- else if (behavior.kind === 'pose') {
256
- if (!finite(value))
257
- domain('output', 'a position is a number', { value });
258
- }
259
- };
260
247
  const param = b.param;
261
248
  if (behavior.kind === 'items') {
262
249
  if (b.rule !== 'value' || param !== undefined)
@@ -265,44 +252,8 @@ function checkRule(b, node, targets, path) {
265
252
  }
266
253
  if (!isValue(node))
267
254
  return domain('kind', `${b.accessor} is ${node.kind === 'list' ? 'a list' : 'an object'}; this behavior reads one value`);
268
- if (b.rule === 'map') {
269
- if (!param || typeof param !== 'object')
270
- return domain('cases', 'a map gives a result for each value: { value: result, default }');
271
- const closed = closedDomain(node);
272
- const keys = Object.keys(param).filter(k => k !== 'default');
273
- if (closed)
274
- for (const k of keys)
275
- if (!closed.some(v => String(v) === k))
276
- domain('cases', `${k} is not a value of ${b.accessor}`, { value: k });
277
- const covered = closed ? closed.every(v => Object.hasOwn(param, String(v))) : false;
278
- if (!covered && !Object.hasOwn(param, 'default'))
279
- domain('default', closed ? `not every value of ${b.accessor} has a result; give a default` : `${b.accessor} is open; give a default`);
280
- for (const k of Object.keys(param))
281
- output(param[k]);
282
- return;
283
- }
284
- if (b.rule === 'range') {
285
- if (node.kind !== 'number')
286
- return domain('kind', `a range reads a number; ${b.accessor} is ${node.kind}`);
287
- if (!param || typeof param !== 'object')
288
- return domain('cases', "a range gives a result for each interval: { 'from~to': result, default }");
289
- const intervals = [];
290
- for (const k of Object.keys(param)) {
291
- if (k === 'default')
292
- continue;
293
- const iv = intervalOf(k);
294
- if (!iv)
295
- domain('cases', `${k} is not an interval: from~to, either end may be left out`, { value: k });
296
- intervals.push(iv);
297
- }
298
- if (!Object.hasOwn(param, 'default') && !covers(intervals, node.min ?? -Infinity, node.max ?? Infinity))
299
- domain('default', `the intervals leave values of ${b.accessor} without a result; give a default`);
300
- for (const k of Object.keys(param))
301
- output(param[k]);
302
- return;
303
- }
304
255
  if (b.rule !== 'value' && b.rule !== 'scale')
305
- return domain('kind', `${String(b.rule)} is not a rule; value, map, range or scale`);
256
+ return domain('kind', `${String(b.rule)} is not a rule; value, scale or table`);
306
257
  /* value and scale: the value itself, or scale × value + offset. */
307
258
  let scale = 1;
308
259
  let offset = 0;
@@ -316,28 +267,25 @@ function checkRule(b, node, targets, path) {
316
267
  }
317
268
  else if (param !== undefined)
318
269
  return domain('cases', 'value takes no param');
319
- if (behavior.kind === 'status') {
320
- if (b.rule === 'scale')
321
- domain('kind', 'a status is a code; it is read as it is or by a map');
322
- if (node.kind === 'boolean')
323
- domain('kind', 'a status is a code: a number or a text');
324
- return;
325
- }
326
270
  if (behavior.kind === 'motion-clip' || behavior.kind === 'palette') {
327
271
  if (b.rule === 'scale' || node.kind !== 'text' || !node.values)
328
- return domain('kind', `${behavior.kind === 'palette' ? 'a colour' : 'a clip'} is read as it is only from a text of fixed values; otherwise by a map`);
329
- for (const v of node.values)
330
- output(v);
272
+ return domain('kind', `${behavior.kind === 'palette' ? 'a colour' : 'a clip'} is read as it is only from a text of fixed values; otherwise by a table`);
273
+ for (const v of node.values) {
274
+ if (behavior.kind === 'palette' && !isFigureColour(v))
275
+ domain('output', 'a colour is #RRGGBB', { value: v });
276
+ if (behavior.kind === 'motion-clip' && !targets.groups.get(behavior.group).includes(v))
277
+ domain('output', `${v} is not a clip of ${behavior.group}`, { value: v });
278
+ }
331
279
  return;
332
280
  }
333
281
  if (behavior.kind === 'motion-paused') {
334
282
  if (b.rule === 'scale' || node.kind !== 'boolean')
335
- domain('kind', 'a pause is read as it is only from true or false; otherwise by a map');
283
+ domain('kind', 'a pause is read as it is only from true or false; otherwise by a table');
336
284
  return;
337
285
  }
338
286
  /* state, motion-speed and pose read a number. */
339
287
  if (node.kind !== 'number')
340
- return domain('kind', `${b.accessor} is ${node.kind === 'text' ? 'a text' : 'true or false'}; this behavior reads a number, or a value by a map`);
288
+ return domain('kind', `${b.accessor} is ${node.kind === 'text' ? 'a text' : 'true or false'}; this behavior reads a number, or a value by a table`);
341
289
  if (behavior.kind === 'pose') {
342
290
  const unit = behavior.axis === 'heading' ? 'deg' : 'mm';
343
291
  if (b.rule === 'value' && node.unit !== unit)
@@ -374,10 +322,165 @@ function covers(intervals, min, max) {
374
322
  }
375
323
  return false;
376
324
  }
325
+ /** A legend role a consumer chooses a legend by: lower case letters, digits and `-`. */
326
+ const LEGEND_ROLE = /^[a-z0-9][a-z0-9-]*$/;
327
+ /**
328
+ * A table binding (ADR-0166 3, 6): its targets are the figure's, its lookup fits the field, every look is one its
329
+ * targets take and has no column for another, a closed field's every value has a look or there is a default, and a range
330
+ * table's intervals do not overlap.
331
+ */
332
+ function checkTable(b, node, targets, path) {
333
+ const domain = domainOf(b.id, path);
334
+ const t = b.targets;
335
+ if (!t || typeof t !== 'object' || Array.isArray(t))
336
+ fail('ASSET_SCHEMA', `${path}.targets`, 'object required');
337
+ fields(t, [], ['parts', 'inputs', 'group'], `${path}.targets`);
338
+ for (const k of ['parts', 'inputs'])
339
+ if (t[k] !== undefined && (!Array.isArray(t[k]) || t[k].some((x) => typeof x !== 'string' || !x)))
340
+ fail('ASSET_SCHEMA', `${path}.targets.${k}`, 'a list of ids');
341
+ if (t.group !== undefined && (typeof t.group !== 'string' || !t.group))
342
+ fail('ASSET_SCHEMA', `${path}.targets.group`, 'a nonempty string');
343
+ const target = (what, message, id) => refuse(`${path}.targets`, message, { reason: 'data-table-target', binding: b.id, what, ...(id !== undefined ? { id } : {}) });
344
+ const parts = t.parts ?? [];
345
+ const inputs = t.inputs ?? [];
346
+ if (!parts.length && !inputs.length && t.group === undefined)
347
+ target('none', 'a table shows on a part, a state input, a motion group, or more');
348
+ parts.forEach((p, i) => {
349
+ if (!targets.parts.has(p))
350
+ target('part', `${p} is not a part of this figure`, p);
351
+ if (parts.indexOf(p) !== i)
352
+ target('part', `${p} is named twice`, p);
353
+ });
354
+ inputs.forEach((s, i) => {
355
+ if (!targets.states.has(s))
356
+ target('input', `${s} is not a state input of this figure`, s);
357
+ if (inputs.indexOf(s) !== i)
358
+ target('input', `${s} is named twice`, s);
359
+ });
360
+ if (t.group !== undefined && !targets.groups.has(t.group))
361
+ target('group', `${t.group} is not a motion group of this figure`, t.group);
362
+ if (b.lookup !== 'code' && b.lookup !== 'ranges')
363
+ fail('ASSET_SCHEMA', `${path}.lookup`, 'code or ranges');
364
+ if (!isValue(node))
365
+ return domain('kind', `${b.accessor} is ${node.kind === 'list' ? 'a list' : 'an object'}; a table reads one value`);
366
+ if (b.lookup === 'code' && node.kind === 'number')
367
+ return domain('kind', `${b.accessor} is a number; a table reads it by ranges`);
368
+ if (b.lookup === 'ranges' && node.kind !== 'number')
369
+ return domain('kind', `a range table reads a number; ${b.accessor} is ${node.kind === 'text' ? 'a text' : 'true or false'}`);
370
+ const table = b.table;
371
+ if (!table || typeof table !== 'object' || Array.isArray(table))
372
+ fail('ASSET_SCHEMA', `${path}.table`, 'an object of looks');
373
+ const keys = Object.keys(table).filter(k => k !== 'default');
374
+ if (b.lookup === 'code') {
375
+ const closed = closedDomain(node);
376
+ if (closed) {
377
+ for (const k of keys)
378
+ if (!closed.some(v => String(v) === k))
379
+ domain('cases', `${k} is not a value of ${b.accessor}`, { value: k });
380
+ if (!closed.every(v => Object.hasOwn(table, String(v))) && !Object.hasOwn(table, 'default'))
381
+ domain('default', `not every value of ${b.accessor} has a look; give a default`);
382
+ }
383
+ }
384
+ else {
385
+ const intervals = [];
386
+ for (const k of keys) {
387
+ const iv = figureIntervalOf(k);
388
+ if (!iv)
389
+ return domain('cases', `${k} is not an interval: from~to, either end may be left out`, { value: k });
390
+ const other = intervals.find(([from, to]) => iv[0] < to && from < iv[1]);
391
+ if (other)
392
+ domain('overlap', `${other[2]} and ${k} overlap; a value has one look`, { value: k });
393
+ intervals.push([iv[0], iv[1], k]);
394
+ }
395
+ const n = node;
396
+ if (!Object.hasOwn(table, 'default') && !covers(intervals.map(([from, to]) => [from, to]), n.min ?? -Infinity, n.max ?? Infinity))
397
+ domain('default', `the intervals leave values of ${b.accessor} without a look; give a default`);
398
+ }
399
+ for (const [key, look] of Object.entries(table)) {
400
+ const at = `${path}.table.${key}`;
401
+ if (!look || typeof look !== 'object' || Array.isArray(look))
402
+ fail('ASSET_SCHEMA', at, 'object required');
403
+ fields(look, [], ['color', 'effect', 'values', 'clip', 'speed', 'paused'], at);
404
+ const bad = (what, message, extra = {}) => refuse(`${at}.${what === 'column' ? extra.id : what}`, message, { reason: 'data-table-entry', binding: b.id, key, what, ...extra });
405
+ const column = (name, has) => {
406
+ if (look[name] !== undefined && !has)
407
+ bad('column', `${name} goes to no target of ${b.id}`, { id: name });
408
+ };
409
+ column('color', parts.length > 0);
410
+ column('effect', parts.length > 0);
411
+ column('values', inputs.length > 0);
412
+ for (const [name] of GROUP_COLUMNS)
413
+ column(name, t.group !== undefined);
414
+ if (look.color !== undefined && !isFigureColour(look.color))
415
+ bad('color', 'a colour is #RRGGBB', { value: look.color });
416
+ if (look.effect !== undefined) {
417
+ const e = look.effect;
418
+ if (!e || typeof e !== 'object' || Array.isArray(e))
419
+ fail('ASSET_SCHEMA', `${at}.effect`, 'object required');
420
+ fields(e, ['kind'], ['hz'], `${at}.effect`);
421
+ if (e.kind !== 'blink' && e.kind !== 'glow')
422
+ bad('effect', 'an effect is blink or glow', { value: e.kind });
423
+ if (e.hz !== undefined && !(finite(e.hz) && e.hz > 0))
424
+ bad('effect', 'hz is a positive number', { value: e.hz });
425
+ }
426
+ if (look.values !== undefined) {
427
+ if (!look.values || typeof look.values !== 'object' || Array.isArray(look.values))
428
+ fail('ASSET_SCHEMA', `${at}.values`, 'an object of values');
429
+ for (const [id, v] of Object.entries(look.values)) {
430
+ if (!inputs.includes(id))
431
+ bad('values', `${id} is not a target of ${b.id}`, { id });
432
+ const input = targets.states.get(id);
433
+ if (!finite(v) || v < input.min || v > input.max)
434
+ bad('values', `${String(v)} is outside ${id}, ${input.min} to ${input.max}`, { id, value: v });
435
+ }
436
+ }
437
+ if (look.clip !== undefined && (typeof look.clip !== 'string' || !targets.groups.get(t.group).includes(look.clip)))
438
+ bad('clip', `${String(look.clip)} is not a clip of ${t.group}`, { value: look.clip });
439
+ if (look.speed !== undefined && !(finite(look.speed) && look.speed >= 0 && look.speed <= FIGURE_MOTION_SPEED_MAX))
440
+ bad('speed', `a motion speed is 0 to ${FIGURE_MOTION_SPEED_MAX}`, { value: look.speed });
441
+ if (look.paused !== undefined && typeof look.paused !== 'boolean')
442
+ bad('paused', 'a pause is true or false', { value: look.paused });
443
+ }
444
+ if (b.legend !== undefined) {
445
+ if (!b.legend || typeof b.legend !== 'object' || Array.isArray(b.legend))
446
+ fail('ASSET_SCHEMA', `${path}.legend`, 'object required');
447
+ fields(b.legend, ['role'], [], `${path}.legend`);
448
+ if (typeof b.legend.role !== 'string' || !LEGEND_ROLE.test(b.legend.role))
449
+ refuse(`${path}.legend.role`, `${String(b.legend.role)} is not a role: lower case letters, digits and -`, { reason: 'data-legend-invalid', binding: b.id, role: String(b.legend.role) });
450
+ }
451
+ }
377
452
  function checkBinding(b, schema, targets, path) {
378
- fields(b, ['id', 'accessor', 'behavior', 'rule'], ['param'], path);
453
+ if (!b || typeof b !== 'object' || Array.isArray(b))
454
+ fail('ASSET_SCHEMA', path, 'object required');
455
+ const table = b.rule === 'table';
456
+ if (table)
457
+ fields(b, ['id', 'accessor', 'rule', 'lookup', 'targets', 'table'], ['label', 'legend'], path);
458
+ else
459
+ fields(b, ['id', 'accessor', 'behavior', 'rule'], ['label', 'param'], path);
379
460
  if (typeof b.id !== 'string' || !FIGURE_PART_ID.test(b.id))
380
461
  refuse(`${path}.id`, `${String(b.id)} is not an id: a letter, then letters, digits, - and _`, { reason: 'data-id-invalid', list: 'bindings', id: String(b.id) });
462
+ if (b.label !== undefined && (typeof b.label !== 'string' || !b.label.trim()))
463
+ fail('ASSET_SCHEMA', `${path}.label`, 'a name shown to a person, or none');
464
+ /* The accessor goes down the schema to what the binding reads. */
465
+ const accessorRefused = (accessor, why) => refuse(`${path}.accessor`, `${accessor}: ${why}`, { reason: 'data-accessor-invalid', binding: b.id, accessor, why });
466
+ const found = () => {
467
+ const steps = parseFigureAccessor(b.accessor);
468
+ if (!steps)
469
+ accessorRefused(String(b.accessor), 'syntax');
470
+ const at = nodeAt(schema, steps);
471
+ if ('why' in at)
472
+ return accessorRefused(b.accessor, at.why);
473
+ return at;
474
+ };
475
+ if (table) {
476
+ const { node } = found();
477
+ if (node.kind === 'list')
478
+ accessorRefused(b.accessor, 'list-needs-index');
479
+ else if (!isValue(node))
480
+ accessorRefused(b.accessor, 'not-a-value');
481
+ checkTable(b, node, targets, path);
482
+ return b;
483
+ }
381
484
  const behavior = checkBehavior(b.behavior, `${path}.behavior`);
382
485
  if (b.param !== undefined && (!b.param || typeof b.param !== 'object' || Array.isArray(b.param)))
383
486
  fail('ASSET_SCHEMA', `${path}.param`, 'an object of results');
@@ -389,19 +492,10 @@ function checkBinding(b, schema, targets, path) {
389
492
  absent(behavior.token);
390
493
  if ((behavior.kind === 'motion-clip' || behavior.kind === 'motion-speed' || behavior.kind === 'motion-paused') && !targets.groups.has(behavior.group))
391
494
  absent(behavior.group);
392
- if (behavior.kind === 'status' && !targets.ports.has(behavior.port))
393
- absent(behavior.port);
394
- /* The accessor goes down the schema to what the behavior reads. */
395
- const accessorRefused = (accessor, why) => refuse(`${path}.accessor`, `${accessor}: ${why}`, { reason: 'data-accessor-invalid', binding: b.id, accessor, why });
396
- const steps = parseFigureAccessor(b.accessor);
397
- if (!steps)
398
- accessorRefused(String(b.accessor), 'syntax');
399
- const found = nodeAt(schema, steps);
400
- if ('why' in found)
401
- return accessorRefused(b.accessor, found.why);
495
+ const { node } = found();
402
496
  if (behavior.kind === 'items') {
403
- if (found.node.kind === 'list') {
404
- const item = found.node.item;
497
+ if (node.kind === 'list') {
498
+ const item = node.item;
405
499
  if (!behavior.id)
406
500
  refuse(`${path}.behavior.id`, 'items from a list say which field of an element is its id', { reason: 'data-accessor-invalid', binding: b.id, accessor: b.accessor, why: 'item-id-required' });
407
501
  for (const f of ['id', 'type', 'place', 'stack', 'status']) {
@@ -418,41 +512,20 @@ function checkBinding(b, schema, targets, path) {
418
512
  accessorRefused(`${b.accessor}[].${rel}`, 'not-a-value');
419
513
  }
420
514
  }
421
- else if (isValue(found.node)) {
515
+ else if (isValue(node)) {
422
516
  if (behavior.id || behavior.type || behavior.place || behavior.stack || behavior.status)
423
517
  refuse(`${path}.behavior`, 'one value is the id itself; element fields are for a list', { reason: 'data-accessor-invalid', binding: b.id, accessor: b.accessor, why: 'not-a-list' });
424
518
  }
425
519
  else
426
520
  accessorRefused(b.accessor, 'not-a-value');
427
521
  }
428
- else if (found.node.kind === 'list')
522
+ else if (node.kind === 'list')
429
523
  accessorRefused(b.accessor, 'list-needs-index');
430
- else if (!isValue(found.node))
524
+ else if (!isValue(node))
431
525
  accessorRefused(b.accessor, 'not-a-value');
432
- checkRule(b, found.node, targets, path);
526
+ checkRule(b, node, targets, path);
433
527
  return b;
434
528
  }
435
- function checkPort(p, targets, path) {
436
- fields(p, ['id', 'label', 'tokens', 'parts'], ['group'], path);
437
- if (typeof p.id !== 'string' || !FIGURE_PART_ID.test(p.id))
438
- refuse(`${path}.id`, `${String(p.id)} is not an id: a letter, then letters, digits, - and _`, { reason: 'data-id-invalid', list: 'ports', id: String(p.id) });
439
- const invalid = (what, message, id) => refuse(`${path}.${what}`, message, { reason: 'data-port-invalid', port: p.id, what, ...(id !== undefined ? { id } : {}) });
440
- if (typeof p.label !== 'string' || !p.label.trim())
441
- invalid('label', 'a name shown to a person');
442
- if (!Array.isArray(p.tokens) || !Array.isArray(p.parts))
443
- fail('ASSET_SCHEMA', path, 'tokens and parts are lists');
444
- if (!p.tokens.length && !p.parts.length)
445
- invalid('token', 'a status port shows on a colour token, a part, or both');
446
- for (const t of p.tokens)
447
- if (typeof t !== 'string' || !Object.hasOwn(targets.palette, t))
448
- invalid('token', `${String(t)} is not a colour of this figure`, String(t));
449
- for (const part of p.parts)
450
- if (typeof part !== 'string' || !targets.parts.has(part))
451
- invalid('part', `${String(part)} is not a part of this figure`, String(part));
452
- if (p.group !== undefined && (typeof p.group !== 'string' || !targets.groups.has(p.group)))
453
- invalid('group', `${String(p.group)} is not a motion group of this figure`, String(p.group));
454
- return p;
455
- }
456
529
  /**
457
530
  * Refuses a data interface the contract does not allow (`DATA_INTERFACE`, or `ASSET_SCHEMA` for its shape). Called by
458
531
  * `compileFigureAsset`, after the drivers and motion groups, whose clips a binding may name.
@@ -461,44 +534,38 @@ export function validateFigureDataInterface(asset) {
461
534
  const di = asset.dataInterface;
462
535
  if (di === undefined)
463
536
  return;
464
- fields(di, ['version', 'schema', 'bindings'], ['ports'], 'dataInterface');
537
+ fields(di, ['version', 'schema', 'bindings'], [], 'dataInterface');
465
538
  if (di.version !== 'figure-data-interface-2')
466
539
  fail('VERSION', 'dataInterface.version', String(di.version));
467
540
  if (!Array.isArray(di.schema))
468
541
  fail('ASSET_SCHEMA', 'dataInterface.schema', 'a list of fields');
469
542
  if (!Array.isArray(di.bindings))
470
543
  fail('ASSET_SCHEMA', 'dataInterface.bindings', 'a list of bindings');
471
- if (di.ports !== undefined && !Array.isArray(di.ports))
472
- fail('ASSET_SCHEMA', 'dataInterface.ports', 'a list of status ports');
473
544
  checkFields(di.schema, 'dataInterface.schema');
474
545
  const targets = targetsOf(asset);
475
- const portIds = new Set();
476
- (di.ports ?? []).forEach((p, i) => {
477
- const port = checkPort(p, targets, `dataInterface.ports[${i}]`);
478
- if (portIds.has(port.id))
479
- refuse(`dataInterface.ports[${i}].id`, `${port.id} is declared twice`, { reason: 'data-id-duplicate', list: 'ports', id: port.id });
480
- portIds.add(port.id);
481
- });
482
546
  const ids = new Set();
547
+ /* One writer each (ADR-0166 6): a state input, a motion group's clip, speed or pause -- by a value binding or a table. */
483
548
  const writers = new Map();
549
+ const write = (key, behavior, id, binding, path) => {
550
+ const other = writers.get(key);
551
+ if (other !== undefined)
552
+ refuse(path, `${other} and ${binding} both write ${key}; one writer each`, { reason: 'data-behavior-duplicate', bindings: [other, binding], behavior, id });
553
+ writers.set(key, binding);
554
+ };
484
555
  di.bindings.forEach((b, i) => {
485
556
  const path = `dataInterface.bindings[${i}]`;
486
557
  const binding = checkBinding(b, di.schema, targets, path);
487
558
  if (ids.has(binding.id))
488
559
  refuse(`${path}.id`, `${binding.id} is declared twice`, { reason: 'data-id-duplicate', list: 'bindings', id: binding.id });
489
560
  ids.add(binding.id);
490
- const key = writeKey(binding.behavior);
491
- if (key === null)
561
+ if (binding.rule === 'table') {
562
+ for (const w of tableWrites(binding))
563
+ write(w.key, w.behavior, w.id, binding.id, `${path}.targets`);
492
564
  return;
493
- const other = writers.get(key);
494
- if (other !== undefined)
495
- refuse(`${path}.behavior`, `${other} and ${binding.id} both write ${key}; one writer each`, {
496
- reason: 'data-behavior-duplicate',
497
- bindings: [other, binding.id],
498
- behavior: binding.behavior.kind,
499
- id: behaviorId(binding.behavior)
500
- });
501
- writers.set(key, binding.id);
565
+ }
566
+ const key = writeKey(binding.behavior);
567
+ if (key !== null)
568
+ write(key, binding.behavior.kind, behaviorId(binding.behavior), binding.id, `${path}.behavior`);
502
569
  });
503
570
  }
504
571
  /** The data interface of an asset, checked, with what the schema has at each binding; null when the figure declares none. */
@@ -510,7 +577,7 @@ export function compileFigureDataInterface(asset) {
510
577
  const bindings = di.bindings.map(binding => {
511
578
  const node = nodeAt(di.schema, parseFigureAccessor(binding.accessor)).node;
512
579
  let itemNodes;
513
- if (binding.behavior.kind === 'items' && node.kind === 'list') {
580
+ if (binding.rule !== 'table' && binding.behavior.kind === 'items' && node.kind === 'list') {
514
581
  const item = node.item;
515
582
  itemNodes = {};
516
583
  for (const role of ['id', 'type', 'place', 'stack', 'status']) {
@@ -538,7 +605,7 @@ export function compileFigureDataInterface(asset) {
538
605
  }
539
606
  };
540
607
  walk(di.schema, '');
541
- return Object.freeze({ schema: Object.freeze(di.schema), ports: Object.freeze(di.ports ?? []), bindings: Object.freeze(bindings), jsonFields: Object.freeze(jsonFields) });
608
+ return Object.freeze({ schema: Object.freeze(di.schema), bindings: Object.freeze(bindings), jsonFields: Object.freeze(jsonFields) });
542
609
  }
543
610
  /** The schema as lines, parents before their fields, with the bindings that read each place. */
544
611
  export function figureDataRows(di) {
@@ -549,7 +616,7 @@ export function figureDataRows(di) {
549
616
  const asRow = (accessor) => accessor.replace(/\[\d+\]/g, '[]');
550
617
  for (const b of di.bindings) {
551
618
  note(asRow(b.accessor), b.id);
552
- if (b.behavior.kind === 'items')
619
+ if (b.rule !== 'table' && b.behavior.kind === 'items')
553
620
  for (const rel of [b.behavior.id, b.behavior.type, b.behavior.place, b.behavior.stack, b.behavior.status])
554
621
  if (rel)
555
622
  note(`${asRow(b.accessor)}[].${asRow(rel)}`, b.id);
@@ -641,25 +708,36 @@ export function inferFigureDataSchema(sample) {
641
708
  return inferFields(sample);
642
709
  }
643
710
  /* ---------------------------------------------------------------- renames */
644
- /** The interface after a part was renamed: a state input the part owned (`<part>.…`) and a status port's part follow it. */
711
+ /**
712
+ * The interface after a part was renamed: a state input the part owned (`<part>.…`) and a table's part follow it -- in
713
+ * a table, its targets and the values of its looks.
714
+ */
645
715
  export function renameDataInterface(asset, from, to) {
646
716
  if (!asset.dataInterface)
647
717
  return;
648
718
  const moved = (id) => (id === from ? to : id.startsWith(`${from}.`) ? to + id.slice(from.length) : id);
649
719
  asset.dataInterface = {
650
720
  ...asset.dataInterface,
651
- ...(asset.dataInterface.ports ? { ports: asset.dataInterface.ports.map(p => ({ ...p, parts: p.parts.map(moved) })) } : {}),
652
- bindings: asset.dataInterface.bindings.map(b => (b.behavior.kind === 'state' ? { ...b, behavior: { ...b.behavior, input: moved(b.behavior.input) } } : b))
721
+ bindings: asset.dataInterface.bindings.map(b => {
722
+ if (b.rule !== 'table')
723
+ return b.behavior.kind === 'state' ? { ...b, behavior: { ...b.behavior, input: moved(b.behavior.input) } } : b;
724
+ const { parts, inputs, group } = b.targets;
725
+ return {
726
+ ...b,
727
+ targets: { ...(parts ? { parts: parts.map(moved) } : {}), ...(inputs ? { inputs: inputs.map(moved) } : {}), ...(group !== undefined ? { group } : {}) },
728
+ table: Object.fromEntries(Object.entries(b.table).map(([key, look]) => [key, look.values ? { ...look, values: Object.fromEntries(Object.entries(look.values).map(([id, v]) => [moved(id), v])) } : look]))
729
+ };
730
+ })
653
731
  };
654
732
  }
655
- /** The interface after a clip was renamed: every map that selects it in a motion group selects it by its new name. */
733
+ /** The interface after a clip was renamed: every table that selects it in a motion group selects it by its new name. */
656
734
  export function renameDataInterfaceClip(asset, group, from, to) {
657
735
  if (!asset.dataInterface)
658
736
  return;
659
737
  asset.dataInterface = {
660
738
  ...asset.dataInterface,
661
- bindings: asset.dataInterface.bindings.map(b => b.behavior.kind === 'motion-clip' && b.behavior.group === group && b.param && (b.rule === 'map' || b.rule === 'range')
662
- ? { ...b, param: Object.fromEntries(Object.entries(b.param).map(([k, v]) => [k, v === from ? to : v])) }
739
+ bindings: asset.dataInterface.bindings.map(b => b.rule === 'table' && b.targets.group === group
740
+ ? { ...b, table: Object.fromEntries(Object.entries(b.table).map(([key, look]) => [key, look.clip === from ? { ...look, clip: to } : look])) }
663
741
  : b)
664
742
  };
665
743
  }
@@ -711,7 +789,9 @@ export function renameDataField(di, accessor, to) {
711
789
  return at === names.length - 1 && names.every((name, i) => prefix[i] === name) ? { field: to } : step;
712
790
  }));
713
791
  };
714
- out.bindings = out.bindings.map(b => {
792
+ out.bindings = out.bindings.map((b) => {
793
+ if (b.rule === 'table')
794
+ return { ...b, accessor: through(b.accessor) };
715
795
  const next = { ...b, accessor: through(b.accessor) };
716
796
  if (b.behavior.kind !== 'items')
717
797
  return next;