@things-factory/figure-ui 10.1.272 → 10.1.273

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 (56) hide show
  1. package/client/modeller/data-interface.ts +30 -18
  2. package/client/modeller/editor-styles.ts +17 -6
  3. package/client/modeller/editor.ts +2 -0
  4. package/client/modeller/fasten-form.ts +12 -2
  5. package/client/modeller/fasten-geometry.ts +5 -3
  6. package/client/modeller/figure-data-panel.ts +384 -200
  7. package/client/modeller/figure-fasten-drawing.ts +21 -13
  8. package/client/modeller/figure-links.ts +24 -9
  9. package/client/modeller/figure-parts.ts +7 -11
  10. package/client/modeller/figure-preview.ts +258 -76
  11. package/dist-client/modeller/clips.d.ts +1 -4
  12. package/dist-client/modeller/data-interface.d.ts +11 -8
  13. package/dist-client/modeller/data-interface.js +27 -17
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor-styles.js +17 -6
  16. package/dist-client/modeller/editor-styles.js.map +1 -1
  17. package/dist-client/modeller/editor.js +2 -0
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/fasten-form.d.ts +5 -0
  20. package/dist-client/modeller/fasten-form.js +8 -2
  21. package/dist-client/modeller/fasten-form.js.map +1 -1
  22. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  23. package/dist-client/modeller/fasten-geometry.js +5 -3
  24. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  25. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  26. package/dist-client/modeller/figure-data-panel.js +382 -190
  27. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  28. package/dist-client/modeller/figure-fasten-drawing.js +21 -13
  29. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  30. package/dist-client/modeller/figure-links.js +31 -16
  31. package/dist-client/modeller/figure-links.js.map +1 -1
  32. package/dist-client/modeller/figure-parts.js +7 -11
  33. package/dist-client/modeller/figure-parts.js.map +1 -1
  34. package/dist-client/modeller/figure-preview.d.ts +13 -0
  35. package/dist-client/modeller/figure-preview.js +257 -77
  36. package/dist-client/modeller/figure-preview.js.map +1 -1
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +8 -7
  39. package/test/browser/editing-page.js +1 -1
  40. package/test/browser/links.check.mjs +30 -0
  41. package/test/browser/modeller.check.mjs +2 -2
  42. package/test/browser/parts-page.js +4 -0
  43. package/test/browser/parts.check.mjs +16 -10
  44. package/test/browser/screen-page.js +12 -1
  45. package/test/browser/screen.check.mjs +142 -11
  46. package/test/browser/serve-response.mjs +5 -2
  47. package/test/browser/side-panel-sweep.check.mjs +171 -10
  48. package/test/data-motion.test.ts +39 -8
  49. package/test/fasten-form.test.ts +25 -0
  50. package/test/fasten-geometry.test.ts +13 -0
  51. package/translations/en.json +23 -11
  52. package/translations/es.json +22 -10
  53. package/translations/ja.json +22 -10
  54. package/translations/ko.json +24 -12
  55. package/translations/ms.json +22 -10
  56. package/translations/zh.json +22 -10
@@ -10,7 +10,7 @@
10
10
  import { test, describe, before, after } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
- import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
13
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
14
 
15
15
  import { buildPage } from './build.mjs'
16
16
  import { openPage } from './serve.mjs'
@@ -116,6 +116,17 @@ describe('the side panel', () => {
116
116
  assert.equal(hit, 'heading')
117
117
  })
118
118
 
119
+ test('the heading is one line, the part\'s name and kind side by side, on a ground the rows under it do not show through (user 2026-11-12)', async () => {
120
+ const head = await read(DEEP => {
121
+ const subject = eval(DEEP)('div[subject]')[0]
122
+ const [name, kind] = [subject.querySelector('b'), subject.querySelector('small')]
123
+ const middle = el => { const r = el.getBoundingClientRect(); return Math.round(r.top + r.height / 2) }
124
+ return { oneLine: Math.abs(middle(name) - middle(kind)) <= 3, ground: getComputedStyle(subject).backgroundColor }
125
+ }, DEEP)
126
+ assert.equal(head.oneLine, true)
127
+ assert.ok(!/rgba\(.*, 0\)$|transparent/.test(head.ground), `a solid ground: ${head.ground}`)
128
+ })
129
+
119
130
  test('U5 the motion tab: no animation yet is said in its own words, the controls\' words said once', async () => {
120
131
  await click(await tab('motion'), 'the motion tab')
121
132
  const hints = await read(DEEP => eval(DEEP)('p[hint]').map(p => p.textContent.trim()), DEEP)
@@ -125,6 +136,7 @@ describe('the side panel', () => {
125
136
 
126
137
  test('U6 the data tab: a field name and Enter adds the field', async () => {
127
138
  await click(await tab('data'), 'the data tab')
139
+ assert.equal(await read(DEEP => eval(DEEP)('details[add-field]')[0]?.open, DEEP), true, 'no field yet: the form stands open')
128
140
  await click(await read(() => look.at('input[aria-label="figure.label.data-name"]')), 'the field name')
129
141
  await page().keyboard.type('speed')
130
142
  await page().keyboard.press('Enter')
@@ -143,27 +155,49 @@ describe('the side panel', () => {
143
155
 
144
156
  describe('the preview', () => {
145
157
  /* A number and a text, of names far apart in length, each read by a table: the preview gives each a field. */
146
- const asset = addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
158
+ /* Its size declared, so the preview offers the size help over it (shown only where the size can change). */
159
+ const asset = applyFigureAuthoring(addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }), { kind: 'declare-instance-size', at: { x: 400, y: 400, z: 400 } })
147
160
  asset.dataInterface = {
148
161
  version: 'figure-data-interface-2',
149
162
  schema: [
150
163
  { name: 'A', kind: 'number' },
151
- { name: 'CARRIERSTATUS', kind: 'text' }
164
+ { name: 'CARRIERSTATUS', kind: 'text' },
165
+ { name: 'LOW', kind: 'boolean' },
166
+ { name: 'HEADING', kind: 'number', unit: 'deg' }
152
167
  ],
153
168
  bindings: [
154
169
  { id: 'a', accessor: 'A', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~10': { color: '#ff0000' }, default: { color: '#0000ff' } } },
170
+ { id: 'h', accessor: 'HEADING', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~180': { color: '#00ffff' }, default: { color: '#ffff00' } } },
171
+ { id: 'l', accessor: 'LOW', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { true: { color: '#ff00ff' }, default: { color: '#0000ff' } } },
155
172
  { id: 's', accessor: 'CARRIERSTATUS', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { X: { color: '#00ff00' } } }
156
173
  ]
157
174
  }
158
175
  before(async () => {
159
176
  await mount('box', serializeFigureAsset(asset))
160
177
  await click(await read(() => probe.modeButton('preview')), 'the preview')
161
- await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] input').length === 2, [DEEP], 'the two values in the preview')
178
+ await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] label input').length === 5, [DEEP], 'the four values in the preview')
162
179
  })
163
180
 
164
- test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths', async () => {
165
- const lefts = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] input')].map(i => Math.round(i.getBoundingClientRect().left)), DEEP)
166
- assert.equal(lefts[0], lefts[1], `the fields start at ${lefts.join(' and ')}`)
181
+ test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths -- and end in one, with the help', async () => {
182
+ const edges = await read(DEEP => {
183
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
184
+ return [...root.querySelectorAll('div[clips] label input:not([type=checkbox]):not([type=range]):not([typed])'), root.querySelector('div[clips] div[setup] > button')].map(i => {
185
+ const r = i.getBoundingClientRect()
186
+ return { left: Math.round(r.left), right: Math.round(r.right) }
187
+ })
188
+ }, DEEP)
189
+ assert.equal(edges[0].left, edges[1].left, `the fields start at ${JSON.stringify(edges)}`)
190
+ /* A number with no range and a text are as wide as each other, to the help's right edge (user 2026-11-12: every width differed). */
191
+ assert.equal(edges[0].right, edges[1].right, `the fields end at ${JSON.stringify(edges)}`)
192
+ assert.equal(edges[0].right, edges[2].right, `the help ends where they do: ${JSON.stringify(edges)}`)
193
+ })
194
+ test('the names stay on one line: a unit is not put under its name (user 2026-11-12)', async () => {
195
+ const tall = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label > span:first-child')].map(s => {
196
+ const range = document.createRange()
197
+ range.selectNodeContents(s)
198
+ return new Set([...range.getClientRects()].map(r => Math.round(r.bottom))).size
199
+ }), DEEP)
200
+ assert.ok(tall.every(n => n === 1), `lines per name: ${tall}`)
167
201
  })
168
202
 
169
203
  test('U7 the size help\'s choice and button are in the bar\'s look, not the browser\'s grey', async () => {
@@ -183,13 +217,140 @@ describe('the preview', () => {
183
217
  test('a value the sample gives is shown as the sample\'s, not offered as a field; the sample off, the field is back', async () => {
184
218
  const rows = () => read(DEEP => {
185
219
  const root = eval(DEEP)('figure-preview')[0].shadowRoot
186
- return { fields: root.querySelectorAll('div[clips] input, div[clips] select').length, sampled: [...root.querySelectorAll('div[clips] label[sampled] output')].map(o => o.textContent.trim()) }
220
+ return { fields: root.querySelectorAll('div[clips] label input, div[clips] label select').length, sampled: [...root.querySelectorAll('div[clips] label[sampled] output')].map(o => o.textContent.trim()) }
187
221
  }, DEEP)
188
222
  await read(DEEP => (eval(DEEP)('figure-preview')[0].sample = { A: 4 }), DEEP)
189
223
  await until(DEEP => eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label[sampled]'), [DEEP], 'the sampled row')
190
- assert.deepEqual(await rows(), { fields: 1, sampled: ['4'] }, 'A from the sample, CARRIERSTATUS still a field')
224
+ assert.deepEqual(await rows(), { fields: 4, sampled: ['4'] }, 'A from the sample, the others still fields')
191
225
  await read(DEEP => (eval(DEEP)('figure-preview')[0].sample = null), DEEP)
192
226
  await until(DEEP => !eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label[sampled]'), [DEEP], 'the field back')
193
- assert.deepEqual(await rows(), { fields: 2, sampled: [] })
227
+ assert.deepEqual(await rows(), { fields: 5, sampled: [] })
228
+ })
229
+ test('an angle with no range is a dial, a slider over one turn and a box, as the controls\' angles are (user 2026-11-12)', async () => {
230
+ const row = await read(DEEP => {
231
+ const label = [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label')].find(l => l.querySelector('span').textContent.trim().startsWith('HEADING'))
232
+ const slider = label.querySelector('input[type=range]')
233
+ return { dial: !!label.querySelector('svg[dial]'), min: slider?.min, max: slider?.max, box: !!label.querySelector('input[typed]') }
234
+ }, DEEP)
235
+ assert.deepEqual(row, { dial: true, min: '0', max: '360', box: true })
236
+ })
237
+ test('a click on a number\'s name gives it nothing; dragged, it gives the value dragged to (user 2026-11-12)', async () => {
238
+ const name = () => read(DEEP => {
239
+ const label = [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label')].find(l => l.querySelector('span').textContent.trim() === 'A')
240
+ const r = label.querySelector('span[scrub]').getBoundingClientRect()
241
+ return { x: r.left + 4, y: r.top + r.height / 2 }
242
+ }, DEEP)
243
+ const valuing = () => read(DEEP => eval(DEEP)('figure-preview')[0].valuing.A, DEEP)
244
+ const at = await name()
245
+ await page().mouse.click(at.x, at.y)
246
+ assert.equal(await valuing(), undefined, 'a click is not a value')
247
+ await page().mouse.move(at.x, at.y)
248
+ await page().mouse.down()
249
+ await page().mouse.move(at.x + 15, at.y)
250
+ await page().mouse.up()
251
+ assert.equal(await valuing(), 15, 'dragged 15 pixels from nothing given')
252
+ })
253
+ test('the data tab\'s fields are a table -- field, kind, what each moves -- one row a field (user 2026-11-12)', async () => {
254
+ await click(await tab('data'), 'the data tab')
255
+ const table = await until(DEEP => {
256
+ const fields = eval(DEEP)('div[fields]')[0]
257
+ if (!fields) return undefined
258
+ const cells = row => [...row.querySelectorAll(':scope > span')].map(c => c.textContent.trim().replace(/\s+/g, ' '))
259
+ return { head: cells(fields.querySelector(':scope > div[head]')), rows: [...fields.querySelectorAll(':scope > button[row]')].map(cells) }
260
+ }, [DEEP], 'the fields table')
261
+ assert.deepEqual(table.head, ['figure.label.data-column-field', 'figure.label.data-column-kind', 'figure.label.data-column-moves'])
262
+ assert.equal(table.rows.length, 4, 'one row a field')
263
+ assert.ok(table.rows.every(r => r.length === 3), JSON.stringify(table.rows))
264
+ /* What a field moves is named, not counted. */
265
+ assert.ok(table.rows.every(r => !r[2].includes('figure.label.data-bound')), JSON.stringify(table.rows))
266
+ const tops = await read(DEEP => [...eval(DEEP)('div[fields]')[0].querySelectorAll(':scope > button[row]')].map(r => [...r.children].map(c => Math.round(c.getBoundingClientRect().left))), DEEP)
267
+ assert.equal(new Set(tops.map(t => t.join())).size, 1, `the columns line up: ${JSON.stringify(tops)}`)
268
+ /* With fields there, the form to add one is folded under its summary; on a figure with none it stands open (U6). */
269
+ assert.equal(await read(DEEP => eval(DEEP)('details[add-field]')[0]?.open, DEEP), false, 'folded')
270
+ })
271
+ test('a field\'s row opened holds its bindings and a folded form to add one; no list of bindings apart (user 2026-11-12)', async () => {
272
+ await read(DEEP => [...eval(DEEP)('div[fields]')[0].querySelectorAll(':scope > button[row]')].find(r => r.textContent.trim().startsWith('A')).click(), DEEP)
273
+ const detail = await until(DEEP => {
274
+ const fields = eval(DEEP)('div[fields]')[0]
275
+ const d = fields.querySelector(':scope > div[detail]')
276
+ if (!d) return undefined
277
+ return {
278
+ lines: [...d.querySelectorAll('div[bindings] button[line] strong')].map(s => s.textContent.trim()),
279
+ folded: d.querySelector('details[add-binding]')?.open === false,
280
+ apart: [...fields.getRootNode().querySelectorAll('section > h3')].map(h => h.textContent.trim()).filter(t => t.startsWith('figure.label.data-bindings'))
281
+ }
282
+ }, [DEEP], 'the A row opened')
283
+ assert.deepEqual(detail, { lines: ['A'], folded: true, apart: [] })
284
+ })
285
+ test('a table\'s targets are a property list and each value a box of its settings, one under another (user 2026-11-12)', async () => {
286
+ /* The A row is open from the test before: open its binding. */
287
+ await read(DEEP => eval(DEEP)('div[fields]')[0].querySelector(':scope > div[detail] button[line]').click(), DEEP)
288
+ const editor = await until(DEEP => {
289
+ const d = eval(DEEP)('div[fields]')[0].querySelector(':scope > div[detail]')
290
+ const targets = d.querySelector('div[targets]')
291
+ if (!targets) return undefined
292
+ const cases = [...d.querySelectorAll('div[cases] > div[case]')].map(c => Math.round(c.getBoundingClientRect().left))
293
+ const names = [...d.querySelectorAll('div[props] > span:nth-child(odd)')].map(n => Math.round(n.getBoundingClientRect().height))
294
+ return {
295
+ targets: [...targets.querySelectorAll(':scope > span:not([none])')].map(t => t.textContent.trim()),
296
+ ticks: targets.querySelectorAll('input[type=checkbox]').length + d.querySelectorAll('div[chips]').length,
297
+ stacked: cases.length >= 2 && new Set(cases).size === 1,
298
+ oneLine: names.length > 0 && new Set(names).size === 1
299
+ }
300
+ }, [DEEP], 'the table editor')
301
+ assert.deepEqual(editor, { targets: ['figure.label.data-target-parts', 'box'], ticks: 0, stacked: true, oneLine: true })
302
+ })
303
+ test('a sample typed as JSON5 is read, in a box with room for it (user 2026-11-12)', async () => {
304
+ await click(await tab('data'), 'the data tab')
305
+ const box = await until(DEEP => eval(DEEP)('textarea[sample]')[0], [DEEP], 'the sample box')
306
+ assert.ok(box)
307
+ const seen = await read(async DEEP => {
308
+ const area = eval(DEEP)('textarea[sample]')[0]
309
+ area.value = "{ A: 7, CARRIERSTATUS: 'X', }"
310
+ area.dispatchEvent(new Event('input'))
311
+ const panel = area.getRootNode().host
312
+ await panel.updateComplete
313
+ return {
314
+ height: Math.round(area.getBoundingClientRect().height),
315
+ refused: [...panel.shadowRoot.querySelectorAll('p[hint]')].some(p => p.textContent.includes('figure.text.data-not-json'))
316
+ }
317
+ }, DEEP)
318
+ assert.equal(seen.refused, false, 'read, not refused as not JSON')
319
+ assert.ok(seen.height >= 240, `room for a record: ${seen.height}px`)
320
+ })
321
+ test('a yes or no is a check box in the fields\' column; checked, the figure is given true (user 2026-11-12)', async () => {
322
+ const box = await read(DEEP => {
323
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
324
+ const check = root.querySelector('div[clips] label input[type=checkbox]')
325
+ return { found: !!check, choices: root.querySelectorAll('div[clips] label select').length, left: Math.round(check.getBoundingClientRect().left), fields: Math.round(root.querySelector('div[clips] label input:not([type=checkbox])').getBoundingClientRect().left) }
326
+ }, DEEP)
327
+ assert.deepEqual(box, { found: true, choices: 0, left: box.fields, fields: box.fields }, 'no list of two words; the box where the fields start')
328
+ await read(DEEP => eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label input[type=checkbox]').click(), DEEP)
329
+ assert.equal(await read(DEEP => eval(DEEP)('figure-preview')[0].valuing.LOW, DEEP), true)
330
+ })
331
+ })
332
+
333
+ describe('the data table\'s kind column', () => {
334
+ before(async () => {
335
+ const asset = addFigureBox(createFigureAsset('Kinds'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
336
+ asset.dataInterface = {
337
+ version: 'figure-data-interface-2',
338
+ schema: [
339
+ { name: 'MODE', kind: 'text', values: ['idle', 'walk', 'run'] },
340
+ { name: 'ANGLE', kind: 'number', unit: 'deg', min: -45, max: 45 }
341
+ ],
342
+ bindings: []
343
+ }
344
+ await mount('box', serializeFigureAsset(asset))
345
+ })
346
+
347
+ test('says the kind and unit on one line; the values and range in its title (user 2026-11-12)', async () => {
348
+ await click(await tab('data'), 'the data tab')
349
+ const cells = await until(DEEP => {
350
+ const rows = [...(eval(DEEP)('div[fields]')[0]?.querySelectorAll(':scope > button[row]') ?? [])]
351
+ return rows.length === 2 ? rows.map(r => { const c = r.children[1]; return { text: c.textContent.trim(), title: c.title } }) : undefined
352
+ }, [DEEP], 'the two rows')
353
+ assert.ok(cells.every(c => !/[~(]/.test(c.text)), `no range or values in the cell: ${JSON.stringify(cells)}`)
354
+ assert.ok(cells[0].title.includes('idle, walk, run') && cells[1].title.includes('-45~45'), `said in the title: ${JSON.stringify(cells)}`)
194
355
  })
195
356
  })
@@ -22,6 +22,7 @@ import {
22
22
  fieldOfKind,
23
23
  fitsField,
24
24
  freeIdOf,
25
+ freeNumberCodeOf,
25
26
  legendRoleOf,
26
27
  methodsOf,
27
28
  motionWrittenByData,
@@ -29,6 +30,7 @@ import {
29
30
  schemaFollowingCodes,
30
31
  tableColumnsOf,
31
32
  tableDraftOf,
33
+ tableWithKeep,
32
34
  tableWithKeys,
33
35
  tableWithLook,
34
36
  tableWithTargets,
@@ -97,7 +99,7 @@ test('작동 목록과 방법: 값 그대로 · 배율이 움직일 수 있는
97
99
  assert.deepEqual(fits({ name: 'o', kind: 'object', fields: [] }), [])
98
100
  assert.deepEqual(fits({ name: 'l', kind: 'list', item: { kind: 'object', fields: [] } }), ['items'])
99
101
  const choices = behaviorsOf(DOG)
100
- assert.deepEqual(methodsOf({ kind: 'number' }, choices), ['value', 'scale', 'ranges'])
102
+ assert.deepEqual(methodsOf({ kind: 'number' }, choices), ['value', 'scale', 'code', 'ranges'], '숫자도 코드 표 (ADR-0166 개정 1)')
101
103
  assert.deepEqual(methodsOf({ kind: 'text' }, choices), ['value', 'code'])
102
104
  assert.deepEqual(methodsOf({ kind: 'boolean' }, choices), ['value', 'code'])
103
105
  assert.deepEqual(methodsOf({ kind: 'list', item: { kind: 'text' } }, choices), ['value'])
@@ -169,21 +171,20 @@ test('샘플: 보드의 Figure 와 같은 읽기로 -- 무엇을 움직이는지
169
171
  assert.ok(Number.isNaN(typedValueOf(row('cadence'), '') as number), '빈 칸은 0 이 아니다')
170
172
  })
171
173
 
172
- test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간 -- 새 필드는 표가 만든다 (ADR-0166 10)', () => {
174
+ test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간 (ADR-0166 10)', () => {
173
175
  const mode = tableDraftOf(DOG, { path: 'mode', node: fieldAt(DOG.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'] })
174
- assert.deepEqual([mode.binding.id, mode.binding.accessor, mode.binding.table, mode.schema], ['mode', 'mode', { idle: {}, walk: {}, run: {} }, undefined])
176
+ assert.deepEqual([mode.binding.id, mode.binding.accessor, mode.binding.table], ['mode', 'mode', { idle: {}, walk: {}, run: {} }])
175
177
  editFigurePart(DOG, { kind: 'set-data-binding', binding: mode.binding })
176
178
  const open = tableDraftOf(DOG, { path: 'status', node: fieldAt(DOG.dataInterface!.schema, 'status')! }, 'code', { parts: ['head'] })
177
179
  assert.deepEqual(open.binding.table, {})
178
180
  const pct = tableDraftOf(DOG, { path: 'cadence', node: fieldAt(DOG.dataInterface!.schema, 'cadence')! }, 'ranges', { parts: ['body'] })
179
181
  assert.deepEqual(pct.binding.table, { '0~': {} })
180
182
  editFigurePart(DOG, { kind: 'set-data-binding', binding: pct.binding })
181
- /* 새 이름이면 필드를 만든다: 코드 표는 값 목록이 표의 키인 글자, 구간 표는 숫자. 한 걸음에 둘 다. */
182
- const made = tableDraftOf(DOG, { name: 'EMPTYTYPE' }, 'code', { parts: ['body'] })
183
- assert.deepEqual(made.schema!.at(-1), { name: 'EMPTYTYPE', kind: 'text', values: ['CODE'] })
183
+ /* 표는 표의 행이 연 필드에 붙는다: 필드는 먼저 등록한다 (데이터 탭이 필드마다 연결을 둔다, 2026-11-12). */
184
+ const typed = withField(DOG, { name: 'EMPTYTYPE', kind: 'text', values: ['CODE'] })
185
+ const made = tableDraftOf(typed, { path: 'EMPTYTYPE', node: fieldAt(typed.dataInterface!.schema, 'EMPTYTYPE')! }, 'code', { parts: ['body'] })
184
186
  assert.deepEqual(made.binding.table, { CODE: {} })
185
- const asset = editFigurePart(DOG, { kind: 'set-data-binding', binding: made.binding, schema: made.schema })
186
- assert.deepEqual(tableDraftOf(DOG, { name: 'RATE' }, 'ranges', { parts: ['body'] }).schema!.at(-1), { name: 'RATE', kind: 'number' })
187
+ const asset = editFigurePart(typed, { kind: 'set-data-binding', binding: made.binding })
187
188
  /* 닫힌 필드의 값 목록은 표의 키를 따라간다: 코드를 더하면 필드에도. */
188
189
  const b = asset.dataInterface!.bindings.find(x => x.id === 'emptytype')! as never
189
190
  const grown = tableWithKeys(b, [['FULL', { color: '#3b6d11' }], ['EMPTY', {}]])
@@ -194,6 +195,25 @@ test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자
194
195
  assert.equal(legendRoleOf('Body_Status'), 'body-status')
195
196
  })
196
197
 
198
+ /*
199
+ * ADR-0166 개정 1 (2026-11-12, 튜토리얼): MCS 는 STATUS 를 숫자(4002)로 보낸다. 숫자 칸에도 코드 표를 만들고, 「그 밖의 값」은
200
+ * 표가 마지막으로 보인 모양을 그대로 둘 수 있다 -- 4004(이동 중)에 포크가 내려가지 않는다.
201
+ */
202
+ test('숫자 칸의 코드 표: 키는 숫자, 새 코드도 숫자 -- 그 밖의 값은 「그대로 둠」, 대상을 바꿔도 남는다 (ADR-0166 개정 1)', () => {
203
+ const draft = tableDraftOf(DOG, { path: 'cadence', node: fieldAt(DOG.dataInterface!.schema, 'cadence')! }, 'code', { parts: ['body'] })
204
+ assert.deepEqual(draft.binding.table, { 0: {} }, '숫자 칸의 첫 코드는 0')
205
+ assert.equal(draft.binding.lookup, 'code')
206
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: draft.binding })
207
+ assert.equal(freeNumberCodeOf([]), '0')
208
+ assert.equal(freeNumberCodeOf(['4002', '4003']), '4004')
209
+ assert.equal(freeNumberCodeOf(['-1', 'x']), '0')
210
+ const kept = tableWithKeep(tableWithKeys(draft.binding, [['4002', { color: '#3b6d11' }], ['4003', {}]]), true)
211
+ assert.deepEqual(kept.table, { 4002: { color: '#3b6d11' }, 4003: {}, default: { keep: true } })
212
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: kept })
213
+ assert.deepEqual(tableWithTargets(kept, { parts: ['head'] }).table.default, { keep: true }, '대상을 바꿔도 그대로 둠은 남는다')
214
+ assert.deepEqual(tableWithKeep(kept, false).table.default, {}, '끄면 빈 모양(기본값)')
215
+ })
216
+
197
217
  test('표의 대상과 열: 부품은 색·효과, 조작값은 값 하나씩, 동작 그룹은 클립·배속·멈춤 -- 대상을 빼면 그 열도 빠진다', () => {
198
218
  const free = editFigurePart(DOG, { kind: 'remove-data-binding', id: 'mode-locomotion' })
199
219
  const draftTable = tableDraftOf(free, { path: 'mode', node: fieldAt(free.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'], inputs: ['fl-knee'], group: 'locomotion' }).binding
@@ -295,6 +315,17 @@ test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () =>
295
315
  assert.match(editor, /<figure-data-panel/)
296
316
  assert.match(editor, /\.sample=\$\{this\.dataSample\}/)
297
317
  assert.match(editor, /this\.dataSample = e\.detail\.data/)
318
+ /*
319
+ * 견본 데이터는 미리보기에서만 보인다: 편집 화면에서 「미리보기에 적용」을 켜면 그렇게 말하고, 단추 하나로 미리보기로 간다
320
+ * (튜토리얼 2026-11-12: 켜도 편집 화면이 그대로라 적용이 안 된 것처럼 보였다). 편집 화면은 기본 자세 그대로다.
321
+ */
322
+ assert.match(editor, /\?previewing=\$\{this\.middle === 'preview'\}/)
323
+ assert.match(editor, /@show-preview=\$\{\(\) => \(this\.middle = 'preview'\)\}/)
324
+ const panel = read('../client/modeller/figure-data-panel.ts')
325
+ assert.match(panel, /this\.sampling && !this\.previewing/)
326
+ assert.match(panel, /'figure\.text\.data-sample-shown-in-preview'/)
327
+ assert.match(panel, /'figure\.button\.data-sample-show-preview'/)
328
+ assert.match(panel, /new CustomEvent\('show-preview'/)
298
329
  /* 「+ 회전」은 여러 명령을 되돌리기 한 단계로: 하나가 거절되면 아무것도 바뀌지 않는다. */
299
330
  assert.match(editor, /@part-edits=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.applyRecipe\(e\.detail\.edits\)\)\}/)
300
331
  const preview = read('../client/modeller/figure-preview.ts')
@@ -134,6 +134,31 @@ test('another way of meeting works the gap out again; a number typed stays; a pr
134
134
  assert.equal(fastenChanged(r, 'lid', pressed, { mode: 'flush' }).gap, 0, 'pressed: left at 0')
135
135
  })
136
136
 
137
+ /*
138
+ * A part already fastened, aimed again through 「바꾸기」, goes where it is aimed with its fastening as it was -- the
139
+ * gap and the place across the face carried to the new face or part (tutorial 2026-11-12: a pillar copied from the front
140
+ * one and turned to the body's back face stayed in front, its gap worked out as the whole body's depth). 「현재 상태로
141
+ * 붙이기」 is a loose part's: fastening it does not move it.
142
+ */
143
+ test('a fastened part aimed again goes there with its gap and its place across the face; a loose one stays where it stands', () => {
144
+ const r = conveyor()
145
+ const own = fastenFormOf(r, 'roller')
146
+ const under = fastenChanged(r, 'roller', own, { targetFace: 'bottom' })
147
+ assert.deepEqual([under.targetFace, under.gap, under.align.x, under.alignMm.x], ['bottom', 0, 'mm', 300], 'its own gap and place, not where it stands')
148
+ const moved = applyFigureAuthoring(r.asset, fastenPlanOf(r, 'roller', under).action as never)
149
+ const [x, y] = centreOf(moved, 'roller')
150
+ assert.ok(Math.abs(x! - 300) < 1e-6, `still 300 mm across: ${x}`)
151
+ assert.ok(y! < 0, `under the frame now: ${y}`)
152
+ /* Another part: on it, as fastened before. */
153
+ const onLid = aimFasten(r, 'roller', own, { part: 'lid' })
154
+ assert.deepEqual([onLid.target, onLid.gap, onLid.alignMm.x], ['lid', 0, 300])
155
+ /* A mode changed keeps its gap too; a number typed is the author's. */
156
+ assert.equal(fastenChanged(r, 'roller', own, { mode: 'flush' }).gap, 0)
157
+ /* The loose lid is fastened where it stands, as before. */
158
+ const loose = keptGapOf(floating(), 'lid', aimFasten(floating(), 'lid', fastenFormOf(floating(), 'lid'), { part: 'plate' }))
159
+ assert.equal(loose.gap, 40)
160
+ })
161
+
137
162
  test('the target list: every other part, the one hanging from this part listed as a loop', () => {
138
163
  const r = conveyor()
139
164
  assert.deepEqual(fastenTargetsOf(r, 'frame'), [
@@ -121,3 +121,16 @@ test('the face drawing shows a part bigger than the face whole when lined up wit
121
121
  assert.equal(drawingSpanOf(600, 300), 900)
122
122
  assert.equal(drawingSpanOf(undefined, 200), 600)
123
123
  })
124
+
125
+ /*
126
+ * A part far off the face's centre -- a palette box left 1500 mm up, aimed at a body's front -- is drawn whole with its
127
+ * size label: the drawing reaches it, not only the face and the part's own size (roll cradle tutorial 2026-11-12: the
128
+ * label was cut off the drawing's top and could not be pressed).
129
+ */
130
+ test('the face drawing reaches a part standing far off the face, its far edge inside', () => {
131
+ const span = drawingSpanOf(1200, 300, 1500)
132
+ assert.ok(span / 2 >= 1500 + 300 / 2, `span ${span}`)
133
+ assert.equal(drawingSpanOf(1200, 300, -1500), span, 'either side alike')
134
+ /* Near the centre it keeps the room it had. */
135
+ assert.equal(drawingSpanOf(600, 300, 100), 900)
136
+ })
@@ -1052,12 +1052,12 @@
1052
1052
  "figure.label.data-scale": "scale",
1053
1053
  "figure.label.data-offset": "offset",
1054
1054
  "figure.label.data-sample": "sample data",
1055
- "figure.text.data-sample-explained": "Enter one record as JSON to see what it moves. It is read the way a figure on a board reads its data, and not saved.",
1056
- "figure.text.data-sample-moves-all": "A position in the data moves every figure in the preview to it; on a board, figures given the same value stand in the same place too.",
1055
+ "figure.text.data-sample-explained": "Enter one record as JSON or JSON5 to see what it moves. It is read the way a figure on a board reads its data, and not saved.",
1057
1056
  "figure.label.data-sample-in-preview": "apply to the preview",
1057
+ "figure.text.data-sample-shown-in-preview": "The sample shows in the preview; the edit view keeps the figure as made.",
1058
+ "figure.button.data-sample-show-preview": "Show the preview",
1058
1059
  "figure.label.data-schema": "data shape",
1059
1060
  "figure.text.data-schema-explained": "The shape of the data a site sends this figure. Name each field as the site's data names it; group fields in objects and lists.",
1060
- "figure.label.data-bound": "{count} bound",
1061
1061
  "figure.text.data-no-fields": "Add a field to the data shape first.",
1062
1062
  "figure.label.data-name": "field name",
1063
1063
  "figure.label.data-label": "display name",
@@ -1068,17 +1068,18 @@
1068
1068
  "figure.button.remove-data-field": "remove the field",
1069
1069
  "figure.label.data-from-sample": "make from a sample",
1070
1070
  "figure.text.data-from-sample-explained": "Paste one record of the site's data to guess its fields. Units, ranges and value lists are not guessed; set them after.",
1071
- "figure.text.data-not-json": "This is not JSON.",
1071
+ "figure.text.data-not-json": "This is not JSON or JSON5.",
1072
1072
  "figure.text.data-guessed": "{count} fields found.",
1073
1073
  "figure.text.data-guessed-replaces": "{count} fields found. Using them replaces the current data shape.",
1074
1074
  "figure.button.data-use-guessed": "use this shape",
1075
- "figure.text.data-bindings-explained": "Connects data values to the figure. A number shows as it is, scaled, or by a range table; a text or true/false by a code table. A table sets, per value, parts' colour and effect, controls and a motion group at once. Select a row to edit it.",
1076
- "figure.label.data-field": "data value",
1075
+ "figure.text.data-bindings-explained": "Connects data values to the figure. A number applies unchanged, scaled, or by a range table; a text or true/false by a code table. A table sets, per value, parts' colour and effect, controls and a motion group at once. Select a row to edit it.",
1077
1076
  "figure.label.data-behavior": "behaviour",
1078
1077
  "figure.label.data-seconds": "travel time (s)",
1079
1078
  "figure.label.data-paused": "paused",
1080
1079
  "figure.label.data-playing": "playing",
1081
1080
  "figure.label.data-otherwise": "any other value",
1081
+ "figure.label.data-keep": "keep as is",
1082
+ "figure.text.data-keep": "A value with no row of its own leaves the look this table last showed.",
1082
1083
  "figure.label.data-range-from": "from",
1083
1084
  "figure.label.data-range-to": "below",
1084
1085
  "figure.button.add-data-range": "add an interval",
@@ -1101,7 +1102,6 @@
1101
1102
  "figure.button.data-add-rotation": "make the rotation",
1102
1103
  "figure.label.data-none": "none",
1103
1104
  "figure.button.remove-data-binding": "remove the binding",
1104
- "figure.label.data-items-count": "{count} items",
1105
1105
  "figure.text.data-value-not-a-list": "{path}: not a list.",
1106
1106
  "figure.text.data-value-value-unknown": "{path}: not one of its values.",
1107
1107
  "figure.text.data-value-json-unreadable": "{path}: the JSON text cannot be read.",
@@ -1239,10 +1239,7 @@
1239
1239
  "figure.label.data-legend-role": "Legend role",
1240
1240
  "figure.label.data-legend-swappable": "A board may swap it for a legend",
1241
1241
  "figure.label.data-method": "Method",
1242
- "figure.label.data-new-field": "New field",
1243
1242
  "figure.label.data-sample-legend": "Sample legend",
1244
- "figure.label.data-table-legend": "legend {name}",
1245
- "figure.label.data-table-own": "figure's table",
1246
1243
  "figure.label.data-target-group": "Motion group",
1247
1244
  "figure.label.data-target-inputs": "Controls",
1248
1245
  "figure.label.data-target-parts": "Parts",
@@ -1410,5 +1407,20 @@
1410
1407
  "figure.label.clip-interpolation-smooth": "Ease in and out",
1411
1408
  "figure.label.clip-interpolation-step": "Jump",
1412
1409
  "figure.text.copy-track-off-key": "A smooth track can be shifted only so that it starts at one of its keys. Possible values: {seconds} s",
1413
- "figure.text.mirror-copies-clips": "The motion in {clips} is copied to the copy too."
1410
+ "figure.text.mirror-copies-clips": "The motion in {clips} is copied to the copy too.",
1411
+ "figure.label.data-bindings-title": "Data bindings",
1412
+ "figure.label.data-column-field": "Field",
1413
+ "figure.label.data-column-kind": "Kind",
1414
+ "figure.label.data-column-moves": "Drives",
1415
+ "figure.text.data-method-does-value": "Applies the data value unchanged.",
1416
+ "figure.text.data-method-does-scale": "Applies the data value multiplied by a scale.",
1417
+ "figure.text.data-method-does-code": "Applies what the table sets for each data value.",
1418
+ "figure.text.data-method-does-ranges": "Applies what the table sets for the range the data value falls in.",
1419
+ "figure.label.data-bindings-without-field": "Bindings without a field",
1420
+ "figure.button.add": "Add",
1421
+ "figure.label.data-targets": "Targets",
1422
+ "figure.label.data-target-add": "Add target",
1423
+ "figure.label.data-target-choose": "Choose a part or control",
1424
+ "figure.text.data-no-targets": "No target chosen.",
1425
+ "figure.button.data-remove-target": "Remove target"
1414
1426
  }
@@ -1052,12 +1052,12 @@
1052
1052
  "figure.label.data-scale": "escala",
1053
1053
  "figure.label.data-offset": "desplazamiento",
1054
1054
  "figure.label.data-sample": "datos de ejemplo",
1055
- "figure.text.data-sample-explained": "Introduzca un registro en JSON para ver qué mueve. Se lee como una figura en un tablero lee sus datos, y no se guarda.",
1056
- "figure.text.data-sample-moves-all": "Una posición en los datos lleva todas las figuras de la vista previa a ella; en un tablero, las figuras con el mismo valor también quedan en el mismo lugar.",
1055
+ "figure.text.data-sample-explained": "Introduzca un registro en JSON o JSON5 para ver qué mueve. Se lee como una figura en un tablero lee sus datos, y no se guarda.",
1057
1056
  "figure.label.data-sample-in-preview": "aplicar a la vista previa",
1057
+ "figure.text.data-sample-shown-in-preview": "Los datos de muestra se ven en la vista previa; la edición muestra la figura tal como se hizo.",
1058
+ "figure.button.data-sample-show-preview": "Ver la vista previa",
1058
1059
  "figure.label.data-schema": "forma de los datos",
1059
1060
  "figure.text.data-schema-explained": "La forma de los datos que un sitio envía a esta figura. Nombre cada campo como lo nombran los datos del sitio; agrupe campos en objetos y listas.",
1060
- "figure.label.data-bound": "{count} vinculados",
1061
1061
  "figure.text.data-no-fields": "Primero añada un campo a la forma de los datos.",
1062
1062
  "figure.label.data-name": "nombre del campo",
1063
1063
  "figure.label.data-label": "nombre visible",
@@ -1068,17 +1068,18 @@
1068
1068
  "figure.button.remove-data-field": "quitar el campo",
1069
1069
  "figure.label.data-from-sample": "crear desde una muestra",
1070
1070
  "figure.text.data-from-sample-explained": "Pegue un registro de los datos del sitio para deducir sus campos. Unidades, rangos y listas de valores no se deducen; fíjelos después.",
1071
- "figure.text.data-not-json": "No es JSON.",
1071
+ "figure.text.data-not-json": "No es JSON ni JSON5.",
1072
1072
  "figure.text.data-guessed": "Se encontraron {count} campos.",
1073
1073
  "figure.text.data-guessed-replaces": "Se encontraron {count} campos. Usarlos reemplaza la forma de datos actual.",
1074
1074
  "figure.button.data-use-guessed": "usar esta forma",
1075
1075
  "figure.text.data-bindings-explained": "Conecta valores de datos con la figura. Un número se muestra tal cual, escalado o con una tabla de rangos; un texto o verdadero/falso, con una tabla de códigos. Una tabla fija por valor el color y efecto de las piezas, los controles y un grupo de movimiento. Seleccione una fila para editarla.",
1076
- "figure.label.data-field": "valor de datos",
1077
1076
  "figure.label.data-behavior": "comportamiento",
1078
1077
  "figure.label.data-seconds": "tiempo de desplazamiento (s)",
1079
1078
  "figure.label.data-paused": "en pausa",
1080
1079
  "figure.label.data-playing": "en marcha",
1081
1080
  "figure.label.data-otherwise": "cualquier otro valor",
1081
+ "figure.label.data-keep": "mantener",
1082
+ "figure.text.data-keep": "Un valor sin fila propia deja el aspecto que esta tabla mostró por última vez.",
1082
1083
  "figure.label.data-range-from": "desde",
1083
1084
  "figure.label.data-range-to": "hasta (sin incluir)",
1084
1085
  "figure.button.add-data-range": "añadir intervalo",
@@ -1101,7 +1102,6 @@
1101
1102
  "figure.button.data-add-rotation": "crear la rotación",
1102
1103
  "figure.label.data-none": "ninguno",
1103
1104
  "figure.button.remove-data-binding": "quitar el vínculo",
1104
- "figure.label.data-items-count": "{count} artículos",
1105
1105
  "figure.text.data-value-not-a-list": "{path}: no es una lista.",
1106
1106
  "figure.text.data-value-value-unknown": "{path}: no es uno de sus valores.",
1107
1107
  "figure.text.data-value-json-unreadable": "{path}: no se puede leer el texto JSON.",
@@ -1239,10 +1239,7 @@
1239
1239
  "figure.label.data-legend-role": "Rol de leyenda",
1240
1240
  "figure.label.data-legend-swappable": "Quien lo usa puede cambiarla por una leyenda",
1241
1241
  "figure.label.data-method": "Método",
1242
- "figure.label.data-new-field": "Campo nuevo",
1243
1242
  "figure.label.data-sample-legend": "Leyenda de ejemplo",
1244
- "figure.label.data-table-legend": "leyenda {name}",
1245
- "figure.label.data-table-own": "tabla de la figura",
1246
1243
  "figure.label.data-target-group": "Grupo de movimiento",
1247
1244
  "figure.label.data-target-inputs": "Controles",
1248
1245
  "figure.label.data-target-parts": "Piezas",
@@ -1410,5 +1407,20 @@
1410
1407
  "figure.label.clip-interpolation-smooth": "Arranque y parada suaves",
1411
1408
  "figure.label.clip-interpolation-step": "Salto",
1412
1409
  "figure.text.copy-track-off-key": "Una pista suave solo puede desplazarse para que empiece en una de sus claves. Valores posibles: {seconds} s",
1413
- "figure.text.mirror-copies-clips": "El movimiento de {clips} también se copia en la copia."
1410
+ "figure.text.mirror-copies-clips": "El movimiento de {clips} también se copia en la copia.",
1411
+ "figure.label.data-bindings-title": "Vínculos de datos",
1412
+ "figure.label.data-column-field": "Campo",
1413
+ "figure.label.data-column-kind": "Tipo",
1414
+ "figure.label.data-column-moves": "Mueve",
1415
+ "figure.text.data-method-does-value": "Aplica el valor del dato sin convertirlo.",
1416
+ "figure.text.data-method-does-scale": "Aplica el valor del dato multiplicado por una escala.",
1417
+ "figure.text.data-method-does-code": "Aplica lo que la tabla fija para cada valor del dato.",
1418
+ "figure.text.data-method-does-ranges": "Aplica lo que la tabla fija para el intervalo en que cae el valor.",
1419
+ "figure.label.data-bindings-without-field": "Vínculos sin campo",
1420
+ "figure.button.add": "Añadir",
1421
+ "figure.label.data-targets": "Destinos",
1422
+ "figure.label.data-target-add": "Añadir destino",
1423
+ "figure.label.data-target-choose": "Elija una pieza o un control",
1424
+ "figure.text.data-no-targets": "No hay destinos elegidos.",
1425
+ "figure.button.data-remove-target": "Quitar destino"
1414
1426
  }
@@ -1052,12 +1052,12 @@
1052
1052
  "figure.label.data-scale": "倍率",
1053
1053
  "figure.label.data-offset": "加算",
1054
1054
  "figure.label.data-sample": "サンプルデータ",
1055
- "figure.text.data-sample-explained": "データを1件JSONで入れて何が動くかを確認します。ボードに置いたFigureと同じ方法で読み、保存しません。",
1056
- "figure.text.data-sample-moves-all": "位置を決める値はプレビューのFigureをすべてその位置に移します。ボードでも同じ値を受けたFigureは同じ位置に立ちます。",
1055
+ "figure.text.data-sample-explained": "データを1件JSONまたはJSON5で入れて何が動くかを確認します。ボードに置いたFigureと同じ方法で読み、保存しません。",
1057
1056
  "figure.label.data-sample-in-preview": "プレビューに適用",
1057
+ "figure.text.data-sample-shown-in-preview": "サンプルデータはプレビューに表示されます。編集画面は作ったままです。",
1058
+ "figure.button.data-sample-show-preview": "プレビューで見る",
1058
1059
  "figure.label.data-schema": "データ形式",
1059
1060
  "figure.text.data-schema-explained": "現場がこのFigureに送るデータの形です。フィールド名は現場データの名前どおりに書き、オブジェクトとリストでまとめられます。",
1060
- "figure.label.data-bound": "動作 {count}件",
1061
1061
  "figure.text.data-no-fields": "先にデータ形式にフィールドを追加します。",
1062
1062
  "figure.label.data-name": "フィールド名",
1063
1063
  "figure.label.data-label": "表示名",
@@ -1068,17 +1068,18 @@
1068
1068
  "figure.button.remove-data-field": "フィールドを削除",
1069
1069
  "figure.label.data-from-sample": "サンプルから作る",
1070
1070
  "figure.text.data-from-sample-explained": "現場データを1件貼り付けるとフィールドを推定します。単位・範囲・値の一覧は推定しないので、置き換えた後に決めます。",
1071
- "figure.text.data-not-json": "JSON形式ではありません。",
1071
+ "figure.text.data-not-json": "JSONまたはJSON5形式ではありません。",
1072
1072
  "figure.text.data-guessed": "フィールドが {count} 件見つかりました。",
1073
1073
  "figure.text.data-guessed-replaces": "フィールドが {count} 件見つかりました。置き換えると今のデータ形式に代わります。",
1074
1074
  "figure.button.data-use-guessed": "この形式に置き換える",
1075
1075
  "figure.text.data-bindings-explained": "データの値をフィギュアにつなぎます。数値はそのまま・倍率・区間表で、文字と真偽はコード表で表示します。表は値ごとに部品の色と効果、操作値、動作グループをまとめて決めます。行を選ぶと編集できます。",
1076
- "figure.label.data-field": "データ値",
1077
1076
  "figure.label.data-behavior": "動作",
1078
1077
  "figure.label.data-seconds": "移動時間 (秒)",
1079
1078
  "figure.label.data-paused": "一時停止",
1080
1079
  "figure.label.data-playing": "再生",
1081
1080
  "figure.label.data-otherwise": "その他の値",
1081
+ "figure.label.data-keep": "そのまま",
1082
+ "figure.text.data-keep": "表にない値が来ると、この表が最後に示した見た目をそのまま保ちます。",
1082
1083
  "figure.label.data-range-from": "以上",
1083
1084
  "figure.label.data-range-to": "未満",
1084
1085
  "figure.button.add-data-range": "区間を追加",
@@ -1101,7 +1102,6 @@
1101
1102
  "figure.button.data-add-rotation": "回転を作る",
1102
1103
  "figure.label.data-none": "なし",
1103
1104
  "figure.button.remove-data-binding": "連結を削除",
1104
- "figure.label.data-items-count": "アイテム {count}件",
1105
1105
  "figure.text.data-value-not-a-list": "{path}: リストではありません。",
1106
1106
  "figure.text.data-value-value-unknown": "{path}: 値の一覧にない値です。",
1107
1107
  "figure.text.data-value-json-unreadable": "{path}: JSON文字列を読めません。",
@@ -1239,10 +1239,7 @@
1239
1239
  "figure.label.data-legend-role": "凡例の役割",
1240
1240
  "figure.label.data-legend-swappable": "使う側が凡例に置き換えられる",
1241
1241
  "figure.label.data-method": "方法",
1242
- "figure.label.data-new-field": "新しいフィールド",
1243
1242
  "figure.label.data-sample-legend": "サンプル凡例",
1244
- "figure.label.data-table-legend": "凡例 {name}",
1245
- "figure.label.data-table-own": "フィギュアの表",
1246
1243
  "figure.label.data-target-group": "動作グループ",
1247
1244
  "figure.label.data-target-inputs": "操作値",
1248
1245
  "figure.label.data-target-parts": "部品",
@@ -1410,5 +1407,20 @@
1410
1407
  "figure.label.clip-interpolation-smooth": "滑らかに発進・停止",
1411
1408
  "figure.label.clip-interpolation-step": "即時切り替え",
1412
1409
  "figure.text.copy-track-off-key": "滑らかに動くトラックは、キーのある時点に合わせてのみ時間をずらせます。可能な値: {seconds}秒",
1413
- "figure.text.mirror-copies-clips": "「{clips}」クリップの動きもコピーに一緒に複製されます。"
1410
+ "figure.text.mirror-copies-clips": "「{clips}」クリップの動きもコピーに一緒に複製されます。",
1411
+ "figure.label.data-bindings-title": "データ連結",
1412
+ "figure.label.data-column-field": "フィールド",
1413
+ "figure.label.data-column-kind": "種類",
1414
+ "figure.label.data-column-moves": "連結先",
1415
+ "figure.text.data-method-does-value": "データ値を変換せずに適用します。",
1416
+ "figure.text.data-method-does-scale": "データ値に倍率を掛けて適用します。",
1417
+ "figure.text.data-method-does-code": "データ値ごとに表で指定した設定を適用します。",
1418
+ "figure.text.data-method-does-ranges": "データ値が属する区間ごとに表で指定した設定を適用します。",
1419
+ "figure.label.data-bindings-without-field": "フィールドのない連結",
1420
+ "figure.button.add": "追加",
1421
+ "figure.label.data-targets": "対象",
1422
+ "figure.label.data-target-add": "対象を追加",
1423
+ "figure.label.data-target-choose": "部品または操作値を選択",
1424
+ "figure.text.data-no-targets": "選択した対象がありません。",
1425
+ "figure.button.data-remove-target": "対象を削除"
1414
1426
  }