@willwade/aac-processors 0.0.15 → 0.0.17

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.
@@ -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,9 @@ 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();
194
266
  const toVisit = [
195
267
  {
196
268
  board: brd,
@@ -213,6 +285,7 @@ class MetricsCalculator {
213
285
  continue;
214
286
  }
215
287
  visitedBoardIds.set(board.id, level);
288
+ visitedBoardEfforts.set(board.id, priorEffort);
216
289
  const rows = board.grid.length;
217
290
  const cols = board.grid[0]?.length || 0;
218
291
  // Calculate board-level effort
@@ -237,194 +310,219 @@ class MetricsCalculator {
237
310
  boardEffort = Math.max(0, boardEffort - reuseDiscount);
238
311
  // Calculate board link percentages
239
312
  const boardPcts = this.calculateBoardLinkPercentages(tree, board);
240
- // Get scanning configuration from page (if available)
241
- const blockScanEnabled = board.scanningConfig?.blockScanEnabled || false;
313
+ // Get scanning configuration from page (if available) or options
314
+ const scanningConfig = options.scanningConfig || board.scanningConfig;
315
+ const blockScanEnabled = scanningConfig?.blockScanEnabled || false;
242
316
  // Process each button
243
317
  const priorScanBlocks = new Set();
244
318
  const btnHeight = 1.0 / rows;
245
319
  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;
320
+ // Iterate over all buttons on the page to handle overlapping cells (common in some gridsets like Super Core 50)
321
+ // Sort buttons by position to ensure consistent prior_buttons/scanning calculation
322
+ const sortedButtons = [...board.buttons]
323
+ .filter((b) => (b.label || '').length > 0)
324
+ .sort((a, b) => {
325
+ if ((a.y ?? 0) !== (b.y ?? 0))
326
+ return (a.y ?? 0) - (b.y ?? 0);
327
+ return (a.x ?? 0) - (b.x ?? 0);
328
+ });
329
+ sortedButtons.forEach((btn) => {
330
+ const rowIndex = btn.y ?? 0;
331
+ const colIndex = btn.x ?? 0;
332
+ const x = btnWidth / 2 + btnWidth * colIndex;
333
+ const y = btnHeight / 2 + btnHeight * rowIndex;
334
+ // Calculate prior items for visual scan effort
335
+ // If block scanning enabled, count unique scan blocks instead of individual buttons
336
+ const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
337
+ // Calculate button-level effort
338
+ let buttonEffort = boardEffort;
339
+ // Debug for specific button (disabled for production)
340
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
341
+ if (debugSpecificButton) {
342
+ console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
343
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
344
+ console.log(` Current level: ${level}`);
345
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
346
+ }
347
+ // Apply semantic_id discounts
348
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
349
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
350
+ const old = buttonEffort;
351
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
352
+ if (debugSpecificButton)
353
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
354
+ }
355
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
356
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
357
+ boardPcts[`upstream-${btn.semantic_id}`];
358
+ const old = buttonEffort;
359
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
360
+ if (debugSpecificButton)
361
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
362
+ }
363
+ // Apply clone_id discounts
364
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
365
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
366
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
367
+ }
368
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
369
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
370
+ boardPcts[`upstream-${btn.clone_id}`];
371
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
372
+ }
373
+ // Calculate button effort based on access method (Touch vs Scanning)
374
+ const isScanning = !!scanningConfig || !!board.scanType;
375
+ if (isScanning) {
376
+ const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
377
+ // Determine effective costs based on selection method
378
+ let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
379
+ const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
380
+ // Step Scan 2 Switch: Every step is a physical selection with Switch 1
381
+ if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
382
+ // The cost of moving is now a selection cost
383
+ currentStepCost = currentSelectionCost;
384
+ }
385
+ else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
386
+ // Single switch step scan: every step is a physical selection
387
+ currentStepCost = currentSelectionCost;
252
388
  }
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)}`);
389
+ let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
390
+ // Factor in error correction if enabled
391
+ if (scanningConfig?.errorCorrectionEnabled) {
392
+ const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
393
+ // A "miss" results in needing to wait for a loop (or part of one)
394
+ // We model this as errorRate * (loopSteps * stepCost)
395
+ const retryPenalty = loopSteps * currentStepCost;
396
+ sEffort += errorRate * retryPenalty;
267
397
  }
268
- // Apply semantic_id discounts
398
+ // Apply discounts to scanning effort (similar to touch)
269
399
  if (btn.semantic_id && boardPcts[btn.semantic_id]) {
270
400
  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)})`);
401
+ sEffort = Math.min(sEffort, sEffort * discount);
283
402
  }
284
- // Apply clone_id discounts
285
- if (btn.clone_id && boardPcts[btn.clone_id]) {
403
+ else if (btn.clone_id && boardPcts[btn.clone_id]) {
286
404
  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);
405
+ sEffort = Math.min(sEffort, sEffort * discount);
293
406
  }
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]) {
407
+ buttonEffort += sEffort;
408
+ }
409
+ else {
410
+ // Add distance effort (Touch only)
411
+ let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
412
+ // Apply distance discounts
413
+ if (btn.semantic_id) {
414
+ if (boardPcts[btn.semantic_id]) {
301
415
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
302
- sEffort = Math.min(sEffort, sEffort * discount);
416
+ distance = Math.min(distance, distance * discount);
303
417
  }
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);
418
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
419
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
420
+ boardPcts[`upstream-${btn.semantic_id}`];
421
+ distance = Math.min(distance, distance * discount);
307
422
  }
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
- }
423
+ else if (level > 0 && setPcts[btn.semantic_id]) {
424
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
425
+ setPcts[btn.semantic_id];
426
+ distance = Math.min(distance, distance * discount);
329
427
  }
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
- }
428
+ }
429
+ if (btn.clone_id) {
430
+ if (boardPcts[btn.clone_id]) {
431
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
432
+ distance = Math.min(distance, distance * discount);
344
433
  }
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);
434
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
435
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
436
+ boardPcts[`upstream-${btn.clone_id}`];
437
+ distance = Math.min(distance, distance * discount);
350
438
  }
351
- else {
352
- buttonEffort += (0, effort_1.localScanEffort)(distance);
439
+ else if (level > 0 && setPcts[btn.clone_id]) {
440
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
441
+ distance = Math.min(distance, distance * discount);
353
442
  }
354
443
  }
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
- }
444
+ buttonEffort += distance;
445
+ // Add visual scan or local scan effort
446
+ if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
447
+ (entryX === 1.0 && entryY === 1.0)) {
448
+ buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
363
449
  }
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
- }
450
+ else {
451
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
390
452
  }
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);
453
+ }
454
+ // Add cumulative prior effort
455
+ buttonEffort += priorEffort;
456
+ // Track scan blocks for block scanning, otherwise track individual buttons
457
+ if (blockScanEnabled) {
458
+ const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
459
+ if (scanBlockId !== undefined && scanBlockId !== null) {
460
+ priorScanBlocks.add(scanBlockId);
461
+ }
462
+ }
463
+ // Handle navigation buttons
464
+ if (btn.targetPageId) {
465
+ const nextBoard = tree.getPage(btn.targetPageId);
466
+ if (nextBoard) {
467
+ // Only add to toVisit if this board hasn't been visited yet at any level
468
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
469
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
470
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
471
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
472
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
473
+ ? board.id
474
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
475
+ ? btn.targetPageId
476
+ : temporaryHomeId;
477
+ toVisit.push({
478
+ board: nextBoard,
479
+ level: level + 1,
480
+ priorEffort: buttonEffort + changeEffort,
481
+ temporaryHomeId: tempHomeId,
482
+ entryX: x,
483
+ entryY: y,
484
+ entryCloneId: btn.clone_id,
485
+ entrySemanticId: btn.semantic_id,
486
+ });
425
487
  }
426
488
  }
427
- });
489
+ }
490
+ // Track word if it speaks or adds to sentence
491
+ const intent = String(btn.semanticAction?.intent || '');
492
+ const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
493
+ intent === 'SPEAK_TEXT' ||
494
+ intent === 'SPEAK_IMMEDIATE' ||
495
+ intent === 'INSERT_TEXT' ||
496
+ btn.semanticAction?.fallback?.type === 'SPEAK';
497
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
498
+ btn.semanticAction?.fallback?.add_to_sentence;
499
+ if (isSpeak || addToSentence) {
500
+ let finalEffort = buttonEffort;
501
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
502
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
503
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
504
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
505
+ finalEffort -= discount;
506
+ }
507
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
508
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
509
+ finalEffort -= discount;
510
+ }
511
+ const existing = knownButtons.get(btn.label);
512
+ const knownBtn = {
513
+ id: btn.id,
514
+ label: btn.label,
515
+ level,
516
+ effort: finalEffort,
517
+ count: (existing?.count || 0) + 1,
518
+ semantic_id: btn.semantic_id,
519
+ clone_id: btn.clone_id,
520
+ temporary_home_id: temporaryHomeId || undefined,
521
+ };
522
+ if (!existing || finalEffort < existing.effort) {
523
+ knownButtons.set(btn.label, knownBtn);
524
+ }
525
+ }
428
526
  });
429
527
  }
430
528
  // Convert to array and group by level
@@ -435,10 +533,14 @@ class MetricsCalculator {
435
533
  }
436
534
  levels[btn.level].push(btn);
437
535
  });
536
+ // Calculate total_buttons as sum of all button counts (matching Ruby line 136)
537
+ // Ruby: total_buttons: buttons.map{|b| b[:count] || 1}.sum
538
+ const calculatedTotalButtons = buttons.reduce((sum, btn) => sum + (btn.count || 1), 0);
438
539
  return {
439
540
  buttons,
440
541
  levels,
441
- totalButtons: buttons.length,
542
+ totalButtons: calculatedTotalButtons,
543
+ visitedBoardEfforts,
442
544
  };
443
545
  }
444
546
  /**
@@ -496,11 +598,12 @@ class MetricsCalculator {
496
598
  /**
497
599
  * Calculate scanning steps and selections for a button based on access method
498
600
  */
499
- calculateScanSteps(board, btn, rowIndex, colIndex) {
601
+ calculateScanSteps(board, btn, rowIndex, colIndex, overrideConfig) {
602
+ const config = overrideConfig || board.scanningConfig;
500
603
  // Determine scanning type from local scanType or scanningConfig
501
604
  let type = board.scanType || treeStructure_1.AACScanType.LINEAR;
502
- if (board.scanningConfig?.cellScanningOrder) {
503
- const order = board.scanningConfig.cellScanningOrder;
605
+ if (config?.cellScanningOrder) {
606
+ const order = config.cellScanningOrder;
504
607
  // String matching for CellScanningOrder
505
608
  if (order === aac_1.CellScanningOrder.RowColumnScan)
506
609
  type = treeStructure_1.AACScanType.ROW_COLUMN;
@@ -512,63 +615,86 @@ class MetricsCalculator {
512
615
  type = treeStructure_1.AACScanType.LINEAR;
513
616
  }
514
617
  // Force block scan if enabled in config
515
- const isBlockScan = board.scanningConfig?.blockScanEnabled ||
618
+ const isBlockScan = config?.blockScanEnabled ||
516
619
  type === treeStructure_1.AACScanType.BLOCK_ROW_COLUMN ||
517
620
  type === treeStructure_1.AACScanType.BLOCK_COLUMN_ROW;
518
621
  if (isBlockScan) {
519
622
  const blockId = btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
520
623
  // If no block assigned, treat as its own block at the end (fallback)
521
624
  if (blockId === null) {
522
- return { steps: rowIndex + colIndex + 1, selections: 1 };
625
+ const loop = board.grid.length + (board.grid[0]?.length || 0);
626
+ return { steps: rowIndex + colIndex + 1, selections: 1, loopSteps: loop };
523
627
  }
524
- const config = board.scanBlocksConfig?.find((c) => c.id === blockId);
525
- const blockOrder = config?.order ?? blockId;
526
- // Linear scan within the block
628
+ const blockConfig = board.scanBlocksConfig?.find((c) => c.id === blockId);
629
+ const blockOrder = blockConfig?.order ?? blockId;
630
+ // Count unique blocks
631
+ const blocks = new Set();
527
632
  let btnInBlockIndex = 0;
528
- let found = false;
633
+ let itemsInBlock = 0;
529
634
  for (let r = 0; r < board.grid.length; r++) {
530
635
  for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
531
636
  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;
637
+ if (b) {
638
+ const id = b.scanBlock ?? b.scanBlocks?.[0];
639
+ if (id !== undefined && id !== null)
640
+ blocks.add(id);
641
+ if (id === blockId) {
642
+ itemsInBlock++;
643
+ if (b === btn) {
644
+ btnInBlockIndex = itemsInBlock - 1;
645
+ }
536
646
  }
537
- btnInBlockIndex++;
538
647
  }
539
648
  }
540
- if (found)
541
- break;
542
649
  }
543
650
  // 1 selection for block, 1 for item
544
- return { steps: blockOrder + btnInBlockIndex + 1, selections: 2 };
651
+ return {
652
+ steps: blockOrder + btnInBlockIndex + 1,
653
+ selections: 2,
654
+ loopSteps: blocks.size + itemsInBlock,
655
+ };
545
656
  }
546
657
  switch (type) {
547
658
  case treeStructure_1.AACScanType.LINEAR: {
548
659
  let index = 0;
549
660
  let found = false;
661
+ let totalVisible = 0;
550
662
  for (let r = 0; r < board.grid.length; r++) {
551
663
  for (let c = 0; c < board.grid[r].length; c++) {
552
664
  const b = board.grid[r][c];
553
665
  if (b && (b.label || '').length > 0) {
554
- if (b === btn) {
555
- found = true;
556
- break;
666
+ totalVisible++;
667
+ if (!found) {
668
+ if (b === btn) {
669
+ found = true;
670
+ }
671
+ else {
672
+ index++;
673
+ }
557
674
  }
558
- index++;
559
675
  }
560
676
  }
561
- if (found)
562
- break;
563
677
  }
564
- return { steps: index + 1, selections: 1 };
678
+ return { steps: index + 1, selections: 1, loopSteps: totalVisible };
565
679
  }
566
680
  case treeStructure_1.AACScanType.ROW_COLUMN:
567
- return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
681
+ return {
682
+ steps: rowIndex + 1 + (colIndex + 1),
683
+ selections: 2,
684
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
685
+ };
568
686
  case treeStructure_1.AACScanType.COLUMN_ROW:
569
- return { steps: colIndex + 1 + (rowIndex + 1), selections: 2 };
687
+ return {
688
+ steps: colIndex + 1 + (rowIndex + 1),
689
+ selections: 2,
690
+ loopSteps: (board.grid[0]?.length || 0) + board.grid.length,
691
+ };
570
692
  default:
571
- return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
693
+ return {
694
+ steps: rowIndex + 1 + (colIndex + 1),
695
+ selections: 2,
696
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
697
+ };
572
698
  }
573
699
  }
574
700
  }