@willwade/aac-processors 0.1.7 → 0.1.8

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 (32) hide show
  1. package/dist/analytics.d.ts +7 -0
  2. package/dist/analytics.js +23 -0
  3. package/dist/browser/index.browser.js +5 -0
  4. package/dist/browser/metrics.js +17 -0
  5. package/dist/browser/processors/gridset/helpers.js +390 -0
  6. package/dist/browser/processors/snap/helpers.js +252 -0
  7. package/dist/browser/utilities/analytics/history.js +116 -0
  8. package/dist/browser/utilities/analytics/metrics/comparison.js +477 -0
  9. package/dist/browser/utilities/analytics/metrics/core.js +775 -0
  10. package/dist/browser/utilities/analytics/metrics/effort.js +221 -0
  11. package/dist/browser/utilities/analytics/metrics/obl-types.js +6 -0
  12. package/dist/browser/utilities/analytics/metrics/obl.js +282 -0
  13. package/dist/browser/utilities/analytics/metrics/sentence.js +121 -0
  14. package/dist/browser/utilities/analytics/metrics/types.js +6 -0
  15. package/dist/browser/utilities/analytics/metrics/vocabulary.js +138 -0
  16. package/dist/browser/utilities/analytics/reference/browser.js +67 -0
  17. package/dist/browser/utilities/analytics/reference/index.js +129 -0
  18. package/dist/browser/utils/dotnetTicks.js +17 -0
  19. package/dist/index.browser.d.ts +1 -0
  20. package/dist/index.browser.js +18 -1
  21. package/dist/index.node.d.ts +2 -2
  22. package/dist/index.node.js +5 -5
  23. package/dist/metrics.d.ts +17 -0
  24. package/dist/metrics.js +44 -0
  25. package/dist/utilities/analytics/metrics/comparison.d.ts +2 -1
  26. package/dist/utilities/analytics/metrics/comparison.js +3 -3
  27. package/dist/utilities/analytics/metrics/vocabulary.d.ts +2 -2
  28. package/dist/utilities/analytics/reference/browser.d.ts +31 -0
  29. package/dist/utilities/analytics/reference/browser.js +73 -0
  30. package/dist/utilities/analytics/reference/index.d.ts +21 -0
  31. package/dist/utilities/analytics/reference/index.js +22 -46
  32. package/package.json +9 -1
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Core Metrics Analysis Engine
3
+ *
4
+ * Implements the main BFS traversal algorithm from the Ruby aac-metrics tool.
5
+ * Calculates effort scores for all buttons in an AAC board set.
6
+ *
7
+ * Based on: aac-metrics/lib/aac-metrics/metrics.rb
8
+ */
9
+ import { AACSemanticCategory, AACScanType, } from '../../../core/treeStructure';
10
+ import { CellScanningOrder, ScanningSelectionMethod } from '../../../types/aac';
11
+ import { baseBoardEffort, distanceEffort, visualScanEffort, EFFORT_CONSTANTS, localScanEffort, scanningEffort, } from './effort';
12
+ export class MetricsCalculator {
13
+ constructor() {
14
+ this.locale = 'en';
15
+ }
16
+ /**
17
+ * Main analysis function - calculates metrics for an AAC tree
18
+ *
19
+ * @param tree - The AAC tree to analyze
20
+ * @param options - Optional configuration for metrics calculation
21
+ * @returns Complete metrics result
22
+ */
23
+ analyze(tree, options = {}) {
24
+ // Get root board - prioritize tree.rootId, then fall back to boards with no parentId
25
+ let rootBoard;
26
+ if (tree.rootId) {
27
+ rootBoard = tree.pages[tree.rootId];
28
+ }
29
+ if (!rootBoard) {
30
+ rootBoard = Object.values(tree.pages).find((p) => !p.parentId);
31
+ }
32
+ if (!rootBoard) {
33
+ throw new Error('No root board found in tree');
34
+ }
35
+ this.locale = tree.metadata?.locale || rootBoard.locale || 'en';
36
+ // Step 1: Build semantic/clone reference maps
37
+ const { setRefs, setPcts } = this.buildReferenceMaps(tree);
38
+ // Step 2: BFS traversal from root board
39
+ const startBoards = [rootBoard];
40
+ // Find boards with temporary_home settings
41
+ Object.values(tree.pages).forEach((board) => {
42
+ board.buttons.forEach((btn) => {
43
+ if (btn.targetPageId && btn.semanticAction) {
44
+ // Check for temporary_home in platformData or fallback
45
+ const tempHome = btn.semanticAction.platformData?.grid3?.parameters?.temporary_home ||
46
+ btn.semanticAction.fallback?.temporary_home;
47
+ if (tempHome === 'prior') {
48
+ startBoards.push(board);
49
+ }
50
+ else if (tempHome === true && btn.targetPageId) {
51
+ const targetBoard = tree.getPage(btn.targetPageId);
52
+ if (targetBoard && !startBoards.includes(targetBoard)) {
53
+ startBoards.push(targetBoard);
54
+ }
55
+ }
56
+ }
57
+ });
58
+ });
59
+ const knownButtons = new Map();
60
+ const levels = {};
61
+ let totalButtons = 0;
62
+ // Identify spelling/keyboard page and its access effort
63
+ const { spellingPage, spellingBaseEffort, spellingAvgLetterEffort } = this.identifySpellingMetrics(tree, options, setPcts);
64
+ // Analyze from each starting board
65
+ startBoards.forEach((startBoard) => {
66
+ const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard, options);
67
+ result.buttons.forEach((btn) => {
68
+ const existing = knownButtons.get(btn.label);
69
+ if (!existing || btn.effort < existing.effort) {
70
+ knownButtons.set(btn.label, btn);
71
+ }
72
+ if (btn.count && existing && existing.count) {
73
+ btn.count += existing.count;
74
+ }
75
+ });
76
+ if (startBoard === rootBoard) {
77
+ Object.assign(levels, result.levels);
78
+ totalButtons = result.totalButtons;
79
+ }
80
+ });
81
+ // Update buttons using dynamic spelling effort if applicable
82
+ const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
83
+ // Calculate metrics for word forms (smart grammar predictions) if enabled
84
+ // Default to true if not specified
85
+ const useSmartGrammar = options.useSmartGrammar !== false;
86
+ if (useSmartGrammar) {
87
+ const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics(tree, buttons, options);
88
+ // Remove buttons that were replaced by lower-effort word forms
89
+ const filteredButtons = buttons.filter((btn) => !replacedLabels.has(btn.label.toLowerCase()));
90
+ // Add word forms and re-sort
91
+ filteredButtons.push(...wordFormMetrics);
92
+ filteredButtons.sort((a, b) => a.effort - b.effort);
93
+ // Replace the original buttons array
94
+ buttons.length = 0;
95
+ buttons.push(...filteredButtons);
96
+ }
97
+ // Calculate grid dimensions
98
+ const grid = this.calculateGridDimensions(tree);
99
+ // Identify prediction metrics
100
+ let predictionPageId;
101
+ let hasDynamicPrediction = false;
102
+ // A page is prediction-capable if it has an AutoContent Prediction button reachable from root
103
+ // We already have analyzed from rootBoard
104
+ if (rootBoard) {
105
+ const rootAnalysis = this.analyzeFrom(tree, rootBoard, setPcts, true, options);
106
+ // Scan reached pages for prediction slots
107
+ for (const [pageId, _] of rootAnalysis.visitedBoardEfforts) {
108
+ const page = tree.getPage(pageId);
109
+ const hasPredictionSlot = page?.buttons.some((b) => b.contentType === 'AutoContent' && b.contentSubType === 'Prediction');
110
+ if (hasPredictionSlot) {
111
+ hasDynamicPrediction = true;
112
+ predictionPageId = pageId;
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ return {
118
+ analysis_version: '0.2',
119
+ locale: this.locale,
120
+ total_boards: Object.keys(tree.pages).length,
121
+ total_buttons: totalButtons,
122
+ total_words: buttons.length,
123
+ reference_counts: setRefs,
124
+ grid,
125
+ buttons,
126
+ levels,
127
+ spelling_effort_base: spellingBaseEffort,
128
+ spelling_effort_per_letter: spellingAvgLetterEffort,
129
+ spelling_page_id: spellingPage?.id,
130
+ has_dynamic_prediction: hasDynamicPrediction,
131
+ prediction_page_id: predictionPageId,
132
+ };
133
+ }
134
+ /**
135
+ * Identify keyboard/spelling page and calculate base/avg effort
136
+ */
137
+ identifySpellingMetrics(tree, options, setPcts) {
138
+ let spellingPage = null;
139
+ if (options.spellingPageId) {
140
+ spellingPage = tree.getPage(options.spellingPageId) || null;
141
+ }
142
+ if (!spellingPage && tree.metadata?.defaultKeyboardPageId) {
143
+ spellingPage = tree.getPage(tree.metadata.defaultKeyboardPageId) || null;
144
+ }
145
+ if (!spellingPage) {
146
+ // Look for pages with keyboard-like names or content
147
+ spellingPage =
148
+ Object.values(tree.pages).find((p) => {
149
+ const name = p.name.toLowerCase();
150
+ return name.includes('keyboard') || name.includes('spelling') || name.includes('abc');
151
+ }) || null;
152
+ }
153
+ if (!spellingPage)
154
+ return { spellingPage: null, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
155
+ // Calculate effort to reach this page from root
156
+ const rootBoard = tree.rootId
157
+ ? tree.pages[tree.rootId]
158
+ : Object.values(tree.pages).find((p) => !p.parentId);
159
+ if (!rootBoard)
160
+ return { spellingPage, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
161
+ // Analyze specifically to find the lowest effort path to the spelling page
162
+ const result = this.analyzeFrom(tree, rootBoard, setPcts, true, options);
163
+ const spellingBaseEffort = result.visitedBoardEfforts.get(spellingPage.id) ?? 10;
164
+ // Calculate average effort of alphabetical buttons on that page
165
+ const letters = spellingPage.buttons.filter((b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label));
166
+ let avgEffort = 2.5;
167
+ if (letters.length > 0) {
168
+ // We need to calculate the effort of these buttons relative to the spelling page itself
169
+ // (as if the user is already on the keyboard)
170
+ const keyboardResult = this.analyzeFrom(tree, spellingPage, setPcts, false, options);
171
+ const keyboardLetters = keyboardResult.buttons.filter((b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label));
172
+ if (keyboardLetters.length > 0) {
173
+ avgEffort = keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / keyboardLetters.length;
174
+ }
175
+ }
176
+ return {
177
+ spellingPage,
178
+ spellingBaseEffort,
179
+ spellingAvgLetterEffort: avgEffort,
180
+ };
181
+ }
182
+ /**
183
+ * Build reference maps for semantic_id and clone_id frequencies
184
+ */
185
+ buildReferenceMaps(tree) {
186
+ const setRefs = {};
187
+ const cellRefs = {};
188
+ let rootRows = 0;
189
+ let rootCols = 0;
190
+ // First pass: calculate dimensions and count references
191
+ Object.values(tree.pages).forEach((board) => {
192
+ rootRows = rootRows || board.grid.length;
193
+ rootCols = rootCols || board.grid[0]?.length || 0;
194
+ // Count semantic_id and clone_id occurrences from board properties (upstream)
195
+ board.semantic_ids?.forEach((id) => {
196
+ setRefs[id] = (setRefs[id] || 0) + 1;
197
+ });
198
+ board.clone_ids?.forEach((id) => {
199
+ setRefs[id] = (setRefs[id] || 0) + 1;
200
+ });
201
+ // Count cell references
202
+ for (let r = 0; r < board.grid.length; r++) {
203
+ for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
204
+ const ref = `${r}.${c}`;
205
+ const hasButton = board.grid[r][c] !== null;
206
+ cellRefs[ref] = (cellRefs[ref] || 0) + (hasButton ? 1.0 : 0.25);
207
+ }
208
+ }
209
+ });
210
+ // Calculate percentages
211
+ const setPcts = {};
212
+ const totalBoards = Object.keys(tree.pages).length;
213
+ Object.entries(setRefs).forEach(([id, count]) => {
214
+ // Extract location from ID (Ruby uses id.split(/-/)[1])
215
+ const parts = id.split('-');
216
+ if (parts.length >= 2) {
217
+ const loc = parts[1];
218
+ const cellCount = cellRefs[loc] || totalBoards;
219
+ setPcts[id] = count / cellCount;
220
+ if (setPcts[id] > 1.0) {
221
+ // console.log(`⚠️ setPcts[${id}] = ${setPcts[id].toFixed(4)} (count=${count}, cellCount=${cellCount.toFixed(2)})`);
222
+ setPcts[id] = 1.0; // Cap at 1.0 like Ruby effectively does
223
+ }
224
+ }
225
+ else {
226
+ setPcts[id] = count / totalBoards;
227
+ }
228
+ });
229
+ return { setRefs, setPcts };
230
+ }
231
+ /**
232
+ * Count scan items for visual scanning effort
233
+ * When block scanning is enabled, count unique scan blocks instead of individual buttons
234
+ */
235
+ countScanBlocks(board, currentRowIndex, currentColIndex, priorScanBlocks) {
236
+ // Block scanning: count unique scan blocks before current position
237
+ // Reuse the priorScanBlocks set from the parent scope
238
+ for (let r = 0; r <= currentRowIndex; r++) {
239
+ const row = board.grid[r];
240
+ if (!row)
241
+ continue;
242
+ for (let c = 0; c < row.length; c++) {
243
+ if (r === currentRowIndex && c === currentColIndex)
244
+ return priorScanBlocks.size;
245
+ const btn = row[c];
246
+ if (btn && (btn.label || btn.id).length > 0) {
247
+ const block = btn.scanBlock ||
248
+ (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
249
+ if (block !== null)
250
+ priorScanBlocks.add(block);
251
+ }
252
+ }
253
+ }
254
+ return priorScanBlocks.size;
255
+ }
256
+ /**
257
+ * Analyze starting from a specific board
258
+ */
259
+ analyzeFrom(tree, brd, setPcts, _isRoot, options = {}) {
260
+ const visitedBoardIds = new Map();
261
+ const visitedBoardEfforts = new Map();
262
+ const toVisit = [
263
+ {
264
+ board: brd,
265
+ level: 0,
266
+ entryX: 1.0,
267
+ entryY: 1.0,
268
+ },
269
+ ];
270
+ const knownButtons = new Map();
271
+ const levels = {};
272
+ while (toVisit.length > 0) {
273
+ const item = toVisit.shift();
274
+ if (!item)
275
+ break;
276
+ const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item;
277
+ // Skip if already visited at a lower level with equal or better prior effort
278
+ // Skip if already visited at a strictly lower level
279
+ const existingLevel = visitedBoardIds.get(board.id);
280
+ if (existingLevel !== undefined && existingLevel < level) {
281
+ continue;
282
+ }
283
+ visitedBoardIds.set(board.id, level);
284
+ visitedBoardEfforts.set(board.id, priorEffort);
285
+ const rows = board.grid.length;
286
+ const cols = board.grid[0]?.length || 0;
287
+ // Calculate board-level effort
288
+ // Ruby uses grid size (rows * cols) for field size effort
289
+ const gridSize = rows * cols;
290
+ let boardEffort = baseBoardEffort(rows, cols, gridSize);
291
+ // Apply reuse discounts - iterate through grid cells like Ruby does
292
+ let reuseDiscount = 0.0;
293
+ board.grid.forEach((row) => {
294
+ row.forEach((btn) => {
295
+ if (!btn)
296
+ return;
297
+ if (btn.clone_id && setPcts[btn.clone_id]) {
298
+ reuseDiscount += EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id];
299
+ }
300
+ else if (btn.semantic_id && setPcts[btn.semantic_id]) {
301
+ reuseDiscount +=
302
+ EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id];
303
+ }
304
+ });
305
+ });
306
+ boardEffort = Math.max(0, boardEffort - reuseDiscount);
307
+ // Calculate board link percentages
308
+ const boardPcts = this.calculateBoardLinkPercentages(tree, board);
309
+ // Get scanning configuration from page (if available) or options
310
+ const scanningConfig = options.scanningConfig || board.scanningConfig;
311
+ const blockScanEnabled = scanningConfig?.blockScanEnabled || false;
312
+ // Process each button
313
+ const btnHeight = 1.0 / rows;
314
+ const btnWidth = 1.0 / cols;
315
+ // Track scan blocks for block scanning
316
+ const priorScanBlocks = new Set();
317
+ // Iterate over grid positions directly (not just buttons)
318
+ // This matches Ruby's nested loop: rows.times do |row_idx|; columns.times do |col_idx|
319
+ for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
320
+ for (let colIndex = 0; colIndex < cols; colIndex++) {
321
+ const btn = board.grid[rowIndex]?.[colIndex];
322
+ if (!btn)
323
+ continue; // Skip empty cells
324
+ const x = btnWidth / 2 + btnWidth * colIndex;
325
+ const y = btnHeight / 2 + btnHeight * rowIndex;
326
+ // Calculate prior grid positions (not just buttons)
327
+ // This matches Ruby's prior_buttons which increments for each grid position
328
+ const priorGridPositions = rowIndex * cols + colIndex;
329
+ // For block scanning, count unique scan blocks instead
330
+ const priorItems = blockScanEnabled
331
+ ? this.countScanBlocks(board, rowIndex, colIndex, priorScanBlocks)
332
+ : priorGridPositions;
333
+ // Calculate button-level effort
334
+ let buttonEffort = boardEffort;
335
+ // Debug for specific button (disabled for production)
336
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
337
+ if (debugSpecificButton) {
338
+ console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
339
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
340
+ console.log(` Current level: ${level}`);
341
+ console.log(` Prior positions: ${priorItems}`);
342
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
343
+ }
344
+ // Apply semantic_id discounts
345
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
346
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
347
+ const old = buttonEffort;
348
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
349
+ if (debugSpecificButton)
350
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
351
+ }
352
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
353
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
354
+ boardPcts[`upstream-${btn.semantic_id}`];
355
+ const old = buttonEffort;
356
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
357
+ if (debugSpecificButton)
358
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
359
+ }
360
+ // Apply clone_id discounts
361
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
362
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
363
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
364
+ }
365
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
366
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
367
+ boardPcts[`upstream-${btn.clone_id}`];
368
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
369
+ }
370
+ // Calculate button effort based on access method (Touch vs Scanning)
371
+ const isScanning = !!scanningConfig || !!board.scanType;
372
+ if (isScanning) {
373
+ const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
374
+ // Determine effective costs based on selection method
375
+ let currentStepCost = options.scanStepCost ?? EFFORT_CONSTANTS.SCAN_STEP_COST;
376
+ const currentSelectionCost = options.scanSelectionCost ?? EFFORT_CONSTANTS.SCAN_SELECTION_COST;
377
+ // Step Scan 2 Switch: Every step is a physical selection with Switch 1
378
+ if (scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan2Switch) {
379
+ // The cost of moving is now a selection cost
380
+ currentStepCost = currentSelectionCost;
381
+ }
382
+ else if (scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan1Switch) {
383
+ // Single switch step scan: every step is a physical selection
384
+ currentStepCost = currentSelectionCost;
385
+ }
386
+ let sEffort = scanningEffort(steps, selections, currentStepCost, currentSelectionCost);
387
+ // Factor in error correction if enabled
388
+ if (scanningConfig?.errorCorrectionEnabled) {
389
+ const errorRate = scanningConfig.errorRate ?? EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
390
+ // A "miss" results in needing to wait for a loop (or part of one)
391
+ // We model this as errorRate * (loopSteps * stepCost)
392
+ const retryPenalty = loopSteps * currentStepCost;
393
+ sEffort += errorRate * retryPenalty;
394
+ }
395
+ // Apply discounts to scanning effort (similar to touch)
396
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
397
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
398
+ sEffort = Math.min(sEffort, sEffort * discount);
399
+ }
400
+ else if (btn.clone_id && boardPcts[btn.clone_id]) {
401
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
402
+ sEffort = Math.min(sEffort, sEffort * discount);
403
+ }
404
+ buttonEffort += sEffort;
405
+ }
406
+ else {
407
+ // Add distance effort (Touch only)
408
+ let distance = distanceEffort(x, y, entryX, entryY);
409
+ // Apply distance discounts
410
+ if (btn.semantic_id) {
411
+ if (boardPcts[btn.semantic_id]) {
412
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
413
+ distance = Math.min(distance, distance * discount);
414
+ }
415
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
416
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
417
+ boardPcts[`upstream-${btn.semantic_id}`];
418
+ distance = Math.min(distance, distance * discount);
419
+ }
420
+ else if (level > 0 && setPcts[btn.semantic_id]) {
421
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
422
+ setPcts[btn.semantic_id];
423
+ distance = Math.min(distance, distance * discount);
424
+ }
425
+ }
426
+ if (btn.clone_id) {
427
+ if (boardPcts[btn.clone_id]) {
428
+ const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
429
+ distance = Math.min(distance, distance * discount);
430
+ }
431
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
432
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
433
+ boardPcts[`upstream-${btn.clone_id}`];
434
+ distance = Math.min(distance, distance * discount);
435
+ }
436
+ else if (level > 0 && setPcts[btn.clone_id]) {
437
+ const discount = EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
438
+ distance = Math.min(distance, distance * discount);
439
+ }
440
+ }
441
+ buttonEffort += distance;
442
+ // Add visual scan or local scan effort
443
+ if (distance > EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
444
+ (entryX === 1.0 && entryY === 1.0)) {
445
+ buttonEffort += visualScanEffort(priorItems);
446
+ }
447
+ else {
448
+ buttonEffort += localScanEffort(distance);
449
+ }
450
+ }
451
+ // Add cumulative prior effort
452
+ buttonEffort += priorEffort;
453
+ // Track scan blocks for block scanning, otherwise track individual buttons
454
+ if (blockScanEnabled) {
455
+ const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
456
+ if (scanBlockId !== undefined && scanBlockId !== null) {
457
+ priorScanBlocks.add(scanBlockId);
458
+ }
459
+ }
460
+ // Handle navigation buttons
461
+ if (btn.targetPageId) {
462
+ const nextBoard = tree.getPage(btn.targetPageId);
463
+ if (nextBoard) {
464
+ // Only add to toVisit if this board hasn't been visited yet at any level
465
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
466
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
467
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
468
+ const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
469
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
470
+ ? board.id
471
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
472
+ ? btn.targetPageId
473
+ : temporaryHomeId;
474
+ toVisit.push({
475
+ board: nextBoard,
476
+ level: level + 1,
477
+ priorEffort: buttonEffort + changeEffort,
478
+ temporaryHomeId: tempHomeId,
479
+ entryX: x,
480
+ entryY: y,
481
+ entryCloneId: btn.clone_id,
482
+ entrySemanticId: btn.semantic_id,
483
+ });
484
+ }
485
+ }
486
+ }
487
+ // Track word if it speaks or adds to sentence
488
+ const isSpeak = btn.semanticAction?.category === AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button
489
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
490
+ btn.semanticAction?.fallback?.add_to_sentence;
491
+ if (isSpeak || addToSentence) {
492
+ let finalEffort = buttonEffort;
493
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
494
+ const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
495
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
496
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
497
+ finalEffort -= discount;
498
+ }
499
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
500
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
501
+ finalEffort -= discount;
502
+ }
503
+ const existing = knownButtons.get(btn.label);
504
+ const knownBtn = {
505
+ id: btn.id,
506
+ label: btn.label,
507
+ level,
508
+ effort: finalEffort,
509
+ count: (existing?.count || 0) + 1,
510
+ semantic_id: btn.semantic_id,
511
+ clone_id: btn.clone_id,
512
+ temporary_home_id: temporaryHomeId || undefined,
513
+ };
514
+ if (!existing || finalEffort < existing.effort) {
515
+ knownButtons.set(btn.label, knownBtn);
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ // Convert to array and group by level
522
+ const buttons = Array.from(knownButtons.values());
523
+ buttons.forEach((btn) => {
524
+ if (!levels[btn.level]) {
525
+ levels[btn.level] = [];
526
+ }
527
+ levels[btn.level].push(btn);
528
+ });
529
+ // Calculate total_buttons as sum of all button counts (matching Ruby line 136)
530
+ // Ruby: total_buttons: buttons.map{|b| b[:count] || 1}.sum
531
+ const calculatedTotalButtons = buttons.reduce((sum, btn) => sum + (btn.count || 1), 0);
532
+ return {
533
+ buttons,
534
+ levels,
535
+ totalButtons: calculatedTotalButtons,
536
+ visitedBoardEfforts,
537
+ };
538
+ }
539
+ /**
540
+ * Calculate what percentage of links to this board match semantic_id/clone_id
541
+ */
542
+ calculateBoardLinkPercentages(tree, board) {
543
+ const boardPcts = {};
544
+ let totalLinks = 0;
545
+ Object.values(tree.pages).forEach((sourceBoard) => {
546
+ sourceBoard.buttons.forEach((btn) => {
547
+ if (btn.targetPageId === board.id) {
548
+ totalLinks++;
549
+ if (btn.semantic_id) {
550
+ boardPcts[btn.semantic_id] = (boardPcts[btn.semantic_id] || 0) + 1;
551
+ }
552
+ if (btn.clone_id) {
553
+ boardPcts[btn.clone_id] = (boardPcts[btn.clone_id] || 0) + 1;
554
+ }
555
+ // Also count IDs present on the source board that links to this one
556
+ sourceBoard.semantic_ids?.forEach((id) => {
557
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
558
+ });
559
+ sourceBoard.clone_ids?.forEach((id) => {
560
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
561
+ });
562
+ }
563
+ });
564
+ });
565
+ // Convert counts to percentages
566
+ if (totalLinks > 0) {
567
+ Object.keys(boardPcts).forEach((id) => {
568
+ boardPcts[id] = boardPcts[id] / totalLinks;
569
+ });
570
+ }
571
+ boardPcts['all'] = totalLinks;
572
+ return boardPcts;
573
+ }
574
+ /**
575
+ * Calculate metrics for word forms (smart grammar predictions)
576
+ *
577
+ * Word forms are dynamically generated and not part of the tree structure.
578
+ * Their effort is calculated as:
579
+ * - Parent button's cumulative effort (to reach the button)
580
+ * - + Effort to select the word form from its position in predictions grid
581
+ *
582
+ * If a word exists as both a regular button and a word form, the version
583
+ * with lower effort is kept.
584
+ *
585
+ * @param tree - The AAC tree
586
+ * @param buttons - Already calculated button metrics
587
+ * @param options - Metrics options
588
+ * @returns Object containing word form metrics and labels that were replaced
589
+ */
590
+ calculateWordFormMetrics(tree, buttons, _options = {}) {
591
+ const wordFormMetrics = [];
592
+ const replacedLabels = new Set();
593
+ // Track buttons by label to compare efforts
594
+ const existingLabels = new Map();
595
+ buttons.forEach((btn) => existingLabels.set(btn.label.toLowerCase(), btn));
596
+ // Iterate through all pages to find buttons with predictions
597
+ Object.values(tree.pages).forEach((page) => {
598
+ page.grid.forEach((row) => {
599
+ row.forEach((btn) => {
600
+ if (!btn || !btn.predictions || btn.predictions.length === 0)
601
+ return;
602
+ // Find the parent button's metrics
603
+ const parentMetrics = buttons.find((b) => b.id === btn.id);
604
+ if (!parentMetrics)
605
+ return;
606
+ // Calculate effort for each word form
607
+ btn.predictions.forEach((wordForm, index) => {
608
+ const wordFormLower = wordForm.toLowerCase();
609
+ // Calculate effort based on position in predictions array
610
+ // Assume predictions are displayed in a grid layout (e.g., 2 columns)
611
+ const predictionsGridCols = 2; // Typical predictions layout
612
+ const predictionRowIndex = Math.floor(index / predictionsGridCols);
613
+ const predictionColIndex = index % predictionsGridCols;
614
+ // Calculate visual scan effort to reach this word form position
615
+ // Using similar logic to button scanning effort
616
+ const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
617
+ const predictionSelectionEffort = visualScanEffort(predictionPriorItems);
618
+ // Word form effort = parent button's cumulative effort + selection effort
619
+ const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
620
+ // Check if this word already exists as a regular button
621
+ const existingBtn = existingLabels.get(wordFormLower);
622
+ // If word exists and has lower or equal effort, skip the word form
623
+ if (existingBtn && existingBtn.effort <= wordFormEffort) {
624
+ return;
625
+ }
626
+ // If word exists but word form has lower effort, mark for replacement
627
+ if (existingBtn && existingBtn.effort > wordFormEffort) {
628
+ replacedLabels.add(wordFormLower);
629
+ }
630
+ // Create word form metric
631
+ const wordFormBtn = {
632
+ id: `${btn.id}_wordform_${index}`,
633
+ label: wordForm,
634
+ level: parentMetrics.level,
635
+ effort: wordFormEffort,
636
+ count: 1,
637
+ semantic_id: parentMetrics.semantic_id,
638
+ clone_id: parentMetrics.clone_id,
639
+ temporary_home_id: parentMetrics.temporary_home_id,
640
+ is_word_form: true, // Mark this as a word form metric
641
+ parent_button_id: btn.id, // Track parent button
642
+ parent_button_label: parentMetrics.label, // Track parent label
643
+ };
644
+ wordFormMetrics.push(wordFormBtn);
645
+ existingLabels.set(wordFormLower, wordFormBtn);
646
+ });
647
+ });
648
+ });
649
+ });
650
+ console.log(`📝 Calculated ${wordFormMetrics.length} word form metrics` +
651
+ (replacedLabels.size > 0
652
+ ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(', ')})`
653
+ : ''));
654
+ return { wordFormMetrics, replacedLabels };
655
+ }
656
+ /**
657
+ * Calculate grid dimensions from the tree
658
+ */
659
+ calculateGridDimensions(tree) {
660
+ let totalRows = 0;
661
+ let totalCols = 0;
662
+ let count = 0;
663
+ Object.values(tree.pages).forEach((page) => {
664
+ totalRows += page.grid.length;
665
+ totalCols += page.grid[0]?.length || 0;
666
+ count++;
667
+ });
668
+ return {
669
+ rows: Math.round(totalRows / count),
670
+ columns: Math.round(totalCols / count),
671
+ };
672
+ }
673
+ /**
674
+ * Calculate scanning steps and selections for a button based on access method
675
+ */
676
+ calculateScanSteps(board, btn, rowIndex, colIndex, overrideConfig) {
677
+ const config = overrideConfig || board.scanningConfig;
678
+ // Determine scanning type from local scanType or scanningConfig
679
+ let type = board.scanType || AACScanType.LINEAR;
680
+ if (config?.cellScanningOrder) {
681
+ const order = config.cellScanningOrder;
682
+ // String matching for CellScanningOrder
683
+ if (order === CellScanningOrder.RowColumnScan)
684
+ type = AACScanType.ROW_COLUMN;
685
+ else if (order === CellScanningOrder.ColumnRowScan)
686
+ type = AACScanType.COLUMN_ROW;
687
+ else if (order === CellScanningOrder.SimpleScanColumnsFirst)
688
+ type = AACScanType.COLUMN_ROW;
689
+ else if (order === CellScanningOrder.SimpleScan)
690
+ type = AACScanType.LINEAR;
691
+ }
692
+ // Force block scan if enabled in config
693
+ const isBlockScan = config?.blockScanEnabled ||
694
+ type === AACScanType.BLOCK_ROW_COLUMN ||
695
+ type === AACScanType.BLOCK_COLUMN_ROW;
696
+ if (isBlockScan) {
697
+ const blockId = btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
698
+ // If no block assigned, treat as its own block at the end (fallback)
699
+ if (blockId === null) {
700
+ const loop = board.grid.length + (board.grid[0]?.length || 0);
701
+ return { steps: rowIndex + colIndex + 1, selections: 1, loopSteps: loop };
702
+ }
703
+ const blockConfig = board.scanBlocksConfig?.find((c) => c.id === blockId);
704
+ const blockOrder = blockConfig?.order ?? blockId;
705
+ // Count unique blocks
706
+ const blocks = new Set();
707
+ let btnInBlockIndex = 0;
708
+ let itemsInBlock = 0;
709
+ for (let r = 0; r < board.grid.length; r++) {
710
+ for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
711
+ const b = board.grid[r][c];
712
+ if (b) {
713
+ const id = b.scanBlock ?? b.scanBlocks?.[0];
714
+ if (id !== undefined && id !== null)
715
+ blocks.add(id);
716
+ if (id === blockId) {
717
+ itemsInBlock++;
718
+ if (b === btn) {
719
+ btnInBlockIndex = itemsInBlock - 1;
720
+ }
721
+ }
722
+ }
723
+ }
724
+ }
725
+ // 1 selection for block, 1 for item
726
+ return {
727
+ steps: blockOrder + btnInBlockIndex + 1,
728
+ selections: 2,
729
+ loopSteps: blocks.size + itemsInBlock,
730
+ };
731
+ }
732
+ switch (type) {
733
+ case AACScanType.LINEAR: {
734
+ let index = 0;
735
+ let found = false;
736
+ let totalVisible = 0;
737
+ for (let r = 0; r < board.grid.length; r++) {
738
+ for (let c = 0; c < board.grid[r].length; c++) {
739
+ const b = board.grid[r][c];
740
+ if (b && (b.label || '').length > 0) {
741
+ totalVisible++;
742
+ if (!found) {
743
+ if (b === btn) {
744
+ found = true;
745
+ }
746
+ else {
747
+ index++;
748
+ }
749
+ }
750
+ }
751
+ }
752
+ }
753
+ return { steps: index + 1, selections: 1, loopSteps: totalVisible };
754
+ }
755
+ case AACScanType.ROW_COLUMN:
756
+ return {
757
+ steps: rowIndex + 1 + (colIndex + 1),
758
+ selections: 2,
759
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
760
+ };
761
+ case AACScanType.COLUMN_ROW:
762
+ return {
763
+ steps: colIndex + 1 + (rowIndex + 1),
764
+ selections: 2,
765
+ loopSteps: (board.grid[0]?.length || 0) + board.grid.length,
766
+ };
767
+ default:
768
+ return {
769
+ steps: rowIndex + 1 + (colIndex + 1),
770
+ selections: 2,
771
+ loopSteps: board.grid.length + (board.grid[0]?.length || 0),
772
+ };
773
+ }
774
+ }
775
+ }