@willwade/aac-processors 0.0.14 → 0.0.16

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 (53) hide show
  1. package/README.md +58 -10
  2. package/dist/applePanels.d.ts +6 -0
  3. package/dist/applePanels.js +13 -0
  4. package/dist/astericsGrid.d.ts +6 -0
  5. package/dist/astericsGrid.js +13 -0
  6. package/dist/core/treeStructure.d.ts +1 -0
  7. package/dist/dot.d.ts +6 -0
  8. package/dist/dot.js +13 -0
  9. package/dist/excel.d.ts +6 -0
  10. package/dist/excel.js +13 -0
  11. package/dist/gridset.d.ts +17 -0
  12. package/dist/gridset.js +130 -0
  13. package/dist/index.d.ts +23 -2
  14. package/dist/index.js +36 -7
  15. package/dist/obf.d.ts +7 -0
  16. package/dist/obf.js +15 -0
  17. package/dist/obfset.d.ts +6 -0
  18. package/dist/obfset.js +13 -0
  19. package/dist/opml.d.ts +6 -0
  20. package/dist/opml.js +13 -0
  21. package/dist/processors/gridset/commands.js +15 -0
  22. package/dist/processors/gridset/pluginTypes.js +4 -4
  23. package/dist/processors/gridsetProcessor.d.ts +4 -0
  24. package/dist/processors/gridsetProcessor.js +315 -47
  25. package/dist/processors/index.d.ts +8 -18
  26. package/dist/processors/index.js +9 -175
  27. package/dist/processors/snapProcessor.js +105 -9
  28. package/dist/processors/touchchatProcessor.js +33 -13
  29. package/dist/snap.d.ts +7 -0
  30. package/dist/snap.js +24 -0
  31. package/dist/touchchat.d.ts +7 -0
  32. package/dist/touchchat.js +16 -0
  33. package/dist/translation.d.ts +13 -0
  34. package/dist/translation.js +21 -0
  35. package/dist/types/aac.d.ts +13 -3
  36. package/dist/types/aac.js +6 -2
  37. package/dist/utilities/analytics/metrics/comparison.d.ts +1 -0
  38. package/dist/utilities/analytics/metrics/comparison.js +52 -24
  39. package/dist/utilities/analytics/metrics/core.d.ts +7 -2
  40. package/dist/utilities/analytics/metrics/core.js +327 -197
  41. package/dist/utilities/analytics/metrics/effort.d.ts +8 -3
  42. package/dist/utilities/analytics/metrics/effort.js +10 -5
  43. package/dist/utilities/analytics/metrics/sentence.js +17 -4
  44. package/dist/utilities/analytics/metrics/types.d.ts +39 -0
  45. package/dist/utilities/analytics/metrics/vocabulary.js +1 -1
  46. package/dist/utilities/analytics/reference/index.js +12 -1
  47. package/dist/utilities/translation/translationProcessor.d.ts +2 -1
  48. package/dist/utilities/translation/translationProcessor.js +5 -2
  49. package/dist/validation.d.ts +13 -0
  50. package/dist/validation.js +28 -0
  51. package/package.json +58 -4
  52. package/dist/utilities/screenshotConverter.d.ts +0 -69
  53. package/dist/utilities/screenshotConverter.js +0 -453
@@ -20,9 +20,10 @@ class MetricsCalculator {
20
20
  * Main analysis function - calculates metrics for an AAC tree
21
21
  *
22
22
  * @param tree - The AAC tree to analyze
23
+ * @param options - Optional configuration for metrics calculation
23
24
  * @returns Complete metrics result
24
25
  */
25
- analyze(tree) {
26
+ analyze(tree, options = {}) {
26
27
  // Get root board - prioritize tree.rootId, then fall back to boards with no parentId
27
28
  let rootBoard;
28
29
  if (tree.rootId) {
@@ -61,9 +62,11 @@ class MetricsCalculator {
61
62
  const knownButtons = new Map();
62
63
  const levels = {};
63
64
  let totalButtons = 0;
65
+ // Identify spelling/keyboard page and its access effort
66
+ const { spellingPage, spellingBaseEffort, spellingAvgLetterEffort } = this.identifySpellingMetrics(tree, options, setPcts);
64
67
  // Analyze from each starting board
65
68
  startBoards.forEach((startBoard) => {
66
- const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard);
69
+ const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard, options);
67
70
  result.buttons.forEach((btn) => {
68
71
  const existing = knownButtons.get(btn.label);
69
72
  if (!existing || btn.effort < existing.effort) {
@@ -78,10 +81,28 @@ class MetricsCalculator {
78
81
  totalButtons = result.totalButtons;
79
82
  }
80
83
  });
81
- // Convert to array and sort
84
+ // Update buttons using dynamic spelling effort if applicable
82
85
  const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
83
86
  // Calculate grid dimensions
84
87
  const grid = this.calculateGridDimensions(tree);
88
+ // Identify prediction metrics
89
+ let predictionPageId;
90
+ let hasDynamicPrediction = false;
91
+ // A page is prediction-capable if it has an AutoContent Prediction button reachable from root
92
+ // We already have analyzed from rootBoard
93
+ if (rootBoard) {
94
+ const rootAnalysis = this.analyzeFrom(tree, rootBoard, setPcts, true, options);
95
+ // Scan reached pages for prediction slots
96
+ for (const [pageId, _] of rootAnalysis.visitedBoardEfforts) {
97
+ const page = tree.getPage(pageId);
98
+ const hasPredictionSlot = page?.buttons.some((b) => b.contentType === 'AutoContent' && b.contentSubType === 'Prediction');
99
+ if (hasPredictionSlot) {
100
+ hasDynamicPrediction = true;
101
+ predictionPageId = pageId;
102
+ break;
103
+ }
104
+ }
105
+ }
85
106
  return {
86
107
  analysis_version: '0.2',
87
108
  locale: this.locale,
@@ -92,6 +113,56 @@ class MetricsCalculator {
92
113
  grid,
93
114
  buttons,
94
115
  levels,
116
+ spelling_effort_base: spellingBaseEffort,
117
+ spelling_effort_per_letter: spellingAvgLetterEffort,
118
+ spelling_page_id: spellingPage?.id,
119
+ has_dynamic_prediction: hasDynamicPrediction,
120
+ prediction_page_id: predictionPageId,
121
+ };
122
+ }
123
+ /**
124
+ * Identify keyboard/spelling page and calculate base/avg effort
125
+ */
126
+ identifySpellingMetrics(tree, options, setPcts) {
127
+ let spellingPage = null;
128
+ if (options.spellingPageId) {
129
+ spellingPage = tree.getPage(options.spellingPageId) || null;
130
+ }
131
+ if (!spellingPage) {
132
+ // Look for pages with keyboard-like names or content
133
+ spellingPage =
134
+ Object.values(tree.pages).find((p) => {
135
+ const name = p.name.toLowerCase();
136
+ return name.includes('keyboard') || name.includes('spelling') || name.includes('abc');
137
+ }) || null;
138
+ }
139
+ if (!spellingPage)
140
+ return { spellingPage: null, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
141
+ // Calculate effort to reach this page from root
142
+ const rootBoard = tree.rootId
143
+ ? tree.pages[tree.rootId]
144
+ : Object.values(tree.pages).find((p) => !p.parentId);
145
+ if (!rootBoard)
146
+ return { spellingPage, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
147
+ // Analyze specifically to find the lowest effort path to the spelling page
148
+ const result = this.analyzeFrom(tree, rootBoard, setPcts, true, options);
149
+ const spellingBaseEffort = result.visitedBoardEfforts.get(spellingPage.id) ?? 10;
150
+ // Calculate average effort of alphabetical buttons on that page
151
+ const letters = spellingPage.buttons.filter((b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label));
152
+ let avgEffort = 2.5;
153
+ if (letters.length > 0) {
154
+ // We need to calculate the effort of these buttons relative to the spelling page itself
155
+ // (as if the user is already on the keyboard)
156
+ const keyboardResult = this.analyzeFrom(tree, spellingPage, setPcts, false, options);
157
+ const keyboardLetters = keyboardResult.buttons.filter((b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label));
158
+ if (keyboardLetters.length > 0) {
159
+ avgEffort = keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / keyboardLetters.length;
160
+ }
161
+ }
162
+ return {
163
+ spellingPage,
164
+ spellingBaseEffort,
165
+ spellingAvgLetterEffort: avgEffort,
95
166
  };
96
167
  }
97
168
  /**
@@ -189,8 +260,10 @@ class MetricsCalculator {
189
260
  /**
190
261
  * Analyze starting from a specific board
191
262
  */
192
- analyzeFrom(tree, brd, setPcts, _isRoot) {
263
+ analyzeFrom(tree, brd, setPcts, _isRoot, options = {}) {
193
264
  const visitedBoardIds = new Map();
265
+ const visitedBoardEfforts = new Map();
266
+ let totalButtons = 0;
194
267
  const toVisit = [
195
268
  {
196
269
  board: brd,
@@ -213,8 +286,15 @@ class MetricsCalculator {
213
286
  continue;
214
287
  }
215
288
  visitedBoardIds.set(board.id, level);
289
+ visitedBoardEfforts.set(board.id, priorEffort);
216
290
  const rows = board.grid.length;
217
291
  const cols = board.grid[0]?.length || 0;
292
+ // Count all non-empty buttons reached in this pageset
293
+ board.buttons.forEach((btn) => {
294
+ if ((btn.label || '').length > 0) {
295
+ totalButtons++;
296
+ }
297
+ });
218
298
  // Calculate board-level effort
219
299
  // Ruby uses grid size (rows * cols) for field size effort
220
300
  const gridSize = rows * cols;
@@ -237,194 +317,219 @@ class MetricsCalculator {
237
317
  boardEffort = Math.max(0, boardEffort - reuseDiscount);
238
318
  // Calculate board link percentages
239
319
  const boardPcts = this.calculateBoardLinkPercentages(tree, board);
240
- // Get scanning configuration from page (if available)
241
- const blockScanEnabled = board.scanningConfig?.blockScanEnabled || false;
320
+ // Get scanning configuration from page (if available) or options
321
+ const scanningConfig = options.scanningConfig || board.scanningConfig;
322
+ const blockScanEnabled = scanningConfig?.blockScanEnabled || false;
242
323
  // Process each button
243
324
  const priorScanBlocks = new Set();
244
325
  const btnHeight = 1.0 / rows;
245
326
  const btnWidth = 1.0 / cols;
246
- board.grid.forEach((row, rowIndex) => {
247
- row.forEach((btn, colIndex) => {
248
- // Skip null buttons and buttons with empty labels (matching Ruby behavior)
249
- if (!btn || (btn.label || '').length === 0) {
250
- // Don't count these toward prior_buttons (Ruby uses "next unless")
251
- return;
327
+ // Iterate over all buttons on the page to handle overlapping cells (common in some gridsets like Super Core 50)
328
+ // Sort buttons by position to ensure consistent prior_buttons/scanning calculation
329
+ const sortedButtons = [...board.buttons]
330
+ .filter((b) => (b.label || '').length > 0)
331
+ .sort((a, b) => {
332
+ if ((a.y ?? 0) !== (b.y ?? 0))
333
+ return (a.y ?? 0) - (b.y ?? 0);
334
+ return (a.x ?? 0) - (b.x ?? 0);
335
+ });
336
+ sortedButtons.forEach((btn) => {
337
+ const rowIndex = btn.y ?? 0;
338
+ const colIndex = btn.x ?? 0;
339
+ const x = btnWidth / 2 + btnWidth * colIndex;
340
+ const y = btnHeight / 2 + btnHeight * rowIndex;
341
+ // Calculate prior items for visual scan effort
342
+ // If block scanning enabled, count unique scan blocks instead of individual buttons
343
+ const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
344
+ // Calculate button-level effort
345
+ let buttonEffort = boardEffort;
346
+ // Debug for specific button (disabled for production)
347
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
348
+ if (debugSpecificButton) {
349
+ console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
350
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
351
+ console.log(` Current level: ${level}`);
352
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
353
+ }
354
+ // Apply semantic_id discounts
355
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
356
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
357
+ const old = buttonEffort;
358
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
359
+ if (debugSpecificButton)
360
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
361
+ }
362
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
363
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
364
+ boardPcts[`upstream-${btn.semantic_id}`];
365
+ const old = buttonEffort;
366
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
367
+ if (debugSpecificButton)
368
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
369
+ }
370
+ // Apply clone_id discounts
371
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
372
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
373
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
374
+ }
375
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
376
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
377
+ boardPcts[`upstream-${btn.clone_id}`];
378
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
379
+ }
380
+ // Calculate button effort based on access method (Touch vs Scanning)
381
+ const isScanning = !!scanningConfig || !!board.scanType;
382
+ if (isScanning) {
383
+ const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
384
+ // Determine effective costs based on selection method
385
+ let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
386
+ const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
387
+ // Step Scan 2 Switch: Every step is a physical selection with Switch 1
388
+ if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
389
+ // The cost of moving is now a selection cost
390
+ currentStepCost = currentSelectionCost;
252
391
  }
253
- const x = btnWidth / 2 + btnWidth * colIndex;
254
- const y = btnHeight / 2 + btnHeight * rowIndex;
255
- // Calculate prior items for visual scan effort
256
- // If block scanning enabled, count unique scan blocks instead of individual buttons
257
- const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
258
- // Calculate button-level effort
259
- let buttonEffort = boardEffort;
260
- // Debug for specific button (disabled for production)
261
- const debugSpecificButton = btn.label === '$938c2cc0dc';
262
- if (debugSpecificButton) {
263
- console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
264
- console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
265
- console.log(` Current level: ${level}`);
266
- console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
392
+ else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
393
+ // Single switch step scan: every step is a physical selection
394
+ currentStepCost = currentSelectionCost;
267
395
  }
268
- // Apply semantic_id discounts
396
+ let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
397
+ // Factor in error correction if enabled
398
+ if (scanningConfig?.errorCorrectionEnabled) {
399
+ const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
400
+ // A "miss" results in needing to wait for a loop (or part of one)
401
+ // We model this as errorRate * (loopSteps * stepCost)
402
+ const retryPenalty = loopSteps * currentStepCost;
403
+ sEffort += errorRate * retryPenalty;
404
+ }
405
+ // Apply discounts to scanning effort (similar to touch)
269
406
  if (btn.semantic_id && boardPcts[btn.semantic_id]) {
270
407
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
271
- const old = buttonEffort;
272
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
273
- if (debugSpecificButton)
274
- console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
275
- }
276
- else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
277
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
278
- boardPcts[`upstream-${btn.semantic_id}`];
279
- const old = buttonEffort;
280
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
281
- if (debugSpecificButton)
282
- console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
408
+ sEffort = Math.min(sEffort, sEffort * discount);
283
409
  }
284
- // Apply clone_id discounts
285
- if (btn.clone_id && boardPcts[btn.clone_id]) {
410
+ else if (btn.clone_id && boardPcts[btn.clone_id]) {
286
411
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
287
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
288
- }
289
- else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
290
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
291
- boardPcts[`upstream-${btn.clone_id}`];
292
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
412
+ sEffort = Math.min(sEffort, sEffort * discount);
293
413
  }
294
- // Calculate button effort based on access method (Touch vs Scanning)
295
- const isScanning = !!board.scanningConfig || !!board.scanType;
296
- if (isScanning) {
297
- const { steps, selections } = this.calculateScanSteps(board, btn, rowIndex, colIndex);
298
- let sEffort = (0, effort_1.scanningEffort)(steps, selections);
299
- // Apply discounts to scanning effort (similar to touch)
300
- if (btn.semantic_id && boardPcts[btn.semantic_id]) {
414
+ buttonEffort += sEffort;
415
+ }
416
+ else {
417
+ // Add distance effort (Touch only)
418
+ let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
419
+ // Apply distance discounts
420
+ if (btn.semantic_id) {
421
+ if (boardPcts[btn.semantic_id]) {
301
422
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
302
- sEffort = Math.min(sEffort, sEffort * discount);
423
+ distance = Math.min(distance, distance * discount);
303
424
  }
304
- else if (btn.clone_id && boardPcts[btn.clone_id]) {
305
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
306
- sEffort = Math.min(sEffort, sEffort * discount);
425
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
426
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
427
+ boardPcts[`upstream-${btn.semantic_id}`];
428
+ distance = Math.min(distance, distance * discount);
307
429
  }
308
- buttonEffort += sEffort;
309
- }
310
- else {
311
- // Add distance effort (Touch only)
312
- let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
313
- // Apply distance discounts
314
- if (btn.semantic_id) {
315
- if (boardPcts[btn.semantic_id]) {
316
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
317
- distance = Math.min(distance, distance * discount);
318
- }
319
- else if (boardPcts[`upstream-${btn.semantic_id}`]) {
320
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
321
- boardPcts[`upstream-${btn.semantic_id}`];
322
- distance = Math.min(distance, distance * discount);
323
- }
324
- else if (level > 0 && setPcts[btn.semantic_id]) {
325
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
326
- setPcts[btn.semantic_id];
327
- distance = Math.min(distance, distance * discount);
328
- }
430
+ else if (level > 0 && setPcts[btn.semantic_id]) {
431
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
432
+ setPcts[btn.semantic_id];
433
+ distance = Math.min(distance, distance * discount);
329
434
  }
330
- if (btn.clone_id) {
331
- if (boardPcts[btn.clone_id]) {
332
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
333
- distance = Math.min(distance, distance * discount);
334
- }
335
- else if (boardPcts[`upstream-${btn.clone_id}`]) {
336
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
337
- boardPcts[`upstream-${btn.clone_id}`];
338
- distance = Math.min(distance, distance * discount);
339
- }
340
- else if (level > 0 && setPcts[btn.clone_id]) {
341
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
342
- distance = Math.min(distance, distance * discount);
343
- }
435
+ }
436
+ if (btn.clone_id) {
437
+ if (boardPcts[btn.clone_id]) {
438
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
439
+ distance = Math.min(distance, distance * discount);
344
440
  }
345
- buttonEffort += distance;
346
- // Add visual scan or local scan effort
347
- if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
348
- (entryX === 1.0 && entryY === 1.0)) {
349
- buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
441
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
442
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
443
+ boardPcts[`upstream-${btn.clone_id}`];
444
+ distance = Math.min(distance, distance * discount);
350
445
  }
351
- else {
352
- buttonEffort += (0, effort_1.localScanEffort)(distance);
446
+ else if (level > 0 && setPcts[btn.clone_id]) {
447
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
448
+ distance = Math.min(distance, distance * discount);
353
449
  }
354
450
  }
355
- // Add cumulative prior effort
356
- buttonEffort += priorEffort;
357
- // Track scan blocks for block scanning, otherwise track individual buttons
358
- if (blockScanEnabled) {
359
- const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
360
- if (scanBlockId !== undefined && scanBlockId !== null) {
361
- priorScanBlocks.add(scanBlockId);
362
- }
451
+ buttonEffort += distance;
452
+ // Add visual scan or local scan effort
453
+ if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
454
+ (entryX === 1.0 && entryY === 1.0)) {
455
+ buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
363
456
  }
364
- // Handle navigation buttons
365
- if (btn.targetPageId) {
366
- const nextBoard = tree.getPage(btn.targetPageId);
367
- if (nextBoard) {
368
- // Only add to toVisit if this board hasn't been visited yet at any level
369
- // The visitedBoardIds map stores the *lowest* level a board was visited.
370
- // If it's already in the map, it means we've processed it or scheduled it at a lower level.
371
- if (visitedBoardIds.get(nextBoard.id) === undefined) {
372
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
373
- const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
374
- ? board.id
375
- : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
376
- ? btn.targetPageId
377
- : temporaryHomeId;
378
- toVisit.push({
379
- board: nextBoard,
380
- level: level + 1,
381
- priorEffort: buttonEffort + changeEffort,
382
- temporaryHomeId: tempHomeId,
383
- entryX: x,
384
- entryY: y,
385
- entryCloneId: btn.clone_id,
386
- entrySemanticId: btn.semantic_id,
387
- });
388
- }
389
- }
457
+ else {
458
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
390
459
  }
391
- // Track word if it speaks or adds to sentence
392
- const intent = String(btn.semanticAction?.intent || '');
393
- const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
394
- intent === 'SPEAK_TEXT' ||
395
- intent === 'SPEAK_IMMEDIATE' ||
396
- intent === 'INSERT_TEXT' ||
397
- btn.semanticAction?.fallback?.type === 'SPEAK';
398
- const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
399
- btn.semanticAction?.fallback?.add_to_sentence;
400
- if (isSpeak || addToSentence) {
401
- let finalEffort = buttonEffort;
402
- // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
403
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
404
- if (btn.clone_id && boardPcts[btn.clone_id]) {
405
- const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
406
- finalEffort -= discount;
407
- }
408
- else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
409
- const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
410
- finalEffort -= discount;
411
- }
412
- const existing = knownButtons.get(btn.label);
413
- const knownBtn = {
414
- id: btn.id,
415
- label: btn.label,
416
- level,
417
- effort: finalEffort,
418
- count: (existing?.count || 0) + 1,
419
- semantic_id: btn.semantic_id,
420
- clone_id: btn.clone_id,
421
- temporary_home_id: temporaryHomeId || undefined,
422
- };
423
- if (!existing || finalEffort < existing.effort) {
424
- knownButtons.set(btn.label, knownBtn);
460
+ }
461
+ // Add cumulative prior effort
462
+ buttonEffort += priorEffort;
463
+ // Track scan blocks for block scanning, otherwise track individual buttons
464
+ if (blockScanEnabled) {
465
+ const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
466
+ if (scanBlockId !== undefined && scanBlockId !== null) {
467
+ priorScanBlocks.add(scanBlockId);
468
+ }
469
+ }
470
+ // Handle navigation buttons
471
+ if (btn.targetPageId) {
472
+ const nextBoard = tree.getPage(btn.targetPageId);
473
+ if (nextBoard) {
474
+ // Only add to toVisit if this board hasn't been visited yet at any level
475
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
476
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
477
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
478
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
479
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
480
+ ? board.id
481
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
482
+ ? btn.targetPageId
483
+ : temporaryHomeId;
484
+ toVisit.push({
485
+ board: nextBoard,
486
+ level: level + 1,
487
+ priorEffort: buttonEffort + changeEffort,
488
+ temporaryHomeId: tempHomeId,
489
+ entryX: x,
490
+ entryY: y,
491
+ entryCloneId: btn.clone_id,
492
+ entrySemanticId: btn.semantic_id,
493
+ });
425
494
  }
426
495
  }
427
- });
496
+ }
497
+ // Track word if it speaks or adds to sentence
498
+ const intent = String(btn.semanticAction?.intent || '');
499
+ const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
500
+ intent === 'SPEAK_TEXT' ||
501
+ intent === 'SPEAK_IMMEDIATE' ||
502
+ intent === 'INSERT_TEXT' ||
503
+ btn.semanticAction?.fallback?.type === 'SPEAK';
504
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
505
+ btn.semanticAction?.fallback?.add_to_sentence;
506
+ if (isSpeak || addToSentence) {
507
+ let finalEffort = buttonEffort;
508
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
509
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
510
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
511
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
512
+ finalEffort -= discount;
513
+ }
514
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
515
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
516
+ finalEffort -= discount;
517
+ }
518
+ const existing = knownButtons.get(btn.label);
519
+ const knownBtn = {
520
+ id: btn.id,
521
+ label: btn.label,
522
+ level,
523
+ effort: finalEffort,
524
+ count: (existing?.count || 0) + 1,
525
+ semantic_id: btn.semantic_id,
526
+ clone_id: btn.clone_id,
527
+ temporary_home_id: temporaryHomeId || undefined,
528
+ };
529
+ if (!existing || finalEffort < existing.effort) {
530
+ knownButtons.set(btn.label, knownBtn);
531
+ }
532
+ }
428
533
  });
429
534
  }
430
535
  // Convert to array and group by level
@@ -438,7 +543,8 @@ class MetricsCalculator {
438
543
  return {
439
544
  buttons,
440
545
  levels,
441
- totalButtons: buttons.length,
546
+ totalButtons,
547
+ visitedBoardEfforts,
442
548
  };
443
549
  }
444
550
  /**
@@ -496,11 +602,12 @@ class MetricsCalculator {
496
602
  /**
497
603
  * Calculate scanning steps and selections for a button based on access method
498
604
  */
499
- calculateScanSteps(board, btn, rowIndex, colIndex) {
605
+ calculateScanSteps(board, btn, rowIndex, colIndex, overrideConfig) {
606
+ const config = overrideConfig || board.scanningConfig;
500
607
  // Determine scanning type from local scanType or scanningConfig
501
608
  let type = board.scanType || treeStructure_1.AACScanType.LINEAR;
502
- if (board.scanningConfig?.cellScanningOrder) {
503
- const order = board.scanningConfig.cellScanningOrder;
609
+ if (config?.cellScanningOrder) {
610
+ const order = config.cellScanningOrder;
504
611
  // String matching for CellScanningOrder
505
612
  if (order === aac_1.CellScanningOrder.RowColumnScan)
506
613
  type = treeStructure_1.AACScanType.ROW_COLUMN;
@@ -512,63 +619,86 @@ class MetricsCalculator {
512
619
  type = treeStructure_1.AACScanType.LINEAR;
513
620
  }
514
621
  // Force block scan if enabled in config
515
- const isBlockScan = board.scanningConfig?.blockScanEnabled ||
622
+ const isBlockScan = config?.blockScanEnabled ||
516
623
  type === treeStructure_1.AACScanType.BLOCK_ROW_COLUMN ||
517
624
  type === treeStructure_1.AACScanType.BLOCK_COLUMN_ROW;
518
625
  if (isBlockScan) {
519
626
  const blockId = btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
520
627
  // If no block assigned, treat as its own block at the end (fallback)
521
628
  if (blockId === null) {
522
- return { steps: rowIndex + colIndex + 1, selections: 1 };
629
+ const loop = board.grid.length + (board.grid[0]?.length || 0);
630
+ return { steps: rowIndex + colIndex + 1, selections: 1, loopSteps: loop };
523
631
  }
524
- const config = board.scanBlocksConfig?.find((c) => c.id === blockId);
525
- const blockOrder = config?.order ?? blockId;
526
- // Linear scan within the block
632
+ const blockConfig = board.scanBlocksConfig?.find((c) => c.id === blockId);
633
+ const blockOrder = blockConfig?.order ?? blockId;
634
+ // Count unique blocks
635
+ const blocks = new Set();
527
636
  let btnInBlockIndex = 0;
528
- let found = false;
637
+ let itemsInBlock = 0;
529
638
  for (let r = 0; r < board.grid.length; r++) {
530
639
  for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
531
640
  const b = board.grid[r][c];
532
- if (b && (b.scanBlock === blockId || b.scanBlocks?.includes(blockId))) {
533
- if (b === btn) {
534
- found = true;
535
- break;
641
+ if (b) {
642
+ const id = b.scanBlock ?? b.scanBlocks?.[0];
643
+ if (id !== undefined && id !== null)
644
+ blocks.add(id);
645
+ if (id === blockId) {
646
+ itemsInBlock++;
647
+ if (b === btn) {
648
+ btnInBlockIndex = itemsInBlock - 1;
649
+ }
536
650
  }
537
- btnInBlockIndex++;
538
651
  }
539
652
  }
540
- if (found)
541
- break;
542
653
  }
543
654
  // 1 selection for block, 1 for item
544
- return { steps: blockOrder + btnInBlockIndex + 1, selections: 2 };
655
+ return {
656
+ steps: blockOrder + btnInBlockIndex + 1,
657
+ selections: 2,
658
+ loopSteps: blocks.size + itemsInBlock,
659
+ };
545
660
  }
546
661
  switch (type) {
547
662
  case treeStructure_1.AACScanType.LINEAR: {
548
663
  let index = 0;
549
664
  let found = false;
665
+ let totalVisible = 0;
550
666
  for (let r = 0; r < board.grid.length; r++) {
551
667
  for (let c = 0; c < board.grid[r].length; c++) {
552
668
  const b = board.grid[r][c];
553
669
  if (b && (b.label || '').length > 0) {
554
- if (b === btn) {
555
- found = true;
556
- break;
670
+ totalVisible++;
671
+ if (!found) {
672
+ if (b === btn) {
673
+ found = true;
674
+ }
675
+ else {
676
+ index++;
677
+ }
557
678
  }
558
- index++;
559
679
  }
560
680
  }
561
- if (found)
562
- break;
563
681
  }
564
- return { steps: index + 1, selections: 1 };
682
+ return { steps: index + 1, selections: 1, loopSteps: totalVisible };
565
683
  }
566
684
  case treeStructure_1.AACScanType.ROW_COLUMN:
567
- return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
685
+ return {
686
+ steps: rowIndex + 1 + (colIndex + 1),
687
+ selections: 2,
688
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
689
+ };
568
690
  case treeStructure_1.AACScanType.COLUMN_ROW:
569
- return { steps: colIndex + 1 + (rowIndex + 1), selections: 2 };
691
+ return {
692
+ steps: colIndex + 1 + (rowIndex + 1),
693
+ selections: 2,
694
+ loopSteps: (board.grid[0]?.length || 0) + board.grid.length,
695
+ };
570
696
  default:
571
- return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
697
+ return {
698
+ steps: rowIndex + 1 + (colIndex + 1),
699
+ selections: 2,
700
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
701
+ };
572
702
  }
573
703
  }
574
704
  }